From 83eb8994fd69a42307848ed56557c1dfec511a9d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 02:27:10 +0200 Subject: [PATCH 001/108] test: restore mnemonic test compilation --- .../java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt b/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt index 77da7be4ae..079a727c1e 100644 --- a/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt @@ -62,6 +62,7 @@ class MnemonicInputFieldTest { onBackspaceInEmpty = { backspaceInEmptyCount++ }, focusRequester = focusRequester, index = 0, + isFocused = true, ) } } From eaccc229fc304f07b5b3670079775b125344b3c0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 02:27:33 +0200 Subject: [PATCH 002/108] fix: prevent false on-chain send success --- .../java/to/bitkit/services/TxBumpingTests.kt | 5 +- .../to/bitkit/services/UtxoSelectionTests.kt | 5 +- .../wallets/send/SendPendingScreenTest.kt | 70 ++++++ .../java/to/bitkit/data/keychain/Keychain.kt | 27 ++- .../to/bitkit/repositories/LightningRepo.kt | 145 +++++++++++-- .../repositories/OnchainSendAttemptStore.kt | 202 ++++++++++++++++++ .../repositories/PaykitPaymentProofRepo.kt | 179 ++++++++++------ .../to/bitkit/services/LightningService.kt | 30 ++- .../screens/wallets/send/SendPendingScreen.kt | 18 +- .../java/to/bitkit/ui/sheets/SendSheet.kt | 9 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 182 +++++++++++----- .../to/bitkit/viewmodels/TransferViewModel.kt | 93 +++++--- app/src/main/res/values/strings.xml | 1 + .../bitkit/repositories/LightningRepoTest.kt | 131 +++++++++++- .../OnchainSendAttemptStoreTest.kt | 137 ++++++++++++ .../PaykitOnchainPaymentProofLookupTest.kt | 55 ----- .../PaykitPaymentProofRepoTest.kt | 109 +++++++--- .../bitkit/services/LightningServiceTest.kt | 50 +++++ .../viewmodels/AppViewModelSendFlowTest.kt | 117 +++++++++- .../viewmodels/TransferViewModelTest.kt | 117 +++++++++- changelog.d/next/1211.fixed.md | 1 + journeys/send/onchain-accepted-result.xml | 24 +++ 22 files changed, 1411 insertions(+), 296 deletions(-) create mode 100644 app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingScreenTest.kt create mode 100644 app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt create mode 100644 app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt delete mode 100644 app/src/test/java/to/bitkit/repositories/PaykitOnchainPaymentProofLookupTest.kt create mode 100644 changelog.d/next/1211.fixed.md create mode 100644 journeys/send/onchain-accepted-result.xml diff --git a/app/src/androidTest/java/to/bitkit/services/TxBumpingTests.kt b/app/src/androidTest/java/to/bitkit/services/TxBumpingTests.kt index ddec667793..f076821a29 100644 --- a/app/src/androidTest/java/to/bitkit/services/TxBumpingTests.kt +++ b/app/src/androidTest/java/to/bitkit/services/TxBumpingTests.kt @@ -10,6 +10,8 @@ import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before import org.junit.Rule +import to.bitkit.repositories.OnchainSendOutcome +import kotlin.test.assertIs import org.junit.Test import org.junit.runner.RunWith import to.bitkit.data.keychain.Keychain @@ -148,11 +150,12 @@ class TxBumpingTests { val lowFeeRate = 1uL // 1 sat/vbyte (very low) println("Sending $sendAmount sats to $destinationAddress with low fee rate of $lowFeeRate sat/vbyte") - val originalTxId = lightningService.send( + val outcome = lightningService.send( address = destinationAddress, sats = sendAmount, satsPerVByte = lowFeeRate, ) + val originalTxId = assertIs(outcome).txid lightningService.sync() diff --git a/app/src/androidTest/java/to/bitkit/services/UtxoSelectionTests.kt b/app/src/androidTest/java/to/bitkit/services/UtxoSelectionTests.kt index b93fd0da34..30c1785aa2 100644 --- a/app/src/androidTest/java/to/bitkit/services/UtxoSelectionTests.kt +++ b/app/src/androidTest/java/to/bitkit/services/UtxoSelectionTests.kt @@ -10,6 +10,8 @@ import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before import org.junit.Rule +import to.bitkit.repositories.OnchainSendOutcome +import kotlin.test.assertIs import org.junit.Test import org.junit.runner.RunWith import org.lightningdevkit.ldknode.CoinSelectionAlgorithm @@ -203,12 +205,13 @@ class UtxoSelectionTests { val feeRate = 1uL // 1 sat/vbyte println("Sending $sendAmount sats to $destinationAddress using specific UTXOs") - val txId = lightningService.send( + val outcome = lightningService.send( address = destinationAddress, sats = sendAmount, satsPerVByte = feeRate, utxosToSpend = utxosToSpend ) + val txId = assertIs(outcome).txid assertTrue(txId.isNotEmpty(), "Transaction ID should not be empty") println("Transaction sent successfully with txid: $txId") diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingScreenTest.kt new file mode 100644 index 0000000000..7ff0bab001 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingScreenTest.kt @@ -0,0 +1,70 @@ +package to.bitkit.ui.screens.wallets.send + +import android.graphics.Bitmap +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Rule +import org.junit.Test +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.theme.AppThemeSurface +import java.io.File +import kotlin.test.assertEquals + +@ComposeUi +class SendPendingScreenTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun unresolvedOnchainSendStaysPendingAfterCloseAndReopen() { + var visible by mutableStateOf(true) + var closeCount = 0 + composeTestRule.setContent { + AppThemeSurface { + CompositionLocalProvider(LocalInspectionMode provides true) { + if (visible) { + SendPendingContent( + amount = 1_000L, + isOnchain = true, + activityId = null, + onClose = { closeCount++; visible = false }, + onViewDetails = { error("Unresolved send has no activity") }, + ) + } + } + } + } + assertUnresolved() + saveScreenshot("ln112-onchain-pending.png") + composeTestRule.onNodeWithText("Close").performClick() + composeTestRule.runOnIdle { assertEquals(1, closeCount); visible = true } + assertUnresolved() + saveScreenshot("ln112-onchain-pending-reopened.png") + } + + private fun assertUnresolved() { + composeTestRule.onNodeWithText("Payment Pending").assertIsDisplayed() + composeTestRule.onNodeWithText("Bitkit will block another send", substring = true).assertIsDisplayed() + composeTestRule.onNodeWithText("Details").assertIsNotEnabled() + composeTestRule.onNodeWithText("Retry").assertDoesNotExist() + composeTestRule.onNodeWithText("Payment Sent").assertDoesNotExist() + } + + private fun saveScreenshot(name: String) { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val image = requireNotNull(instrumentation.uiAutomation.takeScreenshot()) + File(instrumentation.targetContext.getExternalFilesDir(null), name).outputStream().use { + check(image.compress(Bitmap.CompressFormat.PNG, 100, it)) + } + image.recycle() + } +} diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index 2a41579d54..9c1a473279 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -52,10 +52,15 @@ class Keychain @Inject constructor( fun loadString(key: String): String? = load(key)?.decodeToString() + fun loadString(key: String, walletIndex: Int): String? = + loadIndexed(key, stringPreferencesKey("${key}_$walletIndex"))?.decodeToString() + + fun load(key: String): ByteArray? = loadIndexed(key, key.indexed) + @Suppress("TooGenericExceptionCaught") - fun load(key: String): ByteArray? { + private fun loadIndexed(key: String, indexedKey: Preferences.Key): ByteArray? { try { - return snapshot[key.indexed]?.fromBase64()?.let { + return snapshot[indexedKey]?.fromBase64()?.let { keyStore.decrypt(it) } } catch (c: CancellationException) { @@ -131,11 +136,16 @@ class Keychain @Inject constructor( Logger.info("Saved value for key '$key'", context = TAG) } + suspend fun upsertString(key: String, value: String) = upsertIndexed(key, value, key.indexed) + + suspend fun upsertString(key: String, value: String, walletIndex: Int) = + upsertIndexed(key, value, stringPreferencesKey("${key}_$walletIndex")) + @Suppress("TooGenericExceptionCaught") - suspend fun upsertString(key: String, value: String) { + private suspend fun upsertIndexed(key: String, value: String, indexedKey: Preferences.Key) { try { val encryptedValue = keyStore.encrypt(value.toByteArray()) - keychain.edit { it[key.indexed] = encryptedValue.toBase64() } + keychain.edit { it[indexedKey] = encryptedValue.toBase64() } } catch (c: CancellationException) { throw c } catch (t: Throwable) { @@ -144,10 +154,14 @@ class Keychain @Inject constructor( Logger.info("Upserted value for key '$key'", context = TAG) } + suspend fun delete(key: String) = deleteIndexed(key, key.indexed) + + suspend fun delete(key: String, walletIndex: Int) = deleteIndexed(key, stringPreferencesKey("${key}_$walletIndex")) + @Suppress("TooGenericExceptionCaught") - suspend fun delete(key: String) { + private suspend fun deleteIndexed(key: String, indexedKey: Preferences.Key) { try { - keychain.edit { it.remove(key.indexed) } + keychain.edit { it.remove(indexedKey) } } catch (c: CancellationException) { throw c } catch (t: Throwable) { @@ -236,6 +250,7 @@ class Keychain @Inject constructor( PAYKIT_SDK_STATE, PAYKIT_PENDING_BACKUP_RESTORE, PAYKIT_PENDING_PAYMENT_PROOFS, + ONCHAIN_SEND_ATTEMPT, PAYKIT_PRESENTED_PAYMENT_REQUESTS, PUBKY_SECRET_KEY, SHARED_PUBKY_SOURCE, diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 9f04373d65..46d241dbfd 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -50,6 +50,7 @@ import org.lightningdevkit.ldknode.ClosureReason import org.lightningdevkit.ldknode.CoinSelectionAlgorithm import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.Network +import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentHash @@ -127,6 +128,7 @@ class LightningRepo @Inject constructor( private val lnurlService: LnurlService, private val cacheStore: CacheStore, private val preActivityMetadataRepo: PreActivityMetadataRepo, + private val onchainSendAttemptStore: OnchainSendAttemptStore, private val connectivityRepo: ConnectivityRepo, private val vssBackupClientLdk: VssBackupClientLdk, private val urlValidator: UrlValidator, @@ -553,6 +555,24 @@ class LightningRepo @Inject constructor( } private suspend fun onEvent(event: Event) { + val observedTxid = when (event) { + is Event.OnchainTransactionReceived -> event.txid + is Event.OnchainTransactionConfirmed -> event.txid + else -> null + } + if (observedTxid != null) { + runSuspendCatching { + val observed = onchainSendAttemptStore.observeExactTransaction(observedTxid) + val attempt = observed ?: onchainSendAttemptStore.current() + if (attempt != null && attempt.txid.equals(observedTxid, ignoreCase = true) && + attempt.hasPositiveEvidence && !attempt.localFollowupComplete && + !attempt.isTransfer && attempt.requestId == null + ) { + finishOnchainSendLocally(attempt) + } + } + .onFailure { Logger.warn("Failed to record exact on-chain transaction observation", it, context = TAG) } + } handleLdkEvent(event) recordProbeOutcome(event) val settledReceiveInvoice: SettledReceiveInvoice? @@ -1457,7 +1477,9 @@ class LightningRepo @Inject constructor( tags: List = emptyList(), beforeSendAttempt: suspend () -> Unit = {}, onBroadcast: suspend (Txid) -> Unit = {}, - ): Result = executeWhenNodeRunning("sendOnChain") { + requestId: PaykitPaymentRequestId? = null, + orderId: String? = null, + ): Result = executeWhenNodeRunning("sendOnChain") { require(address.isNotEmpty()) { "Send address cannot be empty" } // Ensure wallet is synced before sending to have up-to-date state @@ -1481,37 +1503,122 @@ class LightningRepo @Inject constructor( Logger.debug("UTXOs selected to spend: $utxosForSend", context = TAG) - beforeSendAttempt() - val txId = lightningService.send(address, sats, satsPerVByte, utxosForSend, isMaxAmount) - onBroadcast(txId) + val attempt = runSuspendCatching { + onchainSendAttemptStore.admit( + walletId = WalletScope.default, + requestId = requestId, + orderId = orderId, + address = address, + amountSats = sats, + isMaxAmount = isMaxAmount, + feeRateSatsPerVByte = satsPerVByte, + isTransfer = isTransfer, + channelId = channelId, + tags = tags, + beforeSendAttempt = beforeSendAttempt, + ) + }.getOrElse { + val error = if (it is OnchainSendBlockedError || it is OnchainSendAttemptUnreadableError) { + it + } else { + OnchainSendNotDispatchedError(it) + } + return@executeWhenNodeRunning Result.failure(error) + } + val nodeResult = runSuspendCatching { + lightningService.send(address, sats, satsPerVByte, utxosForSend, isMaxAmount, attempt.walletIndex) + } + val failure = nodeResult.exceptionOrNull() + if (failure != null) { + if (failure is NodeException || failure is ServiceError.NodeNotSetup) { + onchainSendAttemptStore.releaseBeforeDispatch(attempt.attemptId, attempt.walletIndex) + return@executeWhenNodeRunning Result.failure(OnchainSendNotDispatchedError(failure)) + } + return@executeWhenNodeRunning Result.failure(OnchainSendPendingError(failure)) + } + val outcome = nodeResult.getOrThrow() + val recorded = runSuspendCatching { onchainSendAttemptStore.recordOutcome(attempt.attemptId, outcome, attempt.walletIndex) } + .getOrElse { error -> + if (outcome !is OnchainSendOutcome.Accepted) { + return@executeWhenNodeRunning Result.failure(OnchainSendPendingError(error, outcome.txid)) + } + Logger.warn("Failed to persist accepted on-chain outcome; admission stays blocked", error, context = TAG) + attempt.copy(evidence = OnchainSendEvidence.Accepted, txid = outcome.txid) + } + + if (outcome !is OnchainSendOutcome.Accepted) return@executeWhenNodeRunning Result.success(outcome) + val txId = outcome.txid + runSuspendCatching { onBroadcast(txId) } + .onFailure { Logger.warn("Failed to continue accepted on-chain send", it, context = TAG) } + + runSuspendCatching { finishOnchainSendLocally(recorded) } + .onFailure { Logger.warn("Failed to finish accepted on-chain send locally", it, context = TAG) } + runSuspendCatching { syncState() } + .onFailure { Logger.warn("Failed to sync after accepted on-chain send", it, context = TAG) } + Result.success(outcome) + } + private suspend fun finishOnchainSendLocally(attempt: OnchainSendAttempt) { + val txId = requireNotNull(attempt.txid) { "On-chain send has no transaction id" } val preActivityMetadata = PreActivityMetadata( - walletId = WalletScope.default, + walletId = attempt.walletId, paymentId = txId, createdAt = nowTimestamp().toEpochMilli().toULong(), - tags = tags, + tags = attempt.tags, paymentHash = null, txId = txId, - address = address, + address = attempt.address, isReceive = false, - feeRate = satsPerVByte, - isTransfer = isTransfer, - channelId = channelId ?: "", + feeRate = attempt.feeRateSatsPerVByte, + isTransfer = attempt.isTransfer, + channelId = attempt.channelId ?: "", ) - preActivityMetadataRepo.addPreActivityMetadata(preActivityMetadata) - + preActivityMetadataRepo.addPreActivityMetadata(preActivityMetadata).getOrThrow() coreService.activity.createSentOnchainActivityFromSendResult( txid = txId, - address = address, - amount = sats, + address = attempt.address, + amount = attempt.amountSats, fee = 0u, - feeRate = satsPerVByte, - isTransfer = isTransfer, - channelId = channelId, + feeRate = attempt.feeRateSatsPerVByte, + isTransfer = attempt.isTransfer, + channelId = attempt.channelId, + walletId = attempt.walletId, ) + check(coreService.activity.getOnchainActivityByTxId(txId, attempt.walletId) != null) { + "Accepted on-chain transaction has no durable local activity" + } + } + + suspend fun completeAcceptedOrdinaryFollowup(txid: String) { + val attempt = onchainSendAttemptStore.current() + if (attempt != null && !attempt.isTransfer && attempt.requestId == null && + attempt.txid.equals(txid, ignoreCase = true) && attempt.hasPositiveEvidence + ) { + runSuspendCatching { + finishOnchainSendLocally(attempt) + onchainSendAttemptStore.markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + }.onFailure { Logger.warn("Failed to finish accepted ordinary send locally", it, context = TAG) } + } + } - syncState() - Result.success(txId) + suspend fun completeAcceptedTransferFollowup(orderId: String, txid: String) { + val attempt = onchainSendAttemptStore.current() + if (attempt?.orderId == orderId && attempt.txid.equals(txid, ignoreCase = true) && + attempt.hasPositiveEvidence + ) { + onchainSendAttemptStore.markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + } + } + + suspend fun currentOnchainSendAttempt(): OnchainSendAttempt? = onchainSendAttemptStore.current() + + suspend fun completeAcceptedShopFollowup(requestId: PaykitPaymentRequestId, txid: String) { + val attempt = onchainSendAttemptStore.current() + if (attempt?.requestId == requestId && attempt.txid.equals(txid, ignoreCase = true) && + attempt.hasPositiveEvidence + ) { + onchainSendAttemptStore.markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + } } suspend fun determineUtxosToSpend( diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt new file mode 100644 index 0000000000..d8d44d792e --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -0,0 +1,202 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import to.bitkit.data.keychain.Keychain +import to.bitkit.di.IoDispatcher +import to.bitkit.utils.AppError +import to.bitkit.services.LightningService +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +sealed interface OnchainSendOutcome { + val txid: String + + data class Accepted(override val txid: String) : OnchainSendOutcome + data class Rejected(override val txid: String, val reason: String) : OnchainSendOutcome + data class Unknown(override val txid: String) : OnchainSendOutcome +} + +@Serializable +enum class OnchainSendEvidence { + Pending, + Accepted, + Rejected, + Unknown, + Observed, +} + +@Serializable +@Suppress("LongParameterList") +data class OnchainSendAttempt( + val walletId: String, + val attemptId: String, + val requestId: PaykitPaymentRequestId?, + val orderId: String?, + val address: String, + val amountSats: ULong, + val isMaxAmount: Boolean, + val feeRateSatsPerVByte: ULong, + val isTransfer: Boolean, + val channelId: String?, + val tags: List, + val evidence: OnchainSendEvidence = OnchainSendEvidence.Pending, + val txid: String? = null, + val refusalReason: String? = null, + val localFollowupComplete: Boolean = false, + val walletIndex: Int = 0, +) { + val isUnresolved: Boolean + get() = evidence == OnchainSendEvidence.Pending || + evidence == OnchainSendEvidence.Rejected || + evidence == OnchainSendEvidence.Unknown + + val hasPositiveEvidence: Boolean + get() = evidence == OnchainSendEvidence.Accepted || evidence == OnchainSendEvidence.Observed + + val blocksNextSend: Boolean + get() = isUnresolved || (hasPositiveEvidence && !localFollowupComplete) +} + +class OnchainSendBlockedError(val attempt: OnchainSendAttempt? = null) : + AppError("A previous on-chain send is unresolved or this payment was already sent.") + +class OnchainSendAttemptUnreadableError(cause: Throwable) : AppError("Failed to read on-chain send attempt", cause) + +class OnchainSendNotDispatchedError(cause: Throwable) : AppError("On-chain send did not reach the backend", cause) + +class OnchainSendPendingError(cause: Throwable, val txid: String? = null) : + AppError("On-chain send outcome is unknown; do not send again", cause) + +@Singleton +class OnchainSendAttemptStore @Inject constructor( + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val keychain: Keychain, + private val lightningService: LightningService, +) { + companion object { + private val KEY = Keychain.Key.ONCHAIN_SEND_ATTEMPT.name + } + + private val mutex = Mutex() + + suspend fun current(): OnchainSendAttempt? = withContext(ioDispatcher) { + mutex.withLock { load(lightningService.currentWalletIndex) } + } + + @Suppress("LongParameterList") + suspend fun admit( + walletId: String, + requestId: PaykitPaymentRequestId?, + orderId: String?, + address: String, + amountSats: ULong, + isMaxAmount: Boolean, + feeRateSatsPerVByte: ULong, + isTransfer: Boolean, + channelId: String?, + tags: List, + beforeSendAttempt: suspend () -> Unit, + ): OnchainSendAttempt = withContext(ioDispatcher) { + mutex.withLock { + val walletIndex = lightningService.currentWalletIndex + val previous = load(walletIndex) + if (previous != null && ( + previous.walletId != walletId || + previous.blocksNextSend || + (requestId != null && previous.requestId == requestId) || + (orderId != null && previous.orderId == orderId) + ) + ) { + throw OnchainSendBlockedError(previous) + } + beforeSendAttempt() + val attempt = OnchainSendAttempt( + walletId = walletId, + attemptId = UUID.randomUUID().toString(), + requestId = requestId, + orderId = orderId, + address = address, + amountSats = amountSats, + isMaxAmount = isMaxAmount, + feeRateSatsPerVByte = feeRateSatsPerVByte, + isTransfer = isTransfer, + channelId = channelId, + tags = tags, + walletIndex = walletIndex, + ) + persist(attempt) + attempt + } + } + + suspend fun recordOutcome(attemptId: String, outcome: OnchainSendOutcome, walletIndex: Int): OnchainSendAttempt = + withContext(ioDispatcher + NonCancellable) { + mutex.withLock { + val current = load(walletIndex) + if (current?.attemptId != attemptId) throw OnchainSendBlockedError() + val evidence = when (outcome) { + is OnchainSendOutcome.Accepted -> OnchainSendEvidence.Accepted + is OnchainSendOutcome.Rejected -> OnchainSendEvidence.Rejected + is OnchainSendOutcome.Unknown -> OnchainSendEvidence.Unknown + } + current.copy( + evidence = evidence, + txid = outcome.txid, + refusalReason = (outcome as? OnchainSendOutcome.Rejected)?.reason, + ).also { persist(it) } + } + } + + suspend fun releaseBeforeDispatch(attemptId: String, walletIndex: Int) = withContext(ioDispatcher + NonCancellable) { + mutex.withLock { + val current = load(walletIndex) + if (current?.attemptId == attemptId && current.evidence == OnchainSendEvidence.Pending) { + keychain.delete(KEY, walletIndex) + } + } + } + + suspend fun markLocalFollowupComplete(attemptId: String, walletIndex: Int) = withContext(ioDispatcher + NonCancellable) { + mutex.withLock { + val current = load(walletIndex) + if (current?.attemptId == attemptId && current.hasPositiveEvidence) { + persist(current.copy(localFollowupComplete = true)) + } + } + } + + suspend fun observeExactTransaction(txid: String): OnchainSendAttempt? = + withContext(ioDispatcher + NonCancellable) { + mutex.withLock { + val current = load(lightningService.currentWalletIndex) ?: return@withLock null + if (!current.txid.equals(txid, ignoreCase = true) || !current.isUnresolved) return@withLock null + current.copy(evidence = OnchainSendEvidence.Observed).also { persist(it) } + } + } + + private fun load(walletIndex: Int): OnchainSendAttempt? { + val value = keychain.loadString(KEY, walletIndex) ?: return null + return runCatching { + Json.decodeFromString(value).also { attempt -> + require(attempt.walletId.isNotBlank() && attempt.attemptId.isNotBlank() && attempt.walletIndex == walletIndex) + require(attempt.evidence == OnchainSendEvidence.Pending || + attempt.txid?.matches(Regex("[0-9a-fA-F]{64}")) == true) + require(!attempt.localFollowupComplete || attempt.hasPositiveEvidence) + } + } + .getOrElse { throw OnchainSendAttemptUnreadableError(it) } + } + + private suspend fun persist(attempt: OnchainSendAttempt) { + keychain.upsertString(KEY, Json.encodeToString(attempt), attempt.walletIndex) + } +} diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 7520856c17..cd7ef1a80b 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -1,8 +1,5 @@ package to.bitkit.repositories -import com.synonym.bitkitcore.Activity -import com.synonym.bitkitcore.ActivityFilter -import com.synonym.bitkitcore.PaymentType import com.synonym.paykit.BillingPeriod import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow @@ -76,43 +73,12 @@ data class PaykitOnchainPaymentProofResolution( val transactionId: String, ) -@Singleton -class PaykitOnchainPaymentProofLookup @Inject constructor( - private val activityRepo: ActivityRepo, -) { - suspend fun existingTransactionIds( - address: String, - amountSats: ULong, - walletId: String = WalletScope.default, - ): Set = matchingTransactionIds(address, amountSats, walletId).mapTo(mutableSetOf(), String::lowercase) - - suspend fun transactionId( - address: String, - amountSats: ULong, - excluding: Set, - walletId: String = WalletScope.default, - ): String? = matchingTransactionIds(address, amountSats, walletId).lastOrNull { it.lowercase() !in excluding } - - private suspend fun matchingTransactionIds(address: String, amountSats: ULong, walletId: String): List = - activityRepo.getActivities( - walletId = walletId, - filter = ActivityFilter.ONCHAIN, - txType = PaymentType.SENT, - ).getOrThrow().mapNotNull { activity -> - val onchain = (activity as? Activity.Onchain)?.v1 ?: return@mapNotNull null - onchain.txId.takeIf { - onchain.doesExist && onchain.address == address && onchain.value == amountSats - } - } -} - @Singleton @Suppress("TooManyFunctions") class PaykitPaymentProofRepo @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val paykitSdkService: PaykitSdkService, private val lightningRepo: LightningRepo, - private val onchainPaymentLookup: PaykitOnchainPaymentProofLookup, private val store: PaykitPaymentProofStore, ) { companion object { @@ -139,12 +105,21 @@ class PaykitPaymentProofRepo @Inject constructor( kind: PaykitPaymentProofKind, ): Result = withContext(ioDispatcher) { runSuspendCatching { + val onchainAttempt = lightningRepo.currentOnchainSendAttempt() + if (onchainAttempt?.requestId == request.id) throw PaykitPaymentRequestError.OperationInProgress operationMutex.withLock { val proof = pendingProof(request, paymentEndpointIdentifier, kind) val currentProofs = loadProofs() if (currentProofs.any { it.isStartedFor(proof.identity, request.id) }) { throw PaykitPaymentRequestError.OperationInProgress } + val alreadyPaid = paykitSdkService.paymentRequests().any { record -> + record.paymentRequestId == request.paymentRequestId && + PubkyPublicKeyFormat.matches(record.counterparty, request.counterparty) && + record.counterpartyReceiverPath == request.counterpartyReceiverPath && + record.paymentProofs.any { it.billingPeriod.matches(request.billingPeriod) } + } + if (alreadyPaid) throw PaykitPaymentRequestError.OperationInProgress val proofs = currentProofs .filterNot { it.isUnstartedFor(proof.identity, request.id) } + proof @@ -160,9 +135,14 @@ class PaykitPaymentProofRepo @Inject constructor( ): Result = withContext(ioDispatcher) { runSuspendCatching { if (!paymentHash.isHex(HASH_BYTE_COUNT)) throw PaykitPaymentRequestError.RequestUnavailable + val onchainAttempt = lightningRepo.currentOnchainSendAttempt() + if (onchainAttempt?.requestId == request.id) throw PaykitPaymentRequestError.OperationInProgress val identity = currentIdentity() ?: throw PaykitPaymentRequestError.RequestUnavailable operationMutex.withLock { val proofs = loadProofs().toMutableList() + if (proofs.any { it.isStartedFor(identity, request.id) }) { + throw PaykitPaymentRequestError.OperationInProgress + } val index = proofs.indexOfLast { PubkyPublicKeyFormat.matches(it.identity, identity) && it.requestId == request.id && @@ -196,13 +176,11 @@ class PaykitPaymentProofRepo @Inject constructor( ): Result = withContext(ioDispatcher) { runSuspendCatching { val identity = currentIdentity() ?: throw PaykitPaymentRequestError.RequestUnavailable - val existingTransactionIds = onchainPaymentLookup.existingTransactionIds( - address, - request.amountSats, - walletId, - ) operationMutex.withLock { val proofs = loadProofs().toMutableList() + if (proofs.any { it.isStartedFor(identity, request.id) }) { + throw PaykitPaymentRequestError.OperationInProgress + } val index = proofs.indexOfLast { PubkyPublicKeyFormat.matches(it.identity, identity) && it.requestId == request.id && @@ -217,7 +195,6 @@ class PaykitPaymentProofRepo @Inject constructor( onchainAddress = address, onchainAmountSats = request.amountSats, onchainWalletId = walletId, - onchainMatchingTransactionIdsBeforeAttempt = existingTransactionIds, ) persist(proofs) } @@ -262,17 +239,17 @@ class PaykitPaymentProofRepo @Inject constructor( request: PaykitPaymentRequest, txid: String, paymentEndpointIdentifier: String, - ) = withContext(ioDispatcher) { + ): Boolean = withContext(ioDispatcher) { if (!txid.isHex(HASH_BYTE_COUNT)) { Logger.warn("Ignored a Paykit on-chain proof with an invalid transaction id", context = TAG) - return@withContext + return@withContext false } - val identity = currentIdentity() ?: return@withContext + val identity = currentIdentity() ?: return@withContext false val fallbackProof = runSuspendCatching { pendingProof(request, paymentEndpointIdentifier, PaykitPaymentProofKind.Onchain) }.getOrNull() - operationMutex.withLock { + val completed = operationMutex.withLock { val completion = runSuspendCatching { val proofs = loadProofs().toMutableList() val index = proofs.indexOfLast { @@ -296,8 +273,9 @@ class PaykitPaymentProofRepo @Inject constructor( ) } if (index >= 0) proofs[index] = proof else proofs += proof - persistAndSubmit(listOf(proof), proofs) - publishOnchainResolution(proof, txid) + val retained = persistAndSubmit(listOf(proof), proofs) + if (retained) publishOnchainResolution(proof, txid) + retained } completion.onFailure { Logger.warn( @@ -312,11 +290,20 @@ class PaykitPaymentProofRepo @Inject constructor( paymentIdentifier = txid.lowercase(), proofData = txid.lowercase(), ) - runSuspendCatching { submitReady(proof) } + val delivered = runSuspendCatching { submitReady(proof) } .onFailure { Logger.warn("Failed to complete a Paykit on-chain payment proof", it, context = TAG) } - publishOnchainResolution(proof, txid) + .getOrDefault(false) + if (delivered) publishOnchainResolution(proof, txid) + delivered + } else { + completion.getOrDefault(false) } } + if (completed) { + runSuspendCatching { lightningRepo.completeAcceptedShopFollowup(request.id, txid) } + .onFailure { Logger.warn("Failed to finish accepted Shop send locally", it, context = TAG) } + } + completed } suspend fun failLightningPayment(paymentHash: String) = removeProofs { @@ -386,8 +373,18 @@ class PaykitPaymentProofRepo @Inject constructor( } suspend fun reconcile() = withContext(ioDispatcher) { + val attempt = runSuspendCatching { lightningRepo.currentOnchainSendAttempt() } + .onFailure { + Logger.warn("Failed to read on-chain send evidence during proof reconciliation", it, context = TAG) + } + .getOrNull() + if (attempt != null && attempt.hasPositiveEvidence && attempt.requestId != null) { + runSuspendCatching { finishAlreadyQueuedOnchainProof(attempt) } + .onFailure { Logger.warn("Failed to check queued Shop proof", it, context = TAG) } + } if (!store.hasPendingProofs()) return@withContext + var completedShopTxid: String? = null operationMutex.withLock { runSuspendCatching { val storedProofs = loadProofs() @@ -407,7 +404,8 @@ class PaykitPaymentProofRepo @Inject constructor( } proofs.forEach { proof -> - runSuspendCatching { reconcileProof(proof, payments) } + runSuspendCatching { reconcileProof(proof, payments, attempt) } + .onSuccess { if (it) completedShopTxid = attempt?.txid } .onFailure { Logger.warn( "Failed to reconcile a pending Paykit payment proof", @@ -418,16 +416,36 @@ class PaykitPaymentProofRepo @Inject constructor( } }.onFailure { Logger.error("Failed to reconcile pending Paykit payment proofs", it, context = TAG) } } + val requestId = attempt?.requestId + val txid = completedShopTxid + if (requestId != null && txid != null) { + runSuspendCatching { lightningRepo.completeAcceptedShopFollowup(requestId, txid) } + .onFailure { Logger.warn("Failed to finish reconciled Shop send locally", it, context = TAG) } + } } private suspend fun reconcileProof( proof: PendingPaykitPaymentProof, payments: List, - ) { - when { - proof.proofData != null -> submitReady(proof) - proof.kind == PaykitPaymentProofKind.Onchain && proof.paymentStarted -> reconcileOnchainProof(proof) - proof.kind == PaykitPaymentProofKind.Lightning -> reconcileLightningProof(proof, payments) + attempt: OnchainSendAttempt?, + ): Boolean { + return when { + proof.kind == PaykitPaymentProofKind.Onchain && proof.proofData != null -> { + if (!attempt.matchesPositiveShopProof(proof)) return false + submitReady(proof) + true // The proof is already durable even if private delivery remains pending. + } + proof.proofData != null -> { + submitReady(proof) + false + } + proof.kind == PaykitPaymentProofKind.Onchain && proof.paymentStarted -> + reconcileOnchainProof(proof, attempt) + proof.kind == PaykitPaymentProofKind.Lightning -> { + reconcileLightningProof(proof, payments) + false + } + else -> false } } @@ -461,24 +479,44 @@ class PaykitPaymentProofRepo @Inject constructor( } } - private suspend fun reconcileOnchainProof(proof: PendingPaykitPaymentProof) { - val address = proof.onchainAddress ?: return - val amountSats = proof.onchainAmountSats ?: return - val txid = onchainPaymentLookup.transactionId( - address, - amountSats, - excluding = proof.onchainMatchingTransactionIdsBeforeAttempt, - walletId = proof.onchainWalletId, - ) ?: return - if (!txid.isHex(HASH_BYTE_COUNT)) return + private suspend fun reconcileOnchainProof( + proof: PendingPaykitPaymentProof, + attempt: OnchainSendAttempt?, + ): Boolean { + if (!attempt.matchesPositiveShopProof(proof)) return false + val txid = attempt?.txid ?: return false val proofs = loadProofs().toMutableList() val index = proofs.indexOf(proof) - if (index < 0) return + if (index < 0) return false val completed = proof.copy(paymentIdentifier = txid.lowercase(), proofData = txid.lowercase()) proofs[index] = completed - persistAndSubmit(listOf(completed), proofs) - publishOnchainResolution(proof, txid) + val retained = persistAndSubmit(listOf(completed), proofs) + if (retained) publishOnchainResolution(proof, txid) + return retained + } + + private fun OnchainSendAttempt?.matchesPositiveShopProof(proof: PendingPaykitPaymentProof): Boolean = + this != null && hasPositiveEvidence && requestId == proof.requestId && + walletId == proof.onchainWalletId && txid?.isHex(HASH_BYTE_COUNT) == true && + (proof.proofData == null || proof.proofData.equals(txid, ignoreCase = true)) + + private suspend fun finishAlreadyQueuedOnchainProof(attempt: OnchainSendAttempt) { + val requestId = attempt.requestId ?: return + val txid = attempt.txid?.takeIf { it.isHex(HASH_BYTE_COUNT) } ?: return + val record = paykitSdkService.paymentRequests().firstOrNull { + it.paymentRequestId == requestId.paymentRequestId && + PubkyPublicKeyFormat.matches(it.counterparty, requestId.counterparty) && + it.counterpartyReceiverPath == requestId.counterpartyReceiverPath + } ?: return + val expected = proofJson(PaykitPaymentProofKind.Onchain, txid.lowercase()).proofValues() + if (record.paymentProofs.any { + it.billingPeriod?.startsAt == requestId.billingPeriodStartsAt && + it.proof.exportText().proofValues() == expected + } + ) { + lightningRepo.completeAcceptedShopFollowup(requestId, txid) + } } private fun publishOnchainResolution(proof: PendingPaykitPaymentProof, txid: String) { @@ -591,7 +629,7 @@ class PaykitPaymentProofRepo @Inject constructor( private suspend fun persistAndSubmit( completedProofs: List, allProofs: List, - ) { + ): Boolean { val didPersist = runSuspendCatching { persist(allProofs) } .onFailure { Logger.warn( @@ -608,15 +646,16 @@ class PaykitPaymentProofRepo @Inject constructor( hasUndeliveredProof = hasUndeliveredProof || !wasDelivered } if (!didPersist && hasUndeliveredProof) { - runSuspendCatching { persist(allProofs) } + return runSuspendCatching { persist(allProofs) } .onFailure { Logger.warn( "Failed to retain a completed Paykit payment proof for retry", it, context = TAG, ) - } + }.isSuccess } + return didPersist || !hasUndeliveredProof } private suspend fun pendingProof( diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 71b75c30ef..d33d6c8019 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -38,6 +38,7 @@ import org.lightningdevkit.ldknode.Node import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.OnchainWalletAccountConfig +import org.lightningdevkit.ldknode.OnchainSendResult import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.PeerDetails @@ -67,6 +68,7 @@ import to.bitkit.models.WatchOnlyAccountRecord import to.bitkit.models.WatchOnlyAccountSetupState import to.bitkit.models.msatFloorOf import to.bitkit.models.toAddressType +import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.utils.AppError import to.bitkit.utils.LdkError import to.bitkit.utils.LdkLogWriter @@ -134,6 +136,7 @@ class LightningService internal constructor( private val loggerLdk: LoggerLdk, private val watchOnlyAccountLifecycleCoordinator: WatchOnlyAccountLifecycleCoordinator, private val ldkQueue: CoroutineContext, + private val onchainFeeRateFactory: (ULong) -> FeeRate = { FeeRate.fromSatPerVbUnchecked(it) }, ) : BaseCoroutineScope(bgDispatcher, TAG) { companion object { @@ -940,32 +943,43 @@ class LightningService internal constructor( satsPerVByte: ULong, utxosToSpend: List? = null, isMaxAmount: Boolean = false, - ): Txid { - val node = this.node ?: throw ServiceError.NodeNotSetup() + walletIndex: Int = currentWalletIndex, + ): OnchainSendOutcome { Logger.info( "Sending $sats sats to $address, satsPerVByte=$satsPerVByte, isMaxAmount = $isMaxAmount", context = TAG, ) - return ServiceQueue.LDK.background(ldkQueue) { + // Keep only this generated NodeError unwrapped: the new contract guarantees it was not dispatched. + val result = callOnchainSend { + if (currentWalletIndex != walletIndex) throw ServiceError.NodeNotSetup() + val node = this.node ?: throw ServiceError.NodeNotSetup() if (isMaxAmount) { - node.onchainPayment().sendAllToAddress( + node.onchainPayment().sendAllToAddressWithBroadcastResult( address = address, - retainReserve = true, - feeRate = FeeRate.fromSatPerVbUnchecked(satsPerVByte), + retainReserves = true, + feeRate = onchainFeeRateFactory(satsPerVByte), ) } else { - node.onchainPayment().sendToAddress( + node.onchainPayment().sendToAddressWithBroadcastResult( address = address, amountSats = sats, - feeRate = FeeRate.fromSatPerVbUnchecked(satsPerVByte), + feeRate = onchainFeeRateFactory(satsPerVByte), utxosToSpend = utxosToSpend, ) } } + return when (result) { + is OnchainSendResult.Accepted -> OnchainSendOutcome.Accepted(result.txid) + is OnchainSendResult.Rejected -> OnchainSendOutcome.Rejected(result.txid, result.reason) + is OnchainSendResult.Unknown -> OnchainSendOutcome.Unknown(result.txid) + } } + internal suspend fun callOnchainSend(block: suspend () -> T): T = + ServiceQueue.LDK.background(ldkQueue) { runSuspendCatching { block() } }.getOrThrow() + suspend fun send(bolt11: String, sats: ULong? = null): PaymentId { val node = this.node ?: throw ServiceError.NodeNotSetup() diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt index d5f0a9f5cc..1c7521ab10 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt @@ -46,6 +46,7 @@ fun SendPendingScreen( paymentHash: String, amount: Long, observeResolution: Boolean = true, + isOnchain: Boolean = false, onPaymentSuccess: (String, Long) -> Unit, onPaymentError: (PendingPaymentResolution.Failure) -> Unit, onClose: () -> Unit, @@ -71,8 +72,9 @@ fun SendPendingScreen( } } - Content( + SendPendingContent( amount = if (observeResolution) uiState.amount else amount, + isOnchain = isOnchain, activityId = uiState.activityId, onClose = onClose, onViewDetails = onViewDetails, @@ -80,8 +82,9 @@ fun SendPendingScreen( } @Composable -private fun Content( +internal fun SendPendingContent( amount: Long, + isOnchain: Boolean, activityId: String?, onClose: () -> Unit, onViewDetails: (String) -> Unit, @@ -104,7 +107,13 @@ private fun Content( BalanceHeaderView(sats = amount, modifier = Modifier.fillMaxWidth()) VerticalSpacer(32.dp) - BodyM(stringResource(R.string.wallet__send_pending__description), color = Colors.White64) + BodyM( + stringResource( + if (isOnchain) R.string.wallet__send_pending__onchain_description + else R.string.wallet__send_pending__description, + ), + color = Colors.White64, + ) FillHeight() HourglassAnimation(modifier = Modifier.align(Alignment.CenterHorizontally)) @@ -154,8 +163,9 @@ private fun HourglassAnimation(modifier: Modifier = Modifier) { private fun Preview() { AppThemeSurface { BottomSheetPreview { - Content( + SendPendingContent( amount = 50_000L, + isOnchain = false, activityId = null, onClose = {}, onViewDetails = {}, diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 0ed710ef41..be687ed6f7 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -174,7 +174,12 @@ fun SendSheet( is SendEffect.NavigateToComingSoon -> navController.navigateTo(SendRoute.ComingSoon) is SendEffect.NavigateToContacts -> navController.navigateTo(SendRoute.ContactSelect) is SendEffect.NavigateToPending -> navController.navigateTo( - SendRoute.Pending(it.paymentHash, it.amount, observeResolution = it.observeResolution) + SendRoute.Pending( + it.paymentHash, + it.amount, + observeResolution = it.observeResolution, + isOnchain = it.isOnchain, + ) ) { popUpTo(startDestination) { inclusive = true } } is SendEffect.NavigateToError -> navController.navigateTo( SendRoute.errorFromFailure( @@ -462,6 +467,7 @@ fun SendSheet( paymentHash = route.paymentHash, amount = route.amount, observeResolution = route.observeResolution, + isOnchain = route.isOnchain, onPaymentSuccess = { paymentHash, amountWithFee -> appViewModel.onSendSuccess( NewTransactionSheetDetails( @@ -655,6 +661,7 @@ sealed interface SendRoute { val paymentHash: String, val amount: Long, val observeResolution: Boolean = true, + val isOnchain: Boolean = false, val retryRoute: SendRetryRoute = SendRetryRoute.Confirm, val paymentRequest: String? = null, ) : InternalOnly diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 7e4f844045..361b111ab3 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -37,6 +37,7 @@ import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException @@ -70,7 +71,6 @@ import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.ChannelDataMigration import org.lightningdevkit.ldknode.ClosureReason import org.lightningdevkit.ldknode.Event -import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.PaymentFailureReason import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.SpendableUtxo @@ -152,6 +152,10 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LnurlPayInvoiceMismatchError import to.bitkit.repositories.MethodId import to.bitkit.repositories.NodeEventUpdate +import to.bitkit.repositories.OnchainSendBlockedError +import to.bitkit.repositories.OnchainSendNotDispatchedError +import to.bitkit.repositories.OnchainSendOutcome +import to.bitkit.repositories.OnchainSendPendingError import to.bitkit.repositories.PaykitOnchainPaymentProofResolution import to.bitkit.repositories.PaykitPaymentProofKind import to.bitkit.repositories.PaykitPaymentProofRepo @@ -204,7 +208,6 @@ import to.bitkit.utils.AppError import to.bitkit.utils.Bip21Utils import to.bitkit.utils.Logger import to.bitkit.utils.NetworkValidationHelper -import to.bitkit.utils.ServiceError import to.bitkit.utils.jsonLogOf import to.bitkit.utils.timedsheets.TimedSheetManager import to.bitkit.utils.timedsheets.sheets.AppUpdateTimedSheet @@ -3999,8 +4002,29 @@ class AppViewModel @Inject constructor( val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest val proofPreparation = preparePaymentProof(incomingPaymentRequest) - if (proofPreparation.exceptionOrNull() is PaykitPaymentRequestError.OperationInProgress) { - handlePaymentPreparationFailure(PaykitPaymentRequestError.OperationInProgress, contactPaymentContext) + if (proofPreparation.isFailure) { + if (incomingPaymentRequest != null && _sendUiState.value.payMethod == SendMethod.ONCHAIN) { + val previous = runSuspendCatching { lightningRepo.currentOnchainSendAttempt() }.getOrNull() + ?.takeIf { it.requestId == incomingPaymentRequest.id } + if (previous != null) { + val txid = previous.txid + if (previous.hasPositiveEvidence && txid != null) { + onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = txid, + sats = previous.amountSats.toLong(), + isLoadingDetails = false, + ) + ) + } else { + showUnresolvedOnchainSend(txid, previous.amountSats, requestId = previous.requestId) + } + return + } + } + handlePaymentPreparationFailure(requireNotNull(proofPreparation.exceptionOrNull()), contactPaymentContext) return } val preparedPaymentProofRequest = proofPreparation.getOrNull() @@ -4070,6 +4094,7 @@ class AppViewModel @Inject constructor( address = address, amount = amount, tags = tags, + requestId = incomingPaymentRequest?.id, beforeSendAttempt = { if (preparedPaymentProofRequest != null) { markOnchainPaymentStarted(incomingPaymentRequest, address).getOrThrow() @@ -4080,20 +4105,41 @@ class AppViewModel @Inject constructor( proofRequest = null completeOnchainPaymentProofInBackground(incomingPaymentRequest, txId) }, - ).onSuccess { txId -> - Logger.info("Onchain send result txid: $txId", context = TAG) - onSendSuccess( - NewTransactionSheetDetails( - type = NewTransactionSheetType.ONCHAIN, - direction = NewTransactionSheetDirection.SENT, - paymentHashOrTxId = txId, - sats = amount.toLong(), - isLoadingDetails = true, - ) - ) - lightningRepo.sync() - activityRepo.syncActivities() - _successSendUiState.update { it.copy(isLoadingDetails = false) } + ).onSuccess { outcome -> + proofRequest = null + when (outcome) { + is OnchainSendOutcome.Accepted -> { + Logger.info("Accepted on-chain send '${outcome.txid}'", context = TAG) + onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = outcome.txid, + sats = amount.toLong(), + isLoadingDetails = true, + ) + ) + if (incomingPaymentRequest == null) { + withContext(NonCancellable) { + lightningRepo.completeAcceptedOrdinaryFollowup(outcome.txid) + } + } + lightningRepo.sync() + activityRepo.syncActivities() + _successSendUiState.update { it.copy(isLoadingDetails = false) } + } + + is OnchainSendOutcome.Rejected -> { + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.wallet__error_sending_title), + description = outcome.reason, + ) + showUnresolvedOnchainSend(outcome.txid, amount, incomingPaymentRequest) + } + + is OnchainSendOutcome.Unknown -> showUnresolvedOnchainSend(outcome.txid, amount, incomingPaymentRequest) + } }.onFailure { error -> handleOnchainPaymentFailure( error = error, @@ -4113,19 +4159,45 @@ class AppViewModel @Inject constructor( contactPaymentContext: ContactPaymentContext?, ) { val amount = _sendUiState.value.amount - if (paymentStarted && !error.isDefiniteOnchainPreBroadcastFailure()) { + val unresolved = error !is OnchainSendNotDispatchedError + if (unresolved) { Logger.warn("On-chain payment outcome is uncertain after send started", error, context = TAG) - uncertainOnchainPaymentRequestId = incomingPaymentRequest?.id + if (!paymentStarted) cancelPaymentProofPreparation(preparedPaymentProofRequest) + val previous = (error as? OnchainSendBlockedError)?.attempt + val priorAccepted = previous?.takeIf { + it.hasPositiveEvidence && it.txid != null && !it.isTransfer && + it.requestId == incomingPaymentRequest?.id + } + if (priorAccepted != null) { + val txid = requireNotNull(priorAccepted.txid) + onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = txid, + sats = priorAccepted.amountSats.toLong(), + isLoadingDetails = false, + ) + ) + if (priorAccepted.requestId == null) lightningRepo.completeAcceptedOrdinaryFollowup(txid) + return + } + previous?.refusalReason?.let { + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.wallet__error_sending_title), + description = it, + ) + } + val unresolvedRequestId = previous?.requestId ?: incomingPaymentRequest?.id + uncertainOnchainPaymentRequestId = unresolvedRequestId paykitPaymentProofRepo.onchainPaymentResolutions.value - .firstOrNull { it.requestId == incomingPaymentRequest?.id } + .firstOrNull { it.requestId == unresolvedRequestId } ?.let(::handlePaykitOnchainPaymentResolution) - if (uncertainOnchainPaymentRequestId == null) return - setSendEffect( - SendEffect.NavigateToPending( - paymentHash = incomingPaymentRequest?.paymentRequestId.orEmpty(), - amount = amount.toLong(), - observeResolution = false, - ) + showUnresolvedOnchainSend( + txid = (error as? OnchainSendPendingError)?.txid ?: previous?.txid, + amount = previous?.amountSats ?: amount, + requestId = unresolvedRequestId, ) return } @@ -4146,6 +4218,23 @@ class AppViewModel @Inject constructor( } } + private fun showUnresolvedOnchainSend( + txid: String?, + amount: ULong, + request: PaykitPaymentRequest? = null, + requestId: PaykitPaymentRequestId? = request?.id, + ) { + uncertainOnchainPaymentRequestId = requestId + setSendEffect( + SendEffect.NavigateToPending( + paymentHash = txid ?: requestId?.paymentRequestId.orEmpty(), + amount = amount.toLong(), + observeResolution = false, + isOnchain = true, + ) + ) + } + private suspend fun proceedWithLightningPayment( incomingPaymentRequest: PaykitPaymentRequest?, preparedPaymentProofRequest: PaykitPaymentRequest?, @@ -4159,7 +4248,8 @@ class AppViewModel @Inject constructor( val paymentHash = decodedInvoice.paymentHash.toHex() associateLightningPaymentProof(incomingPaymentRequest, paymentHash).onFailure { cancelPaymentProofPreparation(proofRequest) - proofRequest = null + handlePaymentPreparationFailure(it, null) + return } val tags = _sendUiState.value.selectedTags @@ -4493,11 +4583,10 @@ class AppViewModel @Inject constructor( address: String, amount: ULong, tags: List = emptyList(), + requestId: PaykitPaymentRequestId? = null, beforeSendAttempt: suspend () -> Unit = {}, onBroadcast: suspend (Txid) -> Unit = {}, - ): Result { - var broadcastTxId: Txid? = null - return lightningRepo.sendOnChain( + ): Result = lightningRepo.sendOnChain( address = address, sats = amount, speed = _sendUiState.value.speed, @@ -4505,13 +4594,10 @@ class AppViewModel @Inject constructor( isMaxAmount = _sendUiState.value.payMethod == SendMethod.ONCHAIN && amount == walletRepo.balanceState.value.maxSendOnchainSats, tags = tags, + requestId = requestId, beforeSendAttempt = beforeSendAttempt, - onBroadcast = { - broadcastTxId = it - onBroadcast(it) - }, - ).recoverCatching { broadcastTxId ?: throw it } - } + onBroadcast = onBroadcast, + ) private suspend fun sendLightning( bolt11: String, @@ -5971,6 +6057,7 @@ sealed class SendEffect { val paymentHash: String, val amount: Long, val observeResolution: Boolean = true, + val isOnchain: Boolean = false, ) : SendEffect() } @@ -6022,25 +6109,6 @@ private class LightningPaymentFailedError( val paymentRequest: String?, ) : AppError(reason?.name) -private fun Throwable.isDefiniteOnchainPreBroadcastFailure(): Boolean = - generateSequence(this as Throwable?) { it.cause } - .any { - it is ServiceError.NodeNotSetup || - it is ServiceError.NodeNotStarted || - it is NodeException.NotRunning || - it is NodeException.OnchainTxCreationFailed || - it is NodeException.OnchainTxSigningFailed || - it is NodeException.WalletOperationFailed || - it is NodeException.PersistenceFailed || - it is NodeException.InvalidAddress || - it is NodeException.InvalidAmount || - it is NodeException.InvalidNetwork || - it is NodeException.InvalidFeeRate || - it is NodeException.InsufficientFunds || - it is NodeException.CoinSelectionFailed || - it is NodeException.NoSpendableOutputs - } - sealed interface LnurlParams { data class LnurlPay(val data: LnurlPayData) : LnurlParams data class LnurlWithdraw(val data: LnurlWithdrawData) : LnurlParams diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index c2727fbf82..48c2a585c6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -70,6 +70,7 @@ import to.bitkit.repositories.HwPassphraseMismatchError import to.bitkit.repositories.HwPassphraseRequiredError import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.BoltzService @@ -370,6 +371,24 @@ class TransferViewModel @Inject constructor( walletRepo.getAddresses(count = 1).onFailure { ToastEventBus.send(it) }.getOrNull()?.firstOrNull()?.address private suspend fun paySpendingConfirmOrder(order: IBtOrder, shown: TransferToSpendingUiState): Boolean { + val previous = lightningRepo.currentOnchainSendAttempt() + if (previous?.orderId == order.id) { + val txid = previous.txid + if (previous.hasPositiveEvidence && txid != null) { + if (!previous.localFollowupComplete) { + withContext(NonCancellable) { + fundPaidOrder(order = order, txId = txid, requireTransferPersisted = true) + lightningRepo.completeAcceptedTransferFollowup(order.id, txid) + } + } + return true + } + ToastEventBus.send( + AppError(previous.refusalReason ?: "This funding payment is unresolved. Check its transaction before retrying.") + ) + return false + } + if (cacheStore.data.first().paidOrders.containsKey(order.id)) return true val plan = resolveSpendingConfirmPlan(order, shown) ?: return false Logger.debug( @@ -392,24 +411,38 @@ class TransferViewModel @Inject constructor( isTransfer = true, channelId = order.channel?.shortChannelId, isMaxAmount = plan.shouldUseSendAll, + orderId = order.id, ) - .onSuccess { txId -> - // Survive ViewModel clearance between broadcast and paid-order cache write. - withContext(NonCancellable) { - fundPaidOrder( - order = order, - txId = txId, - txTotalSats = if (plan.shouldUseSendAll) { - plan.spendableBalance - } else { - order.feeSat.safe() + plan.miningFeeSats.safe() - }, - preTransferOnchainSats = plan.totalOnchainBalance, - ) + .fold( + onSuccess = { outcome -> + if (outcome !is OnchainSendOutcome.Accepted) { + ToastEventBus.send( + AppError("Funding transaction is unresolved. Check its transaction before retrying.") + ) + return@fold false + } + // Survive ViewModel clearance between accepted broadcast and paid-order cache write. + withContext(NonCancellable) { + fundPaidOrder( + order = order, + txId = outcome.txid, + txTotalSats = if (plan.shouldUseSendAll) { + plan.spendableBalance + } else { + order.feeSat.safe() + plan.miningFeeSats.safe() + }, + preTransferOnchainSats = plan.totalOnchainBalance, + requireTransferPersisted = true, + ) + lightningRepo.completeAcceptedTransferFollowup(order.id, outcome.txid) + } + true + }, + onFailure = { + ToastEventBus.send(it) + false } - } - .onFailure { ToastEventBus.send(it) } - .isSuccess + ) } private suspend fun resolveSpendingConfirmPlan( @@ -631,16 +664,28 @@ class TransferViewModel @Inject constructor( txTotalSats: ULong? = null, preTransferOnchainSats: ULong? = null, activityWalletId: String = WalletScope.default, + requireTransferPersisted: Boolean = false, ) { cacheStore.addPaidOrder(orderId = order.id, txId = txId) - transferRepo.createTransfer( - type = TransferType.TO_SPENDING, - amountSats = order.clientBalanceSat.toLong(), - fundingTxId = txId, - lspOrderId = order.id, - txTotalSats = txTotalSats?.toLong(), - preTransferOnchainSats = preTransferOnchainSats?.toLong(), - ) + val existingOrderId = if (requireTransferPersisted) { + transferRepo.findLspOrderIdByFundingTxId(txId).getOrThrow() + } else { + null + } + if (existingOrderId != null && existingOrderId != order.id) { + throw AppError("Funding transaction is already assigned to another order") + } + if (existingOrderId == null) { + val transfer = transferRepo.createTransfer( + type = TransferType.TO_SPENDING, + amountSats = order.clientBalanceSat.toLong(), + fundingTxId = txId, + lspOrderId = order.id, + txTotalSats = txTotalSats?.toLong(), + preTransferOnchainSats = preTransferOnchainSats?.toLong(), + ) + if (requireTransferPersisted) transfer.getOrThrow() + } if (createTransferActivity) { transferRepo.createPendingToSpendingActivity( order = order, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d39957fed1..3710e92d6a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1456,6 +1456,7 @@ The maximum spendable amount is a bit lower due to a required reserve balance. Reserve Balance This payment is taking a bit longer than expected. You can continue using Bitkit. + The transaction outcome is unresolved. Check its status before trying again. Bitkit will block another send while this outcome is unresolved. Payment Pending This invoice has already been paid. Currency conversion failed diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index eea548e5a1..ef55e8aa60 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -83,6 +83,7 @@ import to.bitkit.utils.AppError import to.bitkit.utils.LdkError import to.bitkit.utils.UrlValidator import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.assertFailsWith import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs @@ -111,6 +112,7 @@ class LightningRepoTest : BaseUnitTest() { private val keychain = mock() private val cacheStore = mock() private val preActivityMetadataRepo = mock() + private val onchainSendAttemptStore = mock() private val lnurlService = mock() private val connectivityRepo = mock() private val vssBackupClientLdk = mock() @@ -144,6 +146,7 @@ class LightningRepoTest : BaseUnitTest() { lnurlService = lnurlService, cacheStore = cacheStore, preActivityMetadataRepo = preActivityMetadataRepo, + onchainSendAttemptStore = onchainSendAttemptStore, connectivityRepo = connectivityRepo, vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, @@ -1394,9 +1397,32 @@ class LightningRepoTest : BaseUnitTest() { sats = any(), satsPerVByte = any(), utxosToSpend = anyOrNull(), - isMaxAmount = any() + isMaxAmount = any(), + walletIndex = any(), ) - ).thenReturn("testPaymentId") + ).thenReturn(OnchainSendOutcome.Accepted("testPaymentId")) + + val attempt = OnchainSendAttempt( + walletId = "test-wallet", + attemptId = "attempt-1", + requestId = null, + orderId = null, + address = "test_address", + amountSats = 1000uL, + isMaxAmount = false, + feeRateSatsPerVByte = 10uL, + isTransfer = true, + channelId = "test_channel_id", + tags = emptyList(), + ) + whenever { + onchainSendAttemptStore.admit( + any(), anyOrNull(), anyOrNull(), any(), any(), any(), any(), any(), anyOrNull(), any(), any(), + ) + } + .thenReturn(attempt) + whenever { onchainSendAttemptStore.recordOutcome(any(), any(), any()) } + .thenReturn(attempt.copy(evidence = OnchainSendEvidence.Accepted, txid = "testPaymentId")) startNodeForTesting() @@ -1416,12 +1442,110 @@ class LightningRepoTest : BaseUnitTest() { // Verify the result is successful assertTrue(result.isSuccess) - assertEquals("testPaymentId", result.getOrNull()) + assertEquals(OnchainSendOutcome.Accepted("testPaymentId"), result.getOrNull()) // Verify pre-activity metadata was saved verifyBlocking(preActivityMetadataRepo) { addPreActivityMetadata(any()) } + + whenever { onchainSendAttemptStore.recordOutcome(any(), any(), any()) } + .thenThrow(IllegalStateException("encrypted attempt write failed")) + val acceptedDespiteStorageFailure = spySut.sendOnChain( + address = "test_address", + sats = 1000uL, + speed = TransactionSpeed.Fast, + isTransfer = true, + channelId = "test_channel_id", + ) + assertEquals(OnchainSendOutcome.Accepted("testPaymentId"), acceptedDespiteStorageFailure.getOrThrow()) + } + + @Test + fun `only a direct undispatched node error releases an admitted send`() = test { + val attempt = pendingSendAttempt() + val repo = prepareGuardedSend(attempt) + var sendError: Throwable = NodeException.InvalidAddress("invalid address") + whenever(lightningService.send(any(), any(), any(), anyOrNull(), any(), any())) + .doSuspendableAnswer { throw sendError } + assertTrue(repo.sendOnChain("address", 1_000uL).exceptionOrNull() is OnchainSendNotDispatchedError) + verify(onchainSendAttemptStore).releaseBeforeDispatch(attempt.attemptId, attempt.walletIndex) + + sendError = LdkError(NodeException.PersistenceFailed("workflow storage failure")) + assertTrue(repo.sendOnChain("address", 1_000uL).exceptionOrNull() is OnchainSendPendingError) + verify(onchainSendAttemptStore, times(1)).releaseBeforeDispatch(any(), any()) + } + + @Test + fun `UI cancellation after admission never releases pending guard`() = test { + val attempt = pendingSendAttempt() + val repo = prepareGuardedSend(attempt) + whenever(lightningService.send(any(), any(), any(), anyOrNull(), any(), any())) + .thenThrow(CancellationException("screen closed")) + assertFailsWith { repo.sendOnChain("address", 1_000uL) } + verify(onchainSendAttemptStore, never()).releaseBeforeDispatch(any(), any()) + } + + @Test + fun `accepted callback workflow error cannot turn a paid send into retry`() = test { + val attempt = pendingSendAttempt() + val repo = prepareGuardedSend(attempt) + val txid = "ab".repeat(32) + val accepted = OnchainSendOutcome.Accepted(txid) + whenever(lightningService.send(any(), any(), any(), anyOrNull(), any(), any())).thenReturn(accepted) + whenever(onchainSendAttemptStore.recordOutcome(any(), any(), any())) + .thenReturn(attempt.copy(evidence = OnchainSendEvidence.Accepted, txid = txid)) + val result = repo.sendOnChain("address", 1_000uL, onBroadcast = { + throw NodeException.PersistenceFailed("callback storage failure") + }) + assertEquals(accepted, result.getOrThrow()) + verify(onchainSendAttemptStore, never()).releaseBeforeDispatch(any(), any()) + } + + private fun pendingSendAttempt() = OnchainSendAttempt( + walletId = "test-wallet", attemptId = "attempt-1", requestId = null, orderId = null, + address = "address", amountSats = 1_000uL, isMaxAmount = false, feeRateSatsPerVByte = 1uL, + isTransfer = false, channelId = null, tags = emptyList(), + ) + + private suspend fun prepareGuardedSend(attempt: OnchainSendAttempt): LightningRepo { + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(coinSelectAuto = false))) + whenever(onchainSendAttemptStore.admit( + any(), anyOrNull(), anyOrNull(), any(), any(), any(), any(), any(), anyOrNull(), any(), any(), + )).thenReturn(attempt) + startNodeForTesting() + return spy(sut).also { doReturn(Result.success(1uL)).whenever(it).getFeeRateForSpeed(any(), anyOrNull()) } + } + + @Test + fun `accepted ordinary send stays guarded until its activity is durable`() = test { + val txid = "ab".repeat(32) + val attempt = OnchainSendAttempt( + walletId = "test-wallet", + attemptId = "attempt-1", + requestId = null, + orderId = null, + address = "bcrt1qrecipient", + amountSats = 1_000uL, + isMaxAmount = false, + feeRateSatsPerVByte = 1uL, + isTransfer = false, + channelId = null, + tags = emptyList(), + evidence = OnchainSendEvidence.Accepted, + txid = txid, + ) + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + whenever(onchainSendAttemptStore.current()).thenReturn(attempt) + + sut.completeAcceptedOrdinaryFollowup(txid) + verify(onchainSendAttemptStore, never()).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + + whenever { activityService.getOnchainActivityByTxId(txid, attempt.walletId) }.thenReturn(mock()) + sut.completeAcceptedOrdinaryFollowup(txid) + verify(onchainSendAttemptStore).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) } @Test @@ -1703,6 +1827,7 @@ class LightningRepoTest : BaseUnitTest() { lnurlService = lnurlService, cacheStore = cacheStore, preActivityMetadataRepo = preActivityMetadataRepo, + onchainSendAttemptStore = onchainSendAttemptStore, connectivityRepo = connectivityRepo, vssBackupClientLdk = vssBackupClientLdk, urlValidator = failingValidator, diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt new file mode 100644 index 0000000000..2ccc093fbe --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -0,0 +1,137 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import org.junit.Test +import to.bitkit.services.LightningService +import org.mockito.kotlin.any +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.keychain.Keychain +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class OnchainSendAttemptStoreTest : BaseUnitTest() { + private val key = Keychain.Key.ONCHAIN_SEND_ATTEMPT.name + + @Test + fun `reopen blocks unresolved send until exact transaction is observed and followup is complete`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { + saved = it.getArgument(1) + } + val firstStore = OnchainSendAttemptStore(testDispatcher, keychain, mock()) + val attempt = firstStore.admitForTest() + firstStore.recordOutcome(attempt.attemptId, OnchainSendOutcome.Unknown("ab".repeat(32)), attempt.walletIndex) + + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, mock()) + assertFailsWith { reopened.admitForTest() } + assertNull(reopened.observeExactTransaction("cd".repeat(32))) + assertFailsWith { reopened.admitForTest() } + + val observed = reopened.observeExactTransaction("ab".repeat(32)) + assertEquals(OnchainSendEvidence.Observed, observed?.evidence) + assertFailsWith { reopened.admitForTest() } + reopened.markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + assertTrue(reopened.admitForTest().attemptId != attempt.attemptId) + } + + @Test + fun `corrupt attempt refuses a new send without replacing evidence`() = test { + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenReturn("not-json") + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock()) + + assertFailsWith { store.admitForTest() } + verify(keychain).loadString(key, 0) + } + + @Test + fun `outcome remains in the admitted node wallet after config context changes`() = test { + val values = mutableMapOf() + val keychain = mock() + val service = mock() + var currentIndex = 7 + whenever(service.currentWalletIndex).thenAnswer { currentIndex } + whenever(keychain.loadString(eq(key), any())).thenAnswer { values[it.getArgument(1)] } + whenever(keychain.upsertString(eq(key), any(), any())).doSuspendableAnswer { + values[it.getArgument(2)] = it.getArgument(1) + } + val store = OnchainSendAttemptStore(testDispatcher, keychain, service) + val attempt = store.admitForTest() + assertEquals(7, attempt.walletIndex) + currentIndex = 8 + store.recordOutcome(attempt.attemptId, OnchainSendOutcome.Accepted("ab".repeat(32)), attempt.walletIndex) + assertNull(store.current()) + currentIndex = 7 + assertEquals(OnchainSendEvidence.Accepted, store.current()?.evidence) + assertFailsWith { store.admitForTest() } + } + + @Test + fun `parallel admissions dispatch only one before-send callback`() = test { + var saved: String? = null + var callbacks = 0 + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock()) + val entered = CompletableDeferred() + val finish = CompletableDeferred() + val first = async { store.admitForTest { callbacks++; entered.complete(Unit); finish.await() } } + entered.await() + val second = async { runCatching { store.admitForTest { callbacks++ } } } + finish.complete(Unit) + first.await() + assertTrue(second.await().exceptionOrNull() is OnchainSendBlockedError) + assertEquals(1, callbacks) + } + + @Test + fun `cancel after admission and failed result save preserve durable pending guard`() = test { + var saved: String? = null + var failWrite = false + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { + if (failWrite) error("storage unavailable") + saved = it.getArgument(1) + } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock()) + val admitted = CompletableDeferred() + val send = launch { admitted.complete(store.admitForTest()); kotlinx.coroutines.awaitCancellation() } + val attempt = admitted.await() + send.cancelAndJoin() + failWrite = true + assertFailsWith { + store.recordOutcome(attempt.attemptId, OnchainSendOutcome.Accepted("ab".repeat(32)), attempt.walletIndex) + } + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, mock()) + assertEquals(OnchainSendEvidence.Pending, reopened.current()?.evidence) + assertFailsWith { reopened.admitForTest() } + } + + private suspend fun OnchainSendAttemptStore.admitForTest(beforeSendAttempt: suspend () -> Unit = {}): OnchainSendAttempt = admit( + walletId = "wallet-1", + requestId = null, + orderId = null, + address = "bcrt1qrecipient", + amountSats = 1_000uL, + isMaxAmount = false, + feeRateSatsPerVByte = 1uL, + isTransfer = false, + channelId = null, + tags = emptyList(), + beforeSendAttempt = beforeSendAttempt, + ) +} diff --git a/app/src/test/java/to/bitkit/repositories/PaykitOnchainPaymentProofLookupTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitOnchainPaymentProofLookupTest.kt deleted file mode 100644 index 560b7cfaf7..0000000000 --- a/app/src/test/java/to/bitkit/repositories/PaykitOnchainPaymentProofLookupTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -package to.bitkit.repositories - -import com.synonym.bitkitcore.Activity -import com.synonym.bitkitcore.ActivityFilter -import com.synonym.bitkitcore.OnchainActivity -import com.synonym.bitkitcore.PaymentType -import org.junit.Test -import org.mockito.kotlin.mock -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import to.bitkit.ext.create -import to.bitkit.test.BaseUnitTest -import kotlin.test.assertEquals - -class PaykitOnchainPaymentProofLookupTest : BaseUnitTest() { - private val activityRepo = mock() - private val lookup = PaykitOnchainPaymentProofLookup(activityRepo) - - @Test - fun `payment lookup uses one scoped activity query`() = test { - val walletId = "hardware-wallet" - val activity = Activity.Onchain( - OnchainActivity.create( - walletId = walletId, - id = "activity-id", - txType = PaymentType.SENT, - txId = "transaction-id", - value = 1_000uL, - fee = 100uL, - address = "bcrt1qpaymentproof", - timestamp = 1uL, - ) - ) - whenever( - activityRepo.getActivities( - walletId = walletId, - filter = ActivityFilter.ONCHAIN, - txType = PaymentType.SENT, - ) - ).thenReturn(Result.success(listOf(activity))) - - val transactionIds = lookup.existingTransactionIds( - address = activity.v1.address, - amountSats = activity.v1.value, - walletId = walletId, - ) - - assertEquals(setOf(activity.v1.txId), transactionIds) - verify(activityRepo).getActivities( - walletId = walletId, - filter = ActivityFilter.ONCHAIN, - txType = PaymentType.SENT, - ) - } -} diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 06092a9246..9853ca7fad 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -55,7 +55,6 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { private val paykitSdkService = mock() private val lightningRepo = mock() - private val onchainPaymentLookup = mock() private val store = mock() private var storedProofs = emptyList() private var shouldFailNextLoad = false @@ -71,7 +70,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(store.hasPendingProofs()).thenReturn(true) whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) - whenever(onchainPaymentLookup.existingTransactionIds(any(), any(), any())).thenReturn(emptySet()) + whenever(paykitSdkService.paymentRequests()).thenReturn(emptyList()) whenever(store.load()).thenAnswer { if (shouldFailNextLoad) { shouldFailNextLoad = false @@ -93,6 +92,39 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } } + @Test + fun `lightning association cannot bypass a started onchain request`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue).copy( + acceptedPaymentEndpointIdentifiers = listOf(MethodId.P2wpkh.rawValue, MethodId.Bolt11.rawValue), + ) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + + val result = repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue) + + assertTrue(result.isFailure) + assertEquals(PaykitPaymentRequestError.OperationInProgress, result.exceptionOrNull()) + assertEquals(1, storedProofs.size) + assertEquals(PaykitPaymentProofKind.Onchain, storedProofs.single().kind) + } + + @Test + fun `onchain callback cannot bypass a started lightning request`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue).copy( + acceptedPaymentEndpointIdentifiers = listOf(MethodId.P2wpkh.rawValue, MethodId.Bolt11.rawValue), + ) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() + + val result = repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS) + + assertEquals(PaykitPaymentRequestError.OperationInProgress, result.exceptionOrNull()) + assertEquals(1, storedProofs.count { it.paymentStarted }) + assertEquals(PaykitPaymentProofKind.Lightning, storedProofs.single { it.paymentStarted }.kind) + } + @Test fun `reconcile avoids Paykit and proof loading without persisted proofs`() = test { whenever(store.hasPendingProofs()).thenReturn(false) @@ -252,13 +284,13 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `existing proof suppresses duplicate submission`() = test { + fun `queued onchain proof blocks same Shop request switched to Lightning`() = test { val existingProofJson = mock { - on { exportText() } doReturn """{"type":"${PaykitPaymentProofKind.Lightning.type}","data":"$PREIMAGE"}""" + on { exportText() } doReturn """{"type":"${PaykitPaymentProofKind.Onchain.type}","data":"${"ab".repeat(32)}"}""" } val existingProof = mock { on { billingPeriod } doReturn null - on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue + on { paymentEndpointIdentifier } doReturn MethodId.P2wpkh.rawValue on { proof } doReturn existingProofJson } val record = paymentRequestRecord(listOf(existingProof)) @@ -266,14 +298,25 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) val repo = paymentProofRepo() - repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning).getOrThrow() - repo.associateLightningPayment(request, PAYMENT_HASH, MethodId.Bolt11.rawValue).getOrThrow() - repo.completeLightningPayment(PAYMENT_HASH, PREIMAGE) + val result = repo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) + assertEquals(PaykitPaymentRequestError.OperationInProgress, result.exceptionOrNull()) assertTrue(storedProofs.isEmpty()) verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) } + @Test + fun `accepted onchain attempt blocks Lightning before its proof is queued`() = test { + val request = paymentRequest(MethodId.Bolt11.rawValue) + whenever(lightningRepo.currentOnchainSendAttempt()) + .thenReturn(acceptedAttempt(request, "ab".repeat(32))) + + val result = paymentProofRepo().prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) + + assertEquals(PaykitPaymentRequestError.OperationInProgress, result.exceptionOrNull()) + assertTrue(storedProofs.isEmpty()) + } + @Test fun `failed lightning payment clears persisted correlation`() = test { val request = paymentRequest(MethodId.Bolt11.rawValue) @@ -621,24 +664,17 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `uncertain onchain payment is reconciled from its private destination`() = test { + fun `accepted onchain payment is reconciled from its exact recorded transaction`() = test { val txid = "ab".repeat(32) val record = paymentRequestRecord() val request = paymentRequest(MethodId.P2wpkh.rawValue) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) - whenever( - onchainPaymentLookup.transactionId( - ONCHAIN_ADDRESS, - request.amountSats, - emptySet(), - WalletScope.default, - ) - ).thenReturn(txid) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedAttempt(request, txid)) repo.reconcile() assertTrue(storedProofs.isEmpty()) @@ -655,7 +691,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `reconcile publishes every onchain resolution`() = test { + fun `reconcile only resolves the request tied to exact transaction evidence`() = test { val secondPaymentRequestId = "550e8400-e29b-41d4-a716-446655440001" val firstRequest = paymentRequest(MethodId.P2wpkh.rawValue) val secondRequest = paymentRequest(MethodId.P2wpkh.rawValue, secondPaymentRequestId) @@ -667,44 +703,34 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) .thenReturn(paymentRequestRecord()) - whenever(onchainPaymentLookup.transactionId(any(), any(), any(), any())) - .thenReturn("ab".repeat(32), "cd".repeat(32)) val repo = paymentProofRepo() repo.prepare(firstRequest, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(firstRequest, ONCHAIN_ADDRESS).getOrThrow() repo.prepare(secondRequest, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(secondRequest, ONCHAIN_ADDRESS).getOrThrow() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedAttempt(firstRequest, "ab".repeat(32))) repo.reconcile() assertEquals( - listOf(PAYMENT_REQUEST_ID, secondPaymentRequestId), + listOf(PAYMENT_REQUEST_ID), repo.onchainPaymentResolutions.value.map { it.requestId.paymentRequestId }, ) + assertNull(storedProofs.single { it.requestId == secondRequest.id }.proofData) } @Test - fun `uncertain onchain payment ignores transaction from before attempt`() = test { - val oldTransactionId = "ab".repeat(32) + fun `uncertain onchain payment does not infer broadcast from destination or amount`() = test { val record = paymentRequestRecord() val request = paymentRequest(MethodId.P2wpkh.rawValue) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) - whenever(onchainPaymentLookup.existingTransactionIds(any(), any(), any())).thenReturn(setOf(oldTransactionId)) - whenever( - onchainPaymentLookup.transactionId( - ONCHAIN_ADDRESS, - request.amountSats, - setOf(oldTransactionId), - WalletScope.default, - ) - ).thenReturn(null) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() repo.reconcile() - assertEquals(setOf(oldTransactionId), storedProofs.single().onchainMatchingTransactionIdsBeforeAttempt) + assertTrue(storedProofs.single().onchainMatchingTransactionIdsBeforeAttempt.isEmpty()) assertNull(storedProofs.single().proofData) verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) } @@ -770,10 +796,25 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ioDispatcher = testDispatcher, paykitSdkService = paykitSdkService, lightningRepo = lightningRepo, - onchainPaymentLookup = onchainPaymentLookup, store = store, ) + private fun acceptedAttempt(request: PaykitPaymentRequest, txid: String) = OnchainSendAttempt( + walletId = WalletScope.default, + attemptId = "attempt-1", + requestId = request.id, + orderId = null, + address = ONCHAIN_ADDRESS, + amountSats = request.amountSats, + isMaxAmount = false, + feeRateSatsPerVByte = 1uL, + isTransfer = false, + channelId = null, + tags = emptyList(), + evidence = OnchainSendEvidence.Accepted, + txid = txid, + ) + private fun paymentRequest( endpoint: String, paymentRequestId: String = PAYMENT_REQUEST_ID, diff --git a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt index 14ecdf1b26..c9e9da6001 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -16,6 +16,8 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import org.lightningdevkit.ldknode.AddressType +import org.lightningdevkit.ldknode.FeeRate +import org.lightningdevkit.ldknode.OnchainSendResult import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.Node import org.lightningdevkit.ldknode.NodeException @@ -39,6 +41,7 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.env.Env import to.bitkit.ext.createChannelDetails import to.bitkit.models.WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX +import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.models.WatchOnlyAccountRecord import to.bitkit.models.WatchOnlyAccountSetupState import to.bitkit.test.BaseUnitTest @@ -48,6 +51,7 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Duration.Companion.milliseconds @@ -64,6 +68,8 @@ class LightningServiceTest : BaseUnitTest() { private val watchOnlyAccountStore = mock() private val loggerLdk = mock() private val node = mock() + private val onchainPayment = mock() + private val sendFeeRate = mock() private val watchOnlyAccountLifecycleCoordinator = WatchOnlyAccountLifecycleCoordinator() @get:Rule @@ -83,6 +89,7 @@ class LightningServiceTest : BaseUnitTest() { loggerLdk = loggerLdk, watchOnlyAccountLifecycleCoordinator = watchOnlyAccountLifecycleCoordinator, ldkQueue = testDispatcher, + onchainFeeRateFactory = { sendFeeRate }, ) sut.node = node } @@ -109,6 +116,49 @@ class LightningServiceTest : BaseUnitTest() { assertTrue(sut.canReceive()) } + @Test + fun `fixed send returns only the generated accepted rejected or unknown outcome`() = test { + val txid = "ab".repeat(32) + whenever(node.onchainPayment()).thenReturn(onchainPayment) + whenever(onchainPayment.sendToAddressWithBroadcastResult("address", 1_000uL, sendFeeRate, null)) + .thenReturn(OnchainSendResult.Accepted(txid)) + .thenReturn(OnchainSendResult.Rejected(txid, "non-final")) + .thenReturn(OnchainSendResult.Unknown(txid)) + + assertEquals(OnchainSendOutcome.Accepted(txid), sut.send("address", 1_000uL, 1uL)) + assertEquals(OnchainSendOutcome.Rejected(txid, "non-final"), sut.send("address", 1_000uL, 1uL)) + assertEquals(OnchainSendOutcome.Unknown(txid), sut.send("address", 1_000uL, 1uL)) + verify(onchainPayment, times(3)).sendToAddressWithBroadcastResult("address", 1_000uL, sendFeeRate, null) + verify(onchainPayment, never()).sendToAddress("address", 1_000uL, sendFeeRate, null) + } + + @Test + fun `send all retains reserves and uses the explicit outcome method once`() = test { + val txid = "ab".repeat(32) + whenever(node.onchainPayment()).thenReturn(onchainPayment) + whenever(onchainPayment.sendAllToAddressWithBroadcastResult("address", true, sendFeeRate)) + .thenReturn(OnchainSendResult.Unknown(txid)) + + assertEquals(OnchainSendOutcome.Unknown(txid), sut.send("address", 1_000uL, 1uL, isMaxAmount = true)) + verify(onchainPayment).sendAllToAddressWithBroadcastResult("address", true, sendFeeRate) + verify(onchainPayment, never()).sendAllToAddress("address", true, sendFeeRate) + } + + @Test + fun `onchain send refuses a different wallet before invoking native payment`() = test { + assertFailsWith { + sut.send("bcrt1qrecipient", 1_000uL, 1uL, walletIndex = 9) + } + verify(node, never()).onchainPayment() + } + + @Test + fun `generated node error stays direct across the service queue`() = test { + assertFailsWith { + sut.callOnchainSend { throw NodeException.InvalidAddress("invalid address") } + } + } + @Test fun `stop destroys the node handle and clears it`() = test { sut.stop() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 75d1942dc4..39e84997d6 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -109,6 +109,7 @@ import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransportType import to.bitkit.models.USD +import to.bitkit.models.WalletScope import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo @@ -120,6 +121,11 @@ import to.bitkit.repositories.HealthRepo import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.IncomingPaykitPaymentRequestFailureReason import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.OnchainSendAttempt +import to.bitkit.repositories.OnchainSendBlockedError +import to.bitkit.repositories.OnchainSendEvidence +import to.bitkit.repositories.OnchainSendNotDispatchedError +import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.LightningState import to.bitkit.repositories.MethodId import to.bitkit.repositories.NodeEventUpdate @@ -6190,6 +6196,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), ) verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) } @@ -6291,7 +6299,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `proof preparation failure does not block incoming onchain payment`() = test { + fun `proof preparation failure blocks incoming onchain payment`() = test { val address = "bcrt1qpaymentrequest" val request = paymentRequest() val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) @@ -6315,9 +6323,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) - verify(paykitPaymentRequestRepo).accept(request) - verify(lightningRepo).sendOnChain( + verify(privatePaykitRepo, never()).consumePrivatePaymentList(testPublicKey, privateContext) + verify(paykitPaymentRequestRepo, never()).accept(request) + verify(lightningRepo, never()).sendOnChain( address = any(), sats = any(), speed = anyOrNull(), @@ -6329,12 +6337,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), ) verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any()) } @Test - fun `proof association failure does not block incoming lightning payment`() = test { + fun `proof association failure blocks incoming lightning payment`() = test { val request = paymentRequest() val bolt11 = "lnbcrt1paymentrequest" val paymentHash = "010203" @@ -6367,7 +6377,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentProofRepo).prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) verify(paykitPaymentProofRepo).associateLightningPayment(request, paymentHash, MethodId.Bolt11.rawValue) verify(paykitPaymentProofRepo).cancelPreparation(request) - verify(lightningRepo).payInvoice(bolt11 = bolt11, sats = null) + verify(lightningRepo, never()).payInvoice(bolt11 = bolt11, sats = null) } @Test @@ -6675,6 +6685,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .doSuspendableAnswer { completionStarted.complete(Unit) finishCompletion.await() + true } setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( @@ -6937,7 +6948,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { stubOnchainSend( address = "bcrt1qpreflightfailure", sats = request.amountSats, - result = Result.failure(IllegalStateException("preflight failed")), + result = Result.failure(OnchainSendNotDispatchedError(IllegalStateException("preflight failed"))), invokeBeforeSendAttempt = false, ) setActiveContactPaymentContext( @@ -6979,7 +6990,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { stubOnchainSend( address = "bcrt1qdefinitefailure", sats = request.amountSats, - result = Result.failure(error), + result = Result.failure(OnchainSendNotDispatchedError(error)), ) setActiveContactPaymentContext( testPublicKey, @@ -7042,9 +7053,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { request.paymentRequestId, request.amountSats.toLong(), observeResolution = false, + isOnchain = true, ) assertEquals( - SendEffect.NavigateToPending(pendingRoute.paymentHash, pendingRoute.amount, false), + SendEffect.NavigateToPending(pendingRoute.paymentHash, pendingRoute.amount, false, isOnchain = true), awaitItem(), ) sut.showSheet(Sheet.Send(pendingRoute)) @@ -7209,6 +7221,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), ) } @@ -7269,6 +7283,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), ) } @@ -7328,6 +7344,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), ) } @@ -7348,6 +7366,81 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() } + @Test + fun `rejected onchain send opens unresolved state without a success transaction`() = test { + val address = "bcrt1qrejected" + val txid = "ab".repeat(32) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + stubOnchainSend(address, 1_000u, Result.success(OnchainSendOutcome.Rejected(txid, "broadcast refused"))) + setSendState(SendUiState(address = address, amount = 1_000u, payMethod = SendMethod.ONCHAIN)) + + sut.sendEffect.test { + confirmCurrentPayment() + assertEquals(SendEffect.NavigateToPending(txid, 1_000, false, isOnchain = true), awaitItem()) + } + assertNull(sut.successSendUiState.value.paymentHashOrTxId) + } + + @Test + fun `unknown onchain send opens unresolved state without a success transaction`() = test { + val address = "bcrt1qunknown" + val txid = "cd".repeat(32) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + stubOnchainSend(address, 1_000u, Result.success(OnchainSendOutcome.Unknown(txid))) + setSendState(SendUiState(address = address, amount = 1_000u, payMethod = SendMethod.ONCHAIN)) + + sut.sendEffect.test { + confirmCurrentPayment() + assertEquals(SendEffect.NavigateToPending(txid, 1_000, false, isOnchain = true), awaitItem()) + } + assertNull(sut.successSendUiState.value.paymentHashOrTxId) + } + + @Test + fun `generic outer error after ordinary send remains unresolved`() = test { + val address = "bcrt1qoutererror" + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + stubOnchainSend(address, 1_000u, Result.failure(IllegalStateException("outcome unknown"))) + setSendState(SendUiState(address = address, amount = 1_000u, payMethod = SendMethod.ONCHAIN)) + + sut.sendEffect.test { + confirmCurrentPayment() + assertEquals(SendEffect.NavigateToPending("", 1_000, false, isOnchain = true), awaitItem()) + } + assertNull(sut.successSendUiState.value.paymentHashOrTxId) + } + + @Test + fun `reopened accepted ordinary send shows its recorded transaction instead of sending again`() = test { + val address = "bcrt1qreopened" + val txid = "ef".repeat(32) + val previous = OnchainSendAttempt( + walletId = WalletScope.default, + attemptId = "attempt-1", + requestId = null, + orderId = null, + address = address, + amountSats = 1_000uL, + isMaxAmount = false, + feeRateSatsPerVByte = 1uL, + isTransfer = false, + channelId = null, + tags = emptyList(), + evidence = OnchainSendEvidence.Accepted, + txid = txid, + ) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + stubOnchainSend(address, 1_000u, Result.failure(OnchainSendBlockedError(previous))) + setSendState(SendUiState(address = address, amount = 1_000u, payMethod = SendMethod.ONCHAIN)) + + sut.sendEffect.test { + confirmCurrentPayment() + assertEquals(SendEffect.PaymentSuccess, awaitItem()) + } + assertEquals(txid, sut.successSendUiState.value.paymentHashOrTxId) + verify(lightningRepo).completeAcceptedOrdinaryFollowup(txid) + } + @Test fun `private lightning contact payment consumes private list before send`() = test { val bolt11 = "lnbcrt1privatecontact" @@ -7864,13 +7957,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } private suspend fun stubSuccessfulOnchainSend(address: String, sats: ULong, txId: String = "txid") { - stubOnchainSend(address, sats, Result.success(txId), broadcastTxId = txId) + stubOnchainSend(address, sats, Result.success(OnchainSendOutcome.Accepted(txId)), broadcastTxId = txId) } private suspend fun stubOnchainSend( address: String, sats: ULong, - result: Result, + result: Result, invokeBeforeSendAttempt: Boolean = true, broadcastTxId: String? = null, ) { @@ -7887,6 +7980,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), ) }.doSuspendableAnswer { invocation -> kotlin.check(invocation.getArgument(0) == address) diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 8bbaccbb96..8bb3858510 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -56,6 +56,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.R +import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore @@ -88,6 +89,9 @@ import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.TransferRepo +import to.bitkit.repositories.OnchainSendOutcome +import to.bitkit.repositories.OnchainSendAttempt +import to.bitkit.repositories.OnchainSendEvidence import to.bitkit.repositories.WalletRepo import to.bitkit.services.BoltzService import to.bitkit.test.BaseUnitTest @@ -139,6 +143,14 @@ class TransferViewModelTest : BaseUnitTest() { whenever(context.getString(any())).thenReturn("") whenever(walletRepo.getOnchainAddress()).thenReturn(WALLET_ADDRESS) whenever(settingsStore.data).thenReturn(MutableStateFlow(SettingsData())) + whenever(cacheStore.data).thenReturn(MutableStateFlow(AppCacheData())) + whenever { transferRepo.findLspOrderIdByFundingTxId(any()) }.thenReturn(Result.success(null)) + whenever { + transferRepo.createTransfer( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + ) + } + .thenReturn(Result.success("transfer-id")) whenever { hwWalletRepo.needsPassphrase(any()) }.thenReturn(false) val nodeStatus = mock() whenever(nodeStatus.isRunning).thenReturn(true) @@ -1144,6 +1156,8 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), ) verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) verify(blocktankRepo, times(1)).createOrder(eq(order.clientBalanceSat), eq(order.lspBalanceSat), any()) @@ -1182,6 +1196,8 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), ) verify(lightningRepo, never()).sendOnChain( address = any(), @@ -1195,6 +1211,8 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), ) verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) } @@ -1225,6 +1243,8 @@ class TransferViewModelTest : BaseUnitTest() { any(), any(), any(), + anyOrNull(), + anyOrNull(), ), ).thenReturn(Result.failure(AppError("Coin selection failed"))) @@ -1247,6 +1267,8 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), ) verify(lightningRepo, never()).sendOnChain( address = any(), @@ -1260,10 +1282,84 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), ) verify(cacheStore, never()).addPaidOrder(any(), any()) } + @Test + fun `accepted transfer resumes after transfer storage failure without another send`() = test { + val order = spendingOrder(feeSat = 98_000uL) + val attempt = OnchainSendAttempt( + walletId = "wallet", attemptId = "attempt", requestId = null, orderId = order.id, + address = order.payment?.onchain?.address.orEmpty(), amountSats = order.feeSat, + isMaxAmount = false, feeRateSatsPerVByte = 1uL, isTransfer = true, + channelId = null, tags = emptyList(), evidence = OnchainSendEvidence.Accepted, txid = TXID, + ) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + whenever(transferRepo.findLspOrderIdByFundingTxId(TXID)).thenReturn(Result.success(null)) + whenever(transferRepo.createTransfer( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + )).thenReturn(Result.failure(AppError("transfer storage unavailable"))) + quoteOrder(order) + prepareConfirm() + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + verify(lightningRepo, never()).completeAcceptedTransferFollowup(any(), any()) + + whenever(transferRepo.findLspOrderIdByFundingTxId(TXID)).thenReturn(Result.success(order.id)) + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + verify(lightningRepo).completeAcceptedTransferFollowup(order.id, TXID) + verify(lightningRepo, never()).sendOnChain( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), anyOrNull(), any(), any(), any(), any(), + anyOrNull(), anyOrNull(), + ) + verify(transferRepo, times(1)).createTransfer( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + ) + } + + @Test + fun `unknown transfer funding does not mark order paid`() = test { + val order = spendingOrder(feeSat = 98_000uL) + stubSpendableBalances(spendable = 100_000u) + whenever(lightningRepo.estimateSendAllFee(any(), any(), anyOrNull())) + .thenReturn(Result.success(1_000uL)) + whenever { lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) } + .thenReturn(Result.success(listOf(stubUtxo(100_000u)))) + whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(1_000uL)) + whenever( + lightningRepo.sendOnChain( + address = any(), + sats = any(), + speed = any(), + utxosToSpend = anyOrNull(), + feeRates = anyOrNull(), + isTransfer = any(), + channelId = anyOrNull(), + isMaxAmount = any(), + tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + ), + ).thenReturn(Result.success(OnchainSendOutcome.Unknown(TXID))) + quoteOrder(order) + + prepareConfirm() + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + + verify(cacheStore, never()).addPaidOrder(any(), any()) + verify(transferRepo, never()).createTransfer( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + ) + } + @Test fun `confirmation blocks replacing or clearing the transfer while creating its order`() = test { val order = spendingOrder(feeSat = 98_000uL) @@ -1316,8 +1412,13 @@ class TransferViewModelTest : BaseUnitTest() { any(), any(), any(), + anyOrNull(), + anyOrNull(), ), - ).thenReturn(Result.failure(AppError("Coin selection failed")), Result.success(TXID)) + ).thenReturn( + Result.failure(AppError("Coin selection failed")), + Result.success(OnchainSendOutcome.Accepted(TXID)), + ) quoteOrder(order) prepareConfirm() @@ -1573,6 +1674,8 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), ) } @@ -1754,6 +1857,8 @@ class TransferViewModelTest : BaseUnitTest() { any(), any(), any(), + anyOrNull(), + anyOrNull(), ) verify(cacheStore, never()).addPaidOrder(any(), any()) } @@ -1780,6 +1885,8 @@ class TransferViewModelTest : BaseUnitTest() { any(), any(), any(), + anyOrNull(), + anyOrNull(), ), ).thenReturn(Result.failure(AppError("Coin selection failed"))) quoteOrder(order) @@ -1926,6 +2033,8 @@ class TransferViewModelTest : BaseUnitTest() { any(), any(), any(), + anyOrNull(), + anyOrNull(), ) verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) } @@ -3167,6 +3276,8 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), ) } @@ -3315,8 +3426,10 @@ class TransferViewModelTest : BaseUnitTest() { any(), any(), any(), + anyOrNull(), + anyOrNull(), ), - ).thenReturn(Result.success(TXID)) + ).thenReturn(Result.success(OnchainSendOutcome.Accepted(TXID))) } private companion object { diff --git a/changelog.d/next/1211.fixed.md b/changelog.d/next/1211.fixed.md new file mode 100644 index 0000000000..76d1bcc1be --- /dev/null +++ b/changelog.d/next/1211.fixed.md @@ -0,0 +1 @@ +On-chain sends now show when a transaction was not accepted or its broadcast outcome is uncertain, and prevent another send while the outcome needs checking. diff --git a/journeys/send/onchain-accepted-result.xml b/journeys/send/onchain-accepted-result.xml new file mode 100644 index 0000000000..0733e6a4f4 --- /dev/null +++ b/journeys/send/onchain-accepted-result.xml @@ -0,0 +1,24 @@ + + + Verifies that fixed-amount and send-all sends show success only after the configured backend + acknowledges the transaction, and that an accepted send finishes its local activity before a new + send is admitted. Precondition: an onboarded dev wallet with confirmed savings funds sufficient + for both sends and fees, a running synced node, and a second regtest wallet's savings address. + The receiving wallet and backend must be test fixtures. + Rejected or missing-result cases need a controlled broadcast-refusal/response-loss fixture, + which the Capabilities table does not provide, and belong in Manual Tests. + + + Hand the receiving test wallet address to the app with adb shell am start -a android.intent.action.VIEW -d "bitcoin:<test recipient address>?amount=0.00001" to.bitkit.dev + Verify the Send Amount screen is visible with 1 000 sats and Savings selected + Tap Continue (tag "ContinueAmount") and verify the Confirm screen shows the receiving test wallet address (tag "ReviewUri") + Swipe the send handle (tag "GRAB") fully to the right + Wait for the sent transaction sheet (tag "new_transaction_sheet") and verify it shows the accepted payment amount + Tap Details (tag "Details") and verify the outbound transaction has a transaction ID; record that exact ID without inferring a different transaction from address or amount + Close the transaction details and return to the wallet home screen + Hand the receiving test wallet address to the app again with adb shell am start -a android.intent.action.VIEW -d "bitcoin:<test recipient address>" to.bitkit.dev + Choose Max on Send Amount, tap Continue (tag "ContinueAmount"), and verify the Confirm screen shows the receiving test wallet address + Swipe the send handle (tag "GRAB") fully to the right + Wait for the sent transaction sheet (tag "new_transaction_sheet"), tap Details (tag "Details"), and verify this send-all transaction has its own exact transaction ID + + From 00a2e529ce2216d465509dd85b1ec16cab4a4b09 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 02:54:41 +0200 Subject: [PATCH 003/108] fix: use verified broadcast outcome bindings --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c3a4f08a16..bc630bebd6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -65,7 +65,7 @@ ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "k ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.66" } +ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.67" } lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } From b7740c76d644788be60c5a9ebfd7770d1434bdf2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 02:56:12 +0200 Subject: [PATCH 004/108] test: make accepted send checks reproducible --- journeys/send/onchain-accepted-result.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/journeys/send/onchain-accepted-result.xml b/journeys/send/onchain-accepted-result.xml index 0733e6a4f4..60b88719af 100644 --- a/journeys/send/onchain-accepted-result.xml +++ b/journeys/send/onchain-accepted-result.xml @@ -11,14 +11,14 @@ Hand the receiving test wallet address to the app with adb shell am start -a android.intent.action.VIEW -d "bitcoin:<test recipient address>?amount=0.00001" to.bitkit.dev Verify the Send Amount screen is visible with 1 000 sats and Savings selected - Tap Continue (tag "ContinueAmount") and verify the Confirm screen shows the receiving test wallet address (tag "ReviewUri") + Tap Continue (tag "ContinueAmount"), expand Show Details (tag "SendConfirmToggleDetails") if collapsed, and verify the Confirm screen shows the receiving test wallet address (tag "ReviewUri") Swipe the send handle (tag "GRAB") fully to the right Wait for the sent transaction sheet (tag "new_transaction_sheet") and verify it shows the accepted payment amount - Tap Details (tag "Details") and verify the outbound transaction has a transaction ID; record that exact ID without inferring a different transaction from address or amount + Tap Details (tag "Details"), then Explore (tag "ActivityTxDetails"), and verify the outbound transaction shows a transaction ID (tag "TXID"); record that exact ID without inferring a different transaction from address or amount Close the transaction details and return to the wallet home screen Hand the receiving test wallet address to the app again with adb shell am start -a android.intent.action.VIEW -d "bitcoin:<test recipient address>" to.bitkit.dev - Choose Max on Send Amount, tap Continue (tag "ContinueAmount"), and verify the Confirm screen shows the receiving test wallet address + Choose Max on Send Amount, tap Continue (tag "ContinueAmount"), expand Show Details (tag "SendConfirmToggleDetails") if collapsed, and verify the Confirm screen shows the receiving test wallet address (tag "ReviewUri") Swipe the send handle (tag "GRAB") fully to the right - Wait for the sent transaction sheet (tag "new_transaction_sheet"), tap Details (tag "Details"), and verify this send-all transaction has its own exact transaction ID + Wait for the sent transaction sheet (tag "new_transaction_sheet"), tap Details (tag "Details"), then Explore (tag "ActivityTxDetails"), and verify this send-all transaction shows its own exact transaction ID (tag "TXID") From 5bafcf2e924b0dfb0ed51a0d626e7f7249bc059b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 02:59:05 +0200 Subject: [PATCH 005/108] chore: link release notes to the pull request --- changelog.d/next/{1211.fixed.md => 1384.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{1211.fixed.md => 1384.fixed.md} (100%) diff --git a/changelog.d/next/1211.fixed.md b/changelog.d/next/1384.fixed.md similarity index 100% rename from changelog.d/next/1211.fixed.md rename to changelog.d/next/1384.fixed.md From 904264c266b3a1df2ec0891b38ba81e9b963cf09 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 03:16:25 +0200 Subject: [PATCH 006/108] docs: clarify on-chain max confirmation --- journeys/send/onchain-accepted-result.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/journeys/send/onchain-accepted-result.xml b/journeys/send/onchain-accepted-result.xml index 60b88719af..7507cdfe84 100644 --- a/journeys/send/onchain-accepted-result.xml +++ b/journeys/send/onchain-accepted-result.xml @@ -17,8 +17,8 @@ Tap Details (tag "Details"), then Explore (tag "ActivityTxDetails"), and verify the outbound transaction shows a transaction ID (tag "TXID"); record that exact ID without inferring a different transaction from address or amount Close the transaction details and return to the wallet home screen Hand the receiving test wallet address to the app again with adb shell am start -a android.intent.action.VIEW -d "bitcoin:<test recipient address>" to.bitkit.dev - Choose Max on Send Amount, tap Continue (tag "ContinueAmount"), expand Show Details (tag "SendConfirmToggleDetails") if collapsed, and verify the Confirm screen shows the receiving test wallet address (tag "ReviewUri") - Swipe the send handle (tag "GRAB") fully to the right + Tap the available balance (tag "AvailableAmount") on Send Amount to choose Max, tap Continue (tag "ContinueAmount"), expand Show Details (tag "SendConfirmToggleDetails") if collapsed, and verify the Confirm screen shows the receiving test wallet address (tag "ReviewUri") + Swipe the send handle (tag "GRAB") fully to the right, then tap Yes, Send (tag "DialogConfirm") if the over-50%-of-balance confirmation appears Wait for the sent transaction sheet (tag "new_transaction_sheet"), tap Details (tag "Details"), then Explore (tag "ActivityTxDetails"), and verify this send-all transaction shows its own exact transaction ID (tag "TXID") From 6966111bc70e9bb089ebfcdb32a5ea2fd8b194d1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 04:09:53 +0200 Subject: [PATCH 007/108] fix: preserve original on-chain follow-up context --- .../wallets/send/SendPendingScreenTest.kt | 3 +- .../bitkit/models/PaykitPaymentStateBackup.kt | 3 + .../to/bitkit/repositories/LightningRepo.kt | 3 + .../repositories/OnchainSendAttemptStore.kt | 46 ++++++- .../repositories/PaykitPaymentProofRepo.kt | 36 ++++- .../repositories/PaykitPaymentProofStore.kt | 1 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 22 ++- .../to/bitkit/viewmodels/TransferViewModel.kt | 31 ++++- .../models/PaykitPaymentStateBackupTest.kt | 8 ++ .../bitkit/repositories/LightningRepoTest.kt | 89 +++++++++++- .../OnchainSendAttemptStoreTest.kt | 49 +++++++ .../PaykitPaymentProofRepoTest.kt | 128 +++++++++++++++++- .../PaykitPaymentProofStoreTest.kt | 26 ++++ .../viewmodels/AppViewModelSendFlowTest.kt | 34 +++-- .../viewmodels/TransferViewModelTest.kt | 75 ++++++++++ 15 files changed, 507 insertions(+), 47 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingScreenTest.kt index 7ff0bab001..80ab281034 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingScreenTest.kt @@ -25,7 +25,8 @@ class SendPendingScreenTest { val composeTestRule = createComposeRule() @Test - fun unresolvedOnchainSendStaysPendingAfterCloseAndReopen() { + fun unresolvedOnchainContentStaysPendingWhenVisibilityIsToggled() { + // Component rendering only; this fixture does not reload the durable attempt or attempt another send. var visible by mutableStateOf(true) var closeCount = 0 composeTestRule.setContent { diff --git a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt index f5301f8419..8735ffa79d 100644 --- a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt +++ b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt @@ -50,6 +50,7 @@ data class PaykitPaymentStateBackup( val onchainAmountSats: ULong? = null, val onchainWalletId: String? = null, val onchainMatchingTransactionIdsBeforeAttempt: Set, + val onchainAcceptanceVerified: Boolean = false, ) { constructor(proof: PendingPaykitPaymentProof) : this( identity = proof.identity, @@ -64,6 +65,7 @@ data class PaykitPaymentStateBackup( onchainAmountSats = proof.onchainAmountSats, onchainWalletId = proof.onchainWalletId, onchainMatchingTransactionIdsBeforeAttempt = proof.onchainMatchingTransactionIdsBeforeAttempt, + onchainAcceptanceVerified = proof.onchainAcceptanceVerified, ) fun restored() = PendingPaykitPaymentProof( @@ -79,6 +81,7 @@ data class PaykitPaymentStateBackup( onchainAmountSats = onchainAmountSats, onchainWalletId = onchainWalletId ?: WalletScope.default, onchainMatchingTransactionIdsBeforeAttempt = onchainMatchingTransactionIdsBeforeAttempt, + onchainAcceptanceVerified = onchainAcceptanceVerified, ) } } diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 46d241dbfd..5ac7cf0d57 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -569,6 +569,7 @@ class LightningRepo @Inject constructor( !attempt.isTransfer && attempt.requestId == null ) { finishOnchainSendLocally(attempt) + onchainSendAttemptStore.markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) } } .onFailure { Logger.warn("Failed to record exact on-chain transaction observation", it, context = TAG) } @@ -1479,6 +1480,7 @@ class LightningRepo @Inject constructor( onBroadcast: suspend (Txid) -> Unit = {}, requestId: PaykitPaymentRequestId? = null, orderId: String? = null, + transferContext: OnchainTransferContext? = null, ): Result = executeWhenNodeRunning("sendOnChain") { require(address.isNotEmpty()) { "Send address cannot be empty" } @@ -1515,6 +1517,7 @@ class LightningRepo @Inject constructor( isTransfer = isTransfer, channelId = channelId, tags = tags, + transferContext = transferContext, beforeSendAttempt = beforeSendAttempt, ) }.getOrElse { diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index d8d44d792e..f53af889e4 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -34,6 +34,12 @@ enum class OnchainSendEvidence { Observed, } +@Serializable +data class OnchainTransferContext( + val txTotalSats: ULong, + val preTransferOnchainSats: ULong, +) + @Serializable @Suppress("LongParameterList") data class OnchainSendAttempt( @@ -53,6 +59,7 @@ data class OnchainSendAttempt( val refusalReason: String? = null, val localFollowupComplete: Boolean = false, val walletIndex: Int = 0, + val transferContext: OnchainTransferContext? = null, ) { val isUnresolved: Boolean get() = evidence == OnchainSendEvidence.Pending || @@ -87,9 +94,11 @@ class OnchainSendAttemptStore @Inject constructor( } private val mutex = Mutex() + // One known result per existing wallet guard; entries disappear after a successful durable write. + private val retainedAccepted = mutableMapOf() suspend fun current(): OnchainSendAttempt? = withContext(ioDispatcher) { - mutex.withLock { load(lightningService.currentWalletIndex) } + mutex.withLock { loadWithRetainedAccepted(lightningService.currentWalletIndex) } } @Suppress("LongParameterList") @@ -104,6 +113,7 @@ class OnchainSendAttemptStore @Inject constructor( isTransfer: Boolean, channelId: String?, tags: List, + transferContext: OnchainTransferContext? = null, beforeSendAttempt: suspend () -> Unit, ): OnchainSendAttempt = withContext(ioDispatcher) { mutex.withLock { @@ -116,7 +126,7 @@ class OnchainSendAttemptStore @Inject constructor( (orderId != null && previous.orderId == orderId) ) ) { - throw OnchainSendBlockedError(previous) + throw OnchainSendBlockedError(loadWithRetainedAccepted(walletIndex) ?: previous) } beforeSendAttempt() val attempt = OnchainSendAttempt( @@ -132,6 +142,7 @@ class OnchainSendAttemptStore @Inject constructor( channelId = channelId, tags = tags, walletIndex = walletIndex, + transferContext = transferContext, ) persist(attempt) attempt @@ -148,17 +159,21 @@ class OnchainSendAttemptStore @Inject constructor( is OnchainSendOutcome.Rejected -> OnchainSendEvidence.Rejected is OnchainSendOutcome.Unknown -> OnchainSendEvidence.Unknown } - current.copy( + val recorded = current.copy( evidence = evidence, txid = outcome.txid, refusalReason = (outcome as? OnchainSendOutcome.Rejected)?.reason, - ).also { persist(it) } + ) + if (outcome is OnchainSendOutcome.Accepted) retainedAccepted[walletIndex] = recorded + persist(recorded) + retainedAccepted.remove(walletIndex) + recorded } } suspend fun releaseBeforeDispatch(attemptId: String, walletIndex: Int) = withContext(ioDispatcher + NonCancellable) { mutex.withLock { - val current = load(walletIndex) + val current = loadWithRetainedAccepted(walletIndex) if (current?.attemptId == attemptId && current.evidence == OnchainSendEvidence.Pending) { keychain.delete(KEY, walletIndex) } @@ -167,9 +182,10 @@ class OnchainSendAttemptStore @Inject constructor( suspend fun markLocalFollowupComplete(attemptId: String, walletIndex: Int) = withContext(ioDispatcher + NonCancellable) { mutex.withLock { - val current = load(walletIndex) + val current = loadWithRetainedAccepted(walletIndex) if (current?.attemptId == attemptId && current.hasPositiveEvidence) { persist(current.copy(localFollowupComplete = true)) + retainedAccepted.remove(walletIndex) } } } @@ -177,7 +193,7 @@ class OnchainSendAttemptStore @Inject constructor( suspend fun observeExactTransaction(txid: String): OnchainSendAttempt? = withContext(ioDispatcher + NonCancellable) { mutex.withLock { - val current = load(lightningService.currentWalletIndex) ?: return@withLock null + val current = loadWithRetainedAccepted(lightningService.currentWalletIndex) ?: return@withLock null if (!current.txid.equals(txid, ignoreCase = true) || !current.isUnresolved) return@withLock null current.copy(evidence = OnchainSendEvidence.Observed).also { persist(it) } } @@ -196,6 +212,22 @@ class OnchainSendAttemptStore @Inject constructor( .getOrElse { throw OnchainSendAttemptUnreadableError(it) } } + private fun loadWithRetainedAccepted(walletIndex: Int): OnchainSendAttempt? { + val persisted = load(walletIndex) + val retained = retainedAccepted[walletIndex] ?: return persisted + if (persisted?.attemptId != retained.attemptId || persisted.walletId != retained.walletId || + (persisted.txid != null && !persisted.txid.equals(retained.txid, ignoreCase = true)) + ) { + retainedAccepted.remove(walletIndex) + return persisted + } + if (persisted.hasPositiveEvidence) { + retainedAccepted.remove(walletIndex) + return persisted + } + return retained + } + private suspend fun persist(attempt: OnchainSendAttempt) { keychain.upsertString(KEY, Json.encodeToString(attempt), attempt.walletIndex) } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index cd7ef1a80b..9d3b4530cc 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -65,6 +65,7 @@ data class PendingPaykitPaymentProof( val onchainAmountSats: ULong? = null, val onchainWalletId: String = WalletScope.default, val onchainMatchingTransactionIdsBeforeAttempt: Set = emptySet(), + val onchainAcceptanceVerified: Boolean = false, ) data class PaykitOnchainPaymentProofResolution( @@ -239,6 +240,7 @@ class PaykitPaymentProofRepo @Inject constructor( request: PaykitPaymentRequest, txid: String, paymentEndpointIdentifier: String, + acceptedOutcome: OnchainSendOutcome.Accepted? = null, ): Boolean = withContext(ioDispatcher) { if (!txid.isHex(HASH_BYTE_COUNT)) { Logger.warn("Ignored a Paykit on-chain proof with an invalid transaction id", context = TAG) @@ -246,6 +248,14 @@ class PaykitPaymentProofRepo @Inject constructor( } val identity = currentIdentity() ?: return@withContext false + if (acceptedOutcome != null && !acceptedOutcome.txid.equals(txid, ignoreCase = true)) return@withContext false + val attempt = if (acceptedOutcome == null) { + runSuspendCatching { lightningRepo.currentOnchainSendAttempt() }.getOrNull() + } else { + null + } + fun hasPositiveEvidence(proof: PendingPaykitPaymentProof): Boolean = acceptedOutcome != null || + (attempt.matchesPositiveShopProof(proof) && attempt?.txid.equals(txid, ignoreCase = true)) val fallbackProof = runSuspendCatching { pendingProof(request, paymentEndpointIdentifier, PaykitPaymentProofKind.Onchain) }.getOrNull() @@ -264,14 +274,17 @@ class PaykitPaymentProofRepo @Inject constructor( proofs[index].copy( paymentIdentifier = txid.lowercase(), proofData = txid.lowercase(), + onchainAcceptanceVerified = true, ) } else { pendingProof(request, paymentEndpointIdentifier, PaykitPaymentProofKind.Onchain).copy( paymentStarted = true, paymentIdentifier = txid.lowercase(), proofData = txid.lowercase(), + onchainAcceptanceVerified = true, ) } + if (!hasPositiveEvidence(proof)) return@runSuspendCatching false if (index >= 0) proofs[index] = proof else proofs += proof val retained = persistAndSubmit(listOf(proof), proofs) if (retained) publishOnchainResolution(proof, txid) @@ -284,11 +297,12 @@ class PaykitPaymentProofRepo @Inject constructor( context = TAG, ) } - if (completion.isFailure && fallbackProof != null) { + if (completion.isFailure && fallbackProof != null && hasPositiveEvidence(fallbackProof)) { val proof = fallbackProof.copy( paymentStarted = true, paymentIdentifier = txid.lowercase(), proofData = txid.lowercase(), + onchainAcceptanceVerified = true, ) val delivered = runSuspendCatching { submitReady(proof) } .onFailure { Logger.warn("Failed to complete a Paykit on-chain payment proof", it, context = TAG) } @@ -431,9 +445,20 @@ class PaykitPaymentProofRepo @Inject constructor( ): Boolean { return when { proof.kind == PaykitPaymentProofKind.Onchain && proof.proofData != null -> { - if (!attempt.matchesPositiveShopProof(proof)) return false + if (!proof.paymentStarted || !proof.proofData.isHex(HASH_BYTE_COUNT) || + !proof.paymentIdentifier.equals(proof.proofData, ignoreCase = true) + ) return false + if (proof.onchainAcceptanceVerified != true) { + if (!attempt.matchesPositiveShopProof(proof)) return false + val proofs = loadProofs().toMutableList() + val index = proofs.indexOf(proof) + if (index < 0) return false + val verified = proof.copy(onchainAcceptanceVerified = true) + proofs[index] = verified + return persistAndSubmit(listOf(verified), proofs) + } submitReady(proof) - true // The proof is already durable even if private delivery remains pending. + attempt.matchesPositiveShopProof(proof) } proof.proofData != null -> { submitReady(proof) @@ -489,7 +514,9 @@ class PaykitPaymentProofRepo @Inject constructor( val proofs = loadProofs().toMutableList() val index = proofs.indexOf(proof) if (index < 0) return false - val completed = proof.copy(paymentIdentifier = txid.lowercase(), proofData = txid.lowercase()) + val completed = proof.copy( + paymentIdentifier = txid.lowercase(), proofData = txid.lowercase(), onchainAcceptanceVerified = true, + ) proofs[index] = completed val retained = persistAndSubmit(listOf(completed), proofs) if (retained) publishOnchainResolution(proof, txid) @@ -543,6 +570,7 @@ class PaykitPaymentProofRepo @Inject constructor( ?.let(PubkyPublicKeyFormat::normalized) private suspend fun submitReady(proof: PendingPaykitPaymentProof): Boolean { + if (proof.kind == PaykitPaymentProofKind.Onchain && proof.onchainAcceptanceVerified != true) return false val proofData = proof.proofData ?: return false val identityStatus = paykitSdkService.identityStatus() if ( diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt index 58cbb92caa..364b8e6d33 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofStore.kt @@ -39,6 +39,7 @@ class PaykitPaymentProofStore @Inject constructor( identity: String, ): Map = load() .filter { PubkyPublicKeyFormat.matches(it.identity, identity) && it.proofData != null } + .filter { it.kind != PaykitPaymentProofKind.Onchain || it.onchainAcceptanceVerified } .associate { it.requestId to it.kind } fun inFlightRequestIds(identity: String): Set = load() diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 828a9fdde3..926466027c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4102,15 +4102,12 @@ class AppViewModel @Inject constructor( onchainPaymentStarted = true } }, - onBroadcast = { txId -> - proofRequest = null - completeOnchainPaymentProofInBackground(incomingPaymentRequest, txId) - }, ).onSuccess { outcome -> proofRequest = null when (outcome) { is OnchainSendOutcome.Accepted -> { Logger.info("Accepted on-chain send '${outcome.txid}'", context = TAG) + completeOnchainPaymentProofInBackground(incomingPaymentRequest, outcome.txid, outcome) onSendSuccess( NewTransactionSheetDetails( type = NewTransactionSheetType.ONCHAIN, @@ -4167,7 +4164,7 @@ class AppViewModel @Inject constructor( val previous = (error as? OnchainSendBlockedError)?.attempt val priorAccepted = previous?.takeIf { it.hasPositiveEvidence && it.txid != null && !it.isTransfer && - it.requestId == incomingPaymentRequest?.id + incomingPaymentRequest != null && it.requestId == incomingPaymentRequest.id } if (priorAccepted != null) { val txid = requireNotNull(priorAccepted.txid) @@ -4180,9 +4177,15 @@ class AppViewModel @Inject constructor( isLoadingDetails = false, ) ) - if (priorAccepted.requestId == null) lightningRepo.completeAcceptedOrdinaryFollowup(txid) return } + if (previous != null && previous.hasPositiveEvidence && previous.txid != null && + !previous.isTransfer && previous.requestId == null + ) { + // Finish the earlier payment, but this blocked confirmation did not send the new payment. + runSuspendCatching { lightningRepo.completeAcceptedOrdinaryFollowup(previous.txid) } + .onFailure { Logger.warn("Failed to finish earlier ordinary send locally", it, context = TAG) } + } previous?.refusalReason?.let { toast( type = Toast.ToastType.ERROR, @@ -4375,7 +4378,11 @@ class AppViewModel @Inject constructor( } ?: Result.success(Unit) - private fun completeOnchainPaymentProofInBackground(request: PaykitPaymentRequest?, txId: String) { + private fun completeOnchainPaymentProofInBackground( + request: PaykitPaymentRequest?, + txId: String, + acceptedOutcome: OnchainSendOutcome.Accepted? = null, + ) { val paymentRequest = request ?: return val endpointIdentifier = paymentProofPreparation().endpointIdentifier viewModelScope.launch { @@ -4383,6 +4390,7 @@ class AppViewModel @Inject constructor( request = paymentRequest, txid = txId, paymentEndpointIdentifier = endpointIdentifier, + acceptedOutcome = acceptedOutcome, ) if (paymentRequest.billingPeriod != null) refreshIncomingPaykitPaymentRequests() } diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 48c2a585c6..0018b7f278 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -71,6 +71,7 @@ import to.bitkit.repositories.HwPassphraseRequiredError import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.OnchainSendOutcome +import to.bitkit.repositories.OnchainTransferContext import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.BoltzService @@ -377,7 +378,13 @@ class TransferViewModel @Inject constructor( if (previous.hasPositiveEvidence && txid != null) { if (!previous.localFollowupComplete) { withContext(NonCancellable) { - fundPaidOrder(order = order, txId = txid, requireTransferPersisted = true) + fundPaidOrder( + order = order, + txId = txid, + txTotalSats = previous.transferContext?.txTotalSats, + preTransferOnchainSats = previous.transferContext?.preTransferOnchainSats, + requireTransferPersisted = true, + ) lightningRepo.completeAcceptedTransferFollowup(order.id, txid) } } @@ -401,6 +408,14 @@ class TransferViewModel @Inject constructor( if (holdForFeesChange(order, shown, plan) || isSendAllBelowOrderFee(order, shown, plan)) return false val address = order.payment?.onchain?.address.orEmpty() + val transferContext = OnchainTransferContext( + txTotalSats = if (plan.shouldUseSendAll) { + plan.spendableBalance + } else { + order.feeSat.safe() + plan.miningFeeSats.safe() + }, + preTransferOnchainSats = plan.totalOnchainBalance, + ) return lightningRepo .sendOnChain( address = address, @@ -412,6 +427,7 @@ class TransferViewModel @Inject constructor( channelId = order.channel?.shortChannelId, isMaxAmount = plan.shouldUseSendAll, orderId = order.id, + transferContext = transferContext, ) .fold( onSuccess = { outcome -> @@ -426,12 +442,8 @@ class TransferViewModel @Inject constructor( fundPaidOrder( order = order, txId = outcome.txid, - txTotalSats = if (plan.shouldUseSendAll) { - plan.spendableBalance - } else { - order.feeSat.safe() + plan.miningFeeSats.safe() - }, - preTransferOnchainSats = plan.totalOnchainBalance, + txTotalSats = transferContext.txTotalSats, + preTransferOnchainSats = transferContext.preTransferOnchainSats, requireTransferPersisted = true, ) lightningRepo.completeAcceptedTransferFollowup(order.id, outcome.txid) @@ -676,6 +688,11 @@ class TransferViewModel @Inject constructor( throw AppError("Funding transaction is already assigned to another order") } if (existingOrderId == null) { + if (requireTransferPersisted) { + check(txTotalSats != null && preTransferOnchainSats != null) { + "Accepted transfer is missing its original balance context" + } + } val transfer = transferRepo.createTransfer( type = TransferType.TO_SPENDING, amountSats = order.clientBalanceSat.toLong(), diff --git a/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt index 912a5174ac..6e6ddda290 100644 --- a/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt @@ -9,6 +9,7 @@ import to.bitkit.repositories.PaykitPaymentProofKind import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -23,6 +24,7 @@ class PaykitPaymentStateBackupTest { val backup = Json.decodeFromString(fixture) val restored = backup.pendingProofs.single().restored() assertTrue(restored.paymentStarted) + assertFalse(restored.onchainAcceptanceVerified) assertEquals(PaykitPaymentProofKind.Onchain, restored.kind) assertEquals( Instant.parse(requireNotNull(restored.requestId.billingPeriodStartsAt)), @@ -41,6 +43,12 @@ class PaykitPaymentStateBackupTest { assertEquals(restored, decoded.pendingProofs.single().restored()) assertEquals(backup.subscriptions, decoded.subscriptions) + val verifiedProof = restored.copy(onchainAcceptanceVerified = true) + val verifiedBackup = PaykitPaymentStateBackup.Proof(verifiedProof) + val verifiedJson = Json.encodeToString(verifiedBackup) + assertContains(verifiedJson, "\"onchainAcceptanceVerified\":true") + assertTrue(Json.decodeFromString(verifiedJson).restored().onchainAcceptanceVerified) + val hardwareProof = restored.copy(onchainWalletId = "hardware-wallet") val hardwareBackup = PaykitPaymentStateBackup.Proof(hardwareProof) val hardwareJson = Json.encodeToString(hardwareBackup) diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index ef55e8aa60..ce99fddb99 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -1417,7 +1417,7 @@ class LightningRepoTest : BaseUnitTest() { ) whenever { onchainSendAttemptStore.admit( - any(), anyOrNull(), anyOrNull(), any(), any(), any(), any(), any(), anyOrNull(), any(), any(), + any(), anyOrNull(), anyOrNull(), any(), any(), any(), any(), any(), anyOrNull(), any(), anyOrNull(), any(), ) } .thenReturn(attempt) @@ -1511,7 +1511,7 @@ class LightningRepoTest : BaseUnitTest() { private suspend fun prepareGuardedSend(attempt: OnchainSendAttempt): LightningRepo { whenever(settingsStore.data).thenReturn(flowOf(SettingsData(coinSelectAuto = false))) whenever(onchainSendAttemptStore.admit( - any(), anyOrNull(), anyOrNull(), any(), any(), any(), any(), any(), anyOrNull(), any(), any(), + any(), anyOrNull(), anyOrNull(), any(), any(), any(), any(), any(), anyOrNull(), any(), anyOrNull(), any(), )).thenReturn(attempt) startNodeForTesting() return spy(sut).also { doReturn(Result.success(1uL)).whenever(it).getFeeRateForSpeed(any(), anyOrNull()) } @@ -1548,6 +1548,91 @@ class LightningRepoTest : BaseUnitTest() { verify(onchainSendAttemptStore).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) } + @Test + fun `transient accepted write failure repairs original guard without another native send`() = test { + val txid = "ab".repeat(32) + val key = Keychain.Key.ONCHAIN_SEND_ATTEMPT.name + var saved: String? = null + var writes = 0 + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { + writes++ + if (writes == 2) error("transient accepted write failure") + saved = it.getArgument(1) + } + val store = OnchainSendAttemptStore(testDispatcher, keychain, lightningService) + sut = LightningRepo( + bgDispatcher = testDispatcher, lightningService = lightningService, settingsStore = settingsStore, + coreService = coreService, lspNotificationsService = lspNotificationsService, + firebaseMessaging = firebaseMessaging, keychain = keychain, lnurlService = lnurlService, + cacheStore = cacheStore, preActivityMetadataRepo = preActivityMetadataRepo, + onchainSendAttemptStore = store, connectivityRepo = connectivityRepo, + vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, electrumProbeService = electrumProbeService, + ) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(coinSelectAuto = false))) + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + whenever(activityService.getOnchainActivityByTxId(eq(txid), any())).thenReturn(mock()) + whenever(lightningService.send(any(), any(), any(), anyOrNull(), any(), any())) + .thenReturn(OnchainSendOutcome.Accepted(txid)) + startNodeForTesting() + val repo = spy(sut).also { doReturn(Result.success(1uL)).whenever(it).getFeeRateForSpeed(any(), anyOrNull()) } + + assertEquals(OnchainSendOutcome.Accepted(txid), repo.sendOnChain("address", 1_000uL).getOrThrow()) + assertEquals(txid, repo.currentOnchainSendAttempt()?.txid) + val blocked = assertIs(repo.sendOnChain("different-address", 2_000uL).exceptionOrNull()) + assertEquals(txid, blocked.attempt?.txid) + repo.completeAcceptedOrdinaryFollowup(txid) + + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, lightningService).current() + assertEquals(txid, reopened?.txid) + assertEquals(OnchainSendEvidence.Accepted, reopened?.evidence) + assertTrue(reopened?.localFollowupComplete == true) + verify(lightningService, times(1)).send(any(), any(), any(), anyOrNull(), any(), any()) + } + + @Test + fun `transfer send admits original balance context before native dispatch`() = test { + val context = OnchainTransferContext(txTotalSats = 99_000uL, preTransferOnchainSats = 125_000uL) + val attempt = pendingSendAttempt().copy(isTransfer = true, orderId = "order-1", transferContext = context) + val repo = prepareGuardedSend(attempt) + whenever(lightningService.send(any(), any(), any(), anyOrNull(), any(), any())) + .doSuspendableAnswer { + verify(onchainSendAttemptStore).admit( + any(), isNull(), eq("order-1"), any(), any(), any(), any(), eq(true), anyOrNull(), any(), + eq(context), any(), + ) + OnchainSendOutcome.Unknown("ab".repeat(32)) + } + whenever(onchainSendAttemptStore.recordOutcome(any(), any(), any())) + .thenReturn(attempt.copy(evidence = OnchainSendEvidence.Unknown, txid = "ab".repeat(32))) + + assertTrue(repo.sendOnChain( + "address", 98_000uL, isTransfer = true, orderId = "order-1", transferContext = context, + ).getOrThrow() is OnchainSendOutcome.Unknown) + } + + @Test + fun `observed ordinary send completes guard only after durable local activity`() = test { + val txid = "ab".repeat(32) + val attempt = pendingSendAttempt().copy(evidence = OnchainSendEvidence.Observed, txid = txid) + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + whenever(onchainSendAttemptStore.observeExactTransaction(txid)).thenReturn(attempt).thenReturn(null) + whenever(onchainSendAttemptStore.current()).thenReturn(attempt) + val eventHandler = startNodeAndCaptureEvents() + val event = Event.OnchainTransactionReceived(txid = txid, details = mock()) + + eventHandler(event) + verify(onchainSendAttemptStore, never()).markLocalFollowupComplete(any(), any()) + + whenever(activityService.getOnchainActivityByTxId(txid, attempt.walletId)).thenReturn(mock()) + eventHandler(event) + verify(onchainSendAttemptStore).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + } + @Test fun `registerForNotifications should fail when node is not running`() = test { val result = sut.registerForNotifications() diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index 2ccc093fbe..2c8a619e47 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -46,6 +46,28 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { assertTrue(reopened.admitForTest().attemptId != attempt.attemptId) } + @Test + fun `transfer balance context is durable before dispatch and survives reopen`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val context = OnchainTransferContext(txTotalSats = 99_000uL, preTransferOnchainSats = 125_000uL) + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock()) + val attempt = store.admit( + walletId = "wallet-1", requestId = null, orderId = "order-1", address = "bcrt1qfunding", + amountSats = 98_000uL, isMaxAmount = false, feeRateSatsPerVByte = 1uL, isTransfer = true, + channelId = null, tags = emptyList(), transferContext = context, beforeSendAttempt = {}, + ) + + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, mock()) + assertEquals(context, reopened.current()?.transferContext) + assertEquals(OnchainSendEvidence.Pending, reopened.current()?.evidence) + assertFailsWith { reopened.admitForTest() } + store.recordOutcome(attempt.attemptId, OnchainSendOutcome.Accepted("ab".repeat(32)), attempt.walletIndex) + assertEquals(context, reopened.current()?.transferContext) + } + @Test fun `corrupt attempt refuses a new send without replacing evidence`() = test { val keychain = mock() @@ -78,6 +100,33 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { assertFailsWith { store.admitForTest() } } + @Test + fun `known accepted survives write failures in memory while reopened pending stays blocked`() = test { + var saved: String? = null + var failWrite = false + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { + if (failWrite) error("storage unavailable") + saved = it.getArgument(1) + } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock()) + val attempt = store.admitForTest() + val txid = "ab".repeat(32) + failWrite = true + assertFailsWith { + store.recordOutcome(attempt.attemptId, OnchainSendOutcome.Accepted(txid), attempt.walletIndex) + } + assertEquals(txid, store.current()?.txid) + assertEquals(OnchainSendEvidence.Accepted, store.current()?.evidence) + assertFailsWith { store.markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) } + assertFailsWith { store.admitForTest() } + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, mock()) + assertEquals(OnchainSendEvidence.Pending, reopened.current()?.evidence) + assertNull(reopened.current()?.txid) + assertFailsWith { reopened.admitForTest() } + } + @Test fun `parallel admissions dispatch only one before-send callback`() = test { var saved: String? = null diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 4a827db97d..348dc0e310 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -400,7 +400,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() assertTrue(storedProofs.single().paymentStarted) - repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, OnchainSendOutcome.Accepted(txid)) val endpointCaptor = argumentCaptor() val proofCaptor = argumentCaptor() @@ -479,7 +479,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(record) val repo = paymentProofRepo() - repo.completeOnchainPayment(request, txid, endpoint) + repo.completeOnchainPayment(request, txid, endpoint, OnchainSendOutcome.Accepted(txid)) verify(paykitSdkService).submitPaymentProof(any(), any(), any(), eq(endpoint), any(), isNull()) assertTrue(storedProofs.isEmpty()) @@ -587,7 +587,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() shouldFailNextSave = true - repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, OnchainSendOutcome.Accepted(txid)) verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) assertTrue(storedProofs.isEmpty()) @@ -606,7 +606,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() shouldFailNextSave = true - repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, OnchainSendOutcome.Accepted(txid)) assertEquals(txid, storedProofs.single().proofData) verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) @@ -624,7 +624,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() shouldFailProofRemoval = true - repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue) + repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, OnchainSendOutcome.Accepted(txid)) verify(paykitSdkService).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) assertEquals(txid, storedProofs.single().proofData) @@ -643,7 +643,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.prepare(request, endpoint, PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() shouldFailNextLoad = true - repo.completeOnchainPayment(request, txid, endpoint) + repo.completeOnchainPayment(request, txid, endpoint, OnchainSendOutcome.Accepted(txid)) val endpointCaptor = argumentCaptor() val proofCaptor = argumentCaptor() @@ -735,6 +735,122 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) } + @Test + fun `completed onchain proof retries after a newer send replaces the bounded guard`() = test { + val txid = "ab".repeat(32) + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val record = paymentRequestRecord() + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + .thenThrow(IllegalStateException("delivery unavailable")) + .thenReturn(record) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + assertTrue(repo.completeOnchainPayment(request, txid, MethodId.P2wpkh.rawValue, OnchainSendOutcome.Accepted(txid))) + assertEquals(txid, storedProofs.single().proofData) + assertTrue(storedProofs.single().onchainAcceptanceVerified) + + val newerRequest = paymentRequest(MethodId.P2wpkh.rawValue, "550e8400-e29b-41d4-a716-446655440001") + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedAttempt(newerRequest, "cd".repeat(32))) + paymentProofRepo().reconcile() + + assertTrue(storedProofs.isEmpty()) + val proofCaptor = argumentCaptor() + verify(paykitSdkService, times(2)).submitPaymentProof( + eq(request.counterparty), eq(request.counterpartyReceiverPath), eq(request.paymentRequestId), + eq(MethodId.P2wpkh.rawValue), proofCaptor.capture(), isNull(), + ) + assertTrue(proofCaptor.allValues.all { it.contains(txid) }) + verify(lightningRepo, never()).completeAcceptedShopFollowup(newerRequest.id, "cd".repeat(32)) + } + + @Test + fun `legacy completed onchain proof without acknowledgement stays blocked after guard replacement`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val txid = "ab".repeat(32) + storedProofs = listOf(PendingPaykitPaymentProof( + identity = LOCAL_IDENTITY, requestId = request.id, paymentEndpointIdentifier = MethodId.P2wpkh.rawValue, + kind = PaykitPaymentProofKind.Onchain, paymentStarted = true, paymentIdentifier = txid, proofData = txid, + )) + val newerRequest = paymentRequest(MethodId.P2wpkh.rawValue, "550e8400-e29b-41d4-a716-446655440001") + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedAttempt(newerRequest, "cd".repeat(32))) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + + paymentProofRepo().reconcile() + + assertEquals(1, storedProofs.size) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + verify(lightningRepo, never()).completeAcceptedShopFollowup(any(), any()) + } + + @Test + fun `txid-only onchain completion cannot mint acknowledged evidence`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + + assertFalse(repo.completeOnchainPayment(request, "ab".repeat(32), MethodId.P2wpkh.rawValue)) + assertNull(storedProofs.single().proofData) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + } + + @Test + fun `exact positive guard upgrades an unmarked completed proof durably`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val txid = "ab".repeat(32) + storedProofs = listOf(PendingPaykitPaymentProof( + identity = LOCAL_IDENTITY, requestId = request.id, paymentEndpointIdentifier = MethodId.P2wpkh.rawValue, + kind = PaykitPaymentProofKind.Onchain, paymentStarted = true, paymentIdentifier = txid, proofData = txid, + )) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn( + acceptedAttempt(request, txid).copy(evidence = OnchainSendEvidence.Observed), + ) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + .thenThrow(IllegalStateException("delivery unavailable")) + paymentProofRepo().reconcile() + + assertTrue(storedProofs.single().onchainAcceptanceVerified) + verify(lightningRepo).completeAcceptedShopFollowup(request.id, txid) + } + + @Test + fun `mismatched accepted outcome cannot verify a different proof txid`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + + assertFalse(repo.completeOnchainPayment( + request, "ab".repeat(32), MethodId.P2wpkh.rawValue, OnchainSendOutcome.Accepted("cd".repeat(32)), + )) + assertFalse(storedProofs.single().onchainAcceptanceVerified) + assertNull(storedProofs.single().proofData) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + } + + @Test + fun `malformed completed onchain proof is neither delivered nor used to complete the guard`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val txid = "ab".repeat(32) + storedProofs = listOf(PendingPaykitPaymentProof( + identity = LOCAL_IDENTITY, requestId = request.id, paymentEndpointIdentifier = MethodId.P2wpkh.rawValue, + kind = PaykitPaymentProofKind.Onchain, paymentStarted = true, paymentIdentifier = txid, + proofData = "cd".repeat(32), + )) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedAttempt(request, txid)) + + paymentProofRepo().reconcile() + + assertEquals(1, storedProofs.size) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + verify(lightningRepo, never()).completeAcceptedShopFollowup(any(), any()) + } + @Test fun `cancel preparation does not remove another identity proof`() = test { val request = paymentRequest(MethodId.Bolt11.rawValue) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofStoreTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofStoreTest.kt index 13ba49107f..bc777126bc 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofStoreTest.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.SerializationException import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.eq +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -13,6 +14,8 @@ import to.bitkit.test.BaseUnitTest import kotlin.test.assertContains import kotlin.test.assertFailsWith import kotlin.test.assertIs +import kotlin.test.assertEquals +import kotlin.test.assertTrue class PaykitPaymentProofStoreTest : BaseUnitTest() { companion object { @@ -32,6 +35,29 @@ class PaykitPaymentProofStoreTest : BaseUnitTest() { verify(keychain, never()).delete(KEY) } + @Test + fun `unverified txid proof stays in flight without claiming local completion`() = test { + val identity = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + val requestId = PaykitPaymentRequestId("request", "counterparty", "bitkit/wallet") + val proof = PendingPaykitPaymentProof( + identity = identity, requestId = requestId, paymentEndpointIdentifier = MethodId.P2wpkh.rawValue, + kind = PaykitPaymentProofKind.Onchain, paymentStarted = true, + paymentIdentifier = "ab".repeat(32), proofData = "ab".repeat(32), + ) + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(KEY)).thenAnswer { saved } + whenever(keychain.upsertString(eq(KEY), any())).doSuspendableAnswer { saved = it.getArgument(1) } + val store = PaykitPaymentProofStore(keychain) + store.save(listOf(proof)) + assertTrue(store.completedRequestProofKindsAwaitingSubmission(identity).isEmpty()) + assertEquals(setOf(requestId), store.inFlightRequestIds(identity)) + + store.save(listOf(proof.copy(onchainAcceptanceVerified = true))) + assertContains(requireNotNull(saved), "\"onchainAcceptanceVerified\":true") + assertEquals(mapOf(requestId to PaykitPaymentProofKind.Onchain), store.completedRequestProofKindsAwaitingSubmission(identity)) + } + @Test fun `saving no proofs removes persisted state`() = test { val keychain = mock() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index fa3c1e3430..9efcaf4af3 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -6213,8 +6213,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { onBroadcast = any(), requestId = anyOrNull(), orderId = anyOrNull(), + transferContext = anyOrNull(), ) - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, OnchainSendOutcome.Accepted("txid")) } @Test @@ -6310,7 +6311,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentRequestRepo).accept(request) } - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, OnchainSendOutcome.Accepted("txid")) } @Test @@ -6354,6 +6355,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { onBroadcast = any(), requestId = anyOrNull(), orderId = anyOrNull(), + transferContext = anyOrNull(), ) verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any()) } @@ -6688,7 +6690,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `hardware payment request completes proof in background after broadcast`() = test { + fun `hardware payment request forwards txid without claiming typed acceptance`() = test { val request = paymentRequest() val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) val completionStarted = CompletableDeferred() @@ -6700,7 +6702,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .doSuspendableAnswer { completionStarted.complete(Unit) finishCompletion.await() - true + false } setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( @@ -6769,7 +6771,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, OnchainSendOutcome.Accepted("txid")) verify(paykitPaymentRequestRepo).refresh() } @@ -7175,14 +7177,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `post broadcast bookkeeping failure still completes payment proof`() = test { + fun `retained accepted outcome after bookkeeping failure completes verified payment proof`() = test { val request = paymentRequest() balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) stubOnchainSend( address = "bcrt1qbookkeepingfailure", sats = request.amountSats, - result = Result.failure(IllegalStateException("activity persistence failed")), + result = Result.success(OnchainSendOutcome.Accepted("broadcast-txid")), broadcastTxId = "broadcast-txid", ) setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = request) @@ -7198,7 +7200,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "broadcast-txid", MethodId.P2wpkh.rawValue) + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "broadcast-txid", MethodId.P2wpkh.rawValue, OnchainSendOutcome.Accepted("broadcast-txid")) verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) } @@ -7238,6 +7240,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { onBroadcast = any(), requestId = anyOrNull(), orderId = anyOrNull(), + transferContext = anyOrNull(), ) } @@ -7300,6 +7303,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { onBroadcast = any(), requestId = anyOrNull(), orderId = anyOrNull(), + transferContext = anyOrNull(), ) } @@ -7361,6 +7365,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { onBroadcast = any(), requestId = anyOrNull(), orderId = anyOrNull(), + transferContext = anyOrNull(), ) } @@ -7426,7 +7431,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `reopened accepted ordinary send shows its recorded transaction instead of sending again`() = test { + fun `blocked ordinary payment never reports an earlier accepted send as its success`() = test { val address = "bcrt1qreopened" val txid = "ef".repeat(32) val previous = OnchainSendAttempt( @@ -7445,14 +7450,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { txid = txid, ) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) - stubOnchainSend(address, 1_000u, Result.failure(OnchainSendBlockedError(previous))) - setSendState(SendUiState(address = address, amount = 1_000u, payMethod = SendMethod.ONCHAIN)) + setSendState(SendUiState(address = "bcrt1qdifferentrecipient", amount = 2_000u, payMethod = SendMethod.ONCHAIN)) + stubOnchainSend("bcrt1qdifferentrecipient", 2_000u, Result.failure(OnchainSendBlockedError(previous))) + whenever(lightningRepo.completeAcceptedOrdinaryFollowup(txid)) + .doSuspendableAnswer { throw AppError("attempt reload unavailable") } sut.sendEffect.test { confirmCurrentPayment() - assertEquals(SendEffect.PaymentSuccess, awaitItem()) + assertEquals(SendEffect.NavigateToPending(txid, 1_000, false, isOnchain = true), awaitItem()) } - assertEquals(txid, sut.successSendUiState.value.paymentHashOrTxId) + assertNull(sut.successSendUiState.value.paymentHashOrTxId) verify(lightningRepo).completeAcceptedOrdinaryFollowup(txid) } @@ -7997,6 +8004,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { onBroadcast = any(), requestId = anyOrNull(), orderId = anyOrNull(), + transferContext = anyOrNull(), ) }.doSuspendableAnswer { invocation -> kotlin.check(invocation.getArgument(0) == address) diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 8bb3858510..1d3477e115 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -91,6 +91,7 @@ import to.bitkit.repositories.LightningState import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.OnchainSendAttempt +import to.bitkit.repositories.OnchainTransferContext import to.bitkit.repositories.OnchainSendEvidence import to.bitkit.repositories.WalletRepo import to.bitkit.services.BoltzService @@ -1158,6 +1159,7 @@ class TransferViewModelTest : BaseUnitTest() { onBroadcast = any(), requestId = anyOrNull(), orderId = anyOrNull(), + transferContext = eq(OnchainTransferContext(txTotalSats = 100_000uL, preTransferOnchainSats = 100_000uL)), ) verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) verify(blocktankRepo, times(1)).createOrder(eq(order.clientBalanceSat), eq(order.lspBalanceSat), any()) @@ -1198,6 +1200,7 @@ class TransferViewModelTest : BaseUnitTest() { onBroadcast = any(), requestId = anyOrNull(), orderId = anyOrNull(), + transferContext = eq(OnchainTransferContext(txTotalSats = 38_171uL, preTransferOnchainSats = 41_000uL)), ) verify(lightningRepo, never()).sendOnChain( address = any(), @@ -1213,6 +1216,7 @@ class TransferViewModelTest : BaseUnitTest() { onBroadcast = any(), requestId = anyOrNull(), orderId = anyOrNull(), + transferContext = anyOrNull(), ) verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) } @@ -1245,6 +1249,7 @@ class TransferViewModelTest : BaseUnitTest() { any(), anyOrNull(), anyOrNull(), + anyOrNull(), ), ).thenReturn(Result.failure(AppError("Coin selection failed"))) @@ -1269,6 +1274,7 @@ class TransferViewModelTest : BaseUnitTest() { onBroadcast = any(), requestId = anyOrNull(), orderId = anyOrNull(), + transferContext = anyOrNull(), ) verify(lightningRepo, never()).sendOnChain( address = any(), @@ -1284,6 +1290,7 @@ class TransferViewModelTest : BaseUnitTest() { onBroadcast = any(), requestId = anyOrNull(), orderId = anyOrNull(), + transferContext = anyOrNull(), ) verify(cacheStore, never()).addPaidOrder(any(), any()) } @@ -1296,6 +1303,7 @@ class TransferViewModelTest : BaseUnitTest() { address = order.payment?.onchain?.address.orEmpty(), amountSats = order.feeSat, isMaxAmount = false, feeRateSatsPerVByte = 1uL, isTransfer = true, channelId = null, tags = emptyList(), evidence = OnchainSendEvidence.Accepted, txid = TXID, + transferContext = OnchainTransferContext(txTotalSats = 99_000uL, preTransferOnchainSats = 125_000uL), ) whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) whenever(transferRepo.findLspOrderIdByFundingTxId(TXID)).thenReturn(Result.success(null)) @@ -1315,12 +1323,71 @@ class TransferViewModelTest : BaseUnitTest() { verify(lightningRepo, never()).sendOnChain( any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), + anyOrNull(), ) verify(transferRepo, times(1)).createTransfer( any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), ) } + @Test + fun `accepted transfer recovery preserves original balance totals`() = test { + val order = spendingOrder(feeSat = 98_000uL) + val attempt = OnchainSendAttempt( + walletId = "wallet", attemptId = "attempt", requestId = null, orderId = order.id, + address = order.payment?.onchain?.address.orEmpty(), amountSats = order.feeSat, + isMaxAmount = false, feeRateSatsPerVByte = 1uL, isTransfer = true, + channelId = null, tags = emptyList(), evidence = OnchainSendEvidence.Accepted, txid = TXID, + transferContext = OnchainTransferContext(txTotalSats = 99_000uL, preTransferOnchainSats = 125_000uL), + ) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + whenever(transferRepo.findLspOrderIdByFundingTxId(TXID)).thenReturn(Result.success(null)) + whenever(transferRepo.createTransfer( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + )).thenReturn(Result.success("transfer-id")) + quoteOrder(order) + prepareConfirm() + // A later balance is not the balance snapshot from before this accepted payment. + stubSpendableBalances(7_000uL) + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + + verify(transferRepo).createTransfer( + type = eq(TransferType.TO_SPENDING), amountSats = eq(order.clientBalanceSat.toLong()), + channelId = anyOrNull(), fundingTxId = eq(TXID), lspOrderId = eq(order.id), + claimableAtHeight = anyOrNull(), txTotalSats = eq(99_000L), preTransferOnchainSats = eq(125_000L), + ) + verify(lightningRepo, never()).sendOnChain( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), anyOrNull(), any(), any(), any(), any(), + anyOrNull(), anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `accepted transfer without original context stays blocked instead of inventing balance totals`() = test { + val order = spendingOrder(feeSat = 98_000uL) + val attempt = OnchainSendAttempt( + walletId = "wallet", attemptId = "attempt", requestId = null, orderId = order.id, + address = order.payment?.onchain?.address.orEmpty(), amountSats = order.feeSat, + isMaxAmount = false, feeRateSatsPerVByte = 1uL, isTransfer = true, + channelId = null, tags = emptyList(), evidence = OnchainSendEvidence.Accepted, txid = TXID, + ) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + whenever(transferRepo.findLspOrderIdByFundingTxId(TXID)).thenReturn(Result.success(null)) + quoteOrder(order) + prepareConfirm() + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + + verify(transferRepo, never()).createTransfer( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + ) + verify(lightningRepo, never()).completeAcceptedTransferFollowup(any(), any()) + verifySendOnChain(sats = order.feeSat, count = 0) + assertFalse(sut.spendingUiState.value.isConfirmPaying) + } + @Test fun `unknown transfer funding does not mark order paid`() = test { val order = spendingOrder(feeSat = 98_000uL) @@ -1346,6 +1413,7 @@ class TransferViewModelTest : BaseUnitTest() { onBroadcast = any(), requestId = anyOrNull(), orderId = anyOrNull(), + transferContext = anyOrNull(), ), ).thenReturn(Result.success(OnchainSendOutcome.Unknown(TXID))) quoteOrder(order) @@ -1414,6 +1482,7 @@ class TransferViewModelTest : BaseUnitTest() { any(), anyOrNull(), anyOrNull(), + anyOrNull(), ), ).thenReturn( Result.failure(AppError("Coin selection failed")), @@ -1676,6 +1745,7 @@ class TransferViewModelTest : BaseUnitTest() { onBroadcast = any(), requestId = anyOrNull(), orderId = anyOrNull(), + transferContext = anyOrNull(), ) } @@ -1859,6 +1929,7 @@ class TransferViewModelTest : BaseUnitTest() { any(), anyOrNull(), anyOrNull(), + anyOrNull(), ) verify(cacheStore, never()).addPaidOrder(any(), any()) } @@ -1887,6 +1958,7 @@ class TransferViewModelTest : BaseUnitTest() { any(), anyOrNull(), anyOrNull(), + anyOrNull(), ), ).thenReturn(Result.failure(AppError("Coin selection failed"))) quoteOrder(order) @@ -2035,6 +2107,7 @@ class TransferViewModelTest : BaseUnitTest() { any(), anyOrNull(), anyOrNull(), + anyOrNull(), ) verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) } @@ -3278,6 +3351,7 @@ class TransferViewModelTest : BaseUnitTest() { onBroadcast = any(), requestId = anyOrNull(), orderId = anyOrNull(), + transferContext = anyOrNull(), ) } @@ -3428,6 +3502,7 @@ class TransferViewModelTest : BaseUnitTest() { any(), anyOrNull(), anyOrNull(), + anyOrNull(), ), ).thenReturn(Result.success(OnchainSendOutcome.Accepted(TXID))) } From ae0be374344d1f08d67a2f1843b82aef461f18fd Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 04:36:05 +0200 Subject: [PATCH 008/108] fix: require observed hardware shop payments --- .../to/bitkit/repositories/HwWalletRepo.kt | 15 +++ .../repositories/PaykitPaymentProofRepo.kt | 65 +++++++++++- .../java/to/bitkit/repositories/TrezorRepo.kt | 13 +++ .../java/to/bitkit/services/TrezorService.kt | 12 +++ .../screens/wallets/send/HwSendSignScreen.kt | 3 + .../screens/wallets/send/HwSendViewModel.kt | 12 ++- .../java/to/bitkit/ui/sheets/SendSheet.kt | 22 ++++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 48 ++++++--- .../bitkit/repositories/HwWalletRepoTest.kt | 59 +++++++++++ .../PaykitPaymentProofRepoTest.kt | 99 +++++++++++++++++++ .../wallets/send/HwSendViewModelTest.kt | 18 ++++ .../viewmodels/AppViewModelSendFlowTest.kt | 90 +++++++++++++---- .../hardware-wallet/shop-onchain-proof.xml | 23 +++++ 13 files changed, 439 insertions(+), 40 deletions(-) create mode 100644 journeys/hardware-wallet/shop-onchain-proof.xml diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 8e804a1204..a7b4202cc1 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -548,6 +548,21 @@ class HwWalletRepo @Inject constructor( } } + /** Fresh backend observation of this exact transaction in the original hardware wallet. */ + suspend fun observeExactTransaction(walletId: String, txid: String): Result = withContext(ioDispatcher) { + runSuspendCatching { + require(walletId != WalletScope.default && txid.matches(Regex("[0-9a-fA-F]{64}"))) + val account = getFundingAccount(walletId).getOrThrow() + val detail = trezorRepo.getTransactionDetail( + extendedKey = account.xpub, + txid = txid, + network = Env.network.toCoreNetwork(), + scriptType = account.accountType, + ).getOrThrow() + detail.txid.equals(txid, ignoreCase = true) && detail.sent > 0uL + } + } + suspend fun disconnectStaleSession(walletId: String): Result = withContext(ioDispatcher) { runSuspendCatching { val deviceId = transportDeviceIdOrNull(walletId) ?: return@runSuspendCatching diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 9d3b4530cc..cc086a020a 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject @@ -36,6 +37,7 @@ import java.security.MessageDigest import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Instant +import kotlin.time.Duration.Companion.seconds /** New proof kinds must be readable on both platforms before either platform writes them to a wallet backup. */ @Serializable @@ -72,6 +74,8 @@ data class PaykitOnchainPaymentProofResolution( val identity: String, val requestId: PaykitPaymentRequestId, val transactionId: String, + val walletId: String = WalletScope.default, + val amountSats: ULong? = null, ) @Singleton @@ -81,10 +85,12 @@ class PaykitPaymentProofRepo @Inject constructor( private val paykitSdkService: PaykitSdkService, private val lightningRepo: LightningRepo, private val store: PaykitPaymentProofStore, + private val hwWalletRepo: HwWalletRepo, ) { companion object { private const val TAG = "PaykitPaymentProofRepo" private const val HASH_BYTE_COUNT = 32 + private val HARDWARE_OBSERVATION_TIMEOUT = 15.seconds } private val operationMutex = Mutex() @@ -340,6 +346,37 @@ class PaykitPaymentProofRepo @Inject constructor( return true } + suspend fun completeHardwareOnchainPayment( + requestId: PaykitPaymentRequestId, + walletId: String, + txid: String, + identity: String? = null, + ): Boolean = withContext(ioDispatcher) { + if (walletId == WalletScope.default || !txid.isHex(HASH_BYTE_COUNT)) return@withContext false + val originalIdentity = identity?.let(PubkyPublicKeyFormat::normalized) ?: return@withContext false + operationMutex.withLock { + runSuspendCatching { + val proofs = loadProofs().toMutableList() + val index = proofs.indices.singleOrNull { index -> + val proof = proofs[index] + PubkyPublicKeyFormat.matches(proof.identity, originalIdentity) && + proof.requestId == requestId && proof.onchainWalletId == walletId && + proof.kind == PaykitPaymentProofKind.Onchain && proof.paymentStarted + } ?: return@runSuspendCatching false + val original = proofs[index] + if ((original.paymentIdentifier != null && !original.paymentIdentifier.equals(txid, true)) || + (original.proofData != null && !original.proofData.equals(txid, true)) + ) return@runSuspendCatching false + // This id identifies the original lookup; it is not yet a payment proof or acceptance evidence. + val pending = original.copy(paymentIdentifier = txid.lowercase()) + proofs[index] = pending + persist(proofs) + reconcileHardwareOnchainProof(pending) + }.onFailure { Logger.warn("Failed to retain or observe the original hardware Shop payment", it, context = TAG) } + .getOrDefault(false) + } + } + suspend fun failOnchainPayment(request: PaykitPaymentRequest) { removeRequestProofs(request) { it.kind == PaykitPaymentProofKind.Onchain && @@ -419,7 +456,7 @@ class PaykitPaymentProofRepo @Inject constructor( proofs.forEach { proof -> runSuspendCatching { reconcileProof(proof, payments, attempt) } - .onSuccess { if (it) completedShopTxid = attempt?.txid } + .onSuccess { if (it && proof.onchainWalletId == WalletScope.default) completedShopTxid = attempt?.txid } .onFailure { Logger.warn( "Failed to reconcile a pending Paykit payment proof", @@ -449,6 +486,7 @@ class PaykitPaymentProofRepo @Inject constructor( !proof.paymentIdentifier.equals(proof.proofData, ignoreCase = true) ) return false if (proof.onchainAcceptanceVerified != true) { + if (proof.onchainWalletId != WalletScope.default) return reconcileHardwareOnchainProof(proof) if (!attempt.matchesPositiveShopProof(proof)) return false val proofs = loadProofs().toMutableList() val index = proofs.indexOf(proof) @@ -508,6 +546,7 @@ class PaykitPaymentProofRepo @Inject constructor( proof: PendingPaykitPaymentProof, attempt: OnchainSendAttempt?, ): Boolean { + if (proof.onchainWalletId != WalletScope.default) return reconcileHardwareOnchainProof(proof) if (!attempt.matchesPositiveShopProof(proof)) return false val txid = attempt?.txid ?: return false @@ -523,6 +562,28 @@ class PaykitPaymentProofRepo @Inject constructor( return retained } + private suspend fun reconcileHardwareOnchainProof(proof: PendingPaykitPaymentProof): Boolean { + val txid = proof.paymentIdentifier?.takeIf { it.isHex(HASH_BYTE_COUNT) } ?: return false + if (!proof.paymentStarted || proof.onchainWalletId == WalletScope.default || + (proof.proofData != null && !proof.proofData.equals(txid, true)) + ) return false + if (!proof.onchainAcceptanceVerified) { + val observed = withTimeoutOrNull(HARDWARE_OBSERVATION_TIMEOUT) { + hwWalletRepo.observeExactTransaction(proof.onchainWalletId, txid).getOrDefault(false) + } == true + if (!observed) return false + } + val proofs = loadProofs().toMutableList() + val index = proofs.indexOf(proof) + if (index < 0) return false + val completed = proof.copy(proofData = txid.lowercase(), onchainAcceptanceVerified = true) + proofs[index] = completed + val retained = persistAndSubmit(listOf(completed), proofs) + if (retained) publishOnchainResolution(completed, txid) + // Hardware completion never acknowledges an unrelated node-wallet guard. + return retained + } + private fun OnchainSendAttempt?.matchesPositiveShopProof(proof: PendingPaykitPaymentProof): Boolean = this != null && hasPositiveEvidence && requestId == proof.requestId && walletId == proof.onchainWalletId && txid?.isHex(HASH_BYTE_COUNT) == true && @@ -551,6 +612,8 @@ class PaykitPaymentProofRepo @Inject constructor( identity = proof.identity, requestId = proof.requestId, transactionId = txid.lowercase(), + walletId = proof.onchainWalletId, + amountSats = proof.onchainAmountSats, ) _onchainPaymentResolutions.update { resolutions -> if (resolution in resolutions) resolutions else resolutions + resolution diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 69a4bb9775..8fe3994465 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -12,6 +12,7 @@ import com.synonym.bitkitcore.ComposeResult import com.synonym.bitkitcore.EventListener import com.synonym.bitkitcore.SingleAddressInfoResult import com.synonym.bitkitcore.TransactionHistoryResult +import com.synonym.bitkitcore.TransactionDetail import com.synonym.bitkitcore.TrezorAddressResponse import com.synonym.bitkitcore.TrezorCoinType import com.synonym.bitkitcore.TrezorDeviceInfo @@ -439,6 +440,18 @@ class TrezorRepo @Inject constructor( } } + suspend fun getTransactionDetail( + extendedKey: String, + txid: String, + network: BitkitCoreNetwork, + scriptType: AccountType, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + awaitSetup() + trezorService.getTransactionDetail(extendedKey, currentElectrumUrl(), txid, network, scriptType) + } + } + suspend fun getTransactionHistory( extendedKey: String, network: BitkitCoreNetwork = Env.network.toCoreNetwork(), diff --git a/app/src/main/java/to/bitkit/services/TrezorService.kt b/app/src/main/java/to/bitkit/services/TrezorService.kt index 5760c5ce84..e515779afd 100644 --- a/app/src/main/java/to/bitkit/services/TrezorService.kt +++ b/app/src/main/java/to/bitkit/services/TrezorService.kt @@ -7,6 +7,7 @@ import com.synonym.bitkitcore.ComposeResult import com.synonym.bitkitcore.EventListener import com.synonym.bitkitcore.SingleAddressInfoResult import com.synonym.bitkitcore.TransactionHistoryResult +import com.synonym.bitkitcore.TransactionDetail import com.synonym.bitkitcore.TrezorAddressResponse import com.synonym.bitkitcore.TrezorCoinType import com.synonym.bitkitcore.TrezorDeviceInfo @@ -26,6 +27,7 @@ import com.synonym.bitkitcore.onchainComposeTransaction import com.synonym.bitkitcore.onchainGetAccountInfo import com.synonym.bitkitcore.onchainGetAddressInfo import com.synonym.bitkitcore.onchainGetTransactionHistory +import com.synonym.bitkitcore.onchainGetTransactionDetail import com.synonym.bitkitcore.onchainStartWatcher import com.synonym.bitkitcore.onchainStopAllWatchers import com.synonym.bitkitcore.onchainStopWatcher @@ -243,6 +245,16 @@ class TrezorService @Inject constructor( } } + suspend fun getTransactionDetail( + extendedKey: String, + electrumUrl: String, + txid: String, + network: BitkitCoreNetwork, + scriptType: AccountType, + ): TransactionDetail = ServiceQueue.CORE.background { + onchainGetTransactionDetail(extendedKey, electrumUrl, txid, network, scriptType) + } + suspend fun getTransactionHistory( extendedKey: String, electrumUrl: String, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt index f6048341ae..226131433c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt @@ -41,6 +41,7 @@ private const val SEND_SIGN_VISUAL_TOP_RATIO = 0.54f fun HwSendSignScreen( walletId: String, sendUiState: SendUiState, + paymentIdentity: String?, satsPerVByte: ULong, viewModel: HwSendViewModel, prepareContactPayment: suspend () -> Boolean, @@ -53,6 +54,8 @@ fun HwSendSignScreen( amountSats = sendUiState.amount, satsPerVByte = satsPerVByte, tags = sendUiState.selectedTags, + paymentRequestId = sendUiState.incomingPaymentRequestId, + paymentIdentity = paymentIdentity, ) val onBackRequest: () -> Unit = { if (!uiState.isSigning && !uiState.isBroadcastUnresolved) onBack() } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt index 397d6d97cb..19e892d409 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt @@ -31,6 +31,7 @@ import to.bitkit.repositories.HwPassphraseMismatchError import to.bitkit.repositories.HwPassphraseRequiredError import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.PreActivityMetadataRepo +import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.services.CoreService import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger @@ -72,7 +73,7 @@ class HwSendViewModel @Inject constructor( request: HwSendRequest, beforeBroadcast: suspend () -> Boolean = { true }, ) { - if (_uiState.value.isSigning || signingJob?.isActive == true) return + if (pendingResult.value != null || _uiState.value.isSigning || signingJob?.isActive == true) return if (pendingBroadcast?.matches(request) == false) return signingWalletId = request.walletId _uiState.update { it.copy(isSigning = true) } @@ -107,7 +108,10 @@ class HwSendViewModel @Inject constructor( } runSuspendCatching { persistResult(request, result) } .onFailure { Logger.error("Failed to persist hardware send result", it, context = TAG) } - pendingResult.update { HwSendResult(request.walletId, result.txId, request.amountSats) } + pendingResult.update { + HwSendResult(request.walletId, result.txId, request.amountSats, + request.paymentRequestId, request.paymentIdentity) + } }.onFailure { if (it is CancellationException && it !is TimeoutCancellationException) throw it handleFailure(it, request.walletId) @@ -319,6 +323,8 @@ data class HwSendResult( val walletId: String, val txId: String, val amountSats: ULong, + val paymentRequestId: PaykitPaymentRequestId? = null, + val paymentIdentity: String? = null, ) data class HwSendRequest( @@ -327,6 +333,8 @@ data class HwSendRequest( val amountSats: ULong, val satsPerVByte: ULong, val tags: List, + val paymentRequestId: PaykitPaymentRequestId? = null, + val paymentIdentity: String? = null, ) private data class PendingHwSendBroadcast( diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index be687ed6f7..601f367c8e 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -128,7 +128,19 @@ fun SendSheet( val navController = rememberNavController() LaunchedEffect(hwSendViewModel, navController) { hwSendViewModel.results.collect { result -> - appViewModel.completeHardwareContactPayment(result.txId) + val proofComplete = appViewModel.completeHardwareContactPayment( + result.txId, result.walletId, result.paymentRequestId, result.paymentIdentity, + ) + if (!proofComplete) { + navController.navigateTo(SendRoute.Pending( + paymentHash = result.txId, + amount = result.amountSats.toLong(), + observeResolution = false, + isOnchain = true, + )) { popUpTo(navController.graph.id) { inclusive = true } } + hwSendViewModel.completeBroadcast() + return@collect + } appViewModel.onSendSuccess( details = NewTransactionSheetDetails( type = NewTransactionSheetType.ONCHAIN, @@ -319,12 +331,18 @@ fun SendSheet( ?.toULong() ?.takeIf { rate -> rate > 0uL } ?: HARDWARE_SEND_FALLBACK_SATS_PER_VBYTE + val paymentIdentity = appViewModel.hardwarePaymentIdentity() HwSendSignScreen( walletId = walletId, sendUiState = uiState, + paymentIdentity = paymentIdentity, satsPerVByte = satsPerVByte, viewModel = hwSendViewModel, - prepareContactPayment = appViewModel::prepareHardwareContactPayment, + prepareContactPayment = { + appViewModel.prepareHardwareContactPayment( + walletId, uiState.address, uiState.incomingPaymentRequestId, paymentIdentity, + ) + }, onBack = { navController.previousBackStackEntry ?.savedStateHandle diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 926466027c..d1ad361951 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -842,7 +842,8 @@ class AppViewModel @Inject constructor( type = NewTransactionSheetType.ONCHAIN, direction = NewTransactionSheetDirection.SENT, paymentHashOrTxId = resolution.transactionId, - sats = _sendUiState.value.amount.toLong(), + sats = resolution.amountSats?.toLong() ?: _sendUiState.value.amount.toLong(), + activityWalletId = resolution.walletId, isLoadingDetails = true, ) ) @@ -860,6 +861,7 @@ class AppViewModel @Inject constructor( contactPublicKey = resolution.requestId.counterparty, forPaymentId = resolution.transactionId, syncLdkPayments = false, + walletId = resolution.walletId, ).onFailure { Logger.warn("Failed to associate a resolved Paykit payment with its contact", it, context = TAG) } @@ -5248,24 +5250,34 @@ class AppViewModel @Inject constructor( } } - suspend fun prepareHardwareContactPayment(): Boolean { - val contactPaymentContext = synchronized(contactPaymentContextLock) { activeContactPaymentContext } - if (isPreparedContactPayment(contactPaymentContext)) return true + fun hardwarePaymentIdentity(): String? = pubkyRepo.publicKey.value?.let(PubkyPublicKeyFormat::normalized) + suspend fun prepareHardwareContactPayment( + walletId: String? = _sendUiState.value.hardwareWalletId, + address: String = _sendUiState.value.address, + requestId: PaykitPaymentRequestId? = activeIncomingPaymentRequest()?.id, + identity: String? = hardwarePaymentIdentity(), + ): Boolean { + val contactPaymentContext = synchronized(contactPaymentContextLock) { activeContactPaymentContext } + if (contactPaymentContext?.incomingPaymentRequest?.id != requestId) return false val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest + if (incomingPaymentRequest != null && + (identity == null || !PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value)) + ) return false + // A retry of the original signed transaction skips this hook in HwSendViewModel. + // A new Shop payment must consult the durable paymentStarted guard again. + if (incomingPaymentRequest == null && isPreparedContactPayment(contactPaymentContext)) return true val proofPreparation = preparePaymentProof(incomingPaymentRequest) - if (proofPreparation.exceptionOrNull() is PaykitPaymentRequestError.OperationInProgress) { - handlePaymentPreparationFailure(PaykitPaymentRequestError.OperationInProgress, contactPaymentContext) + val preparedPaymentProofRequest = proofPreparation.getOrElse { + handlePaymentPreparationFailure(it, contactPaymentContext) return false } - val preparedPaymentProofRequest = proofPreparation.getOrNull() if (!prepareContactPayment(contactPaymentContext)) { cancelPaymentProofPreparation(preparedPaymentProofRequest) return false } if (preparedPaymentProofRequest != null) { - val walletId = _sendUiState.value.hardwareWalletId ?: WalletScope.default - markOnchainPaymentStarted(incomingPaymentRequest, _sendUiState.value.address, walletId).onFailure { + markOnchainPaymentStarted(incomingPaymentRequest, address, walletId ?: WalletScope.default).onFailure { synchronized(contactPaymentContextLock) { if (preparedContactPaymentContext == contactPaymentContext) preparedContactPaymentContext = null } @@ -5274,14 +5286,20 @@ class AppViewModel @Inject constructor( return false } } - return true + return incomingPaymentRequest == null || PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value) } - fun completeHardwareContactPayment(txId: String) { - val incomingPaymentRequest = synchronized(contactPaymentContextLock) { - activeContactPaymentContext?.incomingPaymentRequest - } - completeOnchainPaymentProofInBackground(incomingPaymentRequest, txId) + suspend fun completeHardwareContactPayment( + txId: String, + walletId: String, + requestId: PaykitPaymentRequestId?, + identity: String?, + ): Boolean { + if (requestId == null) return true + val completed = paykitPaymentProofRepo.completeHardwareOnchainPayment(requestId, walletId, txId, identity) && + PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value) + if (!completed) uncertainOnchainPaymentRequestId = requestId + return completed } fun onHardwareSignCancelled() { diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 5e31cda7f8..dcfe6e3c65 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -12,6 +12,7 @@ import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.PreActivityMetadata import com.synonym.bitkitcore.TransactionDetails +import com.synonym.bitkitcore.TransactionDetail import com.synonym.bitkitcore.TrezorAddressResponse import com.synonym.bitkitcore.TrezorException import com.synonym.bitkitcore.TrezorFeatures @@ -19,6 +20,8 @@ import com.synonym.bitkitcore.TrezorSignedTx import com.synonym.bitkitcore.WalletBalance import com.synonym.bitkitcore.WatcherEvent import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @@ -29,6 +32,7 @@ import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.eq +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -135,6 +139,61 @@ class HwWalletRepoTest : BaseUnitTest() { whenever { hwWalletStore.setPendingName(any(), anyOrNull()) }.thenReturn(Unit) } + @Test + fun `fresh exact hardware observation keeps original account across wallet changes`() = test { + val txid = "ab".repeat(32) + val started = CompletableDeferred() + val finish = CompletableDeferred() + whenever(hwWalletStore.loadKnownDevices()).thenAnswer { storeData.value.knownDevices } + whenever(trezorRepo.getTransactionDetail("zpubNS", txid, Env.network.toCoreNetwork(), AccountType.NATIVE_SEGWIT)) + .doSuspendableAnswer { + started.complete(Unit) + finish.await() + Result.success(mock { + on { this.txid }.thenReturn(txid) + on { sent }.thenReturn(1uL) + }) + } + val sut = createRepo() + val result = async { sut.observeExactTransaction(HARDWARE_WALLET_ID, txid) } + started.await() + storeData.value = HwWalletData(knownDevices = listOf(hiddenWallet)) + finish.complete(Unit) + assertTrue(result.await().getOrThrow()) + verify(trezorRepo).getTransactionDetail("zpubNS", txid, Env.network.toCoreNetwork(), AccountType.NATIVE_SEGWIT) + verify(trezorRepo, never()).broadcastRawTx(any()) + } + + @Test + fun `fresh hardware observation rejects missing wallet mismatched txid and lookup errors`() = test { + val txid = "ab".repeat(32) + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) + val mismatchedDetail = mock { on { this.txid }.thenReturn("cd".repeat(32)) } + whenever(trezorRepo.getTransactionDetail("zpubNS", txid, Env.network.toCoreNetwork(), AccountType.NATIVE_SEGWIT)) + .thenReturn(Result.success(mismatchedDetail)) + .thenReturn(Result.failure(AppError("transaction not found"))) + val sut = createRepo() + assertFalse(sut.observeExactTransaction(HARDWARE_WALLET_ID, txid).getOrThrow()) + assertTrue(sut.observeExactTransaction(HARDWARE_WALLET_ID, txid).isFailure) + assertTrue(sut.observeExactTransaction(HIDDEN_WALLET_ID, txid).isFailure) + verify(trezorRepo, times(2)).getTransactionDetail("zpubNS", txid, Env.network.toCoreNetwork(), AccountType.NATIVE_SEGWIT) + verify(trezorRepo, never()).broadcastRawTx(any()) + } + + @Test + fun `fresh hardware observation rejects exact inbound transaction`() = test { + val txid = "ab".repeat(32) + val incoming = mock { + on { this.txid }.thenReturn(txid) + on { sent }.thenReturn(0uL) + } + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) + whenever(trezorRepo.getTransactionDetail("zpubNS", txid, Env.network.toCoreNetwork(), AccountType.NATIVE_SEGWIT)) + .thenReturn(Result.success(incoming)) + assertFalse(createRepo().observeExactTransaction(HARDWARE_WALLET_ID, txid).getOrThrow()) + verify(trezorRepo, never()).broadcastRawTx(any()) + } + private fun passphraseCapableFeatures(): TrezorFeatures = mock { on { passphraseProtection }.thenReturn(true) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 348dc0e310..f10e582d3f 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -56,6 +56,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { private val paykitSdkService = mock() private val lightningRepo = mock() private val store = mock() + private val hwWalletRepo = mock() private var storedProofs = emptyList() private var shouldFailNextLoad = false private var shouldFailNextSave = false @@ -908,11 +909,109 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(listOf(request.id), storedProofs.map { it.requestId }) } + @Test + fun `hardware core txid remains pending until fresh exact observation then resumes original proof`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val txid = "ab".repeat(32) + val walletId = "original-hardware-wallet" + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + whenever(hwWalletRepo.observeExactTransaction(walletId, txid)).thenReturn(Result.success(false)) + + assertFalse(repo.completeHardwareOnchainPayment(request.id, walletId, txid, LOCAL_IDENTITY)) + assertEquals(txid, storedProofs.single().paymentIdentifier) + assertNull(storedProofs.single().proofData) + assertFalse(storedProofs.single().onchainAcceptanceVerified) + assertEquals(PaykitPaymentRequestError.OperationInProgress, + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).exceptionOrNull()) + repo.failOnchainPayment(request) + assertEquals(1, storedProofs.size) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + + whenever(hwWalletRepo.observeExactTransaction(walletId, txid)).thenReturn(Result.success(true)) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + .thenReturn(paymentRequestRecord()) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedAttempt(request, "cd".repeat(32))) + paymentProofRepo().reconcile() + verify(lightningRepo, never()).completeAcceptedShopFollowup(any(), any()) + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), eq(MethodId.P2wpkh.rawValue), + eq("""{"data":"$txid","type":"bitcoin-onchain-txid"}"""), isNull()) + assertTrue(storedProofs.isEmpty()) + verify(hwWalletRepo, never()).broadcastFunding(any()) + } + + @Test + fun `hardware fresh observation submits completed durable proof with original wallet`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val txid = "cd".repeat(32) + val walletId = "original-hardware-wallet" + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + whenever(hwWalletRepo.observeExactTransaction(walletId, txid)).thenReturn(Result.success(true)) + // No delivery session: preserve the verified proof durably for later delivery. + assertTrue(repo.completeHardwareOnchainPayment(request.id, walletId, txid, LOCAL_IDENTITY)) + assertEquals(txid, storedProofs.single().proofData) + assertTrue(storedProofs.single().onchainAcceptanceVerified) + assertEquals(walletId, storedProofs.single().onchainWalletId) + assertEquals(request.id, repo.onchainPaymentResolutions.value.single().requestId) + verify(hwWalletRepo, never()).broadcastFunding(any()) + } + + @Test + fun `hardware observation errors and changed transaction id retain only the original pending lookup`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val txid = "ab".repeat(32) + val walletId = "original-hardware-wallet" + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + whenever(hwWalletRepo.observeExactTransaction(walletId, txid)).thenReturn(Result.failure(AppError("lookup failed"))) + assertFalse(repo.completeHardwareOnchainPayment(request.id, walletId, txid, LOCAL_IDENTITY)) + assertFalse(repo.completeHardwareOnchainPayment(request.id, "different-wallet", txid, LOCAL_IDENTITY)) + assertFalse(repo.completeHardwareOnchainPayment(request.id, walletId, "cd".repeat(32), LOCAL_IDENTITY)) + repo.reconcile() + assertEquals(txid, storedProofs.single().paymentIdentifier) + assertNull(storedProofs.single().proofData) + assertFalse(storedProofs.single().onchainAcceptanceVerified) + verify(hwWalletRepo, times(2)).observeExactTransaction(walletId, txid) + verify(hwWalletRepo, never()).broadcastFunding(any()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + } + + @Test + fun `hardware callback cannot verify another identity with the same request and wallet`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val walletId = "original-hardware-wallet" + val txid = "ab".repeat(32) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + val original = storedProofs.single() + val other = original.copy(identity = COUNTERPARTY) + storedProofs = listOf(other) + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(COUNTERPARTY, true)) + whenever(hwWalletRepo.observeExactTransaction(walletId, txid)).thenReturn(Result.success(true)) + + assertFalse(repo.completeHardwareOnchainPayment(request.id, walletId, txid, LOCAL_IDENTITY)) + assertEquals(listOf(other), storedProofs) + verify(hwWalletRepo, never()).observeExactTransaction(any(), any()) + + storedProofs = listOf(original, other) + assertTrue(repo.completeHardwareOnchainPayment(request.id, walletId, txid, LOCAL_IDENTITY)) + assertTrue(storedProofs.first { it.identity == LOCAL_IDENTITY }.onchainAcceptanceVerified) + assertEquals(other, storedProofs.first { it.identity == COUNTERPARTY }) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + } + private fun paymentProofRepo() = PaykitPaymentProofRepo( ioDispatcher = testDispatcher, paykitSdkService = paykitSdkService, lightningRepo = lightningRepo, store = store, + hwWalletRepo = hwWalletRepo, ) private fun acceptedAttempt(request: PaykitPaymentRequest, txid: String) = OnchainSendAttempt( diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt index 5e700b8c11..a2890a743d 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt @@ -26,6 +26,7 @@ import to.bitkit.models.Toast import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.PreActivityMetadataRepo +import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.services.ActivityService import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest @@ -317,6 +318,23 @@ class HwSendViewModelTest : BaseUnitTest() { return PaymentFixture(funding, signedTx, broadcast) } + @Test + fun `core completed hardware result retains original request and never rebroadcasts while proof is pending`() = test { + val fixture = stubSuccessfulPayment() + val originalId = PaykitPaymentRequestId("original-request", "counterparty", "receiver") + val original = request().copy(paymentRequestId = originalId, paymentIdentity = "original-identity") + sut.signAndBroadcast(original) + advanceUntilIdle() + + assertEquals(originalId, sut.results.first().paymentRequestId) + assertEquals("original-identity", sut.results.first().paymentIdentity) + // Local proof work has not consumed the result yet: neither this request nor another may resend. + sut.signAndBroadcast(original) + sut.signAndBroadcast(original.copy(paymentRequestId = originalId.copy(paymentRequestId = "different-request"))) + advanceUntilIdle() + verify(hwWalletRepo, times(1)).broadcastFunding(fixture.signedTx) + } + private fun request() = HwSendRequest( walletId = WALLET_ID, address = ADDRESS, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 9efcaf4af3..ccbb29ab22 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -6558,6 +6558,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `in flight proof blocks switching to a hardware payment`() = test { + pubkyPublicKey.value = testPublicKey val request = paymentRequest() val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) whenever(paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain)) @@ -6575,7 +6576,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `approved hardware payment request preparation is idempotent`() = test { + fun `started hardware payment request blocks another preparation instead of reusing approval`() = test { + pubkyPublicKey.value = testPublicKey val request = paymentRequest() val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) @@ -6593,23 +6595,20 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) ) + whenever(paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain)) + .thenReturn(Result.success(Unit), Result.failure(PaykitPaymentRequestError.OperationInProgress)) assertTrue(sut.prepareHardwareContactPayment()) - assertTrue(sut.prepareHardwareContactPayment()) + assertFalse(sut.prepareHardwareContactPayment()) - inOrder(paykitPaymentProofRepo, privatePaykitRepo, paykitPaymentRequestRepo).apply { - verify(paykitPaymentProofRepo).prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain) - verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) - verify(paykitPaymentRequestRepo).accept(request) - verify(paykitPaymentProofRepo).markOnchainPaymentStarted( - request, - "bcrt1qpaymentrequest", - "hardware-wallet", - ) - } + verify(paykitPaymentProofRepo, times(2)).prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain) + verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) + verify(paykitPaymentRequestRepo).accept(request) + verify(paykitPaymentProofRepo).markOnchainPaymentStarted(request, "bcrt1qpaymentrequest", "hardware-wallet") } @Test fun `cancelling hardware signing fails the started payment proof`() = test { + pubkyPublicKey.value = testPublicKey val request = paymentRequest() val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) @@ -6636,6 +6635,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `dismissing hardware signing fails the started payment proof`() = test { + pubkyPublicKey.value = testPublicKey val request = paymentRequest() val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) @@ -6663,7 +6663,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `proof preparation failure does not block hardware payment request`() = test { + fun `proof preparation failure blocks hardware payment request before broadcast`() = test { + pubkyPublicKey.value = testPublicKey val request = paymentRequest() val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) whenever(paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain)) @@ -6682,15 +6683,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ), ) - assertTrue(sut.prepareHardwareContactPayment()) + assertFalse(sut.prepareHardwareContactPayment()) - verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) - verify(paykitPaymentRequestRepo).accept(request) + verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) + verify(paykitPaymentRequestRepo, never()).accept(any()) verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any()) } @Test - fun `hardware payment request forwards txid without claiming typed acceptance`() = test { + fun `hardware payment request waits for pending proof retention without claiming typed acceptance`() = test { + pubkyPublicKey.value = testPublicKey val request = paymentRequest() val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) val completionStarted = CompletableDeferred() @@ -6698,7 +6700,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) - whenever(paykitPaymentProofRepo.completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue)) + whenever(paykitPaymentProofRepo.completeHardwareOnchainPayment(request.id, "hardware-wallet", "txid", testPublicKey)) .doSuspendableAnswer { completionStarted.complete(Unit) finishCompletion.await() @@ -6716,15 +6718,63 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) assertTrue(sut.prepareHardwareContactPayment()) - sut.completeHardwareContactPayment("txid") + val completion = backgroundScope.launch { sut.completeHardwareContactPayment("txid", "hardware-wallet", request.id, testPublicKey) } runCurrent() completionStarted.await() + assertFalse(completion.isCompleted) assertFalse(finishCompletion.isCompleted) finishCompletion.complete(Unit) advanceUntilIdle() - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) + verify(paykitPaymentProofRepo).completeHardwareOnchainPayment(request.id, "hardware-wallet", "txid", testPublicKey) + } + + @Test + fun `hardware proof callback keeps original request and wallet after screen context changes`() = test { + pubkyPublicKey.value = testPublicKey + val request = paymentRequest() + val txid = "ab".repeat(32) + val walletId = "original-hardware-wallet" + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = request) + setSendState(SendUiState(address = "bcrt1qpaymentrequest", amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, hardwareWalletId = walletId, isPaymentRequest = true)) + assertTrue(sut.prepareHardwareContactPayment()) + pubkyPublicKey.value = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = request.copy(paymentRequestId = "another-request")) + setSendState(SendUiState(address = "bcrt1qother", amount = 9_000uL, + payMethod = SendMethod.ONCHAIN, hardwareWalletId = "another-wallet", isPaymentRequest = true)) + whenever(paykitPaymentProofRepo.completeHardwareOnchainPayment(request.id, walletId, txid, testPublicKey)) + .thenReturn(true) + + assertFalse(sut.completeHardwareContactPayment(txid, walletId, request.id, testPublicKey)) + verify(paykitPaymentProofRepo).completeHardwareOnchainPayment(request.id, walletId, txid, testPublicKey) + verify(lightningRepo, never()).sendOnChain(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull()) + } + + @Test + fun `pending hardware proof resolution uses original wallet and amount without another payment`() = test { + pubkyPublicKey.value = testPublicKey + val request = paymentRequest() + val txid = "ab".repeat(32) + val walletId = "original-hardware-wallet" + pubkyPublicKey.value = testPublicKey + setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = request) + setSendState(SendUiState(address = "bcrt1qpaymentrequest", amount = 9_000uL, + payMethod = SendMethod.ONCHAIN, hardwareWalletId = "different-selected-wallet", + incomingPaymentRequestId = request.id, isPaymentRequest = true)) + sut.showSheet(Sheet.Send(SendRoute.Pending(txid, request.amountSats.toLong(), false, isOnchain = true))) + whenever(paykitPaymentProofRepo.completeHardwareOnchainPayment(request.id, walletId, txid, testPublicKey)).thenReturn(false) + assertFalse(sut.completeHardwareContactPayment(txid, walletId, request.id, testPublicKey)) + onchainPaymentResolutions.value = listOf(PaykitOnchainPaymentProofResolution( + testPublicKey, request.id, txid, walletId, request.amountSats, + )) + runCurrent() + assertEquals(txid, sut.successSendUiState.value.paymentHashOrTxId) + assertEquals(walletId, sut.successSendUiState.value.activityWalletId) + assertEquals(request.amountSats.toLong(), sut.successSendUiState.value.sats) + verify(lightningRepo, never()).sendOnChain(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull()) } @Test diff --git a/journeys/hardware-wallet/shop-onchain-proof.xml b/journeys/hardware-wallet/shop-onchain-proof.xml new file mode 100644 index 0000000000..6133b1187e --- /dev/null +++ b/journeys/hardware-wallet/shop-onchain-proof.xml @@ -0,0 +1,23 @@ + + + Pays a linked issuer's one-time on-chain request with the paired, funded Bridge Trezor emulator. + Requires the hardware-wallet Bridge setup and payment-requests issuer fixture; the hardware + native-segwit account must cover 100,000 sats plus fees. Observe the exact returned transaction + in the original hardware account and match the issuer's delivered proof to that transaction. + This journey does not inject native broadcast faults or prove physical USB/BLE behavior. + + + Launch the E2E Bitkit app on regtest with Paykit UI enabled, a Pubky identity authenticated, the fixture issuer saved and linked on receiver path "bitkit/server", and the funded Bridge hardware wallet paired + Have the issuer publish a current regtest P2WPKH destination under identifier "btc-regtest-p2wpkh" with JSON payload {"value":"<current address>"} + Have the issuer send a new proposed one-time Payment Request for amount "0.001", asset "btc", and accepted identifier "btc-regtest-p2wpkh"; record its exact request ID and payer identity for the backend proof check + Verify Payment Request confirmation (testTag "PaymentRequestConfirm") shows 100,000 sats and the saved issuer contact under Show details (testTag "SendConfirmToggleDetails") + Tap the funding-source button (testTag "SendConfirmAssetButton"), waiting for balance loading between taps, until FROM shows the paired hardware wallet + Swipe to confirm; verify hardware sign shows the original issuer destination (testTag "HardwareSendAddress") and 100,000 sats (testTag "HardwareSendAmount") + Tap Open Trezor Connect (testTag "HardwareSendOpenTrezorConnect") once and approve the Bridge emulator's transaction prompts + While signing or local proof observation is running, verify another tap cannot start another payment; wait for Success (testTag "SendSuccess") or the existing Payment Pending screen + If Pending is shown, verify there is no resend action; close the sheet and reopen the same request from Payment Requests and verify it cannot start a fresh hardware payment + Once the original transaction is visible to Electrum, resume Bitkit to allow existing proof reconciliation; verify the same original payment finishes or its request leaves the pending list, without another signing prompt + Open the original hardware wallet's activity and record the exact new transaction ID; independently read that transaction from the regtest backend and verify it spends that hardware account's inputs + Inspect the fixture issuer's proof for the original request and payer identity; verify type "bitcoin-onchain-txid" contains exactly the recorded original transaction ID, and the backend contains only the original payment + + From 9de645a5d11b05362d37b7d726994b3692569759 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 05:13:06 +0200 Subject: [PATCH 009/108] fix: protect unresolved hardware shop payments --- .../to/bitkit/repositories/ActivityRepo.kt | 23 +++++ .../to/bitkit/repositories/HwWalletRepo.kt | 20 +++- .../repositories/PaykitPaymentProofRepo.kt | 5 +- .../screens/wallets/send/HwSendViewModel.kt | 3 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 6 +- .../bitkit/repositories/ActivityRepoTest.kt | 26 ++++++ .../bitkit/repositories/HwWalletRepoTest.kt | 28 ++++++ .../PaykitPaymentProofRepoTest.kt | 91 +++++++++++++++++-- .../wallets/send/HwSendViewModelTest.kt | 25 +++++ .../viewmodels/AppViewModelSendFlowTest.kt | 43 ++++++++- gradle/libs.versions.toml | 2 +- .../hardware-wallet/shop-onchain-proof.xml | 6 +- journeys/send/onchain-accepted-result.xml | 4 +- 13 files changed, 261 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 4119de267f..6190e1132a 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -203,6 +203,29 @@ class ActivityRepo @Inject constructor( walletId: String = WalletScope.default, ): OnchainActivity? = coreService.activity.getOnchainActivityByTxId(txid, walletId) + /** Local follow-up after the original hardware wallet independently observed this exact outgoing tx. */ + suspend fun completeObservedHardwarePayment( + walletId: String, + txid: String, + address: String, + amountSats: ULong, + fee: ULong, + feeRate: ULong, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + require(walletId != WalletScope.default) + coreService.activity.createSentOnchainActivityFromSendResult( + txid = txid, address = address, amount = amountSats, fee = fee, feeRate = feeRate, + isTransfer = false, channelId = null, walletId = walletId, + ) + // The Core writer logs storage errors internally. Read back before completing the proof. + val activity = getOnchainActivityByTxId(txid, walletId) + check(activity?.walletId == walletId && activity.txId.equals(txid, true) && + activity.txType == PaymentType.SENT) { "Original hardware payment activity is not durable" } + notifyPaymentActivityChanged() + } + } + /** * Checks if a transaction is inbound (received) by looking up the payment direction. */ diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index a7b4202cc1..86dd236729 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -549,7 +549,12 @@ class HwWalletRepo @Inject constructor( } /** Fresh backend observation of this exact transaction in the original hardware wallet. */ - suspend fun observeExactTransaction(walletId: String, txid: String): Result = withContext(ioDispatcher) { + suspend fun observeExactTransaction( + walletId: String, + txid: String, + originalAddress: String? = null, + originalAmountSats: ULong? = null, + ): Result = withContext(ioDispatcher) { runSuspendCatching { require(walletId != WalletScope.default && txid.matches(Regex("[0-9a-fA-F]{64}"))) val account = getFundingAccount(walletId).getOrThrow() @@ -559,7 +564,18 @@ class HwWalletRepo @Inject constructor( network = Env.network.toCoreNetwork(), scriptType = account.accountType, ).getOrThrow() - detail.txid.equals(txid, ignoreCase = true) && detail.sent > 0uL + if (!detail.txid.equals(txid, ignoreCase = true) || detail.sent == 0uL) { + return@runSuspendCatching false + } + if (originalAddress != null || originalAmountSats != null) { + val address = requireNotNull(originalAddress).also { require(it.isNotBlank()) } + val amount = requireNotNull(originalAmountSats) + val fee = requireNotNull(detail.fee) + val rate = requireNotNull(detail.feeRate).also { require(it.isFinite() && it >= 0.0) } + activityRepo.completeObservedHardwarePayment(walletId, txid, address, amount, fee, ceil(rate).toULong()) + .getOrThrow() + } + true } } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index cc086a020a..d5011a8f1d 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -380,6 +380,7 @@ class PaykitPaymentProofRepo @Inject constructor( suspend fun failOnchainPayment(request: PaykitPaymentRequest) { removeRequestProofs(request) { it.kind == PaykitPaymentProofKind.Onchain && + it.onchainWalletId == WalletScope.default && it.paymentStarted && it.paymentIdentifier == null && it.proofData == null @@ -568,8 +569,10 @@ class PaykitPaymentProofRepo @Inject constructor( (proof.proofData != null && !proof.proofData.equals(txid, true)) ) return false if (!proof.onchainAcceptanceVerified) { + val address = proof.onchainAddress?.takeIf { it.isNotBlank() } ?: return false + val amount = proof.onchainAmountSats ?: return false val observed = withTimeoutOrNull(HARDWARE_OBSERVATION_TIMEOUT) { - hwWalletRepo.observeExactTransaction(proof.onchainWalletId, txid).getOrDefault(false) + hwWalletRepo.observeExactTransaction(proof.onchainWalletId, txid, address, amount).getOrDefault(false) } == true if (!observed) return false } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt index 19e892d409..c90db51468 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt @@ -254,6 +254,9 @@ class HwSendViewModel @Inject constructor( walletId = request.walletId, ) } + // A Core txid alone is not positive evidence for a Shop payment. Its original proof + // completes local activity after a fresh exact outgoing transaction observation. + if (request.paymentRequestId != null) return coreService.activity.createSentOnchainActivityFromSendResult( txid = result.txId, address = request.address, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index d1ad361951..f5374e1b81 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -5312,14 +5312,16 @@ class AppViewModel @Inject constructor( ?.takeIf { it == preparedContactPaymentContext && _sendUiState.value.hardwareWalletId != null } ?.incomingPaymentRequest } - if (request == null) { + // A returned Core result may still lack a durable txid when its candidate save failed. + // Dismissing that uncertain original payment must not reopen its started proof for a new send. + if (request == null || uncertainOnchainPaymentRequestId == request.id) { isSubmittingPaymentRequest = false return } viewModelScope.launch { try { - paykitPaymentProofRepo.failOnchainPayment(request) + paykitPaymentProofRepo.cancelPreparation(request) } finally { isSubmittingPaymentRequest = false } diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index 61b8b3e102..a0af3c2f05 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -24,6 +24,7 @@ import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.kotlin.wheneverBlocking @@ -157,6 +158,31 @@ class ActivityRepoTest : BaseUnitTest() { ) } + @Test + fun `observed hardware payment completes only after original Sent activity is durable`() = test { + val txid = "ab".repeat(32) + val wallet = "original-hardware-wallet" + val original = baseOnchainActivity.copy(walletId = wallet, txId = txid) + whenever(coreService.activity.getOnchainActivityByTxId(txid, wallet)) + .thenReturn(null) + .thenReturn(original.copy(walletId = "different-wallet")) + .thenReturn(original) + val before = sut.activitiesChanged.value + + assertTrue(sut.completeObservedHardwarePayment(wallet, txid, original.address, original.value, + original.fee, original.feeRate).isFailure) + assertEquals(before, sut.activitiesChanged.value) + assertTrue(sut.completeObservedHardwarePayment(wallet, txid, original.address, original.value, + original.fee, original.feeRate).isFailure) + assertEquals(before, sut.activitiesChanged.value) + sut.completeObservedHardwarePayment(wallet, txid, original.address, original.value, + original.fee, original.feeRate).getOrThrow() + assertTrue(sut.activitiesChanged.value > before) + verify(coreService.activity, times(3)).createSentOnchainActivityFromSendResult( + txid, original.address, original.value, original.fee, original.feeRate, false, null, wallet, + ) + } + private fun setupSyncActivitiesMocks( cacheData: AppCacheData, ) { diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index dcfe6e3c65..498006f9f0 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -164,6 +164,34 @@ class HwWalletRepoTest : BaseUnitTest() { verify(trezorRepo, never()).broadcastRawTx(any()) } + @Test + fun `observed hardware Shop activity failure retries original transaction without broadcast`() = test { + val txid = "ab".repeat(32) + val address = "bcrt1-original-shop-address" + val amount = 25000uL + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) + val detail = mock { + on { this.txid }.thenReturn(txid) + on { sent }.thenReturn(26000uL) + on { fee }.thenReturn(1000uL) + on { feeRate }.thenReturn(2.0) + } + whenever(trezorRepo.getTransactionDetail("zpubNS", txid, Env.network.toCoreNetwork(), AccountType.NATIVE_SEGWIT)) + .thenReturn(Result.success(detail)) + whenever(activityRepo.completeObservedHardwarePayment(HARDWARE_WALLET_ID, txid, address, amount, 1000uL, 2uL)) + .thenReturn(Result.failure(AppError("local write failed"))) + .thenReturn(Result.success(Unit)) + + val sut = createRepo() + assertTrue(sut.observeExactTransaction(HARDWARE_WALLET_ID, txid, address, amount).isFailure) + assertTrue(sut.observeExactTransaction(HARDWARE_WALLET_ID, txid, address, amount).getOrThrow()) + verify(activityRepo, times(2)) + .completeObservedHardwarePayment(HARDWARE_WALLET_ID, txid, address, amount, 1000uL, 2uL) + verify(trezorRepo, times(2)) + .getTransactionDetail("zpubNS", txid, Env.network.toCoreNetwork(), AccountType.NATIVE_SEGWIT) + verify(trezorRepo, never()).broadcastRawTx(any()) + } + @Test fun `fresh hardware observation rejects missing wallet mismatched txid and lookup errors`() = test { val txid = "ab".repeat(32) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index f10e582d3f..6031100e9b 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -1,5 +1,8 @@ package to.bitkit.repositories +import android.content.Context +import com.synonym.bitkitcore.BroadcastException +import com.synonym.bitkitcore.TrezorFeatures import com.synonym.paykit.BillingPeriod import com.synonym.paykit.IdentityStatus import com.synonym.paykit.PaymentProofRecord @@ -11,6 +14,7 @@ import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivateJsonObject import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.NodeException @@ -30,10 +34,15 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.models.NodeLifecycleState +import to.bitkit.models.HwFundingSignedTx +import to.bitkit.models.HwFundingTransaction import to.bitkit.models.WalletScope import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService +import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.screens.wallets.send.HwSendRequest +import to.bitkit.ui.screens.wallets.send.HwSendViewModel import to.bitkit.utils.AppError import to.bitkit.utils.LdkError import to.bitkit.utils.ServiceError @@ -917,7 +926,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() - whenever(hwWalletRepo.observeExactTransaction(walletId, txid)).thenReturn(Result.success(false)) + whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)).thenReturn(Result.success(false)) assertFalse(repo.completeHardwareOnchainPayment(request.id, walletId, txid, LOCAL_IDENTITY)) assertEquals(txid, storedProofs.single().paymentIdentifier) @@ -929,7 +938,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(1, storedProofs.size) verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) - whenever(hwWalletRepo.observeExactTransaction(walletId, txid)).thenReturn(Result.success(true)) + whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)).thenReturn(Result.success(true)) whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) .thenReturn(paymentRequestRecord()) @@ -950,7 +959,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() - whenever(hwWalletRepo.observeExactTransaction(walletId, txid)).thenReturn(Result.success(true)) + whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)).thenReturn(Result.success(true)) // No delivery session: preserve the verified proof durably for later delivery. assertTrue(repo.completeHardwareOnchainPayment(request.id, walletId, txid, LOCAL_IDENTITY)) assertEquals(txid, storedProofs.single().proofData) @@ -968,7 +977,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() - whenever(hwWalletRepo.observeExactTransaction(walletId, txid)).thenReturn(Result.failure(AppError("lookup failed"))) + whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)).thenReturn(Result.failure(AppError("lookup failed"))) assertFalse(repo.completeHardwareOnchainPayment(request.id, walletId, txid, LOCAL_IDENTITY)) assertFalse(repo.completeHardwareOnchainPayment(request.id, "different-wallet", txid, LOCAL_IDENTITY)) assertFalse(repo.completeHardwareOnchainPayment(request.id, walletId, "cd".repeat(32), LOCAL_IDENTITY)) @@ -976,7 +985,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(txid, storedProofs.single().paymentIdentifier) assertNull(storedProofs.single().proofData) assertFalse(storedProofs.single().onchainAcceptanceVerified) - verify(hwWalletRepo, times(2)).observeExactTransaction(walletId, txid) + verify(hwWalletRepo, times(2)).observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats) verify(hwWalletRepo, never()).broadcastFunding(any()) verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) } @@ -993,11 +1002,11 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val other = original.copy(identity = COUNTERPARTY) storedProofs = listOf(other) whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(COUNTERPARTY, true)) - whenever(hwWalletRepo.observeExactTransaction(walletId, txid)).thenReturn(Result.success(true)) + whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)).thenReturn(Result.success(true)) assertFalse(repo.completeHardwareOnchainPayment(request.id, walletId, txid, LOCAL_IDENTITY)) assertEquals(listOf(other), storedProofs) - verify(hwWalletRepo, never()).observeExactTransaction(any(), any()) + verify(hwWalletRepo, never()).observeExactTransaction(any(), any(), any(), any()) storedProofs = listOf(original, other) assertTrue(repo.completeHardwareOnchainPayment(request.id, walletId, txid, LOCAL_IDENTITY)) @@ -1006,6 +1015,74 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) } + @Test + fun `Core exception after hardware Shop start survives dismissal and reopen without a new payment`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val walletId = "original-hardware-wallet" + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.cancelPreparation(request) + assertTrue(storedProofs.isEmpty()) + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + val context = mock() + whenever(context.getString(any())).thenReturn("message") + val features = mock() + val funding = HwFundingTransaction("psbt", 1000uL, 2.0f, 2000uL, 2uL) + val signed = HwFundingSignedTx("signed-fixture-tx", 1000uL, 2uL, 2000uL) + whenever(hwWalletRepo.needsPassphrase(walletId)).thenReturn(false) + whenever(hwWalletRepo.ensureConnected(walletId)).thenReturn(Result.success(features)) + whenever(hwWalletRepo.composeFundingTransaction(walletId, ONCHAIN_ADDRESS, request.amountSats, 2uL)) + .thenReturn(Result.success(funding)) + whenever(hwWalletRepo.signFunding(walletId, funding)).thenReturn(Result.success(signed)) + whenever(hwWalletRepo.broadcastFunding(signed)) + .thenReturn(Result.failure(BroadcastException.ElectrumException("response lost after dispatch"))) + val send = HwSendViewModel(context, hwWalletRepo, mock(), mock(), mock()) + send.signAndBroadcast(HwSendRequest(walletId, ONCHAIN_ADDRESS, request.amountSats, 2uL, emptyList(), + request.id, LOCAL_IDENTITY)) { + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + true + } + advanceUntilIdle() + assertFalse(send.uiState.value.isSigning) + assertFalse(send.uiState.value.isBroadcastUnresolved) + assertTrue(storedProofs.single().paymentStarted) + assertNull(storedProofs.single().paymentIdentifier) + + // Both generic failure and preparation cancellation must preserve an already dispatched Shop payment. + repo.failOnchainPayment(request) + repo.cancelPreparation(request) + send.cancel() + assertTrue(storedProofs.single().paymentStarted) + assertNull(storedProofs.single().proofData) + assertEquals(PaykitPaymentRequestError.OperationInProgress, + paymentProofRepo().prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain) + .exceptionOrNull()) + verify(hwWalletRepo, times(1)).broadcastFunding(signed) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + } + + @Test + fun `hardware candidate save failure leaves started proof blocking a fresh payment`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val walletId = "hardware-wallet" + val txid = "ab".repeat(32) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + val original = storedProofs.single() + shouldFailNextSave = true + + assertFalse(repo.completeHardwareOnchainPayment(request.id, walletId, txid, LOCAL_IDENTITY)) + assertEquals(listOf(original), storedProofs) + assertTrue(original.paymentStarted) + assertNull(original.paymentIdentifier) + assertNull(original.proofData) + assertTrue(repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain) + .exceptionOrNull() is PaykitPaymentRequestError.OperationInProgress) + verify(hwWalletRepo, never()).observeExactTransaction(any(), any(), any(), any()) + verify(hwWalletRepo, never()).broadcastFunding(any()) + } + private fun paymentProofRepo() = PaykitPaymentProofRepo( ioDispatcher = testDispatcher, paykitSdkService = paykitSdkService, diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt index a2890a743d..f7b435524e 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt @@ -335,6 +335,31 @@ class HwSendViewModelTest : BaseUnitTest() { verify(hwWalletRepo, times(1)).broadcastFunding(fixture.signedTx) } + @Test + fun `hardware Shop core result does not create Sent activity before exact observation`() = test { + val fixture = stubSuccessfulPayment() + val original = request().copy(paymentRequestId = PaykitPaymentRequestId("request", "counterparty", "receiver")) + sut.signAndBroadcast(original) + advanceUntilIdle() + assertEquals(fixture.broadcast.txId, sut.results.first().txId) + verify(activityService, never()).createSentOnchainActivityFromSendResult( + any(), any(), any(), any(), any(), any(), org.mockito.kotlin.anyOrNull(), any(), + ) + verify(activityRepo, never()).notifyPaymentActivityChanged() + verify(hwWalletRepo, times(1)).broadcastFunding(fixture.signedTx) + } + + @Test + fun `ordinary hardware Core result preserves existing Sent activity behavior`() = test { + val fixture = stubSuccessfulPayment() + sut.signAndBroadcast(request()) + advanceUntilIdle() + verify(activityService).createSentOnchainActivityFromSendResult( + fixture.broadcast.txId, ADDRESS, AMOUNT_SATS, fixture.broadcast.miningFeeSats, + fixture.broadcast.feeRate, false, null, WALLET_ID, + ) + } + private fun request() = HwSendRequest( walletId = WALLET_ID, address = ADDRESS, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index ccbb29ab22..6e07c36bf7 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -6607,7 +6607,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `cancelling hardware signing fails the started payment proof`() = test { + fun `cancelling hardware signing only cancels unstarted preparation`() = test { pubkyPublicKey.value = testPublicKey val request = paymentRequest() val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) @@ -6630,11 +6630,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.onHardwareSignCancelled() advanceUntilIdle() - verify(paykitPaymentProofRepo).failOnchainPayment(request) + verify(paykitPaymentProofRepo).cancelPreparation(request) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(request) } @Test - fun `dismissing hardware signing fails the started payment proof`() = test { + fun `dismissing hardware signing only cancels unstarted preparation`() = test { pubkyPublicKey.value = testPublicKey val request = paymentRequest() val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) @@ -6659,7 +6660,41 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.hideSheet() advanceUntilIdle() - verify(paykitPaymentProofRepo).failOnchainPayment(request) + verify(paykitPaymentProofRepo).cancelPreparation(request) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(request) + } + + @Test + fun `hardware result persistence failure then dismissal preserves original started guard`() = test { + pubkyPublicKey.value = testPublicKey + val request = paymentRequest() + val txid = "ab".repeat(32) + val walletId = "hardware-wallet" + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain)) + .thenReturn(Result.success(Unit), Result.failure(PaykitPaymentRequestError.OperationInProgress)) + // Core returned, but retaining its candidate txid failed: durable proof is still started with no identifier. + whenever(paykitPaymentProofRepo.completeHardwareOnchainPayment(request.id, walletId, txid, testPublicKey)) + .thenReturn(false) + setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = request) + setSendState(SendUiState(address = "bcrt1qpaymentrequest", amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, hardwareWalletId = walletId, isPaymentRequest = true)) + sut.showSheet(Sheet.Send(SendRoute.HardwareSign)) + advanceUntilIdle() + assertTrue(sut.prepareHardwareContactPayment()) + assertFalse(sut.completeHardwareContactPayment(txid, walletId, request.id, testPublicKey)) + + sut.onHardwareSignCancelled() + sut.hideSheet() + advanceUntilIdle() + verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) + verify(paykitPaymentProofRepo, never()).cancelPreparation(any()) + + setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = request) + assertFalse(sut.prepareHardwareContactPayment(walletId, "bcrt1qpaymentrequest", request.id, testPublicKey)) + verify(paykitPaymentRequestRepo, times(1)).accept(request) + verify(paykitPaymentProofRepo, times(1)).markOnchainPaymentStarted(request, "bcrt1qpaymentrequest", walletId) + verify(lightningRepo, never()).sendOnChain(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull()) } @Test diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bc630bebd6..f128d4030c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -65,7 +65,7 @@ ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "k ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.67" } +ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.68" } lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } diff --git a/journeys/hardware-wallet/shop-onchain-proof.xml b/journeys/hardware-wallet/shop-onchain-proof.xml index 6133b1187e..ab8cd68999 100644 --- a/journeys/hardware-wallet/shop-onchain-proof.xml +++ b/journeys/hardware-wallet/shop-onchain-proof.xml @@ -4,6 +4,8 @@ Requires the hardware-wallet Bridge setup and payment-requests issuer fixture; the hardware native-segwit account must cover 100,000 sats plus fees. Observe the exact returned transaction in the original hardware account and match the issuer's delivered proof to that transaction. + If Core fails after payment has started and returns no transaction ID, dismissing and reopening + must keep that original request pending; this journey cannot inject that response-loss condition. This journey does not inject native broadcast faults or prove physical USB/BLE behavior. @@ -15,8 +17,8 @@ Swipe to confirm; verify hardware sign shows the original issuer destination (testTag "HardwareSendAddress") and 100,000 sats (testTag "HardwareSendAmount") Tap Open Trezor Connect (testTag "HardwareSendOpenTrezorConnect") once and approve the Bridge emulator's transaction prompts While signing or local proof observation is running, verify another tap cannot start another payment; wait for Success (testTag "SendSuccess") or the existing Payment Pending screen - If Pending is shown, verify there is no resend action; close the sheet and reopen the same request from Payment Requests and verify it cannot start a fresh hardware payment - Once the original transaction is visible to Electrum, resume Bitkit to allow existing proof reconciliation; verify the same original payment finishes or its request leaves the pending list, without another signing prompt + If Pending is shown, verify there is no resend action or Sent activity from the returned Core transaction ID alone; close the sheet and reopen the same request from Payment Requests and verify it cannot start a fresh hardware payment + Once the original outgoing transaction is visible to Electrum, resume Bitkit to allow existing proof reconciliation; verify the same original payment creates Sent activity in its original hardware wallet and finishes or its request leaves the pending list, without another signing prompt Open the original hardware wallet's activity and record the exact new transaction ID; independently read that transaction from the regtest backend and verify it spends that hardware account's inputs Inspect the fixture issuer's proof for the original request and payer identity; verify type "bitcoin-onchain-txid" contains exactly the recorded original transaction ID, and the backend contains only the original payment diff --git a/journeys/send/onchain-accepted-result.xml b/journeys/send/onchain-accepted-result.xml index 7507cdfe84..9d18c74341 100644 --- a/journeys/send/onchain-accepted-result.xml +++ b/journeys/send/onchain-accepted-result.xml @@ -13,12 +13,12 @@ Verify the Send Amount screen is visible with 1 000 sats and Savings selected Tap Continue (tag "ContinueAmount"), expand Show Details (tag "SendConfirmToggleDetails") if collapsed, and verify the Confirm screen shows the receiving test wallet address (tag "ReviewUri") Swipe the send handle (tag "GRAB") fully to the right - Wait for the sent transaction sheet (tag "new_transaction_sheet") and verify it shows the accepted payment amount + Wait for the Bitcoin Sent success screen (tag "SendSuccess") and verify it shows the accepted payment amount Tap Details (tag "Details"), then Explore (tag "ActivityTxDetails"), and verify the outbound transaction shows a transaction ID (tag "TXID"); record that exact ID without inferring a different transaction from address or amount Close the transaction details and return to the wallet home screen Hand the receiving test wallet address to the app again with adb shell am start -a android.intent.action.VIEW -d "bitcoin:<test recipient address>" to.bitkit.dev Tap the available balance (tag "AvailableAmount") on Send Amount to choose Max, tap Continue (tag "ContinueAmount"), expand Show Details (tag "SendConfirmToggleDetails") if collapsed, and verify the Confirm screen shows the receiving test wallet address (tag "ReviewUri") Swipe the send handle (tag "GRAB") fully to the right, then tap Yes, Send (tag "DialogConfirm") if the over-50%-of-balance confirmation appears - Wait for the sent transaction sheet (tag "new_transaction_sheet"), tap Details (tag "Details"), then Explore (tag "ActivityTxDetails"), and verify this send-all transaction shows its own exact transaction ID (tag "TXID") + Wait for the Bitcoin Sent success screen (tag "SendSuccess"), tap Details (tag "Details"), then Explore (tag "ActivityTxDetails"), and verify this send-all transaction shows its own exact transaction ID (tag "TXID") From 6c75463b40df36ab1d5e7af7c5918c2d4f0adaf4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 16:10:15 +0200 Subject: [PATCH 010/108] fix: preserve paid funding after local follow-up failure --- .../to/bitkit/viewmodels/TransferViewModel.kt | 6 +- .../viewmodels/TransferViewModelTest.kt | 65 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 012989fd37..5965e42e21 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -385,7 +385,8 @@ class TransferViewModel @Inject constructor( preTransferOnchainSats = previous.transferContext?.preTransferOnchainSats, requireTransferPersisted = true, ) - lightningRepo.completeAcceptedTransferFollowup(order.id, txid) + runSuspendCatching { lightningRepo.completeAcceptedTransferFollowup(order.id, txid) } + .onFailure { Logger.warn("Failed to finish accepted transfer locally", it, context = TAG) } } } return true @@ -448,7 +449,8 @@ class TransferViewModel @Inject constructor( preTransferOnchainSats = transferContext.preTransferOnchainSats, requireTransferPersisted = true, ) - lightningRepo.completeAcceptedTransferFollowup(order.id, outcome.txid) + runSuspendCatching { lightningRepo.completeAcceptedTransferFollowup(order.id, outcome.txid) } + .onFailure { Logger.warn("Failed to finish accepted transfer locally", it, context = TAG) } } true }, diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index ff7e2c1155..7c04926947 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -1309,17 +1309,82 @@ class TransferViewModelTest : BaseUnitTest() { whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) whenever(transferRepo.persistAcceptedFunding(order, TXID, original)) .thenReturn(Result.failure(AppError("transfer storage unavailable")), Result.success(Unit)) + val effects = mutableListOf() + backgroundScope.launch { sut.transferEffects.collect { effects.add(it) } } quoteOrder(order) prepareConfirm() + effects.clear() sut.onTransferToSpendingConfirm() advanceUntilIdle() verify(lightningRepo, never()).completeAcceptedTransferFollowup(any(), any()) + assertEquals(order, sut.spendingUiState.value.order) + assertFalse(sut.spendingUiState.value.isConfirmPaying) + assertTrue(effects.isEmpty()) sut.onTransferToSpendingConfirm() advanceUntilIdle() verify(lightningRepo).completeAcceptedTransferFollowup(order.id, TXID) verifySendOnChain(sats = order.feeSat, count = 0) verify(transferRepo, times(2)).persistAcceptedFunding(order, TXID, original) + verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) + assertEquals(listOf(TransferEffect.OnSpendingFundingPaid), effects) + } + + @Test + fun `durably paid accepted funding survives local followup failure without a second order`() = test { + assertPaidFundingSurvivesLocalFailure(resumedEvidence = null) + } + + @Test + fun `durably paid resumed accepted funding survives local followup failure without a second order`() = test { + assertPaidFundingSurvivesLocalFailure(resumedEvidence = OnchainSendEvidence.Accepted) + } + + @Test + fun `durably paid observed funding survives local followup failure without a second order`() = test { + assertPaidFundingSurvivesLocalFailure(resumedEvidence = OnchainSendEvidence.Observed) + } + + private suspend fun TestScope.assertPaidFundingSurvivesLocalFailure(resumedEvidence: OnchainSendEvidence?) { + val order = spendingOrder(feeSat = 98_000uL) + val original = OnchainTransferContext(99_000uL, 110_000uL) + val attempt = acceptedFundingAttempt(order, original).copy( + evidence = resumedEvidence ?: OnchainSendEvidence.Accepted, + ) + var retainedAttempt = if (resumedEvidence == null) null else attempt + whenever(lightningRepo.currentOnchainSendAttempt()).doSuspendableAnswer { retainedAttempt } + whenever(lightningRepo.completeAcceptedTransferFollowup(order.id, TXID)).doSuspendableAnswer { + throw AppError("accepted activity readback unavailable") + } + stubSpendableBalances(spendable = 110_000uL) + stubSingleUtxoFunding(miningFee = 1_000uL) + stubSendOnChainSuccess() + val toasts = collectToasts() + val effects = mutableListOf() + backgroundScope.launch { sut.transferEffects.collect { effects.add(it) } } + quoteOrder(order) + whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn( + Result.success(order), Result.success(order.copy(id = "second-order")), + ) + prepareConfirm() + effects.clear() + + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + // Simulate the background resumer repairing the original activity and acknowledging the guard. + // A second swipe on the still-mounted confirmation must not create or fund another order. + retainedAttempt = attempt.copy(localFollowupComplete = true) + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + + verifySendOnChain(sats = order.feeSat, count = if (resumedEvidence == null) 1 else 0) + verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) + verify(transferRepo).persistAcceptedFunding(order, TXID, original) + verify(cacheStore).addPaidOrder(order.id, TXID) + verify(lightningRepo).completeAcceptedTransferFollowup(order.id, TXID) + assertEquals(listOf(TransferEffect.OnSpendingFundingPaid), effects) + assertTrue(sut.spendingUiState.value.isConfirmPaying) + assertTrue(toasts.isEmpty()) } @Test From 5f7839aec67237f683648f734a6febf1064ae61f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 5 Oct 2026 21:57:32 +0200 Subject: [PATCH 011/108] fix: keep unsent shop payments retryable --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 4 +- app/src/main/res/values/strings.xml | 4 +- .../viewmodels/AppViewModelSendFlowTest.kt | 58 ++++++++++++++++++- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index f7e47a4688..b35fc3c0ba 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4256,6 +4256,7 @@ class AppViewModel @Inject constructor( (sendAttempted && error !is OnchainSendNotDispatchedError) if (unresolved) { Logger.warn("On-chain payment outcome is uncertain after send started", error, context = TAG) + if (!sendAttempted) releasePrivatePaymentListIfNeeded(contactPaymentContext) if (!paymentProofStarted) cancelPaymentProofPreparation(preparedPaymentProofRequest) val previous = (error as? OnchainSendBlockedError)?.attempt val priorAccepted = previous?.takeIf { @@ -4353,8 +4354,9 @@ class AppViewModel @Inject constructor( var createdMetadataPaymentId: String? = null val paymentHash = decodedInvoice.paymentHash.toHex() associateLightningPaymentProof(incomingPaymentRequest, paymentHash).onFailure { + releasePrivatePaymentListIfNeeded(contactPaymentContext) cancelPaymentProofPreparation(proofRequest) - handlePaymentPreparationFailure(it, null) + handlePaymentPreparationFailure(it, contactPaymentContext) return } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a7bca742cc..fae4784079 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1475,11 +1475,11 @@ The maximum spendable amount is a bit lower due to a required reserve balance. Reserve Balance This payment is taking a bit longer than expected. You can continue using Bitkit. - The transaction outcome is unresolved. Check its status before trying again. Bitkit will block another send while this outcome is unresolved. Funding transaction is unresolved. Check its transaction before trying again. + Payment Pending + The transaction outcome is unresolved. Check its status before trying again. Bitkit will block another send while this outcome is unresolved. Broadcast refused: %1$s Transaction ID: %1$s - Payment Pending This invoice has already been paid. Currency conversion failed QuickPay diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 4ef581605c..4f136c9b1c 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -128,6 +128,7 @@ import to.bitkit.repositories.MethodId import to.bitkit.repositories.NodeEventUpdate import to.bitkit.repositories.OnchainSendAttempt import to.bitkit.repositories.OnchainSendBlockedError +import to.bitkit.repositories.OnchainSendAttemptUnreadableError import to.bitkit.repositories.OnchainSendEvidence import to.bitkit.repositories.OnchainSendNotDispatchedError import to.bitkit.repositories.OnchainSendOutcome @@ -6634,7 +6635,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) whenever(lightningRepo.payInvoice(bolt11 = bolt11, sats = null)).thenReturn(Result.success(paymentHash)) - setActiveContactPaymentContext(testPublicKey, privateContext, request) + setActiveContactPaymentContext(testPublicKey, privateContext, request, isInitialSubscriptionPayment = true) setSendState( SendUiState( address = bolt11, @@ -6644,8 +6645,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ), ) - sut.setSendEvent(SendEvent.PayConfirmed) - advanceUntilIdle() + sut.sendEffect.test { + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + assertTrue(awaitItem() is SendEffect.NavigateToError) + } + verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) + verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentProofRepo).prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) verify(paykitPaymentProofRepo).associateLightningPayment(request, paymentHash, MethodId.Bolt11.rawValue) @@ -8220,6 +8226,52 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) } + @Test + fun `blocked admission releases consumed private context before dispatch only`() = test { + val cases = listOf( + false to OnchainSendBlockedError(), + true to OnchainSendBlockedError(), + false to OnchainSendAttemptUnreadableError(IllegalStateException("guard unreadable")), + true to OnchainSendAttemptUnreadableError(IllegalStateException("guard unreadable")), + ) + for ((index, case) in cases.withIndex()) { + val (sendAttempted, error) = case + val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL + index.toULong()) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + stubOnchainSend( + "bcrt1qblocked", + request.amountSats, + Result.failure(error), + invokeBeforeSendAttempt = sendAttempted, + ) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = "bcrt1qblocked", + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + isPaymentRequest = true, + ), + ) + + sut.sendEffect.test { + confirmCurrentPayment() + assertTrue(awaitItem() is SendEffect.NavigateToPending) + } + verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) + if (sendAttempted) { + verify(privatePaykitRepo, never()).releasePrivatePaymentList(testPublicKey, privateContext) + } else { + verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) + } + verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) + } + } + @Test fun `generic outer error after ordinary send remains unresolved`() = test { val address = "bcrt1qoutererror" From 152b43dd34d9fb1231a2f5ba9f1e3df2c8a0af32 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 6 Oct 2026 00:14:31 +0200 Subject: [PATCH 012/108] fix: retain payment identity during on-chain retry --- .../PreparedOnchainRecoveryFixtureTest.kt | 112 ++++ .../java/to/bitkit/services/TxBumpingTests.kt | 8 +- .../to/bitkit/services/UtxoSelectionTests.kt | 8 +- .../wallets/send/SendPendingScreenTest.kt | 2 +- .../bitkit/data/backup/VssStoreIdProvider.kt | 13 +- .../models/ActiveOnchainAttemptBackup.kt | 170 ++++++ .../bitkit/models/PaykitPaymentStateBackup.kt | 1 + .../java/to/bitkit/repositories/BackupRepo.kt | 48 +- .../to/bitkit/repositories/LightningRepo.kt | 141 ++++- .../repositories/OnchainSendAttemptStore.kt | 167 +++++- .../repositories/OnchainSendCoordinator.kt | 196 +++++++ .../repositories/PaykitPaymentProofRepo.kt | 113 +++- .../to/bitkit/services/LightningService.kt | 69 +-- .../screens/wallets/send/SendPendingScreen.kt | 191 ++++++- .../wallets/send/SendPendingViewModel.kt | 56 ++ .../java/to/bitkit/ui/sheets/SendSheet.kt | 32 ++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 95 +++- .../to/bitkit/viewmodels/TransferViewModel.kt | 2 + app/src/main/res/values/strings.xml | 7 +- .../models/ActiveOnchainAttemptBackupTest.kt | 106 ++++ .../models/PaykitPaymentStateBackupTest.kt | 18 + .../to/bitkit/repositories/BackupRepoTest.kt | 72 +++ .../bitkit/repositories/LightningRepoTest.kt | 56 +- .../OnchainSendAttemptStoreTest.kt | 100 +++- .../OnchainSendCoordinatorTest.kt | 504 ++++++++++++++++++ .../PaykitPaymentProofRepoTest.kt | 154 +++++- .../bitkit/services/LightningServiceTest.kt | 61 ++- .../wallets/send/SendPendingViewModelTest.kt | 54 ++ .../viewmodels/AppViewModelSendFlowTest.kt | 90 +++- .../viewmodels/TransferViewModelTest.kt | 32 +- .../active-onchain-attempt-golden.json | 75 +++ changelog.d/next/1384.fixed.md | 2 +- gradle/libs.versions.toml | 2 +- .../send/onchain-original-payment-retry.xml | 27 + 34 files changed, 2618 insertions(+), 166 deletions(-) create mode 100644 app/src/androidTest/java/to/bitkit/services/PreparedOnchainRecoveryFixtureTest.kt create mode 100644 app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt create mode 100644 app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt create mode 100644 app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt create mode 100644 app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt create mode 100644 app/src/test/resources/active-onchain-attempt-golden.json create mode 100644 journeys/send/onchain-original-payment-retry.xml diff --git a/app/src/androidTest/java/to/bitkit/services/PreparedOnchainRecoveryFixtureTest.kt b/app/src/androidTest/java/to/bitkit/services/PreparedOnchainRecoveryFixtureTest.kt new file mode 100644 index 0000000000..1ff70ef035 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/services/PreparedOnchainRecoveryFixtureTest.kt @@ -0,0 +1,112 @@ +package to.bitkit.services + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import to.bitkit.data.SettingsStore +import to.bitkit.data.keychain.Keychain +import to.bitkit.env.Env +import to.bitkit.models.WalletScope +import to.bitkit.repositories.OnchainSendAttemptStore +import to.bitkit.repositories.OnchainSendOutcome +import to.bitkit.repositories.WalletRepo +import to.bitkit.test.annotations.CoreServiceIntegration +import to.bitkit.test.annotations.DeviceIntegration +import java.io.File +import javax.inject.Inject + +/** Disposable-device fixture: real signed receipt, deliberately seeded Pending, never a native fault claim. */ +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +@DeviceIntegration +@CoreServiceIntegration +class PreparedOnchainRecoveryFixtureTest { + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var lightningService: LightningService + + @Inject + lateinit var coreService: CoreService + + @Inject + lateinit var keychain: Keychain + + @Inject + lateinit var walletRepo: WalletRepo + + @Inject + lateinit var store: OnchainSendAttemptStore + + @Inject + lateinit var settingsStore: SettingsStore + + @Test + fun verifySharedVssBindingVectors() { + val mnemonic = List(11) { "abandon" }.plus("about").joinToString(" ") + val vectors = mapOf( + "bitcoin" to "28f258542d91310274942356e7777d8978bac6dbd3fadbfbfe5d663b19f1f6c5", + "testnet" to "0ce1e7952991cf5ce84bfa135dbc194b3d55a7f2fc69807091c4ca1d6eaac809", + "signet" to "966319adba79fb70afbf6eb1c7bde0cf93433e08d6f2f832c30467b812005fbe", + "regtest" to "fe843546f607f38ba7b1e8fe479c3103139ebea5b83628cbaa465e41cf6cb6c0", + ) + vectors.forEach { (network, expected) -> + val storeId = com.synonym.vssclient.vssDeriveStoreId("bitkit_v1_$network", mnemonic, null) + val binding = java.security.MessageDigest.getInstance("SHA-256") + .digest(storeId.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) } + org.junit.Assert.assertEquals(expected, binding) + } + } + + @Test + fun seedPreparedOriginalForRecoveryUi() = runBlocking { + val context = ApplicationProvider.getApplicationContext() + Env.initAppStoragePath(context.filesDir.absolutePath) + hiltRule.inject() + // Refuse a pre-existing wallet; the runner must use an owned, disposable app install. + check(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) == null) + walletRepo.createWallet(null).getOrThrow() + keychain.upsertString(Keychain.Key.PIN.name, "1234") // Public disposable-fixture PIN only. + settingsStore.update { + it.copy(isPinEnabled = true, isPinForPaymentsEnabled = true, isBiometricEnabled = false) + } + lightningService.setup(walletIndex = 0) + try { + lightningService.start() + lightningService.sync() + val depositAddress = lightningService.newAddress() + coreService.blocktank.regtestMine(1u) + coreService.blocktank.regtestDeposit(depositAddress, 100_000uL) + coreService.blocktank.regtestMine(6u) + delay(15_000) + lightningService.sync() + val recipient = "bcrt1qs04g2ka4pr9s3mv73nu32tvfy7r3cxd27wkyu8" + val isMax = InstrumentationRegistry.getArguments().getString("recoveryMode") == "max" + val original = store.admit( + walletId = WalletScope.default, requestId = null, orderId = null, address = recipient, + amountSats = 1_000uL, isMaxAmount = isMax, feeRateSatsPerVByte = 1uL, + isTransfer = false, channelId = null, tags = emptyList(), beforeSendAttempt = {}, + ) + val prepared = lightningService.prepareOnchainSend(recipient, 1_000uL, 1uL, isMaxAmount = isMax) + store.retainPreparedReceipt(original.attemptId, 0, prepared.receipt, false) + // No original broadcast: this seeds the uncertainty UI using actual native receipt provenance. + val pending = store.recordOutcome(original.attemptId, OnchainSendOutcome.Unknown(prepared.receipt.txid), 0) + File(context.getExternalFilesDir(null), "recovery-original-native-receipt.json") + .writeText(Json.encodeToString(pending)) + } finally { + lightningService.stop() + } + // Retain this disposable fixture for the real app UI journey; owned-device teardown removes it afterward. + } +} diff --git a/app/src/androidTest/java/to/bitkit/services/TxBumpingTests.kt b/app/src/androidTest/java/to/bitkit/services/TxBumpingTests.kt index f076821a29..573360540f 100644 --- a/app/src/androidTest/java/to/bitkit/services/TxBumpingTests.kt +++ b/app/src/androidTest/java/to/bitkit/services/TxBumpingTests.kt @@ -10,18 +10,18 @@ import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before import org.junit.Rule -import to.bitkit.repositories.OnchainSendOutcome -import kotlin.test.assertIs import org.junit.Test import org.junit.runner.RunWith import to.bitkit.data.keychain.Keychain import to.bitkit.env.Env +import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.WalletRepo import to.bitkit.test.annotations.CoreServiceIntegration import to.bitkit.test.annotations.DeviceIntegration import javax.inject.Inject import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -150,11 +150,11 @@ class TxBumpingTests { val lowFeeRate = 1uL // 1 sat/vbyte (very low) println("Sending $sendAmount sats to $destinationAddress with low fee rate of $lowFeeRate sat/vbyte") - val outcome = lightningService.send( + val outcome = lightningService.prepareOnchainSend( address = destinationAddress, sats = sendAmount, satsPerVByte = lowFeeRate, - ) + ).broadcast() val originalTxId = assertIs(outcome).txid lightningService.sync() diff --git a/app/src/androidTest/java/to/bitkit/services/UtxoSelectionTests.kt b/app/src/androidTest/java/to/bitkit/services/UtxoSelectionTests.kt index 30c1785aa2..a8a3c45604 100644 --- a/app/src/androidTest/java/to/bitkit/services/UtxoSelectionTests.kt +++ b/app/src/androidTest/java/to/bitkit/services/UtxoSelectionTests.kt @@ -10,19 +10,19 @@ import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before import org.junit.Rule -import to.bitkit.repositories.OnchainSendOutcome -import kotlin.test.assertIs import org.junit.Test import org.junit.runner.RunWith import org.lightningdevkit.ldknode.CoinSelectionAlgorithm import to.bitkit.data.keychain.Keychain import to.bitkit.env.Env +import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.WalletRepo import to.bitkit.test.annotations.CoreServiceIntegration import to.bitkit.test.annotations.DeviceIntegration import javax.inject.Inject import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.test.fail @@ -205,12 +205,12 @@ class UtxoSelectionTests { val feeRate = 1uL // 1 sat/vbyte println("Sending $sendAmount sats to $destinationAddress using specific UTXOs") - val outcome = lightningService.send( + val outcome = lightningService.prepareOnchainSend( address = destinationAddress, sats = sendAmount, satsPerVByte = feeRate, utxosToSpend = utxosToSpend - ) + ).broadcast() val txId = assertIs(outcome).txid assertTrue(txId.isNotEmpty(), "Transaction ID should not be empty") diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingScreenTest.kt index cf9c00b1af..e6f80cf3b3 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingScreenTest.kt @@ -82,7 +82,7 @@ class SendPendingScreenTest { composeTestRule.onNodeWithText("Test backend refusal", substring = true).assertIsDisplayed() composeTestRule.onNodeWithText("ab".repeat(32), substring = true).assertIsDisplayed() composeTestRule.onNodeWithText("Payment Pending").assertIsDisplayed() - composeTestRule.onNodeWithText("Bitkit will block another send", substring = true).assertIsDisplayed() + composeTestRule.onNodeWithText("Other payments remain blocked", substring = true).assertIsDisplayed() composeTestRule.onNodeWithText("Details").assertIsNotEnabled() composeTestRule.onNodeWithText("Retry").assertDoesNotExist() composeTestRule.onNodeWithText("Payment Sent").assertDoesNotExist() diff --git a/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt b/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt index 5f88f13a94..35c7bc524b 100644 --- a/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt +++ b/app/src/main/java/to/bitkit/data/backup/VssStoreIdProvider.kt @@ -5,6 +5,7 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.env.Env import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError +import java.security.MessageDigest import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton @@ -30,11 +31,21 @@ class VssStoreIdProvider @Inject constructor( ) cacheMap[walletIndex] = storeId - Logger.info("VSS store id setup for wallet[$walletIndex]: '$storeId'", context = TAG) + Logger.info("VSS store id setup for wallet[$walletIndex]", context = TAG) return storeId } } + fun getBackupWalletBinding(walletIndex: Int): String { + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name, walletIndex) + ?: throw ServiceError.MnemonicNotFound() + val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name, walletIndex) + val indexedStoreId = vssDeriveStoreId(Env.vssStoreIdPrefix, mnemonic, passphrase) + check(indexedStoreId == getVssStoreId()) { "Backup namespace does not match original indexed wallet" } + return MessageDigest.getInstance("SHA-256").digest(indexedStoreId.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + } + fun clearCache() { cacheMap.clear() } diff --git a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt new file mode 100644 index 0000000000..82cde5090c --- /dev/null +++ b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt @@ -0,0 +1,170 @@ +package to.bitkit.models + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import to.bitkit.repositories.OnchainSendAttempt +import to.bitkit.repositories.OnchainSendEvidence +import to.bitkit.repositories.OnchainSendInput +import to.bitkit.repositories.OnchainTransferContext +import to.bitkit.repositories.PaykitPaymentProofKind +import to.bitkit.repositories.PaykitPaymentRequestId + +/** Shared v1: one active operation; integer strings avoid cross-platform precision loss. */ +@Serializable +@Suppress("LongParameterList") +data class ActiveOnchainAttemptBackup( + val version: Int = 1, + val wallet: Wallet, + val attemptId: String, + val requestId: PaykitPaymentRequestId? = null, + val orderId: String? = null, + val payerIdentity: String? = null, + val address: String, + val amountSats: String, + val isMaxAmount: Boolean, + val status: String, + val txid: String? = null, + val rejectionReason: String? = null, + val originalInputs: List? = null, + val candidateTxids: List = emptyList(), + val feeRateSatsPerVByte: String, + val followup: Followup? = null, + val transfer: Transfer? = null, +) { + @Serializable + data class Wallet(val kind: String = "software", val network: String, val binding: String, val sourceIndex: String) + + @Serializable + data class Input(val txid: String, val vout: String) + + @Serializable + data class Followup( + val feeSats: String, + val tags: List, + val contact: JsonElement? = null, + val createdAtMillis: String, + val channelId: String? = null, + ) + + @Serializable + data class Transfer( + val txTotalSats: String, + val preTransferOnchainSats: String, + val originalOrderClientBalanceSats: String, + val originalOrderFeeSats: String, + ) + + @Suppress("LongMethod", "CyclomaticComplexMethod") + fun restored(network: String, binding: String, walletId: String, destinationIndex: Int): OnchainSendAttempt { + require(version == 1 && wallet.kind == "software") + require(wallet.network in setOf("bitcoin", "testnet", "signet", "regtest") && wallet.network == network) + require(wallet.binding.matches(HEX) && wallet.binding == binding) + require(unsigned(wallet.sourceIndex) <= Int.MAX_VALUE.toULong() && destinationIndex >= 0) + require(attemptId.matches(Regex("[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}"))) + require(address.isNotBlank() && unsigned(amountSats) > 0uL) + require(requestId == null || orderId == null) + require( + requestId == null || + (payerIdentity != null && PubkyPublicKeyFormat.normalized(payerIdentity) == payerIdentity) + ) + require(unsigned(feeRateSatsPerVByte) <= UInt.MAX_VALUE.toULong()) + require(candidateTxids.distinct().size == candidateTxids.size && candidateTxids.all { it.matches(HEX) }) + val inputs = originalInputs?.map { input -> + require(input.txid.matches(HEX) && unsigned(input.vout) <= UInt.MAX_VALUE.toULong()) + OnchainSendInput(input.txid, unsigned(input.vout).toUInt()) + } + if (inputs == null) { + require(candidateTxids.isEmpty() && txid == null && status == "pending") + } else { + require(inputs.isNotEmpty() && inputs.distinct().size == inputs.size) + require(unsigned(feeRateSatsPerVByte) > 0uL) + require(candidateTxids.isNotEmpty() && txid in candidateTxids) + } + val evidence = when (status) { + "pending" -> OnchainSendEvidence.Pending + "accepted" -> OnchainSendEvidence.Accepted.also { require(txid != null && txid in candidateTxids) } + "rejected" -> OnchainSendEvidence.Rejected + "unknown" -> OnchainSendEvidence.Unknown + else -> error("Unsupported active operation status") + } + followup?.let { + unsigned(it.feeSats) + unsigned(it.createdAtMillis) + } + val transferContext = transfer?.let { + require(orderId != null) + OnchainTransferContext( + unsigned(it.txTotalSats), + unsigned(it.preTransferOnchainSats), + unsigned(it.originalOrderClientBalanceSats), + unsigned(it.originalOrderFeeSats), + ) + } + require(orderId == null || transferContext != null) + return OnchainSendAttempt( + walletId = walletId, walletIndex = destinationIndex, attemptId = attemptId, + requestId = requestId, orderId = orderId, payerIdentity = payerIdentity, + address = address, amountSats = unsigned(amountSats), isMaxAmount = isMaxAmount, + feeRateSatsPerVByte = unsigned(feeRateSatsPerVByte), isTransfer = orderId != null, + channelId = followup?.channelId, tags = followup?.tags.orEmpty(), evidence = evidence, + txid = txid, refusalReason = rejectionReason, localFollowupComplete = false, + originalInputs = inputs, candidateTxids = candidateTxids, transferContext = transferContext, + backupFollowup = followup, restoredFromBackup = true, + ) + } + + fun validateProofs(proofs: List, defaultWalletId: String) { + val originalRequest = requestId ?: return + val proof = proofs.single { it.requestId == originalRequest && it.identity == payerIdentity } + check(proof.kind == PaykitPaymentProofKind.Onchain.type && proof.paymentStarted) + check(proof.onchainWalletId == null || proof.onchainWalletId == defaultWalletId) + check(proof.onchainAddress == address && proof.onchainAmountSats?.toString() == amountSats) + check(proof.paymentIdentifier == null || proof.paymentIdentifier in candidateTxids) + check( + proof.proofData == null || (proof.proofData == proof.paymentIdentifier && proof.proofData in candidateTxids) + ) + if (proof.onchainAcceptanceVerified) { + check(status == "accepted" && txid != null && proof.paymentIdentifier == txid && proof.proofData == txid) + } + } + + companion object { + private val HEX = Regex("[0-9a-f]{64}") + private fun unsigned(value: String): ULong { + require(value.matches(Regex("0|[1-9][0-9]*"))) + return value.toULong() + } + + fun from(attempt: OnchainSendAttempt, network: String, binding: String): ActiveOnchainAttemptBackup { + require(attempt.walletId == WalletScope.default) + val wire = ActiveOnchainAttemptBackup( + wallet = Wallet(network = network, binding = binding, sourceIndex = attempt.walletIndex.toString()), + attemptId = attempt.attemptId, requestId = attempt.requestId, orderId = attempt.orderId, + payerIdentity = attempt.payerIdentity, + address = attempt.address, + amountSats = attempt.amountSats.toString(), + isMaxAmount = attempt.isMaxAmount, + status = when (attempt.evidence) { + OnchainSendEvidence.Pending -> "pending" + OnchainSendEvidence.Accepted, OnchainSendEvidence.Observed -> "accepted" + OnchainSendEvidence.Rejected -> "rejected" + OnchainSendEvidence.Unknown -> "unknown" + }, + txid = attempt.txid, rejectionReason = attempt.refusalReason, + originalInputs = attempt.originalInputs?.map { Input(it.txid, it.vout.toString()) }, + candidateTxids = attempt.candidateTxids, feeRateSatsPerVByte = attempt.feeRateSatsPerVByte.toString(), + followup = attempt.backupFollowup, + transfer = attempt.transferContext?.let { + Transfer( + it.txTotalSats.toString(), + it.preTransferOnchainSats.toString(), + requireNotNull(it.originalOrderClientBalanceSats).toString(), + requireNotNull(it.originalOrderFeeSats).toString(), + ) + }, + ) + wire.restored(network, binding, attempt.walletId, attempt.walletIndex) + return wire + } + } +} diff --git a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt index 8735ffa79d..94e0782af4 100644 --- a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt +++ b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt @@ -16,6 +16,7 @@ import kotlin.time.Instant data class PaykitPaymentStateBackup( val subscriptions: Map, val pendingProofs: List, + val activeOnchainAttempt: ActiveOnchainAttemptBackup? = null, ) { @Serializable data class Subscription( diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index a45f162c62..234d534df8 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -37,14 +37,17 @@ import to.bitkit.data.WatchOnlyAccountStore import to.bitkit.data.WidgetsStore import to.bitkit.data.backup.VssBackupClient import to.bitkit.data.backup.VssBackupClientLdk +import to.bitkit.data.backup.VssStoreIdProvider import to.bitkit.data.hwWalletNames import to.bitkit.data.keychain.Keychain import to.bitkit.data.resetPin import to.bitkit.di.IoDispatcher import to.bitkit.di.json +import to.bitkit.env.Env import to.bitkit.ext.formatPlural import to.bitkit.ext.nowMillis import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.ActiveOnchainAttemptBackup import to.bitkit.models.ActivityBackupV1 import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus @@ -54,6 +57,7 @@ import to.bitkit.models.PaykitPaymentStateBackup import to.bitkit.models.SettingsBackupV1 import to.bitkit.models.Toast import to.bitkit.models.WalletBackupV1 +import to.bitkit.models.WalletScope import to.bitkit.models.WidgetsBackupV1 import to.bitkit.services.LightningService import to.bitkit.services.PaykitSdkService @@ -108,6 +112,8 @@ class BackupRepo @Inject constructor( private val paykitPaymentRequestRepo: Provider, private val paykitPresentationStore: PaykitPaymentRequestPresentationStore, private val keychain: Keychain, + private val onchainSendAttemptStore: OnchainSendAttemptStore, + private val vssStoreIdProvider: VssStoreIdProvider, private val preActivityMetadataRepo: PreActivityMetadataRepo, private val lightningService: LightningService, private val clock: Clock, @@ -355,6 +361,7 @@ class BackupRepo @Inject constructor( dataListenerJobs.add(observeBackupChanges(privatePaykitRepo.get().backupStateVersion, BackupCategory.WALLET)) dataListenerJobs.add(observeBackupChanges(paykitSdkService.backupStateVersion, BackupCategory.WALLET)) dataListenerJobs.add(observeBackupChanges(paykitPaymentProofStore.backupStateVersion, BackupCategory.WALLET)) + dataListenerJobs.add(observeBackupChanges(onchainSendAttemptStore.backupStateVersion, BackupCategory.WALLET)) dataListenerJobs.add(observeBackupChanges(paykitPresentationStore.backupStateVersion, BackupCategory.WALLET)) dataListenerJobs.add( observeBackupChanges( @@ -631,6 +638,17 @@ class BackupRepo @Inject constructor( val paykitSdkBackupState = privatePaykitRepo.get().backupSnapshot().getOrThrow() val watchOnlyAccountSnapshot = watchOnlyAccountStore.backupSnapshot() + val walletIndex = backupWalletIndex() + val proofs = paykitPaymentProofRepo.get().backupSnapshot() + val active = onchainSendAttemptStore.backupSnapshot(walletIndex)?.let { attempt -> + val wire = ActiveOnchainAttemptBackup.from( + attempt, + Env.network.name.lowercase(), + vssStoreIdProvider.getBackupWalletBinding(walletIndex), + ) + validateActiveProof(wire, proofs) + wire + } val payload = WalletBackupV1( createdAt = currentTimeMillis(), transfers = transfers, @@ -640,7 +658,8 @@ class BackupRepo @Inject constructor( watchOnlyAccountAllocationState = watchOnlyAccountSnapshot.allocationState, paykitPaymentState = PaykitPaymentStateBackup( subscriptions = paykitPresentationStore.backupSnapshot(), - pendingProofs = paykitPaymentProofRepo.get().backupSnapshot(), + pendingProofs = proofs, + activeOnchainAttempt = active, ), ) @@ -778,6 +797,19 @@ class BackupRepo @Inject constructor( keychain.upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, String(dataBytes)) val parsed = json.decodeFromString(String(dataBytes)) parsed.paykitPaymentState?.let { + val walletIndex = backupWalletIndex() + it.activeOnchainAttempt?.let { wire -> + validateActiveProof(wire, it.pendingProofs) + val attempt = wire.restored( + Env.network.name.lowercase(), + vssStoreIdProvider.getBackupWalletBinding(walletIndex), + WalletScope.default, + walletIndex, + ) + check(backupWalletIndex() == walletIndex) { "Backup wallet changed during restore" } + onchainSendAttemptStore.restoreActive(attempt) + check(backupWalletIndex() == walletIndex) { "Backup wallet changed during restore" } + } paykitPaymentRequestRepo.get().clear() paykitPresentationStore.restoreBackup(it.subscriptions) paykitPaymentProofRepo.get().restoreBackup(it.pendingProofs) @@ -804,6 +836,20 @@ class BackupRepo @Inject constructor( return parsed.createdAt } + // Match the actual custom VSS namespace selected by Keychain, then verify its derived binding. + private suspend fun backupWalletIndex(): Int { + val index = db.configDao().getAll().first().firstOrNull()?.walletIndex ?: 0L + require(index in 0..Int.MAX_VALUE.toLong()) + return index.toInt() + } + + private fun validateActiveProof( + wire: ActiveOnchainAttemptBackup, + proofs: List, + ) { + wire.validateProofs(proofs, WalletScope.default) + } + suspend fun getLatestBackupTime(): ULong? = withContext(ioDispatcher) { runCatching { withTimeout(VSS_TIMESTAMP_TIMEOUT) { diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 6cd3961d22..505f356a4a 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -50,7 +50,6 @@ import org.lightningdevkit.ldknode.ClosureReason import org.lightningdevkit.ldknode.CoinSelectionAlgorithm import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.Network -import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentHash @@ -69,7 +68,6 @@ import to.bitkit.env.Defaults import to.bitkit.env.Env import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.nowMillis -import to.bitkit.ext.nowTimestamp import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toPeerDetailsList import to.bitkit.ext.totalNextOutboundHtlcLimitSats @@ -1494,6 +1492,7 @@ class LightningRepo @Inject constructor( requestId: PaykitPaymentRequestId? = null, orderId: String? = null, transferContext: OnchainTransferContext? = null, + payerIdentity: String? = null, ): Result = executeWhenNodeRunning("sendOnChain") { require(address.isNotEmpty()) { "Send address cannot be empty" } @@ -1532,6 +1531,7 @@ class LightningRepo @Inject constructor( tags = tags, transferContext = transferContext, beforeSendAttempt = beforeSendAttempt, + payerIdentity = payerIdentity, ) }.getOrElse { val error = if (it is OnchainSendBlockedError || it is OnchainSendAttemptUnreadableError) { @@ -1541,29 +1541,71 @@ class LightningRepo @Inject constructor( } return@executeWhenNodeRunning Result.failure(error) } - val nodeResult = runSuspendCatching { - lightningService.send(address, sats, satsPerVByte, utxosForSend, isMaxAmount, attempt.walletIndex) + val preparedResult = runSuspendCatching { + lightningService.prepareOnchainSend( + address, + sats, + satsPerVByte, + utxosForSend, + isMaxAmount, + attempt.walletIndex + ) + .also { prepared -> + if (!isMaxAmount && utxosForSend != null) { + check( + prepared.receipt.inputs.toSet() == utxosForSend.map { + OnchainSendInput(it.outpoint.txid, it.outpoint.vout) + }.toSet() + ) + } + onchainSendAttemptStore.retainPreparedReceipt( + attempt.attemptId, + attempt.walletIndex, + prepared.receipt, + isRecovery = false, + ) + } } - val failure = nodeResult.exceptionOrNull() - if (failure != null) { - if (failure is NodeException || failure is ServiceError.NodeNotSetup) { - onchainSendAttemptStore.releaseBeforeDispatch(attempt.attemptId, attempt.walletIndex) - return@executeWhenNodeRunning Result.failure(OnchainSendNotDispatchedError(failure)) + val prepared = preparedResult.getOrElse { + // Preparation and receipt persistence never dispatch. A failed save leaves the preguard if cleanup fails. + runSuspendCatching { onchainSendAttemptStore.releaseBeforeDispatch(attempt.attemptId, attempt.walletIndex) } + return@executeWhenNodeRunning Result.failure(OnchainSendNotDispatchedError(it)) + } + val outcome = runSuspendCatching { + onchainSendAttemptStore.broadcastPreparedCandidate( + attempt.attemptId, + attempt.walletIndex, + prepared.receipt.txid, + prepared::broadcast, + ) + }.getOrElse { error -> + val winner = runSuspendCatching { onchainSendAttemptStore.current() }.getOrNull() + if (winner?.attemptId == attempt.attemptId && winner.hasPositiveEvidence) { + OnchainSendOutcome.Accepted(requireNotNull(winner.txid)) + } else { + return@executeWhenNodeRunning Result.failure(OnchainSendPendingError(error, prepared.receipt.txid)) } - return@executeWhenNodeRunning Result.failure(OnchainSendPendingError(failure)) } - val outcome = nodeResult.getOrThrow() - val recorded = runSuspendCatching { onchainSendAttemptStore.recordOutcome(attempt.attemptId, outcome, attempt.walletIndex) } - .getOrElse { error -> - if (outcome !is OnchainSendOutcome.Accepted) { - return@executeWhenNodeRunning Result.failure(OnchainSendPendingError(error, outcome.txid)) + val recorded = runSuspendCatching { + onchainSendAttemptStore.recordOutcome(attempt.attemptId, outcome, attempt.walletIndex) + }.getOrElse { error -> + val winner = runSuspendCatching { onchainSendAttemptStore.current() }.getOrNull() + if (winner?.hasPositiveEvidence == true) { + winner + } else { + if (outcome !is OnchainSendOutcome.Accepted || !outcome.txid.equals(prepared.receipt.txid, true)) { + return@executeWhenNodeRunning Result.failure(OnchainSendPendingError(error, prepared.receipt.txid)) } - Logger.warn("Failed to persist accepted on-chain outcome; admission stays blocked", error, context = TAG) attempt.copy(evidence = OnchainSendEvidence.Accepted, txid = outcome.txid) } - - if (outcome !is OnchainSendOutcome.Accepted) return@executeWhenNodeRunning Result.success(outcome) - val txId = outcome.txid + } + val winningOutcome = if (recorded.hasPositiveEvidence) { + OnchainSendOutcome.Accepted(requireNotNull(recorded.txid)) + } else { + outcome + } + if (winningOutcome !is OnchainSendOutcome.Accepted) return@executeWhenNodeRunning Result.success(winningOutcome) + val txId = winningOutcome.txid runSuspendCatching { onBroadcast(txId) } .onFailure { Logger.warn("Failed to continue accepted on-chain send", it, context = TAG) } @@ -1571,15 +1613,70 @@ class LightningRepo @Inject constructor( .onFailure { Logger.warn("Failed to finish accepted on-chain send locally", it, context = TAG) } runSuspendCatching { syncState() } .onFailure { Logger.warn("Failed to sync after accepted on-chain send", it, context = TAG) } - Result.success(outcome) + Result.success(winningOutcome) + } + + private val recoveryCoordinator by lazy { + OnchainSendCoordinator( + onchainSendAttemptStore, + object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = + error("Initial sends use sendOnChain admission") + + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, + ): PreparedOnchainSend { + val inputs = requireNotNull(attempt.originalInputs) + val outputs = lightningService.listSpendableOutputs().getOrThrow() + val selected = inputs.map { input -> + outputs.singleOrNull { it.outpoint.txid == input.txid && it.outpoint.vout == input.vout } + ?: throw OnchainSendBlockedError(attempt) + } + return lightningService.prepareOnchainSend( + attempt.address, + attempt.amountSats, + feeRateSatsPerVByte, + selected, + false, + attempt.walletIndex, + ) + } + }, + bgDispatcher + ) + } + + suspend fun retryOriginalOnchainSend( + attemptId: String, + walletId: String, + feeRateSatsPerVByte: ULong, + authorizeOriginal: suspend (OnchainSendAttempt) -> Unit, + ): Result = executeWhenNodeRunning("retryOriginalOnchainSend") { + val result = recoveryCoordinator.retryOriginal(attemptId, walletId, feeRateSatsPerVByte, authorizeOriginal) + if (result.getOrNull() is OnchainSendOutcome.Accepted) { + runSuspendCatching { + onchainSendAttemptStore.current()?.takeIf { it.hasPositiveEvidence }?.let { + finishOnchainSendLocally( + it + ) + } + }.onFailure { Logger.warn("Failed to finish recovered on-chain send locally", it, context = TAG) } + } + result } private suspend fun finishOnchainSendLocally(attempt: OnchainSendAttempt) { + val originalFollowup = attempt.backupFollowup + check( + !attempt.restoredFromBackup || originalFollowup != null + ) { "Original local follow-up context is unavailable" } + check(originalFollowup?.contact == null) { "Unsupported original contact follow-up" } val txId = requireNotNull(attempt.txid) { "On-chain send has no transaction id" } val preActivityMetadata = PreActivityMetadata( walletId = attempt.walletId, paymentId = txId, - createdAt = nowTimestamp().toEpochMilli().toULong(), + createdAt = originalFollowup?.createdAtMillis?.toULong() ?: nowMillis().toULong(), tags = attempt.tags, paymentHash = null, txId = txId, @@ -1594,7 +1691,7 @@ class LightningRepo @Inject constructor( txid = txId, address = attempt.address, amount = attempt.amountSats, - fee = 0u, + fee = originalFollowup?.feeSats?.toULong() ?: 0uL, feeRate = attempt.feeRateSatsPerVByte, isTransfer = attempt.isTransfer, channelId = attempt.channelId, diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index f53af889e4..af8675603a 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -2,6 +2,9 @@ package to.bitkit.repositories import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -11,8 +14,9 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import to.bitkit.data.keychain.Keychain import to.bitkit.di.IoDispatcher -import to.bitkit.utils.AppError +import to.bitkit.models.ActiveOnchainAttemptBackup import to.bitkit.services.LightningService +import to.bitkit.utils.AppError import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -38,6 +42,18 @@ enum class OnchainSendEvidence { data class OnchainTransferContext( val txTotalSats: ULong, val preTransferOnchainSats: ULong, + val originalOrderClientBalanceSats: ULong? = null, + val originalOrderFeeSats: ULong? = null, +) + +@Serializable +data class OnchainSendInput(val txid: String, val vout: UInt) + +data class OnchainPreparedReceipt( + val txid: String, + val inputs: List, + val address: String, + val amountSats: ULong, ) @Serializable @@ -60,6 +76,11 @@ data class OnchainSendAttempt( val localFollowupComplete: Boolean = false, val walletIndex: Int = 0, val transferContext: OnchainTransferContext? = null, + val payerIdentity: String? = null, + val originalInputs: List? = null, + val candidateTxids: List = emptyList(), + val backupFollowup: ActiveOnchainAttemptBackup.Followup? = null, + val restoredFromBackup: Boolean = false, ) { val isUnresolved: Boolean get() = evidence == OnchainSendEvidence.Pending || @@ -94,8 +115,11 @@ class OnchainSendAttemptStore @Inject constructor( } private val mutex = Mutex() - // One known result per existing wallet guard; entries disappear after a successful durable write. - private val retainedAccepted = mutableMapOf() + private val _backupStateVersion = MutableStateFlow(0L) + val backupStateVersion = _backupStateVersion.asStateFlow() + + // One known positive result per existing wallet guard; entries disappear after a successful durable write. + private val retainedPositive = mutableMapOf() suspend fun current(): OnchainSendAttempt? = withContext(ioDispatcher) { mutex.withLock { loadWithRetainedAccepted(lightningService.currentWalletIndex) } @@ -115,6 +139,7 @@ class OnchainSendAttemptStore @Inject constructor( tags: List, transferContext: OnchainTransferContext? = null, beforeSendAttempt: suspend () -> Unit, + payerIdentity: String? = null, ): OnchainSendAttempt = withContext(ioDispatcher) { mutex.withLock { val walletIndex = lightningService.currentWalletIndex @@ -143,17 +168,86 @@ class OnchainSendAttemptStore @Inject constructor( tags = tags, walletIndex = walletIndex, transferContext = transferContext, + payerIdentity = payerIdentity, + backupFollowup = ActiveOnchainAttemptBackup.Followup( + feeSats = "0", + tags = tags, + createdAtMillis = System.currentTimeMillis().toString(), + channelId = channelId, + ), ) persist(attempt) attempt } } + suspend fun retainPreparedReceipt( + attemptId: String, + walletIndex: Int, + receipt: OnchainPreparedReceipt, + isRecovery: Boolean, + ): OnchainSendAttempt = withContext(ioDispatcher + NonCancellable) { + mutex.withLock { + val current = loadWithRetainedAccepted(walletIndex) + if (current?.attemptId != attemptId || walletIndex != lightningService.currentWalletIndex || + current.hasPositiveEvidence + ) { + throw OnchainSendBlockedError(current) + } + require(receipt.txid.matches(Regex("[0-9a-fA-F]{64}"))) + require(receipt.inputs.isNotEmpty() && receipt.inputs.distinct().size == receipt.inputs.size) + require(receipt.inputs.all { it.txid.matches(Regex("[0-9a-fA-F]{64}")) }) + require(receipt.address == current.address) + if (current.requestId != null) require(!current.payerIdentity.isNullOrBlank()) + require(receipt.amountSats > 0uL) + if (isRecovery) { + requireNotNull(current.originalInputs) + require(current.candidateTxids.isNotEmpty()) + require(receipt.inputs.toSet() == current.originalInputs.toSet()) + require(receipt.amountSats == current.amountSats) + } else { + require(current.originalInputs == null && current.candidateTxids.isEmpty() && current.txid == null) + require((current.isMaxAmount && current.requestId == null) || receipt.amountSats == current.amountSats) + } + current.copy( + amountSats = receipt.amountSats, + originalInputs = current.originalInputs ?: receipt.inputs, + candidateTxids = (current.candidateTxids + receipt.txid.lowercase()).distinct(), + txid = receipt.txid.lowercase(), + evidence = OnchainSendEvidence.Pending, + refusalReason = null, + ).also { persist(it) } + } + } + + suspend fun broadcastPreparedCandidate( + attemptId: String, + walletIndex: Int, + txid: String, + broadcast: suspend () -> OnchainSendOutcome, + ): OnchainSendOutcome = withContext(ioDispatcher + NonCancellable) { + mutex.withLock { + val current = loadWithRetainedAccepted(walletIndex) + if (current?.attemptId != attemptId || walletIndex != lightningService.currentWalletIndex) { + throw OnchainSendBlockedError(current) + } + if (current.hasPositiveEvidence) return@withLock OnchainSendOutcome.Accepted(requireNotNull(current.txid)) + require(current.txid.equals(txid, ignoreCase = true) && txid.lowercase() in current.candidateTxids) + broadcast() + } + } + suspend fun recordOutcome(attemptId: String, outcome: OnchainSendOutcome, walletIndex: Int): OnchainSendAttempt = withContext(ioDispatcher + NonCancellable) { mutex.withLock { - val current = load(walletIndex) + val current = loadWithRetainedAccepted(walletIndex) if (current?.attemptId != attemptId) throw OnchainSendBlockedError() + if (current.candidateTxids.isNotEmpty() && + outcome.txid.lowercase() !in current.candidateTxids + ) { + throw OnchainSendBlockedError(current) + } + if (current.hasPositiveEvidence) return@withLock current val evidence = when (outcome) { is OnchainSendOutcome.Accepted -> OnchainSendEvidence.Accepted is OnchainSendOutcome.Rejected -> OnchainSendEvidence.Rejected @@ -164,9 +258,9 @@ class OnchainSendAttemptStore @Inject constructor( txid = outcome.txid, refusalReason = (outcome as? OnchainSendOutcome.Rejected)?.reason, ) - if (outcome is OnchainSendOutcome.Accepted) retainedAccepted[walletIndex] = recorded + if (outcome is OnchainSendOutcome.Accepted) retainedPositive[walletIndex] = recorded persist(recorded) - retainedAccepted.remove(walletIndex) + retainedPositive.remove(walletIndex) recorded } } @@ -174,8 +268,11 @@ class OnchainSendAttemptStore @Inject constructor( suspend fun releaseBeforeDispatch(attemptId: String, walletIndex: Int) = withContext(ioDispatcher + NonCancellable) { mutex.withLock { val current = loadWithRetainedAccepted(walletIndex) - if (current?.attemptId == attemptId && current.evidence == OnchainSendEvidence.Pending) { + if (current?.attemptId == attemptId && current.evidence == OnchainSendEvidence.Pending && + current.candidateTxids.isEmpty() + ) { keychain.delete(KEY, walletIndex) + _backupStateVersion.update { it + 1 } } } } @@ -184,8 +281,14 @@ class OnchainSendAttemptStore @Inject constructor( mutex.withLock { val current = loadWithRetainedAccepted(walletIndex) if (current?.attemptId == attemptId && current.hasPositiveEvidence) { + check( + (!current.restoredFromBackup || current.backupFollowup != null) && + current.backupFollowup?.contact == null + ) { + "Original local follow-up context is unavailable" + } persist(current.copy(localFollowupComplete = true)) - retainedAccepted.remove(walletIndex) + retainedPositive.remove(walletIndex) } } } @@ -194,11 +297,32 @@ class OnchainSendAttemptStore @Inject constructor( withContext(ioDispatcher + NonCancellable) { mutex.withLock { val current = loadWithRetainedAccepted(lightningService.currentWalletIndex) ?: return@withLock null - if (!current.txid.equals(txid, ignoreCase = true) || !current.isUnresolved) return@withLock null - current.copy(evidence = OnchainSendEvidence.Observed).also { persist(it) } + val matchesCandidate = current.txid.equals(txid, ignoreCase = true) || + txid.lowercase() in current.candidateTxids + if (!matchesCandidate || !current.isUnresolved) return@withLock null + val observed = current.copy(evidence = OnchainSendEvidence.Observed, txid = txid.lowercase()) + retainedPositive[current.walletIndex] = observed + persist(observed) + retainedPositive.remove(current.walletIndex) + observed } } + suspend fun backupSnapshot(walletIndex: Int): OnchainSendAttempt? = withContext(ioDispatcher) { + mutex.withLock { loadWithRetainedAccepted(walletIndex)?.takeIf { it.blocksNextSend } } + } + + suspend fun restoreActive(attempt: OnchainSendAttempt) = withContext(ioDispatcher + NonCancellable) { + mutex.withLock { + val existing = loadWithRetainedAccepted(attempt.walletIndex) + check(existing == null || !existing.blocksNextSend || existing == attempt) { + "Cannot replace an existing active on-chain operation" + } + persist(attempt.copy(localFollowupComplete = false)) + retainedPositive.remove(attempt.walletIndex) + } + } + private fun load(walletIndex: Int): OnchainSendAttempt? { val value = keychain.loadString(KEY, walletIndex) ?: return null return runCatching { @@ -207,6 +331,17 @@ class OnchainSendAttemptStore @Inject constructor( require(attempt.evidence == OnchainSendEvidence.Pending || attempt.txid?.matches(Regex("[0-9a-fA-F]{64}")) == true) require(!attempt.localFollowupComplete || attempt.hasPositiveEvidence) + require(attempt.candidateTxids.distinct().size == attempt.candidateTxids.size) + require(attempt.candidateTxids.all { it.matches(Regex("[0-9a-f]{64}")) }) + if (attempt.originalInputs != null) { + require(attempt.originalInputs.isNotEmpty()) + require(attempt.originalInputs.distinct().size == attempt.originalInputs.size) + require(attempt.originalInputs.all { it.txid.matches(Regex("[0-9a-fA-F]{64}")) }) + require(attempt.candidateTxids.isNotEmpty()) + require(attempt.txid?.lowercase() in attempt.candidateTxids) + } else { + require(attempt.candidateTxids.isEmpty()) + } } } .getOrElse { throw OnchainSendAttemptUnreadableError(it) } @@ -214,15 +349,18 @@ class OnchainSendAttemptStore @Inject constructor( private fun loadWithRetainedAccepted(walletIndex: Int): OnchainSendAttempt? { val persisted = load(walletIndex) - val retained = retainedAccepted[walletIndex] ?: return persisted + val retained = retainedPositive[walletIndex] ?: return persisted if (persisted?.attemptId != retained.attemptId || persisted.walletId != retained.walletId || - (persisted.txid != null && !persisted.txid.equals(retained.txid, ignoreCase = true)) + ( + persisted.txid != null && !persisted.txid.equals(retained.txid, ignoreCase = true) && + retained.txid?.lowercase() !in persisted.candidateTxids + ) ) { - retainedAccepted.remove(walletIndex) + retainedPositive.remove(walletIndex) return persisted } if (persisted.hasPositiveEvidence) { - retainedAccepted.remove(walletIndex) + retainedPositive.remove(walletIndex) return persisted } return retained @@ -230,5 +368,6 @@ class OnchainSendAttemptStore @Inject constructor( private suspend fun persist(attempt: OnchainSendAttempt) { keychain.upsertString(KEY, Json.encodeToString(attempt), attempt.walletIndex) + _backupStateVersion.update { it + 1 } } } diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt new file mode 100644 index 0000000000..77d2949e8d --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt @@ -0,0 +1,196 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import to.bitkit.di.IoDispatcher +import to.bitkit.ext.runSuspendCatching + +/** Rust converts sat/vB to sat/kwu by multiplying by 250; validate before the unchecked FFI factory. */ +object OnchainRecoveryFeeRate { + // Native sat/vB conversion multiplies by 250; shared backup/iOS fees also require UInt32. + val maximum: ULong = minOf(ULong.MAX_VALUE / 250uL, UInt.MAX_VALUE.toULong()) + + fun isValid(rate: ULong): Boolean = rate > 0uL && rate <= maximum + + fun parse(input: String): ULong? = input.toULongOrNull()?.takeIf(::isValid) +} + +/** One native prepared transaction; its direct submission is cached even if it fails. */ +class PreparedOnchainSend( + val receipt: OnchainPreparedReceipt, + private val submit: suspend () -> OnchainSendOutcome, +) { + private val mutex = Mutex() + private var result: Result? = null + + suspend fun broadcast(): OnchainSendOutcome = withContext(NonCancellable) { + mutex.withLock { + val cached = result ?: runSuspendCatching { submit() }.also { result = it } + cached.getOrThrow() + } + } +} + +/** Recovery always uses the original actual recipient amount and exact inputs, including an initial Max send. */ +interface OnchainPreparedSender { + suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend + suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong): PreparedOnchainSend +} + +/** Thin boundary for the additive native prepare API, implemented in LightningService once matching bindings exist. */ +interface OnchainPreparationProtocol { + suspend fun prepareFixed( + address: String, + amountSats: ULong, + feeRateSatsPerVByte: ULong, + inputs: List?, + walletIndex: Int, + ): PreparedOnchainSend + + suspend fun prepareMax( + address: String, + retainReserves: Boolean, + feeRateSatsPerVByte: ULong, + walletIndex: Int, + ): PreparedOnchainSend +} + +class OnchainPreparedSenderAdapter( + private val native: OnchainPreparationProtocol, + private val initialInputs: List? = null, +) : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend { + val prepared = if (attempt.isMaxAmount) { + native.prepareMax(attempt.address, true, attempt.feeRateSatsPerVByte, attempt.walletIndex) + } else { + native.prepareFixed( + attempt.address, + attempt.amountSats, + attempt.feeRateSatsPerVByte, + initialInputs, + attempt.walletIndex, + ) + } + if (!attempt.isMaxAmount && initialInputs != null) { + require(initialInputs.isNotEmpty() && initialInputs.distinct().size == initialInputs.size) + require(prepared.receipt.inputs.toSet() == initialInputs.toSet()) + } + return prepared + } + + override suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong): PreparedOnchainSend { + val inputs = requireNotNull(attempt.originalInputs) + require(inputs.isNotEmpty() && inputs.distinct().size == inputs.size) + return native.prepareFixed( + attempt.address, + attempt.amountSats, + feeRateSatsPerVByte, + inputs, + attempt.walletIndex, + ) + } +} + +class OnchainSendCoordinator( + private val store: OnchainSendAttemptStore, + private val sender: OnchainPreparedSender, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) { + private val mutex = Mutex() + + suspend fun sendInitial(attempt: OnchainSendAttempt): Result = withContext(ioDispatcher) { + runSuspendCatching { + mutex.withLock { + val original = store.current() + if (original?.attemptId != attempt.attemptId || original.walletId != attempt.walletId) { + throw OnchainSendBlockedError(original) + } + if (original.hasPositiveEvidence || original.candidateTxids.isNotEmpty()) { + throw OnchainSendBlockedError(original) + } + val prepared = sender.prepareInitial(original) + store.retainPreparedReceipt( + original.attemptId, + original.walletIndex, + prepared.receipt, + isRecovery = false, + ) + submit(original, prepared) + } + } + } + + suspend fun retryOriginal( + attemptId: String, + walletId: String, + feeRateSatsPerVByte: ULong, + authorizeOriginal: suspend (OnchainSendAttempt) -> Unit, + ): Result = withContext(ioDispatcher) { + val result = runSuspendCatching { + mutex.withLock { + val attempt = store.current() + if (attempt?.attemptId != attemptId || attempt.walletId != walletId) { + throw OnchainSendBlockedError(attempt) + } + if (attempt.hasPositiveEvidence) { + return@withLock OnchainSendOutcome.Accepted(requireNotNull(attempt.txid)) + } + if (!attempt.isUnresolved || attempt.originalInputs.isNullOrEmpty() || + attempt.candidateTxids.isEmpty() + ) { + throw OnchainSendBlockedError(attempt) + } + require(OnchainRecoveryFeeRate.isValid(feeRateSatsPerVByte)) + val prepared = sender.prepareRecovery(attempt, feeRateSatsPerVByte) + val retained = store.retainPreparedReceipt( + attempt.attemptId, + attempt.walletIndex, + prepared.receipt, + isRecovery = true, + ) + authorizeOriginal(retained) + submit(retained, prepared) + } + } + if (result.isFailure) { + val winner = runSuspendCatching { store.current() }.getOrNull() + if (winner?.attemptId == attemptId && winner.walletId == walletId && winner.hasPositiveEvidence) { + return@withContext Result.success(OnchainSendOutcome.Accepted(requireNotNull(winner.txid))) + } + } + result + } + + private suspend fun submit(attempt: OnchainSendAttempt, prepared: PreparedOnchainSend): OnchainSendOutcome { + val outcome = runSuspendCatching { + store.broadcastPreparedCandidate( + attempt.attemptId, + attempt.walletIndex, + prepared.receipt.txid, + prepared::broadcast, + ) + }.getOrElse { error -> + val winner = runSuspendCatching { store.current() }.getOrNull() + if (winner?.hasPositiveEvidence == true) return OnchainSendOutcome.Accepted(requireNotNull(winner.txid)) + throw OnchainSendPendingError(error, prepared.receipt.txid) + } + val recorded = runSuspendCatching { + store.recordOutcome(attempt.attemptId, outcome, attempt.walletIndex) + }.getOrElse { error -> + val winner = runSuspendCatching { store.current() }.getOrNull() + if (winner?.hasPositiveEvidence == true) return OnchainSendOutcome.Accepted(requireNotNull(winner.txid)) + if (outcome is OnchainSendOutcome.Accepted && outcome.txid.equals(prepared.receipt.txid, true)) { + return outcome + } + throw OnchainSendPendingError(error, outcome.txid) + } + return if (recorded.hasPositiveEvidence) { + OnchainSendOutcome.Accepted(requireNotNull(recorded.txid)) + } else { + outcome + } + } +} diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index e3c7ba56ca..29e95dbb37 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -36,8 +36,8 @@ import to.bitkit.utils.asNodeException import java.security.MessageDigest import javax.inject.Inject import javax.inject.Singleton -import kotlin.time.Instant import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant /** New proof kinds must be readable on both platforms before either platform writes them to a wallet backup. */ @Serializable @@ -242,6 +242,72 @@ class PaykitPaymentProofRepo @Inject constructor( } } + suspend fun captureOriginalOnchainPayer(request: PaykitPaymentRequest): Result = withContext(ioDispatcher) { + runSuspendCatching { + val identity = currentIdentity() ?: throw PaykitPaymentRequestError.RequestUnavailable + operationMutex.withLock { + val proof = loadProofs().singleOrNull { + PubkyPublicKeyFormat.matches(it.identity, identity) && it.requestId == request.id && + it.kind == PaykitPaymentProofKind.Onchain && !it.paymentStarted + } ?: throw PaykitPaymentRequestError.RequestUnavailable + requireNotNull(PubkyPublicKeyFormat.normalized(proof.identity)) + } + } + } + + suspend fun verifyOriginalOnchainPayer(request: PaykitPaymentRequest, payerIdentity: String): Result = + withContext(ioDispatcher) { + runSuspendCatching { + val identity = currentIdentity() ?: throw PaykitPaymentRequestError.RequestUnavailable + if (!PubkyPublicKeyFormat.matches(identity, payerIdentity)) { + throw PaykitPaymentRequestError.RequestUnavailable + } + operationMutex.withLock { + check( + loadProofs().any { + it.isStartedFor(payerIdentity, request.id) && it.kind == PaykitPaymentProofKind.Onchain + } + ) + } + } + } + + /** Authorize a successor against the existing payer proof without preparing or consuming private context again. */ + suspend fun authorizeOnchainRecovery(attempt: OnchainSendAttempt): Result = withContext(ioDispatcher) { + runSuspendCatching { + operationMutex.withLock { + val current = lightningRepo.currentOnchainSendAttempt() + val identity = currentIdentity() ?: throw PaykitPaymentRequestError.RequestUnavailable + if (current != attempt || !attempt.isUnresolved) { + throw PaykitPaymentRequestError.OperationInProgress + } + if (attempt.originalInputs.isNullOrEmpty() || attempt.candidateTxids.isEmpty()) { + throw PaykitPaymentRequestError.OperationInProgress + } + if (attempt.walletId != WalletScope.default || attempt.payerIdentity == null || + !PubkyPublicKeyFormat.matches(identity, attempt.payerIdentity) + ) { + throw PaykitPaymentRequestError.OperationInProgress + } + val proof = loadProofs().singleOrNull { + PubkyPublicKeyFormat.matches(it.identity, identity) && it.matchesOriginalShopAttempt(attempt) + } ?: throw PaykitPaymentRequestError.RequestUnavailable + if (proof.proofData != null || proof.onchainAcceptanceVerified) { + throw PaykitPaymentRequestError.OperationInProgress + } + } + } + } + + suspend fun completeRecoveredOnchainPayment(request: PaykitPaymentRequest, attempt: OnchainSendAttempt): Boolean = + withContext(ioDispatcher) { + val proof = operationMutex.withLock { + loadProofs().singleOrNull { it.matchesOriginalShopAttempt(attempt) } + } ?: return@withContext false + val txid = attempt.txid ?: return@withContext false + completeOnchainPayment(request, txid, proof.paymentEndpointIdentifier) + } + suspend fun completeOnchainPayment( request: PaykitPaymentRequest, txid: String, @@ -255,16 +321,23 @@ class PaykitPaymentProofRepo @Inject constructor( val identity = currentIdentity() ?: return@withContext false if (acceptedOutcome != null && !acceptedOutcome.txid.equals(txid, ignoreCase = true)) return@withContext false - val attempt = if (acceptedOutcome == null) { - runSuspendCatching { lightningRepo.currentOnchainSendAttempt() }.getOrNull() - } else { + val attempt = runSuspendCatching { lightningRepo.currentOnchainSendAttempt() } + .getOrElse { return@withContext false } + val hasCandidateFamily = attempt?.candidateTxids?.isNotEmpty() == true + fun hasPositiveEvidence(proof: PendingPaykitPaymentProof): Boolean = + if (hasCandidateFamily) { + attempt.matchesPositiveShopProof(proof) && attempt?.txid.equals(txid, ignoreCase = true) + } else { + acceptedOutcome != null || + (attempt.matchesPositiveShopProof(proof) && attempt?.txid.equals(txid, ignoreCase = true)) + } + val fallbackProof = if (hasCandidateFamily) { null + } else { + runSuspendCatching { + pendingProof(request, paymentEndpointIdentifier, PaykitPaymentProofKind.Onchain) + }.getOrNull() } - fun hasPositiveEvidence(proof: PendingPaykitPaymentProof): Boolean = acceptedOutcome != null || - (attempt.matchesPositiveShopProof(proof) && attempt?.txid.equals(txid, ignoreCase = true)) - val fallbackProof = runSuspendCatching { - pendingProof(request, paymentEndpointIdentifier, PaykitPaymentProofKind.Onchain) - }.getOrNull() val completed = operationMutex.withLock { val completion = runSuspendCatching { val proofs = loadProofs().toMutableList() @@ -273,9 +346,15 @@ class PaykitPaymentProofRepo @Inject constructor( it.requestId == request.id && it.kind == PaykitPaymentProofKind.Onchain && it.paymentStarted && - it.paymentIdentifier == null && + ( + it.paymentIdentifier == null || + (hasCandidateFamily && it.matchesOriginalShopAttempt(requireNotNull(attempt))) + ) && it.proofData == null } + if (hasCandidateFamily && index < 0) return@runSuspendCatching false + val originalProof = if (index >= 0) proofs[index] else fallbackProof + if (originalProof == null || !hasPositiveEvidence(originalProof)) return@runSuspendCatching false val proof = if (index >= 0) { proofs[index].copy( paymentIdentifier = txid.lowercase(), @@ -304,7 +383,8 @@ class PaykitPaymentProofRepo @Inject constructor( context = TAG, ) } - if (completion.isFailure && fallbackProof != null && hasPositiveEvidence(fallbackProof)) { + val canAttemptFallback = completion.isFailure && !hasCandidateFamily + if (canAttemptFallback && fallbackProof != null && hasPositiveEvidence(fallbackProof)) { val proof = fallbackProof.copy( paymentStarted = true, paymentIdentifier = txid.lowercase(), @@ -594,9 +674,20 @@ class PaykitPaymentProofRepo @Inject constructor( return retained } + private fun PendingPaykitPaymentProof.matchesOriginalShopAttempt(attempt: OnchainSendAttempt): Boolean = + paymentStarted && kind == PaykitPaymentProofKind.Onchain && requestId == attempt.requestId && + attempt.payerIdentity != null && PubkyPublicKeyFormat.matches(identity, attempt.payerIdentity) && + onchainWalletId == attempt.walletId && onchainAddress == attempt.address && + onchainAmountSats == attempt.amountSats && + (paymentIdentifier == null || attempt.candidateTxids.any { it.equals(paymentIdentifier, true) }) + private fun OnchainSendAttempt?.matchesPositiveShopProof(proof: PendingPaykitPaymentProof): Boolean = this != null && hasPositiveEvidence && requestId == proof.requestId && walletId == proof.onchainWalletId && txid?.isHex(HASH_BYTE_COUNT) == true && + ( + candidateTxids.isEmpty() || + (candidateTxids.any { it.equals(txid, true) } && proof.matchesOriginalShopAttempt(this)) + ) && (proof.proofData == null || proof.proofData.equals(txid, ignoreCase = true)) private suspend fun finishAlreadyQueuedOnchainProof(attempt: OnchainSendAttempt) { diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index d33d6c8019..021bc20032 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -37,8 +37,8 @@ import org.lightningdevkit.ldknode.KeychainKind import org.lightningdevkit.ldknode.Node import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.NodeStatus -import org.lightningdevkit.ldknode.OnchainWalletAccountConfig import org.lightningdevkit.ldknode.OnchainSendResult +import org.lightningdevkit.ldknode.OnchainWalletAccountConfig import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.PeerDetails @@ -68,7 +68,11 @@ import to.bitkit.models.WatchOnlyAccountRecord import to.bitkit.models.WatchOnlyAccountSetupState import to.bitkit.models.msatFloorOf import to.bitkit.models.toAddressType +import to.bitkit.repositories.OnchainPreparedReceipt +import to.bitkit.repositories.OnchainRecoveryFeeRate +import to.bitkit.repositories.OnchainSendInput import to.bitkit.repositories.OnchainSendOutcome +import to.bitkit.repositories.PreparedOnchainSend import to.bitkit.utils.AppError import to.bitkit.utils.LdkError import to.bitkit.utils.LdkLogWriter @@ -937,44 +941,49 @@ class LightningService internal constructor( } } - suspend fun send( + /** Prepare without dispatch; the caller must durably retain the receipt before calling broadcast. */ + suspend fun prepareOnchainSend( address: Address, sats: ULong, satsPerVByte: ULong, utxosToSpend: List? = null, isMaxAmount: Boolean = false, walletIndex: Int = currentWalletIndex, - ): OnchainSendOutcome { - - Logger.info( - "Sending $sats sats to $address, satsPerVByte=$satsPerVByte, isMaxAmount = $isMaxAmount", - context = TAG, + ): PreparedOnchainSend = callOnchainSend { + require(OnchainRecoveryFeeRate.isValid(satsPerVByte)) + if (currentWalletIndex != walletIndex) throw ServiceError.NodeNotSetup() + val originalNode = node ?: throw ServiceError.NodeNotSetup() + val prepared = if (isMaxAmount) { + originalNode.onchainPayment().prepareSendAllToAddress( + address, + true, + onchainFeeRateFactory(satsPerVByte), + ) + } else { + originalNode.onchainPayment().prepareSendToAddress( + address, + sats, + onchainFeeRateFactory(satsPerVByte), + utxosToSpend, + ) + } + val receipt = OnchainPreparedReceipt( + prepared.txid(), + prepared.inputs().map { OnchainSendInput(it.txid, it.vout) }, + address, + prepared.recipientAmountSats(), ) - - // Keep only this generated NodeError unwrapped: the new contract guarantees it was not dispatched. - val result = callOnchainSend { - if (currentWalletIndex != walletIndex) throw ServiceError.NodeNotSetup() - val node = this.node ?: throw ServiceError.NodeNotSetup() - if (isMaxAmount) { - node.onchainPayment().sendAllToAddressWithBroadcastResult( - address = address, - retainReserves = true, - feeRate = onchainFeeRateFactory(satsPerVByte), - ) - } else { - node.onchainPayment().sendToAddressWithBroadcastResult( - address = address, - amountSats = sats, - feeRate = onchainFeeRateFactory(satsPerVByte), - utxosToSpend = utxosToSpend, - ) + PreparedOnchainSend(receipt) { + callOnchainSend { + // Dispatch only through the handle that signed the original receipt. + if (currentWalletIndex != walletIndex || node !== originalNode) throw ServiceError.NodeNotSetup() + when (val result = prepared.broadcast()) { + is OnchainSendResult.Accepted -> OnchainSendOutcome.Accepted(result.txid) + is OnchainSendResult.Rejected -> OnchainSendOutcome.Rejected(result.txid, result.reason) + is OnchainSendResult.Unknown -> OnchainSendOutcome.Unknown(result.txid) + } } } - return when (result) { - is OnchainSendResult.Accepted -> OnchainSendOutcome.Accepted(result.txid) - is OnchainSendResult.Rejected -> OnchainSendOutcome.Rejected(result.txid, result.reason) - is OnchainSendResult.Unknown -> OnchainSendOutcome.Unknown(result.txid) - } } internal suspend fun callOnchainSend(block: suspend () -> T): T = diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt index 5e7b934f84..a3662f837c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt @@ -15,33 +15,51 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.AlertDialog import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.filterNotNull import to.bitkit.R import to.bitkit.models.WalletScope +import to.bitkit.repositories.OnchainRecoveryFeeRate +import to.bitkit.repositories.OnchainSendAttempt +import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.ui.components.BalanceHeaderView +import to.bitkit.ui.components.BiometricsView import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.TextInput import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.settingsViewModel import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.rememberBiometricAuthSupported + +const val RECOVERY_PIN_CHECK_RESULT_KEY = "RECOVERY_PIN_CHECK_RESULT_KEY" @Composable fun SendPendingScreen( @@ -56,6 +74,10 @@ fun SendPendingScreen( viewModel: SendPendingViewModel, walletId: String = WalletScope.default, refusalReason: String? = null, + retryOriginal: suspend (OnchainSendAttempt, ULong) -> Result, + onRecovered: (String, Long) -> Unit, + savedStateHandle: SavedStateHandle, + onNavigateToPin: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -81,14 +103,153 @@ fun SendPendingScreen( } } - SendPendingContent( - amount = if (observeResolution) uiState.amount else amount, - isOnchain = isOnchain, - activityId = uiState.activityId, - txid = txid, - refusalReason = refusalReason, - onClose = onClose, - onViewDetails = onViewDetails, + uiState.recoveredTxid?.let { winner -> + LaunchedEffect(winner) { onRecovered(winner, uiState.recoveryAttempt?.amountSats?.toLong() ?: amount) } + } + RecoveryAuthorization( + uiState, + savedStateHandle, + onNavigateToPin, + { viewModel.retryOriginal(it, retryOriginal) }, + ) { onRetry -> + SendPendingContent( + amount = if (observeResolution || isOnchain) uiState.amount else amount, + isOnchain = isOnchain, + activityId = uiState.activityId, + txid = uiState.currentTxid ?: txid, + refusalReason = refusalReason, + onClose = onClose, + onViewDetails = onViewDetails, + canRetry = uiState.recoveryAttempt != null, + isRecovering = uiState.isRecovering, + recoveryError = uiState.recoveryErrorMessage(), + onRetry = onRetry, + ) + } +} + +@Composable +private fun SendPendingUiState.recoveryErrorMessage(): String? = recoveryError?.let { + stringResource( + if (invalidFeeRate) { + R.string.wallet__send_pending__retry_invalid_fee + } else { + R.string.wallet__send_pending__retry_error + } + ) +} + +@Composable +private fun RecoveryAuthorization( + uiState: SendPendingUiState, + savedStateHandle: SavedStateHandle, + onNavigateToPin: () -> Unit, + retryOriginal: (ULong) -> Unit, + content: @Composable (() -> Unit) -> Unit, +) { + var showFeeApproval by remember { mutableStateOf(false) } + var feeInput by rememberSaveable { mutableStateOf("") } + var approvedRate by rememberSaveable { mutableStateOf(null) } + var showBiometrics by remember { mutableStateOf(false) } + val settings = settingsViewModel ?: return + val isPinEnabled by settings.isPinEnabled.collectAsStateWithLifecycle() + val pinForPayments by settings.isPinForPaymentsEnabled.collectAsStateWithLifecycle() + val isBiometricEnabled by settings.isBiometricEnabled.collectAsStateWithLifecycle() + val isBiometrySupported = rememberBiometricAuthSupported() + fun submitAuthorizedRetry() { + val rate = approvedRate?.let(OnchainRecoveryFeeRate::parse) ?: return + approvedRate = null + retryOriginal(rate) + } + LaunchedEffect(savedStateHandle) { + savedStateHandle.getStateFlow(RECOVERY_PIN_CHECK_RESULT_KEY, null) + .filterNotNull().collect { successful -> + savedStateHandle.remove(RECOVERY_PIN_CHECK_RESULT_KEY) + if (successful) submitAuthorizedRetry() else approvedRate = null + } + } + if (showBiometrics) { + BiometricsView( + onSuccess = { + showBiometrics = false + submitAuthorizedRetry() + }, + onFailure = { + showBiometrics = false + onNavigateToPin() + }, + ) + } + if (showFeeApproval) { + uiState.recoveryAttempt?.let { original -> + RecoveryFeeDialog(original, feeInput, { feeInput = it }, { showFeeApproval = false }) { rate -> + showFeeApproval = false + approvedRate = rate.toString() + if (isPinEnabled && pinForPayments) { + if (isBiometricEnabled && isBiometrySupported) showBiometrics = true else onNavigateToPin() + } else { + submitAuthorizedRetry() + } + } + } + } + + content { + feeInput = uiState.recoveryAttempt?.feeRateSatsPerVByte?.toString().orEmpty() + showFeeApproval = true + } +} + +@Composable +private fun RecoveryFeeDialog( + original: OnchainSendAttempt, + feeInput: String, + onFeeChange: (String) -> Unit, + onClose: () -> Unit, + onAuthorize: (ULong) -> Unit, +) { + AlertDialog( + onDismissRequest = onClose, + title = { BodyM(stringResource(R.string.wallet__send_pending__retry_title)) }, + text = { + Column { + BodyM(stringResource(R.string.wallet__send_pending__retry_description)) + original.let { original -> + VerticalSpacer(16.dp) + SelectionContainer { BodyM(original.address, color = Colors.White64) } + BalanceHeaderView(sats = original.amountSats.toLong()) + } + VerticalSpacer(16.dp) + TextInput( + value = feeInput, + onValueChange = onFeeChange, + placeholder = stringResource(R.string.common__sat_vbyte), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + isError = feeInput.isNotBlank() && OnchainRecoveryFeeRate.parse(feeInput) == null, + supportingText = { + if (feeInput.isNotBlank() && OnchainRecoveryFeeRate.parse(feeInput) == null) { + BodyM(stringResource(R.string.wallet__send_pending__retry_invalid_fee)) + } + }, + ) + } + }, + confirmButton = { + PrimaryButton( + text = stringResource(R.string.wallet__send_pending__retry_authorize), + enabled = OnchainRecoveryFeeRate.parse(feeInput) != null, + onClick = { + val rate = OnchainRecoveryFeeRate.parse(feeInput) ?: return@PrimaryButton + onAuthorize(rate) + }, + ) + }, + dismissButton = { + SecondaryButton( + text = stringResource(R.string.common__cancel), + onClick = onClose, + ) + }, ) } @@ -102,6 +263,10 @@ internal fun SendPendingContent( modifier: Modifier = Modifier, txid: String? = null, refusalReason: String? = null, + canRetry: Boolean = false, + isRecovering: Boolean = false, + recoveryError: String? = null, + onRetry: () -> Unit = {}, ) { Column( modifier = modifier @@ -145,6 +310,16 @@ internal fun SendPendingContent( HourglassAnimation(modifier = Modifier.align(Alignment.CenterHorizontally)) FillHeight() + recoveryError?.let { BodyM(it, color = Colors.White64) } + if (canRetry) { + SecondaryButton( + text = stringResource(R.string.wallet__send_pending__retry_title), + enabled = !isRecovering, + onClick = onRetry, + modifier = Modifier.fillMaxWidth(), + ) + VerticalSpacer(16.dp) + } Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { SecondaryButton( text = stringResource(R.string.wallet__send_details), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt index 3523796f6f..3147812043 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt @@ -11,8 +11,13 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.ext.rawId +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.WalletScope import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.OnchainRecoveryFeeRate +import to.bitkit.repositories.OnchainSendAttempt +import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.utils.Logger @@ -22,6 +27,7 @@ import javax.inject.Inject class SendPendingViewModel @Inject constructor( private val pendingPaymentRepo: PendingPaymentRepo, private val activityRepo: ActivityRepo, + private val lightningRepo: LightningRepo, ) : ViewModel() { companion object { @@ -50,6 +56,21 @@ class SendPendingViewModel @Inject constructor( if (isInitialized) return isInitialized = true _uiState.update { it.copy(amount = amount) } + viewModelScope.launch { + runSuspendCatching { lightningRepo.currentOnchainSendAttempt() }.onSuccess { attempt -> + if (attempt?.walletId == walletId && + (attempt.txid.equals(txid, true) || attempt.candidateTxids.any { it.equals(txid, true) }) + ) { + _uiState.update { it.copy(amount = attempt.amountSats.toLong()) } + } + val matchesOriginalWallet = walletId == WalletScope.default && attempt?.walletId == walletId + val hasOriginalReceipt = !attempt?.originalInputs.isNullOrEmpty() && + attempt?.candidateTxids?.any { it.equals(txid, true) } == true + if (matchesOriginalWallet && hasOriginalReceipt && attempt?.isUnresolved == true) { + _uiState.update { it.copy(recoveryAttempt = attempt) } + } + } + } if (txid == null || !txid.matches(Regex("[0-9a-fA-F]{64}"))) return viewModelScope.launch { activityRepo.findActivityByPaymentId(txid, ActivityFilter.ONCHAIN, PaymentType.SENT, true, walletId) @@ -57,6 +78,35 @@ class SendPendingViewModel @Inject constructor( } } + fun retryOriginal( + feeRateSatsPerVByte: ULong, + retry: suspend (OnchainSendAttempt, ULong) -> Result, + ) { + val original = _uiState.value.recoveryAttempt ?: return + if (_uiState.value.isRecovering) return + if (!OnchainRecoveryFeeRate.isValid(feeRateSatsPerVByte)) { + _uiState.update { it.copy(invalidFeeRate = true, recoveryError = "invalid fee rate") } + return + } + _uiState.update { it.copy(isRecovering = true, recoveryError = null, invalidFeeRate = false) } + viewModelScope.launch { + try { + val result = runSuspendCatching { retry(original, feeRateSatsPerVByte).getOrThrow() } + result.onSuccess { outcome -> + _uiState.update { + it.copy( + recoveredTxid = (outcome as? OnchainSendOutcome.Accepted)?.txid, + recoveryError = (outcome as? OnchainSendOutcome.Rejected)?.reason, + currentTxid = outcome.txid, + ) + } + }.onFailure { error -> _uiState.update { it.copy(recoveryError = error.message) } } + } finally { + _uiState.update { it.copy(isRecovering = false) } + } + } + } + override fun onCleared() { pendingPaymentRepo.setActiveHash(null) } @@ -96,4 +146,10 @@ data class SendPendingUiState( val amount: Long = 0L, val activityId: String? = null, val resolution: PendingPaymentResolution? = null, + val recoveryAttempt: OnchainSendAttempt? = null, + val isRecovering: Boolean = false, + val invalidFeeRate: Boolean = false, + val recoveryError: String? = null, + val currentTxid: String? = null, + val recoveredTxid: String? = null, ) diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 2edba48007..a6df0cccf1 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -56,6 +56,7 @@ import to.bitkit.ui.screens.wallets.send.HARDWARE_SIGN_CANCELLED_RESULT_KEY import to.bitkit.ui.screens.wallets.send.HwSendSignScreen import to.bitkit.ui.screens.wallets.send.HwSendViewModel import to.bitkit.ui.screens.wallets.send.PIN_CHECK_RESULT_KEY +import to.bitkit.ui.screens.wallets.send.RECOVERY_PIN_CHECK_RESULT_KEY import to.bitkit.ui.screens.wallets.send.SendAddressScreen import to.bitkit.ui.screens.wallets.send.SendAmountScreen import to.bitkit.ui.screens.wallets.send.SendCoinSelectionScreen @@ -437,6 +438,20 @@ fun SendSheet( addButtonTestTag = "SendTagsSubmit", ) } + composableWithDefaultTransitions { + SendPinCheckScreen( + onBack = { + navController.previousBackStackEntry?.savedStateHandle + ?.set(RECOVERY_PIN_CHECK_RESULT_KEY, false) + navController.popBackStack() + }, + onSuccess = { + navController.previousBackStackEntry?.savedStateHandle + ?.set(RECOVERY_PIN_CHECK_RESULT_KEY, true) + navController.popBackStack() + }, + ) + } composableWithDefaultTransitions { SendPinCheckScreen( onBack = { @@ -513,12 +528,26 @@ fun SendSheet( val route = it.toRoute() val sendUiState by appViewModel.sendUiState.collectAsStateWithLifecycle() SendPendingScreen( + savedStateHandle = it.savedStateHandle, + onNavigateToPin = { navController.navigateTo(SendRoute.RecoveryPinCheck) }, paymentHash = route.paymentHash, amount = route.amount, observeResolution = route.observeResolution, isOnchain = route.isOnchain, walletId = route.walletId, refusalReason = route.refusalReason, + retryOriginal = appViewModel::retryOriginalOnchainSend, + onRecovered = { txid, originalAmount -> + appViewModel.onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = txid, + sats = originalAmount, + activityWalletId = route.walletId, + ) + ) + }, onPaymentSuccess = { paymentHash, amountWithFee -> appViewModel.onSendSuccess( NewTransactionSheetDetails( @@ -685,6 +714,9 @@ sealed interface SendRoute { @Serializable data object PinCheck : InternalOnly + @Serializable + data object RecoveryPinCheck : InternalOnly + @Serializable data object CoinSelection : DeepLinkStart diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index b35fc3c0ba..a83cd879cf 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -15,6 +15,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter +import com.synonym.bitkitcore.BtOrderState2 import com.synonym.bitkitcore.FeeRates import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.LnurlAuthData @@ -153,6 +154,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LnurlPayInvoiceMismatchError import to.bitkit.repositories.MethodId import to.bitkit.repositories.NodeEventUpdate +import to.bitkit.repositories.OnchainSendAttempt import to.bitkit.repositories.OnchainSendAttemptUnreadableError import to.bitkit.repositories.OnchainSendBlockedError import to.bitkit.repositories.OnchainSendNotDispatchedError @@ -4171,6 +4173,14 @@ class AppViewModel @Inject constructor( contactPaymentContext: ContactPaymentContext?, amount: ULong, ) { + val originalPayer = preparedPaymentProofRequest?.let { + paykitPaymentProofRepo.captureOriginalOnchainPayer(it).getOrElse { error -> + releasePrivatePaymentListIfNeeded(contactPaymentContext) + cancelPaymentProofPreparation(preparedPaymentProofRequest) + handlePaymentPreparationFailure(error, contactPaymentContext) + return + } + } val address = _sendUiState.value.address val tags = _sendUiState.value.selectedTags var proofRequest = preparedPaymentProofRequest @@ -4181,10 +4191,15 @@ class AppViewModel @Inject constructor( amount = amount, tags = tags, requestId = incomingPaymentRequest?.id, + payerIdentity = originalPayer, beforeSendAttempt = { if (preparedPaymentProofRequest != null) { markOnchainPaymentStarted(incomingPaymentRequest, address).getOrThrow() paymentProofStarted = true + paykitPaymentProofRepo.verifyOriginalOnchainPayer( + preparedPaymentProofRequest, + requireNotNull(originalPayer), + ).getOrThrow() } incomingPaymentRequest?.let { paykitPaymentRequestRepo.ensurePaymentAllowed(it).getOrThrow() } sendAttempted = true @@ -4321,6 +4336,62 @@ class AppViewModel @Inject constructor( } } + suspend fun retryOriginalOnchainSend( + original: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, + ): Result { + val request = original.requestId?.let { id -> + (paykitPaymentRequestRepo.pendingRequests.value + paykitPaymentRequestRepo.paymentRequestHistory.value) + .firstOrNull { it.id == id } + } + if (original.requestId != null && request == null) { + return Result.failure(PaykitPaymentRequestError.RequestUnavailable) + } + val result = lightningRepo.retryOriginalOnchainSend( + original.attemptId, + original.walletId, + feeRateSatsPerVByte, + ) { attempt -> + check( + attempt.attemptId == original.attemptId && attempt.requestId == original.requestId && + attempt.orderId == original.orderId && attempt.payerIdentity == original.payerIdentity + ) + attempt.orderId?.let { orderId -> + val order = blocktankRepo.fetchOrders(listOf(orderId)).getOrThrow().singleOrNull { it.id == orderId } + check(order != null && order.payment?.onchain?.address == attempt.address) + val originalContext = requireNotNull(attempt.transferContext) + check(order.state2 == BtOrderState2.CREATED) + check(order.clientBalanceSat == originalContext.originalOrderClientBalanceSats) + check(order.feeSat == originalContext.originalOrderFeeSats) + check(kotlin.time.Instant.parse(order.orderExpiresAt) > kotlin.time.Clock.System.now()) + } + if (request != null) { + val currentRequest = + ( + paykitPaymentRequestRepo.pendingRequests.value + + paykitPaymentRequestRepo.paymentRequestHistory.value + ) + .firstOrNull { it.id == attempt.requestId } + ?: throw PaykitPaymentRequestError.RequestUnavailable + paykitPaymentRequestRepo.ensurePaymentAllowed(currentRequest).getOrThrow() + paykitPaymentProofRepo.authorizeOnchainRecovery(attempt).getOrThrow() + } + } + if (result.getOrNull() is OnchainSendOutcome.Accepted) { + runSuspendCatching { + val winner = lightningRepo.currentOnchainSendAttempt() ?: return@runSuspendCatching + if (request != null) { + paykitPaymentProofRepo.completeRecoveredOnchainPayment(request, winner) + } else if (winner.isTransfer) { + transferRepo.resumeAcceptedFunding().getOrThrow() + } else { + lightningRepo.completeAcceptedOrdinaryFollowup(requireNotNull(winner.txid)) + } + }.onFailure { Logger.warn("Failed to finish original recovered send locally", it, context = TAG) } + } + return result + } + private fun showUnresolvedOnchainSend( txid: String?, amount: ULong, @@ -4715,20 +4786,22 @@ class AppViewModel @Inject constructor( amount: ULong, tags: List = emptyList(), requestId: PaykitPaymentRequestId? = null, + payerIdentity: String? = null, beforeSendAttempt: suspend () -> Unit = {}, onBroadcast: suspend (Txid) -> Unit = {}, ): Result = lightningRepo.sendOnChain( - address = address, - sats = amount, - speed = _sendUiState.value.speed, - utxosToSpend = _sendUiState.value.selectedUtxos, - isMaxAmount = _sendUiState.value.payMethod == SendMethod.ONCHAIN && - amount == walletRepo.balanceState.value.maxSendOnchainSats, - tags = tags, - requestId = requestId, - beforeSendAttempt = beforeSendAttempt, - onBroadcast = onBroadcast, - ) + address = address, + sats = amount, + speed = _sendUiState.value.speed, + utxosToSpend = _sendUiState.value.selectedUtxos, + isMaxAmount = _sendUiState.value.payMethod == SendMethod.ONCHAIN && + amount == walletRepo.balanceState.value.maxSendOnchainSats, + tags = tags, + requestId = requestId, + payerIdentity = payerIdentity, + beforeSendAttempt = beforeSendAttempt, + onBroadcast = onBroadcast, + ) private suspend fun sendLightning( bolt11: String, diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 0c575af384..1e3677a28f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -427,6 +427,8 @@ class TransferViewModel @Inject constructor( order.feeSat.safe() + plan.miningFeeSats.safe() }, preTransferOnchainSats = plan.totalOnchainBalance, + originalOrderClientBalanceSats = order.clientBalanceSat, + originalOrderFeeSats = order.feeSat, ) return lightningRepo .sendOnChain( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fae4784079..3087aa7bc6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1477,8 +1477,13 @@ This payment is taking a bit longer than expected. You can continue using Bitkit. Funding transaction is unresolved. Check its transaction before trying again. Payment Pending - The transaction outcome is unresolved. Check its status before trying again. Bitkit will block another send while this outcome is unresolved. + The transaction outcome is unresolved. Check its status before trying again. Other payments remain blocked. If available, use Retry original payment to authorize a fee for the same payment. Broadcast refused: %1$s + Authorize retry + Retry the original payment using the same amount and inputs. Enter the fee rate you authorize in sat/vB. A higher fee may be unavailable; Bitkit will never reduce the payment or add other inputs. + The original payment could not be retried. Its funds remain protected. There may be insufficient funds for the chosen fee. Try another fee or check the transaction status. + Enter a valid positive fee rate in sat/vB. + Retry original payment Transaction ID: %1$s This invoice has already been paid. Currency conversion failed diff --git a/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt new file mode 100644 index 0000000000..80263f07f2 --- /dev/null +++ b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt @@ -0,0 +1,106 @@ +package to.bitkit.models + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Test +import to.bitkit.repositories.OnchainSendEvidence +import java.security.MessageDigest +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull + +class ActiveOnchainAttemptBackupTest { + private val json = Json { ignoreUnknownKeys = true } + private val binding = "fe843546f607f38ba7b1e8fe479c3103139ebea5b83628cbaa465e41cf6cb6c0" + + private fun golden(): WalletBackupV1 { + val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val hash = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } + assertEquals("1e392cdfaa82f48bed7be194ddaa3efe62efbf41a54fd14b70590d9a11f6f7b2", hash) + return json.decodeFromString(bytes.decodeToString()) + } + + @Test + fun `same golden bytes preserve original proof and remap only validated guard`() { + val state = requireNotNull(golden().paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + wire.validateProofs(state.pendingProofs, "wallet0") + val restored = wire.restored("regtest", binding, "destination-wallet", 2) + assertEquals(2, restored.walletIndex) + assertEquals("destination-wallet", restored.walletId) + assertEquals(wire.payerIdentity, restored.payerIdentity) + assertEquals(wire.requestId, restored.requestId) + assertEquals(wire.candidateTxids, restored.candidateTxids) + assertEquals(UInt.MAX_VALUE, restored.originalInputs?.single()?.vout) + assertEquals("123", restored.backupFollowup?.feeSats) + val precise = wire.copy(followup = wire.followup?.copy(createdAtMillis = "1791234000123")) + assertEquals( + "1791234000123", + precise.restored("regtest", binding, "wallet0", 0).backupFollowup?.createdAtMillis + ) + assertEquals(wire.followup?.contact, restored.backupFollowup?.contact) + assertFalse(restored.localFollowupComplete) + assertEquals(OnchainSendEvidence.Unknown, restored.evidence) + assertNull(state.pendingProofs.single().onchainWalletId) + WalletScope.pushTestOverride("destination-wallet").use { + assertEquals("destination-wallet", state.pendingProofs.single().restored().onchainWalletId) + val rebuilt = ActiveOnchainAttemptBackup.from(restored, "regtest", binding) + assertEquals(wire.copy(wallet = wire.wallet.copy(sourceIndex = "2")), rebuilt) + assertEquals(rebuilt, json.decodeFromString(json.encodeToString(rebuilt))) + } + } + + @Test + fun `wrong wallet network payer hardware proof or foreign candidate fail closed`() { + val state = requireNotNull(golden().paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + assertFailsWith { wire.restored("bitcoin", binding, "wallet0", 0) } + assertFailsWith { wire.restored("regtest", "00".repeat(32), "wallet0", 0) } + assertFailsWith { wire.copy(version = 2).restored("regtest", binding, "wallet0", 0) } + assertFailsWith { + wire.copy( + amountSats = "01234" + ).restored("regtest", binding, "wallet0", 0) + } + assertFailsWith { + wire.copy( + txid = "00".repeat(32) + ).restored("regtest", binding, "wallet0", 0) + } + val proof = state.pendingProofs.single() + val invalid = listOf( + proof.copy(identity = "foreign-payer"), + proof.copy(onchainWalletId = "hardware-wallet"), + proof.copy(paymentIdentifier = "00".repeat(32), onchainAcceptanceVerified = true), + proof.copy(onchainAmountSats = 1235uL), + proof.copy(proofData = "00".repeat(32)), + proof.copy(onchainAcceptanceVerified = true, proofData = proof.paymentIdentifier), + ) + invalid.forEach { assertFailsWith { wire.validateProofs(listOf(it), "wallet0") } } + } + + @Test + fun `unprepared zero fee guard stays pending and accepted restore resets local ack`() { + val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt) + val pending = wire.copy( + status = "pending", + txid = null, + originalInputs = null, + candidateTxids = emptyList(), + feeRateSatsPerVByte = "0" + ) + val restored = pending.restored("regtest", binding, "wallet0", 0) + assertEquals(OnchainSendEvidence.Pending, restored.evidence) + assertNull(restored.originalInputs) + assertFalse(restored.localFollowupComplete) + assertFailsWith { + wire.copy( + feeRateSatsPerVByte = "0" + ).restored("regtest", binding, "wallet0", 0) + } + val accepted = wire.copy(status = "accepted").restored("regtest", binding, "wallet0", 0) + assertEquals(OnchainSendEvidence.Accepted, accepted.evidence) + assertFalse(accepted.localFollowupComplete) + } +} diff --git a/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt index 6e6ddda290..5759ddbbd3 100644 --- a/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt @@ -15,6 +15,24 @@ import kotlin.time.ExperimentalTime import kotlin.time.Instant class PaykitPaymentStateBackupTest { + @Test + fun `shared active operation survives payment backup reader writer roundtrip`() { + val wire = """ + {"subscriptions":{},"pendingProofs":[],"activeOnchainAttempt":{"version":1,"wallet":{"kind":"software","network":"regtest","binding":"${"ab".repeat( + 32 + )}","sourceIndex":"0"},"attemptId":"00000000-0000-4000-8000-000000000001","requestId":null,"orderId":null,"payerIdentity":null,"address":"bcrt1qrecipient","amountSats":"20000","isMaxAmount":false,"status":"unknown","txid":"${"cd".repeat( + 32 + )}","rejectionReason":null,"originalInputs":[{"txid":"${"ef".repeat( + 32 + )}","vout":"0"}],"candidateTxids":["${"cd".repeat( + 32 + )}"],"feeRateSatsPerVByte":"1","followup":null,"transfer":null}} + """.trimIndent() + val codec = Json { ignoreUnknownKeys = true } + val decoded = codec.decodeFromString(wire) + assertContains(codec.encodeToString(decoded), "\"activeOnchainAttempt\"") + } + @Test fun `payment backup accepts shared wire format and retains pending payment`() { WalletScope.pushTestOverride("wallet0").use { diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index df3195696d..e4efeab815 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -95,6 +95,8 @@ class BackupRepoTest : BaseUnitTest() { private val paykitPaymentRequestRepo = mock() private val paykitPresentationStore = mock() private val keychain = mock() + private val onchainSendAttemptStore = mock() + private val vssStoreIdProvider = mock() private val preActivityMetadataRepo = mock() private val lightningService = mock() private val clock = mock() @@ -111,6 +113,10 @@ class BackupRepoTest : BaseUnitTest() { fun setUp() = test { whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(1_000)) whenever(db.transferDao()).thenReturn(transferDao) + whenever(db.configDao()).thenReturn(mock()) + whenever(db.configDao().getAll()).thenReturn(MutableStateFlow(listOf(to.bitkit.data.entities.ConfigEntity()))) + whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow(null)) + whenever(onchainSendAttemptStore.backupStateVersion).thenReturn(MutableStateFlow(0L)) whenever { transferDao.upsert(any>()) }.thenReturn(Unit) whenever { cacheStore.updateBackupStatus(any(), any()) }.thenReturn(Unit) whenever { cacheStore.update(any()) }.thenReturn(Unit) @@ -145,6 +151,70 @@ class BackupRepoTest : BaseUnitTest() { sut = createSut() } + @Test + fun `wallet backup exports and restores shared active guard before original proof`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val golden = json.decodeFromString(bytes.decodeToString()) + val state = requireNotNull(golden.paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + val attempt = wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0) + whenever(onchainSendAttemptStore.backupSnapshot(0)).thenReturn(attempt) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + whenever(paykitPaymentProofRepo.backupSnapshot()).thenReturn(state.pendingProofs) + val data = argumentCaptor() + assertTrue(sut.triggerBackup(BackupCategory.WALLET).isSuccess) + verifyBlocking(vssBackupClient) { putObject(eq(BackupCategory.WALLET.name), data.capture()) } + val exported = json.decodeFromString(data.firstValue.decodeToString()) + assertEquals(wire, exported.paykitPaymentState?.activeOnchainAttempt) + stubWalletBackup(paykitPaymentState = state) + sut.performFullRestoreFromLatestBackup().getOrThrow() + val ordered = org.mockito.kotlin.inOrder(onchainSendAttemptStore, paykitPaymentProofRepo) + ordered.verify(onchainSendAttemptStore).restoreActive(attempt) + ordered.verify(paykitPaymentProofRepo).restoreBackup(state.pendingProofs) + } + + @Test + fun `active guard uses captured backup namespace index instead of assuming index zero`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + whenever(db.configDao().getAll()).thenReturn(MutableStateFlow(listOf(to.bitkit.data.entities.ConfigEntity(2)))) + whenever(vssStoreIdProvider.getBackupWalletBinding(2)).thenReturn(wire.wallet.binding) + stubWalletBackup(paykitPaymentState = state) + sut.performFullRestoreFromLatestBackup().getOrThrow() + verify( + onchainSendAttemptStore + ).restoreActive(wire.restored("regtest", wire.wallet.binding, WalletScope.default, 2)) + verify( + onchainSendAttemptStore, + never() + ).restoreActive(wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0)) + } + + @Test + fun `wrong active binding or proof blocks restore without clearing guard or publishing empty backup`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn("00".repeat(32)) + stubWalletBackup(paykitPaymentState = state) + assertTrue(sut.performFullRestoreFromLatestBackup().isFailure) + verify(onchainSendAttemptStore, never()).restoreActive(any()) + verify(paykitPaymentProofRepo, never()).restoreBackup(any()) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + stubWalletBackup( + paykitPaymentState = state.copy( + pendingProofs = state.pendingProofs.map { + it.copy(onchainWalletId = "foreign-hardware") + } + ) + ) + assertTrue(sut.performFullRestoreFromLatestBackup().isFailure) + verify(onchainSendAttemptStore, never()).restoreActive(any()) + verify(paykitPaymentProofRepo, never()).restoreBackup(any()) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + } + @Test fun `start observing is skipped while wiping`() = test { sut.setWiping(true) @@ -1136,6 +1206,8 @@ class BackupRepoTest : BaseUnitTest() { paykitPaymentRequestRepo = Provider { paykitPaymentRequestRepo }, paykitPresentationStore = paykitPresentationStore, keychain = keychain, + onchainSendAttemptStore = onchainSendAttemptStore, + vssStoreIdProvider = vssStoreIdProvider, preActivityMetadataRepo = preActivityMetadataRepo, lightningService = lightningService, clock = clock, diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 562a5b79d2..ebc4ad3af1 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -1519,6 +1519,8 @@ class LightningRepoTest : BaseUnitTest() { @Test fun `sendOnChain should cache activity meta data`() = test { + whenever(onchainSendAttemptStore.broadcastPreparedCandidate(any(), any(), any(), any())) + .doSuspendableAnswer { (it.getArgument OnchainSendOutcome>(3))() } val mockSettingsData = SettingsData( defaultTransactionSpeed = TransactionSpeed.Fast, coinSelectAuto = false // Disable auto coin selection to simplify the test @@ -1529,7 +1531,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(coreService.activity).thenReturn(mock()) whenever( - lightningService.send( + lightningService.prepareOnchainSend( address = any(), sats = any(), satsPerVByte = any(), @@ -1537,7 +1539,7 @@ class LightningRepoTest : BaseUnitTest() { isMaxAmount = any(), walletIndex = any(), ) - ).thenReturn(OnchainSendOutcome.Accepted("testPaymentId")) + ).thenReturn(preparedOutcome(OnchainSendOutcome.Accepted("testPaymentId"))) val attempt = OnchainSendAttempt( walletId = "test-wallet", @@ -1554,7 +1556,8 @@ class LightningRepoTest : BaseUnitTest() { ) whenever { onchainSendAttemptStore.admit( - any(), anyOrNull(), anyOrNull(), any(), any(), any(), any(), any(), anyOrNull(), any(), anyOrNull(), any(), + any(), anyOrNull(), anyOrNull(), any(), any(), any(), any(), any(), + anyOrNull(), any(), anyOrNull(), any(), anyOrNull(), ) } .thenReturn(attempt) @@ -1599,25 +1602,25 @@ class LightningRepoTest : BaseUnitTest() { } @Test - fun `only a direct undispatched node error releases an admitted send`() = test { + fun `preparation errors release an admitted send without dispatch`() = test { val attempt = pendingSendAttempt() val repo = prepareGuardedSend(attempt) var sendError: Throwable = NodeException.InvalidAddress("invalid address") - whenever(lightningService.send(any(), any(), any(), anyOrNull(), any(), any())) + whenever(lightningService.prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any())) .doSuspendableAnswer { throw sendError } assertTrue(repo.sendOnChain("address", 1_000uL).exceptionOrNull() is OnchainSendNotDispatchedError) verify(onchainSendAttemptStore).releaseBeforeDispatch(attempt.attemptId, attempt.walletIndex) sendError = LdkError(NodeException.PersistenceFailed("workflow storage failure")) - assertTrue(repo.sendOnChain("address", 1_000uL).exceptionOrNull() is OnchainSendPendingError) - verify(onchainSendAttemptStore, times(1)).releaseBeforeDispatch(any(), any()) + assertTrue(repo.sendOnChain("address", 1_000uL).exceptionOrNull() is OnchainSendNotDispatchedError) + verify(onchainSendAttemptStore, times(2)).releaseBeforeDispatch(any(), any()) } @Test fun `UI cancellation after admission never releases pending guard`() = test { val attempt = pendingSendAttempt() val repo = prepareGuardedSend(attempt) - whenever(lightningService.send(any(), any(), any(), anyOrNull(), any(), any())) + whenever(lightningService.prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any())) .thenThrow(CancellationException("screen closed")) assertFailsWith { repo.sendOnChain("address", 1_000uL) } verify(onchainSendAttemptStore, never()).releaseBeforeDispatch(any(), any()) @@ -1629,7 +1632,9 @@ class LightningRepoTest : BaseUnitTest() { val repo = prepareGuardedSend(attempt) val txid = "ab".repeat(32) val accepted = OnchainSendOutcome.Accepted(txid) - whenever(lightningService.send(any(), any(), any(), anyOrNull(), any(), any())).thenReturn(accepted) + whenever( + lightningService.prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any()) + ).thenReturn(preparedOutcome(accepted)) whenever(onchainSendAttemptStore.recordOutcome(any(), any(), any())) .thenReturn(attempt.copy(evidence = OnchainSendEvidence.Accepted, txid = txid)) val result = repo.sendOnChain("address", 1_000uL, onBroadcast = { @@ -1639,6 +1644,10 @@ class LightningRepoTest : BaseUnitTest() { verify(onchainSendAttemptStore, never()).releaseBeforeDispatch(any(), any()) } + private fun preparedOutcome(outcome: OnchainSendOutcome) = PreparedOnchainSend( + OnchainPreparedReceipt(outcome.txid, listOf(OnchainSendInput("11".repeat(32), 0u)), "address", 1_000uL), + ) { outcome } + private fun pendingSendAttempt() = OnchainSendAttempt( walletId = "test-wallet", attemptId = "attempt-1", requestId = null, orderId = null, address = "address", amountSats = 1_000uL, isMaxAmount = false, feeRateSatsPerVByte = 1uL, @@ -1646,10 +1655,15 @@ class LightningRepoTest : BaseUnitTest() { ) private suspend fun prepareGuardedSend(attempt: OnchainSendAttempt): LightningRepo { + whenever(onchainSendAttemptStore.broadcastPreparedCandidate(any(), any(), any(), any())) + .doSuspendableAnswer { (it.getArgument OnchainSendOutcome>(3))() } whenever(settingsStore.data).thenReturn(flowOf(SettingsData(coinSelectAuto = false))) - whenever(onchainSendAttemptStore.admit( - any(), anyOrNull(), anyOrNull(), any(), any(), any(), any(), any(), anyOrNull(), any(), anyOrNull(), any(), - )).thenReturn(attempt) + whenever( + onchainSendAttemptStore.admit( + any(), anyOrNull(), anyOrNull(), any(), any(), any(), any(), any(), + anyOrNull(), any(), anyOrNull(), any(), anyOrNull(), + ) + ).thenReturn(attempt) startNodeForTesting() return spy(sut).also { doReturn(Result.success(1uL)).whenever(it).getFeeRateForSpeed(any(), anyOrNull()) } } @@ -1694,7 +1708,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(keychain.loadString(key, 0)).thenAnswer { saved } whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { writes++ - if (writes == 2) error("transient accepted write failure") + if (writes == 3) error("transient accepted write failure") saved = it.getArgument(1) } val store = OnchainSendAttemptStore(testDispatcher, keychain, lightningService) @@ -1711,8 +1725,8 @@ class LightningRepoTest : BaseUnitTest() { whenever(coreService.activity).thenReturn(activityService) whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) whenever(activityService.getOnchainActivityByTxId(eq(txid), any())).thenReturn(mock()) - whenever(lightningService.send(any(), any(), any(), anyOrNull(), any(), any())) - .thenReturn(OnchainSendOutcome.Accepted(txid)) + whenever(lightningService.prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any())) + .thenReturn(preparedOutcome(OnchainSendOutcome.Accepted(txid))) startNodeForTesting() val repo = spy(sut).also { doReturn(Result.success(1uL)).whenever(it).getFeeRateForSpeed(any(), anyOrNull()) } @@ -1726,7 +1740,7 @@ class LightningRepoTest : BaseUnitTest() { assertEquals(txid, reopened?.txid) assertEquals(OnchainSendEvidence.Accepted, reopened?.evidence) assertTrue(reopened?.localFollowupComplete == true) - verify(lightningService, times(1)).send(any(), any(), any(), anyOrNull(), any(), any()) + verify(lightningService, times(1)).prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any()) } @Test @@ -1753,7 +1767,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(activityService.getOnchainActivityByTxId(txid, attempt.walletId)).thenReturn(mock()) sut.completeAcceptedShopFollowup(requestId, txid) verify(onchainSendAttemptStore).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) - verify(lightningService, never()).send(any(), any(), any(), anyOrNull(), any(), any()) + verify(lightningService, never()).prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any()) } @Test @@ -1775,7 +1789,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(activityService.getOnchainActivityByTxId(txid, attempt.walletId)).thenReturn(mock()) sut.completeAcceptedTransferFollowup("order-1", txid) verify(onchainSendAttemptStore).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) - verify(lightningService, never()).send(any(), any(), any(), anyOrNull(), any(), any()) + verify(lightningService, never()).prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any()) } @Test @@ -1783,13 +1797,13 @@ class LightningRepoTest : BaseUnitTest() { val context = OnchainTransferContext(txTotalSats = 99_000uL, preTransferOnchainSats = 125_000uL) val attempt = pendingSendAttempt().copy(isTransfer = true, orderId = "order-1", transferContext = context) val repo = prepareGuardedSend(attempt) - whenever(lightningService.send(any(), any(), any(), anyOrNull(), any(), any())) + whenever(lightningService.prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any())) .doSuspendableAnswer { verify(onchainSendAttemptStore).admit( any(), isNull(), eq("order-1"), any(), any(), any(), any(), eq(true), anyOrNull(), any(), - eq(context), any(), + eq(context), any(), anyOrNull(), ) - OnchainSendOutcome.Unknown("ab".repeat(32)) + preparedOutcome(OnchainSendOutcome.Unknown("ab".repeat(32))) } whenever(onchainSendAttemptStore.recordOutcome(any(), any(), any())) .thenReturn(attempt.copy(evidence = OnchainSendEvidence.Unknown, txid = "ab".repeat(32))) diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index 2c8a619e47..87ae24ce1d 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -5,7 +5,6 @@ import kotlinx.coroutines.async import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.launch import org.junit.Test -import to.bitkit.services.LightningService import org.mockito.kotlin.any import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq @@ -13,6 +12,7 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.keychain.Keychain +import to.bitkit.services.LightningService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -22,6 +22,79 @@ import kotlin.test.assertTrue class OnchainSendAttemptStoreTest : BaseUnitTest() { private val key = Keychain.Key.ONCHAIN_SEND_ATTEMPT.name + @Test + fun `restored exact candidate observes without resend and unsupported contact cannot acknowledge`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val backup = kotlinx.serialization.json.Json { ignoreUnknownKeys = true } + .decodeFromString(bytes.decodeToString()) + val state = requireNotNull(backup.paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + wire.validateProofs(state.pendingProofs, "wallet0") + val original = wire.restored("regtest", wire.wallet.binding, "wallet0", 2) + val values = mutableMapOf() + val keychain = mock() + val service = mock() + whenever(service.currentWalletIndex).thenReturn(2) + whenever(keychain.loadString(eq(key), any())).thenAnswer { values[it.getArgument(1)] } + whenever(keychain.upsertString(eq(key), any(), any())).doSuspendableAnswer { + values[it.getArgument(2)] = it.getArgument(1) + } + val store = OnchainSendAttemptStore(testDispatcher, keychain, service) + store.restoreActive(original) + assertFailsWith { store.admitForTest() } + assertNull(store.observeExactTransaction("00".repeat(32))) + val observed = store.observeExactTransaction(requireNotNull(wire.txid)) + assertEquals(OnchainSendEvidence.Observed, observed?.evidence) + assertEquals(wire.candidateTxids, observed?.candidateTxids) + assertFailsWith { store.markLocalFollowupComplete(original.attemptId, 2) } + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, service) + assertEquals(observed, reopened.current()) + assertFailsWith { reopened.admitForTest() } + val other = original.copy(attemptId = "00000000-0000-4000-8000-000000000009") + assertFailsWith { reopened.restoreActive(other) } + assertEquals(observed, reopened.current()) + } + + @Test + fun `restored supported accepted context resets acknowledgement and finishes idempotently`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val backup = kotlinx.serialization.json.Json { ignoreUnknownKeys = true } + .decodeFromString(bytes.decodeToString()) + val wire = requireNotNull(backup.paykitPaymentState?.activeOnchainAttempt) + val accepted = wire.copy(status = "accepted", followup = wire.followup?.copy(contact = null)) + .restored("regtest", wire.wallet.binding, "wallet0", 0) + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock()) + store.restoreActive(accepted) + assertTrue(store.current()?.localFollowupComplete == false) + assertFailsWith { store.admitForTest() } + store.markLocalFollowupComplete(accepted.attemptId, 0) + store.markLocalFollowupComplete(accepted.attemptId, 0) + assertTrue(store.current()?.localFollowupComplete == true) + } + + @Test + fun `missing imported followup stays blocked while existing local positive context can acknowledge`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock()) + val admitted = store.admitForTest() + val local = admitted.copy(backupFollowup = null) + saved = kotlinx.serialization.json.Json.encodeToString(OnchainSendAttempt.serializer(), local) + store.recordOutcome(local.attemptId, OnchainSendOutcome.Accepted("ab".repeat(32)), 0) + store.markLocalFollowupComplete(local.attemptId, 0) + assertTrue(store.current()?.localFollowupComplete == true) + val imported = requireNotNull(store.current()).copy(restoredFromBackup = true, localFollowupComplete = false) + store.restoreActive(imported) + assertFailsWith { store.markLocalFollowupComplete(imported.attemptId, 0) } + assertFailsWith { store.admitForTest() } + } + @Test fun `reopen blocks unresolved send until exact transaction is observed and followup is complete`() = test { var saved: String? = null @@ -170,7 +243,30 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { assertFailsWith { reopened.admitForTest() } } - private suspend fun OnchainSendAttemptStore.admitForTest(beforeSendAttempt: suspend () -> Unit = {}): OnchainSendAttempt = admit( + @Test + fun `late unknown outcome cannot overwrite exact positive observation`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock()) + val attempt = store.admitForTest() + val txid = "ab".repeat(32) + store.recordOutcome(attempt.attemptId, OnchainSendOutcome.Unknown(txid), attempt.walletIndex) + store.observeExactTransaction(txid) + + val late = store.recordOutcome(attempt.attemptId, OnchainSendOutcome.Unknown(txid), attempt.walletIndex) + + assertEquals(OnchainSendEvidence.Observed, late.evidence) + assertEquals(txid, store.current()?.txid) + assertEquals(OnchainSendEvidence.Observed, store.current()?.evidence) + assertFailsWith { store.admitForTest() } + } + + private suspend fun OnchainSendAttemptStore.admitForTest( + beforeSendAttempt: suspend () -> Unit = { + } + ): OnchainSendAttempt = admit( walletId = "wallet-1", requestId = null, orderId = null, diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt new file mode 100644 index 0000000000..19eb76b615 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt @@ -0,0 +1,504 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.keychain.Keychain +import to.bitkit.services.LightningService +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class OnchainSendCoordinatorTest : BaseUnitTest() { + private val input = OnchainSendInput("11".repeat(32), 0u) + private val firstTxid = "ab".repeat(32) + private val nextTxid = "cd".repeat(32) + + private inner class Fixture { + var saved: String? = null + var failWrite = false + var walletIndex = 0 + val keychain = mock() + val service = mock() + val store: OnchainSendAttemptStore + + init { + whenever(service.currentWalletIndex).thenAnswer { walletIndex } + whenever(keychain.loadString(eq(Keychain.Key.ONCHAIN_SEND_ATTEMPT.name), any())).thenAnswer { saved } + whenever { keychain.upsertString(eq(Keychain.Key.ONCHAIN_SEND_ATTEMPT.name), any(), any()) } + .doSuspendableAnswer { + if (failWrite) error("storage unavailable") + saved = it.getArgument(1) + } + store = OnchainSendAttemptStore(testDispatcher, keychain, service) + } + + suspend fun admit(isMax: Boolean = false): OnchainSendAttempt = store.admit( + walletId = "wallet-1", + requestId = if (isMax) { + null + } else { + PaykitPaymentRequestId( + "request", + "payer", + "receiver" + ) + }, + orderId = "order-1", address = "bcrt1qrecipient", amountSats = 1_000uL, + isMaxAmount = isMax, feeRateSatsPerVByte = 1uL, isTransfer = true, + channelId = "channel-1", tags = listOf("tag"), + transferContext = OnchainTransferContext(1_100uL, 2_000uL), beforeSendAttempt = {}, + payerIdentity = "original-payer", + ) + + fun receipt(txid: String = firstTxid, amount: ULong = 1_000uL) = + OnchainPreparedReceipt(txid, listOf(input), "bcrt1qrecipient", amount) + + suspend fun unresolved(): OnchainSendAttempt { + val attempt = admit() + store.retainPreparedReceipt(attempt.attemptId, 0, receipt(), false) + return store.recordOutcome(attempt.attemptId, OnchainSendOutcome.Unknown(firstTxid), 0) + } + } + + @Test + fun `receipt is durable before first broadcast and restart retains candidate`() = test { + val f = Fixture() + val attempt = f.admit() + var sends = 0 + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt) = PreparedOnchainSend(f.receipt()) { + assertTrue(requireNotNull(f.saved).contains(firstTxid)) + sends++ + OnchainSendOutcome.Unknown(firstTxid) + } + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, + ): PreparedOnchainSend = + error("not requested") + } + assertTrue(OnchainSendCoordinator(f.store, sender, testDispatcher).sendInitial(attempt).isSuccess) + val reopened = OnchainSendAttemptStore(testDispatcher, f.keychain, f.service) + assertEquals(listOf(firstTxid), reopened.current()?.candidateTxids) + assertEquals(listOf(input), reopened.current()?.originalInputs) + assertEquals(1, sends) + } + + @Test + fun `receipt storage failure prevents broadcast`() = test { + val f = Fixture() + val attempt = f.admit() + var sends = 0 + f.failWrite = true + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt) = PreparedOnchainSend(f.receipt()) { + sends++ + OnchainSendOutcome.Accepted(firstTxid) + } + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, + ): PreparedOnchainSend = + error("not requested") + } + assertTrue(OnchainSendCoordinator(f.store, sender, testDispatcher).sendInitial(attempt).isFailure) + assertEquals(0, sends) + assertTrue(f.store.current()?.blocksNextSend == true) + } + + @Test + fun `initial Max actual amount becomes fixed recovery amount with original context`() = test { + val f = Fixture() + val attempt = f.admit(isMax = true) + var authorizations = 0 + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt) = PreparedOnchainSend( + f.receipt(amount = 900uL) + ) { + OnchainSendOutcome.Unknown(firstTxid) + } + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, + ): PreparedOnchainSend { + assertEquals(900uL, attempt.amountSats) + assertEquals(listOf(input), attempt.originalInputs) + assertEquals("order-1", attempt.orderId) + assertEquals("wallet-1", attempt.walletId) + assertEquals(null, attempt.requestId) + assertEquals("channel-1", attempt.channelId) + assertEquals(OnchainTransferContext(1_100uL, 2_000uL), attempt.transferContext) + return PreparedOnchainSend(f.receipt(nextTxid, 900uL)) { OnchainSendOutcome.Unknown(nextTxid) } + } + } + val coordinator = OnchainSendCoordinator(f.store, sender, testDispatcher) + coordinator.sendInitial(attempt).getOrThrow() + coordinator.retryOriginal(attempt.attemptId, attempt.walletId, 2uL) { authorizations++ }.getOrThrow() + assertEquals(listOf(firstTxid, nextTxid), f.store.current()?.candidateTxids) + assertEquals(1, authorizations) + } + + @Test + fun `recovery rejects changed inputs amount or wallet before broadcasting`() = test { + for (mode in listOf("input", "amount", "wallet")) { + val f = Fixture() + val attempt = f.unresolved() + var sends = 0 + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error( + "not requested", + ) + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, + ): PreparedOnchainSend { + if (mode == "wallet") f.walletIndex = 1 + val receipt = when (mode) { + "input" -> f.receipt(nextTxid).copy(inputs = listOf(OnchainSendInput("22".repeat(32), 1u))) + "amount" -> f.receipt(nextTxid, 999uL) + else -> f.receipt(nextTxid) + } + return PreparedOnchainSend(receipt) { + sends++ + OnchainSendOutcome.Accepted(nextTxid) + } + } + } + val result = OnchainSendCoordinator( + f.store, + sender, + testDispatcher + ).retryOriginal(attempt.attemptId, attempt.walletId, 2uL) {} + assertTrue(result.isFailure) + assertEquals(0, sends) + } + } + + @Test + fun `older candidate observation during successor broadcast wins over Unknown`() = test { + val f = Fixture() + val attempt = f.unresolved() + val entered = CompletableDeferred() + val finish = CompletableDeferred() + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error( + "not requested", + ) + override suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong) = + PreparedOnchainSend(f.receipt(nextTxid)) { + entered.complete(Unit) + finish.await() + OnchainSendOutcome.Unknown(nextTxid) + } + } + val coordinator = OnchainSendCoordinator(f.store, sender, testDispatcher) + val retry = async { coordinator.retryOriginal(attempt.attemptId, attempt.walletId, 2uL) {} } + entered.await() + val observed = async { f.store.observeExactTransaction(firstTxid) } + finish.complete(Unit) + retry.await().getOrThrow() + observed.await() + assertEquals(firstTxid, f.store.current()?.txid) + assertEquals(OnchainSendEvidence.Observed, f.store.current()?.evidence) + assertFailsWith { f.admit() } + } + + @Test + fun `positive observation during preparation prevents another broadcast`() = test { + val f = Fixture() + val attempt = f.unresolved() + var sends = 0 + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error( + "not requested", + ) + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, + ): PreparedOnchainSend { + f.store.observeExactTransaction(firstTxid) + return PreparedOnchainSend(f.receipt(nextTxid)) { + sends++ + OnchainSendOutcome.Accepted(nextTxid) + } + } + } + assertTrue( + OnchainSendCoordinator( + f.store, + sender, + testDispatcher + ).retryOriginal(attempt.attemptId, attempt.walletId, 2uL) {}.getOrThrow() == + OnchainSendOutcome.Accepted(firstTxid) + ) + assertEquals(0, sends) + assertEquals(firstTxid, f.store.current()?.txid) + } + + @Test + fun `preexisting readable guard without receipt cannot retry`() = test { + val f = Fixture() + val attempt = f.admit() + f.store.recordOutcome(attempt.attemptId, OnchainSendOutcome.Unknown(firstTxid), 0) + val sender = mock() + assertTrue( + OnchainSendCoordinator( + f.store, + sender, + testDispatcher + ).retryOriginal(attempt.attemptId, attempt.walletId, 2uL) {}.isFailure + ) + assertEquals(OnchainSendEvidence.Unknown, f.store.current()?.evidence) + } + + @Test + fun `adapter uses native Max only initially and exact fixed inputs for every recovery`() = test { + val f = Fixture() + val initial = f.admit(isMax = true) + val native = mock() + whenever(native.prepareMax(initial.address, true, 1uL, 0)).thenReturn( + PreparedOnchainSend(f.receipt(amount = 900uL)) { OnchainSendOutcome.Unknown(firstTxid) }, + ) + whenever(native.prepareFixed(initial.address, 900uL, 2uL, listOf(input), 0)).thenReturn( + PreparedOnchainSend(f.receipt(nextTxid, 900uL)) { OnchainSendOutcome.Unknown(nextTxid) }, + ) + val coordinator = OnchainSendCoordinator(f.store, OnchainPreparedSenderAdapter(native), testDispatcher) + coordinator.sendInitial(initial).getOrThrow() + coordinator.retryOriginal(initial.attemptId, initial.walletId, 2uL) {}.getOrThrow() + verify(native).prepareMax(initial.address, true, 1uL, 0) + verify(native).prepareFixed(initial.address, 900uL, 2uL, listOf(input), 0) + } + + @Test + fun `original authorization failure retains prepared candidate without broadcasting`() = test { + val f = Fixture() + val attempt = f.unresolved() + var prepares = 0 + var broadcasts = 0 + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error( + "not requested", + ) + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, + ): PreparedOnchainSend { + prepares++ + return PreparedOnchainSend(f.receipt(nextTxid)) { + broadcasts++ + OnchainSendOutcome.Accepted(nextTxid) + } + } + } + val result = OnchainSendCoordinator( + f.store, + sender, + testDispatcher + ).retryOriginal(attempt.attemptId, attempt.walletId, 2uL) { retained -> + assertEquals(f.store.current(), retained) + error("original order unavailable") + } + assertTrue(result.isFailure) + assertEquals(1, prepares) + assertEquals(0, broadcasts) + assertEquals(listOf(firstTxid, nextTxid), f.store.current()?.candidateTxids) + } + + @Test + fun `payer switch during suspended preparation denies dispatch and retains candidate guard`() = test { + val f = Fixture() + val original = f.unresolved() + val entered = CompletableDeferred() + val finish = CompletableDeferred() + var currentPayer = original.payerIdentity + var broadcasts = 0 + var authorizations = 0 + var authorizedRecord: OnchainSendAttempt? = null + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error("unused") + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, + ): PreparedOnchainSend { + entered.complete(Unit) + finish.await() + return PreparedOnchainSend(f.receipt(nextTxid)) { + broadcasts++ + OnchainSendOutcome.Accepted(nextTxid) + } + } + } + val coordinator = OnchainSendCoordinator(f.store, sender, testDispatcher) + val result = async { + coordinator.retryOriginal(original.attemptId, original.walletId, 2uL) { retained -> + authorizations++ + authorizedRecord = retained + check(currentPayer == original.payerIdentity) { "original payer changed" } + } + } + entered.await() + currentPayer = "different-payer" + finish.complete(Unit) + assertTrue(result.await().isFailure) + assertEquals(0, broadcasts) + assertEquals(1, authorizations) + val retained = f.store.current() + assertEquals(retained, authorizedRecord) + assertEquals(listOf(firstTxid, nextTxid), retained?.candidateTxids) + assertEquals(original.payerIdentity, retained?.payerIdentity) + assertEquals(original.requestId, retained?.requestId) + assertEquals(original.orderId, retained?.orderId) + assertEquals(original.originalInputs, retained?.originalInputs) + assertEquals(OnchainSendEvidence.Pending, retained?.evidence) + val restarted = OnchainSendAttemptStore(testDispatcher, f.keychain, f.service) + assertEquals(retained, restarted.current()) + assertFailsWith { f.admit() } + } + + @Test + fun `concurrent explicit retries never prepare or broadcast in parallel`() = test { + val f = Fixture() + val attempt = f.unresolved() + val entered = CompletableDeferred() + val finish = CompletableDeferred() + var prepares = 0 + var broadcasts = 0 + var active = 0 + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error( + "not requested", + ) + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, + ): PreparedOnchainSend { + assertEquals(0, active) + prepares++ + val txid = if (prepares == 1) nextTxid else "ef".repeat(32) + return PreparedOnchainSend(f.receipt(txid)) { + active++ + assertEquals(1, active) + broadcasts++ + if (broadcasts == 1) { + entered.complete(Unit) + finish.await() + } + active-- + OnchainSendOutcome.Unknown(txid) + } + } + } + val coordinator = OnchainSendCoordinator(f.store, sender, testDispatcher) + val first = async { coordinator.retryOriginal(attempt.attemptId, attempt.walletId, 2uL) {} } + entered.await() + val second = async { coordinator.retryOriginal(attempt.attemptId, attempt.walletId, 3uL) {} } + assertEquals(1, prepares) + finish.complete(Unit) + first.await().getOrThrow() + second.await().getOrThrow() + assertEquals(2, broadcasts) + assertEquals(listOf(firstTxid, nextTxid, "ef".repeat(32)), f.store.current()?.candidateTxids) + } + + @Test + fun `known older positive survives persistence failure and late successor outcome`() = test { + val f = Fixture() + val attempt = f.unresolved() + f.store.retainPreparedReceipt(attempt.attemptId, 0, f.receipt(nextTxid), true) + f.failWrite = true + assertFailsWith { f.store.observeExactTransaction(firstTxid) } + val retained = f.store.recordOutcome(attempt.attemptId, OnchainSendOutcome.Unknown(nextTxid), 0) + assertEquals(firstTxid, retained.txid) + assertEquals(OnchainSendEvidence.Observed, retained.evidence) + f.failWrite = false + f.store.markLocalFollowupComplete(attempt.attemptId, 0) + val reopened = OnchainSendAttemptStore(testDispatcher, f.keychain, f.service) + assertEquals(firstTxid, reopened.current()?.txid) + assertTrue(reopened.current()?.localFollowupComplete == true) + } + + @Test + fun `old positive write failure during successor preparation returns original winner without sending`() = test { + val f = Fixture() + val original = f.unresolved() + var sends = 0 + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error("unused") + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong + ): PreparedOnchainSend { + f.failWrite = true + runCatching { f.store.observeExactTransaction(firstTxid) } + return PreparedOnchainSend(f.receipt(nextTxid)) { + sends++ + error("must not broadcast after positive observation") + } + } + } + val result = OnchainSendCoordinator(f.store, sender, testDispatcher) + .retryOriginal(original.attemptId, original.walletId, 2uL) {} + assertEquals(OnchainSendOutcome.Accepted(firstTxid), result.getOrThrow()) + assertEquals(0, sends) + assertEquals(OnchainSendEvidence.Observed, f.store.current()?.evidence) + f.failWrite = false + f.store.markLocalFollowupComplete(original.attemptId, 0) + val restarted = OnchainSendAttemptStore(testDispatcher, f.keychain, f.service) + assertEquals(firstTxid, restarted.current()?.txid) + assertTrue(restarted.current()?.localFollowupComplete == true) + } + + @Test + fun `overflowing authorized recovery fee never reaches native preparation`() = test { + assertEquals(UInt.MAX_VALUE.toULong(), OnchainRecoveryFeeRate.maximum) + assertEquals(null, OnchainRecoveryFeeRate.parse("4294967296")) + assertEquals( + OnchainRecoveryFeeRate.maximum, + OnchainRecoveryFeeRate.parse(OnchainRecoveryFeeRate.maximum.toString()), + ) + assertEquals(null, OnchainRecoveryFeeRate.parse((OnchainRecoveryFeeRate.maximum + 1uL).toString())) + assertEquals(null, OnchainRecoveryFeeRate.parse("0")) + val f = Fixture() + val original = f.unresolved() + var preparations = 0 + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error("unused") + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, + ): PreparedOnchainSend { + preparations++ + return PreparedOnchainSend(f.receipt(nextTxid)) { OnchainSendOutcome.Unknown(nextTxid) } + } + } + for (invalidRate in listOf(4_294_967_296uL, ULong.MAX_VALUE)) { + val result = OnchainSendCoordinator(f.store, sender, testDispatcher) + .retryOriginal(original.attemptId, original.walletId, invalidRate) {} + assertTrue(result.isFailure) + } + assertEquals(0, preparations) + assertEquals(listOf(firstTxid), f.store.current()?.candidateTxids) + } + + @Test + fun `prepared broadcast success or error is cached once`() = test { + for (fails in listOf(false, true)) { + var sends = 0 + val prepared = PreparedOnchainSend(Fixture().receipt()) { + sends++ + if (fails) error("native failed") + OnchainSendOutcome.Unknown(firstTxid) + } + repeat(2) { runCatching { prepared.broadcast() } } + assertEquals(1, sends) + } + } +} diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index ca36a80620..a866ad44c5 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -32,14 +32,14 @@ import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import to.bitkit.models.NodeLifecycleState -import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.HwConnectedDevice +import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.HwFundingTransaction +import to.bitkit.models.NodeLifecycleState import to.bitkit.models.WalletScope +import to.bitkit.services.CoreService import to.bitkit.services.PaykitReceiverPaths import to.bitkit.services.PaykitSdkService -import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest import to.bitkit.ui.screens.wallets.send.HwSendRequest import to.bitkit.ui.screens.wallets.send.HwSendViewModel @@ -701,6 +701,154 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(proofCaptor.firstValue.contains(txid)) } + @Test + fun `typed accepted callback cannot invent proof when original guard cannot be read`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + val original = storedProofs.single() + whenever(lightningRepo.currentOnchainSendAttempt()).thenThrow(IllegalStateException("guard unavailable")) + assertFalse( + repo.completeOnchainPayment( + request, + "ab".repeat(32), + MethodId.P2wpkh.rawValue, + OnchainSendOutcome.Accepted("ab".repeat(32)), + ) + ) + assertEquals(listOf(original), storedProofs) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + } + + @Test + fun `shared golden restored software proof authorizes and completes exact observed successor`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val backup = kotlinx.serialization.json.Json { ignoreUnknownKeys = true } + .decodeFromString(bytes.decodeToString()) + val state = requireNotNull(backup.paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + wire.validateProofs(state.pendingProofs, WalletScope.default) + val attempt = wire.restored("regtest", wire.wallet.binding, WalletScope.default, 2) + val repo = paymentProofRepo() + repo.restoreBackup(state.pendingProofs) + assertEquals(WalletScope.default, storedProofs.single().onchainWalletId) + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(requireNotNull(wire.payerIdentity), true)) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + repo.authorizeOnchainRecovery(attempt).getOrThrow() + assertEquals(state.pendingProofs.single().paymentIdentifier, storedProofs.single().paymentIdentifier) + val requestId = requireNotNull(wire.requestId) + val request = paymentRequest(state.pendingProofs.single().paymentEndpointIdentifier).copy( + paymentRequestId = requestId.paymentRequestId, + counterparty = requestId.counterparty, + counterpartyReceiverPath = requestId.counterpartyReceiverPath, + amountSats = attempt.amountSats, + ) + val winner = attempt.copy(evidence = OnchainSendEvidence.Observed) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(winner) + assertTrue(repo.completeRecoveredOnchainPayment(request, winner)) + val completed = storedProofs.single() + assertTrue(completed.onchainAcceptanceVerified) + assertEquals(winner.txid, completed.paymentIdentifier) + assertEquals(winner.txid, completed.proofData) + storedProofs = listOf(completed.copy(onchainWalletId = "hardware-wallet", proofData = null)) + assertFalse(repo.completeRecoveredOnchainPayment(request, winner)) + storedProofs = listOf(completed.copy(identity = COUNTERPARTY, proofData = null)) + assertFalse(repo.completeRecoveredOnchainPayment(request, winner)) + } + + @Test + fun `successor winner updates the original payer proof without creating another proof`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val original = "cd".repeat(32) + val winner = "ab".repeat(32) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + storedProofs = storedProofs.map { it.copy(paymentIdentifier = original) } + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn( + acceptedAttempt(request, winner).copy( + payerIdentity = LOCAL_IDENTITY, + originalInputs = listOf(OnchainSendInput("11".repeat(32), 0u)), + candidateTxids = listOf(original, winner), + ), + ) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + .thenThrow(IllegalStateException("delivery temporarily unavailable")) + + assertTrue( + repo.completeOnchainPayment( + request, + winner, + MethodId.P2wpkh.rawValue, + OnchainSendOutcome.Accepted(winner), + ) + ) + + val proof = storedProofs.single() + assertEquals(LOCAL_IDENTITY, proof.identity) + assertEquals(request.id, proof.requestId) + assertEquals(winner, proof.paymentIdentifier) + assertEquals(winner, proof.proofData) + assertTrue(proof.onchainAcceptanceVerified) + assertFalse(repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).isSuccess) + } + + @Test + fun `original retry authorization retains payer proof and rejects identity switch`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val original = "cd".repeat(32) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + storedProofs = storedProofs.map { it.copy(paymentIdentifier = original) } + val proof = storedProofs.single() + val attempt = acceptedAttempt(request, original).copy( + evidence = OnchainSendEvidence.Unknown, + payerIdentity = LOCAL_IDENTITY, + originalInputs = listOf(OnchainSendInput("11".repeat(32), 0u)), + candidateTxids = listOf(original), + ) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + + repo.authorizeOnchainRecovery(attempt).getOrThrow() + assertEquals(listOf(proof), storedProofs) + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(COUNTERPARTY, true)) + storedProofs = listOf(proof, proof.copy(identity = COUNTERPARTY)) + assertTrue(repo.authorizeOnchainRecovery(attempt).isFailure) + assertEquals(listOf(proof, proof.copy(identity = COUNTERPARTY)), storedProofs) + whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt.copy(payerIdentity = null)) + assertTrue(repo.authorizeOnchainRecovery(attempt.copy(payerIdentity = null)).isFailure) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + } + + @Test + fun `candidate winner cannot replace a proof bound outside its original family`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val winner = "ab".repeat(32) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + storedProofs = storedProofs.map { it.copy(paymentIdentifier = "ef".repeat(32)) } + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn( + acceptedAttempt(request, winner).copy( + payerIdentity = LOCAL_IDENTITY, + originalInputs = listOf(OnchainSendInput("11".repeat(32), 0u)), + candidateTxids = listOf("cd".repeat(32), winner), + ), + ) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + + repo.reconcile() + + assertNull(storedProofs.single().proofData) + assertEquals("ef".repeat(32), storedProofs.single().paymentIdentifier) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) + verify(lightningRepo, never()).finishAcceptedShopActivity(any(), any()) + } + @Test fun `reconcile only resolves the request tied to exact transaction evidence`() = test { val secondPaymentRequestId = "550e8400-e29b-41d4-a716-446655440001" diff --git a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt index c9e9da6001..3ccd90831d 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -16,14 +16,16 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import org.lightningdevkit.ldknode.AddressType -import org.lightningdevkit.ldknode.FeeRate -import org.lightningdevkit.ldknode.OnchainSendResult import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.FeeRate import org.lightningdevkit.ldknode.Node import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.OnchainPayment +import org.lightningdevkit.ldknode.OnchainSendResult import org.lightningdevkit.ldknode.OnchainWalletAccount +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock @@ -41,17 +43,17 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.env.Env import to.bitkit.ext.createChannelDetails import to.bitkit.models.WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX -import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.models.WatchOnlyAccountRecord import to.bitkit.models.WatchOnlyAccountSetupState +import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.test.BaseUnitTest import to.bitkit.utils.LoggerLdk import to.bitkit.utils.ServiceError import java.util.concurrent.CountDownLatch import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Duration.Companion.milliseconds @@ -116,38 +118,51 @@ class LightningServiceTest : BaseUnitTest() { assertTrue(sut.canReceive()) } + private fun preparedNative(txid: String) = mock().also { + whenever(it.txid()).thenReturn(txid) + whenever(it.inputs()).thenReturn(listOf(org.lightningdevkit.ldknode.OutPoint("11".repeat(32), 0u))) + whenever(it.recipientAmountSats()).thenReturn(1_000uL) + } + @Test - fun `fixed send returns only the generated accepted rejected or unknown outcome`() = test { + fun `fixed preparation captures actual receipt without dispatch and caches broadcast`() = test { val txid = "ab".repeat(32) + val native = preparedNative(txid) whenever(node.onchainPayment()).thenReturn(onchainPayment) - whenever(onchainPayment.sendToAddressWithBroadcastResult("address", 1_000uL, sendFeeRate, null)) - .thenReturn(OnchainSendResult.Accepted(txid)) - .thenReturn(OnchainSendResult.Rejected(txid, "non-final")) - .thenReturn(OnchainSendResult.Unknown(txid)) - - assertEquals(OnchainSendOutcome.Accepted(txid), sut.send("address", 1_000uL, 1uL)) - assertEquals(OnchainSendOutcome.Rejected(txid, "non-final"), sut.send("address", 1_000uL, 1uL)) - assertEquals(OnchainSendOutcome.Unknown(txid), sut.send("address", 1_000uL, 1uL)) - verify(onchainPayment, times(3)).sendToAddressWithBroadcastResult("address", 1_000uL, sendFeeRate, null) - verify(onchainPayment, never()).sendToAddress("address", 1_000uL, sendFeeRate, null) + whenever(onchainPayment.prepareSendToAddress("address", 1_000uL, sendFeeRate, null)).thenReturn(native) + whenever(native.broadcast()).thenReturn(OnchainSendResult.Accepted(txid)) + + val prepared = sut.prepareOnchainSend("address", 1_000uL, 1uL) + assertEquals(txid, prepared.receipt.txid) + assertEquals(1_000uL, prepared.receipt.amountSats) + assertEquals(1, prepared.receipt.inputs.size) + verify(native, never()).broadcast() + repeat(2) { assertEquals(OnchainSendOutcome.Accepted(txid), prepared.broadcast()) } + verify(native).broadcast() + verify(onchainPayment, never()).sendToAddressWithBroadcastResult(any(), any(), anyOrNull(), anyOrNull()) } @Test - fun `send all retains reserves and uses the explicit outcome method once`() = test { + fun `initial Max uses native automatic reserve semantics then captures actual amount`() = test { val txid = "ab".repeat(32) + val native = preparedNative(txid) + whenever(native.recipientAmountSats()).thenReturn(900uL) whenever(node.onchainPayment()).thenReturn(onchainPayment) - whenever(onchainPayment.sendAllToAddressWithBroadcastResult("address", true, sendFeeRate)) - .thenReturn(OnchainSendResult.Unknown(txid)) - - assertEquals(OnchainSendOutcome.Unknown(txid), sut.send("address", 1_000uL, 1uL, isMaxAmount = true)) - verify(onchainPayment).sendAllToAddressWithBroadcastResult("address", true, sendFeeRate) - verify(onchainPayment, never()).sendAllToAddress("address", true, sendFeeRate) + whenever(onchainPayment.prepareSendAllToAddress("address", true, sendFeeRate)).thenReturn(native) + whenever(native.broadcast()).thenReturn(OnchainSendResult.Unknown(txid)) + + val prepared = sut.prepareOnchainSend("address", 1_000uL, 1uL, isMaxAmount = true) + assertEquals(900uL, prepared.receipt.amountSats) + verify(native, never()).broadcast() + assertEquals(OnchainSendOutcome.Unknown(txid), prepared.broadcast()) + verify(onchainPayment).prepareSendAllToAddress("address", true, sendFeeRate) + verify(onchainPayment, never()).sendAllToAddressWithBroadcastResult(any(), any(), anyOrNull()) } @Test fun `onchain send refuses a different wallet before invoking native payment`() = test { assertFailsWith { - sut.send("bcrt1qrecipient", 1_000uL, 1uL, walletIndex = 9) + sut.prepareOnchainSend("bcrt1qrecipient", 1_000uL, 1uL, walletIndex = 9) } verify(node, never()).onchainPayment() } diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt index 4904738925..f4ddcb6b95 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt @@ -5,6 +5,7 @@ import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before @@ -13,7 +14,13 @@ import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import to.bitkit.models.WalletScope import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.OnchainSendAttempt +import to.bitkit.repositories.OnchainSendEvidence +import to.bitkit.repositories.OnchainSendInput +import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.test.BaseUnitTest @@ -26,6 +33,7 @@ class SendPendingViewModelTest : BaseUnitTest() { private val pendingPaymentRepo = PendingPaymentRepo() private val activityRepo: ActivityRepo = mock() + private val lightningRepo: LightningRepo = mock() private val hash = "test_payment_hash" private val amount = 5000L @@ -37,9 +45,54 @@ class SendPendingViewModelTest : BaseUnitTest() { whenever { activityRepo.findActivityByPaymentId(any(), any(), any(), any()) }.thenReturn( Result.failure(Exception("not found")) ) + whenever { activityRepo.findActivityByPaymentId(any(), any(), any(), any(), any()) } + .thenReturn(Result.failure(Exception("not found"))) sut = createViewModel() } + @Test + fun `pending original retry is explicit serialized and retains original amount`() = test { + val txid = "ab".repeat(32) + val original = OnchainSendAttempt( + walletId = WalletScope.default, attemptId = "original", requestId = null, + orderId = null, address = "original-address", amountSats = 1_000uL, isMaxAmount = true, + feeRateSatsPerVByte = 2uL, isTransfer = false, channelId = null, tags = emptyList(), + txid = txid, evidence = OnchainSendEvidence.Unknown, + originalInputs = listOf(OnchainSendInput("11".repeat(32), 0u)), + candidateTxids = listOf(txid), + ) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original) + sut.initOnchain(txid, 9_999L) + advanceUntilIdle() + assertEquals(original, sut.uiState.value.recoveryAttempt) + var sends = 0 + val release = CompletableDeferred() + val retry: suspend (OnchainSendAttempt, ULong) -> Result = + { retained, fee -> + sends++ + assertEquals(original, retained) + assertEquals(3uL, fee) + release.await() + Result.success(OnchainSendOutcome.Unknown("cd".repeat(32))) + } + assertEquals(0, sends) + sut.retryOriginal(ULong.MAX_VALUE, retry) + advanceUntilIdle() + assertEquals(0, sends) + assertEquals(true, sut.uiState.value.invalidFeeRate) + assertEquals(false, sut.uiState.value.isRecovering) + sut.retryOriginal(3uL, retry) + sut.retryOriginal(3uL, retry) + advanceUntilIdle() + assertEquals(1, sends) + assertEquals(true, sut.uiState.value.isRecovering) + release.complete(Unit) + advanceUntilIdle() + assertNull(sut.uiState.value.recoveredTxid) + assertEquals("cd".repeat(32), sut.uiState.value.currentTxid) + assertEquals(original.amountSats, sut.uiState.value.recoveryAttempt?.amountSats) + } + @Test fun `onchain activity enables original wallet Details without resolving acceptance`() = test { val txid = "ab".repeat(32) @@ -192,5 +245,6 @@ class SendPendingViewModelTest : BaseUnitTest() { private fun createViewModel() = SendPendingViewModel( pendingPaymentRepo = pendingPaymentRepo, activityRepo = activityRepo, + lightningRepo = lightningRepo, ) } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 4f136c9b1c..faab6c3162 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -127,8 +127,8 @@ import to.bitkit.repositories.LightningState import to.bitkit.repositories.MethodId import to.bitkit.repositories.NodeEventUpdate import to.bitkit.repositories.OnchainSendAttempt -import to.bitkit.repositories.OnchainSendBlockedError import to.bitkit.repositories.OnchainSendAttemptUnreadableError +import to.bitkit.repositories.OnchainSendBlockedError import to.bitkit.repositories.OnchainSendEvidence import to.bitkit.repositories.OnchainSendNotDispatchedError import to.bitkit.repositories.OnchainSendOutcome @@ -451,6 +451,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.isExpired(any())).thenReturn(false) whenever(paykitPaymentRequestRepo.isProcessing(any())).thenReturn(false) whenever(paykitPaymentProofRepo.onchainPaymentResolutions).thenReturn(onchainPaymentResolutions) + whenever { paykitPaymentProofRepo.captureOriginalOnchainPayer(any()) } + .thenReturn(Result.success("pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg")) + whenever { paykitPaymentProofRepo.verifyOriginalOnchainPayer(any(), any()) }.thenReturn(Result.success(Unit)) whenever { paykitPaymentProofRepo.prepare(any(), any(), any()) }.thenReturn(Result.success(Unit)) whenever { paykitPaymentProofRepo.associateLightningPayment(any(), any(), any()) @@ -6470,8 +6473,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { requestId = anyOrNull(), orderId = anyOrNull(), transferContext = anyOrNull(), + payerIdentity = anyOrNull(), ) - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, OnchainSendOutcome.Accepted("txid")) + verify( + paykitPaymentProofRepo + ).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, OnchainSendOutcome.Accepted("txid")) } @Test @@ -6613,6 +6619,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { requestId = anyOrNull(), orderId = anyOrNull(), transferContext = anyOrNull(), + payerIdentity = anyOrNull(), ) verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any()) } @@ -7259,7 +7266,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertFalse(sut.prepareHardwareContactPayment(walletId, "bcrt1qpaymentrequest", request.id, testPublicKey)) verify(paykitPaymentRequestRepo, times(1)).accept(request) verify(paykitPaymentProofRepo, times(1)).markOnchainPaymentStarted(request, "bcrt1qpaymentrequest", walletId) - verify(lightningRepo, never()).sendOnChain(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull()) + verify( + lightningRepo, + never() + ).sendOnChain( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), anyOrNull(), any(), any(), any(), any(), + anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + ) } @Test @@ -7350,7 +7363,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertFalse(sut.completeHardwareContactPayment(txid, walletId, request.id, testPublicKey)) verify(paykitPaymentProofRepo).completeHardwareOnchainPayment(request.id, walletId, txid, testPublicKey) - verify(lightningRepo, never()).sendOnChain(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull()) + verify( + lightningRepo, + never() + ).sendOnChain( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), anyOrNull(), any(), any(), any(), any(), + anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + ) } @Test @@ -7374,7 +7393,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(txid, sut.successSendUiState.value.paymentHashOrTxId) assertEquals(walletId, sut.successSendUiState.value.activityWalletId) assertEquals(request.amountSats.toLong(), sut.successSendUiState.value.sats) - verify(lightningRepo, never()).sendOnChain(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull()) + verify( + lightningRepo, + never() + ).sendOnChain( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), anyOrNull(), any(), any(), any(), any(), + anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + ) } @Test @@ -7971,6 +7996,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { requestId = anyOrNull(), orderId = anyOrNull(), transferContext = anyOrNull(), + payerIdentity = anyOrNull(), ) } @@ -8014,6 +8040,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { requestId = anyOrNull(), orderId = anyOrNull(), transferContext = anyOrNull(), + payerIdentity = anyOrNull(), ) } @@ -8077,6 +8104,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { requestId = anyOrNull(), orderId = anyOrNull(), transferContext = anyOrNull(), + payerIdentity = anyOrNull(), ) } @@ -8139,6 +8167,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { requestId = anyOrNull(), orderId = anyOrNull(), transferContext = anyOrNull(), + payerIdentity = anyOrNull(), ) } @@ -8319,6 +8348,56 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(lightningRepo).completeAcceptedOrdinaryFollowup(txid) } + @Test + fun `recovery denies paid expired or changed original Blocktank order before broadcast`() = test { + val order = to.bitkit.ext.mockOrder().copy( + orderExpiresAt = (Clock.System.now() + 60.seconds).toString(), + ) + val original = OnchainSendAttempt( + walletId = WalletScope.default, + attemptId = "original-order-attempt", + transferContext = to.bitkit.repositories.OnchainTransferContext( + 1_200uL, + 100_000uL, + order.clientBalanceSat, + order.feeSat, + ), + requestId = null, + orderId = order.id, + address = requireNotNull(order.payment?.onchain?.address), + amountSats = order.feeSat, + isMaxAmount = false, + feeRateSatsPerVByte = 1uL, + isTransfer = true, + channelId = null, + tags = emptyList(), + evidence = OnchainSendEvidence.Unknown, + ) + var dispatched = 0 + whenever(lightningRepo.retryOriginalOnchainSend(any(), any(), any(), any())).doSuspendableAnswer { invocation -> + val authorize = invocation.getArgument Unit>(3) + runCatching { + authorize(original) + dispatched += 1 + OnchainSendOutcome.Accepted("ef".repeat(32)) + } + } + val invalidOrders = listOf( + order.copy(state2 = com.synonym.bitkitcore.BtOrderState2.PAID), + order.copy(orderExpiresAt = (Clock.System.now() - 1.seconds).toString()), + order.copy(feeSat = order.feeSat + 1uL), + order.copy(clientBalanceSat = order.clientBalanceSat + 1uL), + ) + for (invalidOrder in invalidOrders) { + whenever(blocktankRepo.fetchOrders(listOf(order.id))).thenReturn(Result.success(listOf(invalidOrder))) + assertTrue(sut.retryOriginalOnchainSend(original, 2uL).isFailure) + } + assertEquals(0, dispatched) + whenever(blocktankRepo.fetchOrders(listOf(order.id))).thenReturn(Result.success(listOf(order))) + assertTrue(sut.retryOriginalOnchainSend(original, 2uL).isSuccess) + assertEquals(1, dispatched) + } + @Test fun `private lightning contact payment consumes private list before send`() = test { val bolt11 = "lnbcrt1privatecontact" @@ -8880,6 +8959,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { requestId = anyOrNull(), orderId = anyOrNull(), transferContext = anyOrNull(), + payerIdentity = anyOrNull(), ) }.doSuspendableAnswer { invocation -> kotlin.check(invocation.getArgument(0) == address) diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 506b3207dd..bac4a53407 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -1168,7 +1168,15 @@ class TransferViewModelTest : BaseUnitTest() { onBroadcast = any(), requestId = anyOrNull(), orderId = anyOrNull(), - transferContext = eq(OnchainTransferContext(txTotalSats = 100_000uL, preTransferOnchainSats = 100_000uL)), + transferContext = eq( + OnchainTransferContext( + txTotalSats = 100_000uL, + preTransferOnchainSats = 100_000uL, + originalOrderClientBalanceSats = order.clientBalanceSat, + originalOrderFeeSats = order.feeSat, + ), + ), + payerIdentity = anyOrNull(), ) verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) verify(blocktankRepo, times(1)).createOrder(eq(order.clientBalanceSat), eq(order.lspBalanceSat), any()) @@ -1209,7 +1217,15 @@ class TransferViewModelTest : BaseUnitTest() { onBroadcast = any(), requestId = anyOrNull(), orderId = anyOrNull(), - transferContext = eq(OnchainTransferContext(txTotalSats = 38_171uL, preTransferOnchainSats = 41_000uL)), + transferContext = eq( + OnchainTransferContext( + txTotalSats = 38_171uL, + preTransferOnchainSats = 41_000uL, + originalOrderClientBalanceSats = order.clientBalanceSat, + originalOrderFeeSats = order.feeSat, + ), + ), + payerIdentity = anyOrNull(), ) verify(lightningRepo, never()).sendOnChain( address = any(), @@ -1226,6 +1242,7 @@ class TransferViewModelTest : BaseUnitTest() { requestId = anyOrNull(), orderId = anyOrNull(), transferContext = anyOrNull(), + payerIdentity = anyOrNull(), ) verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) } @@ -1259,6 +1276,7 @@ class TransferViewModelTest : BaseUnitTest() { anyOrNull(), anyOrNull(), anyOrNull(), + payerIdentity = anyOrNull(), ), ).thenReturn(Result.failure(AppError("Coin selection failed"))) @@ -1284,6 +1302,7 @@ class TransferViewModelTest : BaseUnitTest() { requestId = anyOrNull(), orderId = anyOrNull(), transferContext = anyOrNull(), + payerIdentity = anyOrNull(), ) verify(lightningRepo, never()).sendOnChain( address = any(), @@ -1300,6 +1319,7 @@ class TransferViewModelTest : BaseUnitTest() { requestId = anyOrNull(), orderId = anyOrNull(), transferContext = anyOrNull(), + payerIdentity = anyOrNull(), ) verify(cacheStore, never()).addPaidOrder(any(), any()) } @@ -1454,6 +1474,7 @@ class TransferViewModelTest : BaseUnitTest() { requestId = anyOrNull(), orderId = anyOrNull(), transferContext = anyOrNull(), + payerIdentity = anyOrNull(), ), ).thenReturn(Result.success(OnchainSendOutcome.Unknown(TXID))) quoteOrder(order) @@ -1523,6 +1544,7 @@ class TransferViewModelTest : BaseUnitTest() { anyOrNull(), anyOrNull(), anyOrNull(), + payerIdentity = anyOrNull(), ), ).thenReturn( Result.failure(AppError("Coin selection failed")), @@ -1786,6 +1808,7 @@ class TransferViewModelTest : BaseUnitTest() { requestId = anyOrNull(), orderId = anyOrNull(), transferContext = anyOrNull(), + payerIdentity = anyOrNull(), ) } @@ -1970,6 +1993,7 @@ class TransferViewModelTest : BaseUnitTest() { anyOrNull(), anyOrNull(), anyOrNull(), + payerIdentity = anyOrNull(), ) verify(cacheStore, never()).addPaidOrder(any(), any()) } @@ -1999,6 +2023,7 @@ class TransferViewModelTest : BaseUnitTest() { anyOrNull(), anyOrNull(), anyOrNull(), + payerIdentity = anyOrNull(), ), ).thenReturn(Result.failure(AppError("Coin selection failed"))) quoteOrder(order) @@ -2148,6 +2173,7 @@ class TransferViewModelTest : BaseUnitTest() { anyOrNull(), anyOrNull(), anyOrNull(), + payerIdentity = anyOrNull(), ) verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) } @@ -3467,6 +3493,7 @@ class TransferViewModelTest : BaseUnitTest() { requestId = anyOrNull(), orderId = anyOrNull(), transferContext = anyOrNull(), + payerIdentity = anyOrNull(), ) } @@ -3618,6 +3645,7 @@ class TransferViewModelTest : BaseUnitTest() { anyOrNull(), anyOrNull(), anyOrNull(), + payerIdentity = anyOrNull(), ), ).thenReturn(Result.success(OnchainSendOutcome.Accepted(TXID))) } diff --git a/app/src/test/resources/active-onchain-attempt-golden.json b/app/src/test/resources/active-onchain-attempt-golden.json new file mode 100644 index 0000000000..ae3933bc8d --- /dev/null +++ b/app/src/test/resources/active-onchain-attempt-golden.json @@ -0,0 +1,75 @@ +{ + "version": 1, + "createdAt": 1791234000000, + "transfers": [], + "paykitPaymentState": { + "subscriptions": {}, + "pendingProofs": [ + { + "identity": "pubkyzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + "requestId": { + "paymentRequestId": "550e8400-e29b-41d4-a716-446655440000", + "counterparty": "pubkyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy", + "counterpartyReceiverPath": "bitkit/server", + "billingPeriodStartsAt": null + }, + "paymentEndpointIdentifier": "btc-regtest-p2wpkh", + "kind": "bitcoin-onchain-txid", + "paymentStarted": true, + "paymentIdentifier": "abababababababababababababababababababababababababababababababab", + "proofData": null, + "billingPeriod": null, + "onchainAddress": "bcrt1qoriginal", + "onchainAmountSats": 1234, + "onchainWalletId": null, + "onchainMatchingTransactionIdsBeforeAttempt": [], + "onchainAcceptanceVerified": false + } + ], + "activeOnchainAttempt": { + "version": 1, + "wallet": { + "kind": "software", + "network": "regtest", + "binding": "fe843546f607f38ba7b1e8fe479c3103139ebea5b83628cbaa465e41cf6cb6c0", + "sourceIndex": "0" + }, + "attemptId": "550e8400-e29b-41d4-a716-446655440001", + "requestId": { + "paymentRequestId": "550e8400-e29b-41d4-a716-446655440000", + "counterparty": "pubkyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy", + "counterpartyReceiverPath": "bitkit/server", + "billingPeriodStartsAt": null + }, + "orderId": null, + "payerIdentity": "pubkyzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + "address": "bcrt1qoriginal", + "amountSats": "1234", + "isMaxAmount": false, + "status": "unknown", + "txid": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "rejectionReason": null, + "originalInputs": [ + { + "txid": "efefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefef", + "vout": "4294967295" + } + ], + "candidateTxids": [ + "abababababababababababababababababababababababababababababababab", + "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd" + ], + "feeRateSatsPerVByte": "2", + "followup": { + "feeSats": "123", + "tags": [ + "original tag" + ], + "contact": "pubkyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy", + "createdAtMillis": "1791234000000", + "channelId": null + }, + "transfer": null + } + } +} diff --git a/changelog.d/next/1384.fixed.md b/changelog.d/next/1384.fixed.md index 76d1bcc1be..e9962d4f40 100644 --- a/changelog.d/next/1384.fixed.md +++ b/changelog.d/next/1384.fixed.md @@ -1 +1 @@ -On-chain sends now show when a transaction was not accepted or its broadcast outcome is uncertain, and prevent another send while the outcome needs checking. +On-chain sends keep uncertain payments protected across restart and wallet restore, with an authenticated retry of the original amount and inputs. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f128d4030c..8194ead970 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -65,7 +65,7 @@ ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "k ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.68" } +ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.69" } lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } diff --git a/journeys/send/onchain-original-payment-retry.xml b/journeys/send/onchain-original-payment-retry.xml new file mode 100644 index 0000000000..e7dc80905e --- /dev/null +++ b/journeys/send/onchain-original-payment-retry.xml @@ -0,0 +1,27 @@ + + + Verifies explicit fee approval and normal spend authentication for retrying one original software + on-chain payment. Precondition: an onboarded funded dev wallet, PIN enabled, a synced node, and a + retained unresolved prepared-send receipt with its exact original recipient, amount, inputs and + candidate transaction IDs. Creating a controlled refusal or response-loss fixture is not provided + by the Capabilities table and remains in Manual Tests. A seeded Pending fixture must be labelled + as state injection, never as proof of a native fault. Manual Tests must verify the exact backend + winning transaction retains the complete original input set and recipient amount, and any original + Shop request or Blocktank order completes once without a new request or order. Local activity alone + cannot prove these checks. This journey is currently unrun on rc69. + + + Open the retained original payment's Pending screen and record its displayed transaction ID + Verify Pending describes the unresolved outcome and offers Retry original payment + Tap Retry original payment + Verify the dialog shows the original recipient and amount and prefills the original fee rate + Enter an invalid fee rate of 73786976294838207 sat/vB and verify valid positive fee feedback appears and Authorize retry is disabled + Enter a valid authorized fee rate that the funded fixture can support and tap Authorize retry + Verify the normal payment PIN screen appears before dispatch + Go Back without entering the PIN and verify the original Pending payment remains retained + Tap Retry original payment again, choose the fixture-supported fee and tap Authorize retry + Enter the fixture's normal payment PIN without recording entered digits + Wait for Bitcoin Sent only after exact positive evidence, or verify Pending remains with a readable error if fee headroom is insufficient + If successful, tap Details then Explore and record the exact winning transaction ID shown by TXID + + From 15f45fa715a312bf8f0cb63f3d93b1257be58524 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 6 Oct 2026 19:16:51 +0200 Subject: [PATCH 013/108] fix: update pending screens after payment acceptance --- .../PreparedOnchainRecoveryFixtureTest.kt | 26 +++++- .../wallets/send/SendPendingScreenTest.kt | 50 ++++++++++++ .../to/bitkit/repositories/LightningRepo.kt | 3 + .../screens/wallets/send/SendPendingScreen.kt | 68 +++++++++------- .../wallets/send/SendPendingViewModel.kt | 44 +++++++++- .../wallets/send/SendPendingViewModelTest.kt | 81 +++++++++++++++++++ 6 files changed, 241 insertions(+), 31 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/services/PreparedOnchainRecoveryFixtureTest.kt b/app/src/androidTest/java/to/bitkit/services/PreparedOnchainRecoveryFixtureTest.kt index 1ff70ef035..107228a412 100644 --- a/app/src/androidTest/java/to/bitkit/services/PreparedOnchainRecoveryFixtureTest.kt +++ b/app/src/androidTest/java/to/bitkit/services/PreparedOnchainRecoveryFixtureTest.kt @@ -69,6 +69,22 @@ class PreparedOnchainRecoveryFixtureTest { } } + @Test + fun verifyPersistedOriginalWinnerAfterUi() = runBlocking { + val context = ApplicationProvider.getApplicationContext() + Env.initAppStoragePath(context.filesDir.absolutePath) + hiltRule.inject() + val attempt = requireNotNull(store.current()) + val expectedTxid = requireNotNull(InstrumentationRegistry.getArguments().getString("expectedWinnerTxid")) + org.junit.Assert.assertEquals(expectedTxid, attempt.txid) + org.junit.Assert.assertTrue(attempt.hasPositiveEvidence) + org.junit.Assert.assertTrue(attempt.localFollowupComplete) + org.junit.Assert.assertTrue(attempt.candidateTxids.contains(expectedTxid)) + org.junit.Assert.assertFalse(attempt.originalInputs.isNullOrEmpty()) + File(context.getExternalFilesDir(null), "recovery-final-native-receipt.json") + .writeText(Json.encodeToString(attempt)) + } + @Test fun seedPreparedOriginalForRecoveryUi() = runBlocking { val context = ApplicationProvider.getApplicationContext() @@ -79,11 +95,19 @@ class PreparedOnchainRecoveryFixtureTest { walletRepo.createWallet(null).getOrThrow() keychain.upsertString(Keychain.Key.PIN.name, "1234") // Public disposable-fixture PIN only. settingsStore.update { - it.copy(isPinEnabled = true, isPinForPaymentsEnabled = true, isBiometricEnabled = false) + it.copy( + isPinEnabled = true, + isPinForPaymentsEnabled = true, + isBiometricEnabled = false, + electrumServer = InstrumentationRegistry.getArguments().getString("fixtureElectrumUrl") + ?: it.electrumServer, + ) } lightningService.setup(walletIndex = 0) try { lightningService.start() + // Let the native initial background sync settle before forcing another sync in this fixture. + delay(15_000) lightningService.sync() val depositAddress = lightningService.newAddress() coreService.blocktank.regtestMine(1u) diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingScreenTest.kt index e6f80cf3b3..89dda8dccb 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingScreenTest.kt @@ -1,6 +1,12 @@ package to.bitkit.ui.screens.wallets.send import android.graphics.Bitmap +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -78,6 +84,50 @@ class SendPendingScreenTest { composeTestRule.runOnIdle { assertEquals("queued-local-activity", detailsId) } } + @Test + fun recoveryErrorKeepsAllActionsInsideCompactViewport() { + var retries = 0 + var closes = 0 + composeTestRule.setContent { + AppThemeSurface { + CompositionLocalProvider(LocalInspectionMode provides true) { + Box(Modifier.requiredSize(360.dp, 640.dp).testTag("pending-viewport")) { + SendPendingContent( + amount = 99_890L, + isOnchain = true, + activityId = null, + txid = "ab".repeat(32), + onClose = { closes++ }, + onViewDetails = {}, + canRetry = true, + recoveryError = "The original payment could not be retried. Its funds remain protected. " + + "There may be insufficient funds for the chosen fee. " + + "Try another fee or check the transaction status.", + onRetry = { retries++ }, + ) + } + } + } + } + val viewport = composeTestRule.onNodeWithTag("pending-viewport").fetchSemanticsNode().boundsInRoot + listOf("Retry original payment", "Details", "Close").forEach { text -> + val node = composeTestRule.onNodeWithText(text) + node.assertIsDisplayed() + val bounds = node.fetchSemanticsNode().boundsInRoot + check(bounds.top >= viewport.top && bounds.bottom <= viewport.bottom) { + "$text is clipped: $bounds outside $viewport" + } + } + val retryBounds = composeTestRule.onNodeWithText("Retry original payment").fetchSemanticsNode().boundsInRoot + val closeBounds = composeTestRule.onNodeWithText("Close").fetchSemanticsNode().boundsInRoot + check(retryBounds.bottom < closeBounds.top) { "Retry and Close overlap: $retryBounds / $closeBounds" } + composeTestRule.onNodeWithText("The original payment could not be retried.", substring = true).assertIsDisplayed() + composeTestRule.onNodeWithText("Retry original payment").performClick() + composeTestRule.onNodeWithText("Close").performClick() + composeTestRule.runOnIdle { assertEquals(1, retries); assertEquals(1, closes) } + saveScreenshot("ln112-pending-compact-error-component.png") + } + private fun assertUnresolved() { composeTestRule.onNodeWithText("Test backend refusal", substring = true).assertIsDisplayed() composeTestRule.onNodeWithText("ab".repeat(32), substring = true).assertIsDisplayed() diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 505f356a4a..421de7e031 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1724,6 +1724,9 @@ class LightningRepo @Inject constructor( } } + // A change signal only: consumers must reread and validate the original attempt. + val onchainSendAttemptUpdates get() = onchainSendAttemptStore.backupStateVersion + suspend fun currentOnchainSendAttempt(): OnchainSendAttempt? = onchainSendAttemptStore.current() suspend fun finishAcceptedShopActivity(requestId: PaykitPaymentRequestId, txid: String) { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt index a3662f837c..26a3f70f5f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt @@ -7,6 +7,8 @@ import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.Image +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -46,7 +48,6 @@ import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.BiometricsView import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BottomSheetPreview -import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.TextInput @@ -104,7 +105,7 @@ fun SendPendingScreen( } uiState.recoveredTxid?.let { winner -> - LaunchedEffect(winner) { onRecovered(winner, uiState.recoveryAttempt?.amountSats?.toLong() ?: amount) } + LaunchedEffect(winner) { onRecovered(winner, uiState.amount) } } RecoveryAuthorization( uiState, @@ -281,36 +282,49 @@ internal fun SendPendingContent( .fillMaxSize() .padding(horizontal = 16.dp) ) { - VerticalSpacer(16.dp) - BalanceHeaderView(sats = amount, modifier = Modifier.fillMaxWidth()) - - VerticalSpacer(32.dp) - BodyM( - stringResource( - if (isOnchain) R.string.wallet__send_pending__onchain_description - else R.string.wallet__send_pending__description, - ), - color = Colors.White64, - ) - - if (isOnchain) { - refusalReason?.let { + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + ) { + VerticalSpacer(16.dp) + BalanceHeaderView(sats = amount, modifier = Modifier.fillMaxWidth()) + recoveryError?.let { VerticalSpacer(16.dp) - BodyM(stringResource(R.string.wallet__send_pending__refusal, it), color = Colors.White64) + BodyM(it, color = Colors.White64) } - txid?.let { - VerticalSpacer(16.dp) - SelectionContainer { - BodyM(stringResource(R.string.wallet__send_pending__txid, it), color = Colors.White64) + + VerticalSpacer(32.dp) + BodyM( + stringResource( + if (isOnchain) { + R.string.wallet__send_pending__onchain_description + } else { + R.string.wallet__send_pending__description + }, + ), + color = Colors.White64, + ) + + if (isOnchain) { + refusalReason?.let { + VerticalSpacer(16.dp) + BodyM(stringResource(R.string.wallet__send_pending__refusal, it), color = Colors.White64) + } + txid?.let { + VerticalSpacer(16.dp) + SelectionContainer { + BodyM(stringResource(R.string.wallet__send_pending__txid, it), color = Colors.White64) + } } } - } - FillHeight() - HourglassAnimation(modifier = Modifier.align(Alignment.CenterHorizontally)) - FillHeight() - - recoveryError?.let { BodyM(it, color = Colors.White64) } + VerticalSpacer(32.dp) + HourglassAnimation(modifier = Modifier.align(Alignment.CenterHorizontally)) + VerticalSpacer(16.dp) + } + VerticalSpacer(16.dp) if (canRetry) { SecondaryButton( text = stringResource(R.string.wallet__send_pending__retry_title), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt index 3147812043..b1e639e3ea 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt @@ -66,8 +66,9 @@ class SendPendingViewModel @Inject constructor( val matchesOriginalWallet = walletId == WalletScope.default && attempt?.walletId == walletId val hasOriginalReceipt = !attempt?.originalInputs.isNullOrEmpty() && attempt?.candidateTxids?.any { it.equals(txid, true) } == true - if (matchesOriginalWallet && hasOriginalReceipt && attempt?.isUnresolved == true) { - _uiState.update { it.copy(recoveryAttempt = attempt) } + if (matchesOriginalWallet && hasOriginalReceipt && attempt != null) { + if (attempt.isUnresolved) _uiState.update { it.copy(recoveryAttempt = attempt) } + observeOriginalOrdinaryCompletion(attempt) } } } @@ -78,6 +79,40 @@ class SendPendingViewModel @Inject constructor( } } + private fun observeOriginalOrdinaryCompletion(original: OnchainSendAttempt) { + // Shop and transfer completion have their own proof/order resolution routes. + if (original.requestId != null || original.isTransfer) return + viewModelScope.launch { + lightningRepo.onchainSendAttemptUpdates.collect { + runSuspendCatching { lightningRepo.currentOnchainSendAttempt() }.onSuccess { current -> + if (current == null || !current.hasPositiveEvidence || !current.localFollowupComplete) { + return@onSuccess + } + if (current.matchesOriginalOrdinaryRoute(original)) { + _uiState.update { + it.copy( + recoveredTxid = current.txid, + currentTxid = current.txid, + recoveryAttempt = null, + recoveryError = null, + ) + } + } + } + } + } + } + + private fun OnchainSendAttempt.matchesOriginalOrdinaryRoute(original: OnchainSendAttempt): Boolean { + val sameOperation = attemptId == original.attemptId && amountSats == original.amountSats && + address == original.address && originalInputs == original.originalInputs + val sameWallet = walletId == original.walletId && walletIndex == original.walletIndex + val sameCandidateFamily = candidateTxids.any { it.equals(original.txid, true) } && + candidateTxids.any { it.equals(txid, true) } + val ordinaryPayment = requestId == null && !isTransfer + return sameOperation && sameWallet && sameCandidateFamily && ordinaryPayment + } + fun retryOriginal( feeRateSatsPerVByte: ULong, retry: suspend (OnchainSendAttempt, ULong) -> Result, @@ -94,13 +129,16 @@ class SendPendingViewModel @Inject constructor( val result = runSuspendCatching { retry(original, feeRateSatsPerVByte).getOrThrow() } result.onSuccess { outcome -> _uiState.update { + if (it.recoveredTxid != null) return@update it it.copy( recoveredTxid = (outcome as? OnchainSendOutcome.Accepted)?.txid, recoveryError = (outcome as? OnchainSendOutcome.Rejected)?.reason, currentTxid = outcome.txid, ) } - }.onFailure { error -> _uiState.update { it.copy(recoveryError = error.message) } } + }.onFailure { error -> + _uiState.update { if (it.recoveredTxid == null) it.copy(recoveryError = error.message) else it } + } } finally { _uiState.update { it.copy(isRecovering = false) } } diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt index f4ddcb6b95..9df01e522d 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt @@ -6,6 +6,7 @@ import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before @@ -35,6 +36,8 @@ class SendPendingViewModelTest : BaseUnitTest() { private val activityRepo: ActivityRepo = mock() private val lightningRepo: LightningRepo = mock() + private val attemptUpdates = MutableStateFlow(0L) + private val hash = "test_payment_hash" private val amount = 5000L @@ -47,6 +50,7 @@ class SendPendingViewModelTest : BaseUnitTest() { ) whenever { activityRepo.findActivityByPaymentId(any(), any(), any(), any(), any()) } .thenReturn(Result.failure(Exception("not found"))) + whenever(lightningRepo.onchainSendAttemptUpdates).thenReturn(attemptUpdates) sut = createViewModel() } @@ -93,6 +97,83 @@ class SendPendingViewModelTest : BaseUnitTest() { assertEquals(original.amountSats, sut.uiState.value.recoveryAttempt?.amountSats) } + @Test + fun `exact ordinary observed successor resolves only after original local completion`() = test { + val original = pendingOriginal() + val winner = "cd".repeat(32) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original) + sut.initOnchain(original.txid, 9_999L) + advanceUntilIdle() + val lateResult = CompletableDeferred() + sut.retryOriginal(2uL) { _, _ -> Result.success(lateResult.await()) } + advanceUntilIdle() + val observed = original.copy( + txid = winner, + candidateTxids = original.candidateTxids + winner, + evidence = OnchainSendEvidence.Observed, + ) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(observed) + attemptUpdates.value++ + advanceUntilIdle() + assertNull(sut.uiState.value.recoveredTxid) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(observed.copy(localFollowupComplete = true)) + attemptUpdates.value++ + advanceUntilIdle() + assertEquals(winner, sut.uiState.value.recoveredTxid) + assertEquals(1_000L, sut.uiState.value.amount) + assertNull(sut.uiState.value.recoveryAttempt) + lateResult.complete(OnchainSendOutcome.Unknown(original.txid!!)) + advanceUntilIdle() + assertEquals(winner, sut.uiState.value.recoveredTxid) + assertEquals(winner, sut.uiState.value.currentTxid) + org.mockito.kotlin.verify(activityRepo, org.mockito.kotlin.never()).syncActivities() + } + + @Test + fun `pending screen cannot resolve a foreign operation or wallet after update`() = test { + val original = pendingOriginal() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original) + sut.initOnchain(original.txid, amount) + advanceUntilIdle() + val positive = original.copy(evidence = OnchainSendEvidence.Observed, localFollowupComplete = true) + listOf( + positive.copy(attemptId = "another-operation"), + positive.copy(walletId = "hardware"), + positive.copy(walletIndex = 1), + positive.copy(txid = "ef".repeat(32), candidateTxids = listOf("ef".repeat(32))), + positive.copy(requestId = to.bitkit.repositories.PaykitPaymentRequestId("foreign", "payer", "/pub/paykit")), + ).forEach { foreign -> + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(foreign) + attemptUpdates.value++ + advanceUntilIdle() + assertNull(sut.uiState.value.recoveredTxid) + assertEquals(1_000L, sut.uiState.value.amount) + } + } + + @Test + fun `ordinary queued activity and nonpositive outcomes never resolve pending`() = test { + val original = pendingOriginal() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original) + sut.initOnchain(original.txid, amount) + advanceUntilIdle() + listOf(OnchainSendEvidence.Pending, OnchainSendEvidence.Rejected, OnchainSendEvidence.Unknown).forEach { + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original.copy(evidence = it)) + attemptUpdates.value++ + advanceUntilIdle() + assertNull(sut.uiState.value.recoveredTxid) + } + } + + private fun pendingOriginal() = OnchainSendAttempt( + walletId = WalletScope.default, attemptId = "original", requestId = null, + orderId = null, address = "original-address", amountSats = 1_000uL, isMaxAmount = false, + feeRateSatsPerVByte = 1uL, isTransfer = false, channelId = null, tags = emptyList(), + txid = "ab".repeat(32), evidence = OnchainSendEvidence.Unknown, + originalInputs = listOf(OnchainSendInput("11".repeat(32), 0u)), + candidateTxids = listOf("ab".repeat(32)), + ) + @Test fun `onchain activity enables original wallet Details without resolving acceptance`() = test { val txid = "ab".repeat(32) From 5f8e1050cd0a4881b729fdd9abc3aba36793c4b1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 6 Oct 2026 19:46:24 +0200 Subject: [PATCH 014/108] fix: validate funding terms during payment recovery --- .../java/to/bitkit/repositories/BackupRepo.kt | 9 ++ .../repositories/OnchainSendAttemptStore.kt | 7 +- .../repositories/PaykitPaymentProofRepo.kt | 26 ++++++ .../to/bitkit/repositories/TransferRepo.kt | 16 +++- .../java/to/bitkit/ui/sheets/SendSheet.kt | 5 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 22 ++++- .../to/bitkit/repositories/BackupRepoTest.kt | 48 ++++++++++ .../bitkit/repositories/LightningRepoTest.kt | 10 +- .../OnchainSendAttemptStoreTest.kt | 42 +++++---- .../OnchainSendCoordinatorTest.kt | 11 ++- .../PaykitPaymentProofRepoTest.kt | 51 +++++++++++ .../bitkit/repositories/TransferRepoTest.kt | 91 ++++++++++++++++--- .../viewmodels/AppViewModelSendFlowTest.kt | 47 +++++++++- 13 files changed, 343 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 234d534df8..1008ad7a15 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -113,6 +113,7 @@ class BackupRepo @Inject constructor( private val paykitPresentationStore: PaykitPaymentRequestPresentationStore, private val keychain: Keychain, private val onchainSendAttemptStore: OnchainSendAttemptStore, + private val transferRepo: Provider, private val vssStoreIdProvider: VssStoreIdProvider, private val preActivityMetadataRepo: PreActivityMetadataRepo, private val lightningService: LightningService, @@ -796,6 +797,7 @@ class BackupRepo @Inject constructor( private suspend fun restoreWalletBackup(dataBytes: ByteArray): Long { keychain.upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, String(dataBytes)) val parsed = json.decodeFromString(String(dataBytes)) + var restoredAttempt: OnchainSendAttempt? = null parsed.paykitPaymentState?.let { val walletIndex = backupWalletIndex() it.activeOnchainAttempt?.let { wire -> @@ -808,6 +810,7 @@ class BackupRepo @Inject constructor( ) check(backupWalletIndex() == walletIndex) { "Backup wallet changed during restore" } onchainSendAttemptStore.restoreActive(attempt) + restoredAttempt = attempt check(backupWalletIndex() == walletIndex) { "Backup wallet changed during restore" } } paykitPaymentRequestRepo.get().clear() @@ -832,6 +835,12 @@ class BackupRepo @Inject constructor( pubkyRepo.publicKey.value?.let { paykitPaymentRequestRepo.get().activate(it) } } keychain.delete(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name) + // Restore can finish while the node is already Running: no new lifecycle/event is guaranteed. + restoredAttempt?.takeIf { it.isTransfer && it.hasPositiveEvidence }?.let { attempt -> + transferRepo.get().resumeAcceptedFunding(attempt).onFailure { + Logger.warn("Restored accepted funding remains pending local follow-up", it, context = TAG) + } + } Logger.debug("Restored ${parsed.transfers.size} transfers", context = TAG) return parsed.createdAt } diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index af8675603a..d6caa7c3cd 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -14,12 +14,15 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import to.bitkit.data.keychain.Keychain import to.bitkit.di.IoDispatcher +import to.bitkit.ext.nowMillis import to.bitkit.models.ActiveOnchainAttemptBackup import to.bitkit.services.LightningService import to.bitkit.utils.AppError import java.util.UUID import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.ExperimentalTime sealed interface OnchainSendOutcome { val txid: String @@ -104,11 +107,13 @@ class OnchainSendNotDispatchedError(cause: Throwable) : AppError("On-chain send class OnchainSendPendingError(cause: Throwable, val txid: String? = null) : AppError("On-chain send outcome is unknown; do not send again", cause) +@OptIn(ExperimentalTime::class) @Singleton class OnchainSendAttemptStore @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val keychain: Keychain, private val lightningService: LightningService, + private val clock: Clock, ) { companion object { private val KEY = Keychain.Key.ONCHAIN_SEND_ATTEMPT.name @@ -172,7 +177,7 @@ class OnchainSendAttemptStore @Inject constructor( backupFollowup = ActiveOnchainAttemptBackup.Followup( feeSats = "0", tags = tags, - createdAtMillis = System.currentTimeMillis().toString(), + createdAtMillis = nowMillis(clock).toString(), channelId = channelId, ), ) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 29e95dbb37..16efe33518 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -461,6 +461,32 @@ class PaykitPaymentProofRepo @Inject constructor( } } + suspend fun failHardwareOnchainPaymentBeforeDispatch( + request: PaykitPaymentRequest, + walletId: String, + identity: String, + hasAttemptedBroadcast: Boolean, + ): Boolean = withContext(ioDispatcher) { + if (hasAttemptedBroadcast || walletId == WalletScope.default) return@withContext false + val originalIdentity = PubkyPublicKeyFormat.normalized(identity) ?: return@withContext false + operationMutex.withLock { + runSuspendCatching { + if (currentIdentity() != originalIdentity) return@runSuspendCatching false + val proofs = loadProofs() + val original = proofs.singleOrNull { + PubkyPublicKeyFormat.matches(it.identity, originalIdentity) && it.requestId == request.id && + it.onchainWalletId == walletId && it.kind == PaykitPaymentProofKind.Onchain && + it.paymentStarted && it.paymentIdentifier == null && it.proofData == null && + !it.onchainAcceptanceVerified + } ?: return@runSuspendCatching false + // Only the caller's definite first-dispatch authorization denial permits this removal. + persist(proofs - original) + true + }.onFailure { Logger.warn("Failed to clear denied hardware preparation", it, context = TAG) } + .getOrDefault(false) + } + } + suspend fun failOnchainPayment(request: PaykitPaymentRequest) { removeRequestProofs(request) { it.kind == PaykitPaymentProofKind.Onchain && diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index c055aad0eb..1498eb99b5 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -75,8 +75,14 @@ class TransferRepo @Inject constructor( } /** Uses only the bounded attempt's positive evidence and original order/balance context. */ - suspend fun resumeAcceptedFunding(): Result = runSuspendCatching { + suspend fun resumeAcceptedFunding(expectedAttempt: OnchainSendAttempt? = null): Result = runSuspendCatching { val attempt = lightningRepo.currentOnchainSendAttempt() ?: return@runSuspendCatching + if (expectedAttempt != null) { + check( + attempt.attemptId == expectedAttempt.attemptId && + attempt.walletIndex == expectedAttempt.walletIndex && attempt.walletId == expectedAttempt.walletId + ) { "Restored funding operation is no longer current" } + } if (!attempt.isTransfer || !attempt.hasPositiveEvidence || attempt.localFollowupComplete) { return@runSuspendCatching } @@ -85,7 +91,13 @@ class TransferRepo @Inject constructor( val order = blocktankRepo.fetchOrders(listOf(orderId)).getOrThrow().firstOrNull { it.id == orderId } ?: throw AppError("Original funding order is unavailable") check(order.payment?.onchain?.address == attempt.address) { "Original funding order address changed" } - persistAcceptedFunding(order, txid, attempt.transferContext).getOrThrow() + val context = requireNotNull(attempt.transferContext) { "Original funding context is unavailable" } + check( + order.clientBalanceSat == context.originalOrderClientBalanceSats && + order.feeSat == context.originalOrderFeeSats + ) { "Original funding order terms changed or are unavailable" } + check(lightningRepo.currentOnchainSendAttempt() == attempt) { "Original funding operation changed" } + persistAcceptedFunding(order, txid, context).getOrThrow() lightningRepo.completeAcceptedTransferFollowup(orderId, txid) }.onFailure { Logger.warn("Failed to resume accepted funding", it, context = TAG) } diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index a6df0cccf1..5fcf468542 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -372,7 +372,10 @@ fun SendSheet( }, authorizeContactPayment = { hasAttemptedBroadcast -> appViewModel.authorizeHardwareContactPayment( - hasAttemptedBroadcast, uiState.incomingPaymentRequestId, paymentIdentity, + hasAttemptedBroadcast, + uiState.incomingPaymentRequestId, + paymentIdentity, + walletId, ) }, onBack = { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index a83cd879cf..ec2e7f5920 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -5491,13 +5491,20 @@ class AppViewModel @Inject constructor( hasAttemptedBroadcast: Boolean, requestId: PaykitPaymentRequestId? = activeIncomingPaymentRequest()?.id, identity: String? = hardwarePaymentIdentity(), + walletId: String? = _sendUiState.value.hardwareWalletId, ): Boolean { val contactPaymentContext = synchronized(contactPaymentContextLock) { activeContactPaymentContext } if (contactPaymentContext?.incomingPaymentRequest?.id != requestId) return false val request = contactPaymentContext?.incomingPaymentRequest ?: return true - if (identity == null || !PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value)) return false + if (identity == null || walletId == null) return false + if (walletId != _sendUiState.value.hardwareWalletId || + !PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value) + ) { + return false + } val error = paykitPaymentRequestRepo.ensurePaymentAllowed(request).exceptionOrNull() - if (!PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value) || + if (walletId != _sendUiState.value.hardwareWalletId || + !PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value) || synchronized(contactPaymentContextLock) { activeContactPaymentContext } != contactPaymentContext ) return false if (error == null) return true @@ -5506,7 +5513,16 @@ class AppViewModel @Inject constructor( return false } - paykitPaymentProofRepo.failOnchainPayment(request) + if (!paykitPaymentProofRepo.failHardwareOnchainPaymentBeforeDispatch( + request, + walletId, + identity, + hasAttemptedBroadcast, + ) + ) { + toast(error) + return false + } releasePrivatePaymentListIfNeeded(contactPaymentContext) synchronized(contactPaymentContextLock) { if (preparedContactPaymentContext == contactPaymentContext) preparedContactPaymentContext = null diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index e4efeab815..b58562de8e 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -96,6 +96,7 @@ class BackupRepoTest : BaseUnitTest() { private val paykitPresentationStore = mock() private val keychain = mock() private val onchainSendAttemptStore = mock() + private val transferRepo = mock() private val vssStoreIdProvider = mock() private val preActivityMetadataRepo = mock() private val lightningService = mock() @@ -151,6 +152,52 @@ class BackupRepoTest : BaseUnitTest() { sut = createSut() } + @Test + fun `restored accepted funding resumes after all original context is installed without a node transition`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val originalWire = requireNotNull(state.activeOnchainAttempt) + val original = originalWire.restored("regtest", originalWire.wallet.binding, WalletScope.default, 0) + val funding = original.copy( + requestId = null, + payerIdentity = null, + orderId = "original-order", + isTransfer = true, + evidence = OnchainSendEvidence.Accepted, + transferContext = OnchainTransferContext(2_000uL, 3_000uL, 900uL, 1_000uL), + backupFollowup = requireNotNull(original.backupFollowup).copy(contact = null), + ) + val wire = to.bitkit.models.ActiveOnchainAttemptBackup.from(funding, "regtest", originalWire.wallet.binding) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(originalWire.wallet.binding) + whenever(transferRepo.resumeAcceptedFunding(any())).thenReturn(Result.success(Unit)) + stubWalletBackup(paykitPaymentState = state.copy(pendingProofs = emptyList(), activeOnchainAttempt = wire)) + sut.performFullRestoreFromLatestBackup().getOrThrow() + val ordered = org.mockito.kotlin.inOrder(onchainSendAttemptStore, transferDao, privatePaykitRepo, transferRepo) + ordered.verify(onchainSendAttemptStore).restoreActive( + wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0) + ) + ordered.verify(transferDao).upsert(any>()) + ordered.verify(privatePaykitRepo).restoreBackup(anyOrNull()) + ordered.verify(transferRepo).resumeAcceptedFunding( + wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0) + ) + // Local follow-up failure preserves the restored guard and does not turn restore into a payment failure. + whenever(transferRepo.resumeAcceptedFunding(any())).thenReturn(Result.failure(AppError("storage unavailable"))) + sut.performFullRestoreFromLatestBackup().getOrThrow() + verify(onchainSendAttemptStore, times(2)).restoreActive( + wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0) + ) + verify(transferRepo, times(2)).resumeAcceptedFunding(any()) + stubWalletBackup( + paykitPaymentState = state.copy( + pendingProofs = emptyList(), + activeOnchainAttempt = wire.copy(status = "unknown") + ) + ) + sut.performFullRestoreFromLatestBackup().getOrThrow() + verify(transferRepo, times(2)).resumeAcceptedFunding(any()) + } + @Test fun `wallet backup exports and restores shared active guard before original proof`() = test { val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() @@ -1207,6 +1254,7 @@ class BackupRepoTest : BaseUnitTest() { paykitPresentationStore = paykitPresentationStore, keychain = keychain, onchainSendAttemptStore = onchainSendAttemptStore, + transferRepo = Provider { transferRepo }, vssStoreIdProvider = vssStoreIdProvider, preActivityMetadataRepo = preActivityMetadataRepo, lightningService = lightningService, diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index ebc4ad3af1..f66fb4ec99 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -95,6 +95,7 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @Suppress("LargeClass") +@OptIn(kotlin.time.ExperimentalTime::class) class LightningRepoTest : BaseUnitTest() { companion object { private const val NO_USABLE_CHANNELS_FEEDBACK_DELAY_MS = 2_500L @@ -1711,7 +1712,7 @@ class LightningRepoTest : BaseUnitTest() { if (writes == 3) error("transient accepted write failure") saved = it.getArgument(1) } - val store = OnchainSendAttemptStore(testDispatcher, keychain, lightningService) + val store = OnchainSendAttemptStore(testDispatcher, keychain, lightningService, kotlin.time.Clock.System) sut = LightningRepo( bgDispatcher = testDispatcher, lightningService = lightningService, settingsStore = settingsStore, coreService = coreService, lspNotificationsService = lspNotificationsService, @@ -1736,7 +1737,12 @@ class LightningRepoTest : BaseUnitTest() { assertEquals(txid, blocked.attempt?.txid) repo.completeAcceptedOrdinaryFollowup(txid) - val reopened = OnchainSendAttemptStore(testDispatcher, keychain, lightningService).current() + val reopened = OnchainSendAttemptStore( + testDispatcher, + keychain, + lightningService, + kotlin.time.Clock.System + ).current() assertEquals(txid, reopened?.txid) assertEquals(OnchainSendEvidence.Accepted, reopened?.evidence) assertTrue(reopened?.localFollowupComplete == true) diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index 87ae24ce1d..57d875b4b9 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -19,9 +19,19 @@ import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertTrue +@OptIn(kotlin.time.ExperimentalTime::class) class OnchainSendAttemptStoreTest : BaseUnitTest() { private val key = Keychain.Key.ONCHAIN_SEND_ATTEMPT.name + @Test + fun `admission followup timestamp uses the injected clock`() = test { + val keychain = mock() + val clock = mock() + whenever(clock.now()).thenReturn(kotlin.time.Instant.fromEpochMilliseconds(123_456L)) + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), clock) + assertEquals("123456", store.admitForTest().backupFollowup?.createdAtMillis) + } + @Test fun `restored exact candidate observes without resend and unsupported contact cannot acknowledge`() = test { val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() @@ -39,7 +49,7 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { whenever(keychain.upsertString(eq(key), any(), any())).doSuspendableAnswer { values[it.getArgument(2)] = it.getArgument(1) } - val store = OnchainSendAttemptStore(testDispatcher, keychain, service) + val store = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) store.restoreActive(original) assertFailsWith { store.admitForTest() } assertNull(store.observeExactTransaction("00".repeat(32))) @@ -47,7 +57,7 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { assertEquals(OnchainSendEvidence.Observed, observed?.evidence) assertEquals(wire.candidateTxids, observed?.candidateTxids) assertFailsWith { store.markLocalFollowupComplete(original.attemptId, 2) } - val reopened = OnchainSendAttemptStore(testDispatcher, keychain, service) + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) assertEquals(observed, reopened.current()) assertFailsWith { reopened.admitForTest() } val other = original.copy(attemptId = "00000000-0000-4000-8000-000000000009") @@ -67,7 +77,7 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { val keychain = mock() whenever(keychain.loadString(key, 0)).thenAnswer { saved } whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } - val store = OnchainSendAttemptStore(testDispatcher, keychain, mock()) + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) store.restoreActive(accepted) assertTrue(store.current()?.localFollowupComplete == false) assertFailsWith { store.admitForTest() } @@ -82,7 +92,7 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { val keychain = mock() whenever(keychain.loadString(key, 0)).thenAnswer { saved } whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } - val store = OnchainSendAttemptStore(testDispatcher, keychain, mock()) + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) val admitted = store.admitForTest() val local = admitted.copy(backupFollowup = null) saved = kotlinx.serialization.json.Json.encodeToString(OnchainSendAttempt.serializer(), local) @@ -103,11 +113,11 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } - val firstStore = OnchainSendAttemptStore(testDispatcher, keychain, mock()) + val firstStore = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) val attempt = firstStore.admitForTest() firstStore.recordOutcome(attempt.attemptId, OnchainSendOutcome.Unknown("ab".repeat(32)), attempt.walletIndex) - val reopened = OnchainSendAttemptStore(testDispatcher, keychain, mock()) + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) assertFailsWith { reopened.admitForTest() } assertNull(reopened.observeExactTransaction("cd".repeat(32))) assertFailsWith { reopened.admitForTest() } @@ -126,14 +136,14 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { whenever(keychain.loadString(key, 0)).thenAnswer { saved } whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } val context = OnchainTransferContext(txTotalSats = 99_000uL, preTransferOnchainSats = 125_000uL) - val store = OnchainSendAttemptStore(testDispatcher, keychain, mock()) + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) val attempt = store.admit( walletId = "wallet-1", requestId = null, orderId = "order-1", address = "bcrt1qfunding", amountSats = 98_000uL, isMaxAmount = false, feeRateSatsPerVByte = 1uL, isTransfer = true, channelId = null, tags = emptyList(), transferContext = context, beforeSendAttempt = {}, ) - val reopened = OnchainSendAttemptStore(testDispatcher, keychain, mock()) + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) assertEquals(context, reopened.current()?.transferContext) assertEquals(OnchainSendEvidence.Pending, reopened.current()?.evidence) assertFailsWith { reopened.admitForTest() } @@ -145,7 +155,7 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { fun `corrupt attempt refuses a new send without replacing evidence`() = test { val keychain = mock() whenever(keychain.loadString(key, 0)).thenReturn("not-json") - val store = OnchainSendAttemptStore(testDispatcher, keychain, mock()) + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) assertFailsWith { store.admitForTest() } verify(keychain).loadString(key, 0) @@ -162,7 +172,7 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { whenever(keychain.upsertString(eq(key), any(), any())).doSuspendableAnswer { values[it.getArgument(2)] = it.getArgument(1) } - val store = OnchainSendAttemptStore(testDispatcher, keychain, service) + val store = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) val attempt = store.admitForTest() assertEquals(7, attempt.walletIndex) currentIndex = 8 @@ -183,7 +193,7 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { if (failWrite) error("storage unavailable") saved = it.getArgument(1) } - val store = OnchainSendAttemptStore(testDispatcher, keychain, mock()) + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) val attempt = store.admitForTest() val txid = "ab".repeat(32) failWrite = true @@ -194,7 +204,7 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { assertEquals(OnchainSendEvidence.Accepted, store.current()?.evidence) assertFailsWith { store.markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) } assertFailsWith { store.admitForTest() } - val reopened = OnchainSendAttemptStore(testDispatcher, keychain, mock()) + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) assertEquals(OnchainSendEvidence.Pending, reopened.current()?.evidence) assertNull(reopened.current()?.txid) assertFailsWith { reopened.admitForTest() } @@ -207,7 +217,7 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { val keychain = mock() whenever(keychain.loadString(key, 0)).thenAnswer { saved } whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } - val store = OnchainSendAttemptStore(testDispatcher, keychain, mock()) + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) val entered = CompletableDeferred() val finish = CompletableDeferred() val first = async { store.admitForTest { callbacks++; entered.complete(Unit); finish.await() } } @@ -229,7 +239,7 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { if (failWrite) error("storage unavailable") saved = it.getArgument(1) } - val store = OnchainSendAttemptStore(testDispatcher, keychain, mock()) + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) val admitted = CompletableDeferred() val send = launch { admitted.complete(store.admitForTest()); kotlinx.coroutines.awaitCancellation() } val attempt = admitted.await() @@ -238,7 +248,7 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { assertFailsWith { store.recordOutcome(attempt.attemptId, OnchainSendOutcome.Accepted("ab".repeat(32)), attempt.walletIndex) } - val reopened = OnchainSendAttemptStore(testDispatcher, keychain, mock()) + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) assertEquals(OnchainSendEvidence.Pending, reopened.current()?.evidence) assertFailsWith { reopened.admitForTest() } } @@ -249,7 +259,7 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { val keychain = mock() whenever(keychain.loadString(key, 0)).thenAnswer { saved } whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } - val store = OnchainSendAttemptStore(testDispatcher, keychain, mock()) + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) val attempt = store.admitForTest() val txid = "ab".repeat(32) store.recordOutcome(attempt.attemptId, OnchainSendOutcome.Unknown(txid), attempt.walletIndex) diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt index 19eb76b615..1f2af50485 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt @@ -16,6 +16,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertTrue +@OptIn(kotlin.time.ExperimentalTime::class) class OnchainSendCoordinatorTest : BaseUnitTest() { private val input = OnchainSendInput("11".repeat(32), 0u) private val firstTxid = "ab".repeat(32) @@ -37,7 +38,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { if (failWrite) error("storage unavailable") saved = it.getArgument(1) } - store = OnchainSendAttemptStore(testDispatcher, keychain, service) + store = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) } suspend fun admit(isMax: Boolean = false): OnchainSendAttempt = store.admit( @@ -86,7 +87,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { error("not requested") } assertTrue(OnchainSendCoordinator(f.store, sender, testDispatcher).sendInitial(attempt).isSuccess) - val reopened = OnchainSendAttemptStore(testDispatcher, f.keychain, f.service) + val reopened = OnchainSendAttemptStore(testDispatcher, f.keychain, f.service, kotlin.time.Clock.System) assertEquals(listOf(firstTxid), reopened.current()?.candidateTxids) assertEquals(listOf(input), reopened.current()?.originalInputs) assertEquals(1, sends) @@ -358,7 +359,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { assertEquals(original.orderId, retained?.orderId) assertEquals(original.originalInputs, retained?.originalInputs) assertEquals(OnchainSendEvidence.Pending, retained?.evidence) - val restarted = OnchainSendAttemptStore(testDispatcher, f.keychain, f.service) + val restarted = OnchainSendAttemptStore(testDispatcher, f.keychain, f.service, kotlin.time.Clock.System) assertEquals(retained, restarted.current()) assertFailsWith { f.admit() } } @@ -420,7 +421,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { assertEquals(OnchainSendEvidence.Observed, retained.evidence) f.failWrite = false f.store.markLocalFollowupComplete(attempt.attemptId, 0) - val reopened = OnchainSendAttemptStore(testDispatcher, f.keychain, f.service) + val reopened = OnchainSendAttemptStore(testDispatcher, f.keychain, f.service, kotlin.time.Clock.System) assertEquals(firstTxid, reopened.current()?.txid) assertTrue(reopened.current()?.localFollowupComplete == true) } @@ -451,7 +452,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { assertEquals(OnchainSendEvidence.Observed, f.store.current()?.evidence) f.failWrite = false f.store.markLocalFollowupComplete(original.attemptId, 0) - val restarted = OnchainSendAttemptStore(testDispatcher, f.keychain, f.service) + val restarted = OnchainSendAttemptStore(testDispatcher, f.keychain, f.service, kotlin.time.Clock.System) assertEquals(firstTxid, restarted.current()?.txid) assertTrue(restarted.current()?.localFollowupComplete == true) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index a866ad44c5..d5416be529 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -1115,6 +1115,57 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(listOf(request.id), storedProofs.map { it.requestId }) } + @Test + fun `definite hardware prebroadcast denial releases only the exact original proof`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val walletId = "original-hardware-wallet" + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + val original = storedProofs.single() + storedProofs = storedProofs + original.copy(onchainWalletId = "foreign-wallet") + assertTrue(repo.failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, false)) + assertEquals(listOf(original.copy(onchainWalletId = "foreign-wallet")), storedProofs) + } + + @Test + fun `hardware cleanup cannot release attempted candidate foreign context or failed durable removal`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val walletId = "original-hardware-wallet" + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + val original = storedProofs.single() + assertFalse(repo.failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, true)) + assertFalse(repo.failHardwareOnchainPaymentBeforeDispatch(request, "foreign-wallet", LOCAL_IDENTITY, false)) + assertFalse(repo.failHardwareOnchainPaymentBeforeDispatch(request, WalletScope.default, LOCAL_IDENTITY, false)) + assertFalse(repo.failHardwareOnchainPaymentBeforeDispatch(request, walletId, COUNTERPARTY, false)) + assertFalse( + repo.failHardwareOnchainPaymentBeforeDispatch( + request.copy(paymentRequestId = "different-request"), + walletId, + LOCAL_IDENTITY, + false, + ) + ) + assertEquals(listOf(original), storedProofs) + shouldFailNextSave = true + assertFalse(repo.failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, false)) + assertEquals(listOf(original), storedProofs) + assertEquals( + PaykitPaymentRequestError.OperationInProgress, + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).exceptionOrNull() + ) + for (retained in listOf( + original.copy(paymentIdentifier = "ab".repeat(32)), + original.copy(onchainAcceptanceVerified = true), + )) { + storedProofs = listOf(retained) + assertFalse(repo.failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, false)) + assertEquals(listOf(retained), storedProofs) + } + } + @Test fun `hardware core txid remains pending until fresh exact observation then resumes original proof`() = test { val request = paymentRequest(MethodId.P2wpkh.rawValue) diff --git a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt index 81de568fc9..0ed3b1d452 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -98,14 +98,61 @@ class TransferRepoTest : BaseUnitTest() { ) } + @Test + fun `accepted funding rejects changed original client balance and fee without acknowledgement or resend`() = test { + val original = previewBtOrder() + val txid = "ab".repeat(32) + val attempt = OnchainSendAttempt( + WalletScope.default, "attempt", null, original.id, + requireNotNull(original.payment?.onchain?.address), original.feeSat, false, 1uL, true, null, emptyList(), + OnchainSendEvidence.Accepted, txid, + transferContext = OnchainTransferContext(99_000uL, 125_000uL, original.clientBalanceSat, original.feeSat), + ) + setupClockNowMock() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + for (changed in listOf( + original.copy(clientBalanceSat = original.clientBalanceSat + 1uL), + original.copy(feeSat = original.feeSat + 1uL), + )) { + whenever(blocktankRepo.fetchOrders(listOf(original.id))).thenReturn(Result.success(listOf(changed))) + assertTrue(sut.resumeAcceptedFunding().isFailure) + } + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn( + attempt.copy(transferContext = OnchainTransferContext(99_000uL, 125_000uL)) + ) + whenever(blocktankRepo.fetchOrders(listOf(original.id))).thenReturn(Result.success(listOf(original))) + assertTrue(sut.resumeAcceptedFunding().isFailure) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + assertTrue(sut.resumeAcceptedFunding(attempt.copy(walletIndex = 1)).isFailure) + verify(transferDao, never()).insert(any()) + verify(cacheStore, never()).addPaidOrder(any(), any()) + verify(lightningRepo, never()).completeAcceptedTransferFollowup(any(), any()) + verify(lightningRepo, never()).sendOnChain( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), + anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull() + ) + assertEquals(attempt, lightningRepo.currentOnchainSendAttempt()) + } + @Test fun `startup resumes accepted original funding without a confirmation or native resend`() = test { val order = previewBtOrder() val txid = "ab".repeat(32) val attempt = OnchainSendAttempt( - WalletScope.default, "attempt", null, order.id, - requireNotNull(order.payment?.onchain?.address), order.feeSat, false, 1uL, true, null, emptyList(), - OnchainSendEvidence.Accepted, txid, transferContext = OnchainTransferContext(99_000uL, 125_000uL) + WalletScope.default, + "attempt", + null, + order.id, + requireNotNull(order.payment?.onchain?.address), + order.feeSat, + false, + 1uL, + true, + null, + emptyList(), + OnchainSendEvidence.Accepted, + txid, + transferContext = OnchainTransferContext(99_000uL, 125_000uL, order.clientBalanceSat, order.feeSat), ) setupClockNowMock() whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) @@ -134,7 +181,7 @@ class TransferRepoTest : BaseUnitTest() { verify(lightningRepo).completeAcceptedTransferFollowup(order.id, txid) verify(lightningRepo, never()).sendOnChain( any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), - anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull() + anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull() ) assertNotNull(restarted) } @@ -144,9 +191,20 @@ class TransferRepoTest : BaseUnitTest() { val order = previewBtOrder() val txid = "ab".repeat(32) val attempt = OnchainSendAttempt( - WalletScope.default, "attempt", null, order.id, - requireNotNull(order.payment?.onchain?.address), order.feeSat, false, 1uL, true, null, emptyList(), - OnchainSendEvidence.Observed, txid, transferContext = OnchainTransferContext(99_000uL, 125_000uL) + WalletScope.default, + "attempt", + null, + order.id, + requireNotNull(order.payment?.onchain?.address), + order.feeSat, + false, + 1uL, + true, + null, + emptyList(), + OnchainSendEvidence.Observed, + txid, + transferContext = OnchainTransferContext(99_000uL, 125_000uL, order.clientBalanceSat, order.feeSat), ) setupClockNowMock() whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) @@ -177,7 +235,7 @@ class TransferRepoTest : BaseUnitTest() { assertEquals(125_000L, persisted?.preTransferOnchainSats) verify(lightningRepo, never()).sendOnChain( any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), - anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull() + anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull() ) } @@ -185,9 +243,20 @@ class TransferRepoTest : BaseUnitTest() { fun `unknown funding cannot resume from local records or another transaction event`() = test { val order = previewBtOrder() val attempt = OnchainSendAttempt( - WalletScope.default, "attempt", null, order.id, - requireNotNull(order.payment?.onchain?.address), order.feeSat, false, 1uL, true, null, emptyList(), - OnchainSendEvidence.Unknown, "ab".repeat(32), transferContext = OnchainTransferContext(99_000uL, 125_000uL) + WalletScope.default, + "attempt", + null, + order.id, + requireNotNull(order.payment?.onchain?.address), + order.feeSat, + false, + 1uL, + true, + null, + emptyList(), + OnchainSendEvidence.Unknown, + "ab".repeat(32), + transferContext = OnchainTransferContext(99_000uL, 125_000uL, order.clientBalanceSat, order.feeSat), ) whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) nodeEvents.emit(Event.OnchainTransactionReceived("cd".repeat(32), mock())) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index faab6c3162..dc6d4f8823 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -7083,12 +7083,54 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.ensurePaymentAllowed(request)) .thenReturn(Result.failure(PaykitPaymentRequestError.RequestUnavailable)) + whenever( + paykitPaymentProofRepo.failHardwareOnchainPaymentBeforeDispatch( + request, + "hardware-wallet", + testPublicKey, + false, + ) + ).thenReturn(true) assertFalse(sut.authorizeHardwareContactPayment(hasAttemptedBroadcast = false)) - verify(paykitPaymentProofRepo).failOnchainPayment(request) + verify(paykitPaymentProofRepo).failHardwareOnchainPaymentBeforeDispatch( + request, + "hardware-wallet", + testPublicKey, + false, + ) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(request) verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) } + @Test + fun `hardware prebroadcast proof removal failure retains private preparation`() = test { + pubkyPublicKey.value = testPublicKey + val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState(SendUiState(hardwareWalletId = "hardware-wallet")) + whenever(paykitPaymentRequestRepo.ensurePaymentAllowed(request)) + .thenReturn(Result.failure(PaykitPaymentRequestError.RequestUnavailable)) + whenever( + paykitPaymentProofRepo.failHardwareOnchainPaymentBeforeDispatch( + request, + "hardware-wallet", + testPublicKey, + false, + ) + ).thenReturn(false) + assertFalse(sut.authorizeHardwareContactPayment(false)) + verify(paykitPaymentProofRepo).failHardwareOnchainPaymentBeforeDispatch( + request, + "hardware-wallet", + testPublicKey, + false, + ) + verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) + } + @Test fun `hardware retry denial and cancellation preserve the started proof`() = test { pubkyPublicKey.value = testPublicKey @@ -7130,6 +7172,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(sheet, sut.currentSheet.value) verify(toastManager).enqueue(any()) verify(paykitPaymentProofRepo, never()).failOnchainPayment(request) + verify(paykitPaymentProofRepo, never()).failHardwareOnchainPaymentBeforeDispatch(any(), any(), any(), any()) sut.onHardwareSignCancelled() advanceUntilIdle() @@ -7144,6 +7187,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyPublicKey.value = testPublicKey val request = paymentRequest() setActiveContactPaymentContext(testPublicKey, PrivatePaykitPaymentContext("bitkit/server", 7uL), request) + setSendState(SendUiState(hardwareWalletId = "hardware-wallet")) val authorization = CompletableDeferred>() whenever(paykitPaymentRequestRepo.ensurePaymentAllowed(request)).doSuspendableAnswer { authorization.await() } val completed = CompletableDeferred() @@ -7158,6 +7202,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentRequestRepo).ensurePaymentAllowed(request) verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) verify(paykitPaymentProofRepo, never()).cancelPreparation(any()) + verify(paykitPaymentProofRepo, never()).failHardwareOnchainPaymentBeforeDispatch(any(), any(), any(), any()) } @Test From 6c1a9cba825239fd1d7c376607fbb86b59bccf95 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 6 Oct 2026 20:16:26 +0200 Subject: [PATCH 015/108] test: verify pending payment observation --- .../send/SendPendingObservationDeviceTest.kt | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingObservationDeviceTest.kt diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingObservationDeviceTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingObservationDeviceTest.kt new file mode 100644 index 0000000000..99c478d7f8 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingObservationDeviceTest.kt @@ -0,0 +1,172 @@ +package to.bitkit.ui.screens.wallets.send + +import android.content.Context +import androidx.compose.material3.Text +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.lifecycle.SavedStateHandle +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import to.bitkit.data.SettingsStore +import to.bitkit.data.WidgetsStore +import to.bitkit.data.backup.VssStoreIdProvider +import to.bitkit.env.Env +import to.bitkit.models.ActiveOnchainAttemptBackup +import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.ContactPaymentSettingsRepo +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.OnchainSendAttemptStore +import to.bitkit.repositories.OnchainSendEvidence +import to.bitkit.repositories.PendingPaymentRepo +import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyRepo +import to.bitkit.repositories.PublicPaykitRepo +import to.bitkit.repositories.WidgetsRepo +import to.bitkit.ui.LocalSettingsViewModel +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.viewmodels.SettingsViewModel +import java.io.File +import javax.inject.Inject +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** Owned-device integration: exact observation is injected after an independent read-only backend check. */ +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class SendPendingObservationDeviceTest { + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeTestRule = createComposeRule() + + @Inject lateinit var store: OnchainSendAttemptStore + + @Inject lateinit var lightningRepo: LightningRepo + + @Inject lateinit var activityRepo: ActivityRepo + + @Inject lateinit var settingsStore: SettingsStore + + @Inject lateinit var pubkyRepo: PubkyRepo + + @Inject lateinit var contactPaymentSettingsRepo: ContactPaymentSettingsRepo + + @Inject lateinit var publicPaykitRepo: PublicPaykitRepo + + @Inject lateinit var privatePaykitRepo: PrivatePaykitRepo + + @Inject lateinit var widgetsStore: WidgetsStore + + @Inject lateinit var widgetsRepo: WidgetsRepo + + @Inject lateinit var vssStoreIdProvider: VssStoreIdProvider + + @Test + fun restoredOriginalObservationNavigatesWithoutRetryOrNativeSend() = runBlocking { + val context = ApplicationProvider.getApplicationContext() + Env.initAppStoragePath(context.filesDir.absolutePath) + hiltRule.inject() + val original = requireNotNull(store.current()) + val expected = requireNotNull(InstrumentationRegistry.getArguments().getString("expectedBackendTxid")) + assertEquals(expected, original.txid) + assertTrue(original.hasPositiveEvidence && original.localFollowupComplete) + assertTrue(original.requestId == null && !original.isTransfer) + assertTrue(!lightningRepo.lightningState.value.nodeLifecycleState.isRunning()) + val binding = vssStoreIdProvider.getBackupWalletBinding(original.walletIndex) + val wire = ActiveOnchainAttemptBackup.from(original, Env.network.name.lowercase(), binding) + val restored = wire.restored(Env.network.name.lowercase(), binding, original.walletId, original.walletIndex) + assertTrue( + runCatching { + wire.restored(Env.network.name.lowercase(), "22".repeat(32), original.walletId, original.walletIndex) + }.isFailure, + ) + try { + store.restoreActive(restored.copy(evidence = OnchainSendEvidence.Unknown)) + assertEquals(original.originalInputs, store.current()?.originalInputs) + assertEquals(original.candidateTxids, store.current()?.candidateTxids) + val vm = SendPendingViewModel(PendingPaymentRepo(), activityRepo, lightningRepo) + val settings = SettingsViewModel( + context, + settingsStore, + pubkyRepo, + contactPaymentSettingsRepo, + publicPaykitRepo, + privatePaykitRepo, + widgetsStore, + widgetsRepo, + ) + var recovered by mutableStateOf?>(null) + composeTestRule.setContent { + AppThemeSurface { + CompositionLocalProvider(LocalSettingsViewModel provides settings) { + if (recovered == null) { + SendPendingScreen( + paymentHash = expected, + amount = 1L, // Production VM must restore the original receipt's amount. + observeResolution = false, + isOnchain = true, + onPaymentSuccess = { _, _ -> error("Lightning route must not complete") }, + onPaymentError = { error("No payment failed") }, + onClose = {}, + onViewDetails = {}, + viewModel = vm, + retryOriginal = { _, _ -> error("Observation must not invoke retry") }, + onRecovered = { txid, amount -> recovered = txid to amount }, + savedStateHandle = SavedStateHandle(), + onNavigateToPin = { error("Observation must not request spend authentication") }, + ) + } else { + Text("Original payment recovered") + } + } + } + } + composeTestRule.waitUntil(10_000) { vm.uiState.value.recoveryAttempt != null } + composeTestRule.onNodeWithText("Payment Pending").assertIsDisplayed() + screenshot(context, "current-pending-observation-integration.png") + // The runner independently checked this exact txid, inputs and amount. This injects the local seam; + // it does not claim a native event, broadcast fault, new acceptance or full application navigation. + val observed = assertNotNull(store.observeExactTransaction(expected)) + store.markLocalFollowupComplete(observed.attemptId, observed.walletIndex) + composeTestRule.waitUntil(10_000) { recovered != null } + assertEquals(expected to original.amountSats.toLong(), recovered) + composeTestRule.onNodeWithText("Original payment recovered").assertIsDisplayed() + screenshot(context, "current-recovered-observation-integration.png") + File(context.getExternalFilesDir(null), "current-observation-integration.json") + .writeText(Json.encodeToString(requireNotNull(store.current()))) + } finally { + // Preserve the owned fixture's original known-positive operation and completion flag. + store.observeExactTransaction(expected) + store.markLocalFollowupComplete(original.attemptId, original.walletIndex) + store.restoreActive(original) + store.markLocalFollowupComplete(original.attemptId, original.walletIndex) + } + assertEquals(original, store.current()) + } + + private fun screenshot(context: Context, name: String) { + val destination = File(context.getExternalFilesDir(null), name) + Thread.sleep(500) + val bitmap = requireNotNull(InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot()) + destination.outputStream().use { output -> + check(bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, output)) + } + bitmap.recycle() + } +} From 66d32dd16bd14f4e68835028e06ce506166852c6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 6 Oct 2026 20:46:18 +0200 Subject: [PATCH 016/108] fix: recover original payments without misleading success --- .../models/ActiveOnchainAttemptBackup.kt | 10 ++- .../to/bitkit/repositories/LightningRepo.kt | 58 ++++++++------ .../repositories/OnchainSendAttemptStore.kt | 12 +++ .../repositories/OnchainSendCoordinator.kt | 4 +- .../repositories/PaykitPaymentProofRepo.kt | 63 ++++++++++++--- app/src/main/java/to/bitkit/ui/ContentView.kt | 6 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 23 ++++++ .../to/bitkit/viewmodels/TransferViewModel.kt | 20 ++--- app/src/main/res/values/strings.xml | 1 + .../models/ActiveOnchainAttemptBackupTest.kt | 53 ++++++++++++- .../bitkit/repositories/LightningRepoTest.kt | 25 +++++- .../OnchainSendAttemptStoreTest.kt | 26 ++++++ .../OnchainSendCoordinatorTest.kt | 24 ++++++ .../PaykitPaymentProofRepoTest.kt | 28 +++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 34 +++++++- .../viewmodels/TransferViewModelTest.kt | 6 ++ .../resources/candidate-fee-rates-golden.json | 79 +++++++++++++++++++ 17 files changed, 420 insertions(+), 52 deletions(-) create mode 100644 app/src/test/resources/candidate-fee-rates-golden.json diff --git a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt index 82cde5090c..e3dc825ee2 100644 --- a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt +++ b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt @@ -28,6 +28,7 @@ data class ActiveOnchainAttemptBackup( val originalInputs: List? = null, val candidateTxids: List = emptyList(), val feeRateSatsPerVByte: String, + val candidateFeeRates: Map? = null, val followup: Followup? = null, val transfer: Transfer? = null, ) { @@ -69,6 +70,10 @@ data class ActiveOnchainAttemptBackup( ) require(unsigned(feeRateSatsPerVByte) <= UInt.MAX_VALUE.toULong()) require(candidateTxids.distinct().size == candidateTxids.size && candidateTxids.all { it.matches(HEX) }) + val restoredFeeRates = candidateFeeRates.orEmpty().mapValues { (txid, rate) -> + require(txid.matches(HEX) && txid in candidateTxids) + unsigned(rate).also { require(it > 0uL && it <= UInt.MAX_VALUE.toULong()) } + } val inputs = originalInputs?.map { input -> require(input.txid.matches(HEX) && unsigned(input.vout) <= UInt.MAX_VALUE.toULong()) OnchainSendInput(input.txid, unsigned(input.vout).toUInt()) @@ -108,7 +113,8 @@ data class ActiveOnchainAttemptBackup( feeRateSatsPerVByte = unsigned(feeRateSatsPerVByte), isTransfer = orderId != null, channelId = followup?.channelId, tags = followup?.tags.orEmpty(), evidence = evidence, txid = txid, refusalReason = rejectionReason, localFollowupComplete = false, - originalInputs = inputs, candidateTxids = candidateTxids, transferContext = transferContext, + originalInputs = inputs, candidateTxids = candidateTxids, candidateFeeRates = restoredFeeRates, + transferContext = transferContext, backupFollowup = followup, restoredFromBackup = true, ) } @@ -153,6 +159,8 @@ data class ActiveOnchainAttemptBackup( txid = attempt.txid, rejectionReason = attempt.refusalReason, originalInputs = attempt.originalInputs?.map { Input(it.txid, it.vout.toString()) }, candidateTxids = attempt.candidateTxids, feeRateSatsPerVByte = attempt.feeRateSatsPerVByte.toString(), + candidateFeeRates = attempt.candidateFeeRates.takeIf { it.isNotEmpty() } + ?.mapValues { it.value.toString() }, followup = attempt.backupFollowup, transfer = attempt.transferContext?.let { Transfer( diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 421de7e031..071a41f7b6 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1541,30 +1541,40 @@ class LightningRepo @Inject constructor( } return@executeWhenNodeRunning Result.failure(error) } - val preparedResult = runSuspendCatching { - lightningService.prepareOnchainSend( - address, - sats, - satsPerVByte, - utxosForSend, - isMaxAmount, - attempt.walletIndex - ) - .also { prepared -> - if (!isMaxAmount && utxosForSend != null) { - check( - prepared.receipt.inputs.toSet() == utxosForSend.map { - OnchainSendInput(it.outpoint.txid, it.outpoint.vout) - }.toSet() + val preparedResult = try { + runSuspendCatching { + lightningService.prepareOnchainSend( + address, + sats, + satsPerVByte, + utxosForSend, + isMaxAmount, + attempt.walletIndex + ) + .also { prepared -> + if (!isMaxAmount && utxosForSend != null) { + check( + prepared.receipt.inputs.toSet() == utxosForSend.map { + OnchainSendInput(it.outpoint.txid, it.outpoint.vout) + }.toSet() + ) + } + onchainSendAttemptStore.retainPreparedReceipt( + attempt.attemptId, + attempt.walletIndex, + prepared.receipt.copy(feeRateSatsPerVByte = satsPerVByte), + isRecovery = false, ) } - onchainSendAttemptStore.retainPreparedReceipt( - attempt.attemptId, - attempt.walletIndex, - prepared.receipt, - isRecovery = false, - ) - } + } + } catch (cancelled: CancellationException) { + // Release only the exact empty pre-dispatch guard. A retained receipt remains protected. + withContext(NonCancellable) { + runSuspendCatching { + onchainSendAttemptStore.releaseBeforeDispatch(attempt.attemptId, attempt.walletIndex) + }.onFailure { Logger.warn("Failed to release cancelled preparation", it, context = TAG) } + } + throw cancelled } val prepared = preparedResult.getOrElse { // Preparation and receipt persistence never dispatch. A failed save leaves the preguard if cleanup fails. @@ -1682,7 +1692,7 @@ class LightningRepo @Inject constructor( txId = txId, address = attempt.address, isReceive = false, - feeRate = attempt.feeRateSatsPerVByte, + feeRate = attempt.winningFeeRateSatsPerVByte, isTransfer = attempt.isTransfer, channelId = attempt.channelId ?: "", ) @@ -1692,7 +1702,7 @@ class LightningRepo @Inject constructor( address = attempt.address, amount = attempt.amountSats, fee = originalFollowup?.feeSats?.toULong() ?: 0uL, - feeRate = attempt.feeRateSatsPerVByte, + feeRate = attempt.winningFeeRateSatsPerVByte, isTransfer = attempt.isTransfer, channelId = attempt.channelId, walletId = attempt.walletId, diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index d6caa7c3cd..1758d6e8d8 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -57,6 +57,7 @@ data class OnchainPreparedReceipt( val inputs: List, val address: String, val amountSats: ULong, + val feeRateSatsPerVByte: ULong? = null, ) @Serializable @@ -82,9 +83,15 @@ data class OnchainSendAttempt( val payerIdentity: String? = null, val originalInputs: List? = null, val candidateTxids: List = emptyList(), + val candidateFeeRates: Map = emptyMap(), val backupFollowup: ActiveOnchainAttemptBackup.Followup? = null, val restoredFromBackup: Boolean = false, ) { + val winningFeeRateSatsPerVByte: ULong + get() = candidateFeeRates[txid?.lowercase()] ?: feeRateSatsPerVByte.takeIf { + candidateTxids.isEmpty() || txid.equals(candidateTxids.first(), ignoreCase = true) + } ?: error("Winning candidate fee rate is unavailable") + val isUnresolved: Boolean get() = evidence == OnchainSendEvidence.Pending || evidence == OnchainSendEvidence.Rejected || @@ -214,10 +221,15 @@ class OnchainSendAttemptStore @Inject constructor( require(current.originalInputs == null && current.candidateTxids.isEmpty() && current.txid == null) require((current.isMaxAmount && current.requestId == null) || receipt.amountSats == current.amountSats) } + val feeRate = receipt.feeRateSatsPerVByte ?: current.feeRateSatsPerVByte + require(OnchainRecoveryFeeRate.isValid(feeRate)) + val existingFeeRate = current.candidateFeeRates[receipt.txid.lowercase()] + require(existingFeeRate == null || existingFeeRate == feeRate) current.copy( amountSats = receipt.amountSats, originalInputs = current.originalInputs ?: receipt.inputs, candidateTxids = (current.candidateTxids + receipt.txid.lowercase()).distinct(), + candidateFeeRates = current.candidateFeeRates + (receipt.txid.lowercase() to feeRate), txid = receipt.txid.lowercase(), evidence = OnchainSendEvidence.Pending, refusalReason = null, diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt index 77d2949e8d..5731235daf 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt @@ -115,7 +115,7 @@ class OnchainSendCoordinator( store.retainPreparedReceipt( original.attemptId, original.walletIndex, - prepared.receipt, + prepared.receipt.copy(feeRateSatsPerVByte = original.feeRateSatsPerVByte), isRecovery = false, ) submit(original, prepared) @@ -148,7 +148,7 @@ class OnchainSendCoordinator( val retained = store.retainPreparedReceipt( attempt.attemptId, attempt.walletIndex, - prepared.receipt, + prepared.receipt.copy(feeRateSatsPerVByte = feeRateSatsPerVByte), isRecovery = true, ) authorizeOriginal(retained) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 16efe33518..9d97c93c77 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -275,8 +275,8 @@ class PaykitPaymentProofRepo @Inject constructor( /** Authorize a successor against the existing payer proof without preparing or consuming private context again. */ suspend fun authorizeOnchainRecovery(attempt: OnchainSendAttempt): Result = withContext(ioDispatcher) { runSuspendCatching { + val current = lightningRepo.currentOnchainSendAttempt() operationMutex.withLock { - val current = lightningRepo.currentOnchainSendAttempt() val identity = currentIdentity() ?: throw PaykitPaymentRequestError.RequestUnavailable if (current != attempt || !attempt.isUnresolved) { throw PaykitPaymentRequestError.OperationInProgress @@ -338,6 +338,25 @@ class PaykitPaymentProofRepo @Inject constructor( pendingProof(request, paymentEndpointIdentifier, PaykitPaymentProofKind.Onchain) }.getOrNull() } + // Admission acquires attempt-store then proof mutex; never acquire the store while holding this mutex. + val original = runSuspendCatching { + operationMutex.withLock { + loadProofs().lastOrNull { + PubkyPublicKeyFormat.matches(it.identity, identity) && it.requestId == request.id && + it.kind == PaykitPaymentProofKind.Onchain && it.paymentStarted && it.proofData == null && + ( + it.paymentIdentifier == null || + (hasCandidateFamily && it.matchesOriginalShopAttempt(requireNotNull(attempt))) + ) + } + } + }.getOrNull() + if (original == null && hasCandidateFamily) return@withContext false + val evidenceProof = original ?: fallbackProof ?: return@withContext false + if (!hasPositiveEvidence(evidenceProof)) return@withContext false + if (runSuspendCatching { lightningRepo.finishAcceptedShopActivity(request.id, txid) }.isFailure) { + return@withContext false + } val completed = operationMutex.withLock { val completion = runSuspendCatching { val proofs = loadProofs().toMutableList() @@ -353,6 +372,7 @@ class PaykitPaymentProofRepo @Inject constructor( it.proofData == null } if (hasCandidateFamily && index < 0) return@runSuspendCatching false + if (original != null && (index < 0 || proofs[index] != original)) return@runSuspendCatching false val originalProof = if (index >= 0) proofs[index] else fallbackProof if (originalProof == null || !hasPositiveEvidence(originalProof)) return@runSuspendCatching false val proof = if (index >= 0) { @@ -370,7 +390,6 @@ class PaykitPaymentProofRepo @Inject constructor( ) } if (!hasPositiveEvidence(proof)) return@runSuspendCatching false - lightningRepo.finishAcceptedShopActivity(request.id, txid) if (index >= 0) proofs[index] = proof else proofs += proof val retained = persistAndSubmit(listOf(proof), proofs) if (retained) publishOnchainResolution(proof, txid) @@ -391,10 +410,7 @@ class PaykitPaymentProofRepo @Inject constructor( proofData = txid.lowercase(), onchainAcceptanceVerified = true, ) - val delivered = runSuspendCatching { - lightningRepo.finishAcceptedShopActivity(request.id, txid) - submitReady(proof) - } + val delivered = runSuspendCatching { submitReady(proof) } .onFailure { Logger.warn("Failed to complete a Paykit on-chain payment proof", it, context = TAG) } .getOrDefault(false) if (delivered) publishOnchainResolution(proof, txid) @@ -546,6 +562,30 @@ class PaykitPaymentProofRepo @Inject constructor( } if (!store.hasPendingProofs()) return@withContext + // Snapshot immutable proofs, finish their exact accepted activity without the proof lock, + // then revalidate the same record under the lock before marking or delivering its proof. + val activityReady = mutableSetOf() + val snapshot = runSuspendCatching { + operationMutex.withLock { loadProofs().also { if (it.isEmpty()) persist(emptyList()) } } + }.getOrElse { return@withContext } + if (snapshot.isEmpty()) return@withContext + snapshot.filter { it.kind == PaykitPaymentProofKind.Onchain && it.onchainWalletId == WalletScope.default } + .forEach { proof -> + val txid = proof.proofData ?: attempt?.txid + val validCompletedProof = proof.paymentStarted && proof.proofData?.isHex(HASH_BYTE_COUNT) == true && + proof.paymentIdentifier.equals(proof.proofData, ignoreCase = true) + val positive = if (proof.proofData == null) { + attempt.matchesPositiveShopProof(proof) + } else { + validCompletedProof && (proof.onchainAcceptanceVerified || attempt.matchesPositiveShopProof(proof)) + } + if (positive && txid != null && runSuspendCatching { + lightningRepo.finishAcceptedShopActivity(proof.requestId, txid) + }.isSuccess + ) { + activityReady += proof + } + } var completedShopTxid: String? = null operationMutex.withLock { runSuspendCatching { @@ -566,7 +606,7 @@ class PaykitPaymentProofRepo @Inject constructor( } proofs.forEach { proof -> - runSuspendCatching { reconcileProof(proof, payments, attempt) } + runSuspendCatching { reconcileProof(proof, payments, attempt, proof in activityReady) } .onSuccess { if (it && proof.onchainWalletId == WalletScope.default) completedShopTxid = attempt?.txid } .onFailure { Logger.warn( @@ -590,7 +630,13 @@ class PaykitPaymentProofRepo @Inject constructor( proof: PendingPaykitPaymentProof, payments: List, attempt: OnchainSendAttempt?, + activityReady: Boolean, ): Boolean { + if (proof.kind == PaykitPaymentProofKind.Onchain && proof.onchainWalletId == WalletScope.default && + !activityReady + ) { + return false + } return when { proof.kind == PaykitPaymentProofKind.Onchain && proof.proofData != null -> { if (!proof.paymentStarted || !proof.proofData.isHex(HASH_BYTE_COUNT) || @@ -602,12 +648,10 @@ class PaykitPaymentProofRepo @Inject constructor( val proofs = loadProofs().toMutableList() val index = proofs.indexOf(proof) if (index < 0) return false - lightningRepo.finishAcceptedShopActivity(proof.requestId, proof.proofData) val verified = proof.copy(onchainAcceptanceVerified = true) proofs[index] = verified return persistAndSubmit(listOf(verified), proofs) } - lightningRepo.finishAcceptedShopActivity(proof.requestId, proof.proofData) submitReady(proof) attempt.matchesPositiveShopProof(proof) } @@ -662,7 +706,6 @@ class PaykitPaymentProofRepo @Inject constructor( if (proof.onchainWalletId != WalletScope.default) return reconcileHardwareOnchainProof(proof) if (!attempt.matchesPositiveShopProof(proof)) return false val txid = attempt?.txid ?: return false - lightningRepo.finishAcceptedShopActivity(proof.requestId, txid) val proofs = loadProofs().toMutableList() val index = proofs.indexOf(proof) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index d046be7a63..c4d97b7d9a 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -817,7 +817,11 @@ private fun RootNavHost( val scope = rememberCoroutineScope() LaunchedEffect(Unit) { transferViewModel.transferEffects.collect { effect -> - navController.navigateForTransferEffect(effect) + if (effect is TransferEffect.OnFundingPending) { + appViewModel.showPendingTransfer(effect.attempt) + } else { + navController.navigateForTransferEffect(effect) + } } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index ec2e7f5920..56f3b5c88b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4297,6 +4297,13 @@ class AppViewModel @Inject constructor( // Finish the earlier payment, but this blocked confirmation did not send the new payment. runSuspendCatching { lightningRepo.completeAcceptedOrdinaryFollowup(previous.txid) } .onFailure { Logger.warn("Failed to finish earlier ordinary send locally", it, context = TAG) } + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.wallet__error_sending_title), + description = context.getString(R.string.wallet__send_pending__blocked_new_send), + ) + hideSheet() + return } previous?.refusalReason?.let { toast( @@ -4392,6 +4399,22 @@ class AppViewModel @Inject constructor( return result } + fun showPendingTransfer(attempt: OnchainSendAttempt) { + if (!attempt.isTransfer || attempt.orderId == null || !attempt.blocksNextSend) return + showSheet( + Sheet.Send( + SendRoute.Pending( + paymentHash = attempt.txid.orEmpty(), + amount = attempt.amountSats.toLong(), + observeResolution = false, + isOnchain = true, + walletId = attempt.walletId, + refusalReason = attempt.refusalReason, + ) + ) + ) + } + private fun showUnresolvedOnchainSend( txid: String?, amount: ULong, diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 1e3677a28f..da960e276d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -400,11 +400,7 @@ class TransferViewModel @Inject constructor( } return true } - ToastEventBus.send( - AppError( - previous.refusalReason ?: context.getString(R.string.wallet__send_pending__funding_description) - ) - ) + transferEffects.emit(TransferEffect.OnFundingPending(previous)) return false } if (cacheStore.data.first().paidOrders.containsKey(order.id)) return true @@ -446,9 +442,7 @@ class TransferViewModel @Inject constructor( .fold( onSuccess = { outcome -> if (outcome !is OnchainSendOutcome.Accepted) { - ToastEventBus.send( - AppError(context.getString(R.string.wallet__send_pending__funding_description)) - ) + showRetainedFundingPending(order) return@fold false } // Survive ViewModel clearance between accepted broadcast and paid-order cache write. @@ -466,12 +460,19 @@ class TransferViewModel @Inject constructor( true }, onFailure = { - ToastEventBus.send(it) + if (!showRetainedFundingPending(order)) ToastEventBus.send(it) false } ) } + private suspend fun showRetainedFundingPending(order: IBtOrder): Boolean { + val attempt = lightningRepo.currentOnchainSendAttempt() ?: return false + if (attempt.orderId != order.id || !attempt.isTransfer || !attempt.blocksNextSend) return false + transferEffects.emit(TransferEffect.OnFundingPending(attempt)) + return true + } + private suspend fun resolveSpendingConfirmPlan( order: IBtOrder, shown: TransferToSpendingUiState, @@ -2162,6 +2163,7 @@ data class TransferValues( sealed interface TransferEffect { data object OnQuoteReady : TransferEffect data object OnSpendingFundingPaid : TransferEffect + data class OnFundingPending(val attempt: to.bitkit.repositories.OnchainSendAttempt) : TransferEffect data object OnHwTxSigned : TransferEffect data class ToastException(val e: Throwable) : TransferEffect data class ToastError(val title: String, val description: String) : TransferEffect diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3087aa7bc6..8ba57c8bed 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1474,6 +1474,7 @@ MAX The maximum spendable amount is a bit lower due to a required reserve balance. Reserve Balance + The new payment was not sent. Finish the previous payment before trying again. This payment is taking a bit longer than expected. You can continue using Bitkit. Funding transaction is unresolved. Check its transaction before trying again. Payment Pending diff --git a/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt index 80263f07f2..84585be0b2 100644 --- a/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt @@ -4,13 +4,14 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.junit.Test import to.bitkit.repositories.OnchainSendEvidence +import to.bitkit.test.BaseUnitTest import java.security.MessageDigest import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull -class ActiveOnchainAttemptBackupTest { +class ActiveOnchainAttemptBackupTest : BaseUnitTest() { private val json = Json { ignoreUnknownKeys = true } private val binding = "fe843546f607f38ba7b1e8fe479c3103139ebea5b83628cbaa465e41cf6cb6c0" @@ -21,6 +22,56 @@ class ActiveOnchainAttemptBackupTest { return json.decodeFromString(bytes.decodeToString()) } + @Test + fun `shared iOS candidate fee golden preserves exact map and older winner fee`() { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val hash = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } + assertEquals("ff666fce72a1dbcc870fc277aabbf1a28926f5f926a772d87e3d4960403ce34c", hash) + val backup = Json { ignoreUnknownKeys = true }.decodeFromString(bytes.decodeToString()) + val state = requireNotNull(backup.paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + wire.validateProofs(state.pendingProofs, "wallet0") + val attempt = wire.restored("regtest", wire.wallet.binding, "wallet0", 0) + assertEquals(4uL, attempt.winningFeeRateSatsPerVByte) + assertEquals(2uL, attempt.copy(txid = attempt.candidateTxids.first()).winningFeeRateSatsPerVByte) + assertEquals(wire, ActiveOnchainAttemptBackup.from(attempt, "regtest", wire.wallet.binding)) + } + + @Test + fun `candidate fee wire survives restore and rejects foreign candidates or invalid UInt32 rates`() { + val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt) + val winner = requireNotNull(wire.txid) + val json = Json { ignoreUnknownKeys = true } + fun withRates(rates: Map): ActiveOnchainAttemptBackup { + val fields = json.parseToJsonElement(json.encodeToString(wire)) as kotlinx.serialization.json.JsonObject + val candidateFees = kotlinx.serialization.json.JsonObject( + rates.mapValues { kotlinx.serialization.json.JsonPrimitive(it.value) } + ) + return json.decodeFromString( + kotlinx.serialization.json.JsonObject(fields + ("candidateFeeRates" to candidateFees)).toString() + ) + } + val enhanced = withRates(mapOf(winner to "3")) + val restored = enhanced.restored("regtest", binding, "wallet0", 0) + assertEquals(3uL, restored.winningFeeRateSatsPerVByte) + val exported = ActiveOnchainAttemptBackup.from(restored, "regtest", binding) + assertEquals(enhanced, exported) + listOf( + mapOf("00".repeat(32) to "3"), + mapOf(winner to "0"), + mapOf(winner to "4294967296"), + mapOf(winner to "03"), + ).forEach { + assertFailsWith { withRates(it).restored("regtest", binding, "wallet0", 0) } + } + val original = wire.copy(txid = wire.candidateTxids.first()).restored("regtest", binding, "wallet0", 0) + assertEquals(wire.feeRateSatsPerVByte.toULong(), original.winningFeeRateSatsPerVByte) + if (wire.candidateTxids.size > 1) { + val successor = wire.copy(txid = wire.candidateTxids.last()).restored("regtest", binding, "wallet0", 0) + assertFailsWith { successor.winningFeeRateSatsPerVByte } + } + } + @Test fun `same golden bytes preserve original proof and remap only validated guard`() { val state = requireNotNull(golden().paykitPaymentState) diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index f66fb4ec99..ac25f88b1b 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -1618,13 +1618,14 @@ class LightningRepoTest : BaseUnitTest() { } @Test - fun `UI cancellation after admission never releases pending guard`() = test { + fun `preparation cancellation releases empty admitted guard without dispatch`() = test { val attempt = pendingSendAttempt() val repo = prepareGuardedSend(attempt) whenever(lightningService.prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any())) .thenThrow(CancellationException("screen closed")) assertFailsWith { repo.sendOnChain("address", 1_000uL) } - verify(onchainSendAttemptStore, never()).releaseBeforeDispatch(any(), any()) + verify(onchainSendAttemptStore).releaseBeforeDispatch(attempt.attemptId, attempt.walletIndex) + verify(onchainSendAttemptStore, never()).broadcastPreparedCandidate(any(), any(), any(), any()) } @Test @@ -1669,6 +1670,26 @@ class LightningRepoTest : BaseUnitTest() { return spy(sut).also { doReturn(Result.success(1uL)).whenever(it).getFeeRateForSpeed(any(), anyOrNull()) } } + @Test + fun `accepted successor metadata uses the winning candidate authorized fee`() = test { + val txid = "cd".repeat(32) + val attempt = pendingSendAttempt().copy( + evidence = OnchainSendEvidence.Accepted, + txid = txid, + candidateTxids = listOf("ab".repeat(32), txid), + candidateFeeRates = mapOf("ab".repeat(32) to 1uL, txid to 3uL), + ) + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + whenever(onchainSendAttemptStore.current()).thenReturn(attempt) + whenever(activityService.getOnchainActivityByTxId(txid, attempt.walletId)).thenReturn(mock()) + sut.completeAcceptedOrdinaryFollowup(txid) + val metadata = argumentCaptor() + verify(preActivityMetadataRepo).addPreActivityMetadata(metadata.capture()) + assertEquals(3uL, metadata.firstValue.feeRate) + } + @Test fun `accepted ordinary send stays guarded until its activity is durable`() = test { val txid = "ab".repeat(32) diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index 57d875b4b9..ffc53c3726 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -23,6 +23,32 @@ import kotlin.test.assertTrue class OnchainSendAttemptStoreTest : BaseUnitTest() { private val key = Keychain.Key.ONCHAIN_SEND_ATTEMPT.name + @Test + fun `preparation cancellation cleanup releases only exact empty guard and never a receipt`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + whenever(keychain.delete(key, 0)).doSuspendableAnswer { saved = null } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val empty = store.admitForTest() + store.releaseBeforeDispatch("foreign-attempt", 0) + assertEquals(empty, store.current()) + store.releaseBeforeDispatch(empty.attemptId, 0) + assertNull(store.current()) + val original = store.admitForTest() + val receipt = OnchainPreparedReceipt( + "ab".repeat(32), + listOf(OnchainSendInput("11".repeat(32), 0u)), + original.address, + original.amountSats, + ) + val retained = store.retainPreparedReceipt(original.attemptId, 0, receipt, false) + store.releaseBeforeDispatch(original.attemptId, 0) + assertEquals(retained, store.current()) + assertFailsWith { store.admitForTest() } + } + @Test fun `admission followup timestamp uses the injected clock`() = test { val keychain = mock() diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt index 1f2af50485..d290bc9e51 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt @@ -457,6 +457,30 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { assertTrue(restarted.current()?.localFollowupComplete == true) } + @Test + fun `candidate fees retain authorized successor rate and select the actual older winner`() = test { + val f = Fixture() + val original = f.unresolved() + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error("unused") + override suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong) = + PreparedOnchainSend(f.receipt(nextTxid)) { + val saved = kotlinx.serialization.json.Json.decodeFromString( + requireNotNull(f.saved) + ) + assertEquals(3uL, saved.candidateFeeRates[nextTxid]) + OnchainSendOutcome.Unknown(nextTxid) + } + } + val coordinator = OnchainSendCoordinator(f.store, sender, testDispatcher) + coordinator.retryOriginal(original.attemptId, original.walletId, 3uL) {}.getOrThrow() + val reopened = OnchainSendAttemptStore(testDispatcher, f.keychain, f.service, kotlin.time.Clock.System) + assertEquals(3uL, reopened.current()?.winningFeeRateSatsPerVByte) + reopened.observeExactTransaction(firstTxid) + assertEquals(1uL, reopened.current()?.winningFeeRateSatsPerVByte) + assertEquals(mapOf(firstTxid to 1uL, nextTxid to 3uL), reopened.current()?.candidateFeeRates) + } + @Test fun `overflowing authorized recovery fee never reaches native preparation`() = test { assertEquals(UInt.MAX_VALUE.toULong(), OnchainRecoveryFeeRate.maximum) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index d5416be529..0a335ba487 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -12,6 +12,9 @@ import com.synonym.paykit.PaymentRequestLocalRole import com.synonym.paykit.PaymentRequestRecord import com.synonym.paykit.PaymentRequestTerms import com.synonym.paykit.PrivateJsonObject +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before @@ -894,6 +897,31 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), any()) } + @Test + fun `Shop reconciliation never holds proof mutex while finishing attempt activity`() = test { + val txid = "ab".repeat(32) + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedAttempt(request, txid)) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + val finishing = CompletableDeferred() + val proofLockReleased = CompletableDeferred() + whenever(lightningRepo.finishAcceptedShopActivity(request.id, txid)).doSuspendableAnswer { + finishing.complete(Unit) + proofLockReleased.await() + } + val reconciliation = launch { repo.reconcile() } + finishing.await() + withTimeout(1_000) { + repo.backupSnapshot() + proofLockReleased.complete(Unit) + reconciliation.join() + } + assertTrue(storedProofs.isEmpty()) + } + @Test fun `accepted Shop activity failure retains original proof for reconciliation before delivery`() = test { val txid = "ab".repeat(32) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index dc6d4f8823..bea1095149 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -8378,21 +8378,51 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = emptyList(), evidence = OnchainSendEvidence.Accepted, txid = txid, + candidateTxids = listOf(txid), + originalInputs = listOf(to.bitkit.repositories.OnchainSendInput("11".repeat(32), 0u)), ) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) setSendState(SendUiState(address = "bcrt1qdifferentrecipient", amount = 2_000u, payMethod = SendMethod.ONCHAIN)) stubOnchainSend("bcrt1qdifferentrecipient", 2_000u, Result.failure(OnchainSendBlockedError(previous))) whenever(lightningRepo.completeAcceptedOrdinaryFollowup(txid)) - .doSuspendableAnswer { throw AppError("attempt reload unavailable") } + .doSuspendableAnswer { Unit } sut.sendEffect.test { confirmCurrentPayment() - assertEquals(SendEffect.NavigateToPending(txid, 1_000, false, isOnchain = true), awaitItem()) + expectNoEvents() } assertNull(sut.successSendUiState.value.paymentHashOrTxId) verify(lightningRepo).completeAcceptedOrdinaryFollowup(txid) } + @Test + fun `retained transfer Pending opens original funding recovery without changing send inputs`() = test { + val original = OnchainSendAttempt( + walletId = WalletScope.default, attemptId = "original-funding", requestId = null, + orderId = "order-1", address = "bcrt1qoriginalorder", amountSats = 1_000uL, + isMaxAmount = false, feeRateSatsPerVByte = 2uL, isTransfer = true, + channelId = null, tags = emptyList(), evidence = OnchainSendEvidence.Rejected, + txid = "ab".repeat(32), refusalReason = "declined", + ) + setSendState(SendUiState(address = "bcrt1qother", amount = 9_000u)) + sut.showPendingTransfer(original) + advanceUntilIdle() + assertEquals( + Sheet.Send( + SendRoute.Pending( + paymentHash = requireNotNull(original.txid), + amount = 1_000, + observeResolution = false, + isOnchain = true, + walletId = original.walletId, + refusalReason = original.refusalReason, + ) + ), + sut.currentSheet.value, + ) + assertEquals("bcrt1qother", sut.sendUiState.value.address) + } + @Test fun `recovery denies paid expired or changed original Blocktank order before broadcast`() = test { val order = to.bitkit.ext.mockOrder().copy( diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index bac4a53407..7550181e78 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -1480,8 +1480,14 @@ class TransferViewModelTest : BaseUnitTest() { quoteOrder(order) prepareConfirm() + val retained = acceptedFundingAttempt(order, null).copy(evidence = OnchainSendEvidence.Unknown) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(null, retained) + val effects = mutableListOf() + val collector = launch { sut.transferEffects.collect { effects += it } } sut.onTransferToSpendingConfirm() advanceUntilIdle() + assertEquals(listOf(TransferEffect.OnFundingPending(retained)), effects) + collector.cancel() verify(cacheStore, never()).addPaidOrder(any(), any()) verify(transferRepo, never()).createTransfer( diff --git a/app/src/test/resources/candidate-fee-rates-golden.json b/app/src/test/resources/candidate-fee-rates-golden.json new file mode 100644 index 0000000000..8158aa0bba --- /dev/null +++ b/app/src/test/resources/candidate-fee-rates-golden.json @@ -0,0 +1,79 @@ +{ + "version": 1, + "createdAt": 1791234000000, + "transfers": [], + "paykitPaymentState": { + "subscriptions": {}, + "pendingProofs": [ + { + "identity": "pubkyzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + "requestId": { + "paymentRequestId": "550e8400-e29b-41d4-a716-446655440000", + "counterparty": "pubkyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy", + "counterpartyReceiverPath": "bitkit/server", + "billingPeriodStartsAt": null + }, + "paymentEndpointIdentifier": "btc-regtest-p2wpkh", + "kind": "bitcoin-onchain-txid", + "paymentStarted": true, + "paymentIdentifier": "abababababababababababababababababababababababababababababababab", + "proofData": null, + "billingPeriod": null, + "onchainAddress": "bcrt1qoriginal", + "onchainAmountSats": 1234, + "onchainWalletId": null, + "onchainMatchingTransactionIdsBeforeAttempt": [], + "onchainAcceptanceVerified": false + } + ], + "activeOnchainAttempt": { + "version": 1, + "wallet": { + "kind": "software", + "network": "regtest", + "binding": "fe843546f607f38ba7b1e8fe479c3103139ebea5b83628cbaa465e41cf6cb6c0", + "sourceIndex": "0" + }, + "attemptId": "550e8400-e29b-41d4-a716-446655440001", + "requestId": { + "paymentRequestId": "550e8400-e29b-41d4-a716-446655440000", + "counterparty": "pubkyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy", + "counterpartyReceiverPath": "bitkit/server", + "billingPeriodStartsAt": null + }, + "orderId": null, + "payerIdentity": "pubkyzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + "address": "bcrt1qoriginal", + "amountSats": "1234", + "isMaxAmount": false, + "status": "unknown", + "txid": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "rejectionReason": null, + "originalInputs": [ + { + "txid": "efefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefef", + "vout": "4294967295" + } + ], + "candidateTxids": [ + "abababababababababababababababababababababababababababababababab", + "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd" + ], + "feeRateSatsPerVByte": "2", + "followup": { + "feeSats": "123", + "tags": [ + "original tag" + ], + "contact": "pubkyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy", + "createdAtMillis": "1791234000000", + "channelId": null + }, + "transfer": null, + "candidateFeeRates": { + "abababababababababababababababababababababababababababababababab": "2", + "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd": "4" + } + } + } +} From c7745fc7a597823b0f135cfb7170ba7f2d9a6b34 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 6 Oct 2026 21:25:14 +0200 Subject: [PATCH 017/108] fix: show the actual accepted retry fee --- .../to/bitkit/repositories/LightningRepo.kt | 13 ++++- .../repositories/OnchainSendAttemptStore.kt | 11 ++++ .../java/to/bitkit/services/CoreService.kt | 11 ++++ .../to/bitkit/services/LightningService.kt | 40 +++++++++++++ .../bitkit/repositories/LightningRepoTest.kt | 32 ++++++++++ .../to/bitkit/services/OriginalSendFeeTest.kt | 58 +++++++++++++++++++ 6 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/to/bitkit/services/OriginalSendFeeTest.kt diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 071a41f7b6..d75690b624 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1683,6 +1683,14 @@ class LightningRepo @Inject constructor( ) { "Original local follow-up context is unavailable" } check(originalFollowup?.contact == null) { "Unsupported original contact follow-up" } val txId = requireNotNull(attempt.txid) { "On-chain send has no transaction id" } + val isSuccessor = attempt.candidateTxids.isNotEmpty() && !txId.equals(attempt.candidateTxids.first(), true) + val fee = if (isSuccessor) { + requireNotNull(lightningService.observedOriginalSendFee(attempt)) { + "Winning transaction fee is unavailable" + }.also { onchainSendAttemptStore.retainWinningFee(attempt.attemptId, attempt.walletIndex, txId, it) } + } else { + originalFollowup?.feeSats?.toULong() ?: 0uL + } val preActivityMetadata = PreActivityMetadata( walletId = attempt.walletId, paymentId = txId, @@ -1701,12 +1709,15 @@ class LightningRepo @Inject constructor( txid = txId, address = attempt.address, amount = attempt.amountSats, - fee = originalFollowup?.feeSats?.toULong() ?: 0uL, + fee = fee, feeRate = attempt.winningFeeRateSatsPerVByte, isTransfer = attempt.isTransfer, channelId = attempt.channelId, walletId = attempt.walletId, ) + if (isSuccessor) { + coreService.activity.repairVerifiedSentOnchainFee(txId, attempt.walletId, fee, attempt.winningFeeRateSatsPerVByte) + } check(coreService.activity.getOnchainActivityByTxId(txId, attempt.walletId) != null) { "Accepted on-chain transaction has no durable local activity" } diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index 1758d6e8d8..e72aa2c691 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -294,6 +294,17 @@ class OnchainSendAttemptStore @Inject constructor( } } + suspend fun retainWinningFee(attemptId: String, walletIndex: Int, txid: String, feeSats: ULong) = + withContext(ioDispatcher + NonCancellable) { + mutex.withLock { + val current = loadWithRetainedAccepted(walletIndex) + check(current?.attemptId == attemptId && walletIndex == lightningService.currentWalletIndex && + current.hasPositiveEvidence && current.txid.equals(txid, true)) + val followup = requireNotNull(current.backupFollowup) + persist(current.copy(backupFollowup = followup.copy(feeSats = feeSats.toString()))) + } + } + suspend fun markLocalFollowupComplete(attemptId: String, walletIndex: Int) = withContext(ioDispatcher + NonCancellable) { mutex.withLock { val current = loadWithRetainedAccepted(walletIndex) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 9d4ab907c8..3c40958b31 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -1397,6 +1397,17 @@ class ActivityService( } } + /** Called only after exact original-node transaction/prevout verification, to repair an existing placeholder. */ + suspend fun repairVerifiedSentOnchainFee(txid: String, walletId: String, fee: ULong, feeRate: ULong) = + ServiceQueue.CORE.background { + val existing = requireNotNull(getOnchainActivityByTxId(txid, walletId)) + check(existing.walletId == walletId && existing.txId == txid && existing.txType == PaymentType.SENT) + val updated = existing.copy(fee = fee, feeRate = feeRate) + if (updated != existing) upsertActivity(Activity.Onchain(updated)) + val restored = requireNotNull(getOnchainActivityByTxId(txid, walletId)) + check(restored.fee == fee && restored.feeRate == feeRate) + } + suspend fun handleOnchainTransactionReceived(txid: String, details: TransactionDetails) { ServiceQueue.CORE.background { runCatching { diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 021bc20032..b3fef04772 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -46,6 +46,7 @@ import org.lightningdevkit.ldknode.PublicKey import org.lightningdevkit.ldknode.ScoringFeeParameters import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.Txid +import org.lightningdevkit.ldknode.TransactionDetails import org.lightningdevkit.ldknode.defaultConfig import to.bitkit.async.BaseCoroutineScope import to.bitkit.async.ServiceQueue @@ -71,6 +72,7 @@ import to.bitkit.models.toAddressType import to.bitkit.repositories.OnchainPreparedReceipt import to.bitkit.repositories.OnchainRecoveryFeeRate import to.bitkit.repositories.OnchainSendInput +import to.bitkit.repositories.OnchainSendAttempt import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.PreparedOnchainSend import to.bitkit.utils.AppError @@ -986,6 +988,22 @@ class LightningService internal constructor( } } + suspend fun observedOriginalSendFee(attempt: OnchainSendAttempt): ULong? { + check(attempt.hasPositiveEvidence && attempt.walletIndex == currentWalletIndex) + val originalNode = node ?: throw ServiceError.NodeNotSetup() + val txid = requireNotNull(attempt.txid) + check(txid.lowercase() in attempt.candidateTxids) + return callOnchainSend { + check(currentWalletIndex == attempt.walletIndex && node === originalNode) + val details = originalNode.getTransactionDetails(txid) ?: return@callOnchainSend null + val fee = exactOriginalSendFee(details, requireNotNull(attempt.originalInputs)) { + originalNode.getTransactionDetails(it) + } + check(currentWalletIndex == attempt.walletIndex && node === originalNode) + fee + } + } + internal suspend fun callOnchainSend(block: suspend () -> T): T = ServiceQueue.LDK.background(ldkQueue) { runSuspendCatching { block() } }.getOrThrow() @@ -1429,3 +1447,25 @@ class TrustedPeerForceCloseException : AppError( ) class NetworkGraphCacheDeleteError : AppError("Failed to delete network graph cache") + +/** Exact signed transaction inputs/prevouts, never an estimate or a backend-absence inference. */ +internal fun exactOriginalSendFee( + details: TransactionDetails, + originalInputs: List, + previous: (String) -> TransactionDetails?, +): ULong? { + val inputs = details.inputs.map { OnchainSendInput(it.txid, it.vout) } + check(inputs.isNotEmpty() && inputs.distinct().size == inputs.size && inputs.toSet() == originalInputs.toSet()) + fun checkedSum(values: List): ULong = values.fold(0uL) { sum, value -> + check(value >= 0 && ULong.MAX_VALUE - sum >= value.toULong()) + sum + value.toULong() + } + val inputValues = inputs.map { input -> + val parent = previous(input.txid) ?: return null + parent.outputs.single { it.n == input.vout }.value + } + val inputTotal = checkedSum(inputValues) + val outputTotal = checkedSum(details.outputs.map { it.value }) + check(inputTotal >= outputTotal) + return inputTotal - outputTotal +} diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index ac25f88b1b..11bbd5d9e3 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -1670,6 +1670,37 @@ class LightningRepoTest : BaseUnitTest() { return spy(sut).also { doReturn(Result.success(1uL)).whenever(it).getFeeRateForSpeed(any(), anyOrNull()) } } + @Test + fun `successor activity fee never reuses original nonzero fee and missing exact fee stays guarded`() = test { + val txid = "cd".repeat(32) + val attempt = pendingSendAttempt().copy( + evidence = OnchainSendEvidence.Accepted, + txid = txid, + candidateTxids = listOf("ab".repeat(32), txid), + candidateFeeRates = mapOf("ab".repeat(32) to 1uL, txid to 3uL), + backupFollowup = to.bitkit.models.ActiveOnchainAttemptBackup.Followup("143", emptyList(), createdAtMillis = "123"), + ) + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(onchainSendAttemptStore.current()).thenReturn(attempt) + whenever(lightningService.observedOriginalSendFee(attempt)).thenReturn(null) + sut.completeAcceptedOrdinaryFollowup(txid) + verify(activityService, never()).createSentOnchainActivityFromSendResult( + any(), any(), any(), any(), any(), any(), anyOrNull(), any(), + ) + verify(onchainSendAttemptStore, never()).markLocalFollowupComplete(any(), any()) + whenever(lightningService.observedOriginalSendFee(attempt)).thenReturn(281uL) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + whenever(activityService.getOnchainActivityByTxId(txid, attempt.walletId)).thenReturn(mock()) + sut.completeAcceptedOrdinaryFollowup(txid) + verify(activityService).createSentOnchainActivityFromSendResult( + txid, attempt.address, attempt.amountSats, 281uL, 3uL, false, null, attempt.walletId, + ) + verify(activityService).repairVerifiedSentOnchainFee(txid, attempt.walletId, 281uL, 3uL) + verify(onchainSendAttemptStore).retainWinningFee(attempt.attemptId, attempt.walletIndex, txid, 281uL) + verify(onchainSendAttemptStore).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + } + @Test fun `accepted successor metadata uses the winning candidate authorized fee`() = test { val txid = "cd".repeat(32) @@ -1683,6 +1714,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(coreService.activity).thenReturn(activityService) whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) whenever(onchainSendAttemptStore.current()).thenReturn(attempt) + whenever(lightningService.observedOriginalSendFee(attempt)).thenReturn(281uL) whenever(activityService.getOnchainActivityByTxId(txid, attempt.walletId)).thenReturn(mock()) sut.completeAcceptedOrdinaryFollowup(txid) val metadata = argumentCaptor() diff --git a/app/src/test/java/to/bitkit/services/OriginalSendFeeTest.kt b/app/src/test/java/to/bitkit/services/OriginalSendFeeTest.kt new file mode 100644 index 0000000000..260a8456bd --- /dev/null +++ b/app/src/test/java/to/bitkit/services/OriginalSendFeeTest.kt @@ -0,0 +1,58 @@ +package to.bitkit.services + +import org.junit.Test +import org.lightningdevkit.ldknode.TransactionDetails +import org.lightningdevkit.ldknode.TxInput +import org.lightningdevkit.ldknode.TxOutput +import to.bitkit.repositories.OnchainSendInput +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class OriginalSendFeeTest { + private val parentId = "ef".repeat(32) + private val input = TxInput(parentId, 1u, "", emptyList(), UInt.MAX_VALUE - 2u) + private fun output(value: Long, n: UInt = 0u) = TxOutput("", null, null, value, n) + private val inputs = listOf(OnchainSendInput(parentId, 1u)) + private val details = TransactionDetails(-1000L, listOf(input), listOf(output(1000), output(18719, 1u))) + private val parent = TransactionDetails(20000L, emptyList(), listOf(output(20000, 1u))) + + @Test + fun `winning fee uses exact previous output and never original fee hint`() { + assertEquals(281uL, exactOriginalSendFee(details, inputs) { parent }) + assertNull(exactOriginalSendFee(details, inputs) { null }) + } + + @Test + fun `invalid or substituted input and previous output cannot complete a fee`() { + assertFailsWith { exactOriginalSendFee(details, emptyList()) { parent } } + assertFailsWith { + exactOriginalSendFee(details.copy(inputs = listOf(input, input)), inputs) { parent } + } + assertFailsWith { + exactOriginalSendFee(details, inputs) { parent.copy(outputs = listOf(output(20000, 0u))) } + } + assertFailsWith { + exactOriginalSendFee(details, inputs) { parent.copy(outputs = listOf(output(-1, 1u))) } + } + assertFailsWith { + exactOriginalSendFee(details, inputs) { parent.copy(outputs = listOf(output(100, 1u))) } + } + } + + @Test + fun `negative and overflowing totals cannot complete a fee`() { + assertFailsWith { + exactOriginalSendFee(details.copy(outputs = listOf(output(-1))), inputs) { parent } + } + val other = input.copy(vout = 2u) + val three = input.copy(vout = 3u) + val many = details.copy(inputs = listOf(input, other, three)) + val original = many.inputs.map { OnchainSendInput(it.txid, it.vout) } + assertFailsWith { + exactOriginalSendFee(many, original) { + parent.copy(outputs = listOf(output(Long.MAX_VALUE, 1u), output(Long.MAX_VALUE, 2u), output(Long.MAX_VALUE, 3u))) + } + } + } +} From d3f3bec810eea12ca5955ebb84266caeff97b1a5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 6 Oct 2026 21:29:17 +0200 Subject: [PATCH 018/108] fix: bind retry fallback to the original payment --- .../repositories/OnchainSendCoordinator.kt | 8 +++- .../OnchainSendCoordinatorTest.kt | 37 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt index 5731235daf..17a8714cf3 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt @@ -174,14 +174,18 @@ class OnchainSendCoordinator( ) }.getOrElse { error -> val winner = runSuspendCatching { store.current() }.getOrNull() - if (winner?.hasPositiveEvidence == true) return OnchainSendOutcome.Accepted(requireNotNull(winner.txid)) + if (winner?.attemptId == attempt.attemptId && winner.walletId == attempt.walletId && winner.hasPositiveEvidence) { + return OnchainSendOutcome.Accepted(requireNotNull(winner.txid)) + } throw OnchainSendPendingError(error, prepared.receipt.txid) } val recorded = runSuspendCatching { store.recordOutcome(attempt.attemptId, outcome, attempt.walletIndex) }.getOrElse { error -> val winner = runSuspendCatching { store.current() }.getOrNull() - if (winner?.hasPositiveEvidence == true) return OnchainSendOutcome.Accepted(requireNotNull(winner.txid)) + if (winner?.attemptId == attempt.attemptId && winner.walletId == attempt.walletId && winner.hasPositiveEvidence) { + return OnchainSendOutcome.Accepted(requireNotNull(winner.txid)) + } if (outcome is OnchainSendOutcome.Accepted && outcome.txid.equals(prepared.receipt.txid, true)) { return outcome } diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt index d290bc9e51..aba0fc00cf 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt @@ -457,6 +457,43 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { assertTrue(restarted.current()?.localFollowupComplete == true) } + @Test + fun `post dispatch fallback never returns another attempt or wallet winner`() = test { + for (failBroadcast in listOf(true, false)) { + for (changeWallet in listOf(true, false)) { + val f = Fixture() + val original = f.unresolved() + var current = original + val other = original.copy( + attemptId = if (changeWallet) original.attemptId else "different-attempt", + walletId = if (changeWallet) "different-wallet" else original.walletId, + txid = "ef".repeat(32), evidence = OnchainSendEvidence.Accepted, + localFollowupComplete = true, + ) + val store = mock() + whenever { store.current() }.doSuspendableAnswer { current } + whenever { store.retainPreparedReceipt(any(), any(), any(), any()) }.thenReturn(original) + whenever { store.broadcastPreparedCandidate(any(), any(), any(), any()) }.doSuspendableAnswer { + current = other + if (failBroadcast) error("native dispatch result unavailable") + OnchainSendOutcome.Unknown(nextTxid) + } + whenever { store.recordOutcome(any(), any(), any()) }.doSuspendableAnswer { + error("original operation is no longer current") + } + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error("unused") + override suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong) = + PreparedOnchainSend(f.receipt(nextTxid)) { OnchainSendOutcome.Unknown(nextTxid) } + } + val result = OnchainSendCoordinator(store, sender, testDispatcher) + .retryOriginal(original.attemptId, original.walletId, 2uL) {} + assertTrue(result.isFailure, "A later winner must not satisfy the original retry") + assertEquals(other, current) + } + } + } + @Test fun `candidate fees retain authorized successor rate and select the actual older winner`() = test { val f = Fixture() From b9cf56834c88bead0c74e16cc22983737b721524 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 6 Oct 2026 22:21:13 +0200 Subject: [PATCH 019/108] fix: complete restored on-chain contact follow-up --- .../to/bitkit/repositories/LightningRepo.kt | 9 +++++- .../repositories/OnchainSendAttemptStore.kt | 3 +- .../java/to/bitkit/services/CoreService.kt | 11 ++++++++ .../bitkit/repositories/LightningRepoTest.kt | 28 +++++++++++++++++++ .../OnchainSendAttemptStoreTest.kt | 6 ++-- .../to/bitkit/services/ActivityServiceTest.kt | 23 +++++++++++++++ 6 files changed, 74 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index d75690b624..d05b31962c 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -39,6 +39,7 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.JsonPrimitive import org.lightningdevkit.ldknode.Address import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.BestBlock @@ -1681,7 +1682,10 @@ class LightningRepo @Inject constructor( check( !attempt.restoredFromBackup || originalFollowup != null ) { "Original local follow-up context is unavailable" } - check(originalFollowup?.contact == null) { "Unsupported original contact follow-up" } + val originalContact = originalFollowup?.contact?.let { + require(it is JsonPrimitive && it.isString && it.content.isNotBlank()) { "Invalid original contact" } + it.content + } val txId = requireNotNull(attempt.txid) { "On-chain send has no transaction id" } val isSuccessor = attempt.candidateTxids.isNotEmpty() && !txId.equals(attempt.candidateTxids.first(), true) val fee = if (isSuccessor) { @@ -1715,6 +1719,9 @@ class LightningRepo @Inject constructor( channelId = attempt.channelId, walletId = attempt.walletId, ) + if (originalContact != null) { + coreService.activity.restoreSentOnchainContact(txId, attempt.walletId, originalContact) + } if (isSuccessor) { coreService.activity.repairVerifiedSentOnchainFee(txId, attempt.walletId, fee, attempt.winningFeeRateSatsPerVByte) } diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index e72aa2c691..e579542363 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -310,8 +310,7 @@ class OnchainSendAttemptStore @Inject constructor( val current = loadWithRetainedAccepted(walletIndex) if (current?.attemptId == attemptId && current.hasPositiveEvidence) { check( - (!current.restoredFromBackup || current.backupFollowup != null) && - current.backupFollowup?.contact == null + !current.restoredFromBackup || current.backupFollowup != null ) { "Original local follow-up context is unavailable" } diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 3c40958b31..fe16c8f6c5 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -1397,6 +1397,17 @@ class ActivityService( } } + /** Restore original attribution only when no contact has been saved for the accepted transaction. */ + suspend fun restoreSentOnchainContact(txid: String, walletId: String, contact: String) = + ServiceQueue.CORE.background { + val existing = requireNotNull(getOnchainActivityByTxId(txid, walletId)) + check(existing.walletId == walletId && existing.txId == txid && existing.txType == PaymentType.SENT) + if (existing.contact == null) { + upsertActivity(Activity.Onchain(existing.copy(contact = contact))) + } + check(requireNotNull(getOnchainActivityByTxId(txid, walletId)).contact != null) + } + /** Called only after exact original-node transaction/prevout verification, to repair an existing placeholder. */ suspend fun repairVerifiedSentOnchainFee(txid: String, walletId: String, fee: ULong, feeRate: ULong) = ServiceQueue.CORE.background { diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 11bbd5d9e3..246e18411f 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -1670,6 +1670,34 @@ class LightningRepoTest : BaseUnitTest() { return spy(sut).also { doReturn(Result.success(1uL)).whenever(it).getFeeRateForSpeed(any(), anyOrNull()) } } + @Test + fun `restored accepted contact is applied before acknowledging and failures stay guarded`() = test { + val txid = "ab".repeat(32) + val contact = "pubky" + "y".repeat(52) + val attempt = pendingSendAttempt().copy( + evidence = OnchainSendEvidence.Accepted, txid = txid, restoredFromBackup = true, + backupFollowup = to.bitkit.models.ActiveOnchainAttemptBackup.Followup( + "143", emptyList(), kotlinx.serialization.json.JsonPrimitive(contact), "123", + ), + ) + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(onchainSendAttemptStore.current()).thenReturn(attempt) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + whenever(activityService.getOnchainActivityByTxId(txid, attempt.walletId)).thenReturn(mock()) + whenever(activityService.restoreSentOnchainContact(txid, attempt.walletId, contact)) + .thenThrow(IllegalStateException("contact write failed")) + sut.completeAcceptedOrdinaryFollowup(txid) + verify(onchainSendAttemptStore, never()).markLocalFollowupComplete(any(), any()) + doReturn(Unit).whenever(activityService).restoreSentOnchainContact(txid, attempt.walletId, contact) + org.mockito.Mockito.clearInvocations(activityService) + sut.completeAcceptedOrdinaryFollowup(txid) + inOrder(activityService, onchainSendAttemptStore) { + verify(activityService).restoreSentOnchainContact(txid, attempt.walletId, contact) + verify(onchainSendAttemptStore).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + } + } + @Test fun `successor activity fee never reuses original nonzero fee and missing exact fee stays guarded`() = test { val txid = "cd".repeat(32) diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index ffc53c3726..ada1dfb0a2 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -59,7 +59,7 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { } @Test - fun `restored exact candidate observes without resend and unsupported contact cannot acknowledge`() = test { + fun `restored exact candidate with contact observes without resend and acknowledges`() = test { val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() val backup = kotlinx.serialization.json.Json { ignoreUnknownKeys = true } .decodeFromString(bytes.decodeToString()) @@ -82,13 +82,13 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { val observed = store.observeExactTransaction(requireNotNull(wire.txid)) assertEquals(OnchainSendEvidence.Observed, observed?.evidence) assertEquals(wire.candidateTxids, observed?.candidateTxids) - assertFailsWith { store.markLocalFollowupComplete(original.attemptId, 2) } val reopened = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) assertEquals(observed, reopened.current()) assertFailsWith { reopened.admitForTest() } val other = original.copy(attemptId = "00000000-0000-4000-8000-000000000009") assertFailsWith { reopened.restoreActive(other) } - assertEquals(observed, reopened.current()) + store.markLocalFollowupComplete(original.attemptId, 2) + assertEquals(observed?.copy(localFollowupComplete = true), reopened.current()) } @Test diff --git a/app/src/test/java/to/bitkit/services/ActivityServiceTest.kt b/app/src/test/java/to/bitkit/services/ActivityServiceTest.kt index 0836287fd2..56e734fc52 100644 --- a/app/src/test/java/to/bitkit/services/ActivityServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/ActivityServiceTest.kt @@ -63,6 +63,29 @@ class ActivityServiceTest : BaseUnitTest() { } } + @Test + fun `restored contact fills missing attribution and preserves a later edit`() = test { + ServiceQueue.CORE.background { + val getByTx = binding.getMethod("getActivityByTxId", String::class.java, String::class.java) + val upsert = binding.getMethod("upsertActivity", Activity::class.java) + for (existingContact in listOf(null, "later-contact")) { + var saved = activity().v1.copy(txType = PaymentType.SENT, contact = existingContact) + mockStatic(binding).use { native -> + native.`when` { getByTx.invoke(null, WALLET_ID, ACTIVITY_ID) }.thenAnswer { saved } + native.`when` { upsert.invoke(null, any(Activity::class.java)) }.thenAnswer { + saved = (it.getArgument(0) as Activity.Onchain).v1 + Unit + } + sut.restoreSentOnchainContact(ACTIVITY_ID, WALLET_ID, "original-contact") + kotlin.test.assertEquals(existingContact ?: "original-contact", saved.contact) + if (existingContact != null) { + native.verify({ upsert.invoke(null, any(Activity::class.java)) }, never()) + } + } + } + } + } + private fun activity() = Activity.Onchain( OnchainActivity.create( walletId = WALLET_ID, From df059374b5952059d8dcdfa3cbf169964523ef58 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 6 Oct 2026 23:22:32 +0200 Subject: [PATCH 020/108] fix: open the original funded order after pending recovery --- .../screens/wallets/send/SendPendingScreen.kt | 8 +++- .../wallets/send/SendPendingViewModel.kt | 35 +++++++++----- .../java/to/bitkit/ui/sheets/SendSheet.kt | 1 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 14 ++++++ .../wallets/send/SendPendingViewModelTest.kt | 48 ++++++++++++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 28 +++++++++++ 6 files changed, 119 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt index 26a3f70f5f..45428c4398 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt @@ -7,8 +7,6 @@ import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.Image -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -17,8 +15,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -79,6 +79,7 @@ fun SendPendingScreen( onRecovered: (String, Long) -> Unit, savedStateHandle: SavedStateHandle, onNavigateToPin: () -> Unit, + onRecoveredTransfer: (OnchainSendAttempt) -> Unit = {}, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -107,6 +108,9 @@ fun SendPendingScreen( uiState.recoveredTxid?.let { winner -> LaunchedEffect(winner) { onRecovered(winner, uiState.amount) } } + uiState.recoveredTransfer?.let { winner -> + LaunchedEffect(winner.attemptId, winner.txid) { onRecoveredTransfer(winner) } + } RecoveryAuthorization( uiState, savedStateHandle, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt index b1e639e3ea..e10ba62220 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt @@ -68,7 +68,7 @@ class SendPendingViewModel @Inject constructor( attempt?.candidateTxids?.any { it.equals(txid, true) } == true if (matchesOriginalWallet && hasOriginalReceipt && attempt != null) { if (attempt.isUnresolved) _uiState.update { it.copy(recoveryAttempt = attempt) } - observeOriginalOrdinaryCompletion(attempt) + observeOriginalCompletion(attempt) } } } @@ -79,19 +79,21 @@ class SendPendingViewModel @Inject constructor( } } - private fun observeOriginalOrdinaryCompletion(original: OnchainSendAttempt) { - // Shop and transfer completion have their own proof/order resolution routes. - if (original.requestId != null || original.isTransfer) return + private fun observeOriginalCompletion(original: OnchainSendAttempt) { + // Shop completion has its own proof resolution route. + if (original.requestId != null) return + if (original.isTransfer && (original.orderId == null || original.transferContext == null)) return viewModelScope.launch { lightningRepo.onchainSendAttemptUpdates.collect { runSuspendCatching { lightningRepo.currentOnchainSendAttempt() }.onSuccess { current -> if (current == null || !current.hasPositiveEvidence || !current.localFollowupComplete) { return@onSuccess } - if (current.matchesOriginalOrdinaryRoute(original)) { + if (current.matchesOriginalRoute(original)) { _uiState.update { it.copy( - recoveredTxid = current.txid, + recoveredTxid = current.txid.takeUnless { current.isTransfer }, + recoveredTransfer = current.takeIf { current.isTransfer }, currentTxid = current.txid, recoveryAttempt = null, recoveryError = null, @@ -103,14 +105,15 @@ class SendPendingViewModel @Inject constructor( } } - private fun OnchainSendAttempt.matchesOriginalOrdinaryRoute(original: OnchainSendAttempt): Boolean { + private fun OnchainSendAttempt.matchesOriginalRoute(original: OnchainSendAttempt): Boolean { val sameOperation = attemptId == original.attemptId && amountSats == original.amountSats && address == original.address && originalInputs == original.originalInputs val sameWallet = walletId == original.walletId && walletIndex == original.walletIndex val sameCandidateFamily = candidateTxids.any { it.equals(original.txid, true) } && candidateTxids.any { it.equals(txid, true) } - val ordinaryPayment = requestId == null && !isTransfer - return sameOperation && sameWallet && sameCandidateFamily && ordinaryPayment + val samePurpose = requestId == null && isTransfer == original.isTransfer && + orderId == original.orderId && transferContext == original.transferContext + return sameOperation && sameWallet && sameCandidateFamily && samePurpose } fun retryOriginal( @@ -129,15 +132,22 @@ class SendPendingViewModel @Inject constructor( val result = runSuspendCatching { retry(original, feeRateSatsPerVByte).getOrThrow() } result.onSuccess { outcome -> _uiState.update { - if (it.recoveredTxid != null) return@update it + if (it.recoveredTxid != null || it.recoveredTransfer != null) return@update it it.copy( - recoveredTxid = (outcome as? OnchainSendOutcome.Accepted)?.txid, + recoveredTxid = (outcome as? OnchainSendOutcome.Accepted)?.txid + .takeUnless { original.isTransfer }, recoveryError = (outcome as? OnchainSendOutcome.Rejected)?.reason, currentTxid = outcome.txid, ) } }.onFailure { error -> - _uiState.update { if (it.recoveredTxid == null) it.copy(recoveryError = error.message) else it } + _uiState.update { + if (it.recoveredTxid == null && it.recoveredTransfer == null) { + it.copy(recoveryError = error.message) + } else { + it + } + } } } finally { _uiState.update { it.copy(isRecovering = false) } @@ -190,4 +200,5 @@ data class SendPendingUiState( val recoveryError: String? = null, val currentTxid: String? = null, val recoveredTxid: String? = null, + val recoveredTransfer: OnchainSendAttempt? = null, ) diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 5fcf468542..093cae5086 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -540,6 +540,7 @@ fun SendSheet( walletId = route.walletId, refusalReason = route.refusalReason, retryOriginal = appViewModel::retryOriginalOnchainSend, + onRecoveredTransfer = appViewModel::onRecoveredTransfer, onRecovered = { txid, originalAmount -> appViewModel.onSendSuccess( NewTransactionSheetDetails( diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 56f3b5c88b..36d6d132db 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4399,6 +4399,20 @@ class AppViewModel @Inject constructor( return result } + fun onRecoveredTransfer(original: OnchainSendAttempt) { + viewModelScope.launch { + runSuspendCatching { + val current = lightningRepo.currentOnchainSendAttempt() ?: return@runSuspendCatching + if (current != original || !current.isTransfer || current.requestId != null) return@runSuspendCatching + if (!current.hasPositiveEvidence || !current.localFollowupComplete) return@runSuspendCatching + if (current.txid == null || current.transferContext == null) return@runSuspendCatching + val orderId = current.orderId ?: return@runSuspendCatching + hideSheet() + mainScreenEffect(MainScreenEffect.Navigate(Routes.OrderDetail(orderId))) + }.onFailure { Logger.warn("Failed to open recovered transfer", it, context = TAG) } + } + } + fun showPendingTransfer(attempt: OnchainSendAttempt) { if (!attempt.isTransfer || attempt.orderId == null || !attempt.blocksNextSend) return showSheet( diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt index 9df01e522d..049f9af64a 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt @@ -6,8 +6,8 @@ import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before import org.junit.Test @@ -165,6 +165,52 @@ class SendPendingViewModelTest : BaseUnitTest() { } } + @Test + fun `transfer pending follows exact funded order only after durable completion`() = test { + val original = pendingOriginal().copy( + isTransfer = true, + orderId = "original-order", + transferContext = to.bitkit.repositories.OnchainTransferContext(1_200uL, 20_000uL, 1_000uL, 200uL), + ) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original) + sut.initOnchain(original.txid, amount) + advanceUntilIdle() + sut.retryOriginal(2uL) { _, _ -> Result.success(OnchainSendOutcome.Accepted(original.txid!!)) } + advanceUntilIdle() + assertNull(sut.uiState.value.recoveredTxid) + assertNull(sut.uiState.value.recoveredTransfer) + val winner = "cd".repeat(32) + val observed = original.copy( + txid = winner, + candidateTxids = original.candidateTxids + winner, + evidence = OnchainSendEvidence.Observed, + ) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(observed) + attemptUpdates.value++ + advanceUntilIdle() + assertNull(sut.uiState.value.recoveredTransfer) + val complete = observed.copy(localFollowupComplete = true) + listOf( + complete.copy(orderId = "different-order"), + complete.copy(transferContext = original.transferContext!!.copy(txTotalSats = 1_300uL)), + complete.copy(attemptId = "different-attempt"), + complete.copy(walletIndex = 1), + ).forEach { unrelated -> + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(unrelated) + attemptUpdates.value++ + advanceUntilIdle() + assertNull(sut.uiState.value.recoveredTransfer) + } + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(complete) + attemptUpdates.value++ + advanceUntilIdle() + assertEquals(winner, sut.uiState.value.currentTxid) + assertEquals(complete, sut.uiState.value.recoveredTransfer) + assertNull(sut.uiState.value.recoveredTxid) + assertNull(sut.uiState.value.recoveryAttempt) + org.mockito.kotlin.verify(activityRepo, org.mockito.kotlin.never()).syncActivities() + } + private fun pendingOriginal() = OnchainSendAttempt( walletId = WalletScope.default, attemptId = "original", requestId = null, orderId = null, address = "original-address", amountSats = 1_000uL, isMaxAmount = false, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index bea1095149..60ec68b3b5 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -8395,6 +8395,34 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(lightningRepo).completeAcceptedOrdinaryFollowup(txid) } + @Test + fun `completed original transfer opens its funded order without another send`() = test { + val txid = "ab".repeat(32) + val original = OnchainSendAttempt( + walletId = WalletScope.default, attemptId = "original-funding", requestId = null, + orderId = "original-order", address = "bcrt1qoriginalorder", amountSats = 1_000uL, + isMaxAmount = false, feeRateSatsPerVByte = 2uL, isTransfer = true, + channelId = null, tags = emptyList(), evidence = OnchainSendEvidence.Observed, + txid = txid, localFollowupComplete = true, + transferContext = to.bitkit.repositories.OnchainTransferContext(1_200uL, 20_000uL), + ) + sut.mainScreenEffect.test { + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original.copy(localFollowupComplete = false)) + sut.onRecoveredTransfer(original) + advanceUntilIdle() + expectNoEvents() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original.copy(orderId = "different-order")) + sut.onRecoveredTransfer(original) + advanceUntilIdle() + expectNoEvents() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original) + sut.onRecoveredTransfer(original) + advanceUntilIdle() + assertEquals(MainScreenEffect.Navigate(Routes.OrderDetail("original-order")), awaitItem()) + } + assertNull(sut.successSendUiState.value.paymentHashOrTxId) + } + @Test fun `retained transfer Pending opens original funding recovery without changing send inputs`() = test { val original = OnchainSendAttempt( From 82782f556ba7df9149e5cdff40f85aef0f3064a9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 6 Oct 2026 23:56:58 +0200 Subject: [PATCH 021/108] fix: recover interrupted shop payment preparation --- .../to/bitkit/repositories/LightningRepo.kt | 4 ++ .../repositories/OnchainSendAttemptStore.kt | 43 ++++++++++++++++-- .../repositories/PaykitPaymentProofRepo.kt | 27 +++++++++++ .../OnchainSendAttemptStoreTest.kt | 43 ++++++++++++++++++ .../PaykitPaymentProofRepoTest.kt | 45 +++++++++++++++++++ 5 files changed, 158 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index d05b31962c..7870fa379c 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1757,6 +1757,10 @@ class LightningRepo @Inject constructor( suspend fun currentOnchainSendAttempt(): OnchainSendAttempt? = onchainSendAttemptStore.current() + suspend fun releaseInterruptedShopPreparation( + removeOriginalProof: suspend (OnchainSendAttempt) -> Boolean, + ): Boolean = onchainSendAttemptStore.releaseInterruptedShopPreparation(removeOriginalProof) + suspend fun finishAcceptedShopActivity(requestId: PaykitPaymentRequestId, txid: String) { val attempt = onchainSendAttemptStore.current() if (attempt?.requestId == requestId && attempt.txid.equals(txid, ignoreCase = true) && diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index e579542363..9cd7318b0a 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -16,6 +16,7 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.di.IoDispatcher import to.bitkit.ext.nowMillis import to.bitkit.models.ActiveOnchainAttemptBackup +import to.bitkit.models.WalletScope import to.bitkit.services.LightningService import to.bitkit.utils.AppError import java.util.UUID @@ -86,6 +87,7 @@ data class OnchainSendAttempt( val candidateFeeRates: Map = emptyMap(), val backupFollowup: ActiveOnchainAttemptBackup.Followup? = null, val restoredFromBackup: Boolean = false, + val preparationPending: Boolean = false, ) { val winningFeeRateSatsPerVByte: ULong get() = candidateFeeRates[txid?.lowercase()] ?: feeRateSatsPerVByte.takeIf { @@ -132,6 +134,7 @@ class OnchainSendAttemptStore @Inject constructor( // One known positive result per existing wallet guard; entries disappear after a successful durable write. private val retainedPositive = mutableMapOf() + private val inFlightPreparations = mutableSetOf() suspend fun current(): OnchainSendAttempt? = withContext(ioDispatcher) { mutex.withLock { loadWithRetainedAccepted(lightningService.currentWalletIndex) } @@ -165,7 +168,6 @@ class OnchainSendAttemptStore @Inject constructor( ) { throw OnchainSendBlockedError(loadWithRetainedAccepted(walletIndex) ?: previous) } - beforeSendAttempt() val attempt = OnchainSendAttempt( walletId = walletId, attemptId = UUID.randomUUID().toString(), @@ -181,6 +183,7 @@ class OnchainSendAttemptStore @Inject constructor( walletIndex = walletIndex, transferContext = transferContext, payerIdentity = payerIdentity, + preparationPending = requestId != null, backupFollowup = ActiveOnchainAttemptBackup.Followup( feeSats = "0", tags = tags, @@ -189,10 +192,37 @@ class OnchainSendAttemptStore @Inject constructor( ), ) persist(attempt) + inFlightPreparations += attempt.attemptId + var callbackCompleted = false + try { + beforeSendAttempt() + callbackCompleted = true + } finally { + if (!callbackCompleted) inFlightPreparations -= attempt.attemptId + } attempt } } + suspend fun releaseInterruptedShopPreparation( + removeOriginalProof: suspend (OnchainSendAttempt) -> Boolean, + ): Boolean = withContext(ioDispatcher + NonCancellable) { + mutex.withLock { + val current = loadWithRetainedAccepted(lightningService.currentWalletIndex) ?: return@withLock false + if (current.attemptId in inFlightPreparations) return@withLock false + if (!current.preparationPending || current.restoredFromBackup || current.requestId == null) { + return@withLock false + } + if (current.walletId != WalletScope.default || current.payerIdentity.isNullOrBlank()) return@withLock false + if (current.evidence != OnchainSendEvidence.Pending || current.txid != null) return@withLock false + if (current.candidateTxids.isNotEmpty() || current.originalInputs != null) return@withLock false + if (!removeOriginalProof(current)) return@withLock false + keychain.delete(KEY, current.walletIndex) + _backupStateVersion.update { it + 1 } + true + } + } + suspend fun retainPreparedReceipt( attemptId: String, walletIndex: Int, @@ -233,7 +263,11 @@ class OnchainSendAttemptStore @Inject constructor( txid = receipt.txid.lowercase(), evidence = OnchainSendEvidence.Pending, refusalReason = null, - ).also { persist(it) } + preparationPending = false, + ).also { + persist(it) + inFlightPreparations -= attemptId + } } } @@ -284,9 +318,10 @@ class OnchainSendAttemptStore @Inject constructor( suspend fun releaseBeforeDispatch(attemptId: String, walletIndex: Int) = withContext(ioDispatcher + NonCancellable) { mutex.withLock { + inFlightPreparations -= attemptId val current = loadWithRetainedAccepted(walletIndex) if (current?.attemptId == attemptId && current.evidence == OnchainSendEvidence.Pending && - current.candidateTxids.isEmpty() + current.candidateTxids.isEmpty() && !current.preparationPending ) { keychain.delete(KEY, walletIndex) _backupStateVersion.update { it + 1 } @@ -345,7 +380,7 @@ class OnchainSendAttemptStore @Inject constructor( check(existing == null || !existing.blocksNextSend || existing == attempt) { "Cannot replace an existing active on-chain operation" } - persist(attempt.copy(localFollowupComplete = false)) + persist(attempt.copy(localFollowupComplete = false, preparationPending = false)) retainedPositive.remove(attempt.walletIndex) } } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 9d97c93c77..95f25a8d04 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -112,6 +112,7 @@ class PaykitPaymentProofRepo @Inject constructor( kind: PaykitPaymentProofKind, ): Result = withContext(ioDispatcher) { runSuspendCatching { + releaseInterruptedShopPreparation() val onchainAttempt = lightningRepo.currentOnchainSendAttempt() if (onchainAttempt?.requestId == request.id) throw PaykitPaymentRequestError.OperationInProgress operationMutex.withLock { @@ -550,7 +551,33 @@ class PaykitPaymentProofRepo @Inject constructor( }.onFailure { Logger.warn("Failed to prepare Paykit subscription cancellation", it, context = TAG) } } + private suspend fun releaseInterruptedShopPreparation() { + runSuspendCatching { + lightningRepo.releaseInterruptedShopPreparation { attempt -> + operationMutex.withLock { + val proofs = loadProofs() + val original = proofs.filter { + it.requestId == attempt.requestId && + PubkyPublicKeyFormat.matches(it.identity, requireNotNull(attempt.payerIdentity)) + } + val safe = original.all { + it.kind == PaykitPaymentProofKind.Onchain && it.onchainWalletId == attempt.walletId && + it.paymentIdentifier == null && it.proofData == null && !it.onchainAcceptanceVerified && + ( + !it.paymentStarted || + (it.onchainAddress == attempt.address && it.onchainAmountSats == attempt.amountSats) + ) + } + if (!safe) return@withLock false + if (original.isNotEmpty()) persist(proofs - original.toSet()) + true + } + } + }.onFailure { Logger.warn("Failed to clear interrupted Shop preparation", it, context = TAG) } + } + suspend fun reconcile() = withContext(ioDispatcher) { + releaseInterruptedShopPreparation() val attempt = runSuspendCatching { lightningRepo.currentOnchainSendAttempt() } .onFailure { Logger.warn("Failed to read on-chain send evidence during proof reconciliation", it, context = TAG) diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index ada1dfb0a2..dadd18834a 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -49,6 +49,49 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { assertFailsWith { store.admitForTest() } } + @Test + fun `restart never releases legacy empty guards or recorded candidates`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val original = store.admitForTest() + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + assertEquals(false, reopened.releaseInterruptedShopPreparation { error("legacy guard must remain") }) + val receipt = OnchainPreparedReceipt( + "ab".repeat(32), + listOf(OnchainSendInput("11".repeat(32), 0u)), + original.address, + original.amountSats, + ) + val retained = store.retainPreparedReceipt(original.attemptId, 0, receipt, false) + assertEquals(false, reopened.releaseInterruptedShopPreparation { error("signed candidate must remain") }) + assertEquals(retained, reopened.current()) + } + + @Test + fun `admission is durable before a callback consumes request details`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + store.admitForTest { + assertTrue(saved != null, "guard must exist before proof mutation") + } + } + + @Test + fun `failed admission save does not consume request details`() = test { + val keychain = mock() + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { error("storage failure") } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + var callbacks = 0 + assertFailsWith { store.admitForTest { callbacks++ } } + assertEquals(0, callbacks) + } + @Test fun `admission followup timestamp uses the injected clock`() = test { val keychain = mock() diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 0a335ba487..21fac13547 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -106,6 +106,51 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } } + @Test + fun `restart clears only the original proof from interrupted predispatch admission`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + var saved: String? = null + val keychain = mock() + val key = to.bitkit.data.keychain.Keychain.Key.ONCHAIN_SEND_ATTEMPT.name + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + whenever(keychain.delete(key, 0)).doSuspendableAnswer { saved = null } + val attempts = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + val admitted = attempts.admit( + walletId = WalletScope.default, requestId = request.id, orderId = null, + address = ONCHAIN_ADDRESS, amountSats = request.amountSats, isMaxAmount = false, + feeRateSatsPerVByte = 1uL, isTransfer = false, channelId = null, tags = emptyList(), + payerIdentity = LOCAL_IDENTITY, + beforeSendAttempt = { repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() }, + ) + assertTrue(admitted.preparationPending) + assertTrue(storedProofs.single().paymentStarted) + assertFalse(attempts.releaseInterruptedShopPreparation { error("live preparation must not be cleared") }) + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + whenever(lightningRepo.currentOnchainSendAttempt()).doSuspendableAnswer { reopened.current() } + whenever(lightningRepo.releaseInterruptedShopPreparation(any())).doSuspendableAnswer { + reopened.releaseInterruptedShopPreparation(it.getArgument(0)) + } + val startedProof = storedProofs.single() + storedProofs = listOf(startedProof.copy(paymentIdentifier = "ab".repeat(32))) + repo.reconcile() + assertEquals(admitted, reopened.current()) + assertEquals("ab".repeat(32), storedProofs.single().paymentIdentifier) + storedProofs = listOf(startedProof) + shouldFailProofRemoval = true + repo.reconcile() + assertEquals(admitted, reopened.current()) + assertTrue(storedProofs.single().paymentStarted) + repo.reconcile() + assertNull(reopened.current()) + assertTrue(storedProofs.isEmpty()) + repo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain).getOrThrow() + assertFalse(storedProofs.single().paymentStarted) + verify(hwWalletRepo, never()).broadcastFunding(any()) + } + @Test fun `lightning association cannot bypass a started onchain request`() = test { val request = paymentRequest(MethodId.P2wpkh.rawValue).copy( From b75cda3492ba74bf6262bea706cf8d3f3b950e5c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 00:18:55 +0200 Subject: [PATCH 022/108] fix: use validated native payment recovery release --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8194ead970..fc5422b6d5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -65,7 +65,7 @@ ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "k ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.69" } +ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.70" } lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } From 6686f9af9c8eb115589eefa69f430e65b19604c3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 02:25:19 +0200 Subject: [PATCH 023/108] fix: preserve payment expiry during original retry --- .../to/bitkit/repositories/LightningRepo.kt | 6 +- .../repositories/OnchainSendCoordinator.kt | 11 +++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 + .../OnchainSendCoordinatorTest.kt | 64 +++++++++++++++---- .../viewmodels/AppViewModelSendFlowTest.kt | 4 +- 5 files changed, 67 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 9e15a2d521..dd2b3167c8 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1646,7 +1646,7 @@ class LightningRepo @Inject constructor( override suspend fun prepareRecovery( attempt: OnchainSendAttempt, - feeRateSatsPerVByte: ULong, + feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?, ): PreparedOnchainSend { val inputs = requireNotNull(attempt.originalInputs) val outputs = lightningService.listSpendableOutputs().getOrThrow() @@ -1661,6 +1661,7 @@ class LightningRepo @Inject constructor( selected, false, attempt.walletIndex, + paymentDeadlineAt, ) } }, @@ -1672,9 +1673,10 @@ class LightningRepo @Inject constructor( attemptId: String, walletId: String, feeRateSatsPerVByte: ULong, + paymentDeadlineAt: Instant? = null, authorizeOriginal: suspend (OnchainSendAttempt) -> Unit, ): Result = executeWhenNodeRunning("retryOriginalOnchainSend") { - val result = recoveryCoordinator.retryOriginal(attemptId, walletId, feeRateSatsPerVByte, authorizeOriginal) + val result = recoveryCoordinator.retryOriginal(attemptId, walletId, feeRateSatsPerVByte, paymentDeadlineAt, authorizeOriginal) if (result.getOrNull() is OnchainSendOutcome.Accepted) { runSuspendCatching { onchainSendAttemptStore.current()?.takeIf { it.hasPositiveEvidence }?.let { diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt index 17a8714cf3..ff0b696e90 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt @@ -1,5 +1,6 @@ package to.bitkit.repositories +import kotlin.time.Instant import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.sync.Mutex @@ -37,7 +38,7 @@ class PreparedOnchainSend( /** Recovery always uses the original actual recipient amount and exact inputs, including an initial Max send. */ interface OnchainPreparedSender { suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend - suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong): PreparedOnchainSend + suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant? = null): PreparedOnchainSend } /** Thin boundary for the additive native prepare API, implemented in LightningService once matching bindings exist. */ @@ -48,6 +49,7 @@ interface OnchainPreparationProtocol { feeRateSatsPerVByte: ULong, inputs: List?, walletIndex: Int, + paymentDeadlineAt: Instant? = null, ): PreparedOnchainSend suspend fun prepareMax( @@ -55,6 +57,7 @@ interface OnchainPreparationProtocol { retainReserves: Boolean, feeRateSatsPerVByte: ULong, walletIndex: Int, + paymentDeadlineAt: Instant? = null, ): PreparedOnchainSend } @@ -81,7 +84,7 @@ class OnchainPreparedSenderAdapter( return prepared } - override suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong): PreparedOnchainSend { + override suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?): PreparedOnchainSend { val inputs = requireNotNull(attempt.originalInputs) require(inputs.isNotEmpty() && inputs.distinct().size == inputs.size) return native.prepareFixed( @@ -90,6 +93,7 @@ class OnchainPreparedSenderAdapter( feeRateSatsPerVByte, inputs, attempt.walletIndex, + paymentDeadlineAt, ) } } @@ -127,6 +131,7 @@ class OnchainSendCoordinator( attemptId: String, walletId: String, feeRateSatsPerVByte: ULong, + paymentDeadlineAt: Instant? = null, authorizeOriginal: suspend (OnchainSendAttempt) -> Unit, ): Result = withContext(ioDispatcher) { val result = runSuspendCatching { @@ -144,7 +149,7 @@ class OnchainSendCoordinator( throw OnchainSendBlockedError(attempt) } require(OnchainRecoveryFeeRate.isValid(feeRateSatsPerVByte)) - val prepared = sender.prepareRecovery(attempt, feeRateSatsPerVByte) + val prepared = sender.prepareRecovery(attempt, feeRateSatsPerVByte, paymentDeadlineAt) val retained = store.retainPreparedReceipt( attempt.attemptId, attempt.walletIndex, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 972d822aca..1026713eea 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4617,6 +4617,7 @@ class AppViewModel @Inject constructor( original.attemptId, original.walletId, feeRateSatsPerVByte, + paymentDeadlineAt = request?.paymentDeadlineAt, ) { attempt -> check( attempt.attemptId == original.attemptId && attempt.requestId == original.requestId && @@ -4639,6 +4640,7 @@ class AppViewModel @Inject constructor( ) .firstOrNull { it.id == attempt.requestId } ?: throw PaykitPaymentRequestError.RequestUnavailable + check(currentRequest.paymentDeadlineAt == request.paymentDeadlineAt) paykitPaymentRequestRepo.ensurePaymentAllowed(currentRequest).getOrThrow() paykitPaymentProofRepo.authorizeOnchainRecovery(attempt).getOrThrow() } diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt index aba0fc00cf..b332cb1afd 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt @@ -12,6 +12,8 @@ import org.mockito.kotlin.whenever import to.bitkit.data.keychain.Keychain import to.bitkit.services.LightningService import to.bitkit.test.BaseUnitTest +import kotlin.time.Instant +import kotlin.time.Duration.Companion.milliseconds import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertTrue @@ -69,6 +71,42 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { } } + @Test + fun `retry retains deadline through authorization and rejects expired queued broadcast`() = test { + for (expired in listOf(false, true)) { + val f = Fixture() + val original = f.unresolved() + val deadline = Instant.fromEpochSeconds(1_800_000_000) + var dispatchTime = deadline + var broadcasts = 0 + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error("unused") + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, + paymentDeadlineAt: Instant?, + ) = PreparedOnchainSend(f.receipt(nextTxid)) { + if (paymentDeadlineAt != null && dispatchTime > paymentDeadlineAt) { + throw PaykitPaymentRequestError.RequestExpired + } + broadcasts++ + OnchainSendOutcome.Unknown(nextTxid) + } + } + val result = OnchainSendCoordinator(f.store, sender, testDispatcher).retryOriginal( + original.attemptId, original.walletId, 2uL, deadline, + ) { dispatchTime = if (expired) deadline + 1.milliseconds else deadline } + assertEquals(if (expired) 0 else 1, broadcasts, "Expired retry must not broadcast") + assertEquals(expired, result.isFailure) + val retained = requireNotNull(f.store.current()) + assertEquals(original.attemptId, retained.attemptId) + assertEquals(original.amountSats, retained.amountSats) + assertEquals(original.originalInputs, retained.originalInputs) + assertTrue(retained.blocksNextSend) + assertTrue(nextTxid in retained.candidateTxids) + } + } + @Test fun `receipt is durable before first broadcast and restart retains candidate`() = test { val f = Fixture() @@ -82,7 +120,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { } override suspend fun prepareRecovery( attempt: OnchainSendAttempt, - feeRateSatsPerVByte: ULong, + feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?, ): PreparedOnchainSend = error("not requested") } @@ -106,7 +144,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { } override suspend fun prepareRecovery( attempt: OnchainSendAttempt, - feeRateSatsPerVByte: ULong, + feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?, ): PreparedOnchainSend = error("not requested") } @@ -128,7 +166,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { } override suspend fun prepareRecovery( attempt: OnchainSendAttempt, - feeRateSatsPerVByte: ULong, + feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?, ): PreparedOnchainSend { assertEquals(900uL, attempt.amountSats) assertEquals(listOf(input), attempt.originalInputs) @@ -159,7 +197,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { ) override suspend fun prepareRecovery( attempt: OnchainSendAttempt, - feeRateSatsPerVByte: ULong, + feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?, ): PreparedOnchainSend { if (mode == "wallet") f.walletIndex = 1 val receipt = when (mode) { @@ -193,7 +231,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error( "not requested", ) - override suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong) = + override suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?) = PreparedOnchainSend(f.receipt(nextTxid)) { entered.complete(Unit) finish.await() @@ -223,7 +261,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { ) override suspend fun prepareRecovery( attempt: OnchainSendAttempt, - feeRateSatsPerVByte: ULong, + feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?, ): PreparedOnchainSend { f.store.observeExactTransaction(firstTxid) return PreparedOnchainSend(f.receipt(nextTxid)) { @@ -290,7 +328,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { ) override suspend fun prepareRecovery( attempt: OnchainSendAttempt, - feeRateSatsPerVByte: ULong, + feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?, ): PreparedOnchainSend { prepares++ return PreparedOnchainSend(f.receipt(nextTxid)) { @@ -327,7 +365,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error("unused") override suspend fun prepareRecovery( attempt: OnchainSendAttempt, - feeRateSatsPerVByte: ULong, + feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?, ): PreparedOnchainSend { entered.complete(Unit) finish.await() @@ -379,7 +417,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { ) override suspend fun prepareRecovery( attempt: OnchainSendAttempt, - feeRateSatsPerVByte: ULong, + feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?, ): PreparedOnchainSend { assertEquals(0, active) prepares++ @@ -435,7 +473,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error("unused") override suspend fun prepareRecovery( attempt: OnchainSendAttempt, - feeRateSatsPerVByte: ULong + feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant? ): PreparedOnchainSend { f.failWrite = true runCatching { f.store.observeExactTransaction(firstTxid) } @@ -483,7 +521,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { } val sender = object : OnchainPreparedSender { override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error("unused") - override suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong) = + override suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?) = PreparedOnchainSend(f.receipt(nextTxid)) { OnchainSendOutcome.Unknown(nextTxid) } } val result = OnchainSendCoordinator(store, sender, testDispatcher) @@ -500,7 +538,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { val original = f.unresolved() val sender = object : OnchainPreparedSender { override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error("unused") - override suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong) = + override suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?) = PreparedOnchainSend(f.receipt(nextTxid)) { val saved = kotlinx.serialization.json.Json.decodeFromString( requireNotNull(f.saved) @@ -535,7 +573,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error("unused") override suspend fun prepareRecovery( attempt: OnchainSendAttempt, - feeRateSatsPerVByte: ULong, + feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?, ): PreparedOnchainSend { preparations++ return PreparedOnchainSend(f.receipt(nextTxid)) { OnchainSendOutcome.Unknown(nextTxid) } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 8f36177a87..6b85c15def 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9448,8 +9448,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { evidence = OnchainSendEvidence.Unknown, ) var dispatched = 0 - whenever(lightningRepo.retryOriginalOnchainSend(any(), any(), any(), any())).doSuspendableAnswer { invocation -> - val authorize = invocation.getArgument Unit>(3) + whenever(lightningRepo.retryOriginalOnchainSend(any(), any(), any(), anyOrNull(), any())).doSuspendableAnswer { invocation -> + val authorize = invocation.getArgument Unit>(4) runCatching { authorize(original) dispatched += 1 From 5f800c2cb0b8e920c0838ab2260b2fb7ef7cfe52 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 02:36:46 +0200 Subject: [PATCH 024/108] test: repair paykit device fixtures --- .../to/bitkit/ui/components/DrawerMenuWidgetsTest.kt | 9 ++------- .../wallets/send/SendPendingObservationDeviceTest.kt | 2 -- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt b/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt index b1d06043fb..8a252ae923 100644 --- a/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt @@ -72,7 +72,6 @@ class DrawerMenuWidgetsTest { profileIdentityExists = flowOf(false), onBeforeNavigate = {}, showWidgets = true, - profileIdentityExists = flowOf(false), ) } } @@ -106,7 +105,6 @@ class DrawerMenuWidgetsTest { profileIdentityExists = flowOf(false), onBeforeNavigate = {}, showWidgets = true, - profileIdentityExists = flowOf(false), onOpenWidgetsHome = { openWidgetsHome.value = true }, ) if (openWidgetsHome.value) { @@ -144,7 +142,6 @@ class DrawerMenuWidgetsTest { profileIdentityExists = flowOf(false), onBeforeNavigate = {}, showWidgets = false, - profileIdentityExists = flowOf(false), onOpenWidgetsHome = { error("Should not request home widgets page") }, onOpenWidgetsSheet = { openWidgetsSheet.value = true }, ) @@ -182,10 +179,9 @@ class DrawerMenuWidgetsTest { rootNavController = navController, hasSeenWidgetsIntro = true, hasSeenShopIntro = true, - profileIdentityExists = flowOf(false), + profileIdentityExists = flowOf(true), onBeforeNavigate = {}, showWidgets = true, - profileIdentityExists = flowOf(true), isPaykitEnabled = true, ) } @@ -208,10 +204,9 @@ class DrawerMenuWidgetsTest { rootNavController = navController, hasSeenWidgetsIntro = true, hasSeenShopIntro = true, - profileIdentityExists = flowOf(false), + profileIdentityExists = flowOf(true), onBeforeNavigate = {}, showWidgets = true, - profileIdentityExists = flowOf(true), isPaykitEnabled = false, ) } diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingObservationDeviceTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingObservationDeviceTest.kt index 99c478d7f8..3828423194 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingObservationDeviceTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingObservationDeviceTest.kt @@ -106,8 +106,6 @@ class SendPendingObservationDeviceTest { settingsStore, pubkyRepo, contactPaymentSettingsRepo, - publicPaykitRepo, - privatePaykitRepo, widgetsStore, widgetsRepo, ) From fea3f42df5823d3da083ac274aa1461a01d78108 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 02:48:04 +0200 Subject: [PATCH 025/108] fix: validate original retry inputs through native preparation --- .../to/bitkit/repositories/LightningRepo.kt | 7 +++-- .../bitkit/repositories/LightningRepoTest.kt | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index dd2b3167c8..c3c2aae807 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -52,6 +52,7 @@ import org.lightningdevkit.ldknode.CoinSelectionAlgorithm import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.NodeStatus +import org.lightningdevkit.ldknode.OutPoint import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentHash import org.lightningdevkit.ldknode.PaymentId @@ -1649,10 +1650,10 @@ class LightningRepo @Inject constructor( feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?, ): PreparedOnchainSend { val inputs = requireNotNull(attempt.originalInputs) - val outputs = lightningService.listSpendableOutputs().getOrThrow() + // The saved signed receipt is the input identity. Native preparation resolves + // the actual values and spendability; never replace missing inputs from a fresh list. val selected = inputs.map { input -> - outputs.singleOrNull { it.outpoint.txid == input.txid && it.outpoint.vout == input.vout } - ?: throw OnchainSendBlockedError(attempt) + SpendableUtxo(OutPoint(input.txid, input.vout), 0uL) } return lightningService.prepareOnchainSend( attempt.address, diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 59c47fd9d5..cd37b1d086 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -1674,6 +1674,34 @@ class LightningRepoTest : BaseUnitTest() { OnchainPreparedReceipt(outcome.txid, listOf(OnchainSendInput("11".repeat(32), 0u)), "address", 1_000uL), ) { outcome } + @Test + fun `original retry delegates saved outpoints to native validation when fresh outputs are absent`() = test { + startNodeForTesting() + val originalInputs = listOf(OnchainSendInput("11".repeat(32), 0u), OnchainSendInput("22".repeat(32), 1u)) + val original = pendingSendAttempt().copy( + evidence = OnchainSendEvidence.Unknown, + txid = "aa".repeat(32), + originalInputs = originalInputs, + candidateTxids = listOf("aa".repeat(32)), + ) + whenever(onchainSendAttemptStore.current()).thenReturn(original) + whenever(lightningService.listSpendableOutputs()).thenReturn(Result.success(emptyList())) + whenever(lightningService.prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), anyOrNull())) + .thenThrow(IllegalStateException("fixture stops after exact preparation request")) + var authorizations = 0 + val result = sut.retryOriginalOnchainSend(original.attemptId, original.walletId, 4uL) { + authorizations++ + } + assertTrue(result.isFailure) + assertEquals(0, authorizations) + verify(lightningService).prepareOnchainSend( + eq(original.address), eq(original.amountSats), eq(4uL), + argThat { map { OnchainSendInput(it.outpoint.txid, it.outpoint.vout) } == originalInputs }, + eq(false), eq(original.walletIndex), isNull(), + ) + verify(onchainSendAttemptStore, never()).broadcastPreparedCandidate(any(), any(), any(), any()) + } + private fun pendingSendAttempt() = OnchainSendAttempt( walletId = "test-wallet", attemptId = "attempt-1", requestId = null, orderId = null, address = "address", amountSats = 1_000uL, isMaxAmount = false, feeRateSatsPerVByte = 1uL, From 2cb41e083e8b5e7404af728674e636e62d451900 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 03:12:21 +0200 Subject: [PATCH 026/108] fix: keep payment recovery reachable when sync fails --- .../java/to/bitkit/repositories/LightningRepo.kt | 8 ++++++++ .../to/bitkit/repositories/LightningRepoTest.kt | 15 +++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index c3c2aae807..5f8e02eef9 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1507,6 +1507,14 @@ class LightningRepo @Inject constructor( ): Result = executeWhenNodeRunning("sendOnChain") { require(address.isNotEmpty()) { "Send address cannot be empty" } + // Recovery must remain reachable when observing the original transaction makes sync fail. + val retainedAttempt = runSuspendCatching { onchainSendAttemptStore.current() }.getOrElse { + return@executeWhenNodeRunning Result.failure(it) + } + if (retainedAttempt?.blocksNextSend == true) { + return@executeWhenNodeRunning Result.failure(OnchainSendBlockedError(retainedAttempt)) + } + // Ensure wallet is synced before sending to have up-to-date state ensureSyncedBeforeSend().onFailure { return@executeWhenNodeRunning Result.failure(it) diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index cd37b1d086..c24a4ae907 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -1702,6 +1702,21 @@ class LightningRepoTest : BaseUnitTest() { verify(onchainSendAttemptStore, never()).broadcastPreparedCandidate(any(), any(), any(), any()) } + @Test + fun `unresolved original opens recovery before an unhealthy sync blocks another send`() = test { + startNodeForTesting() + whenever(connectivityRepo.isOnline).thenReturn(MutableStateFlow(ConnectivityState.DISCONNECTED)) + whenever(lightningService.sync()).thenThrow(RuntimeException("exact original observation unavailable")) + val original = pendingSendAttempt().copy(evidence = OnchainSendEvidence.Unknown) + whenever(onchainSendAttemptStore.current()).thenReturn(original) + + val result = sut.sendOnChain("different-address", 2_000uL) + + val blocked = assertIs(result.exceptionOrNull()) + assertEquals(original, blocked.attempt) + verify(onchainSendAttemptStore, never()).admit(any(), anyOrNull(), anyOrNull(), any(), any(), any(), any(), any(), anyOrNull(), any(), anyOrNull(), any(), anyOrNull()) + } + private fun pendingSendAttempt() = OnchainSendAttempt( walletId = "test-wallet", attemptId = "attempt-1", requestId = null, orderId = null, address = "address", amountSats = 1_000uL, isMaxAmount = false, feeRateSatsPerVByte = 1uL, From 59df4f8e0ce5b66b8bdc3ec162545239940cbd16 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 03:35:04 +0200 Subject: [PATCH 027/108] fix: preserve original payment results during recovery --- .../to/bitkit/repositories/LightningRepo.kt | 8 +++- .../java/to/bitkit/ui/sheets/SendSheet.kt | 3 +- .../bitkit/repositories/LightningRepoTest.kt | 38 +++++++++++++++++++ .../wallets/send/HwSendViewModelTest.kt | 5 +++ 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 5f8e02eef9..86d82ade1e 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1610,7 +1610,9 @@ class LightningRepo @Inject constructor( ) }.getOrElse { error -> val winner = runSuspendCatching { onchainSendAttemptStore.current() }.getOrNull() - if (winner?.attemptId == attempt.attemptId && winner.hasPositiveEvidence) { + if (winner?.attemptId == attempt.attemptId && winner.walletId == attempt.walletId && + winner.hasPositiveEvidence + ) { OnchainSendOutcome.Accepted(requireNotNull(winner.txid)) } else { return@executeWhenNodeRunning Result.failure(OnchainSendPendingError(error, prepared.receipt.txid)) @@ -1620,7 +1622,9 @@ class LightningRepo @Inject constructor( onchainSendAttemptStore.recordOutcome(attempt.attemptId, outcome, attempt.walletIndex) }.getOrElse { error -> val winner = runSuspendCatching { onchainSendAttemptStore.current() }.getOrNull() - if (winner?.hasPositiveEvidence == true) { + if (winner?.attemptId == attempt.attemptId && winner.walletId == attempt.walletId && + winner.hasPositiveEvidence + ) { winner } else { if (outcome !is OnchainSendOutcome.Accepted || !outcome.txid.equals(prepared.receipt.txid, true)) { diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 53a352f861..3116861eaf 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -171,7 +171,8 @@ fun SendSheet( isOnchain = true, ) ) { popUpTo(navController.graph.id) { inclusive = true } } - hwSendViewModel.completeBroadcast() + // Proof persistence may have failed. Keep the exact result replayable on reopening + // and retain the signed operation so another request cannot sign or broadcast. return@collect } appViewModel.onSendSuccess( diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index c24a4ae907..498c3ea70c 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -1670,6 +1670,44 @@ class LightningRepoTest : BaseUnitTest() { verify(onchainSendAttemptStore, never()).releaseBeforeDispatch(any(), any()) } + @Test + fun `direct send fallback cannot use a later attempt winner`() = test { + assertDirectSendFallbackIdentity(pendingSendAttempt().copy(attemptId = "later-attempt"), false) + } + + @Test + fun `direct send fallback cannot use another wallet winner`() = test { + assertDirectSendFallbackIdentity(pendingSendAttempt().copy(walletId = "another-wallet"), false) + } + + @Test + fun `direct send fallback retains its exact observed winner`() = test { + assertDirectSendFallbackIdentity(pendingSendAttempt(), true) + } + + private suspend fun assertDirectSendFallbackIdentity(winnerIdentity: OnchainSendAttempt, matches: Boolean) { + val original = pendingSendAttempt() + val repo = prepareGuardedSend(original) + val originalTxid = "ab".repeat(32) + val winnerTxid = "cd".repeat(32) + whenever(lightningService.prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), anyOrNull())) + .thenReturn(preparedOutcome(OnchainSendOutcome.Unknown(originalTxid))) + whenever(onchainSendAttemptStore.current()).thenReturn(null).thenReturn( + winnerIdentity.copy(evidence = OnchainSendEvidence.Observed, txid = winnerTxid), + ) + whenever(onchainSendAttemptStore.recordOutcome(any(), any(), any())) + .thenThrow(IllegalStateException("original outcome raced with durable observation")) + val broadcasts = mutableListOf() + val result = repo.sendOnChain("address", 1_000uL, isTransfer = true, onBroadcast = { broadcasts += it }) + if (matches) { + assertEquals(OnchainSendOutcome.Accepted(winnerTxid), result.getOrThrow()) + assertEquals(listOf(winnerTxid), broadcasts) + } else { + assertIs(result.exceptionOrNull()) + assertTrue(broadcasts.isEmpty()) + } + } + private fun preparedOutcome(outcome: OnchainSendOutcome) = PreparedOnchainSend( OnchainPreparedReceipt(outcome.txid, listOf(OnchainSendInput("11".repeat(32), 0u)), "address", 1_000uL), ) { outcome } diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt index 8bb99726ac..9c008672aa 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt @@ -624,6 +624,11 @@ class HwSendViewModelTest : BaseUnitTest() { assertEquals(originalId, sut.results.first().paymentRequestId) assertEquals("original-identity", sut.results.first().paymentIdentity) + // A failed proof write keeps the result: closing/reopening must replay its exact identity. + sut.cancel() + assertEquals(fixture.broadcast.txId, sut.results.first().txId) + assertEquals(originalId, sut.results.first().paymentRequestId) + assertTrue(sut.uiState.value.isBroadcastUnresolved) // Local proof work has not consumed the result yet: neither this request nor another may resend. sut.signAndBroadcast(original) sut.signAndBroadcast(original.copy(paymentRequestId = originalId.copy(paymentRequestId = "different-request"))) From 5843da22ebc72e3f0757b45f2ecc042767854c36 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 03:38:45 +0200 Subject: [PATCH 028/108] fix: retain proofs with unknown app provenance --- .../bitkit/models/PaykitPaymentStateBackup.kt | 2 +- .../repositories/PaykitPaymentProofRepo.kt | 4 +++- .../models/PaykitPaymentStateBackupTest.kt | 17 +++++++++++++++++ .../repositories/PaykitPaymentProofRepoTest.kt | 10 ++++++++++ 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt index be6d462cf0..43fbc1c07e 100644 --- a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt +++ b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt @@ -43,7 +43,7 @@ data class PaykitPaymentStateBackup( val identity: String, val requestId: PaykitPaymentRequestId, val paymentEndpointIdentifier: String, - val paymentAppId: String, + val paymentAppId: String = "", val kind: String, val paymentStarted: Boolean, val paymentIdentifier: String? = null, diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 8ed7b91ad4..0b37be02be 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -65,7 +65,7 @@ data class PendingPaykitPaymentProof( val identity: String, val requestId: PaykitPaymentRequestId, val paymentEndpointIdentifier: String, - val paymentAppId: String, + val paymentAppId: String = "", val kind: PaykitPaymentProofKind, val paymentStarted: Boolean = false, val paymentIdentifier: String? = null, @@ -868,6 +868,8 @@ class PaykitPaymentProofRepo @Inject constructor( ?.let(PubkyPublicKeyFormat::normalized) private suspend fun submitReady(proof: PendingPaykitPaymentProof): Boolean { + // Missing legacy app provenance must remain readable, never guessed for delivery. + if (proof.paymentAppId.isBlank()) return false if (proof.kind == PaykitPaymentProofKind.Onchain && proof.onchainAcceptanceVerified != true) return false val proofData = proof.proofData ?: return false val identityStatus = paykitSdkService.identityStatus() diff --git a/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt index f7f8d498fb..b3da7c259e 100644 --- a/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/PaykitPaymentStateBackupTest.kt @@ -16,6 +16,23 @@ import kotlin.time.ExperimentalTime import kotlin.time.Instant class PaykitPaymentStateBackupTest { + @Test + fun `proofs without an app id remain readable without inventing provenance`() { + WalletScope.pushTestOverride("wallet0").use { + val local = Json.decodeFromString( + """{"identity":"alice","requestId":{"paymentRequestId":"request","counterparty":"bob"},"paymentEndpointIdentifier":"bitcoin-onchain","kind":"Onchain","paymentStarted":true}""", + ) + assertEquals("", local.paymentAppId) + assertTrue(local.paymentStarted) + assertFalse(local.onchainAcceptanceVerified) + val backup = Json.decodeFromString( + """{"identity":"alice","requestId":{"paymentRequestId":"request","counterparty":"bob"},"paymentEndpointIdentifier":"bitcoin-onchain","kind":"bitcoin-onchain-txid","paymentStarted":true,"onchainMatchingTransactionIdsBeforeAttempt":[]}""", + ) + assertEquals("", backup.restored().paymentAppId) + assertEquals(local.requestId, backup.restored().requestId) + assertFalse(backup.restored().onchainAcceptanceVerified) + } + } @Test fun `shared active operation survives payment backup reader writer roundtrip`() { val wire = """ diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 7ffe3dac5c..e39daf705c 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -134,6 +134,16 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(keychain.delete(any())).doSuspendableAnswer { storedProjectionJson = null } } + @Test + fun `proof without original app provenance stays retained and is not submitted`() = test { + storedProofs = listOf(readyLightningProof(PAYMENT_REQUEST_ID).copy(paymentAppId = "")) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + paymentProofRepo().reconcile() + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + assertEquals("", storedProofs.single().paymentAppId) + assertTrue(storedProofs.single().paymentStarted) + } + @Test fun `unstarted proof preparation notifies backup without refreshing requests`() = test { observeRequestStateChanges() From 875a3cb7db825ef36cf2d4f0f19e3e51cd1b416f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 04:14:53 +0200 Subject: [PATCH 029/108] fix: release expired payments before first broadcast --- .../to/bitkit/repositories/LightningRepo.kt | 6 ++ .../repositories/OnchainSendAttemptStore.kt | 24 ++++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 1 + .../OnchainSendAttemptStoreTest.kt | 95 +++++++++++++++++++ 4 files changed, 125 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 86d82ade1e..aee8800526 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1609,6 +1609,12 @@ class LightningRepo @Inject constructor( prepared::broadcast, ) }.getOrElse { error -> + if (error is OnchainSendNotDispatchedError) { + runSuspendCatching { + onchainSendAttemptStore.releaseBeforeDispatch(attempt.attemptId, attempt.walletIndex) + }.onFailure { Logger.warn("Failed to clear expired preparation", it, context = TAG) } + return@executeWhenNodeRunning Result.failure(error) + } val winner = runSuspendCatching { onchainSendAttemptStore.current() }.getOrNull() if (winner?.attemptId == attempt.attemptId && winner.walletId == attempt.walletId && winner.hasPositiveEvidence diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index 9cd7318b0a..47f75f1a4e 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -19,6 +19,7 @@ import to.bitkit.models.ActiveOnchainAttemptBackup import to.bitkit.models.WalletScope import to.bitkit.services.LightningService import to.bitkit.utils.AppError +import to.bitkit.utils.ServiceError import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -135,6 +136,8 @@ class OnchainSendAttemptStore @Inject constructor( // One known positive result per existing wallet guard; entries disappear after a successful durable write. private val retainedPositive = mutableMapOf() private val inFlightPreparations = mutableSetOf() + // Process-local proof that this initial receipt has never entered the native submission boundary. + private val firstSubmissions = mutableSetOf() suspend fun current(): OnchainSendAttempt? = withContext(ioDispatcher) { mutex.withLock { loadWithRetainedAccepted(lightningService.currentWalletIndex) } @@ -267,6 +270,7 @@ class OnchainSendAttemptStore @Inject constructor( ).also { persist(it) inFlightPreparations -= attemptId + if (!isRecovery) firstSubmissions += attemptId } } } @@ -284,7 +288,25 @@ class OnchainSendAttemptStore @Inject constructor( } if (current.hasPositiveEvidence) return@withLock OnchainSendOutcome.Accepted(requireNotNull(current.txid)) require(current.txid.equals(txid, ignoreCase = true) && txid.lowercase() in current.candidateTxids) - broadcast() + val firstSubmission = firstSubmissions.remove(attemptId) + try { + broadcast() + } catch (error: ServiceError.PaymentDeadlineExpired) { + // Only the queued deadline check raises this before prepared.broadcast(). A retry, + // reopened guard or any earlier dispatch has no process-local first-submission proof. + if (!firstSubmission || current.restoredFromBackup || + current.evidence != OnchainSendEvidence.Pending || current.candidateTxids.size != 1 + ) throw error + persist(current.copy( + txid = null, + originalInputs = null, + candidateTxids = emptyList(), + candidateFeeRates = emptyMap(), + preparationPending = current.requestId != null, + )) + // Shop proof cleanup still precedes guard deletion through the existing preparation path. + throw OnchainSendNotDispatchedError(error) + } } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 1026713eea..4db06a3916 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4589,6 +4589,7 @@ class AppViewModel @Inject constructor( } releasePrivatePaymentListIfNeeded(contactPaymentContext) cancelPaymentProofPreparation(preparedPaymentProofRequest) + if (error is OnchainSendNotDispatchedError) paykitPaymentProofRepo.reconcile() Logger.error("Error sending onchain payment", error, context = TAG) if (contactPaymentContext?.isInitialSubscriptionPayment == true) { setSendEffect(SendEffect.NavigateToError(error.toSendFailureDetails(context, _sendUiState.value.address))) diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index dadd18834a..52d08d38e8 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -23,6 +23,101 @@ import kotlin.test.assertTrue class OnchainSendAttemptStoreTest : BaseUnitTest() { private val key = Keychain.Key.ONCHAIN_SEND_ATTEMPT.name + @Test + fun `expired first Shop submission retains empty guard until original proof cleanup succeeds`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + whenever(keychain.delete(key, 0)).doSuspendableAnswer { saved = null } + val service = mock() + val store = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) + val attempt = store.admit( + walletId = "wallet0", requestId = PaykitPaymentRequestId("request", "payer"), + orderId = "order", address = "bcrt1qrecipient", amountSats = 1000uL, + isMaxAmount = false, feeRateSatsPerVByte = 1uL, isTransfer = false, + channelId = null, tags = emptyList(), beforeSendAttempt = {}, payerIdentity = "payer", + ) + val receipt = OnchainPreparedReceipt( + "ab".repeat(32), listOf(OnchainSendInput("11".repeat(32), 0u)), attempt.address, attempt.amountSats, + ) + store.retainPreparedReceipt(attempt.attemptId, 0, receipt, false) + assertFailsWith { + store.broadcastPreparedCandidate(attempt.attemptId, 0, receipt.txid) { + throw to.bitkit.utils.ServiceError.PaymentDeadlineExpired() + } + } + store.releaseBeforeDispatch(attempt.attemptId, 0) + val pending = requireNotNull(store.current()) + assertTrue(pending.preparationPending) + assertEquals(attempt.requestId, pending.requestId) + assertEquals(attempt.payerIdentity, pending.payerIdentity) + assertTrue(pending.candidateTxids.isEmpty()) + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) + assertEquals(false, reopened.releaseInterruptedShopPreparation { false }) + assertEquals(pending, reopened.current()) + assertEquals(true, reopened.releaseInterruptedShopPreparation { + assertEquals(pending, it) + true + }) + assertNull(reopened.current()) + } + + @Test + fun `first submission deadline failure clears only never dispatched receipt`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + whenever(keychain.delete(key, 0)).doSuspendableAnswer { saved = null } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val attempt = store.admitForTest() + val receipt = OnchainPreparedReceipt( + "ab".repeat(32), listOf(OnchainSendInput("11".repeat(32), 0u)), attempt.address, attempt.amountSats, + ) + store.retainPreparedReceipt(attempt.attemptId, 0, receipt, false) + assertFailsWith { + store.broadcastPreparedCandidate(attempt.attemptId, 0, receipt.txid) { + throw to.bitkit.utils.ServiceError.PaymentDeadlineExpired() + } + } + store.releaseBeforeDispatch(attempt.attemptId, 0) + assertNull(store.current()) + store.admitForTest() + } + + @Test + fun `deadline on later submission or reopened guard never releases original candidate`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val service = mock() + val store = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) + val attempt = store.admitForTest() + val receipt = OnchainPreparedReceipt( + "ab".repeat(32), listOf(OnchainSendInput("11".repeat(32), 0u)), attempt.address, attempt.amountSats, + ) + val retained = store.retainPreparedReceipt(attempt.attemptId, 0, receipt, false) + store.broadcastPreparedCandidate(attempt.attemptId, 0, receipt.txid) { + OnchainSendOutcome.Unknown(receipt.txid) + } + // Even before outcome persistence, this process knows dispatch was attempted. + assertFailsWith { + store.broadcastPreparedCandidate(attempt.attemptId, 0, receipt.txid) { + throw to.bitkit.utils.ServiceError.PaymentDeadlineExpired() + } + } + assertEquals(retained, store.current()) + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) + assertFailsWith { + reopened.broadcastPreparedCandidate(attempt.attemptId, 0, receipt.txid) { + throw to.bitkit.utils.ServiceError.PaymentDeadlineExpired() + } + } + assertEquals(retained, reopened.current()) + } + @Test fun `preparation cancellation cleanup releases only exact empty guard and never a receipt`() = test { var saved: String? = null From a6ddeb0a1dc9482de640705ad2c3d02d35bc8710 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 06:18:25 +0200 Subject: [PATCH 030/108] fix: retain exact fee for recovered original payments --- .../to/bitkit/repositories/LightningRepo.kt | 6 ++-- .../bitkit/repositories/LightningRepoTest.kt | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index aee8800526..98ba5da5d5 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1719,7 +1719,9 @@ class LightningRepo @Inject constructor( } val txId = requireNotNull(attempt.txid) { "On-chain send has no transaction id" } val isSuccessor = attempt.candidateTxids.isNotEmpty() && !txId.equals(attempt.candidateTxids.first(), true) - val fee = if (isSuccessor) { + val needsExactFee = isSuccessor || + (attempt.candidateTxids.isNotEmpty() && originalFollowup?.feeSats?.toULongOrNull() == 0uL) + val fee = if (needsExactFee) { requireNotNull(lightningService.observedOriginalSendFee(attempt)) { "Winning transaction fee is unavailable" }.also { onchainSendAttemptStore.retainWinningFee(attempt.attemptId, attempt.walletIndex, txId, it) } @@ -1753,7 +1755,7 @@ class LightningRepo @Inject constructor( if (originalContact != null) { coreService.activity.restoreSentOnchainContact(txId, attempt.walletId, originalContact) } - if (isSuccessor) { + if (needsExactFee) { coreService.activity.repairVerifiedSentOnchainFee(txId, attempt.walletId, fee, attempt.winningFeeRateSatsPerVByte) } check(coreService.activity.getOnchainActivityByTxId(txId, attempt.walletId) != null) { diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 498c3ea70c..6cb69379a0 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -1803,6 +1803,38 @@ class LightningRepoTest : BaseUnitTest() { } } + @Test + fun `observed original activity waits for its exact fee instead of displaying zero`() = test { + val txid = "ab".repeat(32) + val attempt = pendingSendAttempt().copy( + evidence = OnchainSendEvidence.Accepted, + txid = txid, + candidateTxids = listOf(txid), + candidateFeeRates = mapOf(txid to 1uL), + backupFollowup = to.bitkit.models.ActiveOnchainAttemptBackup.Followup("0", emptyList(), createdAtMillis = "123"), + ) + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(onchainSendAttemptStore.current()).thenReturn(attempt) + whenever(lightningService.observedOriginalSendFee(attempt)).thenReturn(null) + sut.completeAcceptedOrdinaryFollowup(txid) + verify(activityService, never()).createSentOnchainActivityFromSendResult( + any(), any(), any(), any(), any(), any(), anyOrNull(), any(), + ) + verify(onchainSendAttemptStore, never()).markLocalFollowupComplete(any(), any()) + whenever(lightningService.observedOriginalSendFee(attempt)).thenReturn(141uL) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + whenever(activityService.getOnchainActivityByTxId(txid, attempt.walletId)).thenReturn(mock()) + sut.completeAcceptedOrdinaryFollowup(txid) + verify(activityService).createSentOnchainActivityFromSendResult( + txid, attempt.address, attempt.amountSats, 141uL, 1uL, false, null, attempt.walletId, + ) + verify(activityService).repairVerifiedSentOnchainFee(txid, attempt.walletId, 141uL, 1uL) + verify(onchainSendAttemptStore).retainWinningFee(attempt.attemptId, attempt.walletIndex, txid, 141uL) + verify(onchainSendAttemptStore).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + } + + @Test fun `successor activity fee never reuses original nonzero fee and missing exact fee stays guarded`() = test { val txid = "cd".repeat(32) @@ -1912,6 +1944,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(coreService.activity).thenReturn(activityService) whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) whenever(activityService.getOnchainActivityByTxId(eq(txid), any())).thenReturn(mock()) + whenever(lightningService.observedOriginalSendFee(any())).thenReturn(141uL) whenever(lightningService.prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), paymentDeadlineAt = anyOrNull(), )) From f9315bd15233a03b0c8c6656e429087684a46691 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 06:47:09 +0200 Subject: [PATCH 031/108] fix: keep recovery pending until local completion --- .../wallets/send/SendPendingViewModel.kt | 9 ++++--- .../wallets/send/SendPendingViewModelTest.kt | 26 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt index e10ba62220..f8cb0236e4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt @@ -131,13 +131,16 @@ class SendPendingViewModel @Inject constructor( try { val result = runSuspendCatching { retry(original, feeRateSatsPerVByte).getOrThrow() } result.onSuccess { outcome -> + val completed = runSuspendCatching { lightningRepo.currentOnchainSendAttempt() }.getOrNull() + ?.takeIf { it.hasPositiveEvidence && it.localFollowupComplete && it.matchesOriginalRoute(original) } _uiState.update { if (it.recoveredTxid != null || it.recoveredTransfer != null) return@update it it.copy( - recoveredTxid = (outcome as? OnchainSendOutcome.Accepted)?.txid - .takeUnless { original.isTransfer }, + recoveredTxid = completed?.txid.takeUnless { original.isTransfer }, + recoveredTransfer = completed?.takeIf { original.isTransfer }, + recoveryAttempt = if (completed != null) null else it.recoveryAttempt, recoveryError = (outcome as? OnchainSendOutcome.Rejected)?.reason, - currentTxid = outcome.txid, + currentTxid = completed?.txid ?: outcome.txid, ) } }.onFailure { error -> diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt index 049f9af64a..b71ea62d2f 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt @@ -97,6 +97,32 @@ class SendPendingViewModelTest : BaseUnitTest() { assertEquals(original.amountSats, sut.uiState.value.recoveryAttempt?.amountSats) } + @Test + fun `accepted retry waits for exact durable original completion`() = test { + val original = pendingOriginal() + val winner = "cd".repeat(32) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(original) + sut.initOnchain(original.txid, amount) + advanceUntilIdle() + val accepted = original.copy( + txid = winner, candidateTxids = original.candidateTxids + winner, + evidence = OnchainSendEvidence.Accepted, localFollowupComplete = false, + ) + sut.retryOriginal(2uL) { _, _ -> + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(accepted) + Result.success(OnchainSendOutcome.Accepted(winner)) + } + advanceUntilIdle() + assertNull(sut.uiState.value.recoveredTxid) + assertEquals(winner, sut.uiState.value.currentTxid) + assertEquals(original, sut.uiState.value.recoveryAttempt) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(accepted.copy(localFollowupComplete = true)) + attemptUpdates.value++ + advanceUntilIdle() + assertEquals(winner, sut.uiState.value.recoveredTxid) + assertNull(sut.uiState.value.recoveryAttempt) + } + @Test fun `exact ordinary observed successor resolves only after original local completion`() = test { val original = pendingOriginal() From c4e92cd0d9fb46e2067871696b39be9e49a8cffc Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 06:51:22 +0200 Subject: [PATCH 032/108] fix: finish restored accepted ordinary payments --- .../java/to/bitkit/repositories/BackupRepo.kt | 15 ++++++++--- .../to/bitkit/repositories/BackupRepoTest.kt | 25 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index cfebba5d91..b0a2104d94 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -114,6 +114,7 @@ class BackupRepo @Inject constructor( private val keychain: Keychain, private val onchainSendAttemptStore: OnchainSendAttemptStore, private val transferRepo: Provider, + private val lightningRepo: Provider, private val vssStoreIdProvider: VssStoreIdProvider, private val preActivityMetadataRepo: PreActivityMetadataRepo, private val lightningService: LightningService, @@ -838,9 +839,17 @@ class BackupRepo @Inject constructor( } keychain.delete(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name) // Restore can finish while the node is already Running: no new lifecycle/event is guaranteed. - restoredAttempt?.takeIf { it.isTransfer && it.hasPositiveEvidence }?.let { attempt -> - transferRepo.get().resumeAcceptedFunding(attempt).onFailure { - Logger.warn("Restored accepted funding remains pending local follow-up", it, context = TAG) + restoredAttempt?.takeIf { it.hasPositiveEvidence }?.let { attempt -> + if (attempt.isTransfer) { + transferRepo.get().resumeAcceptedFunding(attempt).onFailure { + Logger.warn("Restored accepted funding remains pending local follow-up", it, context = TAG) + } + } else if (attempt.requestId == null && !attempt.localFollowupComplete) { + runSuspendCatching { + lightningRepo.get().completeAcceptedOrdinaryFollowup(requireNotNull(attempt.txid)) + }.onFailure { + Logger.warn("Restored accepted payment remains pending local follow-up", it, context = TAG) + } } } Logger.debug("Restored ${parsed.transfers.size} transfers", context = TAG) diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index b58562de8e..11813c7cd7 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -86,6 +86,7 @@ class BackupRepoTest : BaseUnitTest() { private val hwWalletStore = mock() private val blocktankRepo = mock() private val activityRepo = mock() + private val lightningRepo = mock() private val pubkyRepo = mock() private val paykitSdkService = mock() private val privatePaykitRepo = mock() @@ -198,6 +199,29 @@ class BackupRepoTest : BaseUnitTest() { verify(transferRepo, times(2)).resumeAcceptedFunding(any()) } + @Test + fun `restored accepted ordinary payment resumes after original context without another node event`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val originalWire = requireNotNull(state.activeOnchainAttempt) + val original = originalWire.restored("regtest", originalWire.wallet.binding, WalletScope.default, 0) + val ordinary = original.copy( + requestId = null, payerIdentity = null, orderId = null, isTransfer = false, + evidence = OnchainSendEvidence.Accepted, + ) + val wire = to.bitkit.models.ActiveOnchainAttemptBackup.from(ordinary, "regtest", originalWire.wallet.binding) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(originalWire.wallet.binding) + stubWalletBackup(paykitPaymentState = state.copy(pendingProofs = emptyList(), activeOnchainAttempt = wire)) + sut.performFullRestoreFromLatestBackup().getOrThrow() + val ordered = org.mockito.kotlin.inOrder(onchainSendAttemptStore, privatePaykitRepo, lightningRepo) + ordered.verify(onchainSendAttemptStore).restoreActive(wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0)) + ordered.verify(privatePaykitRepo).restoreBackup(anyOrNull()) + ordered.verify(lightningRepo).completeAcceptedOrdinaryFollowup(requireNotNull(ordinary.txid)) + stubWalletBackup(paykitPaymentState = state.copy(pendingProofs = emptyList(), activeOnchainAttempt = wire.copy(status = "unknown"))) + sut.performFullRestoreFromLatestBackup().getOrThrow() + verify(lightningRepo, times(1)).completeAcceptedOrdinaryFollowup(any()) + } + @Test fun `wallet backup exports and restores shared active guard before original proof`() = test { val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() @@ -1255,6 +1279,7 @@ class BackupRepoTest : BaseUnitTest() { keychain = keychain, onchainSendAttemptStore = onchainSendAttemptStore, transferRepo = Provider { transferRepo }, + lightningRepo = Provider { lightningRepo }, vssStoreIdProvider = vssStoreIdProvider, preActivityMetadataRepo = preActivityMetadataRepo, lightningService = lightningService, From 7984a64110af43b2b70ad9a5a766c6720413309f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 06:51:22 +0200 Subject: [PATCH 033/108] fix: verify observed hardware payment terms --- .../to/bitkit/repositories/HwWalletRepo.kt | 9 +++++- .../bitkit/repositories/HwWalletRepoTest.kt | 28 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 8640551c7a..4c7bfdb985 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -905,7 +905,14 @@ class HwWalletRepo @Inject constructor( } if (originalAddress != null || originalAmountSats != null) { val address = requireNotNull(originalAddress).also { require(it.isNotBlank()) } - val amount = requireNotNull(originalAmountSats) + val amount = requireNotNull(originalAmountSats).also { require(it > 0uL) } + val recipientOutputs = detail.outputs.filter { it.address == address } + if (recipientOutputs.isEmpty()) return@runSuspendCatching false + val recipientAmount = recipientOutputs.fold(0uL) { total, output -> + require(output.value <= ULong.MAX_VALUE - total) { "Observed recipient amount overflow" } + total + output.value + } + if (recipientAmount != amount) return@runSuspendCatching false val fee = requireNotNull(detail.fee) val rate = requireNotNull(detail.feeRate).also { require(it.isFinite() && it >= 0.0) } activityRepo.completeObservedHardwarePayment(walletId, txid, address, amount, fee, ceil(rate).toULong()) diff --git a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt index 6c9e9fc2f9..65c00b11d4 100644 --- a/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/HwWalletRepoTest.kt @@ -184,6 +184,33 @@ class HwWalletRepoTest : BaseUnitTest() { verify(trezorRepo, never()).broadcastRawTx(any(), org.mockito.kotlin.anyOrNull()) } + @Test + fun `hardware observation refuses outgoing transactions with other recipient terms`() = test { + val txid = "ab".repeat(32) + val address = "original-shop-address" + val amount = 25000uL + whenever(hwWalletStore.loadKnownDevices()).thenReturn(listOf(device)) + val detail = mock { + on { this.txid }.thenReturn(txid) + on { sent }.thenReturn(26000uL) + on { fee }.thenReturn(1000uL) + on { feeRate }.thenReturn(2.0) + } + whenever(trezorRepo.getTransactionDetail("zpubNS", txid, Env.network.toCoreNetwork(), AccountType.NATIVE_SEGWIT)) + .thenReturn(Result.success(detail)) + val sut = createRepo() + listOf( + emptyList(), + listOf(com.synonym.bitkitcore.TxDetailOutput(amount, "script", "different-recipient", false)), + listOf(com.synonym.bitkitcore.TxDetailOutput(amount - 1uL, "script", address, false)), + ).forEach { outputs -> + whenever(detail.outputs).thenReturn(outputs) + assertFalse(sut.observeExactTransaction(HARDWARE_WALLET_ID, txid, address, amount).getOrThrow()) + } + verify(activityRepo, never()).completeObservedHardwarePayment(any(), any(), any(), any(), any(), any()) + verify(trezorRepo, never()).broadcastRawTx(any(), org.mockito.kotlin.anyOrNull()) + } + @Test fun `observed hardware Shop activity failure retries original transaction without broadcast`() = test { val txid = "ab".repeat(32) @@ -195,6 +222,7 @@ class HwWalletRepoTest : BaseUnitTest() { on { sent }.thenReturn(26000uL) on { fee }.thenReturn(1000uL) on { feeRate }.thenReturn(2.0) + on { outputs }.thenReturn(listOf(com.synonym.bitkitcore.TxDetailOutput(amount, "script", address, false))) } whenever(trezorRepo.getTransactionDetail("zpubNS", txid, Env.network.toCoreNetwork(), AccountType.NATIVE_SEGWIT)) .thenReturn(Result.success(detail)) From 9c16edb6d3d7ef28ad3b7d7150b9341651f376e8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 07:16:54 +0200 Subject: [PATCH 034/108] fix: exclude never dispatched shop preparations from backups --- .../java/to/bitkit/repositories/BackupRepo.kt | 24 +++++++++++++-- .../repositories/OnchainSendAttemptStore.kt | 12 ++++++++ .../to/bitkit/repositories/BackupRepoTest.kt | 27 +++++++++++++++++ .../OnchainSendAttemptStoreTest.kt | 30 +++++++++++++++++++ 4 files changed, 91 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index b0a2104d94..c2d97c1d89 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -641,8 +641,28 @@ class BackupRepo @Inject constructor( val watchOnlyAccountSnapshot = watchOnlyAccountStore.backupSnapshot() val walletIndex = backupWalletIndex() - val proofs = paykitPaymentProofRepo.get().backupSnapshot() - val active = onchainSendAttemptStore.backupSnapshot(walletIndex)?.let { attempt -> + val (snapshotAttempt, snapshotProofs) = onchainSendAttemptStore.backupSnapshot(walletIndex) { + paykitPaymentProofRepo.get().backupSnapshot() + } + // No native submission is possible until a signed receipt is durably retained. Do not + // export this process-local preparation or its exact empty started proof as a sent guard. + val preparation = snapshotAttempt?.takeIf { + it.preparationPending && !it.restoredFromBackup && it.requestId != null && + it.evidence == OnchainSendEvidence.Pending && it.txid == null && + it.originalInputs == null && it.candidateTxids.isEmpty() + } + val emptyProof = preparation?.let { attempt -> + snapshotProofs.singleOrNull { + it.requestId == attempt.requestId && it.identity == attempt.payerIdentity && + it.kind == PaykitPaymentProofKind.Onchain.type && it.paymentStarted && + (it.onchainWalletId == null || it.onchainWalletId == attempt.walletId) && + it.onchainAddress == attempt.address && it.onchainAmountSats == attempt.amountSats && + it.paymentIdentifier == null && it.proofData == null && !it.onchainAcceptanceVerified + } + } + check(preparation == null || emptyProof != null) { "Cannot snapshot unmatched Shop preparation" } + val proofs = if (emptyProof == null) snapshotProofs else snapshotProofs - emptyProof + val active = snapshotAttempt?.takeIf { preparation == null }?.let { attempt -> val wire = ActiveOnchainAttemptBackup.from( attempt, Env.network.name.lowercase(), diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index 47f75f1a4e..912747a598 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -15,6 +15,7 @@ import kotlinx.serialization.json.Json import to.bitkit.data.keychain.Keychain import to.bitkit.di.IoDispatcher import to.bitkit.ext.nowMillis +import to.bitkit.models.PaykitPaymentStateBackup import to.bitkit.models.ActiveOnchainAttemptBackup import to.bitkit.models.WalletScope import to.bitkit.services.LightningService @@ -396,6 +397,17 @@ class OnchainSendAttemptStore @Inject constructor( mutex.withLock { loadWithRetainedAccepted(walletIndex)?.takeIf { it.blocksNextSend } } } + // Admission and receipt retention take this mutex before the proof mutex. Capture both + // under the same ordering so a pre-dispatch backup cannot mix an empty guard with a sent proof. + suspend fun backupSnapshot( + walletIndex: Int, + captureProofs: suspend () -> List, + ): Pair> = withContext(ioDispatcher) { + mutex.withLock { + loadWithRetainedAccepted(walletIndex)?.takeIf { it.blocksNextSend } to captureProofs() + } + } + suspend fun restoreActive(attempt: OnchainSendAttempt) = withContext(ioDispatcher + NonCancellable) { mutex.withLock { val existing = loadWithRetainedAccepted(attempt.walletIndex) diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 11813c7cd7..824d69502f 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -119,6 +119,10 @@ class BackupRepoTest : BaseUnitTest() { whenever(db.configDao().getAll()).thenReturn(MutableStateFlow(listOf(to.bitkit.data.entities.ConfigEntity()))) whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow(null)) whenever(onchainSendAttemptStore.backupStateVersion).thenReturn(MutableStateFlow(0L)) + whenever(onchainSendAttemptStore.backupSnapshot(eq(0), any())).doSuspendableAnswer { + val capture = it.getArgument List>(1) + onchainSendAttemptStore.backupSnapshot(0) to capture() + } whenever { transferDao.upsert(any>()) }.thenReturn(Unit) whenever { cacheStore.updateBackupStatus(any(), any()) }.thenReturn(Unit) whenever { cacheStore.update(any()) }.thenReturn(Unit) @@ -222,6 +226,29 @@ class BackupRepoTest : BaseUnitTest() { verify(lightningRepo, times(1)).completeAcceptedOrdinaryFollowup(any()) } + @Test + fun `wallet backup excludes never prepared Shop admission and only its empty proof`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + val attempt = wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0).copy( + evidence = OnchainSendEvidence.Pending, txid = null, originalInputs = null, + candidateTxids = emptyList(), candidateFeeRates = emptyMap(), + preparationPending = true, restoredFromBackup = false, + ) + val original = state.pendingProofs.single().copy(paymentIdentifier = null) + val unrelated = original.copy(paymentAppId = "unrelated-app", identity = "other-payer") + whenever(onchainSendAttemptStore.backupSnapshot(0)).thenReturn(attempt) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + whenever(paykitPaymentProofRepo.backupSnapshot()).thenReturn(listOf(original, unrelated)) + val data = argumentCaptor() + sut.triggerBackup(BackupCategory.WALLET).getOrThrow() + verifyBlocking(vssBackupClient) { putObject(eq(BackupCategory.WALLET.name), data.capture()) } + val exported = json.decodeFromString(data.firstValue.decodeToString()) + assertNull(exported.paykitPaymentState?.activeOnchainAttempt) + assertEquals(listOf(unrelated), exported.paykitPaymentState?.pendingProofs) + } + @Test fun `wallet backup exports and restores shared active guard before original proof`() = test { val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index 52d08d38e8..e2e9c31b40 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.doSuspendableAnswer @@ -23,6 +24,35 @@ import kotlin.test.assertTrue class OnchainSendAttemptStoreTest : BaseUnitTest() { private val key = Keychain.Key.ONCHAIN_SEND_ATTEMPT.name + @Test + fun `backup proof capture blocks receipt retention until the snapshot is complete`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val attempt = store.admitForTest() + val captureEntered = CompletableDeferred() + val finishCapture = CompletableDeferred() + val snapshot = async { + store.backupSnapshot(0) { + captureEntered.complete(Unit) + finishCapture.await() + emptyList() + } + } + captureEntered.await() + val receipt = OnchainPreparedReceipt( + "ab".repeat(32), listOf(OnchainSendInput("11".repeat(32), 0u)), attempt.address, attempt.amountSats, + ) + val retain = async { store.retainPreparedReceipt(attempt.attemptId, 0, receipt, false) } + runCurrent() + assertEquals(false, retain.isCompleted) + finishCapture.complete(Unit) + assertNull(snapshot.await().first?.txid) + assertEquals(receipt.txid, retain.await().txid) + } + @Test fun `expired first Shop submission retains empty guard until original proof cleanup succeeds`() = test { var saved: String? = null From 5a07a0f66442f6eaf91ace00094e47c817f4b7cd Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 07:21:09 +0200 Subject: [PATCH 035/108] fix: retain hardware shop candidate before broadcast --- .../repositories/PaykitPaymentProofRepo.kt | 26 +++++++++ .../screens/wallets/send/HwSendViewModel.kt | 11 ++++ .../to/bitkit/utils/SignedTransactionId.kt | 54 +++++++++++++++++++ .../PaykitPaymentProofRepoTest.kt | 31 +++++++++-- .../wallets/send/HwSendViewModelTest.kt | 29 +++++++++- .../bitkit/utils/SignedTransactionIdTest.kt | 29 ++++++++++ .../hardware-signed-transaction-legacy.hex | 1 + .../resources/hardware-signed-transaction.hex | 1 + 8 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/to/bitkit/utils/SignedTransactionId.kt create mode 100644 app/src/test/java/to/bitkit/utils/SignedTransactionIdTest.kt create mode 100644 app/src/test/resources/hardware-signed-transaction-legacy.hex create mode 100644 app/src/test/resources/hardware-signed-transaction.hex diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 0b37be02be..45448851cf 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -485,6 +485,32 @@ class PaykitPaymentProofRepo @Inject constructor( return true } + suspend fun retainHardwareOnchainCandidate( + requestId: PaykitPaymentRequestId, + walletId: String, + txid: String, + identity: String?, + address: String, + amountSats: ULong, + ): Boolean = withContext(ioDispatcher) { + if (walletId == WalletScope.default || !txid.isHex(HASH_BYTE_COUNT)) return@withContext false + val originalIdentity = identity?.let(PubkyPublicKeyFormat::normalized) ?: return@withContext false + operationMutex.withLock { + if (!PubkyPublicKeyFormat.matches(currentIdentity(), originalIdentity)) return@withLock false + val proofs = loadProofs() + val original = proofs.singleOrNull { + PubkyPublicKeyFormat.matches(it.identity, originalIdentity) && it.requestId == requestId && + it.onchainWalletId == walletId && it.kind == PaykitPaymentProofKind.Onchain && + it.paymentStarted && it.onchainAddress == address && it.onchainAmountSats == amountSats && + it.proofData == null && !it.onchainAcceptanceVerified && + (it.paymentIdentifier == null || it.paymentIdentifier.equals(txid, true)) + } ?: return@withLock false + // Lookup identity only: never acceptance, a Sent activity, or a delivered proof. + persist(proofs.map { if (it == original) it.copy(paymentIdentifier = txid.lowercase()) else it }) + true + } + } + suspend fun completeHardwareOnchainPayment( requestId: PaykitPaymentRequestId, walletId: String, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt index 347ef9a45e..644e418d05 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt @@ -36,6 +36,8 @@ import to.bitkit.repositories.HwWalletMismatchError import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.PreActivityMetadataRepo import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.repositories.PaykitPaymentProofRepo +import to.bitkit.utils.SignedTransactionId import to.bitkit.services.CoreService import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.HwErrorPresenter @@ -53,6 +55,7 @@ class HwSendViewModel @Inject constructor( private val preActivityMetadataRepo: PreActivityMetadataRepo, private val coreService: CoreService, private val activityRepo: ActivityRepo, + private val paykitPaymentProofRepo: PaykitPaymentProofRepo, private val clock: Clock = Clock.System, ) : ViewModel() { private companion object { @@ -165,6 +168,14 @@ class HwSendViewModel @Inject constructor( payment: PendingHwSendBroadcast, onPaymentDeadlineExpired: suspend (Boolean) -> Unit, ): HwFundingBroadcastResult? { + payment.request.paymentRequestId?.let { requestId -> + val request = payment.request + val retained = paykitPaymentProofRepo.retainHardwareOnchainCandidate( + requestId, request.walletId, SignedTransactionId.fromHex(payment.signedTx.serializedTx), + request.paymentIdentity, request.address, request.amountSats, + ) + if (!retained) return null + } _uiState.update { it.copy(isBroadcastUnresolved = true) } pendingBroadcast = payment.copy(hasAttemptedBroadcast = true) return withTimeout(BROADCAST_TIMEOUT) { diff --git a/app/src/main/java/to/bitkit/utils/SignedTransactionId.kt b/app/src/main/java/to/bitkit/utils/SignedTransactionId.kt new file mode 100644 index 0000000000..1201764b1a --- /dev/null +++ b/app/src/main/java/to/bitkit/utils/SignedTransactionId.kt @@ -0,0 +1,54 @@ +package to.bitkit.utils + +import java.security.MessageDigest + +/** Canonical Bitcoin txid of a consensus-encoded signed transaction, excluding witness data. */ +internal object SignedTransactionId { + fun fromHex(hex: String): String { + require(hex.length in 20..2_000_000 && hex.length % 2 == 0) + val bytes = hex.hexToByteArray() + var offset = 4 + fun skip(length: Int) { + require(length >= 0 && length <= bytes.size - offset) + offset += length + } + fun compactSize(): Int { + require(offset < bytes.size) + val prefix = bytes[offset++].toInt() and 255 + if (prefix < 253) return prefix + val length = when (prefix) { 253 -> 2; 254 -> 4; else -> 8 } + require(length <= bytes.size - offset) + var value = 0uL + repeat(length) { value = value or ((bytes[offset++].toInt() and 255).toULong() shl (8 * it)) } + require(value <= Int.MAX_VALUE.toULong()) + return value.toInt() + } + val hasWitness = bytes[offset] == 0.toByte() + if (hasWitness) { + require(bytes[offset + 1] == 1.toByte()) + skip(2) + } + val baseStart = offset + val inputCount = compactSize() + require(inputCount > 0 && inputCount <= bytes.size / 41) + repeat(inputCount) { skip(36); skip(compactSize()); skip(4) } + val outputCount = compactSize() + require(outputCount > 0 && outputCount <= bytes.size / 9) + repeat(outputCount) { skip(8); skip(compactSize()) } + val baseEnd = offset + if (hasWitness) { + repeat(inputCount) { + val itemCount = compactSize() + require(itemCount <= bytes.size - offset) + repeat(itemCount) { skip(compactSize()) } + } + } + val lockTimeStart = offset + skip(4) + require(offset == bytes.size) + val canonical = bytes.copyOfRange(0, 4) + bytes.copyOfRange(baseStart, baseEnd) + + bytes.copyOfRange(lockTimeStart, offset) + val sha256 = MessageDigest.getInstance("SHA-256") + return sha256.digest(sha256.digest(canonical)).reversedArray().toHexString() + } +} diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index e39daf705c..c865d3b5b7 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -1441,6 +1441,31 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } } + @Test + fun `prebroadcast hardware candidate survives repository reopen without claiming acceptance`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val walletId = "original-hardware-wallet" + val txid = "ab".repeat(32) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + assertFalse(repo.retainHardwareOnchainCandidate(request.id, walletId, txid, LOCAL_IDENTITY, "other-address", request.amountSats)) + assertFalse(repo.retainHardwareOnchainCandidate(request.id, walletId, txid, LOCAL_IDENTITY, ONCHAIN_ADDRESS, request.amountSats + 1uL)) + assertTrue(repo.retainHardwareOnchainCandidate(request.id, walletId, txid, LOCAL_IDENTITY, ONCHAIN_ADDRESS, request.amountSats)) + assertEquals(txid, storedProofs.single().paymentIdentifier) + assertNull(storedProofs.single().proofData) + assertFalse(storedProofs.single().onchainAcceptanceVerified) + assertFalse(repo.retainHardwareOnchainCandidate(request.id, walletId, "cd".repeat(32), LOCAL_IDENTITY, ONCHAIN_ADDRESS, request.amountSats)) + whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)).thenReturn(Result.success(true)) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())).thenReturn(paymentRequestRecord()) + paymentProofRepo().reconcile() + verify(paykitSdkService).submitPaymentProof(any(), any(), any(), eq(MethodId.P2wpkh.rawValue), + eq("""{"data":"$txid","type":"bitcoin-onchain-txid"}"""), isNull()) + assertTrue(storedProofs.isEmpty()) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + } + @Test fun `hardware core txid remains pending until fresh exact observation then resumes original proof`() = test { val request = paymentRequest(MethodId.P2wpkh.rawValue) @@ -1551,7 +1576,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(context.getString(any())).thenReturn("message") val connected = mock() val funding = HwFundingTransaction("psbt", 1000uL, 2.0f, 2000uL, 2uL) - val signed = HwFundingSignedTx("signed-fixture-tx", 1000uL, 2uL, 2000uL) + val signed = HwFundingSignedTx(requireNotNull(javaClass.getResourceAsStream("/hardware-signed-transaction.hex")).bufferedReader().readText().trim(), 1000uL, 2uL, 2000uL) whenever(hwWalletRepo.needsPassphrase(walletId)).thenReturn(false) whenever(hwWalletRepo.reconnectTimeout(walletId)).thenReturn(30.seconds) whenever(hwWalletRepo.ensureConnected(walletId)).thenReturn(Result.success(connected)) @@ -1560,7 +1585,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(hwWalletRepo.signFunding(walletId, funding)).thenReturn(Result.success(signed)) whenever(hwWalletRepo.broadcastFunding(signed)) .thenReturn(Result.failure(BroadcastException.ElectrumException("response lost after dispatch"))) - val send = HwSendViewModel(context, hwWalletRepo, mock(), mock(), mock()) + val send = HwSendViewModel(context, hwWalletRepo, mock(), mock(), mock(), repo) send.signAndBroadcast(HwSendRequest(walletId, ONCHAIN_ADDRESS, request.amountSats, 2uL, emptyList(), request.id, LOCAL_IDENTITY), prepareContactPayment = { repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() @@ -1570,7 +1595,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertFalse(send.uiState.value.isSigning) assertFalse(send.uiState.value.isBroadcastUnresolved) assertTrue(storedProofs.single().paymentStarted) - assertNull(storedProofs.single().paymentIdentifier) + assertEquals("605fe246a6d51450ecff51ac3d0415f8824964e06a60ed6e186fa163cf1e9d4e", storedProofs.single().paymentIdentifier) // Both generic failure and preparation cancellation must preserve an already dispatched Shop payment. repo.failOnchainPayment(request) diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt index 9c008672aa..0572ce807d 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt @@ -32,6 +32,7 @@ import to.bitkit.repositories.HwWalletMismatchError import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.PreActivityMetadataRepo import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.repositories.PaykitPaymentProofRepo import to.bitkit.services.ActivityService import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest @@ -55,6 +56,7 @@ class HwSendViewModelTest : BaseUnitTest() { private val coreService = mock() private val activityService = mock() private val activityRepo = mock() + private val proofRepo = mock() private lateinit var sut: HwSendViewModel private var now = Instant.parse("2026-10-06T11:59:59Z") @@ -62,6 +64,8 @@ class HwSendViewModelTest : BaseUnitTest() { @Before fun setUp() { whenever(coreService.activity).thenReturn(activityService) + whenever { proofRepo.retainHardwareOnchainCandidate(any(), any(), any(), org.mockito.kotlin.anyOrNull(), any(), any()) } + .thenReturn(true) whenever { hwWalletRepo.reconnectTimeout(any()) }.thenReturn(30.seconds) sut = HwSendViewModel( context = context, @@ -69,6 +73,7 @@ class HwSendViewModelTest : BaseUnitTest() { preActivityMetadataRepo = preActivityMetadataRepo, coreService = coreService, activityRepo = activityRepo, + paykitPaymentProofRepo = proofRepo, clock = object : Clock { override fun now() = now }, @@ -85,7 +90,7 @@ class HwSendViewModelTest : BaseUnitTest() { satsPerVByte = 2uL, ) val signedTx = HwFundingSignedTx( - serializedTx = "rawtx", + serializedTx = signedFixtureHex(), miningFeeSats = funding.miningFeeSats, feeRate = 2uL, totalSpent = funding.totalSpent, @@ -594,7 +599,7 @@ class HwSendViewModelTest : BaseUnitTest() { satsPerVByte = SATS_PER_VBYTE, ) val signedTx = HwFundingSignedTx( - serializedTx = "rawtx", + serializedTx = signedFixtureHex(), miningFeeSats = funding.miningFeeSats, feeRate = SATS_PER_VBYTE, totalSpent = funding.totalSpent, @@ -614,6 +619,23 @@ class HwSendViewModelTest : BaseUnitTest() { return PaymentFixture(funding, signedTx, broadcast) } + @Test + fun `hardware Shop never broadcasts when candidate persistence fails`() = test { + val fixture = stubSuccessfulPayment() + val original = request().copy(paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity") + whenever { proofRepo.retainHardwareOnchainCandidate(any(), any(), any(), org.mockito.kotlin.anyOrNull(), any(), any()) } + .thenReturn(false) + sut.signAndBroadcast(original) + advanceUntilIdle() + verify(hwWalletRepo, never()).broadcastFunding(fixture.signedTx) + verify(proofRepo).retainHardwareOnchainCandidate( + requireNotNull(original.paymentRequestId), WALLET_ID, + "605fe246a6d51450ecff51ac3d0415f8824964e06a60ed6e186fa163cf1e9d4e", + original.paymentIdentity, ADDRESS, AMOUNT_SATS, + ) + } + @Test fun `core completed hardware result retains original request and never rebroadcasts while proof is pending`() = test { val fixture = stubSuccessfulPayment() @@ -661,6 +683,9 @@ class HwSendViewModelTest : BaseUnitTest() { ) } + private fun signedFixtureHex() = requireNotNull(javaClass.getResourceAsStream("/hardware-signed-transaction.hex")) + .bufferedReader().readText().trim() + private fun request() = HwSendRequest( walletId = WALLET_ID, address = ADDRESS, diff --git a/app/src/test/java/to/bitkit/utils/SignedTransactionIdTest.kt b/app/src/test/java/to/bitkit/utils/SignedTransactionIdTest.kt new file mode 100644 index 0000000000..ea02f8ff9f --- /dev/null +++ b/app/src/test/java/to/bitkit/utils/SignedTransactionIdTest.kt @@ -0,0 +1,29 @@ +package to.bitkit.utils + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class SignedTransactionIdTest { + private fun fixture() = requireNotNull(javaClass.getResourceAsStream("/hardware-signed-transaction.hex")) + .bufferedReader().readText().trim() + + @Test + fun `matches independently observed backend transaction and ignores witness bytes`() { + val original = fixture() + val expected = "605fe246a6d51450ecff51ac3d0415f8824964e06a60ed6e186fa163cf1e9d4e" + assertEquals(expected, SignedTransactionId.fromHex(original)) + val withoutWitness = requireNotNull(javaClass.getResourceAsStream("/hardware-signed-transaction-legacy.hex")) + .bufferedReader().readText().trim() + assertEquals(expected, SignedTransactionId.fromHex(withoutWitness)) + val witnessChanged = original.hexToByteArray().also { it[it.size - 5] = (it[it.size - 5].toInt() xor 1).toByte() } + assertEquals(expected, SignedTransactionId.fromHex(witnessChanged.toHexString())) + } + + @Test + fun `rejects truncated or trailing transaction data before persistence`() { + assertFailsWith { SignedTransactionId.fromHex(fixture().dropLast(2)) } + assertFailsWith { SignedTransactionId.fromHex(fixture() + "00") } + assertFailsWith { SignedTransactionId.fromHex("not-a-transaction") } + } +} diff --git a/app/src/test/resources/hardware-signed-transaction-legacy.hex b/app/src/test/resources/hardware-signed-transaction-legacy.hex new file mode 100644 index 0000000000..d0e9dfd5f2 --- /dev/null +++ b/app/src/test/resources/hardware-signed-transaction-legacy.hex @@ -0,0 +1 @@ +0200000001f7c5a048189164c6b05b07516b5dbb9c826c601d12dc4ed97f0069618b8b7c160100000000fdffffff024179010000000000160014f066a63663b0d464b31a7a88619beae011c3fb7be80300000000000016001483ea855bb508cb08ed9e8cf9152d8927871c19aa724c0300 diff --git a/app/src/test/resources/hardware-signed-transaction.hex b/app/src/test/resources/hardware-signed-transaction.hex new file mode 100644 index 0000000000..f705338546 --- /dev/null +++ b/app/src/test/resources/hardware-signed-transaction.hex @@ -0,0 +1 @@ +02000000000101f7c5a048189164c6b05b07516b5dbb9c826c601d12dc4ed97f0069618b8b7c160100000000fdffffff024179010000000000160014f066a63663b0d464b31a7a88619beae011c3fb7be80300000000000016001483ea855bb508cb08ed9e8cf9152d8927871c19aa02473044022052c5a15ade616af16f314bcc2ae15bf4ef4996e0f2315794e647ba6c955745b602200f3095f4a7deb39a94716c0fd2001a2fbff1861a8ff0c2015739a40a62891c22012102cb13c86b55418d0e3bccf29115394e1fb6a9f209d3f59dc9bbb0805b253464cb724c0300 From d864283b1a8bf8bb83c6903ea3e244f0e6b93b80 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 07:50:15 +0200 Subject: [PATCH 036/108] fix: preserve payment state across backup uploads --- .../java/to/bitkit/repositories/BackupRepo.kt | 29 ++++----- .../to/bitkit/repositories/BackupRepoTest.kt | 60 ++++++++++++++++--- 2 files changed, 63 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index c2d97c1d89..50c28637d5 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -422,7 +422,7 @@ class BackupRepo @Inject constructor( scope.launch { failedBackupRequired -= category cacheStore.updateBackupStatus(category) { - it.copy(required = currentTimeMillis()) + it.copy(required = maxOf(currentTimeMillis(), it.required + 1, it.synced + 1)) } Logger.verbose("Marked backup required for: '$category'", context = TAG) } @@ -506,10 +506,11 @@ class BackupRepo @Inject constructor( suspend fun triggerBackup(category: BackupCategory): Result = withContext(ioDispatcher) { Logger.debug("Backup starting for: '$category'", context = TAG) - val backupRequired = currentTimeMillis() + var backupRequired = currentTimeMillis() runningBackups += category failedBackupRequired -= category cacheStore.updateBackupStatus(category) { + backupRequired = maxOf(backupRequired, it.required, it.synced + 1) it.copy(running = true, required = backupRequired) } @@ -526,7 +527,7 @@ class BackupRepo @Inject constructor( cacheStore.updateBackupStatus(category) { it.copy( running = false, - synced = currentTimeMillis(), + synced = backupRequired, ) } Logger.info("Backup succeeded for: '$category'", context = TAG) @@ -644,25 +645,15 @@ class BackupRepo @Inject constructor( val (snapshotAttempt, snapshotProofs) = onchainSendAttemptStore.backupSnapshot(walletIndex) { paykitPaymentProofRepo.get().backupSnapshot() } - // No native submission is possible until a signed receipt is durably retained. Do not - // export this process-local preparation or its exact empty started proof as a sent guard. - val preparation = snapshotAttempt?.takeIf { + // A private preparation may already have consumed a payment-list version. Defer the + // entire wallet snapshot until its receipt is retained; never omit only the guard/proof. + check(snapshotAttempt?.let { it.preparationPending && !it.restoredFromBackup && it.requestId != null && it.evidence == OnchainSendEvidence.Pending && it.txid == null && it.originalInputs == null && it.candidateTxids.isEmpty() - } - val emptyProof = preparation?.let { attempt -> - snapshotProofs.singleOrNull { - it.requestId == attempt.requestId && it.identity == attempt.payerIdentity && - it.kind == PaykitPaymentProofKind.Onchain.type && it.paymentStarted && - (it.onchainWalletId == null || it.onchainWalletId == attempt.walletId) && - it.onchainAddress == attempt.address && it.onchainAmountSats == attempt.amountSats && - it.paymentIdentifier == null && it.proofData == null && !it.onchainAcceptanceVerified - } - } - check(preparation == null || emptyProof != null) { "Cannot snapshot unmatched Shop preparation" } - val proofs = if (emptyProof == null) snapshotProofs else snapshotProofs - emptyProof - val active = snapshotAttempt?.takeIf { preparation == null }?.let { attempt -> + } != true) { "Waiting for signed Shop receipt before wallet backup" } + val proofs = snapshotProofs + val active = snapshotAttempt?.let { attempt -> val wire = ActiveOnchainAttemptBackup.from( attempt, Env.network.name.lowercase(), diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 824d69502f..8676be9c5f 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -227,7 +227,7 @@ class BackupRepoTest : BaseUnitTest() { } @Test - fun `wallet backup excludes never prepared Shop admission and only its empty proof`() = test { + fun `wallet backup defers the entire never prepared Shop snapshot`() = test { val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) val wire = requireNotNull(state.activeOnchainAttempt) @@ -241,12 +241,8 @@ class BackupRepoTest : BaseUnitTest() { whenever(onchainSendAttemptStore.backupSnapshot(0)).thenReturn(attempt) whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) whenever(paykitPaymentProofRepo.backupSnapshot()).thenReturn(listOf(original, unrelated)) - val data = argumentCaptor() - sut.triggerBackup(BackupCategory.WALLET).getOrThrow() - verifyBlocking(vssBackupClient) { putObject(eq(BackupCategory.WALLET.name), data.capture()) } - val exported = json.decodeFromString(data.firstValue.decodeToString()) - assertNull(exported.paykitPaymentState?.activeOnchainAttempt) - assertEquals(listOf(unrelated), exported.paykitPaymentState?.pendingProofs) + assertTrue(sut.triggerBackup(BackupCategory.WALLET).isFailure) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) } @Test @@ -517,6 +513,56 @@ class BackupRepoTest : BaseUnitTest() { } } + @Test + fun `successful upload leaves a newer required snapshot queued`() = test { + whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(3_000)) + val backupStatuses = MutableStateFlow( + mapOf( + BackupCategory.SETTINGS to BackupItemStatus( + synced = 1_000, + required = 2_000, + ), + ) + ) + var uploadAttempts = 0 + whenever { vssBackupClient.putObject(eq(BackupCategory.SETTINGS.name), any()) } + .doSuspendableAnswer { + uploadAttempts += 1 + if (uploadAttempts == 1) { + backupStatuses.update { statuses -> + val status = statuses.getValue(BackupCategory.SETTINGS) + statuses + (BackupCategory.SETTINGS to status.copy(required = 4_000)) + } + whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(5_000)) + Result.success(VssItem(key = BackupCategory.SETTINGS.name, value = byteArrayOf(), version = 1)) + } else { + Result.success( + VssItem( + key = BackupCategory.SETTINGS.name, + value = byteArrayOf(), + version = 1, + ) + ) + } + } + val allowWalletClear = CompletableDeferred().apply { complete(Unit) } + stubBackupStatuses(backupStatuses, allowWalletClear) {} + stubBackupObservers() + + try { + sut.startObservingBackups() + runCurrent() + advanceTimeBy(5_000) + runCurrent() + advanceTimeBy(5_000) + runCurrent() + + verify(vssBackupClient, times(2)).putObject(eq(BackupCategory.SETTINGS.name), any()) + } finally { + sut.stopObservingBackups() + } + } + @Test fun `failed automatic backup retries newer required timestamp`() = test { whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(3_000)) From e5970b81ac752c60a26cfd48b6a9d42990b2d3c9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 07:50:23 +0200 Subject: [PATCH 037/108] fix: release hardware payments denied before dispatch --- .../repositories/PaykitPaymentProofRepo.kt | 32 ++++++++++++++ .../screens/wallets/send/HwSendViewModel.kt | 14 ++++-- .../PaykitPaymentProofRepoTest.kt | 29 +++++++++++++ .../wallets/send/HwSendViewModelTest.kt | 43 +++++++++++++++++++ 4 files changed, 115 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 45448851cf..ac46e16a23 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -511,6 +511,38 @@ class PaykitPaymentProofRepo @Inject constructor( } } + suspend fun clearHardwareOnchainCandidateBeforeDispatch( + requestId: PaykitPaymentRequestId, + walletId: String, + txid: String, + identity: String?, + address: String, + amountSats: ULong, + hasAttemptedBroadcast: Boolean, + ): Boolean = withContext(ioDispatcher) { + if (hasAttemptedBroadcast || walletId == WalletScope.default || !txid.isHex(HASH_BYTE_COUNT)) { + return@withContext false + } + val originalIdentity = identity?.let(PubkyPublicKeyFormat::normalized) ?: return@withContext false + operationMutex.withLock { + runSuspendCatching { + if (!PubkyPublicKeyFormat.matches(currentIdentity(), originalIdentity)) return@runSuspendCatching false + val proofs = loadProofs() + val original = proofs.singleOrNull { + PubkyPublicKeyFormat.matches(it.identity, originalIdentity) && it.requestId == requestId && + it.onchainWalletId == walletId && it.kind == PaykitPaymentProofKind.Onchain && + it.paymentStarted && it.onchainAddress == address && it.onchainAmountSats == amountSats && + it.paymentIdentifier.equals(txid, true) && it.proofData == null && + !it.onchainAcceptanceVerified + } ?: return@runSuspendCatching false + // Only a definite first queued dispatch denial permits the existing preparation cleanup. + persist(proofs.map { if (it == original) it.copy(paymentIdentifier = null) else it }) + true + }.onFailure { Logger.warn("Failed to clear denied hardware candidate", it, context = TAG) } + .getOrDefault(false) + } + } + suspend fun completeHardwareOnchainPayment( requestId: PaykitPaymentRequestId, walletId: String, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt index 644e418d05..a4a68e186d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt @@ -182,9 +182,17 @@ class HwSendViewModel @Inject constructor( hwWalletRepo.broadcastFunding(payment.signedTx, payment.request.paymentDeadlineAt) }.getOrElse { error -> if (generateSequence(error) { it.cause }.any { it is ServiceError.PaymentDeadlineExpired }) { - pendingBroadcast = payment - _uiState.update { it.copy(isBroadcastUnresolved = payment.hasAttemptedBroadcast) } - onPaymentDeadlineExpired(payment.hasAttemptedBroadcast) + val request = payment.request + val cleared = request.paymentRequestId?.let { requestId -> + !payment.hasAttemptedBroadcast && paykitPaymentProofRepo.clearHardwareOnchainCandidateBeforeDispatch( + requestId, request.walletId, SignedTransactionId.fromHex(payment.signedTx.serializedTx), + request.paymentIdentity, request.address, request.amountSats, payment.hasAttemptedBroadcast, + ) + } ?: !payment.hasAttemptedBroadcast + val unresolved = payment.hasAttemptedBroadcast || !cleared + pendingBroadcast = payment.copy(hasAttemptedBroadcast = unresolved) + _uiState.update { it.copy(isBroadcastUnresolved = unresolved) } + onPaymentDeadlineExpired(unresolved) return null } throw error diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index c865d3b5b7..42cb12271c 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -1441,6 +1441,35 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } } + @Test + fun `first queued expiry clears only the exact unsubmitted hardware candidate`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val walletId = "original-hardware-wallet" + val txid = "ab".repeat(32) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + assertTrue(repo.retainHardwareOnchainCandidate(request.id, walletId, txid, LOCAL_IDENTITY, + ONCHAIN_ADDRESS, request.amountSats)) + val retained = storedProofs.single() + suspend fun clear(prior: Boolean = false, candidate: String = txid, address: String = ONCHAIN_ADDRESS) = + repo.clearHardwareOnchainCandidateBeforeDispatch(request.id, walletId, candidate, LOCAL_IDENTITY, + address, request.amountSats, prior) + assertFalse(clear(prior = true)) + assertFalse(clear(candidate = "cd".repeat(32))) + assertFalse(clear(address = "other-address")) + for (accepted in listOf(retained.copy(onchainAcceptanceVerified = true), retained.copy(proofData = txid))) { + storedProofs = listOf(accepted) + assertFalse(clear()) + assertEquals(listOf(accepted), storedProofs) + } + storedProofs = listOf(retained) + assertTrue(clear()) + assertNull(storedProofs.single().paymentIdentifier) + assertTrue(repo.failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, false)) + assertTrue(storedProofs.isEmpty()) + } + @Test fun `prebroadcast hardware candidate survives repository reopen without claiming acceptance`() = test { val request = paymentRequest(MethodId.P2wpkh.rawValue) diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt index 0572ce807d..3e5bc81767 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt @@ -66,6 +66,8 @@ class HwSendViewModelTest : BaseUnitTest() { whenever(coreService.activity).thenReturn(activityService) whenever { proofRepo.retainHardwareOnchainCandidate(any(), any(), any(), org.mockito.kotlin.anyOrNull(), any(), any()) } .thenReturn(true) + whenever { proofRepo.clearHardwareOnchainCandidateBeforeDispatch(any(), any(), any(), + org.mockito.kotlin.anyOrNull(), any(), any(), any()) }.thenReturn(false) whenever { hwWalletRepo.reconnectTimeout(any()) }.thenReturn(30.seconds) sut = HwSendViewModel( context = context, @@ -493,6 +495,47 @@ class HwSendViewModelTest : BaseUnitTest() { assertFalse(sut.uiState.value.hasPendingBroadcast) } + @Test + fun `first queued Shop expiry clears exact retained candidate before releasing preparation`() = test { + val fixture = stubSuccessfulPayment() + val deadline = Instant.parse("2026-10-06T12:00:00Z") + val requestId = PaykitPaymentRequestId("request", "counterparty") + val original = request().copy(paymentRequestId = requestId, paymentIdentity = "original-identity", + paymentDeadlineAt = deadline) + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx, deadline)) + .thenReturn(Result.failure(AppError(ServiceError.PaymentDeadlineExpired()))) + var cleared = false + whenever(proofRepo.clearHardwareOnchainCandidateBeforeDispatch(requestId, WALLET_ID, + "605fe246a6d51450ecff51ac3d0415f8824964e06a60ed6e186fa163cf1e9d4e", + "original-identity", ADDRESS, AMOUNT_SATS, false)).doSuspendableAnswer { + cleared = true + true + } + var expired: Boolean? = null + sut.signAndBroadcast(original, onPaymentDeadlineExpired = { + assertTrue(cleared) + expired = it + }) + advanceUntilIdle() + assertEquals(false, expired) + assertFalse(sut.uiState.value.isBroadcastUnresolved) + } + + @Test + fun `failed queued Shop candidate rollback preserves uncertainty`() = test { + val fixture = stubSuccessfulPayment() + val deadline = Instant.parse("2026-10-06T12:00:00Z") + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx, deadline)) + .thenReturn(Result.failure(AppError(ServiceError.PaymentDeadlineExpired()))) + var expired: Boolean? = null + sut.signAndBroadcast(request().copy(paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", paymentDeadlineAt = deadline), + onPaymentDeadlineExpired = { expired = it }) + advanceUntilIdle() + assertEquals(true, expired) + assertTrue(sut.uiState.value.isBroadcastUnresolved) + } + @Test fun `expiry during rebroadcast keeps the earlier uncertain attempt`() = test { whenever(context.getString(any())).thenReturn("message") From 4250107a4e41ea68589b51d2f2362405f0d719dc Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 08:16:09 +0200 Subject: [PATCH 038/108] fix: keep shop backups consistent and resume restored proofs --- .../java/to/bitkit/repositories/BackupRepo.kt | 12 +++++++- .../to/bitkit/repositories/BackupRepoTest.kt | 28 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 50c28637d5..877337d868 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -638,7 +638,10 @@ class BackupRepo @Inject constructor( } val transfers = db.transferDao().getAll() val privateReservations = privatePaykitAddressReservationRepo.get().backupSnapshot().getOrThrow() - val paykitSdkBackupState = privatePaykitRepo.get().backupSnapshot().getOrThrow() + val privateRepo = privatePaykitRepo.get() + val privateVersion = privateRepo.backupStateVersion.value + val sdkVersion = paykitSdkService.backupStateVersion.value + val paykitSdkBackupState = privateRepo.backupSnapshot().getOrThrow() val watchOnlyAccountSnapshot = watchOnlyAccountStore.backupSnapshot() val walletIndex = backupWalletIndex() @@ -652,6 +655,9 @@ class BackupRepo @Inject constructor( it.evidence == OnchainSendEvidence.Pending && it.txid == null && it.originalInputs == null && it.candidateTxids.isEmpty() } != true) { "Waiting for signed Shop receipt before wallet backup" } + check(privateRepo.backupStateVersion.value == privateVersion && + paykitSdkService.backupStateVersion.value == sdkVersion + ) { "Private payment state changed during wallet snapshot" } val proofs = snapshotProofs val active = snapshotAttempt?.let { attempt -> val wire = ActiveOnchainAttemptBackup.from( @@ -855,6 +861,10 @@ class BackupRepo @Inject constructor( transferRepo.get().resumeAcceptedFunding(attempt).onFailure { Logger.warn("Restored accepted funding remains pending local follow-up", it, context = TAG) } + } else if (attempt.requestId != null && !attempt.localFollowupComplete) { + runSuspendCatching { paykitPaymentProofRepo.get().reconcile() }.onFailure { + Logger.warn("Restored accepted Shop payment remains pending local follow-up", it, context = TAG) + } } else if (attempt.requestId == null && !attempt.localFollowupComplete) { runSuspendCatching { lightningRepo.get().completeAcceptedOrdinaryFollowup(requireNotNull(attempt.txid)) diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 8676be9c5f..fafb0594fa 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -146,6 +146,8 @@ class BackupRepoTest : BaseUnitTest() { whenever { transferDao.getAll() }.thenReturn(emptyList()) whenever { privatePaykitRepo.restoreBackup(anyOrNull()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.backupSnapshot() }.thenReturn(Result.success(null)) + whenever(privatePaykitRepo.backupStateVersion).thenReturn(MutableStateFlow(0L)) + whenever(paykitSdkService.backupStateVersion).thenReturn(MutableStateFlow(0L)) whenever { paykitPaymentProofRepo.backupSnapshot() }.thenReturn(emptyList()) whenever(paykitPresentationStore.backupSnapshot()).thenReturn(emptyMap()) whenever { privatePaykitAddressReservationRepo.restoreBackup(any()) }.thenReturn(Result.success(Unit)) @@ -226,6 +228,32 @@ class BackupRepoTest : BaseUnitTest() { verify(lightningRepo, times(1)).completeAcceptedOrdinaryFollowup(any()) } + @Test + fun `wallet backup rejects private consumption changed during proof capture`() = test { + val version = MutableStateFlow(0L) + whenever(privatePaykitRepo.backupStateVersion).thenReturn(version) + whenever { onchainSendAttemptStore.backupSnapshot(any(), any()) }.doSuspendableAnswer { + version.value = 1L + null to emptyList() + } + assertTrue(sut.triggerBackup(BackupCategory.WALLET).isFailure) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + } + + @Test + fun `restored accepted Shop reconciles after proof and private state`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt).copy(status = "accepted") + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + stubWalletBackup(paykitPaymentState = state.copy(activeOnchainAttempt = wire)) + sut.performFullRestoreFromLatestBackup().getOrThrow() + val ordered = org.mockito.kotlin.inOrder(paykitPaymentProofRepo, privatePaykitRepo) + ordered.verify(paykitPaymentProofRepo).restoreBackup(state.pendingProofs) + ordered.verify(privatePaykitRepo).restoreBackup(anyOrNull()) + ordered.verify(paykitPaymentProofRepo).reconcile() + } + @Test fun `wallet backup defers the entire never prepared Shop snapshot`() = test { val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() From a685b2bea3f7e2ece509452d1f223a62fbf60f56 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 08:16:10 +0200 Subject: [PATCH 039/108] fix: consume completed hardware shop results --- .../ui/screens/wallets/send/HwSendViewModel.kt | 7 +++++++ app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt | 14 ++++++++++++++ .../main/java/to/bitkit/viewmodels/AppViewModel.kt | 4 ++++ .../ui/screens/wallets/send/HwSendViewModelTest.kt | 14 ++++++++++++++ 4 files changed, 39 insertions(+) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt index a4a68e186d..cab574ead1 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt @@ -266,6 +266,13 @@ class HwSendViewModel @Inject constructor( viewModelScope.launch { hwWalletRepo.disconnectStaleSession(walletId) } } + fun completeReconciledBroadcast(walletId: String?, txid: String?): Boolean { + val result = pendingResult.value ?: return false + if (result.walletId != walletId || !result.txId.equals(txid, ignoreCase = true)) return false + completeBroadcast() + return true + } + fun completeBroadcast() { pendingBroadcast = null signingWalletId = null diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 3116861eaf..b71e57c950 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -156,8 +156,20 @@ fun SendSheet( .testTag("SendSheet"), ) { val navController = rememberNavController() + LaunchedEffect(appViewModel, hwSendViewModel) { + appViewModel.resolvedHardwarePayment.collect { resolution -> + if (resolution != null) { + hwSendViewModel.completeReconciledBroadcast(resolution.walletId, resolution.transactionId) + } + } + } LaunchedEffect(hwSendViewModel, navController) { hwSendViewModel.results.collect { result -> + val resolved = appViewModel.resolvedHardwarePayment.value + if (resolved != null && hwSendViewModel.completeReconciledBroadcast( + resolved.walletId, resolved.transactionId + ) + ) return@collect val proofComplete = appViewModel.completeHardwareContactPayment( result.txId, result.walletId, result.paymentRequestId, result.paymentIdentity, ) @@ -204,6 +216,8 @@ fun SendSheet( is SendEffect.NavigateToHardwareSign -> navController.navigateTo(SendRoute.HardwareSign) is SendEffect.PopBack -> navController.popBackStack(it.route, inclusive = false) is SendEffect.PaymentSuccess -> { + val details = appViewModel.successSendUiState.value + hwSendViewModel.completeReconciledBroadcast(details.activityWalletId, details.paymentHashOrTxId) appViewModel.clearClipboardForAutoRead() navController.navigateTo(SendRoute.Success) { popUpTo(navController.graph.id) { inclusive = true } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 4db06a3916..0dbc262fd5 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -857,6 +857,9 @@ class AppViewModel @Inject constructor( } } + private val _resolvedHardwarePayment = MutableStateFlow(null) + val resolvedHardwarePayment = _resolvedHardwarePayment.asStateFlow() + private fun observePaykitOnchainPaymentResolution() { viewModelScope.launch { paykitPaymentProofRepo.onchainPaymentResolutions.collect { resolutions -> @@ -867,6 +870,7 @@ class AppViewModel @Inject constructor( private fun handlePaykitOnchainPaymentResolution(resolution: PaykitOnchainPaymentProofResolution) { if (!PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, resolution.identity)) return + if (resolution.walletId != WalletScope.default) _resolvedHardwarePayment.value = resolution paykitPaymentProofRepo.consumeOnchainPaymentResolution(resolution) val resolvesCurrentPayment = uncertainOnchainPaymentRequestId == resolution.requestId if (!resolvesCurrentPayment) { diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt index 3e5bc81767..cca020c64e 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt @@ -679,6 +679,20 @@ class HwSendViewModelTest : BaseUnitTest() { ) } + @Test + fun `matching asynchronous completion consumes retained result and permits the next hardware send`() = test { + val fixture = stubSuccessfulPayment() + sut.signAndBroadcast(request().copy(paymentRequestId = PaykitPaymentRequestId("original", "counterparty"))) + advanceUntilIdle() + assertFalse(sut.completeReconciledBroadcast("other-wallet", fixture.broadcast.txId)) + assertFalse(sut.completeReconciledBroadcast(WALLET_ID, "other-tx")) + assertEquals(fixture.broadcast.txId, sut.results.first().txId) + assertTrue(sut.completeReconciledBroadcast(WALLET_ID, fixture.broadcast.txId)) + sut.signAndBroadcast(request()) + advanceUntilIdle() + verify(hwWalletRepo, times(2)).broadcastFunding(fixture.signedTx) + } + @Test fun `core completed hardware result retains original request and never rebroadcasts while proof is pending`() = test { val fixture = stubSuccessfulPayment() From d463af9180a0607691aa84b456a9797f9d6f63d8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 08:47:34 +0200 Subject: [PATCH 040/108] fix: resume denied hardware payment cleanup after restart --- .../repositories/PaykitPaymentProofRepo.kt | 53 ++++++++++++++++--- .../bitkit/repositories/PrivatePaykitRepo.kt | 14 ++++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 5 +- .../PaykitPaymentProofRepoTest.kt | 48 ++++++++++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 15 +++--- 5 files changed, 118 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index ac46e16a23..eaa7919520 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -1,5 +1,6 @@ package to.bitkit.repositories +import dagger.Lazy import com.synonym.paykit.BillingPeriod import com.synonym.paykit.PubkyIdentityCapability import kotlinx.coroutines.CoroutineDispatcher @@ -76,6 +77,8 @@ data class PendingPaykitPaymentProof( val onchainWalletId: String = WalletScope.default, val onchainMatchingTransactionIdsBeforeAttempt: Set = emptySet(), val onchainAcceptanceVerified: Boolean = false, + val hardwareDispatchDenied: Boolean = false, + val privatePaymentListVersion: ULong? = null, ) data class PaykitOnchainPaymentProofResolution( @@ -94,6 +97,7 @@ class PaykitPaymentProofRepo @Inject constructor( private val lightningRepo: LightningRepo, private val store: PaykitPaymentProofStore, private val hwWalletRepo: HwWalletRepo, + private val privatePaykitRepo: Lazy, ) { companion object { private const val TAG = "PaykitPaymentProofRepo" @@ -127,7 +131,11 @@ class PaykitPaymentProofRepo @Inject constructor( ) suspend fun backupSnapshot(): List = withContext(ioDispatcher) { - operationMutex.withLock { store.load().map { PaykitPaymentStateBackup.Proof(it) } } + operationMutex.withLock { + val proofs = store.load() + check(proofs.none { it.hardwareDispatchDenied }) { "Hardware dispatch denial cleanup is pending" } + proofs.map { PaykitPaymentStateBackup.Proof(it) } + } } suspend fun restoreBackup(proofs: List) = withContext(ioDispatcher) { @@ -213,6 +221,7 @@ class PaykitPaymentProofRepo @Inject constructor( request: PaykitPaymentRequest, address: String, walletId: String = WalletScope.default, + privatePaymentListVersion: ULong? = null, ): Result = withContext(ioDispatcher) { runSuspendCatching { val identity = currentIdentity() ?: throw PaykitPaymentRequestError.RequestUnavailable @@ -235,6 +244,7 @@ class PaykitPaymentProofRepo @Inject constructor( onchainAddress = address, onchainAmountSats = request.amountSats, onchainWalletId = walletId, + privatePaymentListVersion = privatePaymentListVersion, ) persist(proofs) } @@ -501,7 +511,8 @@ class PaykitPaymentProofRepo @Inject constructor( val original = proofs.singleOrNull { PubkyPublicKeyFormat.matches(it.identity, originalIdentity) && it.requestId == requestId && it.onchainWalletId == walletId && it.kind == PaykitPaymentProofKind.Onchain && - it.paymentStarted && it.onchainAddress == address && it.onchainAmountSats == amountSats && + it.paymentStarted && !it.hardwareDispatchDenied && + it.onchainAddress == address && it.onchainAmountSats == amountSats && it.proofData == null && !it.onchainAcceptanceVerified && (it.paymentIdentifier == null || it.paymentIdentifier.equals(txid, true)) } ?: return@withLock false @@ -536,7 +547,8 @@ class PaykitPaymentProofRepo @Inject constructor( !it.onchainAcceptanceVerified } ?: return@runSuspendCatching false // Only a definite first queued dispatch denial permits the existing preparation cleanup. - persist(proofs.map { if (it == original) it.copy(paymentIdentifier = null) else it }) + // Keep exact identity and receipt until private cleanup and proof removal both finish. + persist(proofs.map { if (it == original) it.copy(hardwareDispatchDenied = true) else it }) true }.onFailure { Logger.warn("Failed to clear denied hardware candidate", it, context = TAG) } .getOrDefault(false) @@ -558,7 +570,7 @@ class PaykitPaymentProofRepo @Inject constructor( val proof = proofs[index] PubkyPublicKeyFormat.matches(proof.identity, originalIdentity) && proof.requestId == requestId && proof.onchainWalletId == walletId && - proof.kind == PaykitPaymentProofKind.Onchain && proof.paymentStarted + proof.kind == PaykitPaymentProofKind.Onchain && proof.paymentStarted && !proof.hardwareDispatchDenied } ?: return@runSuspendCatching false val original = proofs[index] if ((original.paymentIdentifier != null && !original.paymentIdentifier.equals(txid, true)) || @@ -589,9 +601,10 @@ class PaykitPaymentProofRepo @Inject constructor( val original = proofs.singleOrNull { PubkyPublicKeyFormat.matches(it.identity, originalIdentity) && it.requestId == request.id && it.onchainWalletId == walletId && it.kind == PaykitPaymentProofKind.Onchain && - it.paymentStarted && it.paymentIdentifier == null && it.proofData == null && - !it.onchainAcceptanceVerified + it.paymentStarted && (it.paymentIdentifier == null || it.hardwareDispatchDenied) && + it.proofData == null && !it.onchainAcceptanceVerified } ?: return@runSuspendCatching false + if (original.hardwareDispatchDenied) releaseDeniedPrivateConsumption(original) // Only the caller's definite first-dispatch authorization denial permits this removal. persist(proofs - original) true @@ -671,6 +684,32 @@ class PaykitPaymentProofRepo @Inject constructor( }.onFailure { Logger.warn("Failed to clear interrupted Shop preparation", it, context = TAG) } } + private suspend fun releaseDeniedPrivateConsumption(proof: PendingPaykitPaymentProof) { + val version = proof.privatePaymentListVersion ?: return + privatePaykitRepo.get().releasePrivatePaymentListVersion(proof.requestId.counterparty, version).getOrThrow() + } + + private suspend fun finishDeniedHardwarePreparations(proofs: List) { + runSuspendCatching { + operationMutex.withLock { + if (proofs.none { it.hardwareDispatchDenied }) return@withLock + val identity = currentIdentity() ?: return@withLock + val denied = proofs.filter { + it.hardwareDispatchDenied && it.onchainWalletId != WalletScope.default && + it.kind == PaykitPaymentProofKind.Onchain && it.paymentStarted && + it.paymentIdentifier?.isHex(HASH_BYTE_COUNT) == true && it.proofData == null && + !it.onchainAcceptanceVerified && PubkyPublicKeyFormat.matches(it.identity, identity) + } + for (proof in denied) { + if (proof !in loadProofs()) continue + releaseDeniedPrivateConsumption(proof) + // Removal follows idempotent exact-version release. A crash leaves the denial for retry. + persist(loadProofs() - proof) + } + } + }.onFailure { Logger.warn("Denied hardware payment cleanup remains pending", it, context = TAG) } + } + suspend fun reconcile() = withContext(ioDispatcher) { releaseInterruptedShopPreparation() val attempt = runSuspendCatching { lightningRepo.currentOnchainSendAttempt() } @@ -691,6 +730,7 @@ class PaykitPaymentProofRepo @Inject constructor( operationMutex.withLock { loadProofs().also { if (it.isEmpty()) persist(emptyList()) } } }.getOrElse { return@withContext } if (snapshot.isEmpty()) return@withContext + finishDeniedHardwarePreparations(snapshot) snapshot.filter { it.kind == PaykitPaymentProofKind.Onchain && it.onchainWalletId == WalletScope.default } .forEach { proof -> val txid = proof.proofData ?: attempt?.txid @@ -756,6 +796,7 @@ class PaykitPaymentProofRepo @Inject constructor( attempt: OnchainSendAttempt?, activityReady: Boolean, ): Boolean { + if (proof.hardwareDispatchDenied) return false if (proof.kind == PaykitPaymentProofKind.Onchain && proof.onchainWalletId == WalletScope.default && !activityReady ) { diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 5767a237b4..6d44d678ed 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -439,16 +439,26 @@ class PrivatePaykitRepo @Inject constructor( suspend fun releasePrivatePaymentList( publicKey: String, context: PrivatePaykitPaymentContext, + ): Result = releasePrivatePaymentListVersion(publicKey, context.paymentListVersion) + + suspend fun releasePrivatePaymentListVersion( + publicKey: String, + paymentListVersion: ULong?, ): Result = withContext(serializedDispatcher) { runSuspendCatching { val normalizedKey = normalizedPublicKey(publicKey) ?: throw PrivatePaykitError.InvalidPublicKey - val paymentListVersion = context.paymentListVersion ?: return@runSuspendCatching + if (paymentListVersion == null) return@runSuspendCatching val contactState = ensureState().contacts[normalizedKey] ?: return@runSuspendCatching val consumedVersion = contactState.consumedPrivatePaymentListVersion if (consumedVersion != paymentListVersion) return@runSuspendCatching contactState.consumedPrivatePaymentListVersion = null - persistState(markWalletBackup = true) + try { + persistState(markWalletBackup = true) + } catch (error: Throwable) { + contactState.consumedPrivatePaymentListVersion = consumedVersion + throw error + } Logger.info( "Released private Paykit payment list version '$paymentListVersion' " + "for '${redacted(normalizedKey)}'", diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 0dbc262fd5..9a6d697941 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -5812,7 +5812,10 @@ class AppViewModel @Inject constructor( return false } if (preparedPaymentProofRequest != null) { - markOnchainPaymentStarted(incomingPaymentRequest, address, walletId ?: WalletScope.default).onFailure { + paykitPaymentProofRepo.markOnchainPaymentStarted( + requireNotNull(incomingPaymentRequest), address, walletId ?: WalletScope.default, + contactPaymentContext.privatePaymentContext?.paymentListVersion, + ).onFailure { synchronized(contactPaymentContextLock) { if (preparedContactPaymentContext == contactPaymentContext) preparedContactPaymentContext = null } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 42cb12271c..f68a34f82f 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -83,6 +83,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { private val lightningRepo = mock() private val store = mock() private val hwWalletRepo = mock() + private val privatePaykitRepo = mock() private var storedProofs = emptyList() private var shouldFailNextLoad = false private var shouldFailNextSave = false @@ -1441,6 +1442,49 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } } + @Test + fun `denied hardware cleanup retries original private version before removing proof`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val walletId = "original-hardware-wallet" + val txid = "ab".repeat(32) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId, 7uL).getOrThrow() + assertTrue(repo.retainHardwareOnchainCandidate(request.id, walletId, txid, LOCAL_IDENTITY, + ONCHAIN_ADDRESS, request.amountSats)) + assertTrue(repo.clearHardwareOnchainCandidateBeforeDispatch(request.id, walletId, txid, LOCAL_IDENTITY, + ONCHAIN_ADDRESS, request.amountSats, false)) + whenever(privatePaykitRepo.releasePrivatePaymentListVersion(request.id.counterparty, 7uL)) + .thenReturn(Result.failure(IllegalStateException("storage")), Result.success(Unit)) + assertTrue(runCatching { repo.backupSnapshot() }.isFailure) + paymentProofRepo().reconcile() + assertTrue(storedProofs.single().hardwareDispatchDenied) + assertEquals(txid, storedProofs.single().paymentIdentifier) + paymentProofRepo().reconcile() + assertTrue(storedProofs.isEmpty()) + verify(privatePaykitRepo, times(2)).releasePrivatePaymentListVersion(request.id.counterparty, 7uL) + } + + @Test + fun `queued hardware denial survives reopen and finishes cleanup before another preparation`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val walletId = "original-hardware-wallet" + val txid = "ab".repeat(32) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + assertTrue(repo.retainHardwareOnchainCandidate(request.id, walletId, txid, LOCAL_IDENTITY, + ONCHAIN_ADDRESS, request.amountSats)) + assertTrue(repo.clearHardwareOnchainCandidateBeforeDispatch(request.id, walletId, txid, LOCAL_IDENTITY, + ONCHAIN_ADDRESS, request.amountSats, false)) + // Kill after the durable denial, before the sheet's cleanup callback. + val reopened = paymentProofRepo() + reopened.reconcile() + assertTrue(storedProofs.isEmpty()) + assertTrue(reopened.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).isSuccess) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + } + @Test fun `first queued expiry clears only the exact unsubmitted hardware candidate`() = test { val request = paymentRequest(MethodId.P2wpkh.rawValue) @@ -1465,7 +1509,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { } storedProofs = listOf(retained) assertTrue(clear()) - assertNull(storedProofs.single().paymentIdentifier) + assertEquals(txid, storedProofs.single().paymentIdentifier) + assertTrue(storedProofs.single().hardwareDispatchDenied) assertTrue(repo.failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, false)) assertTrue(storedProofs.isEmpty()) } @@ -1674,6 +1719,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { lightningRepo = lightningRepo, store = store, hwWalletRepo = hwWalletRepo, + privatePaykitRepo = dagger.Lazy { privatePaykitRepo }, ) private fun acceptedAttempt(request: PaykitPaymentRequest, txid: String) = OnchainSendAttempt( diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 6b85c15def..f5094d1c97 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -476,7 +476,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { paykitPaymentProofRepo.associateLightningPayment(any(), any(), any(), eq("bitkit")) }.thenReturn(Result.success(Unit)) whenever { - paykitPaymentProofRepo.markOnchainPaymentStarted(any(), any(), any()) + paykitPaymentProofRepo.markOnchainPaymentStarted(any(), any(), any(), anyOrNull()) }.thenReturn(Result.success(Unit)) whenever { activityRepo.setContact(any(), any(), any(), any()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.scheduleSavedContactPreparation(any>()) } @@ -7349,7 +7349,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), ) - verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any()) + verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any(), anyOrNull()) } @Test @@ -7811,7 +7811,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) verify(paykitPaymentRequestRepo, never()).accept(any()) - verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any()) + verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any(), anyOrNull()) } @Test @@ -7839,7 +7839,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentProofRepo).cancelPreparation(request) - verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any()) + verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any(), anyOrNull()) } @Test @@ -7855,6 +7855,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { request, "bcrt1qpaymentrequest", "hardware-wallet", + 7uL, ) ).thenReturn(Result.failure(IllegalStateException("proof start failed"))) setActiveContactPaymentContext(testPublicKey, privateContext, request) @@ -7903,7 +7904,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentProofRepo, times(2)).prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain) verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentRequestRepo).accept(request) - verify(paykitPaymentProofRepo).markOnchainPaymentStarted(request, "bcrt1qpaymentrequest", "hardware-wallet") + verify(paykitPaymentProofRepo).markOnchainPaymentStarted(request, "bcrt1qpaymentrequest", "hardware-wallet", 7uL) verify(paykitPaymentRequestRepo, never()).ensurePaymentAllowed(request) } @@ -8193,7 +8194,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setActiveContactPaymentContext(testPublicKey, privatePaymentContext(7uL), incomingPaymentRequest = request) assertFalse(sut.prepareHardwareContactPayment(walletId, "bcrt1qpaymentrequest", request.id, testPublicKey)) verify(paykitPaymentRequestRepo, times(1)).accept(request) - verify(paykitPaymentProofRepo, times(1)).markOnchainPaymentStarted(request, "bcrt1qpaymentrequest", walletId) + verify(paykitPaymentProofRepo, times(1)).markOnchainPaymentStarted(request, "bcrt1qpaymentrequest", walletId, 7uL) verify( lightningRepo, never() @@ -8231,7 +8232,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) verify(paykitPaymentRequestRepo, never()).accept(any()) - verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any()) + verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any(), anyOrNull()) } @Test From 8f4b85e913a96dcc871c49e713c47221b93b8934 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 09:16:07 +0200 Subject: [PATCH 041/108] fix: preserve payment recovery after failed storage --- .../java/to/bitkit/repositories/BackupRepo.kt | 6 +++++- .../to/bitkit/repositories/LightningRepo.kt | 7 +++---- .../PrivatePaykitAddressReservationRepo.kt | 6 ++++-- .../to/bitkit/repositories/BackupRepoTest.kt | 21 +++++++++++++++++++ .../bitkit/repositories/LightningRepoTest.kt | 2 +- ...PrivatePaykitAddressReservationRepoTest.kt | 8 +++++++ 6 files changed, 42 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 877337d868..2ffca5ae25 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -38,6 +38,7 @@ import to.bitkit.data.WidgetsStore import to.bitkit.data.backup.VssBackupClient import to.bitkit.data.backup.VssBackupClientLdk import to.bitkit.data.backup.VssStoreIdProvider +import to.bitkit.data.entities.TransferEntity import to.bitkit.data.hwWalletNames import to.bitkit.data.keychain.Keychain import to.bitkit.data.resetPin @@ -636,7 +637,7 @@ class BackupRepo @Inject constructor( check(!keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)) { "Wallet backup restore is incomplete" } - val transfers = db.transferDao().getAll() + lateinit var transfers: List val privateReservations = privatePaykitAddressReservationRepo.get().backupSnapshot().getOrThrow() val privateRepo = privatePaykitRepo.get() val privateVersion = privateRepo.backupStateVersion.value @@ -646,6 +647,9 @@ class BackupRepo @Inject constructor( val watchOnlyAccountSnapshot = watchOnlyAccountStore.backupSnapshot() val walletIndex = backupWalletIndex() val (snapshotAttempt, snapshotProofs) = onchainSendAttemptStore.backupSnapshot(walletIndex) { + // Follow-up writes its transfer before completing the guarded attempt. Capture the + // transfer under that same attempt lock so a backup cannot omit both. + transfers = db.transferDao().getAll() paykitPaymentProofRepo.get().backupSnapshot() } // A private preparation may already have consumed a payment-list version. Defer the diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 98ba5da5d5..2773b8503c 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1633,10 +1633,9 @@ class LightningRepo @Inject constructor( ) { winner } else { - if (outcome !is OnchainSendOutcome.Accepted || !outcome.txid.equals(prepared.receipt.txid, true)) { - return@executeWhenNodeRunning Result.failure(OnchainSendPendingError(error, prepared.receipt.txid)) - } - attempt.copy(evidence = OnchainSendEvidence.Accepted, txid = outcome.txid) + // Native acceptance is not durable local completion. Preserve the signed receipt + // and Pending until the exact winner can be persisted or independently observed. + return@executeWhenNodeRunning Result.failure(OnchainSendPendingError(error, prepared.receipt.txid)) } } val winningOutcome = if (recorded.hasPositiveEvidence) { diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt index db7d9d830e..bf499f322b 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepo.kt @@ -210,7 +210,8 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( it in savedKeys }, ) - if (next == current) return@locked + // Keep failed deletion hidden immediately, but retry the durable write even when + // the filtered in-memory ledger already matches the requested cleanup. ledger = next persist(next) notifyBackupStateChanged() @@ -224,7 +225,8 @@ class PrivatePaykitAddressReservationRepo @Inject constructor( contactAssignments = current.contactAssignments.filterKeys { it !in removedKeys }, contactAssignmentHistory = current.contactAssignmentHistory.filterKeys { it !in removedKeys }, ) - if (next == current) return@locked + // Keep failed deletion hidden immediately, but retry the durable write even when + // the filtered in-memory ledger already matches the requested cleanup. ledger = next persist(next) notifyBackupStateChanged() diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index fafb0594fa..d50fe91c60 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -228,6 +228,27 @@ class BackupRepoTest : BaseUnitTest() { verify(lightningRepo, times(1)).completeAcceptedOrdinaryFollowup(any()) } + @Test + fun `wallet backup captures transfer completed before locked attempt snapshot`() = test { + val transfer = TransferEntity( + id = "original-transfer", type = to.bitkit.models.TransferType.TO_SPENDING, + amountSats = 1000, fundingTxId = "ab".repeat(32), createdAt = 1, + ) + var savedTransfers = emptyList() + whenever(transferDao.getAll()).thenAnswer { savedTransfers } + whenever(onchainSendAttemptStore.backupSnapshot(eq(0), any())).doSuspendableAnswer { + // Accepted follow-up commits its transfer and completes the guard before this lock is acquired. + savedTransfers = listOf(transfer) + val capture = it.getArgument List>(1) + null to capture() + } + sut.triggerBackup(BackupCategory.WALLET).getOrThrow() + val data = argumentCaptor() + verifyBlocking(vssBackupClient) { putObject(eq(BackupCategory.WALLET.name), data.capture()) } + val payload = json.decodeFromString(data.firstValue.decodeToString()) + assertEquals(listOf(transfer), payload.transfers) + } + @Test fun `wallet backup rejects private consumption changed during proof capture`() = test { val version = MutableStateFlow(0L) diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 6cb69379a0..09b8814628 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -1617,7 +1617,7 @@ class LightningRepoTest : BaseUnitTest() { isTransfer = true, channelId = "test_channel_id", ) - assertEquals(OnchainSendOutcome.Accepted("testPaymentId"), acceptedDespiteStorageFailure.getOrThrow()) + assertIs(acceptedDespiteStorageFailure.exceptionOrNull()) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt index fd4c70180d..67af719215 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitAddressReservationRepoTest.kt @@ -252,6 +252,14 @@ class PrivatePaykitAddressReservationRepoTest : BaseUnitTest() { assertNull(sut.currentContactPublicKeyForReservedAddress(PRIVATE_ADDRESS)) assertNull(sut.contactPublicKeyForReservedAddress(historicalAddress)) assertEquals(persisted, reservationData.value) + + org.mockito.kotlin.doAnswer { + val transform = it.getArgument<(PrivatePaykitReservationData) -> PrivatePaykitReservationData>(0) + reservationData.value = transform(reservationData.value) + }.whenever(reservationStore).update(any()) + sut.removeContactAssignments(listOf(CONTACT_KEY, historyOnlyKey)) + assertTrue(reservationData.value.contactAssignments.isEmpty()) + assertTrue(reservationData.value.contactAssignmentHistory.isEmpty()) } @Test From 2592aa239b7d5e229043cd92152c00f229c49fed Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 09:44:16 +0200 Subject: [PATCH 042/108] fix: retain each hardware payment completion --- .../java/to/bitkit/ui/sheets/SendSheet.kt | 17 +++++++++---- .../java/to/bitkit/viewmodels/AppViewModel.kt | 24 ++++++++++++++++--- .../viewmodels/AppViewModelSendFlowTest.kt | 24 +++++++++++++++++++ 3 files changed, 57 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index b71e57c950..e29aa2e5ba 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -157,19 +157,26 @@ fun SendSheet( ) { val navController = rememberNavController() LaunchedEffect(appViewModel, hwSendViewModel) { - appViewModel.resolvedHardwarePayment.collect { resolution -> - if (resolution != null) { - hwSendViewModel.completeReconciledBroadcast(resolution.walletId, resolution.transactionId) + appViewModel.resolvedHardwarePayments.collect { resolutions -> + resolutions.values.forEach { resolution -> + if (appViewModel.resolvedHardwarePaymentFor(resolution.walletId, resolution.transactionId) == resolution && + hwSendViewModel.completeReconciledBroadcast(resolution.walletId, resolution.transactionId) + ) { + appViewModel.consumeResolvedHardwarePayment(resolution) + } } } } LaunchedEffect(hwSendViewModel, navController) { hwSendViewModel.results.collect { result -> - val resolved = appViewModel.resolvedHardwarePayment.value + val resolved = appViewModel.resolvedHardwarePaymentFor(result.walletId, result.txId) if (resolved != null && hwSendViewModel.completeReconciledBroadcast( resolved.walletId, resolved.transactionId ) - ) return@collect + ) { + appViewModel.consumeResolvedHardwarePayment(resolved) + return@collect + } val proofComplete = appViewModel.completeHardwareContactPayment( result.txId, result.walletId, result.paymentRequestId, result.paymentIdentity, ) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 9a6d697941..892998cf08 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -857,8 +857,22 @@ class AppViewModel @Inject constructor( } } - private val _resolvedHardwarePayment = MutableStateFlow(null) - val resolvedHardwarePayment = _resolvedHardwarePayment.asStateFlow() + private val _resolvedHardwarePayments = MutableStateFlow< + ImmutableMap, PaykitOnchainPaymentProofResolution> + >(persistentMapOf()) + val resolvedHardwarePayments = _resolvedHardwarePayments.asStateFlow() + + fun resolvedHardwarePaymentFor(walletId: String, transactionId: String): PaykitOnchainPaymentProofResolution? = + _resolvedHardwarePayments.value[walletId to transactionId.lowercase()]?.takeIf { + PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, it.identity) + } + + fun consumeResolvedHardwarePayment(resolution: PaykitOnchainPaymentProofResolution) { + val key = resolution.walletId to resolution.transactionId.lowercase() + _resolvedHardwarePayments.update { current -> + if (current[key] == resolution) (current - key).toImmutableMap() else current + } + } private fun observePaykitOnchainPaymentResolution() { viewModelScope.launch { @@ -870,7 +884,11 @@ class AppViewModel @Inject constructor( private fun handlePaykitOnchainPaymentResolution(resolution: PaykitOnchainPaymentProofResolution) { if (!PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, resolution.identity)) return - if (resolution.walletId != WalletScope.default) _resolvedHardwarePayment.value = resolution + if (resolution.walletId != WalletScope.default) { + _resolvedHardwarePayments.update { + (it + ((resolution.walletId to resolution.transactionId.lowercase()) to resolution)).toImmutableMap() + } + } paykitPaymentProofRepo.consumeOnchainPaymentResolution(resolution) val resolvesCurrentPayment = uncertainOnchainPaymentRequestId == resolution.requestId if (!resolvesCurrentPayment) { diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index f5094d1c97..c446f14367 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -8307,6 +8307,30 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) } + @Test + fun `closed hardware sheet retains earlier completion when multiple proofs resolve`() = test { + pubkyPublicKey.value = testPublicKey + runCurrent() + val request = paymentRequest() + val first = PaykitOnchainPaymentProofResolution( + testPublicKey, request.id, "ab".repeat(32), "hardware-first", request.amountSats, + ) + val second = first.copy(transactionId = "cd".repeat(32), walletId = "hardware-second") + onchainPaymentResolutions.value = listOf(first, second) + runCurrent() + assertEquals(first, sut.resolvedHardwarePaymentFor(first.walletId, first.transactionId)) + assertEquals(second, sut.resolvedHardwarePaymentFor(second.walletId, second.transactionId)) + sut.consumeResolvedHardwarePayment(first.copy(transactionId = "ef".repeat(32))) + assertEquals(first, sut.resolvedHardwarePaymentFor(first.walletId, first.transactionId)) + sut.consumeResolvedHardwarePayment(first) + assertNull(sut.resolvedHardwarePaymentFor(first.walletId, first.transactionId)) + assertEquals(second, sut.resolvedHardwarePaymentFor(second.walletId, second.transactionId)) + pubkyPublicKey.value = null + assertNull(sut.resolvedHardwarePaymentFor(second.walletId, second.transactionId)) + pubkyPublicKey.value = testPublicKey + assertEquals(second, sut.resolvedHardwarePaymentFor(second.walletId, second.transactionId)) + } + @Test fun `pending hardware proof resolution uses original wallet and amount without another payment`() = test { pubkyPublicKey.value = testPublicKey From 2d18a4c65d659ad6e99120b0f4730c63a6c4a059 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 12:49:52 +0200 Subject: [PATCH 043/108] test: verify original payment survives remote backup restore --- .../OnchainBackupRestoreDeviceTest.kt | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 app/src/androidTest/java/to/bitkit/services/OnchainBackupRestoreDeviceTest.kt diff --git a/app/src/androidTest/java/to/bitkit/services/OnchainBackupRestoreDeviceTest.kt b/app/src/androidTest/java/to/bitkit/services/OnchainBackupRestoreDeviceTest.kt new file mode 100644 index 0000000000..d07626b90d --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/services/OnchainBackupRestoreDeviceTest.kt @@ -0,0 +1,69 @@ +package to.bitkit.services + +import android.content.Context +import androidx.work.Configuration +import androidx.work.WorkManager +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import to.bitkit.data.backup.VssBackupClient +import to.bitkit.env.Env +import to.bitkit.models.BackupCategory +import to.bitkit.repositories.BackupRepo +import to.bitkit.repositories.OnchainSendAttemptStore +import javax.inject.Inject +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** Opt-in remote backup check; run only on an owned funded test wallet. */ +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class OnchainBackupRestoreDeviceTest { + @get:Rule val hiltRule: HiltAndroidRule = HiltAndroidRule(this) + @Inject lateinit var backups: BackupRepo + @Inject lateinit var vss: VssBackupClient + @Inject lateinit var attempts: OnchainSendAttemptStore + + @Test + fun remoteWalletBackupRestoresTheOriginalAcceptedPayment(): Unit = runBlocking { + assumeTrue(InstrumentationRegistry.getArguments().getString("ownedWalletVss") == "true") + withTimeout(180_000) { + val context = ApplicationProvider.getApplicationContext() + Env.initAppStoragePath(context.filesDir.absolutePath) + hiltRule.inject() + WorkManager.initialize(context, Configuration.Builder().build()) + vss.setup().getOrThrow() + if (backups.hasPendingWalletRestore()) { + backups.performFullRestoreFromLatestBackup().getOrThrow() + } + val original = assertNotNull(attempts.current()) + assertTrue(original.hasPositiveEvidence && original.localFollowupComplete) + assertTrue(original.requestId == null && !original.isTransfer) + vss.setup(original.walletIndex).getOrThrow() + // Upload the complete owned fixture first: restoring must not rewind unrelated categories. + for (category in BackupCategory.entries.filterNot { it == BackupCategory.LIGHTNING_CONNECTIONS }) { + backups.triggerBackup(category).getOrThrow() + } + assertNotNull(vss.getObject(BackupCategory.WALLET.name).getOrThrow()) + backups.performFullRestoreFromLatestBackup().getOrThrow() + val restored = assertNotNull(attempts.current()) + assertEquals(original.attemptId, restored.attemptId) + assertEquals(original.walletIndex, restored.walletIndex) + assertEquals(original.walletId, restored.walletId) + assertEquals(original.txid, restored.txid) + assertEquals(original.amountSats, restored.amountSats) + assertEquals(original.originalInputs, restored.originalInputs) + assertEquals(original.candidateTxids, restored.candidateTxids) + assertTrue(restored.hasPositiveEvidence && restored.localFollowupComplete) + } + } +} From 86ea341d22fa62b4bf2ef9812d2559b1ec685d60 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 13:24:29 +0200 Subject: [PATCH 044/108] fix: preserve payment recovery progress during restore --- .../OnchainBackupRestoreDeviceTest.kt | 55 +++++++++++++------ .../to/bitkit/repositories/ActivityRepo.kt | 2 +- .../repositories/OnchainSendAttemptStore.kt | 32 ++++++++++- .../bitkit/repositories/ActivityRepoTest.kt | 11 ++++ .../OnchainSendAttemptStoreTest.kt | 28 ++++++++++ 5 files changed, 110 insertions(+), 18 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/services/OnchainBackupRestoreDeviceTest.kt b/app/src/androidTest/java/to/bitkit/services/OnchainBackupRestoreDeviceTest.kt index d07626b90d..f3185bd155 100644 --- a/app/src/androidTest/java/to/bitkit/services/OnchainBackupRestoreDeviceTest.kt +++ b/app/src/androidTest/java/to/bitkit/services/OnchainBackupRestoreDeviceTest.kt @@ -1,27 +1,32 @@ package to.bitkit.services import android.content.Context -import androidx.work.Configuration -import androidx.work.WorkManager import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import androidx.work.Configuration +import androidx.work.WorkManager import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import org.junit.Assume.assumeTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import to.bitkit.data.backup.VssBackupClient +import to.bitkit.data.keychain.Keychain import to.bitkit.env.Env import to.bitkit.models.BackupCategory +import to.bitkit.models.WalletBackupV1 import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.OnchainSendAttemptStore import javax.inject.Inject import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue /** Opt-in remote backup check; run only on an owned funded test wallet. */ @@ -32,6 +37,7 @@ class OnchainBackupRestoreDeviceTest { @Inject lateinit var backups: BackupRepo @Inject lateinit var vss: VssBackupClient @Inject lateinit var attempts: OnchainSendAttemptStore + @Inject lateinit var keychain: Keychain @Test fun remoteWalletBackupRestoresTheOriginalAcceptedPayment(): Unit = runBlocking { @@ -49,21 +55,38 @@ class OnchainBackupRestoreDeviceTest { assertTrue(original.hasPositiveEvidence && original.localFollowupComplete) assertTrue(original.requestId == null && !original.isTransfer) vss.setup(original.walletIndex).getOrThrow() - // Upload the complete owned fixture first: restoring must not rewind unrelated categories. - for (category in BackupCategory.entries.filterNot { it == BackupCategory.LIGHTNING_CONNECTIONS }) { - backups.triggerBackup(category).getOrThrow() + try { + keychain.upsertString( + Keychain.Key.ONCHAIN_SEND_ATTEMPT.name, + Json.encodeToString(original.copy(localFollowupComplete = false)), + original.walletIndex, + ) + assertTrue(assertNotNull(attempts.current()).blocksNextSend) + // Upload the complete owned fixture first: restoring must not rewind unrelated categories. + for (category in BackupCategory.entries.filterNot { it == BackupCategory.LIGHTNING_CONNECTIONS }) { + backups.triggerBackup(category).getOrThrow() + } + val remote = assertNotNull(vss.getObject(BackupCategory.WALLET.name).getOrThrow()) + val payload = Json.decodeFromString(remote.value.decodeToString()) + val wire = assertNotNull(payload.paykitPaymentState?.activeOnchainAttempt) + assertEquals(original.attemptId, wire.attemptId) + assertEquals(original.txid, wire.txid) + keychain.delete(Keychain.Key.ONCHAIN_SEND_ATTEMPT.name, original.walletIndex) + assertNull(attempts.current()) + backups.performFullRestoreFromLatestBackup().getOrThrow() + val restored = assertNotNull(attempts.current()) + assertEquals(original.attemptId, restored.attemptId) + assertEquals(original.walletIndex, restored.walletIndex) + assertEquals(original.walletId, restored.walletId) + assertEquals(original.txid, restored.txid) + assertEquals(original.amountSats, restored.amountSats) + assertEquals(original.originalInputs, restored.originalInputs) + assertEquals(original.candidateTxids, restored.candidateTxids) + assertTrue(restored.hasPositiveEvidence && restored.localFollowupComplete) + } finally { + attempts.restoreActive(original) + attempts.markLocalFollowupComplete(original.attemptId, original.walletIndex) } - assertNotNull(vss.getObject(BackupCategory.WALLET.name).getOrThrow()) - backups.performFullRestoreFromLatestBackup().getOrThrow() - val restored = assertNotNull(attempts.current()) - assertEquals(original.attemptId, restored.attemptId) - assertEquals(original.walletIndex, restored.walletIndex) - assertEquals(original.walletId, restored.walletId) - assertEquals(original.txid, restored.txid) - assertEquals(original.amountSats, restored.amountSats) - assertEquals(original.originalInputs, restored.originalInputs) - assertEquals(original.candidateTxids, restored.candidateTxids) - assertTrue(restored.hasPositiveEvidence && restored.localFollowupComplete) } } } diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 6259cd2595..5fec4a22f3 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -531,10 +531,10 @@ class ActivityRepo @Inject constructor( return@runCatching } - cacheStore.setActivityContactDetached(activity.rawId(), walletId, detached = false) val updatedAt = nowTimestamp().epochSecond.toULong() val updatedActivity = activity.withContact(normalizedKey, updatedAt) updateActivity(updatedActivity.rawId(), updatedActivity).getOrThrow() + cacheStore.setActivityContactDetached(activity.rawId(), walletId, detached = false) updateReplacementContactIfNeeded(updatedActivity, normalizedKey, updatedAt, walletId) }.onFailure { Logger.error("Failed to set contact for payment '$forPaymentId'", it, context = TAG) diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index 912747a598..afde582f8b 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -411,7 +411,37 @@ class OnchainSendAttemptStore @Inject constructor( suspend fun restoreActive(attempt: OnchainSendAttempt) = withContext(ioDispatcher + NonCancellable) { mutex.withLock { val existing = loadWithRetainedAccepted(attempt.walletIndex) - check(existing == null || !existing.blocksNextSend || existing == attempt) { + if (existing?.attemptId == attempt.attemptId) { + // A restore retry may replay an older receipt after fee/activity persistence progressed. + // Normalize only progress fields; recipient, payer, inputs and original context must match. + val sameOperation = existing.copy( + evidence = attempt.evidence, + txid = attempt.txid, + refusalReason = attempt.refusalReason, + localFollowupComplete = attempt.localFollowupComplete, + candidateTxids = attempt.candidateTxids, + candidateFeeRates = attempt.candidateFeeRates, + backupFollowup = existing.backupFollowup?.copy(feeSats = attempt.backupFollowup?.feeSats.orEmpty()), + restoredFromBackup = attempt.restoredFromBackup, + preparationPending = attempt.preparationPending, + ) == attempt + check(sameOperation && existing.candidateTxids.containsAll(attempt.candidateTxids)) { + "Cannot change a restored on-chain operation's original context" + } + persist( + if (existing.restoredFromBackup) { + existing + } else { + existing.copy( + restoredFromBackup = attempt.restoredFromBackup, + localFollowupComplete = false, + preparationPending = false, + ) + } + ) + return@withLock + } + check(existing == null || !existing.blocksNextSend) { "Cannot replace an existing active on-chain operation" } persist(attempt.copy(localFollowupComplete = false, preparationPending = false)) diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index a72fd30acd..13a88e59a2 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -618,6 +618,17 @@ class ActivityRepoTest : BaseUnitTest() { assertEquals(cancellation.message, thrown.message) } + @Test + fun `failed contact assignment preserves manual detachment`() = test { + val activity = createOnchainActivity(id = "detached", txId = "detached-tx") + whenever(coreService.activity.getActivity("detached-tx", WalletScope.default)).thenReturn(activity) + whenever(coreService.activity.update(eq(activity.v1.id), any())) + .thenThrow(RuntimeException("Core write failed")) + val result = sut.setContact("new-contact", "detached-tx", syncLdkPayments = false) + assertTrue(result.isFailure) + verify(cacheStore, never()).setActivityContactDetached(any(), any(), eq(false)) + } + @Test fun `setContact propagates contact to replacement transaction`() = test { val contactPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index e2e9c31b40..cd702b78e4 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -259,6 +259,34 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { assertEquals(observed?.copy(localFollowupComplete = true), reopened.current()) } + @Test + fun `restoring same accepted operation preserves progressed fee and completion`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val wire = requireNotNull( + kotlinx.serialization.json.Json { ignoreUnknownKeys = true } + .decodeFromString(bytes.decodeToString()) + .paykitPaymentState?.activeOnchainAttempt, + ) + val accepted = wire.copy(status = "accepted", followup = wire.followup?.copy(contact = null)) + .restored("regtest", wire.wallet.binding, "wallet0", 0) + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + store.restoreActive(accepted) + store.retainWinningFee(accepted.attemptId, 0, requireNotNull(accepted.txid), 777u) + val progressed = store.current() + store.restoreActive(accepted) + assertEquals(progressed, store.current()) + assertFailsWith { store.restoreActive(accepted.copy(address = "different-recipient")) } + assertFailsWith { store.restoreActive(accepted.copy(payerIdentity = "different-payer")) } + store.markLocalFollowupComplete(accepted.attemptId, 0) + val completed = store.current() + store.restoreActive(accepted) + assertEquals(completed, store.current()) + } + @Test fun `restored supported accepted context resets acknowledgement and finishes idempotently`() = test { val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() From f9cdbc4d24eaf1af7c43ed64594d90f3a506b8eb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 13:48:29 +0200 Subject: [PATCH 045/108] fix: keep recovery pending until acceptance is saved --- .../repositories/OnchainSendAttemptStore.kt | 5 ++ .../repositories/OnchainSendCoordinator.kt | 13 +++-- .../to/bitkit/viewmodels/TransferViewModel.kt | 14 +++++- .../OnchainSendCoordinatorTest.kt | 24 ++++++++++ .../viewmodels/TransferViewModelTest.kt | 47 ++++++++++++++++++- 5 files changed, 95 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index afde582f8b..6652b7bee8 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -144,6 +144,11 @@ class OnchainSendAttemptStore @Inject constructor( mutex.withLock { loadWithRetainedAccepted(lightningService.currentWalletIndex) } } + /** Read only restart-safe evidence, excluding a positive result retained after a failed write. */ + suspend fun currentDurable(): OnchainSendAttempt? = withContext(ioDispatcher) { + mutex.withLock { load(lightningService.currentWalletIndex) } + } + @Suppress("LongParameterList") suspend fun admit( walletId: String, diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt index ff0b696e90..502fdc57f0 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt @@ -161,7 +161,13 @@ class OnchainSendCoordinator( } } if (result.isFailure) { - val winner = runSuspendCatching { store.current() }.getOrNull() + val winner = runSuspendCatching { + if (result.exceptionOrNull() is OnchainSendPendingError) { + store.currentDurable() + } else { + store.current() + } + }.getOrNull() if (winner?.attemptId == attemptId && winner.walletId == walletId && winner.hasPositiveEvidence) { return@withContext Result.success(OnchainSendOutcome.Accepted(requireNotNull(winner.txid))) } @@ -187,13 +193,10 @@ class OnchainSendCoordinator( val recorded = runSuspendCatching { store.recordOutcome(attempt.attemptId, outcome, attempt.walletIndex) }.getOrElse { error -> - val winner = runSuspendCatching { store.current() }.getOrNull() + val winner = runSuspendCatching { store.currentDurable() }.getOrNull() if (winner?.attemptId == attempt.attemptId && winner.walletId == attempt.walletId && winner.hasPositiveEvidence) { return OnchainSendOutcome.Accepted(requireNotNull(winner.txid)) } - if (outcome is OnchainSendOutcome.Accepted && outcome.txid.equals(prepared.receipt.txid, true)) { - return outcome - } throw OnchainSendPendingError(error, outcome.txid) } return if (recorded.hasPositiveEvidence) { diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index da960e276d..caeee577dc 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -386,12 +386,22 @@ class TransferViewModel @Inject constructor( val txid = previous.txid if (previous.hasPositiveEvidence && txid != null) { if (!previous.localFollowupComplete) { + val original = requireNotNull(previous.transferContext) { + "Original funding context is unavailable" + } + check(order.payment?.onchain?.address == previous.address) { + "Original funding order address changed" + } + check( + order.clientBalanceSat == original.originalOrderClientBalanceSats && + order.feeSat == original.originalOrderFeeSats + ) { "Original funding order terms changed or are unavailable" } withContext(NonCancellable) { fundPaidOrder( order = order, txId = txid, - txTotalSats = previous.transferContext?.txTotalSats, - preTransferOnchainSats = previous.transferContext?.preTransferOnchainSats, + txTotalSats = original.txTotalSats, + preTransferOnchainSats = original.preTransferOnchainSats, requireTransferPersisted = true, ) runSuspendCatching { lightningRepo.completeAcceptedTransferFollowup(order.id, txid) } diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt index b332cb1afd..a05f1322bb 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt @@ -71,6 +71,30 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { } } + @Test + fun `accepted retry without durable acceptance stays pending after restart`() = test { + val f = Fixture() + val original = f.unresolved() + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error("unused") + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, + paymentDeadlineAt: Instant?, + ) = PreparedOnchainSend(f.receipt(nextTxid)) { + f.failWrite = true + OnchainSendOutcome.Accepted(nextTxid) + } + } + val result = OnchainSendCoordinator(f.store, sender, testDispatcher) + .retryOriginal(original.attemptId, original.walletId, 2uL) {} + assertTrue(result.exceptionOrNull() is OnchainSendPendingError) + val restarted = OnchainSendAttemptStore(testDispatcher, f.keychain, f.service, kotlin.time.Clock.System) + assertTrue(restarted.current()?.blocksNextSend == true) + assertTrue(restarted.current()?.hasPositiveEvidence == false) + assertEquals(listOf(firstTxid, nextTxid), restarted.current()?.candidateTxids) + } + @Test fun `retry retains deadline through authorization and rejects expired queued broadcast`() = test { for (expired in listOf(false, true)) { diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 175223861d..8190883d58 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -1330,6 +1330,48 @@ class TransferViewModelTest : BaseUnitTest() { verify(cacheStore, never()).addPaidOrder(any(), any()) } + @Test + fun `accepted confirmation rejects changed original client balance`() = test { + assertChangedFundingTermsRejected("balance") + } + + @Test + fun `accepted confirmation rejects changed original service fee`() = test { + assertChangedFundingTermsRejected("fee") + } + + @Test + fun `accepted confirmation rejects changed original funding address`() = test { + assertChangedFundingTermsRejected("address") + } + + private suspend fun TestScope.assertChangedFundingTermsRejected(changedTerm: String) { + val originalOrder = spendingOrder(feeSat = 98_000uL) + val original = OnchainTransferContext( + 99_000uL, + 125_000uL, + originalOrderClientBalanceSats = originalOrder.clientBalanceSat, + originalOrderFeeSats = originalOrder.feeSat, + ) + val attempt = acceptedFundingAttempt(originalOrder, original).let { + if (changedTerm == "address") it.copy(address = "different-original-address") else it + } + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + val changed = when (changedTerm) { + "balance" -> originalOrder.copy(clientBalanceSat = originalOrder.clientBalanceSat + 1uL) + "fee" -> originalOrder.copy(feeSat = originalOrder.feeSat + 1uL) + else -> originalOrder + } + quoteOrder(changed) + prepareConfirm() + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + verify(transferRepo, never()).persistAcceptedFunding(any(), any(), anyOrNull()) + verify(lightningRepo, never()).completeAcceptedTransferFollowup(any(), any()) + verifySendOnChain(sats = changed.feeSat, count = 0) + assertFalse(sut.spendingUiState.value.isConfirmPaying) + } + @Test fun `accepted transfer resumes after transfer storage failure without another send`() = test { val order = spendingOrder(feeSat = 98_000uL) @@ -1451,7 +1493,10 @@ class TransferViewModelTest : BaseUnitTest() { address = order.payment?.onchain?.address.orEmpty(), amountSats = order.feeSat, isMaxAmount = false, feeRateSatsPerVByte = 1uL, isTransfer = true, channelId = null, tags = emptyList(), evidence = OnchainSendEvidence.Accepted, txid = TXID, - transferContext = original, + transferContext = original?.copy( + originalOrderClientBalanceSats = original.originalOrderClientBalanceSats ?: order.clientBalanceSat, + originalOrderFeeSats = original.originalOrderFeeSats ?: order.feeSat, + ), ) @Test From cb127b7cc3edb96c4ad364a328a3f65e467345f9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 14:20:32 +0200 Subject: [PATCH 046/108] fix: prevent unsigned backups and lost hardware retries --- .../java/to/bitkit/repositories/BackupRepo.kt | 15 ++-- .../screens/wallets/send/HwSendViewModel.kt | 24 +++-- .../to/bitkit/repositories/BackupRepoTest.kt | 27 ++++++ .../wallets/send/HwSendViewModelTest.kt | 87 +++++++++++++++++++ 4 files changed, 141 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 2ffca5ae25..03b08ef62f 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -652,13 +652,14 @@ class BackupRepo @Inject constructor( transfers = db.transferDao().getAll() paykitPaymentProofRepo.get().backupSnapshot() } - // A private preparation may already have consumed a payment-list version. Defer the - // entire wallet snapshot until its receipt is retained; never omit only the guard/proof. - check(snapshotAttempt?.let { - it.preparationPending && !it.restoredFromBackup && it.requestId != null && - it.evidence == OnchainSendEvidence.Pending && it.txid == null && - it.originalInputs == null && it.candidateTxids.isEmpty() - } != true) { "Waiting for signed Shop receipt before wallet backup" } + // Every unsigned local guard is unrecoverable without its receipt. Defer the entire + // snapshot until signing finishes; never omit only the guard or related Shop proof. + check( + snapshotAttempt?.let { + !it.restoredFromBackup && it.evidence == OnchainSendEvidence.Pending && it.txid == null && + it.originalInputs == null && it.candidateTxids.isEmpty() + } != true + ) { "Waiting for signed receipt before wallet backup" } check(privateRepo.backupStateVersion.value == privateVersion && paykitSdkService.backupStateVersion.value == sdkVersion ) { "Private payment state changed during wallet snapshot" } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt index cab574ead1..b18e77469f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt @@ -170,11 +170,25 @@ class HwSendViewModel @Inject constructor( ): HwFundingBroadcastResult? { payment.request.paymentRequestId?.let { requestId -> val request = payment.request - val retained = paykitPaymentProofRepo.retainHardwareOnchainCandidate( - requestId, request.walletId, SignedTransactionId.fromHex(payment.signedTx.serializedTx), - request.paymentIdentity, request.address, request.amountSats, - ) - if (!retained) return null + // Protect the only signed receipt while its durable lookup is being saved as well. + _uiState.update { it.copy(isBroadcastUnresolved = true) } + val retained = runSuspendCatching { + paykitPaymentProofRepo.retainHardwareOnchainCandidate( + requestId, + request.walletId, + SignedTransactionId.fromHex(payment.signedTx.serializedTx), + request.paymentIdentity, + request.address, + request.amountSats, + ) + }.onFailure { Logger.warn("Failed to retain signed hardware receipt", it, context = TAG) } + .getOrDefault(false) + if (!retained) { + // No dispatch occurred. Keep the exact signed transaction available for a save retry; + // Back/cancel must not discard it while the durable Shop preparation remains started. + _uiState.update { it.copy(isBroadcastUnresolved = true) } + return null + } } _uiState.update { it.copy(isBroadcastUnresolved = true) } pendingBroadcast = payment.copy(hasAttemptedBroadcast = true) diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index d50fe91c60..6016cda7c7 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -294,6 +294,33 @@ class BackupRepoTest : BaseUnitTest() { verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) } + @Test + fun `wallet backup defers unsigned ordinary and transfer snapshots`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + val attempt = wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0).copy( + evidence = OnchainSendEvidence.Pending, + txid = null, + originalInputs = null, + candidateTxids = emptyList(), + candidateFeeRates = emptyMap(), + preparationPending = true, + restoredFromBackup = false, + ) + val unsigned = attempt.copy(requestId = null, preparationPending = false) + val original = state.pendingProofs.single().copy(paymentIdentifier = null) + val unrelated = original.copy(paymentAppId = "unrelated-app", identity = "other-payer") + whenever(onchainSendAttemptStore.backupSnapshot(0)).thenReturn(unsigned) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + whenever(paykitPaymentProofRepo.backupSnapshot()).thenReturn(listOf(original, unrelated)) + for (transfer in listOf(false, true)) { + whenever(onchainSendAttemptStore.backupSnapshot(0)).thenReturn(unsigned.copy(isTransfer = transfer)) + assertTrue(sut.triggerBackup(BackupCategory.WALLET).isFailure) + } + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + } + @Test fun `wallet backup exports and restores shared active guard before original proof`() = test { val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt index cca020c64e..5be2db9149 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt @@ -679,6 +679,93 @@ class HwSendViewModelTest : BaseUnitTest() { ) } + @Test + fun `cancel cannot discard signed hardware receipt while candidate save is suspended`() = test { + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + ) + val resume = CompletableDeferred() + whenever { + proofRepo.retainHardwareOnchainCandidate( + any(), + any(), + any(), + org.mockito.kotlin.anyOrNull(), + any(), + any(), + ) + }.doSuspendableAnswer { resume.await() } + sut.signAndBroadcast(original) + advanceUntilIdle() + assertTrue(sut.uiState.value.isBroadcastUnresolved) + sut.cancel() + assertTrue(sut.uiState.value.hasPendingBroadcast) + resume.complete(true) + advanceUntilIdle() + verify(hwWalletRepo, times(1)).signFunding(WALLET_ID, fixture.funding) + verify(hwWalletRepo, times(1)).broadcastFunding(fixture.signedTx) + } + + @Test + fun `hardware candidate save denial retains signed receipt across cancel and retries without signing`() = test { + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + ) + whenever { + proofRepo.retainHardwareOnchainCandidate( + any(), + any(), + any(), + org.mockito.kotlin.anyOrNull(), + any(), + any(), + ) + } + .thenReturn(false, true) + sut.signAndBroadcast(original) + advanceUntilIdle() + assertTrue(sut.uiState.value.isBroadcastUnresolved) + sut.cancel() + assertTrue(sut.uiState.value.hasPendingBroadcast) + sut.signAndBroadcast(original) + advanceUntilIdle() + verify(hwWalletRepo, times(1)).signFunding(WALLET_ID, fixture.funding) + verify(hwWalletRepo, times(1)).broadcastFunding(fixture.signedTx) + } + + @Test + fun `hardware candidate save exception retains signed receipt across cancel and retries without signing`() = test { + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + ) + whenever { + proofRepo.retainHardwareOnchainCandidate( + any(), + any(), + any(), + org.mockito.kotlin.anyOrNull(), + any(), + any(), + ) + } + .thenThrow(IllegalStateException("storage unavailable")).thenReturn(true) + sut.signAndBroadcast(original) + advanceUntilIdle() + assertTrue(sut.uiState.value.isBroadcastUnresolved) + sut.cancel() + assertTrue(sut.uiState.value.hasPendingBroadcast) + sut.signAndBroadcast(original) + advanceUntilIdle() + verify(hwWalletRepo, times(1)).signFunding(WALLET_ID, fixture.funding) + verify(hwWalletRepo, times(1)).broadcastFunding(fixture.signedTx) + } + @Test fun `matching asynchronous completion consumes retained result and permits the next hardware send`() = test { val fixture = stubSuccessfulPayment() From 51cc0147d9ea1bf6ecd5786c714216a2ddb20aa4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 14:46:32 +0200 Subject: [PATCH 047/108] fix: retain failed hardware shop payments --- .../screens/wallets/send/HwSendViewModel.kt | 5 +++- .../PaykitPaymentProofRepoTest.kt | 3 ++- .../wallets/send/HwSendViewModelTest.kt | 26 +++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt index b18e77469f..cbee917d14 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt @@ -388,7 +388,10 @@ class HwSendViewModel @Inject constructor( } private suspend fun handleFailure(error: Throwable, walletId: String) { - _uiState.update { it.copy(isBroadcastUnresolved = false) } + val retainedShopPayment = pendingBroadcast?.let { + it.request.paymentRequestId != null && it.hasAttemptedBroadcast + } == true + _uiState.update { it.copy(isBroadcastUnresolved = retainedShopPayment) } when { error.isHwUserCancellation() -> { Logger.info("Hardware send cancelled on device for '$walletId'", context = TAG) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index f68a34f82f..e6fab7df66 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -1667,7 +1667,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { }) advanceUntilIdle() assertFalse(send.uiState.value.isSigning) - assertFalse(send.uiState.value.isBroadcastUnresolved) + assertTrue(send.uiState.value.isBroadcastUnresolved) assertTrue(storedProofs.single().paymentStarted) assertEquals("605fe246a6d51450ecff51ac3d0415f8824964e06a60ed6e186fa163cf1e9d4e", storedProofs.single().paymentIdentifier) @@ -1675,6 +1675,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.failOnchainPayment(request) repo.cancelPreparation(request) send.cancel() + assertTrue(send.uiState.value.hasPendingBroadcast) assertTrue(storedProofs.single().paymentStarted) assertNull(storedProofs.single().proofData) assertEquals(PaykitPaymentRequestError.OperationInProgress, diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt index 5be2db9149..2aebc28660 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt @@ -311,6 +311,32 @@ class HwSendViewModelTest : BaseUnitTest() { assertFalse(sut.uiState.value.isBroadcastUnresolved) } + @Test + fun `Shop broadcast connectivity failure retains original signed payment across cancel`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + ) + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)).thenReturn( + Result.failure(BroadcastException.ElectrumException("connection failed")), + Result.success(fixture.broadcast), + ) + sut.signAndBroadcast(original) + advanceUntilIdle() + assertTrue(sut.uiState.value.isBroadcastUnresolved) + sut.cancel() + advanceUntilIdle() + assertTrue(sut.uiState.value.hasPendingBroadcast) + sut.signAndBroadcast(original) + advanceUntilIdle() + verify(hwWalletRepo, times(1)).signFunding(WALLET_ID, fixture.funding) + verify(hwWalletRepo, times(2)).broadcastFunding(fixture.signedTx) + assertEquals(original.paymentRequestId, sut.results.first().paymentRequestId) + assertEquals(original.paymentIdentity, sut.results.first().paymentIdentity) + } + @Test fun `broadcast connectivity failure unblocks navigation`() = test { whenever(context.getString(any())).thenReturn("message") From 9758e9802940575934cd075028b636594afb10e7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 15:18:19 +0200 Subject: [PATCH 048/108] fix: preserve shop receipts for all broadcast failures --- .../screens/wallets/send/HwSendViewModel.kt | 2 +- .../wallets/send/HwSendViewModelTest.kt | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt index cbee917d14..9313cd41ec 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt @@ -425,7 +425,7 @@ class HwSendViewModel @Inject constructor( description = context.getString(R.string.wallet__payment_timeout), ) else -> { - if (pendingBroadcast != null) { + if (pendingBroadcast != null && !retainedShopPayment) { pendingBroadcast = null _uiState.update { it.copy(hasPendingBroadcast = false) } } diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt index 2aebc28660..4380360e8c 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt @@ -337,6 +337,32 @@ class HwSendViewModelTest : BaseUnitTest() { assertEquals(original.paymentIdentity, sut.results.first().paymentIdentity) } + @Test + fun `Shop invalid broadcast failure retains original signed payment across cancel`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + ) + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)).thenReturn( + Result.failure(BroadcastException.InvalidTransaction("invalid transaction")), + Result.success(fixture.broadcast), + ) + sut.signAndBroadcast(original) + advanceUntilIdle() + assertTrue(sut.uiState.value.isBroadcastUnresolved) + sut.cancel() + advanceUntilIdle() + assertTrue(sut.uiState.value.hasPendingBroadcast) + sut.signAndBroadcast(original) + advanceUntilIdle() + verify(hwWalletRepo, times(1)).signFunding(WALLET_ID, fixture.funding) + verify(hwWalletRepo, times(2)).broadcastFunding(fixture.signedTx) + assertEquals(original.paymentRequestId, sut.results.first().paymentRequestId) + assertEquals(original.paymentIdentity, sut.results.first().paymentIdentity) + } + @Test fun `broadcast connectivity failure unblocks navigation`() = test { whenever(context.getString(any())).thenReturn("message") From 0eeb96f825424665020c6fc3f2d1416bef7868a6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 16:00:09 +0200 Subject: [PATCH 049/108] fix: recover original hardware shop receipts --- .../bitkit/models/PaykitPaymentStateBackup.kt | 12 +++ .../repositories/PaykitPaymentProofRepo.kt | 81 ++++++++++++++-- .../screens/wallets/send/HwSendViewModel.kt | 21 +++++ .../PaykitPaymentProofRepoTest.kt | 90 ++++++++++++++++++ .../wallets/send/HwSendViewModelTest.kt | 94 +++++++++++++++++-- 5 files changed, 283 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt index 43fbc1c07e..92c3cd8648 100644 --- a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt +++ b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt @@ -54,6 +54,10 @@ data class PaykitPaymentStateBackup( val onchainWalletId: String? = null, val onchainMatchingTransactionIdsBeforeAttempt: Set, val onchainAcceptanceVerified: Boolean = false, + val hardwareSignedTransaction: String? = null, + val hardwareMiningFeeSats: ULong? = null, + val hardwareFeeRate: ULong? = null, + val hardwareTotalSpent: ULong? = null, ) { constructor(proof: PendingPaykitPaymentProof) : this( identity = proof.identity, @@ -70,6 +74,10 @@ data class PaykitPaymentStateBackup( onchainWalletId = proof.onchainWalletId, onchainMatchingTransactionIdsBeforeAttempt = proof.onchainMatchingTransactionIdsBeforeAttempt, onchainAcceptanceVerified = proof.onchainAcceptanceVerified, + hardwareSignedTransaction = proof.hardwareSignedTransaction, + hardwareMiningFeeSats = proof.hardwareMiningFeeSats, + hardwareFeeRate = proof.hardwareFeeRate, + hardwareTotalSpent = proof.hardwareTotalSpent ) fun restored() = PendingPaykitPaymentProof( @@ -87,6 +95,10 @@ data class PaykitPaymentStateBackup( onchainWalletId = onchainWalletId ?: WalletScope.default, onchainMatchingTransactionIdsBeforeAttempt = onchainMatchingTransactionIdsBeforeAttempt, onchainAcceptanceVerified = onchainAcceptanceVerified, + hardwareSignedTransaction = hardwareSignedTransaction, + hardwareMiningFeeSats = hardwareMiningFeeSats, + hardwareFeeRate = hardwareFeeRate, + hardwareTotalSpent = hardwareTotalSpent ) } } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index eaa7919520..bd90ea54ec 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -34,12 +34,14 @@ import to.bitkit.di.IoDispatcher import to.bitkit.ext.fromHex import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toHex +import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.PaykitPaymentStateBackup import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.WalletScope import to.bitkit.services.PaykitSdkService import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError +import to.bitkit.utils.SignedTransactionId import to.bitkit.utils.asNodeException import java.security.MessageDigest import javax.inject.Inject @@ -78,6 +80,10 @@ data class PendingPaykitPaymentProof( val onchainMatchingTransactionIdsBeforeAttempt: Set = emptySet(), val onchainAcceptanceVerified: Boolean = false, val hardwareDispatchDenied: Boolean = false, + val hardwareSignedTransaction: String? = null, + val hardwareMiningFeeSats: ULong? = null, + val hardwareFeeRate: ULong? = null, + val hardwareTotalSpent: ULong? = null, val privatePaymentListVersion: ULong? = null, ) @@ -502,26 +508,87 @@ class PaykitPaymentProofRepo @Inject constructor( identity: String?, address: String, amountSats: ULong, + signedTx: HwFundingSignedTx? = null, ): Boolean = withContext(ioDispatcher) { if (walletId == WalletScope.default || !txid.isHex(HASH_BYTE_COUNT)) return@withContext false + if (signedTx != null && + runCatching { SignedTransactionId.fromHex(signedTx.serializedTx) } + .getOrNull()?.equals(txid, true) != true + ) { + return@withContext false + } val originalIdentity = identity?.let(PubkyPublicKeyFormat::normalized) ?: return@withContext false operationMutex.withLock { if (!PubkyPublicKeyFormat.matches(currentIdentity(), originalIdentity)) return@withLock false val proofs = loadProofs() val original = proofs.singleOrNull { - PubkyPublicKeyFormat.matches(it.identity, originalIdentity) && it.requestId == requestId && - it.onchainWalletId == walletId && it.kind == PaykitPaymentProofKind.Onchain && - it.paymentStarted && !it.hardwareDispatchDenied && - it.onchainAddress == address && it.onchainAmountSats == amountSats && - it.proofData == null && !it.onchainAcceptanceVerified && - (it.paymentIdentifier == null || it.paymentIdentifier.equals(txid, true)) + PubkyPublicKeyFormat.matches(it.identity, originalIdentity) && + it.requestId == requestId && + it.onchainWalletId == walletId && + it.kind == PaykitPaymentProofKind.Onchain && + it.paymentStarted && + !it.hardwareDispatchDenied && + it.onchainAddress == address && + it.onchainAmountSats == amountSats && + it.proofData == null && + !it.onchainAcceptanceVerified && + (it.paymentIdentifier == null || it.paymentIdentifier.equals(txid, true)) && + (it.hardwareSignedTransaction == null || it.hardwareSignedTransaction == signedTx?.serializedTx) } ?: return@withLock false // Lookup identity only: never acceptance, a Sent activity, or a delivered proof. - persist(proofs.map { if (it == original) it.copy(paymentIdentifier = txid.lowercase()) else it }) + persist( + proofs.map { + if (it == original) { + it.copy( + paymentIdentifier = txid.lowercase(), + hardwareSignedTransaction = signedTx?.serializedTx ?: it.hardwareSignedTransaction, + hardwareMiningFeeSats = signedTx?.miningFeeSats ?: it.hardwareMiningFeeSats, + hardwareFeeRate = signedTx?.feeRate ?: it.hardwareFeeRate, + hardwareTotalSpent = signedTx?.totalSpent ?: it.hardwareTotalSpent + ) + } else { + it + } + } + ) true } } + suspend fun retainedHardwareOnchainPayment( + requestId: PaykitPaymentRequestId, + walletId: String, + identity: String?, + address: String, + amountSats: ULong, + ): HwFundingSignedTx? = withContext(ioDispatcher) { + operationMutex.withLock { + if (!PubkyPublicKeyFormat.matches(currentIdentity(), identity)) return@withLock null + val proof = loadProofs().singleOrNull { + PubkyPublicKeyFormat.matches(it.identity, identity) && + it.requestId == requestId && + it.onchainWalletId == walletId && + walletId != WalletScope.default && + it.kind == PaykitPaymentProofKind.Onchain && + it.paymentStarted && + !it.hardwareDispatchDenied && + !it.onchainAcceptanceVerified && + it.proofData == null && + it.onchainAddress == address && + it.onchainAmountSats == amountSats + } ?: return@withLock null + proof.retainedSignedHardwareReceipt() + } + } + + private fun PendingPaykitPaymentProof.retainedSignedHardwareReceipt(): HwFundingSignedTx? { + val raw = hardwareSignedTransaction ?: return null + val txid = runCatching { SignedTransactionId.fromHex(raw) }.getOrNull() ?: return null + if (!txid.equals(paymentIdentifier, true)) return null + if (hardwareMiningFeeSats == null || hardwareFeeRate == null || hardwareTotalSpent == null) return null + return HwFundingSignedTx(raw, hardwareMiningFeeSats, hardwareFeeRate, hardwareTotalSpent) + } + suspend fun clearHardwareOnchainCandidateBeforeDispatch( requestId: PaykitPaymentRequestId, walletId: String, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt index 9313cd41ec..add1fe7e22 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt @@ -99,6 +99,26 @@ class HwSendViewModel @Inject constructor( try { runCatching { var pending = pendingBroadcast?.takeIf { it.matches(request) } + if (pending == null && request.paymentRequestId != null) { + val restored = paykitPaymentProofRepo.retainedHardwareOnchainPayment( + request.paymentRequestId, + request.walletId, + request.paymentIdentity, + request.address, + request.amountSats + ) + if (restored != null) { + pending = + PendingHwSendBroadcast( + request, + restored, + isPreparedForBroadcast = true, + hasAttemptedBroadcast = true + ) + pendingBroadcast = pending + _uiState.update { it.copy(hasPendingBroadcast = true, isBroadcastUnresolved = true) } + } + } if (pending == null && hwWalletRepo.needsPassphrase(request.walletId)) { _uiState.update { it.copy(isPassphraseRequired = true) } return@runCatching @@ -180,6 +200,7 @@ class HwSendViewModel @Inject constructor( request.paymentIdentity, request.address, request.amountSats, + payment.signedTx ) }.onFailure { Logger.warn("Failed to retain signed hardware receipt", it, context = TAG) } .getOrDefault(false) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index e6fab7df66..511c05cab4 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -28,6 +28,8 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent +import kotlinx.serialization.json.Json +import kotlinx.serialization.encodeToString import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.NodeException @@ -50,6 +52,7 @@ import to.bitkit.models.HwConnectedDevice import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.HwFundingTransaction import to.bitkit.models.NodeLifecycleState +import to.bitkit.models.PaykitPaymentStateBackup import to.bitkit.models.WalletScope import to.bitkit.services.CoreService import to.bitkit.data.keychain.Keychain @@ -60,6 +63,7 @@ import to.bitkit.ui.screens.wallets.send.HwSendViewModel import to.bitkit.utils.AppError import to.bitkit.utils.LdkError import to.bitkit.utils.ServiceError +import to.bitkit.utils.SignedTransactionId import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse @@ -1515,6 +1519,92 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.isEmpty()) } + @Test + @Suppress("LongMethod") + fun `signed hardware Shop receipt restores exact bytes before explicit retry`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val walletId = "original-hardware-wallet" + val signed = + HwFundingSignedTx( + "02000000000101f7c5a048189164c6b05b07516b5dbb9c826c601d12dc4ed97f0069618b8b7c1601" + + "00000000fdffffff024179010000000000160014f066a63663b0d464b31a7a88619beae011c3fb7b" + + "e80300000000000016001483ea855bb508cb08ed9e8cf9152d8927871c19aa02473044022052c5a1" + + "5ade616af16f314bcc2ae15bf4ef4996e0f2315794e647ba6c955745b602200f3095f4a7deb39a94" + + "716c0fd2001a2fbff1861a8ff0c2015739a40a62891c22012102cb13c86b55418d0e3bccf2911539" + + "4e1fb6a9f209d3f59dc9bbb0805b253464cb724c0300", + 141uL, + 2uL, + request.amountSats + 141uL + ) + val txid = SignedTransactionId.fromHex(signed.serializedTx) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + assertTrue( + repo.retainHardwareOnchainCandidate( + request.id, + walletId, + txid, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amountSats, + signed + ) + ) + val encoded = Json.encodeToString(repo.backupSnapshot()) + storedProofs = emptyList() + val reopened = paymentProofRepo() + reopened.restoreBackup(Json.decodeFromString>(encoded)) + assertEquals( + signed, + reopened.retainedHardwareOnchainPayment( + request.id, + walletId, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amountSats + ) + ) + assertNull( + reopened.retainedHardwareOnchainPayment( + request.id, + "other-wallet", + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amountSats + ) + ) + assertNull( + reopened.retainedHardwareOnchainPayment( + request.id, + walletId, + COUNTERPARTY, + ONCHAIN_ADDRESS, + request.amountSats + ) + ) + assertNull( + reopened.retainedHardwareOnchainPayment( + request.id, + walletId, + LOCAL_IDENTITY, + "other-address", + request.amountSats + ) + ) + assertNull( + reopened.retainedHardwareOnchainPayment( + request.id, + walletId, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amountSats + 1uL + ) + ) + assertFalse(storedProofs.single().onchainAcceptanceVerified) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + } + @Test fun `prebroadcast hardware candidate survives repository reopen without claiming acceptance`() = test { val request = paymentRequest(MethodId.P2wpkh.rawValue) diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt index 4380360e8c..eba6072b23 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt @@ -64,10 +64,29 @@ class HwSendViewModelTest : BaseUnitTest() { @Before fun setUp() { whenever(coreService.activity).thenReturn(activityService) - whenever { proofRepo.retainHardwareOnchainCandidate(any(), any(), any(), org.mockito.kotlin.anyOrNull(), any(), any()) } + whenever { + proofRepo.retainHardwareOnchainCandidate( + any(), + any(), + any(), + org.mockito.kotlin.anyOrNull(), + any(), + any(), + org.mockito.kotlin.anyOrNull() + ) + } .thenReturn(true) - whenever { proofRepo.clearHardwareOnchainCandidateBeforeDispatch(any(), any(), any(), - org.mockito.kotlin.anyOrNull(), any(), any(), any()) }.thenReturn(false) + whenever { + proofRepo.clearHardwareOnchainCandidateBeforeDispatch( + any(), + any(), + any(), + org.mockito.kotlin.anyOrNull(), + any(), + any(), + any() + ) + }.thenReturn(false) whenever { hwWalletRepo.reconnectTimeout(any()) }.thenReturn(30.seconds) sut = HwSendViewModel( context = context, @@ -714,20 +733,76 @@ class HwSendViewModelTest : BaseUnitTest() { return PaymentFixture(funding, signedTx, broadcast) } + @Test + fun `restored hardware Shop receipt retries only after authorization without signing again`() = test { + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity" + ) + whenever { + proofRepo.retainedHardwareOnchainPayment( + requireNotNull(original.paymentRequestId), + WALLET_ID, + original.paymentIdentity, + ADDRESS, + AMOUNT_SATS + ) + }.thenReturn(fixture.signedTx) + sut.signAndBroadcast( + original, + prepareContactPayment = { error("must retain original preparation") }, + authorizeContactPayment = { attempted -> + assertTrue(attempted) + false + } + ) + advanceUntilIdle() + verify(hwWalletRepo, never()).signFunding(any(), any()) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + sut.signAndBroadcast( + original, + prepareContactPayment = { error("must retain original preparation") }, + authorizeContactPayment = { attempted -> + assertTrue(attempted) + true + } + ) + advanceUntilIdle() + verify(hwWalletRepo, never()).signFunding(any(), any()) + verify(hwWalletRepo, times(1)).broadcastFunding(fixture.signedTx) + } + @Test fun `hardware Shop never broadcasts when candidate persistence fails`() = test { val fixture = stubSuccessfulPayment() - val original = request().copy(paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), - paymentIdentity = "original-identity") - whenever { proofRepo.retainHardwareOnchainCandidate(any(), any(), any(), org.mockito.kotlin.anyOrNull(), any(), any()) } + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity" + ) + whenever { + proofRepo.retainHardwareOnchainCandidate( + any(), + any(), + any(), + org.mockito.kotlin.anyOrNull(), + any(), + any(), + org.mockito.kotlin.anyOrNull() + ) + } .thenReturn(false) sut.signAndBroadcast(original) advanceUntilIdle() verify(hwWalletRepo, never()).broadcastFunding(fixture.signedTx) verify(proofRepo).retainHardwareOnchainCandidate( - requireNotNull(original.paymentRequestId), WALLET_ID, + requireNotNull(original.paymentRequestId), + WALLET_ID, "605fe246a6d51450ecff51ac3d0415f8824964e06a60ed6e186fa163cf1e9d4e", - original.paymentIdentity, ADDRESS, AMOUNT_SATS, + original.paymentIdentity, + ADDRESS, + AMOUNT_SATS, + fixture.signedTx ) } @@ -747,6 +822,7 @@ class HwSendViewModelTest : BaseUnitTest() { org.mockito.kotlin.anyOrNull(), any(), any(), + org.mockito.kotlin.anyOrNull() ) }.doSuspendableAnswer { resume.await() } sut.signAndBroadcast(original) @@ -775,6 +851,7 @@ class HwSendViewModelTest : BaseUnitTest() { org.mockito.kotlin.anyOrNull(), any(), any(), + org.mockito.kotlin.anyOrNull() ) } .thenReturn(false, true) @@ -804,6 +881,7 @@ class HwSendViewModelTest : BaseUnitTest() { org.mockito.kotlin.anyOrNull(), any(), any(), + org.mockito.kotlin.anyOrNull() ) } .thenThrow(IllegalStateException("storage unavailable")).thenReturn(true) From 3925ef2bff2ab49110faf60146c80932796fb975 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 16:23:14 +0200 Subject: [PATCH 050/108] fix: retain shop receipt before authorization --- .../repositories/PaykitPaymentProofRepo.kt | 10 +++ .../screens/wallets/send/HwSendSignScreen.kt | 3 +- .../screens/wallets/send/HwSendViewModel.kt | 6 +- .../java/to/bitkit/ui/sheets/SendSheet.kt | 8 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 4 + .../PaykitPaymentProofRepoTest.kt | 53 ++++++++----- .../wallets/send/HwSendViewModelTest.kt | 4 +- .../viewmodels/AppViewModelSendFlowTest.kt | 76 +++++++++++++------ 8 files changed, 116 insertions(+), 48 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index bd90ea54ec..f4ad801d80 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -228,9 +228,14 @@ class PaykitPaymentProofRepo @Inject constructor( address: String, walletId: String = WalletScope.default, privatePaymentListVersion: ULong? = null, + signedTx: HwFundingSignedTx? = null, ): Result = withContext(ioDispatcher) { runSuspendCatching { val identity = currentIdentity() ?: throw PaykitPaymentRequestError.RequestUnavailable + val signedTxid = signedTx?.let { + if (walletId == WalletScope.default) throw PaykitPaymentRequestError.RequestUnavailable + SignedTransactionId.fromHex(it.serializedTx) + } operationMutex.withLock { val proofs = loadProofs().toMutableList() if (proofs.any { it.isStartedFor(identity, request.id) }) { @@ -251,6 +256,11 @@ class PaykitPaymentProofRepo @Inject constructor( onchainAmountSats = request.amountSats, onchainWalletId = walletId, privatePaymentListVersion = privatePaymentListVersion, + paymentIdentifier = signedTxid, + hardwareSignedTransaction = signedTx?.serializedTx, + hardwareMiningFeeSats = signedTx?.miningFeeSats, + hardwareFeeRate = signedTx?.feeRate, + hardwareTotalSpent = signedTx?.totalSpent, ) persist(proofs) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt index a5edd921b0..3b93fe6075 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendSignScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R +import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.HwWalletVendor import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.BodySSB @@ -49,7 +50,7 @@ fun HwSendSignScreen( paymentIdentity: String?, satsPerVByte: ULong, viewModel: HwSendViewModel, - prepareContactPayment: suspend () -> Boolean, + prepareContactPayment: suspend (HwFundingSignedTx) -> Boolean, authorizeContactPayment: suspend (hasAttemptedBroadcast: Boolean) -> Boolean, onPaymentDeadlineExpired: suspend (hasAttemptedBroadcast: Boolean) -> Unit, paymentDeadlineAt: Instant?, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt index add1fe7e22..e18dce6c1c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt @@ -86,7 +86,7 @@ class HwSendViewModel @Inject constructor( fun signAndBroadcast( request: HwSendRequest, - prepareContactPayment: suspend () -> Boolean = { true }, + prepareContactPayment: suspend (HwFundingSignedTx) -> Boolean = { true }, authorizeContactPayment: suspend (hasAttemptedBroadcast: Boolean) -> Boolean = { true }, onPaymentDeadlineExpired: suspend (hasAttemptedBroadcast: Boolean) -> Unit = {}, ) { @@ -136,7 +136,7 @@ class HwSendViewModel @Inject constructor( } var payment = checkNotNull(pending) { "Hardware payment was not prepared" } if (payment.isPreparedForBroadcast.not()) { - if (!prepareContactPayment()) return@runCatching + if (!prepareContactPayment(payment.signedTx)) return@runCatching payment = payment.copy(isPreparedForBroadcast = true) pendingBroadcast = payment } @@ -237,7 +237,7 @@ class HwSendViewModel @Inject constructor( fun submitPassphrase( request: HwSendRequest, passphrase: String, - prepareContactPayment: suspend () -> Boolean = { true }, + prepareContactPayment: suspend (HwFundingSignedTx) -> Boolean = { true }, authorizeContactPayment: suspend (hasAttemptedBroadcast: Boolean) -> Boolean = { true }, onPaymentDeadlineExpired: suspend (hasAttemptedBroadcast: Boolean) -> Unit = {}, ) { diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index e29aa2e5ba..5a8188e9be 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -408,9 +408,13 @@ fun SendSheet( paymentIdentity = paymentIdentity, satsPerVByte = satsPerVByte, viewModel = hwSendViewModel, - prepareContactPayment = { + prepareContactPayment = { signedTx -> appViewModel.prepareHardwareContactPayment( - walletId, uiState.address, uiState.incomingPaymentRequestId, paymentIdentity, + walletId, + uiState.address, + uiState.incomingPaymentRequestId, + paymentIdentity, + signedTx, ) }, authorizeContactPayment = { hasAttemptedBroadcast -> diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 892998cf08..69714f3677 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -144,6 +144,7 @@ import to.bitkit.models.sanitizedQrLogValue import to.bitkit.models.toActivityFilter import to.bitkit.models.toLdkNetwork import to.bitkit.models.toTxType +import to.bitkit.models.HwFundingSignedTx import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo @@ -5810,6 +5811,7 @@ class AppViewModel @Inject constructor( address: String = _sendUiState.value.address, requestId: PaykitPaymentRequestId? = activeIncomingPaymentRequest()?.id, identity: String? = hardwarePaymentIdentity(), + signedTx: HwFundingSignedTx? = null, ): Boolean { val contactPaymentContext = synchronized(contactPaymentContextLock) { activeContactPaymentContext } if (contactPaymentContext?.incomingPaymentRequest?.id != requestId) return false @@ -5817,6 +5819,7 @@ class AppViewModel @Inject constructor( if (incomingPaymentRequest != null && (identity == null || !PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value)) ) return false + if (incomingPaymentRequest != null && signedTx == null) return false // A retry of the original signed transaction skips this hook in HwSendViewModel. // A new Shop payment must consult the durable paymentStarted guard again. if (incomingPaymentRequest == null && isPreparedContactPayment(contactPaymentContext)) return true @@ -5833,6 +5836,7 @@ class AppViewModel @Inject constructor( paykitPaymentProofRepo.markOnchainPaymentStarted( requireNotNull(incomingPaymentRequest), address, walletId ?: WalletScope.default, contactPaymentContext.privatePaymentContext?.paymentListVersion, + signedTx, ).onFailure { synchronized(contactPaymentContextLock) { if (preparedContactPaymentContext == contactPaymentContext) preparedContactPaymentContext = null diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 511c05cab4..5da5897397 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -1539,18 +1539,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val txid = SignedTransactionId.fromHex(signed.serializedTx) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() - assertTrue( - repo.retainHardwareOnchainCandidate( - request.id, - walletId, - txid, - LOCAL_IDENTITY, - ONCHAIN_ADDRESS, - request.amountSats, - signed - ) - ) + // Snapshot at the authorization boundary, before any broadcast callback can save a receipt. + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId, signedTx = signed).getOrThrow() val encoded = Json.encodeToString(repo.backupSnapshot()) storedProofs = emptyList() val reopened = paymentProofRepo() @@ -1750,11 +1740,40 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(hwWalletRepo.broadcastFunding(signed)) .thenReturn(Result.failure(BroadcastException.ElectrumException("response lost after dispatch"))) val send = HwSendViewModel(context, hwWalletRepo, mock(), mock(), mock(), repo) - send.signAndBroadcast(HwSendRequest(walletId, ONCHAIN_ADDRESS, request.amountSats, 2uL, emptyList(), - request.id, LOCAL_IDENTITY), prepareContactPayment = { - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() - true - }) + send.signAndBroadcast( + HwSendRequest( + walletId, + ONCHAIN_ADDRESS, + request.amountSats, + 2uL, + emptyList(), + request.id, + LOCAL_IDENTITY, + ), + prepareContactPayment = { receipt -> + assertEquals(signed, receipt) + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId, signedTx = receipt).getOrThrow() + true + }, + authorizeContactPayment = { + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + val backup = Json.encodeToString(repo.backupSnapshot()) + storedProofs = emptyList() + val reopened = paymentProofRepo() + reopened.restoreBackup(Json.decodeFromString>(backup)) + assertEquals( + signed, + reopened.retainedHardwareOnchainPayment( + request.id, + walletId, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amountSats, + ), + ) + true + }, + ) advanceUntilIdle() assertFalse(send.uiState.value.isSigning) assertTrue(send.uiState.value.isBroadcastUnresolved) diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt index eba6072b23..1fc1eb9a92 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt @@ -168,7 +168,7 @@ class HwSendViewModelTest : BaseUnitTest() { Result.success(fixture.broadcast), ) var preparationCalls = 0 - val prepareContactPayment: suspend () -> Boolean = { + val prepareContactPayment: suspend (HwFundingSignedTx) -> Boolean = { verify(hwWalletRepo).signFunding(WALLET_ID, fixture.funding) verify(hwWalletRepo, never()).broadcastFunding(fixture.signedTx) preparationCalls += 1 @@ -251,7 +251,7 @@ class HwSendViewModelTest : BaseUnitTest() { whenever(hwWalletRepo.reconnectWithPassphrase(WALLET_ID, "hidden wallet")) .thenReturn(Result.success(Unit)) var preparationCalls = 0 - val prepareContactPayment: suspend () -> Boolean = { + val prepareContactPayment: suspend (HwFundingSignedTx) -> Boolean = { preparationCalls += 1 true } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index c446f14367..925195e841 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -99,6 +99,7 @@ import to.bitkit.ext.toSendFailureDetails import to.bitkit.models.BalanceState import to.bitkit.models.ConvertedAmount import to.bitkit.models.FeeRate +import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.HwWallet import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.NewTransactionSheetDetails @@ -476,7 +477,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { paykitPaymentProofRepo.associateLightningPayment(any(), any(), any(), eq("bitkit")) }.thenReturn(Result.success(Unit)) whenever { - paykitPaymentProofRepo.markOnchainPaymentStarted(any(), any(), any(), anyOrNull()) + paykitPaymentProofRepo.markOnchainPaymentStarted(any(), any(), any(), anyOrNull(), anyOrNull()) }.thenReturn(Result.success(Unit)) whenever { activityRepo.setContact(any(), any(), any(), any()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.scheduleSavedContactPreparation(any>()) } @@ -7349,7 +7350,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), ) - verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any(), anyOrNull()) + verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any(), anyOrNull(), anyOrNull()) } @Test @@ -7807,11 +7808,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { SendUiState(address = "bcrt1qpaymentrequest", amount = request.amountSats, isPaymentRequest = true), ) - assertFalse(sut.prepareHardwareContactPayment()) + assertFalse(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) verify(paykitPaymentRequestRepo, never()).accept(any()) - verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any(), anyOrNull()) + verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any(), anyOrNull(), anyOrNull()) } @Test @@ -7835,11 +7836,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) ) - assertFalse(sut.prepareHardwareContactPayment()) + assertFalse(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentProofRepo).cancelPreparation(request) - verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any(), anyOrNull()) + verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any(), anyOrNull(), anyOrNull()) } @Test @@ -7856,6 +7857,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { "bcrt1qpaymentrequest", "hardware-wallet", 7uL, + hardwareSignedReceipt(), ) ).thenReturn(Result.failure(IllegalStateException("proof start failed"))) setActiveContactPaymentContext(testPublicKey, privateContext, request) @@ -7870,7 +7872,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) ) - assertFalse(sut.prepareHardwareContactPayment()) + assertFalse(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentProofRepo).cancelPreparation(request) @@ -7898,13 +7900,19 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain)) .thenReturn(Result.success(Unit), Result.failure(PaykitPaymentRequestError.OperationInProgress)) - assertTrue(sut.prepareHardwareContactPayment()) - assertFalse(sut.prepareHardwareContactPayment()) + assertTrue(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) + assertFalse(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) verify(paykitPaymentProofRepo, times(2)).prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain) verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentRequestRepo).accept(request) - verify(paykitPaymentProofRepo).markOnchainPaymentStarted(request, "bcrt1qpaymentrequest", "hardware-wallet", 7uL) + verify(paykitPaymentProofRepo).markOnchainPaymentStarted( + request, + "bcrt1qpaymentrequest", + "hardware-wallet", + 7uL, + hardwareSignedReceipt(), + ) verify(paykitPaymentRequestRepo, never()).ensurePaymentAllowed(request) } @@ -7960,7 +7968,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { hardwareWalletId = "hardware-wallet", ), ) - assertTrue(sut.prepareHardwareContactPayment()) + assertTrue(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) verify(paykitPaymentRequestRepo, never()).ensurePaymentAllowed(request) whenever(paykitPaymentRequestRepo.ensurePaymentAllowed(request)) .thenReturn(Result.failure(PaykitPaymentRequestError.RequestUnavailable)) @@ -8044,7 +8052,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val sheet = Sheet.Send(SendRoute.HardwareSign) sut.showSheet(sheet) advanceUntilIdle() - assertTrue(sut.prepareHardwareContactPayment()) + assertTrue(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) sut.sendEffect.test { assertFalse(sut.authorizeHardwareContactPayment(hasAttemptedBroadcast = true)) @@ -8122,7 +8130,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { hardwareWalletId = "hardware-wallet", ) ) - assertTrue(sut.prepareHardwareContactPayment()) + assertTrue(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) sut.onHardwareSignCancelled() advanceUntilIdle() @@ -8153,7 +8161,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) sut.showSheet(Sheet.Send(SendRoute.HardwareSign)) advanceUntilIdle() - assertTrue(sut.prepareHardwareContactPayment()) + assertTrue(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) sut.hideSheet() advanceUntilIdle() @@ -8182,7 +8190,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { payMethod = SendMethod.ONCHAIN, hardwareWalletId = walletId, isPaymentRequest = true)) sut.showSheet(Sheet.Send(SendRoute.HardwareSign)) advanceUntilIdle() - assertTrue(sut.prepareHardwareContactPayment()) + assertTrue(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) assertFalse(sut.completeHardwareContactPayment(txid, walletId, request.id, testPublicKey)) sut.onHardwareSignCancelled() @@ -8192,9 +8200,23 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentProofRepo, never()).cancelPreparation(any()) setActiveContactPaymentContext(testPublicKey, privatePaymentContext(7uL), incomingPaymentRequest = request) - assertFalse(sut.prepareHardwareContactPayment(walletId, "bcrt1qpaymentrequest", request.id, testPublicKey)) + assertFalse( + sut.prepareHardwareContactPayment( + walletId, + "bcrt1qpaymentrequest", + request.id, + testPublicKey, + hardwareSignedReceipt(), + ), + ) verify(paykitPaymentRequestRepo, times(1)).accept(request) - verify(paykitPaymentProofRepo, times(1)).markOnchainPaymentStarted(request, "bcrt1qpaymentrequest", walletId, 7uL) + verify(paykitPaymentProofRepo, times(1)).markOnchainPaymentStarted( + request, + "bcrt1qpaymentrequest", + walletId, + 7uL, + hardwareSignedReceipt(), + ) verify( lightningRepo, never() @@ -8228,11 +8250,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ), ) - assertFalse(sut.prepareHardwareContactPayment()) + assertFalse(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) verify(paykitPaymentRequestRepo, never()).accept(any()) - verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any(), anyOrNull()) + verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any(), anyOrNull(), anyOrNull()) } @Test @@ -8262,7 +8284,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) ) - assertTrue(sut.prepareHardwareContactPayment()) + assertTrue(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) val completion = backgroundScope.launch { sut.completeHardwareContactPayment("txid", "hardware-wallet", request.id, testPublicKey) } runCurrent() @@ -8287,7 +8309,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setActiveContactPaymentContext(testPublicKey, privatePaymentContext(7uL), incomingPaymentRequest = request) setSendState(SendUiState(address = "bcrt1qpaymentrequest", amount = request.amountSats, payMethod = SendMethod.ONCHAIN, hardwareWalletId = walletId, isPaymentRequest = true)) - assertTrue(sut.prepareHardwareContactPayment()) + assertTrue(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) pubkyPublicKey.value = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = request.copy(paymentRequestId = "another-request")) setSendState(SendUiState(address = "bcrt1qother", amount = 9_000uL, @@ -8369,8 +8391,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) setActiveContactPaymentContext(testPublicKey, privateContext) - assertTrue(sut.prepareHardwareContactPayment()) - assertTrue(sut.prepareHardwareContactPayment()) + assertTrue(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) + assertTrue(sut.prepareHardwareContactPayment(signedTx = hardwareSignedReceipt())) verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) } @@ -10534,6 +10556,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { creatorIdentity = testPublicKey, wasPublishedToActiveState = wasPublishedToActiveState, ) + + private fun hardwareSignedReceipt() = HwFundingSignedTx( + requireNotNull(javaClass.getResourceAsStream("/hardware-signed-transaction.hex")) + .bufferedReader().use { it.readText().trim() }, + 1000uL, + 2uL, + 2000uL, + ) } private const val SAMROCK_SETUP_URL = From 91351decf586d15be702b2ce4b89891bfce8d1c6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 16:53:25 +0200 Subject: [PATCH 051/108] fix: restore hardware dispatch state --- .../bitkit/models/PaykitPaymentStateBackup.kt | 7 ++-- .../repositories/PaykitPaymentProofRepo.kt | 21 ++++++++--- .../screens/wallets/send/HwSendViewModel.kt | 11 ++++-- .../PaykitPaymentProofRepoTest.kt | 34 ++++++++++++++++-- .../wallets/send/HwSendViewModelTest.kt | 35 ++++++++++++++++++- 5 files changed, 95 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt index 92c3cd8648..41a796bed2 100644 --- a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt +++ b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt @@ -58,6 +58,7 @@ data class PaykitPaymentStateBackup( val hardwareMiningFeeSats: ULong? = null, val hardwareFeeRate: ULong? = null, val hardwareTotalSpent: ULong? = null, + val hardwareDispatchAttempted: Boolean? = null, ) { constructor(proof: PendingPaykitPaymentProof) : this( identity = proof.identity, @@ -77,7 +78,8 @@ data class PaykitPaymentStateBackup( hardwareSignedTransaction = proof.hardwareSignedTransaction, hardwareMiningFeeSats = proof.hardwareMiningFeeSats, hardwareFeeRate = proof.hardwareFeeRate, - hardwareTotalSpent = proof.hardwareTotalSpent + hardwareTotalSpent = proof.hardwareTotalSpent, + hardwareDispatchAttempted = proof.hardwareDispatchAttempted ) fun restored() = PendingPaykitPaymentProof( @@ -98,7 +100,8 @@ data class PaykitPaymentStateBackup( hardwareSignedTransaction = hardwareSignedTransaction, hardwareMiningFeeSats = hardwareMiningFeeSats, hardwareFeeRate = hardwareFeeRate, - hardwareTotalSpent = hardwareTotalSpent + hardwareTotalSpent = hardwareTotalSpent, + hardwareDispatchAttempted = hardwareDispatchAttempted ) } } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index f4ad801d80..c3ba32224e 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -63,6 +63,11 @@ enum class PaykitPaymentProofKind(val type: String) { } } +data class RetainedHardwareOnchainPayment( + val signedTx: HwFundingSignedTx, + val hasAttemptedBroadcast: Boolean, +) + @Serializable data class PendingPaykitPaymentProof( val identity: String, @@ -80,6 +85,7 @@ data class PendingPaykitPaymentProof( val onchainMatchingTransactionIdsBeforeAttempt: Set = emptySet(), val onchainAcceptanceVerified: Boolean = false, val hardwareDispatchDenied: Boolean = false, + val hardwareDispatchAttempted: Boolean? = null, val hardwareSignedTransaction: String? = null, val hardwareMiningFeeSats: ULong? = null, val hardwareFeeRate: ULong? = null, @@ -261,6 +267,7 @@ class PaykitPaymentProofRepo @Inject constructor( hardwareMiningFeeSats = signedTx?.miningFeeSats, hardwareFeeRate = signedTx?.feeRate, hardwareTotalSpent = signedTx?.totalSpent, + hardwareDispatchAttempted = signedTx?.let { false }, ) persist(proofs) } @@ -554,7 +561,8 @@ class PaykitPaymentProofRepo @Inject constructor( hardwareSignedTransaction = signedTx?.serializedTx ?: it.hardwareSignedTransaction, hardwareMiningFeeSats = signedTx?.miningFeeSats ?: it.hardwareMiningFeeSats, hardwareFeeRate = signedTx?.feeRate ?: it.hardwareFeeRate, - hardwareTotalSpent = signedTx?.totalSpent ?: it.hardwareTotalSpent + hardwareTotalSpent = signedTx?.totalSpent ?: it.hardwareTotalSpent, + hardwareDispatchAttempted = true ) } else { it @@ -571,7 +579,7 @@ class PaykitPaymentProofRepo @Inject constructor( identity: String?, address: String, amountSats: ULong, - ): HwFundingSignedTx? = withContext(ioDispatcher) { + ): RetainedHardwareOnchainPayment? = withContext(ioDispatcher) { operationMutex.withLock { if (!PubkyPublicKeyFormat.matches(currentIdentity(), identity)) return@withLock null val proof = loadProofs().singleOrNull { @@ -587,7 +595,9 @@ class PaykitPaymentProofRepo @Inject constructor( it.onchainAddress == address && it.onchainAmountSats == amountSats } ?: return@withLock null - proof.retainedSignedHardwareReceipt() + proof.retainedSignedHardwareReceipt()?.let { + RetainedHardwareOnchainPayment(it, proof.hardwareDispatchAttempted != false) + } } } @@ -676,10 +686,11 @@ class PaykitPaymentProofRepo @Inject constructor( if (currentIdentity() != originalIdentity) return@runSuspendCatching false val proofs = loadProofs() val original = proofs.singleOrNull { + val definitelyUnsent = it.paymentIdentifier == null || it.hardwareDispatchDenied || + (it.hardwareDispatchAttempted == false && it.retainedSignedHardwareReceipt() != null) PubkyPublicKeyFormat.matches(it.identity, originalIdentity) && it.requestId == request.id && it.onchainWalletId == walletId && it.kind == PaykitPaymentProofKind.Onchain && - it.paymentStarted && (it.paymentIdentifier == null || it.hardwareDispatchDenied) && - it.proofData == null && !it.onchainAcceptanceVerified + it.paymentStarted && definitelyUnsent && it.proofData == null && !it.onchainAcceptanceVerified } ?: return@runSuspendCatching false if (original.hardwareDispatchDenied) releaseDeniedPrivateConsumption(original) // Only the caller's definite first-dispatch authorization denial permits this removal. diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt index e18dce6c1c..82acb371f1 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt @@ -111,12 +111,17 @@ class HwSendViewModel @Inject constructor( pending = PendingHwSendBroadcast( request, - restored, + restored.signedTx, isPreparedForBroadcast = true, - hasAttemptedBroadcast = true + hasAttemptedBroadcast = restored.hasAttemptedBroadcast ) pendingBroadcast = pending - _uiState.update { it.copy(hasPendingBroadcast = true, isBroadcastUnresolved = true) } + _uiState.update { + it.copy( + hasPendingBroadcast = true, + isBroadcastUnresolved = restored.hasAttemptedBroadcast + ) + } } } if (pending == null && hwWalletRepo.needsPassphrase(request.walletId)) { diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 5da5897397..da0ee119ea 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -1541,12 +1541,13 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() // Snapshot at the authorization boundary, before any broadcast callback can save a receipt. repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId, signedTx = signed).getOrThrow() + assertEquals(false, storedProofs.single().hardwareDispatchAttempted) val encoded = Json.encodeToString(repo.backupSnapshot()) storedProofs = emptyList() val reopened = paymentProofRepo() reopened.restoreBackup(Json.decodeFromString>(encoded)) assertEquals( - signed, + RetainedHardwareOnchainPayment(signed, false), reopened.retainedHardwareOnchainPayment( request.id, walletId, @@ -1593,6 +1594,35 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) assertFalse(storedProofs.single().onchainAcceptanceVerified) verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + assertTrue(reopened.failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, false)) + assertTrue(storedProofs.isEmpty()) + reopened.restoreBackup(Json.decodeFromString>(encoded)) + assertTrue( + reopened.retainHardwareOnchainCandidate( + request.id, + walletId, + txid, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amountSats, + signed + ) + ) + val dispatched = Json.encodeToString(reopened.backupSnapshot()) + storedProofs = emptyList() + reopened.restoreBackup(Json.decodeFromString>(dispatched)) + assertEquals( + true, + reopened.retainedHardwareOnchainPayment( + request.id, + walletId, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amountSats + )?.hasAttemptedBroadcast + ) + assertFalse(reopened.failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, false)) + assertEquals(1, storedProofs.size) } @Test @@ -1762,7 +1792,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val reopened = paymentProofRepo() reopened.restoreBackup(Json.decodeFromString>(backup)) assertEquals( - signed, + RetainedHardwareOnchainPayment(signed, false), reopened.retainedHardwareOnchainPayment( request.id, walletId, diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt index 1fc1eb9a92..e4b9cec464 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt @@ -748,7 +748,7 @@ class HwSendViewModelTest : BaseUnitTest() { ADDRESS, AMOUNT_SATS ) - }.thenReturn(fixture.signedTx) + }.thenReturn(to.bitkit.repositories.RetainedHardwareOnchainPayment(fixture.signedTx, true)) sut.signAndBroadcast( original, prepareContactPayment = { error("must retain original preparation") }, @@ -773,6 +773,39 @@ class HwSendViewModelTest : BaseUnitTest() { verify(hwWalletRepo, times(1)).broadcastFunding(fixture.signedTx) } + @Test + fun `restored unattempted Shop receipt expires without claiming dispatch or signing again`() = test { + val fixture = stubSuccessfulPayment() + val deadline = Instant.parse("2026-10-06T12:00:00Z") + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + paymentDeadlineAt = deadline + ) + whenever { + proofRepo.retainedHardwareOnchainPayment( + requireNotNull(original.paymentRequestId), + WALLET_ID, + original.paymentIdentity, + ADDRESS, + AMOUNT_SATS + ) + }.thenReturn(to.bitkit.repositories.RetainedHardwareOnchainPayment(fixture.signedTx, false)) + now = deadline + 1.seconds + var attempted: Boolean? = null + sut.signAndBroadcast( + original, + prepareContactPayment = { error("must retain original preparation") }, + authorizeContactPayment = { error("expired before authorization") }, + onPaymentDeadlineExpired = { attempted = it } + ) + advanceUntilIdle() + assertEquals(false, attempted) + assertFalse(sut.uiState.value.isBroadcastUnresolved) + verify(hwWalletRepo, never()).signFunding(any(), any()) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + } + @Test fun `hardware Shop never broadcasts when candidate persistence fails`() = test { val fixture = stubSuccessfulPayment() From b71aff77ae0b07285c17847371c237d0f789b5b2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 17:18:01 +0200 Subject: [PATCH 052/108] fix: keep private shop cleanup retryable --- .../bitkit/models/PaykitPaymentStateBackup.kt | 7 ++++-- .../repositories/PaykitPaymentProofRepo.kt | 3 ++- .../PaykitPaymentProofRepoTest.kt | 22 +++++++++++++++++-- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt index 41a796bed2..1847bd0225 100644 --- a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt +++ b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt @@ -59,6 +59,7 @@ data class PaykitPaymentStateBackup( val hardwareFeeRate: ULong? = null, val hardwareTotalSpent: ULong? = null, val hardwareDispatchAttempted: Boolean? = null, + val privatePaymentListVersion: ULong? = null, ) { constructor(proof: PendingPaykitPaymentProof) : this( identity = proof.identity, @@ -79,7 +80,8 @@ data class PaykitPaymentStateBackup( hardwareMiningFeeSats = proof.hardwareMiningFeeSats, hardwareFeeRate = proof.hardwareFeeRate, hardwareTotalSpent = proof.hardwareTotalSpent, - hardwareDispatchAttempted = proof.hardwareDispatchAttempted + hardwareDispatchAttempted = proof.hardwareDispatchAttempted, + privatePaymentListVersion = proof.privatePaymentListVersion ) fun restored() = PendingPaykitPaymentProof( @@ -101,7 +103,8 @@ data class PaykitPaymentStateBackup( hardwareMiningFeeSats = hardwareMiningFeeSats, hardwareFeeRate = hardwareFeeRate, hardwareTotalSpent = hardwareTotalSpent, - hardwareDispatchAttempted = hardwareDispatchAttempted + hardwareDispatchAttempted = hardwareDispatchAttempted, + privatePaymentListVersion = privatePaymentListVersion ) } } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index c3ba32224e..c232109c2f 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -692,7 +692,8 @@ class PaykitPaymentProofRepo @Inject constructor( it.onchainWalletId == walletId && it.kind == PaykitPaymentProofKind.Onchain && it.paymentStarted && definitelyUnsent && it.proofData == null && !it.onchainAcceptanceVerified } ?: return@runSuspendCatching false - if (original.hardwareDispatchDenied) releaseDeniedPrivateConsumption(original) + // Release the captured private version while its proof still makes cleanup retryable. + releaseDeniedPrivateConsumption(original) // Only the caller's definite first-dispatch authorization denial permits this removal. persist(proofs - original) true diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index da0ee119ea..9cb431a35d 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -1540,7 +1540,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() // Snapshot at the authorization boundary, before any broadcast callback can save a receipt. - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId, signedTx = signed).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId, 7uL, signedTx = signed).getOrThrow() assertEquals(false, storedProofs.single().hardwareDispatchAttempted) val encoded = Json.encodeToString(repo.backupSnapshot()) storedProofs = emptyList() @@ -1594,8 +1594,26 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) assertFalse(storedProofs.single().onchainAcceptanceVerified) verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) - assertTrue(reopened.failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, false)) + assertEquals(7uL, storedProofs.single().privatePaymentListVersion) + whenever(privatePaykitRepo.releasePrivatePaymentListVersion(request.id.counterparty, 7uL)) + .thenReturn( + Result.failure(IllegalStateException("private state storage")), + Result.success(Unit), + Result.success(Unit) + ) + val original = storedProofs.single() + assertFalse(reopened.failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, false)) + assertEquals(listOf(original), storedProofs) + // A restart must retain the original version while cleanup is unfinished. + val retryCleanup = paymentProofRepo() + shouldFailProofRemoval = true + assertFalse(retryCleanup.failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, false)) + assertEquals(listOf(original), storedProofs) + assertTrue( + paymentProofRepo().failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, false) + ) assertTrue(storedProofs.isEmpty()) + verify(privatePaykitRepo, times(3)).releasePrivatePaymentListVersion(request.id.counterparty, 7uL) reopened.restoreBackup(Json.decodeFromString>(encoded)) assertTrue( reopened.retainHardwareOnchainCandidate( From e38369f95c7c2839074f65723699cc74c0014dbf Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 18:22:53 +0200 Subject: [PATCH 053/108] fix: preserve prior private payment boundary --- .../bitkit/models/PaykitPaymentStateBackup.kt | 14 +++++- .../repositories/PaykitPaymentProofRepo.kt | 11 ++++- .../bitkit/repositories/PrivatePaykitRepo.kt | 13 +++++- .../bitkit/repositories/PublicPaykitRepo.kt | 1 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 1 + .../PaykitPaymentProofRepoTest.kt | 14 ++++-- .../repositories/PrivatePaykitRepoTest.kt | 8 ++++ .../viewmodels/AppViewModelSendFlowTest.kt | 45 ++++++++++++++++--- 8 files changed, 94 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt index 1847bd0225..efbdb50c7e 100644 --- a/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt +++ b/app/src/main/java/to/bitkit/models/PaykitPaymentStateBackup.kt @@ -60,7 +60,15 @@ data class PaykitPaymentStateBackup( val hardwareTotalSpent: ULong? = null, val hardwareDispatchAttempted: Boolean? = null, val privatePaymentListVersion: ULong? = null, + val previousPrivatePaymentListVersion: ULong? = null, ) { + init { + require( + previousPrivatePaymentListVersion == null || + (privatePaymentListVersion != null && previousPrivatePaymentListVersion < privatePaymentListVersion) + ) { "Invalid private payment-list boundary" } + } + constructor(proof: PendingPaykitPaymentProof) : this( identity = proof.identity, requestId = proof.requestId, @@ -81,7 +89,8 @@ data class PaykitPaymentStateBackup( hardwareFeeRate = proof.hardwareFeeRate, hardwareTotalSpent = proof.hardwareTotalSpent, hardwareDispatchAttempted = proof.hardwareDispatchAttempted, - privatePaymentListVersion = proof.privatePaymentListVersion + privatePaymentListVersion = proof.privatePaymentListVersion, + previousPrivatePaymentListVersion = proof.previousPrivatePaymentListVersion ) fun restored() = PendingPaykitPaymentProof( @@ -104,7 +113,8 @@ data class PaykitPaymentStateBackup( hardwareFeeRate = hardwareFeeRate, hardwareTotalSpent = hardwareTotalSpent, hardwareDispatchAttempted = hardwareDispatchAttempted, - privatePaymentListVersion = privatePaymentListVersion + privatePaymentListVersion = privatePaymentListVersion, + previousPrivatePaymentListVersion = previousPrivatePaymentListVersion ) } } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index c232109c2f..2160611f3d 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -91,6 +91,7 @@ data class PendingPaykitPaymentProof( val hardwareFeeRate: ULong? = null, val hardwareTotalSpent: ULong? = null, val privatePaymentListVersion: ULong? = null, + val previousPrivatePaymentListVersion: ULong? = null, ) data class PaykitOnchainPaymentProofResolution( @@ -235,6 +236,7 @@ class PaykitPaymentProofRepo @Inject constructor( walletId: String = WalletScope.default, privatePaymentListVersion: ULong? = null, signedTx: HwFundingSignedTx? = null, + previousPrivatePaymentListVersion: ULong? = null, ): Result = withContext(ioDispatcher) { runSuspendCatching { val identity = currentIdentity() ?: throw PaykitPaymentRequestError.RequestUnavailable @@ -262,6 +264,9 @@ class PaykitPaymentProofRepo @Inject constructor( onchainAmountSats = request.amountSats, onchainWalletId = walletId, privatePaymentListVersion = privatePaymentListVersion, + previousPrivatePaymentListVersion = previousPrivatePaymentListVersion.takeIf { + privatePaymentListVersion != null + }, paymentIdentifier = signedTxid, hardwareSignedTransaction = signedTx?.serializedTx, hardwareMiningFeeSats = signedTx?.miningFeeSats, @@ -775,7 +780,11 @@ class PaykitPaymentProofRepo @Inject constructor( private suspend fun releaseDeniedPrivateConsumption(proof: PendingPaykitPaymentProof) { val version = proof.privatePaymentListVersion ?: return - privatePaykitRepo.get().releasePrivatePaymentListVersion(proof.requestId.counterparty, version).getOrThrow() + privatePaykitRepo.get().releasePrivatePaymentListVersion( + proof.requestId.counterparty, + version, + proof.previousPrivatePaymentListVersion, + ).getOrThrow() } private suspend fun finishDeniedHardwarePreparations(proofs: List) { diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 6d44d678ed..d897cc3970 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -439,11 +439,16 @@ class PrivatePaykitRepo @Inject constructor( suspend fun releasePrivatePaymentList( publicKey: String, context: PrivatePaykitPaymentContext, - ): Result = releasePrivatePaymentListVersion(publicKey, context.paymentListVersion) + ): Result = releasePrivatePaymentListVersion( + publicKey, + context.paymentListVersion, + context.previousPaymentListVersion, + ) suspend fun releasePrivatePaymentListVersion( publicKey: String, paymentListVersion: ULong?, + previousPaymentListVersion: ULong? = null, ): Result = withContext(serializedDispatcher) { runSuspendCatching { val normalizedKey = normalizedPublicKey(publicKey) ?: throw PrivatePaykitError.InvalidPublicKey @@ -452,7 +457,10 @@ class PrivatePaykitRepo @Inject constructor( val consumedVersion = contactState.consumedPrivatePaymentListVersion if (consumedVersion != paymentListVersion) return@runSuspendCatching - contactState.consumedPrivatePaymentListVersion = null + if (previousPaymentListVersion != null && previousPaymentListVersion >= paymentListVersion) { + throw PaykitPaymentRequestError.RequestUnavailable + } + contactState.consumedPrivatePaymentListVersion = previousPaymentListVersion try { persistState(markWalletBackup = true) } catch (error: Throwable) { @@ -731,6 +739,7 @@ class PrivatePaykitRepo @Inject constructor( paymentAppsByEndpoint = privatePayable.distinctBy { it.methodId } .associate { it.methodId.rawValue to requireNotNull(it.appId) }, paymentListVersion = paymentListVersion, + previousPaymentListVersion = consumedVersion.takeIf { paymentListVersion != null }, ), ) } diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index 1dce23798a..2316dcecd4 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -87,6 +87,7 @@ internal val PublicPaykitPaymentResult.incomingPaymentRequestFailureReason: data class PrivatePaykitPaymentContext( val paymentAppsByEndpoint: Map, val paymentListVersion: ULong?, + val previousPaymentListVersion: ULong? = null, ) @OptIn(ExperimentalTime::class) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 69714f3677..9dea923c41 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -5837,6 +5837,7 @@ class AppViewModel @Inject constructor( requireNotNull(incomingPaymentRequest), address, walletId ?: WalletScope.default, contactPaymentContext.privatePaymentContext?.paymentListVersion, signedTx, + contactPaymentContext.privatePaymentContext?.previousPaymentListVersion, ).onFailure { synchronized(contactPaymentContextLock) { if (preparedContactPaymentContext == contactPaymentContext) preparedContactPaymentContext = null diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 9cb431a35d..5a0019d1ea 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -1540,7 +1540,14 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() // Snapshot at the authorization boundary, before any broadcast callback can save a receipt. - repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId, 7uL, signedTx = signed).getOrThrow() + repo.markOnchainPaymentStarted( + request, + ONCHAIN_ADDRESS, + walletId, + 7uL, + signedTx = signed, + previousPrivatePaymentListVersion = 6uL, + ).getOrThrow() assertEquals(false, storedProofs.single().hardwareDispatchAttempted) val encoded = Json.encodeToString(repo.backupSnapshot()) storedProofs = emptyList() @@ -1595,7 +1602,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertFalse(storedProofs.single().onchainAcceptanceVerified) verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) assertEquals(7uL, storedProofs.single().privatePaymentListVersion) - whenever(privatePaykitRepo.releasePrivatePaymentListVersion(request.id.counterparty, 7uL)) + assertEquals(6uL, storedProofs.single().previousPrivatePaymentListVersion) + whenever(privatePaykitRepo.releasePrivatePaymentListVersion(request.id.counterparty, 7uL, 6uL)) .thenReturn( Result.failure(IllegalStateException("private state storage")), Result.success(Unit), @@ -1613,7 +1621,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { paymentProofRepo().failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, false) ) assertTrue(storedProofs.isEmpty()) - verify(privatePaykitRepo, times(3)).releasePrivatePaymentListVersion(request.id.counterparty, 7uL) + verify(privatePaykitRepo, times(3)).releasePrivatePaymentListVersion(request.id.counterparty, 7uL, 6uL) reopened.restoreBackup(Json.decodeFromString>(encoded)) assertTrue( reopened.retainHardwareOnchainCandidate( diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 19f9c80ddd..edae4921b8 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -1766,6 +1766,14 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } } + @Test + fun `unsent hardware release preserves previously consumed boundary`() = test { + sut.consumePrivatePaymentList(CONTACT_KEY, PrivatePaykitPaymentContext(emptyMap(), 6uL)).getOrThrow() + sut.consumePrivatePaymentList(CONTACT_KEY, PrivatePaykitPaymentContext(emptyMap(), 7uL)).getOrThrow() + sut.releasePrivatePaymentListVersion(CONTACT_KEY, 7uL, 6uL).getOrThrow() + assertEquals(6uL, cacheData.value.contacts.getValue(CONTACT_KEY).consumedPrivatePaymentListVersion) + } + @Test fun `releasePrivatePaymentList makes matching version reusable without clearing newer consumption`() = test { val releasedContext = PrivatePaykitPaymentContext(mapOf(MethodId.Bolt11.rawValue to "bitkit"), 7uL) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 925195e841..9651c6f1b4 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -477,7 +477,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { paykitPaymentProofRepo.associateLightningPayment(any(), any(), any(), eq("bitkit")) }.thenReturn(Result.success(Unit)) whenever { - paykitPaymentProofRepo.markOnchainPaymentStarted(any(), any(), any(), anyOrNull(), anyOrNull()) + paykitPaymentProofRepo.markOnchainPaymentStarted( + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) }.thenReturn(Result.success(Unit)) whenever { activityRepo.setContact(any(), any(), any(), any()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.scheduleSavedContactPreparation(any>()) } @@ -7350,7 +7357,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), ) - verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any(), anyOrNull(), anyOrNull()) + verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted( + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) } @Test @@ -7812,7 +7826,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) verify(paykitPaymentRequestRepo, never()).accept(any()) - verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any(), anyOrNull(), anyOrNull()) + verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted( + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) } @Test @@ -7840,7 +7861,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentProofRepo).cancelPreparation(request) - verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any(), anyOrNull(), anyOrNull()) + verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted( + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) } @Test @@ -8254,7 +8282,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) verify(paykitPaymentRequestRepo, never()).accept(any()) - verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any(), anyOrNull(), anyOrNull()) + verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted( + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) } @Test From 5214de9bdf48e7eab2c0fe9533d1cde181522f33 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 18:52:16 +0200 Subject: [PATCH 054/108] fix: preserve unsent shop recovery boundaries --- .../repositories/PaykitPaymentProofRepo.kt | 1 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 69 +++++++++++++------ .../PaykitPaymentProofRepoTest.kt | 15 +++- .../viewmodels/AppViewModelSendFlowTest.kt | 26 ++++--- 4 files changed, 80 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 2160611f3d..451a88cb68 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -771,6 +771,7 @@ class PaykitPaymentProofRepo @Inject constructor( ) } if (!safe) return@withLock false + for (proof in original) releaseDeniedPrivateConsumption(proof) if (original.isNotEmpty()) persist(proofs - original.toSet()) true } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 9dea923c41..fde3b730f2 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4476,7 +4476,11 @@ class AppViewModel @Inject constructor( paymentDeadlineAt = incomingPaymentRequest?.paymentDeadlineAt, beforeSendAttempt = { if (preparedPaymentProofRequest != null) { - markOnchainPaymentStarted(incomingPaymentRequest, address).getOrThrow() + markOnchainPaymentStarted( + incomingPaymentRequest, + address, + contactPaymentContext = contactPaymentContext, + ).getOrThrow() paymentProofStarted = true paykitPaymentProofRepo.verifyOriginalOnchainPayer( preparedPaymentProofRequest, @@ -4847,7 +4851,10 @@ class AppViewModel @Inject constructor( } @Suppress("ReturnCount") - private suspend fun prepareContactPayment(contactPaymentContext: ContactPaymentContext?): Boolean { + private suspend fun prepareContactPayment( + contactPaymentContext: ContactPaymentContext?, + beforePrivateConsumption: suspend () -> Result = { Result.success(Unit) }, + ): Boolean { if (isPreparedContactPayment(contactPaymentContext)) return true if (!validateIncomingPaymentRequest(contactPaymentContext)) return false @@ -4857,6 +4864,11 @@ class AppViewModel @Inject constructor( return false } } + beforePrivateConsumption().onFailure { + releasePrivatePaymentListIfNeeded(contactPaymentContext) + handlePaymentPreparationFailure(it, contactPaymentContext) + return false + } consumePrivatePaymentListIfNeeded(contactPaymentContext).onFailure { handlePaymentPreparationFailure(it, contactPaymentContext) return false @@ -4927,8 +4939,15 @@ class AppViewModel @Inject constructor( request: PaykitPaymentRequest?, address: String, walletId: String = WalletScope.default, - ): Result = request?.let { paykitPaymentProofRepo.markOnchainPaymentStarted(it, address, walletId) } - ?: Result.success(Unit) + contactPaymentContext: ContactPaymentContext? = null, + ): Result = request?.let { + val privateContext = contactPaymentContext?.privatePaymentContext + paykitPaymentProofRepo.markOnchainPaymentStarted( + it, address, walletId, + privatePaymentListVersion = privateContext?.paymentListVersion, + previousPrivatePaymentListVersion = privateContext?.previousPaymentListVersion, + ) + } ?: Result.success(Unit) private suspend fun cancelPaymentProofPreparation(request: PaykitPaymentRequest?) { request?.let { paykitPaymentProofRepo.cancelPreparation(it) } @@ -5828,25 +5847,33 @@ class AppViewModel @Inject constructor( handlePaymentPreparationFailure(it, contactPaymentContext) return false } - if (!prepareContactPayment(contactPaymentContext)) { - cancelPaymentProofPreparation(preparedPaymentProofRequest) - return false - } - if (preparedPaymentProofRequest != null) { - paykitPaymentProofRepo.markOnchainPaymentStarted( - requireNotNull(incomingPaymentRequest), address, walletId ?: WalletScope.default, - contactPaymentContext.privatePaymentContext?.paymentListVersion, - signedTx, - contactPaymentContext.privatePaymentContext?.previousPaymentListVersion, - ).onFailure { - synchronized(contactPaymentContextLock) { - if (preparedContactPaymentContext == contactPaymentContext) preparedContactPaymentContext = null + var proofStarted = false + if (!prepareContactPayment(contactPaymentContext) { + if (preparedPaymentProofRequest == null) { + Result.success(Unit) + } else { + // Persist the signed original receipt and cleanup boundary before consuming its endpoint. + paykitPaymentProofRepo.markOnchainPaymentStarted( + requireNotNull(incomingPaymentRequest), + address, + walletId ?: WalletScope.default, + contactPaymentContext.privatePaymentContext?.paymentListVersion, + signedTx, + contactPaymentContext.privatePaymentContext?.previousPaymentListVersion, + ).onSuccess { proofStarted = true } } - releasePrivatePaymentListIfNeeded(contactPaymentContext) - cancelPaymentProofPreparation(preparedPaymentProofRequest) - handlePaymentPreparationFailure(it, contactPaymentContext) - return false } + ) { + if (proofStarted && incomingPaymentRequest != null && identity != null) { + paykitPaymentProofRepo.failHardwareOnchainPaymentBeforeDispatch( + incomingPaymentRequest, + walletId ?: WalletScope.default, + identity, + hasAttemptedBroadcast = false, + ) + } + cancelPaymentProofPreparation(preparedPaymentProofRequest) + return false } return incomingPaymentRequest == null || PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 5a0019d1ea..1b20e2cdc9 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -243,7 +243,14 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { address = ONCHAIN_ADDRESS, amountSats = request.amountSats, isMaxAmount = false, feeRateSatsPerVByte = 1uL, isTransfer = false, channelId = null, tags = emptyList(), payerIdentity = LOCAL_IDENTITY, - beforeSendAttempt = { repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS).getOrThrow() }, + beforeSendAttempt = { + repo.markOnchainPaymentStarted( + request, + ONCHAIN_ADDRESS, + privatePaymentListVersion = 7uL, + previousPrivatePaymentListVersion = 6uL, + ).getOrThrow() + }, ) assertTrue(admitted.preparationPending) assertTrue(storedProofs.single().paymentStarted) @@ -259,6 +266,11 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(admitted, reopened.current()) assertEquals("ab".repeat(32), storedProofs.single().paymentIdentifier) storedProofs = listOf(startedProof) + whenever(privatePaykitRepo.releasePrivatePaymentListVersion(request.id.counterparty, 7uL, 6uL)) + .thenReturn(Result.failure(IllegalStateException("private boundary storage failed")), Result.success(Unit)) + repo.reconcile() + assertEquals(admitted, reopened.current()) + assertEquals(listOf(startedProof), storedProofs) shouldFailProofRemoval = true repo.reconcile() assertEquals(admitted, reopened.current()) @@ -266,6 +278,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { repo.reconcile() assertNull(reopened.current()) assertTrue(storedProofs.isEmpty()) + verify(privatePaykitRepo, times(3)).releasePrivatePaymentListVersion(request.id.counterparty, 7uL, 6uL) repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() assertFalse(storedProofs.single().paymentStarted) verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 9651c6f1b4..da44363f6f 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -7861,13 +7861,18 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentProofRepo).cancelPreparation(request) - verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted( - any(), - any(), - any(), - anyOrNull(), - anyOrNull(), - anyOrNull(), + verify(paykitPaymentProofRepo).markOnchainPaymentStarted( + request, + "bcrt1qpaymentrequest", + "hardware-wallet", + 7uL, + hardwareSignedReceipt(), + ) + verify(paykitPaymentProofRepo).failHardwareOnchainPaymentBeforeDispatch( + request, + "hardware-wallet", + testPublicKey, + hasAttemptedBroadcast = false, ) } @@ -7887,7 +7892,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 7uL, hardwareSignedReceipt(), ) - ).thenReturn(Result.failure(IllegalStateException("proof start failed"))) + ).doSuspendableAnswer { + verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) + Result.failure(IllegalStateException("proof start failed")) + } setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( SendUiState( @@ -8747,7 +8755,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) whenever( - paykitPaymentProofRepo.markOnchainPaymentStarted(request, address, WalletScope.default), + paykitPaymentProofRepo.markOnchainPaymentStarted(request, address, WalletScope.default, 7uL), ).doSuspendableAnswer { whenever(paykitPaymentRequestRepo.ensurePaymentAllowed(request)) .thenReturn(Result.failure(PaykitPaymentRequestError.RequestUnavailable)) From 08d35b77fb8d8039e36463e18601d423f9936304 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 19:42:20 +0200 Subject: [PATCH 055/108] fix: preserve private payment guard after restart --- .../repositories/PaykitPaymentProofRepo.kt | 19 ++++++++++++++++++- .../bitkit/repositories/PrivatePaykitRepo.kt | 10 +++++++++- .../PaykitPaymentProofRepoTest.kt | 19 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 451a88cb68..559b2a896f 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -171,7 +171,13 @@ class PaykitPaymentProofRepo @Inject constructor( operationMutex.withLock { val proof = pendingProof(request, paymentEndpointIdentifier, paymentAppId, kind) val currentProofs = loadProofs() - if (currentProofs.any { it.isStartedFor(proof.identity, request.id) }) { + if (currentProofs.any { + it.isStartedFor(proof.identity, request.id) || + (PubkyPublicKeyFormat.matches(it.identity, proof.identity) && + PubkyPublicKeyFormat.matches(it.requestId.counterparty, request.counterparty) && + it.hardwareSignedTransaction != null && !it.onchainAcceptanceVerified) + } + ) { throw PaykitPaymentRequestError.OperationInProgress } val alreadyPaid = paykitSdkService.paymentRequests().any { record -> @@ -601,6 +607,17 @@ class PaykitPaymentProofRepo @Inject constructor( it.onchainAmountSats == amountSats } ?: return@withLock null proof.retainedSignedHardwareReceipt()?.let { + proof.privatePaymentListVersion?.let { version -> + val consumption = privatePaykitRepo.get().consumePrivatePaymentList( + proof.requestId.counterparty, + PrivatePaykitPaymentContext( + paymentAppsByEndpoint = mapOf(proof.paymentEndpointIdentifier to proof.paymentAppId), + paymentListVersion = version, + ), + ) + val error = consumption.exceptionOrNull() + if (error != null && error !is PrivatePaykitError.PaymentListAlreadyConsumed) throw error + } RetainedHardwareOnchainPayment(it, proof.hardwareDispatchAttempted != false) } } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index b3faee3cc8..b51f26ee13 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -412,6 +412,7 @@ class PrivatePaykitRepo @Inject constructor( result } + @Suppress("TooGenericExceptionCaught") suspend fun consumePrivatePaymentList( publicKey: String, context: PrivatePaykitPaymentContext, @@ -425,9 +426,16 @@ class PrivatePaykitRepo @Inject constructor( throw PrivatePaykitError.PaymentListAlreadyConsumed } + val previousEndpoints = contactState.remoteEndpoints contactState.consumedPrivatePaymentListVersion = paymentListVersion contactState.remoteEndpoints = emptyList() - persistState(markWalletBackup = true) + try { + persistState(markWalletBackup = true) + } catch (error: Throwable) { + contactState.consumedPrivatePaymentListVersion = consumedVersion + contactState.remoteEndpoints = previousEndpoints + throw error + } Logger.info( "Consumed private Paykit payment list version $paymentListVersion " + "for '${redacted(normalizedKey)}'", diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 1b20e2cdc9..e3b2141e3c 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -38,6 +38,7 @@ import org.lightningdevkit.ldknode.PaymentDirection import org.lightningdevkit.ldknode.PaymentKind import org.lightningdevkit.ldknode.PaymentStatus import org.mockito.kotlin.any +import org.mockito.kotlin.argThat import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer @@ -105,6 +106,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { shouldFailNextLoad = false shouldFailNextSave = false shouldFailProofRemoval = false + whenever(privatePaykitRepo.consumePrivatePaymentList(any(), any())).thenReturn(Result.success(Unit)) whenever(store.hasPendingProofs()).thenReturn(true) whenever( paykitSdkService.identityStatus() @@ -1566,6 +1568,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { storedProofs = emptyList() val reopened = paymentProofRepo() reopened.restoreBackup(Json.decodeFromString>(encoded)) + whenever(privatePaykitRepo.consumePrivatePaymentList(eq(request.counterparty), any())).thenReturn(Result.success(Unit)) assertEquals( RetainedHardwareOnchainPayment(signed, false), reopened.retainedHardwareOnchainPayment( @@ -1576,6 +1579,22 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { request.amountSats ) ) + verify(privatePaykitRepo).consumePrivatePaymentList(eq(request.counterparty), argThat { paymentListVersion == 7uL }) + val otherRequest = paymentRequest(MethodId.P2wpkh.rawValue, paymentRequestId = "another-order") + assertTrue(reopened.prepare(otherRequest, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).isFailure) + whenever(privatePaykitRepo.consumePrivatePaymentList(eq(request.counterparty), any())) + .thenReturn(Result.failure(IllegalStateException("private state unavailable"))) + var blockedByStorage = false + try { + reopened.retainedHardwareOnchainPayment(request.id, walletId, LOCAL_IDENTITY, ONCHAIN_ADDRESS, request.amountSats) + } catch (_: IllegalStateException) { + blockedByStorage = true + } + assertTrue(blockedByStorage) + whenever(privatePaykitRepo.consumePrivatePaymentList(eq(request.counterparty), any())) + .thenReturn(Result.failure(PrivatePaykitError.PaymentListAlreadyConsumed)) + assertEquals(signed, reopened.retainedHardwareOnchainPayment(request.id, walletId, LOCAL_IDENTITY, ONCHAIN_ADDRESS, request.amountSats)?.signedTx) + whenever(privatePaykitRepo.consumePrivatePaymentList(eq(request.counterparty), any())).thenReturn(Result.success(Unit)) assertNull( reopened.retainedHardwareOnchainPayment( request.id, From 4306d2249c062b78a5839c3a27b8dec10a5782b3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 20:05:22 +0200 Subject: [PATCH 056/108] fix: keep incomplete payment follow-up retryable --- .../to/bitkit/repositories/ActivityRepo.kt | 13 ++--- .../to/bitkit/repositories/LightningRepo.kt | 13 +++++ .../bitkit/repositories/ActivityRepoTest.kt | 27 ++++++++++ .../bitkit/repositories/LightningRepoTest.kt | 52 +++++++++++++++++++ 4 files changed, 99 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 5fec4a22f3..7a4ffd8b26 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -527,13 +527,14 @@ class ActivityRepo @Inject constructor( ) return@runCatching } - if (PubkyPublicKeyFormat.matches(activity.contact(), normalizedKey)) { - return@runCatching - } - val updatedAt = nowTimestamp().epochSecond.toULong() - val updatedActivity = activity.withContact(normalizedKey, updatedAt) - updateActivity(updatedActivity.rawId(), updatedActivity).getOrThrow() + val updatedActivity = if (PubkyPublicKeyFormat.matches(activity.contact(), normalizedKey)) { + activity + } else { + activity.withContact(normalizedKey, updatedAt).also { + updateActivity(it.rawId(), it).getOrThrow() + } + } cacheStore.setActivityContactDetached(activity.rawId(), walletId, detached = false) updateReplacementContactIfNeeded(updatedActivity, normalizedKey, updatedAt, walletId) }.onFailure { diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 2773b8503c..a8b8fbab68 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1624,9 +1624,11 @@ class LightningRepo @Inject constructor( return@executeWhenNodeRunning Result.failure(OnchainSendPendingError(error, prepared.receipt.txid)) } } + var outcomePersisted = true val recorded = runSuspendCatching { onchainSendAttemptStore.recordOutcome(attempt.attemptId, outcome, attempt.walletIndex) }.getOrElse { error -> + outcomePersisted = false val winner = runSuspendCatching { onchainSendAttemptStore.current() }.getOrNull() if (winner?.attemptId == attempt.attemptId && winner.walletId == attempt.walletId && winner.hasPositiveEvidence @@ -1650,6 +1652,17 @@ class LightningRepo @Inject constructor( runSuspendCatching { finishOnchainSendLocally(recorded) } .onFailure { Logger.warn("Failed to finish accepted on-chain send locally", it, context = TAG) } + if (!outcomePersisted) { + runSuspendCatching { + val durable = requireNotNull(onchainSendAttemptStore.currentDurable()) + check(durable.attemptId == recorded.attemptId && durable.walletId == recorded.walletId && + durable.hasPositiveEvidence && durable.txid.equals(txId, true)) { + "Accepted on-chain outcome repair is not durable" + } + }.getOrElse { + return@executeWhenNodeRunning Result.failure(OnchainSendPendingError(it, txId)) + } + } runSuspendCatching { syncState() } .onFailure { Logger.warn("Failed to sync after accepted on-chain send", it, context = TAG) } Result.success(winningOutcome) diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index 13a88e59a2..0a78b64b46 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -629,6 +629,33 @@ class ActivityRepoTest : BaseUnitTest() { verify(cacheStore, never()).setActivityContactDetached(any(), any(), eq(false)) } + @Test + fun `contact retry completes detachment cleanup and replacement after partial write`() = test { + val contact = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + var original = createOnchainActivity(id = "original", txId = "original-tx", doesExist = false) + val replacement = createOnchainActivity(id = "replacement", txId = "replacement-tx", boostTxIds = listOf("original-tx")) + whenever(coreService.activity.getActivity("original-tx", WalletScope.default)).thenAnswer { original } + whenever(coreService.activity.update(eq("original"), any())).doSuspendableAnswer { + original = it.getArgument(1) + Unit + } + whenever(coreService.activity.get(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenAnswer { listOf(original, replacement) } + var failCleanup = true + whenever(cacheStore.setActivityContactDetached("original", WalletScope.default, false)).doSuspendableAnswer { + if (failCleanup) { + failCleanup = false + error("detachment marker write failed") + } + Unit + } + assertTrue(sut.setContact(contact, "original-tx", syncLdkPayments = false).isFailure) + assertEquals(contact, original.v1.contact) + assertTrue(sut.setContact(contact, "original-tx", syncLdkPayments = false).isSuccess) + verify(cacheStore, times(2)).setActivityContactDetached("original", WalletScope.default, false) + verify(coreService.activity).update(eq("replacement"), argThat { this is Activity.Onchain && v1.contact == contact }) + } + @Test fun `setContact propagates contact to replacement transaction`() = test { val contactPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 09b8814628..8dcd5c8d27 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -1695,6 +1695,9 @@ class LightningRepoTest : BaseUnitTest() { whenever(onchainSendAttemptStore.current()).thenReturn(null).thenReturn( winnerIdentity.copy(evidence = OnchainSendEvidence.Observed, txid = winnerTxid), ) + whenever(onchainSendAttemptStore.currentDurable()).thenReturn( + winnerIdentity.copy(evidence = OnchainSendEvidence.Observed, txid = winnerTxid), + ) whenever(onchainSendAttemptStore.recordOutcome(any(), any(), any())) .thenThrow(IllegalStateException("original outcome raced with durable observation")) val broadcasts = mutableListOf() @@ -1972,6 +1975,55 @@ class LightningRepoTest : BaseUnitTest() { ) } + @Test + fun `accepted write and fee repair failure stay Pending after restart`() = test { + val txid = "ab".repeat(32) + val key = Keychain.Key.ONCHAIN_SEND_ATTEMPT.name + var saved: String? = null + var writes = 0 + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { + writes++ + if (writes >= 3) error("transient accepted write failure") + saved = it.getArgument(1) + } + val store = OnchainSendAttemptStore(testDispatcher, keychain, lightningService, kotlin.time.Clock.System) + sut = LightningRepo( + bgDispatcher = testDispatcher, lightningService = lightningService, settingsStore = settingsStore, + coreService = coreService, lspNotificationsService = lspNotificationsService, + firebaseMessaging = firebaseMessaging, keychain = keychain, lnurlService = lnurlService, + cacheStore = cacheStore, preActivityMetadataRepo = preActivityMetadataRepo, + onchainSendAttemptStore = store, connectivityRepo = connectivityRepo, + vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, electrumProbeService = electrumProbeService, + ) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(coinSelectAuto = false))) + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + whenever(activityService.getOnchainActivityByTxId(eq(txid), any())).thenReturn(mock()) + whenever(lightningService.observedOriginalSendFee(any())).thenReturn(null) + whenever(lightningService.prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), + paymentDeadlineAt = anyOrNull(), + )) + .thenReturn(preparedOutcome(OnchainSendOutcome.Accepted(txid))) + startNodeForTesting() + val repo = spy(sut).also { doReturn(Result.success(1uL)).whenever(it).getFeeRateForSpeed(any(), anyOrNull()) } + + assertIs(repo.sendOnChain("address", 1_000uL).exceptionOrNull()) + val reopened = OnchainSendAttemptStore( + testDispatcher, + keychain, + lightningService, + kotlin.time.Clock.System + ).current() + assertEquals(txid, reopened?.txid) + assertEquals(OnchainSendEvidence.Pending, reopened?.evidence) + assertTrue(reopened?.localFollowupComplete == false) + verify(lightningService, times(1)).prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), + paymentDeadlineAt = anyOrNull(), + ) + } + @Test fun `accepted Shop acknowledgement waits for original durable local activity`() = test { val txid = "ab".repeat(32) From bcc22b115fc8ac32dc4555750b0218c330c57132 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 20:16:17 +0200 Subject: [PATCH 057/108] fix: protect retained payments across cleanup and restart --- .../repositories/PaykitPaymentProofRepo.kt | 21 ++++++++++++-- .../screens/wallets/send/HwSendViewModel.kt | 4 +++ .../PaykitPaymentProofRepoTest.kt | 19 ++++++++++++ .../wallets/send/HwSendViewModelTest.kt | 29 +++++++++++++++++++ 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 559b2a896f..228e056561 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -584,6 +584,17 @@ class PaykitPaymentProofRepo @Inject constructor( } } + suspend fun hasRetainedHardwareOnchainPayment(walletId: String): Boolean = withContext(ioDispatcher) { + operationMutex.withLock { + loadProofs().any { + it.onchainWalletId == walletId && walletId != WalletScope.default && + it.kind == PaykitPaymentProofKind.Onchain && it.paymentStarted && + !it.onchainAcceptanceVerified && it.proofData == null && + it.retainedSignedHardwareReceipt() != null + } + } + } + suspend fun retainedHardwareOnchainPayment( requestId: PaykitPaymentRequestId, walletId: String, @@ -725,7 +736,7 @@ class PaykitPaymentProofRepo @Inject constructor( } suspend fun failOnchainPayment(request: PaykitPaymentRequest) { - removeRequestProofs(request) { + removeRequestProofs(request, releasePrivateBoundary = true) { it.kind == PaykitPaymentProofKind.Onchain && it.onchainWalletId == WalletScope.default && it.paymentStarted && @@ -1142,6 +1153,7 @@ class PaykitPaymentProofRepo @Inject constructor( private suspend fun removeRequestProofs( request: PaykitPaymentRequest, + releasePrivateBoundary: Boolean = false, predicate: (PendingPaykitPaymentProof) -> Boolean, ) = withContext(ioDispatcher) { runSuspendCatching { @@ -1158,7 +1170,12 @@ class PaykitPaymentProofRepo @Inject constructor( PubkyPublicKeyFormat.matches(it.identity, targetIdentity) && predicate(it) } - if (remaining != proofs) persist(remaining) + if (remaining != proofs) { + if (releasePrivateBoundary) { + for (proof in proofs - remaining.toSet()) releaseDeniedPrivateConsumption(proof) + } + persist(remaining) + } } }.onFailure { Logger.warn("Failed to clear a pending Paykit payment proof", it, context = TAG) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt index 82acb371f1..de5baa2992 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt @@ -36,6 +36,7 @@ import to.bitkit.repositories.HwWalletMismatchError import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.PreActivityMetadataRepo import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.repositories.PaykitPaymentRequestError import to.bitkit.repositories.PaykitPaymentProofRepo import to.bitkit.utils.SignedTransactionId import to.bitkit.services.CoreService @@ -124,6 +125,9 @@ class HwSendViewModel @Inject constructor( } } } + if (pending == null && paykitPaymentProofRepo.hasRetainedHardwareOnchainPayment(request.walletId)) { + throw PaykitPaymentRequestError.OperationInProgress + } if (pending == null && hwWalletRepo.needsPassphrase(request.walletId)) { _uiState.update { it.copy(isPassphraseRequired = true) } return@runCatching diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index e3b2141e3c..1e751a72b6 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -689,6 +689,25 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.isEmpty()) } + @Test + fun `definite software failure retains proof until private boundary release succeeds`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted( + request, ONCHAIN_ADDRESS, + privatePaymentListVersion = 7uL, previousPrivatePaymentListVersion = 6uL, + ).getOrThrow() + val original = storedProofs.single() + whenever(privatePaykitRepo.releasePrivatePaymentListVersion(request.id.counterparty, 7uL, 6uL)) + .thenReturn(Result.failure(IllegalStateException("storage")), Result.success(Unit)) + repo.failOnchainPayment(request) + assertEquals(listOf(original), storedProofs) + repo.failOnchainPayment(request) + assertTrue(storedProofs.isEmpty()) + verify(privatePaykitRepo, times(2)).releasePrivatePaymentListVersion(request.id.counterparty, 7uL, 6uL) + } + @Test fun `onchain failure clears started proof without a live identity`() = test { val request = paymentRequest(MethodId.P2wpkh.rawValue) diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt index e4b9cec464..8dde8d1350 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt @@ -64,6 +64,8 @@ class HwSendViewModelTest : BaseUnitTest() { @Before fun setUp() { whenever(coreService.activity).thenReturn(activityService) + whenever { proofRepo.hasRetainedHardwareOnchainPayment(any()) }.thenReturn(false) + whenever { proofRepo.retainHardwareOnchainCandidate( any(), @@ -733,6 +735,33 @@ class HwSendViewModelTest : BaseUnitTest() { return PaymentFixture(funding, signedTx, broadcast) } + @Test + fun `retained hardware receipt blocks ordinary and foreign request signing after restart`() = test { + stubSuccessfulPayment() + whenever(context.getString(org.mockito.kotlin.any())).thenReturn("payment blocked") + whenever { proofRepo.hasRetainedHardwareOnchainPayment(WALLET_ID) }.thenReturn(true) + for (next in listOf(request(), request().copy( + paymentRequestId = PaykitPaymentRequestId("foreign-request", "foreign-counterparty"), + paymentIdentity = "original-identity" + ))) { + sut = HwSendViewModel( + context = context, + hwWalletRepo = hwWalletRepo, + preActivityMetadataRepo = preActivityMetadataRepo, + coreService = coreService, + activityRepo = activityRepo, + paykitPaymentProofRepo = proofRepo, + clock = object : Clock { + override fun now() = now + }, + ) + sut.signAndBroadcast(next) + advanceUntilIdle() + } + verify(hwWalletRepo, never()).signFunding(any(), any()) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + } + @Test fun `restored hardware Shop receipt retries only after authorization without signing again`() = test { val fixture = stubSuccessfulPayment() From cd07c3c0846341d8129e53fdbf016aaee3f71ca2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 20:40:23 +0200 Subject: [PATCH 058/108] fix: retain durable payment recovery guards --- .../models/ActiveOnchainAttemptBackup.kt | 10 +++----- .../to/bitkit/repositories/LightningRepo.kt | 12 +++++++++- .../models/ActiveOnchainAttemptBackupTest.kt | 23 +++++++++++++++---- .../bitkit/repositories/LightningRepoTest.kt | 6 +++++ 4 files changed, 38 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt index e3dc825ee2..426d7d5107 100644 --- a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt +++ b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt @@ -78,13 +78,9 @@ data class ActiveOnchainAttemptBackup( require(input.txid.matches(HEX) && unsigned(input.vout) <= UInt.MAX_VALUE.toULong()) OnchainSendInput(input.txid, unsigned(input.vout).toUInt()) } - if (inputs == null) { - require(candidateTxids.isEmpty() && txid == null && status == "pending") - } else { - require(inputs.isNotEmpty() && inputs.distinct().size == inputs.size) - require(unsigned(feeRateSatsPerVByte) > 0uL) - require(candidateTxids.isNotEmpty() && txid in candidateTxids) - } + require(!inputs.isNullOrEmpty() && inputs.distinct().size == inputs.size) + require(unsigned(feeRateSatsPerVByte) > 0uL) + require(candidateTxids.isNotEmpty() && txid in candidateTxids) val evidence = when (status) { "pending" -> OnchainSendEvidence.Pending "accepted" -> OnchainSendEvidence.Accepted.also { require(txid != null && txid in candidateTxids) } diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index a8b8fbab68..212a5e8f0e 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1708,7 +1708,8 @@ class LightningRepo @Inject constructor( authorizeOriginal: suspend (OnchainSendAttempt) -> Unit, ): Result = executeWhenNodeRunning("retryOriginalOnchainSend") { val result = recoveryCoordinator.retryOriginal(attemptId, walletId, feeRateSatsPerVByte, paymentDeadlineAt, authorizeOriginal) - if (result.getOrNull() is OnchainSendOutcome.Accepted) { + val accepted = result.getOrNull() as? OnchainSendOutcome.Accepted + if (accepted != null) { runSuspendCatching { onchainSendAttemptStore.current()?.takeIf { it.hasPositiveEvidence }?.let { finishOnchainSendLocally( @@ -1716,6 +1717,15 @@ class LightningRepo @Inject constructor( ) } }.onFailure { Logger.warn("Failed to finish recovered on-chain send locally", it, context = TAG) } + runSuspendCatching { + val durable = requireNotNull(onchainSendAttemptStore.currentDurable()) + check(durable.attemptId == attemptId && durable.walletId == walletId && + durable.hasPositiveEvidence && durable.txid.equals(accepted.txid, true)) { + "Recovered on-chain outcome is not durable" + } + }.getOrElse { + return@executeWhenNodeRunning Result.failure(OnchainSendPendingError(it, accepted.txid)) + } } result } diff --git a/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt index cd8313e456..576752f45a 100644 --- a/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt @@ -72,6 +72,20 @@ class ActiveOnchainAttemptBackupTest : BaseUnitTest() { } } + @Test + fun `unsigned active preparation cannot restore a permanent wallet guard`() { + val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt).copy( + originalInputs = null, + candidateTxids = emptyList(), + candidateFeeRates = emptyMap(), + txid = null, + status = "pending", + ) + assertFailsWith { + wire.restored("regtest", binding, "wallet0", 0) + } + } + @Test fun `same golden bytes preserve original proof and remap only validated guard`() { val state = requireNotNull(golden().paykitPaymentState) @@ -132,7 +146,7 @@ class ActiveOnchainAttemptBackupTest : BaseUnitTest() { } @Test - fun `unprepared zero fee guard stays pending and accepted restore resets local ack`() { + fun `unprepared zero fee guard is rejected and accepted restore resets local ack`() { val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt) val pending = wire.copy( status = "pending", @@ -141,10 +155,9 @@ class ActiveOnchainAttemptBackupTest : BaseUnitTest() { candidateTxids = emptyList(), feeRateSatsPerVByte = "0" ) - val restored = pending.restored("regtest", binding, "wallet0", 0) - assertEquals(OnchainSendEvidence.Pending, restored.evidence) - assertNull(restored.originalInputs) - assertFalse(restored.localFollowupComplete) + assertFailsWith { + pending.restored("regtest", binding, "wallet0", 0) + } assertFailsWith { wire.copy( feeRateSatsPerVByte = "0" diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 8dcd5c8d27..190132e3ab 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -2010,6 +2010,12 @@ class LightningRepoTest : BaseUnitTest() { val repo = spy(sut).also { doReturn(Result.success(1uL)).whenever(it).getFeeRateForSpeed(any(), anyOrNull()) } assertIs(repo.sendOnChain("address", 1_000uL).exceptionOrNull()) + val retained = requireNotNull(store.current()) + assertIs( + repo.retryOriginalOnchainSend(retained.attemptId, retained.walletId, 2uL) { + error("A retained accepted result must not authorize another payment") + }.exceptionOrNull() + ) val reopened = OnchainSendAttemptStore( testDispatcher, keychain, From 964ef89f16082eedf05342b516860359e1bbe4f8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 7 Oct 2026 21:09:40 +0200 Subject: [PATCH 059/108] fix: preserve removed contacts during recovery --- .../java/to/bitkit/services/CoreService.kt | 4 ++- .../to/bitkit/services/ActivityServiceTest.kt | 32 ++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index d10c206345..06af5ac484 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -1549,10 +1549,12 @@ class ActivityService( ServiceQueue.CORE.background { val existing = requireNotNull(getOnchainActivityByTxId(txid, walletId)) check(existing.walletId == walletId && existing.txId == txid && existing.txType == PaymentType.SENT) + if (cacheStore.data.first().isContactDetached(existing.id, walletId)) return@background if (existing.contact == null) { upsertActivity(Activity.Onchain(existing.copy(contact = contact))) } - check(requireNotNull(getOnchainActivityByTxId(txid, walletId)).contact != null) + check(cacheStore.data.first().isContactDetached(existing.id, walletId) || + requireNotNull(getOnchainActivityByTxId(txid, walletId)).contact != null) } /** Called only after exact original-node transaction/prevout verification, to repair an existing placeholder. */ diff --git a/app/src/test/java/to/bitkit/services/ActivityServiceTest.kt b/app/src/test/java/to/bitkit/services/ActivityServiceTest.kt index 56e734fc52..2689f722be 100644 --- a/app/src/test/java/to/bitkit/services/ActivityServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/ActivityServiceTest.kt @@ -9,6 +9,11 @@ import org.mockito.ArgumentMatchers.anyLong import org.mockito.ArgumentMatchers.anyString import org.mockito.Mockito.mockStatic import org.mockito.Mockito.never +import kotlinx.coroutines.flow.flowOf +import org.mockito.kotlin.whenever +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore +import to.bitkit.ext.scopedActivityId import org.mockito.kotlin.mock import to.bitkit.async.ServiceQueue import to.bitkit.ext.create @@ -26,10 +31,12 @@ class ActivityServiceTest : BaseUnitTest() { private val updateActivity = binding.getMethod("updateActivity", String::class.java, Activity::class.java) private val markActivityAsSeen = binding.methods.single { it.name.startsWith("markActivityAsSeen") } + private val cacheStore = mock() + private val sut by lazy { ActivityService( coreService = mock(), - cacheStore = mock(), + cacheStore = cacheStore, lightningService = mock(), settingsStore = mock(), privatePaykitContactResolver = mock(), @@ -65,6 +72,7 @@ class ActivityServiceTest : BaseUnitTest() { @Test fun `restored contact fills missing attribution and preserves a later edit`() = test { + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) ServiceQueue.CORE.background { val getByTx = binding.getMethod("getActivityByTxId", String::class.java, String::class.java) val upsert = binding.getMethod("upsertActivity", Activity::class.java) @@ -86,6 +94,28 @@ class ActivityServiceTest : BaseUnitTest() { } } + @Test + fun `restored contact preserves durable manual detachment without blocking completion`() = test { + whenever(cacheStore.data).thenReturn( + flowOf(AppCacheData(detachedActivityContacts = setOf(scopedActivityId(WALLET_ID, ACTIVITY_ID)))) + ) + ServiceQueue.CORE.background { + val getByTx = binding.getMethod("getActivityByTxId", String::class.java, String::class.java) + val upsert = binding.getMethod("upsertActivity", Activity::class.java) + var saved = activity().v1.copy(txType = PaymentType.SENT, contact = null) + mockStatic(binding).use { native -> + native.`when` { getByTx.invoke(null, WALLET_ID, ACTIVITY_ID) }.thenAnswer { saved } + native.`when` { upsert.invoke(null, any(Activity::class.java)) }.thenAnswer { + saved = (it.getArgument(0) as Activity.Onchain).v1 + Unit + } + sut.restoreSentOnchainContact(ACTIVITY_ID, WALLET_ID, "original-contact") + kotlin.test.assertNull(saved.contact) + native.verify({ upsert.invoke(null, any(Activity::class.java)) }, never()) + } + } + } + private fun activity() = Activity.Onchain( OnchainActivity.create( walletId = WALLET_ID, From 7ec4d5675348118ab176fac90919ea20c89bfd3a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 02:45:03 +0200 Subject: [PATCH 060/108] fix: keep unpersisted send acceptance pending --- .../to/bitkit/repositories/LightningRepo.kt | 3 +- .../bitkit/repositories/LightningRepoTest.kt | 59 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 212a5e8f0e..576d560135 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1601,6 +1601,7 @@ class LightningRepo @Inject constructor( runSuspendCatching { onchainSendAttemptStore.releaseBeforeDispatch(attempt.attemptId, attempt.walletIndex) } return@executeWhenNodeRunning Result.failure(OnchainSendNotDispatchedError(it)) } + var outcomePersisted = true val outcome = runSuspendCatching { onchainSendAttemptStore.broadcastPreparedCandidate( attempt.attemptId, @@ -1619,12 +1620,12 @@ class LightningRepo @Inject constructor( if (winner?.attemptId == attempt.attemptId && winner.walletId == attempt.walletId && winner.hasPositiveEvidence ) { + outcomePersisted = false OnchainSendOutcome.Accepted(requireNotNull(winner.txid)) } else { return@executeWhenNodeRunning Result.failure(OnchainSendPendingError(error, prepared.receipt.txid)) } } - var outcomePersisted = true val recorded = runSuspendCatching { onchainSendAttemptStore.recordOutcome(attempt.attemptId, outcome, attempt.walletIndex) }.getOrElse { error -> diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 190132e3ab..69c86b690d 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -2030,6 +2030,65 @@ class LightningRepoTest : BaseUnitTest() { ) } + @Test + fun `retained accepted fallback cannot report success before durable repair`() = test { + val txid = "ab".repeat(32) + val key = Keychain.Key.ONCHAIN_SEND_ATTEMPT.name + var saved: String? = null + var writes = 0 + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { + writes++ + if (writes >= 3) error("transient accepted write failure") + saved = it.getArgument(1) + } + val store = spy(OnchainSendAttemptStore(testDispatcher, keychain, lightningService, kotlin.time.Clock.System)) + doSuspendableAnswer { + store.recordOutcome(it.getArgument(0), OnchainSendOutcome.Accepted(txid), it.getArgument(1)) + error("broadcast result unavailable") + }.whenever(store).broadcastPreparedCandidate(any(), any(), any(), any()) + sut = LightningRepo( + bgDispatcher = testDispatcher, lightningService = lightningService, settingsStore = settingsStore, + coreService = coreService, lspNotificationsService = lspNotificationsService, + firebaseMessaging = firebaseMessaging, keychain = keychain, lnurlService = lnurlService, + cacheStore = cacheStore, preActivityMetadataRepo = preActivityMetadataRepo, + onchainSendAttemptStore = store, connectivityRepo = connectivityRepo, + vssBackupClientLdk = vssBackupClientLdk, urlValidator = urlValidator, electrumProbeService = electrumProbeService, + ) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(coinSelectAuto = false))) + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + whenever(activityService.getOnchainActivityByTxId(eq(txid), any())).thenReturn(mock()) + whenever(lightningService.observedOriginalSendFee(any())).thenReturn(null) + whenever(lightningService.prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), + paymentDeadlineAt = anyOrNull(), + )) + .thenReturn(preparedOutcome(OnchainSendOutcome.Accepted(txid))) + startNodeForTesting() + val repo = spy(sut).also { doReturn(Result.success(1uL)).whenever(it).getFeeRateForSpeed(any(), anyOrNull()) } + + assertIs(repo.sendOnChain("address", 1_000uL).exceptionOrNull()) + val retained = requireNotNull(store.current()) + assertIs( + repo.retryOriginalOnchainSend(retained.attemptId, retained.walletId, 2uL) { + error("A retained accepted result must not authorize another payment") + }.exceptionOrNull() + ) + val reopened = OnchainSendAttemptStore( + testDispatcher, + keychain, + lightningService, + kotlin.time.Clock.System + ).current() + assertEquals(txid, reopened?.txid) + assertEquals(OnchainSendEvidence.Pending, reopened?.evidence) + assertTrue(reopened?.localFollowupComplete == false) + verify(lightningService, times(1)).prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), + paymentDeadlineAt = anyOrNull(), + ) + } + @Test fun `accepted Shop acknowledgement waits for original durable local activity`() = test { val txid = "ab".repeat(32) From 2836a9b0b84acf0609ab81e7f97c9ae7570e1a3f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 03:13:58 +0200 Subject: [PATCH 061/108] fix: finish observed hardware payments after timeout --- .../screens/wallets/send/HwSendViewModel.kt | 19 ++++++++-- .../java/to/bitkit/ui/sheets/SendSheet.kt | 38 +++++++++++-------- .../wallets/send/HwSendViewModelTest.kt | 35 +++++++++++++++++ 3 files changed, 73 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt index de5baa2992..775e2b1f34 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt @@ -310,10 +310,23 @@ class HwSendViewModel @Inject constructor( viewModelScope.launch { hwWalletRepo.disconnectStaleSession(walletId) } } - fun completeReconciledBroadcast(walletId: String?, txid: String?): Boolean { - val result = pendingResult.value ?: return false - if (result.walletId != walletId || !result.txId.equals(txid, ignoreCase = true)) return false + fun completeReconciledBroadcast( + walletId: String?, + txid: String?, + onCompleted: (HwSendResult) -> Unit = {}, + ): Boolean { + val result = pendingResult.value?.takeIf { + it.walletId == walletId && it.txId.equals(txid, ignoreCase = true) + } ?: pendingBroadcast?.takeIf { + pendingResult.value == null && it.hasAttemptedBroadcast && it.request.paymentRequestId != null && + it.request.walletId == walletId && + SignedTransactionId.fromHex(it.signedTx.serializedTx).equals(txid, ignoreCase = true) + }?.let { + HwSendResult(it.request.walletId, requireNotNull(txid).lowercase(), it.request.amountSats, + it.request.paymentRequestId, it.request.paymentIdentity) + } ?: return false completeBroadcast() + onCompleted(result) return true } diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 1fc5c1f3c8..a7ff753075 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -156,11 +156,30 @@ fun SendSheet( .testTag("SendSheet"), ) { val navController = rememberNavController() + fun showHardwareSuccess(txId: String, amountSats: ULong, walletId: String) { + appViewModel.onSendSuccess( + details = NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = txId, + activityWalletId = walletId, + sats = amountSats.toLong(), + ), + walletId = walletId, + navigate = false, + ) + appViewModel.clearClipboardForAutoRead() + navController.navigateTo(SendRoute.Success) { + popUpTo(navController.graph.id) { inclusive = true } + } + } LaunchedEffect(appViewModel, hwSendViewModel) { appViewModel.resolvedHardwarePayments.collect { resolutions -> resolutions.values.forEach { resolution -> if (appViewModel.resolvedHardwarePaymentFor(resolution.walletId, resolution.transactionId) == resolution && - hwSendViewModel.completeReconciledBroadcast(resolution.walletId, resolution.transactionId) + hwSendViewModel.completeReconciledBroadcast(resolution.walletId, resolution.transactionId) { + showHardwareSuccess(it.txId, it.amountSats, it.walletId) + } ) { appViewModel.consumeResolvedHardwarePayment(resolution) } @@ -175,6 +194,7 @@ fun SendSheet( ) ) { appViewModel.consumeResolvedHardwarePayment(resolved) + showHardwareSuccess(result.txId, result.amountSats, result.walletId) return@collect } val proofComplete = appViewModel.completeHardwareContactPayment( @@ -194,21 +214,7 @@ fun SendSheet( // and retain the signed operation so another request cannot sign or broadcast. return@collect } - appViewModel.onSendSuccess( - details = NewTransactionSheetDetails( - type = NewTransactionSheetType.ONCHAIN, - direction = NewTransactionSheetDirection.SENT, - paymentHashOrTxId = result.txId, - activityWalletId = result.walletId, - sats = result.amountSats.toLong(), - ), - walletId = result.walletId, - navigate = false, - ) - appViewModel.clearClipboardForAutoRead() - navController.navigateTo(SendRoute.Success) { - popUpTo(navController.graph.id) { inclusive = true } - } + showHardwareSuccess(result.txId, result.amountSats, result.walletId) hwSendViewModel.completeBroadcast() } } diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt index 8dde8d1350..989239f91b 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt @@ -958,6 +958,41 @@ class HwSendViewModelTest : BaseUnitTest() { verify(hwWalletRepo, times(1)).broadcastFunding(fixture.signedTx) } + @Test + fun `exact asynchronous completion clears a timed out Shop broadcast without a result`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val timeout = runCatching { withTimeout(Duration.ZERO) { Unit } } + .exceptionOrNull() as TimeoutCancellationException + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)).thenReturn(Result.failure(timeout)) + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("original", "counterparty"), + paymentIdentity = "original-identity", + ) + sut.signAndBroadcast(original) + advanceUntilIdle() + + assertTrue(sut.uiState.value.hasPendingBroadcast) + assertTrue(sut.uiState.value.isBroadcastUnresolved) + val txid = "605fe246a6d51450ecff51ac3d0415f8824964e06a60ed6e186fa163cf1e9d4e" + assertFalse(sut.completeReconciledBroadcast("other-wallet", txid)) + assertFalse(sut.completeReconciledBroadcast(WALLET_ID, "other-tx")) + assertTrue(sut.uiState.value.hasPendingBroadcast) + var completed: HwSendResult? = null + assertTrue(sut.completeReconciledBroadcast(WALLET_ID, txid.uppercase()) { completed = it }) + assertEquals(HwSendResult(WALLET_ID, txid, original.amountSats, + original.paymentRequestId, original.paymentIdentity), completed) + assertFalse(sut.uiState.value.hasPendingBroadcast) + assertFalse(sut.uiState.value.isBroadcastUnresolved) + assertFalse(sut.completeReconciledBroadcast(WALLET_ID, txid)) + + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)).thenReturn(Result.success(fixture.broadcast)) + sut.signAndBroadcast(request()) + advanceUntilIdle() + verify(hwWalletRepo, times(2)).signFunding(WALLET_ID, fixture.funding) + verify(hwWalletRepo, times(2)).broadcastFunding(fixture.signedTx) + } + @Test fun `matching asynchronous completion consumes retained result and permits the next hardware send`() = test { val fixture = stubSuccessfulPayment() From 3ca8a3093f4879b4d24decd9c9c5059a9771d16e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 03:43:00 +0200 Subject: [PATCH 062/108] fix: reject incomplete accepted replacement backups --- .../models/ActiveOnchainAttemptBackup.kt | 3 +++ .../models/ActiveOnchainAttemptBackupTest.kt | 21 ++++++++++++++++++- .../to/bitkit/repositories/BackupRepoTest.kt | 6 +++++- .../OnchainSendAttemptStoreTest.kt | 6 ++++-- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt index 426d7d5107..7031c3f17e 100644 --- a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt +++ b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt @@ -88,6 +88,9 @@ data class ActiveOnchainAttemptBackup( "unknown" -> OnchainSendEvidence.Unknown else -> error("Unsupported active operation status") } + if (evidence == OnchainSendEvidence.Accepted && txid != candidateTxids.first()) { + require(txid in restoredFeeRates) + } followup?.let { unsigned(it.feeSats) unsigned(it.createdAtMillis) diff --git a/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt index 576752f45a..f432c2e0d1 100644 --- a/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt @@ -72,6 +72,25 @@ class ActiveOnchainAttemptBackupTest : BaseUnitTest() { } } + @Test + fun `accepted successor restore requires its own valid fee rate`() { + val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt) + val original = wire.candidateTxids.first() + val successor = wire.candidateTxids.last() + val accepted = wire.copy(status = "accepted", txid = successor) + for (rates in listOf(null, emptyMap(), mapOf(original to "2"))) { + assertFailsWith { + accepted.copy(candidateFeeRates = rates).restored("regtest", binding, "wallet0", 0) + } + } + val restored = accepted.copy(candidateFeeRates = mapOf(successor to "4")) + .restored("regtest", binding, "wallet0", 0) + assertEquals(4uL, restored.winningFeeRateSatsPerVByte) + val firstWinner = accepted.copy(txid = original, candidateFeeRates = null) + .restored("regtest", binding, "wallet0", 0) + assertEquals(wire.feeRateSatsPerVByte.toULong(), firstWinner.winningFeeRateSatsPerVByte) + } + @Test fun `unsigned active preparation cannot restore a permanent wallet guard`() { val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt).copy( @@ -163,7 +182,7 @@ class ActiveOnchainAttemptBackupTest : BaseUnitTest() { feeRateSatsPerVByte = "0" ).restored("regtest", binding, "wallet0", 0) } - val accepted = wire.copy(status = "accepted").restored("regtest", binding, "wallet0", 0) + val accepted = wire.copy(status = "accepted", candidateFeeRates = mapOf(requireNotNull(wire.txid) to "4")).restored("regtest", binding, "wallet0", 0) assertEquals(OnchainSendEvidence.Accepted, accepted.evidence) assertFalse(accepted.localFollowupComplete) } diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 7824c697bf..d9a8d0dcce 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -307,6 +307,7 @@ class BackupRepoTest : BaseUnitTest() { orderId = "original-order", isTransfer = true, evidence = OnchainSendEvidence.Accepted, + candidateFeeRates = mapOf(requireNotNull(original.txid) to 4uL), transferContext = OnchainTransferContext(2_000uL, 3_000uL, 900uL, 1_000uL), backupFollowup = requireNotNull(original.backupFollowup).copy(contact = null), ) @@ -350,6 +351,7 @@ class BackupRepoTest : BaseUnitTest() { val ordinary = original.copy( requestId = null, payerIdentity = null, orderId = null, isTransfer = false, evidence = OnchainSendEvidence.Accepted, + candidateFeeRates = mapOf(requireNotNull(original.txid) to 4uL), ) val wire = to.bitkit.models.ActiveOnchainAttemptBackup.from(ordinary, "regtest", originalWire.wallet.binding) whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(originalWire.wallet.binding) @@ -401,7 +403,9 @@ class BackupRepoTest : BaseUnitTest() { fun `restored accepted Shop reconciles after proof and private state`() = test { val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) - val wire = requireNotNull(state.activeOnchainAttempt).copy(status = "accepted") + val originalWire = requireNotNull(state.activeOnchainAttempt) + val wire = originalWire.copy(status = "accepted", + candidateFeeRates = mapOf(requireNotNull(originalWire.txid) to "4")) whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) stubWalletBackup(paykitPaymentState = state.copy(activeOnchainAttempt = wire)) sut.performFullRestoreFromLatestBackup().getOrThrow() diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index cd702b78e4..0aedb67ee7 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -267,7 +267,8 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { .decodeFromString(bytes.decodeToString()) .paykitPaymentState?.activeOnchainAttempt, ) - val accepted = wire.copy(status = "accepted", followup = wire.followup?.copy(contact = null)) + val accepted = wire.copy(status = "accepted", candidateFeeRates = mapOf(requireNotNull(wire.txid) to "4"), + followup = wire.followup?.copy(contact = null)) .restored("regtest", wire.wallet.binding, "wallet0", 0) var saved: String? = null val keychain = mock() @@ -293,7 +294,8 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { val backup = kotlinx.serialization.json.Json { ignoreUnknownKeys = true } .decodeFromString(bytes.decodeToString()) val wire = requireNotNull(backup.paykitPaymentState?.activeOnchainAttempt) - val accepted = wire.copy(status = "accepted", followup = wire.followup?.copy(contact = null)) + val accepted = wire.copy(status = "accepted", candidateFeeRates = mapOf(requireNotNull(wire.txid) to "4"), + followup = wire.followup?.copy(contact = null)) .restored("regtest", wire.wallet.binding, "wallet0", 0) var saved: String? = null val keychain = mock() From 96a9230e8eda8f47a828a33e637bcbfc9c73aa4b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 04:42:02 +0200 Subject: [PATCH 063/108] fix: reject backups missing payment follow-up --- .../bitkit/models/ActiveOnchainAttemptBackup.kt | 2 +- .../models/ActiveOnchainAttemptBackupTest.kt | 15 +++++++++++++++ .../java/to/bitkit/repositories/BackupRepoTest.kt | 14 ++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt index 7031c3f17e..3fc7c98673 100644 --- a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt +++ b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt @@ -91,7 +91,7 @@ data class ActiveOnchainAttemptBackup( if (evidence == OnchainSendEvidence.Accepted && txid != candidateTxids.first()) { require(txid in restoredFeeRates) } - followup?.let { + requireNotNull(followup).let { unsigned(it.feeSats) unsigned(it.createdAtMillis) } diff --git a/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt index f432c2e0d1..41ccf8447c 100644 --- a/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt @@ -91,6 +91,21 @@ class ActiveOnchainAttemptBackupTest : BaseUnitTest() { assertEquals(wire.feeRateSatsPerVByte.toULong(), firstWinner.winningFeeRateSatsPerVByte) } + @Test + fun `restored active operations require original local followup context`() { + val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt) + for (status in listOf("pending", "accepted", "rejected", "unknown")) { + val candidate = wire.copy( + status = status, + txid = wire.candidateTxids.first(), + followup = null, + ) + assertFailsWith { + candidate.restored("regtest", binding, "wallet0", 0) + } + } + } + @Test fun `unsigned active preparation cannot restore a permanent wallet guard`() { val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt).copy( diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index d9a8d0dcce..6a9d5f7eae 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -525,6 +525,20 @@ class BackupRepoTest : BaseUnitTest() { verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) } + @Test + fun `missing active followup blocks restore before installing a wallet guard`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + stubWalletBackup(paykitPaymentState = state.copy(activeOnchainAttempt = wire.copy(followup = null))) + + assertTrue(sut.performFullRestoreFromLatestBackup().isFailure) + verify(onchainSendAttemptStore, never()).restoreActive(any()) + verify(paykitPaymentProofRepo, never()).restoreBackup(any()) + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + } + @Test fun `start observing is skipped while wiping`() = test { sut.setWiping(true) From 9f73e8389e1f88687f86c9f7e0826b94311859df Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 05:42:54 +0200 Subject: [PATCH 064/108] fix: resume merged acceptance after backup restore --- .../java/to/bitkit/repositories/BackupRepo.kt | 6 +++- .../to/bitkit/repositories/BackupRepoTest.kt | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 7f41bba59b..f79a762b59 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -870,7 +870,11 @@ class BackupRepo @Inject constructor( } keychain.delete(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name) // Restore can finish while the node is already Running: no new lifecycle/event is guaranteed. - restoredAttempt?.takeIf { it.hasPositiveEvidence }?.let { attempt -> + restoredAttempt?.let { restored -> + onchainSendAttemptStore.current()?.takeIf { + it.attemptId == restored.attemptId && it.walletIndex == restored.walletIndex + } + }?.takeIf { it.hasPositiveEvidence }?.let { attempt -> if (attempt.isTransfer) { transferRepo.get().resumeAcceptedFunding(attempt).onFailure { Logger.warn("Restored accepted funding remains pending local follow-up", it, context = TAG) diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 6a9d5f7eae..7913f4355b 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -313,6 +313,7 @@ class BackupRepoTest : BaseUnitTest() { ) val wire = to.bitkit.models.ActiveOnchainAttemptBackup.from(funding, "regtest", originalWire.wallet.binding) whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(originalWire.wallet.binding) + whenever(onchainSendAttemptStore.current()).thenReturn(funding) whenever(transferRepo.resumeAcceptedFunding(any())).thenReturn(Result.success(Unit)) stubWalletBackup(paykitPaymentState = state.copy(pendingProofs = emptyList(), activeOnchainAttempt = wire)) sut.performFullRestoreFromLatestBackup().getOrThrow() @@ -332,6 +333,7 @@ class BackupRepoTest : BaseUnitTest() { wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0) ) verify(transferRepo, times(2)).resumeAcceptedFunding(any()) + whenever(onchainSendAttemptStore.current()).thenReturn(funding.copy(evidence = OnchainSendEvidence.Unknown)) stubWalletBackup( paykitPaymentState = state.copy( pendingProofs = emptyList(), @@ -354,6 +356,7 @@ class BackupRepoTest : BaseUnitTest() { candidateFeeRates = mapOf(requireNotNull(original.txid) to 4uL), ) val wire = to.bitkit.models.ActiveOnchainAttemptBackup.from(ordinary, "regtest", originalWire.wallet.binding) + whenever(onchainSendAttemptStore.current()).thenReturn(ordinary) whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(originalWire.wallet.binding) stubWalletBackup(paykitPaymentState = state.copy(pendingProofs = emptyList(), activeOnchainAttempt = wire)) sut.performFullRestoreFromLatestBackup().getOrThrow() @@ -361,11 +364,35 @@ class BackupRepoTest : BaseUnitTest() { ordered.verify(onchainSendAttemptStore).restoreActive(wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0)) ordered.verify(privatePaykitRepo).restoreBackup(anyOrNull()) ordered.verify(lightningRepo).completeAcceptedOrdinaryFollowup(requireNotNull(ordinary.txid)) + whenever(onchainSendAttemptStore.current()).thenReturn(ordinary.copy(evidence = OnchainSendEvidence.Unknown)) stubWalletBackup(paykitPaymentState = state.copy(pendingProofs = emptyList(), activeOnchainAttempt = wire.copy(status = "unknown"))) sut.performFullRestoreFromLatestBackup().getOrThrow() verify(lightningRepo, times(1)).completeAcceptedOrdinaryFollowup(any()) } + @Test + fun `older unresolved backup resumes accepted merged ordinary guard`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val originalWire = requireNotNull(state.activeOnchainAttempt) + val wire = originalWire.copy(requestId = null, payerIdentity = null) + val downloaded = wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0) + val merged = downloaded.copy( + evidence = OnchainSendEvidence.Accepted, + candidateFeeRates = mapOf(requireNotNull(downloaded.txid) to 4uL), + ) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + whenever(onchainSendAttemptStore.current()).thenReturn(merged) + stubWalletBackup(paykitPaymentState = state.copy(pendingProofs = emptyList(), activeOnchainAttempt = wire)) + + sut.performFullRestoreFromLatestBackup().getOrThrow() + + val ordered = org.mockito.kotlin.inOrder(onchainSendAttemptStore, privatePaykitRepo, lightningRepo) + ordered.verify(onchainSendAttemptStore).restoreActive(downloaded) + ordered.verify(privatePaykitRepo).restoreBackup(anyOrNull()) + ordered.verify(lightningRepo).completeAcceptedOrdinaryFollowup(requireNotNull(merged.txid)) + } + @Test fun `wallet backup captures transfer completed before locked attempt snapshot`() = test { val transfer = TransferEntity( @@ -406,6 +433,7 @@ class BackupRepoTest : BaseUnitTest() { val originalWire = requireNotNull(state.activeOnchainAttempt) val wire = originalWire.copy(status = "accepted", candidateFeeRates = mapOf(requireNotNull(originalWire.txid) to "4")) + whenever(onchainSendAttemptStore.current()).thenReturn(wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0)) whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) stubWalletBackup(paykitPaymentState = state.copy(activeOnchainAttempt = wire)) sut.performFullRestoreFromLatestBackup().getOrThrow() From f6166930f937603b6e72f4455d61342548f3e37d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 06:12:13 +0200 Subject: [PATCH 065/108] fix: preserve original contact during payment recovery --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 5 +++- .../viewmodels/AppViewModelSendFlowTest.kt | 30 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 7e6095da3c..18db85a5bf 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4598,6 +4598,9 @@ class AppViewModel @Inject constructor( if (!sendAttempted) releasePrivatePaymentListIfNeeded(contactPaymentContext) if (!paymentProofStarted) cancelPaymentProofPreparation(preparedPaymentProofRequest) val previous = (error as? OnchainSendBlockedError)?.attempt + if (previous != null && (incomingPaymentRequest == null || previous.requestId != incomingPaymentRequest.id)) { + clearCancelledContactContext(contactPaymentContext) + } val priorAccepted = previous?.takeIf { it.hasPositiveEvidence && it.txid != null && !it.isTransfer && incomingPaymentRequest != null && it.requestId == incomingPaymentRequest.id @@ -4636,7 +4639,7 @@ class AppViewModel @Inject constructor( description = it, ) } - val unresolvedRequestId = previous?.requestId ?: incomingPaymentRequest?.id + val unresolvedRequestId = if (previous != null) previous.requestId else incomingPaymentRequest?.id uncertainOnchainPaymentRequestId = unresolvedRequestId paykitPaymentProofRepo.onchainPaymentResolutions.value .firstOrNull { it.requestId == unresolvedRequestId } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index ea9c4305fd..4a3f0f1686 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9458,6 +9458,36 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(lightningRepo).completeAcceptedOrdinaryFollowup(txid) } + @Test + fun `blocked new contact cannot be assigned to recovered older payment`() = test { + val txid = "ef".repeat(32) + val previous = OnchainSendAttempt( + walletId = WalletScope.default, attemptId = "older-payment", requestId = null, orderId = null, + address = "bcrt1qoriginal", amountSats = 1_000uL, isMaxAmount = false, + feeRateSatsPerVByte = 1uL, isTransfer = false, channelId = null, tags = emptyList(), + evidence = OnchainSendEvidence.Unknown, txid = txid, + ) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + setActiveContactPaymentContext(testPublicKey) + setSendState(SendUiState(address = "bcrt1qnewcontact", amount = 2_000u, payMethod = SendMethod.ONCHAIN)) + stubOnchainSend("bcrt1qnewcontact", 2_000u, Result.failure(OnchainSendBlockedError(previous))) + + sut.sendEffect.test { + confirmCurrentPayment() + assertEquals(SendEffect.NavigateToPending(txid, 1_000, false, isOnchain = true), awaitItem()) + } + sut.onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = txid, + sats = 1_000, + ) + ) + advanceUntilIdle() + verify(activityRepo, never()).setContact(any(), eq(txid), any(), any()) + } + @Test fun `completed original transfer opens its funded order without another send`() = test { val txid = "ab".repeat(32) From 118075fbe71cd6d753e62f20ed05be4208c0a302 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 07:12:52 +0200 Subject: [PATCH 066/108] fix: follow original shop payment recovery --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 20 ++++- .../viewmodels/AppViewModelSendFlowTest.kt | 78 +++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 18db85a5bf..dc8ab4b09d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4640,10 +4640,26 @@ class AppViewModel @Inject constructor( ) } val unresolvedRequestId = if (previous != null) previous.requestId else incomingPaymentRequest?.id + if (previous != null) { + _sendUiState.update { + it.copy( + incomingPaymentRequestId = unresolvedRequestId, + isPaymentRequest = unresolvedRequestId != null, + address = previous.address, + amount = previous.amountSats, + ) + } + } uncertainOnchainPaymentRequestId = unresolvedRequestId paykitPaymentProofRepo.onchainPaymentResolutions.value - .firstOrNull { it.requestId == unresolvedRequestId } - ?.let(::handlePaykitOnchainPaymentResolution) + .firstOrNull { + it.requestId == unresolvedRequestId && + PubkyPublicKeyFormat.matches(pubkyRepo.publicKey.value, it.identity) + } + ?.let { + handlePaykitOnchainPaymentResolution(it) + return + } showUnresolvedOnchainSend( txid = (error as? OnchainSendPendingError)?.txid ?: previous?.txid, amount = previous?.amountSats ?: amount, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 4a3f0f1686..d875c6537d 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9458,6 +9458,84 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(lightningRepo).completeAcceptedOrdinaryFollowup(txid) } + @Test + fun `blocked Shop recovery follows original request through success`() = test { + val original = paymentRequest() + val incoming = original.copy(paymentRequestId = "new-request", amountSats = 3_000uL) + val privateContext = privatePaymentContext(7uL) + val txid = "ef".repeat(32) + val previous = OnchainSendAttempt( + walletId = WalletScope.default, attemptId = "older-shop-payment", requestId = original.id, orderId = null, + address = "bcrt1qoriginal", amountSats = original.amountSats, isMaxAmount = false, + feeRateSatsPerVByte = 1uL, isTransfer = false, channelId = null, tags = emptyList(), + evidence = OnchainSendEvidence.Unknown, txid = txid, + ) + pubkyPublicKey.value = testPublicKey + runCurrent() + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(paykitPaymentRequestRepo.accept(incoming)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + setActiveContactPaymentContext(testPublicKey, privateContext, incoming, isInitialSubscriptionPayment = true) + setSendState(SendUiState(address = "bcrt1qnewshop", amount = incoming.amountSats, + payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, + isPaymentRequest = true, incomingPaymentRequestId = incoming.id)) + stubOnchainSend("bcrt1qnewshop", incoming.amountSats, Result.failure(OnchainSendBlockedError(previous))) + val resolution = PaykitOnchainPaymentProofResolution(testPublicKey, original.id, txid, + amountSats = original.amountSats) + + sut.sendEffect.test { + confirmCurrentPayment() + assertEquals(SendEffect.NavigateToPending(txid, original.amountSats.toLong(), false, isOnchain = true), awaitItem()) + assertEquals(original.id, sut.sendUiState.value.incomingPaymentRequestId) + sut.showSheet(Sheet.Send(SendRoute.Pending(txid, original.amountSats.toLong(), false, isOnchain = true))) + onchainPaymentResolutions.value = listOf(resolution) + assertEquals(SendEffect.PaymentSuccess, awaitItem()) + assertEquals(txid, sut.successSendUiState.value.paymentHashOrTxId) + assertEquals(original.amountSats.toLong(), sut.successSendUiState.value.sats) + runCurrent() + expectNoEvents() + } + } + + @Test + fun `already resolved original Shop payment never returns to pending`() = test { + val original = paymentRequest() + val incoming = original.copy(paymentRequestId = "new-request", amountSats = 3_000uL) + val privateContext = privatePaymentContext(7uL) + val txid = "ef".repeat(32) + val previous = OnchainSendAttempt( + walletId = WalletScope.default, attemptId = "older-shop-payment", requestId = original.id, orderId = null, + address = "bcrt1qoriginal", amountSats = original.amountSats, isMaxAmount = false, + feeRateSatsPerVByte = 1uL, isTransfer = false, channelId = null, tags = emptyList(), + evidence = OnchainSendEvidence.Unknown, txid = txid, + ) + pubkyPublicKey.value = testPublicKey + runCurrent() + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(paykitPaymentRequestRepo.accept(incoming)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + setActiveContactPaymentContext(testPublicKey, privateContext, incoming, isInitialSubscriptionPayment = true) + setSendState(SendUiState(address = "bcrt1qnewshop", amount = incoming.amountSats, + payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, + isPaymentRequest = true, incomingPaymentRequestId = incoming.id)) + stubOnchainSend("bcrt1qnewshop", incoming.amountSats, Result.failure(OnchainSendBlockedError(previous))) + val resolution = PaykitOnchainPaymentProofResolution(testPublicKey, original.id, txid, + amountSats = original.amountSats) + sut.showSheet(Sheet.Send(SendRoute.Confirm)) + onchainPaymentResolutions.value = listOf(resolution) + runCurrent() + sut.sendEffect.test { + confirmCurrentPayment() + assertEquals(SendEffect.PaymentSuccess, awaitItem()) + assertEquals(txid, sut.successSendUiState.value.paymentHashOrTxId) + assertEquals(original.amountSats.toLong(), sut.successSendUiState.value.sats) + runCurrent() + expectNoEvents() + } + } + @Test fun `blocked new contact cannot be assigned to recovered older payment`() = test { val txid = "ef".repeat(32) From d400138b33446ae25c02630f5addb610c3bb6170 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 07:44:11 +0200 Subject: [PATCH 067/108] fix: retain original contact in payment backup --- .../to/bitkit/repositories/LightningRepo.kt | 2 ++ .../repositories/OnchainSendAttemptStore.kt | 3 +++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 3 +++ .../bitkit/repositories/LightningRepoTest.kt | 9 ++++++-- .../OnchainSendAttemptStoreTest.kt | 18 +++++++++++++++ .../bitkit/repositories/TransferRepoTest.kt | 3 +++ .../viewmodels/AppViewModelSendFlowTest.kt | 22 ++++++++++++++----- .../viewmodels/TransferViewModelTest.kt | 14 ++++++++++++ 8 files changed, 66 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 576d560135..f09267c8b5 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1504,6 +1504,7 @@ class LightningRepo @Inject constructor( transferContext: OnchainTransferContext? = null, payerIdentity: String? = null, paymentDeadlineAt: Instant? = null, + contactPublicKey: String? = null, ): Result = executeWhenNodeRunning("sendOnChain") { require(address.isNotEmpty()) { "Send address cannot be empty" } @@ -1551,6 +1552,7 @@ class LightningRepo @Inject constructor( transferContext = transferContext, beforeSendAttempt = beforeSendAttempt, payerIdentity = payerIdentity, + contactPublicKey = contactPublicKey, ) }.getOrElse { val error = if (it is OnchainSendBlockedError || it is OnchainSendAttemptUnreadableError) { diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index 6652b7bee8..00f6c99a2c 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -12,6 +12,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive import to.bitkit.data.keychain.Keychain import to.bitkit.di.IoDispatcher import to.bitkit.ext.nowMillis @@ -164,6 +165,7 @@ class OnchainSendAttemptStore @Inject constructor( transferContext: OnchainTransferContext? = null, beforeSendAttempt: suspend () -> Unit, payerIdentity: String? = null, + contactPublicKey: String? = null, ): OnchainSendAttempt = withContext(ioDispatcher) { mutex.withLock { val walletIndex = lightningService.currentWalletIndex @@ -198,6 +200,7 @@ class OnchainSendAttemptStore @Inject constructor( tags = tags, createdAtMillis = nowMillis(clock).toString(), channelId = channelId, + contact = contactPublicKey?.let(::JsonPrimitive), ), ) persist(attempt) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index dc8ab4b09d..39f0b4dcd5 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4511,6 +4511,7 @@ class AppViewModel @Inject constructor( tags = tags, requestId = incomingPaymentRequest?.id, payerIdentity = originalPayer, + contactPublicKey = contactPaymentContext?.publicKey, paymentDeadlineAt = incomingPaymentRequest?.paymentDeadlineAt, beforeSendAttempt = { if (preparedPaymentProofRequest != null) { @@ -5204,6 +5205,7 @@ class AppViewModel @Inject constructor( tags: List = emptyList(), requestId: PaykitPaymentRequestId? = null, payerIdentity: String? = null, + contactPublicKey: String? = null, paymentDeadlineAt: Instant? = null, beforeSendAttempt: suspend () -> Unit = {}, onBroadcast: suspend (Txid) -> Unit = {}, @@ -5217,6 +5219,7 @@ class AppViewModel @Inject constructor( tags = tags, requestId = requestId, payerIdentity = payerIdentity, + contactPublicKey = contactPublicKey, paymentDeadlineAt = paymentDeadlineAt, beforeSendAttempt = beforeSendAttempt, onBroadcast = onBroadcast, diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 69c86b690d..2727e24196 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -1577,6 +1577,7 @@ class LightningRepoTest : BaseUnitTest() { onchainSendAttemptStore.admit( any(), anyOrNull(), anyOrNull(), any(), any(), any(), any(), any(), anyOrNull(), any(), anyOrNull(), any(), anyOrNull(), + contactPublicKey = anyOrNull(), ) } .thenReturn(attempt) @@ -1755,7 +1756,9 @@ class LightningRepoTest : BaseUnitTest() { val blocked = assertIs(result.exceptionOrNull()) assertEquals(original, blocked.attempt) - verify(onchainSendAttemptStore, never()).admit(any(), anyOrNull(), anyOrNull(), any(), any(), any(), any(), any(), anyOrNull(), any(), anyOrNull(), any(), anyOrNull()) + verify(onchainSendAttemptStore, never()).admit(any(), anyOrNull(), anyOrNull(), any(), any(), any(), any(), any(), anyOrNull(), any(), anyOrNull(), any(), anyOrNull(), + contactPublicKey = anyOrNull(), + ) } private fun pendingSendAttempt() = OnchainSendAttempt( @@ -1772,6 +1775,7 @@ class LightningRepoTest : BaseUnitTest() { onchainSendAttemptStore.admit( any(), anyOrNull(), anyOrNull(), any(), any(), any(), any(), any(), anyOrNull(), any(), anyOrNull(), any(), anyOrNull(), + contactPublicKey = anyOrNull(), ) ).thenReturn(attempt) startNodeForTesting() @@ -2154,7 +2158,8 @@ class LightningRepoTest : BaseUnitTest() { verify(onchainSendAttemptStore).admit( any(), isNull(), eq("order-1"), any(), any(), any(), any(), eq(true), anyOrNull(), any(), eq(context), any(), anyOrNull(), - ) + contactPublicKey = anyOrNull(), + ) preparedOutcome(OnchainSendOutcome.Unknown("ab".repeat(32))) } whenever(onchainSendAttemptStore.recordOutcome(any(), any(), any())) diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index 0aedb67ee7..79b285a9e5 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -217,6 +217,22 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { assertEquals(0, callbacks) } + @Test + fun `original contact is durable before dispatch and survives reopening`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val contact = "original-contact" + val attempt = store.admitForTest(contactPublicKey = contact) { + assertTrue(requireNotNull(saved).contains(contact), "original contact must be saved before dispatch") + } + assertEquals(contact, (attempt.backupFollowup?.contact as? kotlinx.serialization.json.JsonPrimitive)?.content) + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + assertEquals(attempt.backupFollowup?.contact, reopened.current()?.backupFollowup?.contact) + } + @Test fun `admission followup timestamp uses the injected clock`() = test { val keychain = mock() @@ -498,6 +514,7 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { } private suspend fun OnchainSendAttemptStore.admitForTest( + contactPublicKey: String? = null, beforeSendAttempt: suspend () -> Unit = { } ): OnchainSendAttempt = admit( @@ -512,5 +529,6 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { channelId = null, tags = emptyList(), beforeSendAttempt = beforeSendAttempt, + contactPublicKey = contactPublicKey, ) } diff --git a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt index 8bb3dfc1e0..5bd6f5b297 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -131,6 +131,7 @@ class TransferRepoTest : BaseUnitTest() { any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ) assertEquals(attempt, lightningRepo.currentOnchainSendAttempt()) } @@ -184,6 +185,7 @@ class TransferRepoTest : BaseUnitTest() { any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ) assertNotNull(restarted) } @@ -239,6 +241,7 @@ class TransferRepoTest : BaseUnitTest() { any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ) } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index d875c6537d..0aa022da01 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -7207,7 +7207,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { transferContext = anyOrNull(), payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), - ) + contactPublicKey = anyOrNull(), + ) verify( paykitPaymentProofRepo ).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, "bitkit", OnchainSendOutcome.Accepted("txid")) @@ -7358,7 +7359,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { transferContext = anyOrNull(), payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), - ) + contactPublicKey = anyOrNull(), + ) verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted( any(), any(), @@ -8262,6 +8264,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ) } @@ -8371,6 +8374,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ) } @@ -8426,6 +8430,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ) } @@ -9056,7 +9061,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { transferContext = anyOrNull(), payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), - ) + contactPublicKey = anyOrNull(), + ) } @Test @@ -9101,7 +9107,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { transferContext = anyOrNull(), payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), - ) + contactPublicKey = anyOrNull(), + ) } @Test @@ -9166,7 +9173,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { transferContext = anyOrNull(), payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), - ) + contactPublicKey = anyOrNull(), + ) } @Test @@ -9276,7 +9284,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { transferContext = anyOrNull(), payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), - ) + contactPublicKey = anyOrNull(), + ) } @Test @@ -10344,6 +10353,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { transferContext = anyOrNull(), payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ) }.doSuspendableAnswer { invocation -> kotlin.check(invocation.getArgument(0) == address) diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 8190883d58..b2e2a5130e 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -1178,6 +1178,7 @@ class TransferViewModelTest : BaseUnitTest() { ), payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ) verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) verify(blocktankRepo, times(1)).createOrder(eq(order.clientBalanceSat), eq(order.lspBalanceSat), any()) @@ -1228,6 +1229,7 @@ class TransferViewModelTest : BaseUnitTest() { ), payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ) verify(lightningRepo, never()).sendOnChain( address = any(), @@ -1246,6 +1248,7 @@ class TransferViewModelTest : BaseUnitTest() { transferContext = anyOrNull(), payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ) verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) } @@ -1281,6 +1284,7 @@ class TransferViewModelTest : BaseUnitTest() { anyOrNull(), payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ), ).thenReturn(Result.failure(AppError("Coin selection failed"))) @@ -1308,6 +1312,7 @@ class TransferViewModelTest : BaseUnitTest() { transferContext = anyOrNull(), payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ) verify(lightningRepo, never()).sendOnChain( address = any(), @@ -1326,6 +1331,7 @@ class TransferViewModelTest : BaseUnitTest() { transferContext = anyOrNull(), payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ) verify(cacheStore, never()).addPaidOrder(any(), any()) } @@ -1527,6 +1533,7 @@ class TransferViewModelTest : BaseUnitTest() { transferContext = anyOrNull(), payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ), ).thenReturn(Result.success(OnchainSendOutcome.Unknown(TXID))) quoteOrder(order) @@ -1604,6 +1611,7 @@ class TransferViewModelTest : BaseUnitTest() { anyOrNull(), payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ), ).thenReturn( Result.failure(AppError("Coin selection failed")), @@ -1869,6 +1877,7 @@ class TransferViewModelTest : BaseUnitTest() { transferContext = anyOrNull(), payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ) } @@ -2055,6 +2064,7 @@ class TransferViewModelTest : BaseUnitTest() { anyOrNull(), payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ) verify(cacheStore, never()).addPaidOrder(any(), any()) } @@ -2086,6 +2096,7 @@ class TransferViewModelTest : BaseUnitTest() { anyOrNull(), payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ), ).thenReturn(Result.failure(AppError("Coin selection failed"))) quoteOrder(order) @@ -2237,6 +2248,7 @@ class TransferViewModelTest : BaseUnitTest() { anyOrNull(), payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ) verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) } @@ -3558,6 +3570,7 @@ class TransferViewModelTest : BaseUnitTest() { transferContext = anyOrNull(), payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ) } @@ -3714,6 +3727,7 @@ class TransferViewModelTest : BaseUnitTest() { anyOrNull(), payerIdentity = anyOrNull(), paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), ), ) fun typed(value: Result) = value.map { OnchainSendOutcome.Accepted(it) } From 6445174bc945b7f32a0a8049963b595bcffda2b0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 08:13:27 +0200 Subject: [PATCH 068/108] fix: resume accepted funding after network recovery --- .../to/bitkit/repositories/TransferRepo.kt | 10 ++++ .../bitkit/repositories/TransferRepoTest.kt | 59 ++++++++++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index 1498eb99b5..7a10763ab0 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -54,6 +55,7 @@ class TransferRepo @Inject constructor( private val transferDao: TransferDao, private val clock: Clock, private val cacheStore: CacheStore, + private val connectivityRepo: ConnectivityRepo, ) { private val lastOrdersFetchMs = AtomicLong(0L) @@ -72,6 +74,14 @@ class TransferRepo @Inject constructor( } } } + repoScope.launch { + connectivityRepo.isOnline.map { it == ConnectivityState.CONNECTED }.distinctUntilChanged().drop(1) + .collect { connected -> + if (connected && lightningRepo.lightningState.value.nodeLifecycleState.isRunning()) { + resumeAcceptedFunding() + } + } + } } /** Uses only the bounded attempt's positive evidence and original order/balance context. */ diff --git a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt index 5bd6f5b297..9be9591d29 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -72,6 +72,8 @@ class TransferRepoTest : BaseUnitTest() { private val clock = mock() private val cacheStore = mock() private val nodeEvents = MutableSharedFlow() + private val online = MutableStateFlow(ConnectivityState.DISCONNECTED) + private val connectivityRepo = mock() companion object Fixtures { private const val ID_ORDER = "test-order-id" @@ -82,6 +84,7 @@ class TransferRepoTest : BaseUnitTest() { @Before fun setUp() { + whenever(connectivityRepo.isOnline).thenReturn(online) whenever(cacheStore.data).thenReturn(MutableStateFlow(AppCacheData())) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) @@ -95,6 +98,7 @@ class TransferRepoTest : BaseUnitTest() { transferDao = transferDao, clock = clock, cacheStore = cacheStore, + connectivityRepo = connectivityRepo, ) } @@ -136,6 +140,58 @@ class TransferRepoTest : BaseUnitTest() { assertEquals(attempt, lightningRepo.currentOnchainSendAttempt()) } + @Test + fun `connectivity recovery completes accepted funding without another send`() = test { + val order = previewBtOrder() + val txid = "ab".repeat(32) + val attempt = OnchainSendAttempt( + WalletScope.default, + "attempt", + null, + order.id, + requireNotNull(order.payment?.onchain?.address), + order.feeSat, + false, + 1uL, + true, + null, + emptyList(), + OnchainSendEvidence.Accepted, + txid, + transferContext = OnchainTransferContext(99_000uL, 125_000uL, order.clientBalanceSat, order.feeSat), + ) + setupClockNowMock() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + whenever(blocktankRepo.fetchOrders(listOf(order.id))) + .thenReturn(Result.failure(AppError("offline"))) + whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState( + nodeLifecycleState = NodeLifecycleState.Running, + ))) + runCurrent() + assertTrue(sut.resumeAcceptedFunding().isFailure) + verify(lightningRepo, never()).completeAcceptedTransferFollowup(any(), any()) + whenever(blocktankRepo.fetchOrders(listOf(order.id))).thenReturn(Result.success(listOf(order))) + online.value = ConnectivityState.CONNECTED + runCurrent() + + verify(transferDao).insert( + org.mockito.kotlin.check { + assertEquals(order.id, it.lspOrderId) + assertEquals(txid, it.fundingTxId) + assertEquals(99_000L, it.txTotalSats) + assertEquals(125_000L, it.preTransferOnchainSats) + } + ) + verify(lightningRepo).completeAcceptedTransferFollowup(order.id, txid) + verify(lightningRepo, never()).sendOnChain( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), + anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ) + assertNotNull(sut) + } + @Test fun `startup resumes accepted original funding without a confirmation or native resend`() = test { val order = previewBtOrder() @@ -163,7 +219,7 @@ class TransferRepoTest : BaseUnitTest() { whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) // Construct after the saved result exists, as on process startup. val restarted = - TransferRepo(testDispatcher, lightningRepo, blocktankRepo, coreService, transferDao, clock, cacheStore) + TransferRepo(testDispatcher, lightningRepo, blocktankRepo, coreService, transferDao, clock, cacheStore, connectivityRepo) runCurrent() lightningRepo.lightningState.value.let { (lightningRepo.lightningState as MutableStateFlow).value = it.copy( @@ -1763,6 +1819,7 @@ class TransferRepoTest : BaseUnitTest() { transferDao = transferDao, clock = clock, cacheStore = cacheStore, + connectivityRepo = connectivityRepo, ) testSut.activeTransfers.test { From 29eba086efb6993cfadd49d0e439b2d3efad2d69 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 08:42:22 +0200 Subject: [PATCH 069/108] fix: reject malformed contacts in payment backups --- .../models/ActiveOnchainAttemptBackup.kt | 4 +++ .../models/ActiveOnchainAttemptBackupTest.kt | 25 +++++++++++++++++++ .../to/bitkit/repositories/BackupRepoTest.kt | 15 +++++++++++ 3 files changed, 44 insertions(+) diff --git a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt index 3fc7c98673..40857cc390 100644 --- a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt +++ b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt @@ -2,6 +2,7 @@ package to.bitkit.models import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive import to.bitkit.repositories.OnchainSendAttempt import to.bitkit.repositories.OnchainSendEvidence import to.bitkit.repositories.OnchainSendInput @@ -94,6 +95,9 @@ data class ActiveOnchainAttemptBackup( requireNotNull(followup).let { unsigned(it.feeSats) unsigned(it.createdAtMillis) + it.contact?.let { contact -> + require(contact is JsonPrimitive && contact.isString && contact.content.isNotBlank()) + } } val transferContext = transfer?.let { require(orderId != null) diff --git a/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt index 41ccf8447c..4e06ac5409 100644 --- a/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt @@ -91,6 +91,31 @@ class ActiveOnchainAttemptBackupTest : BaseUnitTest() { assertEquals(wire.feeRateSatsPerVByte.toULong(), firstWinner.winningFeeRateSatsPerVByte) } + @Test + fun `restore rejects malformed original contact before installing a guard`() { + val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt) + val followup = requireNotNull(wire.followup) + val invalidContacts = listOf( + kotlinx.serialization.json.JsonPrimitive(""), + kotlinx.serialization.json.JsonPrimitive(" "), + kotlinx.serialization.json.JsonPrimitive(123), + kotlinx.serialization.json.JsonPrimitive(true), + kotlinx.serialization.json.JsonNull, + kotlinx.serialization.json.JsonObject(emptyMap()), + kotlinx.serialization.json.JsonArray(emptyList()), + ) + for (contact in invalidContacts) { + assertFailsWith { + wire.copy(followup = followup.copy(contact = contact)).restored("regtest", binding, "wallet0", 0) + } + } + val missing = wire.copy(followup = followup.copy(contact = null)).restored("regtest", binding, "wallet0", 0) + assertNull(missing.backupFollowup?.contact) + val validContact = kotlinx.serialization.json.JsonPrimitive("original-contact") + val valid = wire.copy(followup = followup.copy(contact = validContact)).restored("regtest", binding, "wallet0", 0) + assertEquals(validContact, valid.backupFollowup?.contact) + } + @Test fun `restored active operations require original local followup context`() { val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt) diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 7913f4355b..1baf646abd 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -529,6 +529,21 @@ class BackupRepoTest : BaseUnitTest() { ).restoreActive(wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0)) } + @Test + fun `malformed active contact fails restore before installing the guard`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + stubWalletBackup(paykitPaymentState = state.copy(activeOnchainAttempt = wire.copy( + followup = requireNotNull(wire.followup).copy(contact = kotlinx.serialization.json.JsonPrimitive(123)), + ))) + assertTrue(sut.performFullRestoreFromLatestBackup().isFailure) + verify(onchainSendAttemptStore, never()).restoreActive(any()) + verify(paykitPaymentProofRepo, never()).restoreBackup(any()) + verify(vssBackupClient, never()).putObject(any(), any()) + } + @Test fun `wrong active binding or proof blocks restore without clearing guard or publishing empty backup`() = test { val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() From b85ed5d58c40c43e58d80475c3d3c35b19c9432e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 09:12:05 +0200 Subject: [PATCH 070/108] fix: require complete replacement fees on restore --- .../models/ActiveOnchainAttemptBackup.kt | 4 +- .../models/ActiveOnchainAttemptBackupTest.kt | 39 ++++++++++++++++--- .../to/bitkit/repositories/BackupRepoTest.kt | 35 +++++++++++------ .../OnchainSendAttemptStoreTest.kt | 6 +-- .../PaykitPaymentProofRepoTest.kt | 2 +- 5 files changed, 62 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt index 40857cc390..222f5d0c6e 100644 --- a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt +++ b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt @@ -89,9 +89,7 @@ data class ActiveOnchainAttemptBackup( "unknown" -> OnchainSendEvidence.Unknown else -> error("Unsupported active operation status") } - if (evidence == OnchainSendEvidence.Accepted && txid != candidateTxids.first()) { - require(txid in restoredFeeRates) - } + require(candidateTxids.drop(1).all { it in restoredFeeRates }) requireNotNull(followup).let { unsigned(it.feeSats) unsigned(it.createdAtMillis) diff --git a/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt index 4e06ac5409..1457afb588 100644 --- a/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt @@ -16,9 +16,9 @@ class ActiveOnchainAttemptBackupTest : BaseUnitTest() { private val binding = "fe843546f607f38ba7b1e8fe479c3103139ebea5b83628cbaa465e41cf6cb6c0" private fun golden(): WalletBackupV1 { - val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() val hash = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } - assertEquals("42bd135dbc91aa0b2004f2633f6f8b28c46dddf8da3c6f949cf2ff036c07e0a6", hash) + assertEquals("8487f073ee55b9049aeed241819055138c82af8a596104bc2eada95b6ce96aa7", hash) return json.decodeFromString(bytes.decodeToString()) } @@ -66,12 +66,39 @@ class ActiveOnchainAttemptBackupTest : BaseUnitTest() { } val original = wire.copy(txid = wire.candidateTxids.first()).restored("regtest", binding, "wallet0", 0) assertEquals(wire.feeRateSatsPerVByte.toULong(), original.winningFeeRateSatsPerVByte) - if (wire.candidateTxids.size > 1) { - val successor = wire.copy(txid = wire.candidateTxids.last()).restored("regtest", binding, "wallet0", 0) - assertFailsWith { successor.winningFeeRateSatsPerVByte } + val successor = wire.copy(txid = wire.candidateTxids.last()).restored("regtest", binding, "wallet0", 0) + assertEquals(4uL, successor.winningFeeRateSatsPerVByte) + } + + @Test + fun `every retained successor needs a fee rate regardless of current outcome`() { + val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt) + val original = wire.candidateTxids.first() + val successor = wire.candidateTxids.last() + val later = "12".repeat(32) + for (status in listOf("pending", "unknown", "rejected", "accepted")) { + val candidate = wire.copy(status = status, txid = original, candidateTxids = listOf(original, successor, later)) + for (rates in listOf(null, emptyMap(), mapOf(successor to "4"), mapOf(later to "5"))) { + assertFailsWith { + candidate.copy(candidateFeeRates = rates).restored("regtest", binding, "wallet0", 0) + } + } + val restored = candidate.copy(candidateFeeRates = mapOf(successor to "4", later to "5")) + .restored("regtest", binding, "wallet0", 0) + assertEquals(wire.feeRateSatsPerVByte.toULong(), restored.winningFeeRateSatsPerVByte) + assertEquals(4uL, restored.copy(txid = successor).winningFeeRateSatsPerVByte) + assertEquals(5uL, restored.copy(txid = later).winningFeeRateSatsPerVByte) } } + @Test + fun `incomplete historical fixture cannot install a restored guard`() { + val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val backup = json.decodeFromString(bytes.decodeToString()) + val wire = requireNotNull(backup.paykitPaymentState?.activeOnchainAttempt) + assertFailsWith { wire.restored("regtest", binding, "wallet0", 0) } + } + @Test fun `accepted successor restore requires its own valid fee rate`() { val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt) @@ -86,7 +113,7 @@ class ActiveOnchainAttemptBackupTest : BaseUnitTest() { val restored = accepted.copy(candidateFeeRates = mapOf(successor to "4")) .restored("regtest", binding, "wallet0", 0) assertEquals(4uL, restored.winningFeeRateSatsPerVByte) - val firstWinner = accepted.copy(txid = original, candidateFeeRates = null) + val firstWinner = accepted.copy(txid = original, candidateFeeRates = mapOf(successor to "4")) .restored("regtest", binding, "wallet0", 0) assertEquals(wire.feeRateSatsPerVByte.toULong(), firstWinner.winningFeeRateSatsPerVByte) } diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 1baf646abd..c4d586df40 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -297,7 +297,7 @@ class BackupRepoTest : BaseUnitTest() { @Test fun `restored accepted funding resumes after all original context is installed without a node transition`() = test { - val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) val originalWire = requireNotNull(state.activeOnchainAttempt) val original = originalWire.restored("regtest", originalWire.wallet.binding, WalletScope.default, 0) @@ -346,7 +346,7 @@ class BackupRepoTest : BaseUnitTest() { @Test fun `restored accepted ordinary payment resumes after original context without another node event`() = test { - val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) val originalWire = requireNotNull(state.activeOnchainAttempt) val original = originalWire.restored("regtest", originalWire.wallet.binding, WalletScope.default, 0) @@ -372,7 +372,7 @@ class BackupRepoTest : BaseUnitTest() { @Test fun `older unresolved backup resumes accepted merged ordinary guard`() = test { - val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) val originalWire = requireNotNull(state.activeOnchainAttempt) val wire = originalWire.copy(requestId = null, payerIdentity = null) @@ -428,7 +428,7 @@ class BackupRepoTest : BaseUnitTest() { @Test fun `restored accepted Shop reconciles after proof and private state`() = test { - val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) val originalWire = requireNotNull(state.activeOnchainAttempt) val wire = originalWire.copy(status = "accepted", @@ -445,7 +445,7 @@ class BackupRepoTest : BaseUnitTest() { @Test fun `wallet backup defers the entire never prepared Shop snapshot`() = test { - val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) val wire = requireNotNull(state.activeOnchainAttempt) val attempt = wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0).copy( @@ -464,7 +464,7 @@ class BackupRepoTest : BaseUnitTest() { @Test fun `wallet backup defers unsigned ordinary and transfer snapshots`() = test { - val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) val wire = requireNotNull(state.activeOnchainAttempt) val attempt = wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0).copy( @@ -491,7 +491,7 @@ class BackupRepoTest : BaseUnitTest() { @Test fun `wallet backup exports and restores shared active guard before original proof`() = test { - val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() val golden = json.decodeFromString(bytes.decodeToString()) val state = requireNotNull(golden.paykitPaymentState) val wire = requireNotNull(state.activeOnchainAttempt) @@ -513,7 +513,7 @@ class BackupRepoTest : BaseUnitTest() { @Test fun `active guard uses captured backup namespace index instead of assuming index zero`() = test { - val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) val wire = requireNotNull(state.activeOnchainAttempt) whenever(db.configDao().getAll()).thenReturn(MutableStateFlow(listOf(to.bitkit.data.entities.ConfigEntity(2)))) @@ -529,9 +529,22 @@ class BackupRepoTest : BaseUnitTest() { ).restoreActive(wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0)) } + @Test + fun `missing successor fee blocks restore before installing the guard`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + stubWalletBackup(paykitPaymentState = state.copy(activeOnchainAttempt = wire.copy(candidateFeeRates = null))) + assertTrue(sut.performFullRestoreFromLatestBackup().isFailure) + verify(onchainSendAttemptStore, never()).restoreActive(any()) + verify(paykitPaymentProofRepo, never()).restoreBackup(any()) + verify(vssBackupClient, never()).putObject(any(), any()) + } + @Test fun `malformed active contact fails restore before installing the guard`() = test { - val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) val wire = requireNotNull(state.activeOnchainAttempt) whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) @@ -546,7 +559,7 @@ class BackupRepoTest : BaseUnitTest() { @Test fun `wrong active binding or proof blocks restore without clearing guard or publishing empty backup`() = test { - val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) val wire = requireNotNull(state.activeOnchainAttempt) whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn("00".repeat(32)) @@ -570,7 +583,7 @@ class BackupRepoTest : BaseUnitTest() { @Test fun `missing active followup blocks restore before installing a wallet guard`() = test { - val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) val wire = requireNotNull(state.activeOnchainAttempt) whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index 79b285a9e5..2310d7ee2f 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -244,7 +244,7 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { @Test fun `restored exact candidate with contact observes without resend and acknowledges`() = test { - val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() val backup = kotlinx.serialization.json.Json { ignoreUnknownKeys = true } .decodeFromString(bytes.decodeToString()) val state = requireNotNull(backup.paykitPaymentState) @@ -277,7 +277,7 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { @Test fun `restoring same accepted operation preserves progressed fee and completion`() = test { - val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() val wire = requireNotNull( kotlinx.serialization.json.Json { ignoreUnknownKeys = true } .decodeFromString(bytes.decodeToString()) @@ -306,7 +306,7 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { @Test fun `restored supported accepted context resets acknowledgement and finishes idempotently`() = test { - val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() val backup = kotlinx.serialization.json.Json { ignoreUnknownKeys = true } .decodeFromString(bytes.decodeToString()) val wire = requireNotNull(backup.paykitPaymentState?.activeOnchainAttempt) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 1e751a72b6..e859a9abef 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -1012,7 +1012,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `shared golden restored software proof authorizes and completes exact observed successor`() = test { - val bytes = requireNotNull(javaClass.getResourceAsStream("/active-onchain-attempt-golden.json")).readBytes() + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() val backup = kotlinx.serialization.json.Json { ignoreUnknownKeys = true } .decodeFromString(bytes.decodeToString()) val state = requireNotNull(backup.paykitPaymentState) From a57fb48e999161db342358c6207cd4b6ea4267fa Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 09:41:19 +0200 Subject: [PATCH 071/108] fix: bind restored funding to its order amount --- .../models/ActiveOnchainAttemptBackup.kt | 2 +- .../models/ActiveOnchainAttemptBackupTest.kt | 19 +++++++++++++++++ .../to/bitkit/repositories/BackupRepoTest.kt | 21 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt index 222f5d0c6e..fda35e29b7 100644 --- a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt +++ b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt @@ -98,7 +98,7 @@ data class ActiveOnchainAttemptBackup( } } val transferContext = transfer?.let { - require(orderId != null) + require(orderId != null && unsigned(amountSats) == unsigned(it.originalOrderFeeSats)) OnchainTransferContext( unsigned(it.txTotalSats), unsigned(it.preTransferOnchainSats), diff --git a/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt index 1457afb588..8b2785a7d4 100644 --- a/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt @@ -70,6 +70,25 @@ class ActiveOnchainAttemptBackupTest : BaseUnitTest() { assertEquals(4uL, successor.winningFeeRateSatsPerVByte) } + @Test + fun `restored funding amount must equal its original order fee`() { + val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt).copy( + requestId = null, + payerIdentity = null, + orderId = "original-order", + transfer = ActiveOnchainAttemptBackup.Transfer("2000", "3000", "900", "1000"), + ) + for (amount in listOf("999", "1001", "1234")) { + assertFailsWith { + wire.copy(amountSats = amount).restored("regtest", binding, "wallet0", 0) + } + } + val restored = wire.copy(amountSats = "1000").restored("regtest", binding, "wallet0", 0) + assertEquals(1000uL, restored.amountSats) + assertEquals(restored.amountSats, restored.transferContext?.originalOrderFeeSats) + assertEquals(wire.copy(amountSats = "1000"), ActiveOnchainAttemptBackup.from(restored, "regtest", binding)) + } + @Test fun `every retained successor needs a fee rate regardless of current outcome`() { val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt) diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index c4d586df40..2e2c0bc41c 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -305,6 +305,7 @@ class BackupRepoTest : BaseUnitTest() { requestId = null, payerIdentity = null, orderId = "original-order", + amountSats = 1_000uL, isTransfer = true, evidence = OnchainSendEvidence.Accepted, candidateFeeRates = mapOf(requireNotNull(original.txid) to 4uL), @@ -529,6 +530,26 @@ class BackupRepoTest : BaseUnitTest() { ).restoreActive(wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0)) } + @Test + fun `mismatched funding amount fails restore before installing the guard`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val original = requireNotNull(state.activeOnchainAttempt) + val wire = original.copy( + requestId = null, + payerIdentity = null, + orderId = "original-order", + transfer = to.bitkit.models.ActiveOnchainAttemptBackup.Transfer("2000", "3000", "900", "1000"), + ) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + stubWalletBackup(paykitPaymentState = state.copy(pendingProofs = emptyList(), activeOnchainAttempt = wire)) + assertTrue(sut.performFullRestoreFromLatestBackup().isFailure) + verify(onchainSendAttemptStore, never()).restoreActive(any()) + verify(paykitPaymentProofRepo, never()).restoreBackup(any()) + verify(transferRepo, never()).resumeAcceptedFunding(any()) + verify(vssBackupClient, never()).putObject(any(), any()) + } + @Test fun `missing successor fee blocks restore before installing the guard`() = test { val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() From 6a194a7bc29069036b839cffa58fdee0b49c7a33 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 10:11:38 +0200 Subject: [PATCH 072/108] fix: preserve original fees during backup restore --- .../models/ActiveOnchainAttemptBackup.kt | 1 + .../models/ActiveOnchainAttemptBackupTest.kt | 19 +++++++++++++++++++ .../to/bitkit/repositories/BackupRepoTest.kt | 14 ++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt index fda35e29b7..57c119c8b2 100644 --- a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt +++ b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt @@ -90,6 +90,7 @@ data class ActiveOnchainAttemptBackup( else -> error("Unsupported active operation status") } require(candidateTxids.drop(1).all { it in restoredFeeRates }) + restoredFeeRates[candidateTxids.first()]?.let { require(it == unsigned(feeRateSatsPerVByte)) } requireNotNull(followup).let { unsigned(it.feeSats) unsigned(it.createdAtMillis) diff --git a/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt index 8b2785a7d4..bc951faa6c 100644 --- a/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt @@ -70,6 +70,25 @@ class ActiveOnchainAttemptBackupTest : BaseUnitTest() { assertEquals(4uL, successor.winningFeeRateSatsPerVByte) } + @Test + fun `original candidate rate must agree with the retained original rate`() { + val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt) + val original = wire.candidateTxids.first() + val successor = wire.candidateTxids.last() + for (status in listOf("pending", "unknown", "rejected", "accepted")) { + val candidate = wire.copy(status = status, txid = original) + assertFailsWith { + candidate.copy(candidateFeeRates = mapOf(original to "3", successor to "4")) + .restored("regtest", binding, "wallet0", 0) + } + for (rates in listOf(mapOf(successor to "4"), mapOf(original to "2", successor to "4"))) { + val restored = candidate.copy(candidateFeeRates = rates).restored("regtest", binding, "wallet0", 0) + assertEquals(2uL, restored.winningFeeRateSatsPerVByte) + assertEquals(4uL, restored.copy(txid = successor).winningFeeRateSatsPerVByte) + } + } + } + @Test fun `restored funding amount must equal its original order fee`() { val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt).copy( diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 2e2c0bc41c..7d4ead8c31 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -530,6 +530,20 @@ class BackupRepoTest : BaseUnitTest() { ).restoreActive(wire.restored("regtest", wire.wallet.binding, WalletScope.default, 0)) } + @Test + fun `conflicting original fee fails restore before installing the guard`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + val conflicting = requireNotNull(wire.candidateFeeRates) + (wire.candidateTxids.first() to "3") + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + stubWalletBackup(paykitPaymentState = state.copy(activeOnchainAttempt = wire.copy(candidateFeeRates = conflicting))) + assertTrue(sut.performFullRestoreFromLatestBackup().isFailure) + verify(onchainSendAttemptStore, never()).restoreActive(any()) + verify(paykitPaymentProofRepo, never()).restoreBackup(any()) + verify(vssBackupClient, never()).putObject(any(), any()) + } + @Test fun `mismatched funding amount fails restore before installing the guard`() = test { val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() From 1a0fb1a5d3fc2b5900e7dcfd89421e072b2d8feb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 11:12:57 +0200 Subject: [PATCH 073/108] fix: preserve original funding order terms --- .../to/bitkit/viewmodels/TransferViewModel.kt | 10 +++---- .../viewmodels/TransferViewModelTest.kt | 27 ++++++++++++++++--- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index caeee577dc..1d91ed88cd 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -402,6 +402,7 @@ class TransferViewModel @Inject constructor( txId = txid, txTotalSats = original.txTotalSats, preTransferOnchainSats = original.preTransferOnchainSats, + originalContext = original, requireTransferPersisted = true, ) runSuspendCatching { lightningRepo.completeAcceptedTransferFollowup(order.id, txid) } @@ -462,6 +463,7 @@ class TransferViewModel @Inject constructor( txId = outcome.txid, txTotalSats = transferContext.txTotalSats, preTransferOnchainSats = transferContext.preTransferOnchainSats, + originalContext = transferContext, requireTransferPersisted = true, ) runSuspendCatching { lightningRepo.completeAcceptedTransferFollowup(order.id, outcome.txid) } @@ -702,15 +704,11 @@ class TransferViewModel @Inject constructor( txTotalSats: ULong? = null, preTransferOnchainSats: ULong? = null, activityWalletId: String = WalletScope.default, + originalContext: OnchainTransferContext? = null, requireTransferPersisted: Boolean = false, ) { if (requireTransferPersisted) { - val originalContext = if (txTotalSats != null && preTransferOnchainSats != null) { - OnchainTransferContext(txTotalSats, preTransferOnchainSats) - } else { - null - } - transferRepo.persistAcceptedFunding(order, txId, originalContext).getOrThrow() + transferRepo.persistAcceptedFunding(order, txId, requireNotNull(originalContext)).getOrThrow() } else { cacheStore.addPaidOrder(orderId = order.id, txId = txId) transferRepo.createTransfer( diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index b2e2a5130e..2ed415bec5 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -1180,6 +1180,15 @@ class TransferViewModelTest : BaseUnitTest() { paymentDeadlineAt = anyOrNull(), contactPublicKey = anyOrNull(), ) + verify(transferRepo).persistAcceptedFunding( + order, TXID, + OnchainTransferContext( + txTotalSats = 100_000uL, + preTransferOnchainSats = 100_000uL, + originalOrderClientBalanceSats = order.clientBalanceSat, + originalOrderFeeSats = order.feeSat, + ), + ) verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) verify(blocktankRepo, times(1)).createOrder(eq(order.clientBalanceSat), eq(order.lspBalanceSat), any()) } @@ -1381,7 +1390,11 @@ class TransferViewModelTest : BaseUnitTest() { @Test fun `accepted transfer resumes after transfer storage failure without another send`() = test { val order = spendingOrder(feeSat = 98_000uL) - val original = OnchainTransferContext(99_000uL, 125_000uL) + val original = OnchainTransferContext( + 99_000uL, 125_000uL, + originalOrderClientBalanceSats = order.clientBalanceSat, + originalOrderFeeSats = order.feeSat, + ) val attempt = acceptedFundingAttempt(order, original) whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) whenever(transferRepo.persistAcceptedFunding(order, TXID, original)) @@ -1424,7 +1437,11 @@ class TransferViewModelTest : BaseUnitTest() { private suspend fun TestScope.assertPaidFundingSurvivesLocalFailure(resumedEvidence: OnchainSendEvidence?) { val order = spendingOrder(feeSat = 98_000uL) - val original = OnchainTransferContext(99_000uL, 110_000uL) + val original = OnchainTransferContext( + 99_000uL, 110_000uL, + originalOrderClientBalanceSats = order.clientBalanceSat, + originalOrderFeeSats = order.feeSat, + ) val attempt = acceptedFundingAttempt(order, original).copy( evidence = resumedEvidence ?: OnchainSendEvidence.Accepted, ) @@ -1467,7 +1484,11 @@ class TransferViewModelTest : BaseUnitTest() { @Test fun `accepted transfer recovery preserves original balance totals`() = test { val order = spendingOrder(feeSat = 98_000uL) - val original = OnchainTransferContext(99_000uL, 125_000uL) + val original = OnchainTransferContext( + 99_000uL, 125_000uL, + originalOrderClientBalanceSats = order.clientBalanceSat, + originalOrderFeeSats = order.feeSat, + ) whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedFundingAttempt(order, original)) quoteOrder(order) prepareConfirm() From 9cbc47a533e4cab5d700cf4000c393149be6073b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 11:42:24 +0200 Subject: [PATCH 074/108] fix: preserve send-all funding backups --- .../models/ActiveOnchainAttemptBackup.kt | 5 +++- .../models/ActiveOnchainAttemptBackupTest.kt | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt index 57c119c8b2..6cf6a19c32 100644 --- a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt +++ b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt @@ -99,7 +99,10 @@ data class ActiveOnchainAttemptBackup( } } val transferContext = transfer?.let { - require(orderId != null && unsigned(amountSats) == unsigned(it.originalOrderFeeSats)) + require(orderId != null) + val originalFee = unsigned(it.originalOrderFeeSats) + val recipientAmount = unsigned(amountSats) + require(if (isMaxAmount) recipientAmount >= originalFee else recipientAmount == originalFee) OnchainTransferContext( unsigned(it.txTotalSats), unsigned(it.preTransferOnchainSats), diff --git a/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt index bc951faa6c..57179833ec 100644 --- a/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt @@ -108,6 +108,31 @@ class ActiveOnchainAttemptBackupTest : BaseUnitTest() { assertEquals(wire.copy(amountSats = "1000"), ActiveOnchainAttemptBackup.from(restored, "regtest", binding)) } + @Test + fun `send-all funding backup retains actual recipient amount and original order terms`() { + val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt).copy( + requestId = null, + payerIdentity = null, + orderId = "original-order", + isMaxAmount = true, + amountSats = "99500", + transfer = ActiveOnchainAttemptBackup.Transfer("100000", "100000", "97000", "99000"), + ) + for (status in listOf("pending", "unknown", "rejected", "accepted")) { + val candidate = wire.copy(status = status) + val restored = candidate.restored("regtest", binding, "wallet0", 0) + assertEquals(99500uL, restored.amountSats) + assertEquals(99000uL, restored.transferContext?.originalOrderFeeSats) + assertEquals(candidate, ActiveOnchainAttemptBackup.from(restored, "regtest", binding)) + } + assertFailsWith { + wire.copy(amountSats = "98999").restored("regtest", binding, "wallet0", 0) + } + assertFailsWith { + wire.copy(isMaxAmount = false).restored("regtest", binding, "wallet0", 0) + } + } + @Test fun `every retained successor needs a fee rate regardless of current outcome`() { val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt) From b6a7873d0ae213ada4354c9806868d435b02024c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 12:41:23 +0200 Subject: [PATCH 075/108] fix: reject impossible restored funding amounts --- .../java/to/bitkit/models/ActiveOnchainAttemptBackup.kt | 8 +++++++- .../to/bitkit/models/ActiveOnchainAttemptBackupTest.kt | 8 ++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt index 6cf6a19c32..d615c292ae 100644 --- a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt +++ b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt @@ -102,7 +102,13 @@ data class ActiveOnchainAttemptBackup( require(orderId != null) val originalFee = unsigned(it.originalOrderFeeSats) val recipientAmount = unsigned(amountSats) - require(if (isMaxAmount) recipientAmount >= originalFee else recipientAmount == originalFee) + require( + if (isMaxAmount) { + recipientAmount >= originalFee && recipientAmount <= unsigned(it.txTotalSats) + } else { + recipientAmount == originalFee + } + ) OnchainTransferContext( unsigned(it.txTotalSats), unsigned(it.preTransferOnchainSats), diff --git a/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt index 57179833ec..8fe18f438d 100644 --- a/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt @@ -125,8 +125,12 @@ class ActiveOnchainAttemptBackupTest : BaseUnitTest() { assertEquals(99000uL, restored.transferContext?.originalOrderFeeSats) assertEquals(candidate, ActiveOnchainAttemptBackup.from(restored, "regtest", binding)) } - assertFailsWith { - wire.copy(amountSats = "98999").restored("regtest", binding, "wallet0", 0) + for (status in listOf("pending", "unknown", "rejected", "accepted")) { + for (amount in listOf("98999", "100001")) { + assertFailsWith { + wire.copy(status = status, amountSats = amount).restored("regtest", binding, "wallet0", 0) + } + } } assertFailsWith { wire.copy(isMaxAmount = false).restored("regtest", binding, "wallet0", 0) From f1b3f2e3a426b1bf675f9a33fa536392e8e3437a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 13:14:11 +0200 Subject: [PATCH 076/108] fix: reject funding outside approved terms --- .../repositories/OnchainSendAttemptStore.kt | 5 +++ .../OnchainSendAttemptStoreTest.kt | 37 +++++++++++++++++++ .../OnchainSendCoordinatorTest.kt | 18 ++++----- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index 00f6c99a2c..60c769a288 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -262,6 +262,11 @@ class OnchainSendAttemptStore @Inject constructor( } else { require(current.originalInputs == null && current.candidateTxids.isEmpty() && current.txid == null) require((current.isMaxAmount && current.requestId == null) || receipt.amountSats == current.amountSats) + if (current.isTransfer && current.isMaxAmount) { + val context = requireNotNull(current.transferContext) + val originalFee = requireNotNull(context.originalOrderFeeSats) + require(receipt.amountSats >= originalFee && receipt.amountSats <= context.txTotalSats) + } } val feeRate = receipt.feeRateSatsPerVByte ?: current.feeRateSatsPerVByte require(OnchainRecoveryFeeRate.isValid(feeRate)) diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index 2310d7ee2f..7bc972672e 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -15,6 +15,7 @@ import org.mockito.kotlin.whenever import to.bitkit.data.keychain.Keychain import to.bitkit.services.LightningService import to.bitkit.test.BaseUnitTest +import kotlin.test.assertFalse import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNull @@ -369,6 +370,42 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { assertTrue(reopened.admitForTest().attemptId != attempt.attemptId) } + @Test + fun `send-all funding validates prepared amount before candidate retention or dispatch`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val context = OnchainTransferContext(100_000uL, 100_000uL, 97_000uL, 99_000uL) + val attempt = store.admit( + walletId = "wallet-1", requestId = null, orderId = "order-1", address = "bcrt1qfunding", + amountSats = 99_000uL, isMaxAmount = true, feeRateSatsPerVByte = 1uL, isTransfer = true, + channelId = null, tags = emptyList(), transferContext = context, beforeSendAttempt = {}, + ) + val receipt = OnchainPreparedReceipt( + "ab".repeat(32), listOf(OnchainSendInput("11".repeat(32), 0u)), attempt.address, 99_500uL, + ) + for (amount in listOf(98_999uL, 100_001uL)) { + assertFailsWith { + store.retainPreparedReceipt(attempt.attemptId, 0, receipt.copy(amountSats = amount), false) + } + assertNull(store.current()?.txid) + assertTrue(store.current()?.candidateTxids?.isEmpty() == true) + var dispatched = false + assertFailsWith { + store.broadcastPreparedCandidate(attempt.attemptId, 0, receipt.txid) { + dispatched = true + OnchainSendOutcome.Accepted(receipt.txid) + } + } + assertFalse(dispatched) + } + val retained = store.retainPreparedReceipt(attempt.attemptId, 0, receipt, false) + assertEquals(99_500uL, retained.amountSats) + assertEquals(context, retained.transferContext) + } + @Test fun `transfer balance context is durable before dispatch and survives reopen`() = test { var saved: String? = null diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt index a05f1322bb..d070bf2641 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt @@ -57,7 +57,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { orderId = "order-1", address = "bcrt1qrecipient", amountSats = 1_000uL, isMaxAmount = isMax, feeRateSatsPerVByte = 1uL, isTransfer = true, channelId = "channel-1", tags = listOf("tag"), - transferContext = OnchainTransferContext(1_100uL, 2_000uL), beforeSendAttempt = {}, + transferContext = OnchainTransferContext(1_100uL, 2_000uL, 900uL, 1_000uL), beforeSendAttempt = {}, payerIdentity = "original-payer", ) @@ -184,7 +184,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { var authorizations = 0 val sender = object : OnchainPreparedSender { override suspend fun prepareInitial(attempt: OnchainSendAttempt) = PreparedOnchainSend( - f.receipt(amount = 900uL) + f.receipt(amount = 1_050uL) ) { OnchainSendOutcome.Unknown(firstTxid) } @@ -192,14 +192,14 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?, ): PreparedOnchainSend { - assertEquals(900uL, attempt.amountSats) + assertEquals(1_050uL, attempt.amountSats) assertEquals(listOf(input), attempt.originalInputs) assertEquals("order-1", attempt.orderId) assertEquals("wallet-1", attempt.walletId) assertEquals(null, attempt.requestId) assertEquals("channel-1", attempt.channelId) - assertEquals(OnchainTransferContext(1_100uL, 2_000uL), attempt.transferContext) - return PreparedOnchainSend(f.receipt(nextTxid, 900uL)) { OnchainSendOutcome.Unknown(nextTxid) } + assertEquals(OnchainTransferContext(1_100uL, 2_000uL, 900uL, 1_000uL), attempt.transferContext) + return PreparedOnchainSend(f.receipt(nextTxid, 1_050uL)) { OnchainSendOutcome.Unknown(nextTxid) } } } val coordinator = OnchainSendCoordinator(f.store, sender, testDispatcher) @@ -328,16 +328,16 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { val initial = f.admit(isMax = true) val native = mock() whenever(native.prepareMax(initial.address, true, 1uL, 0)).thenReturn( - PreparedOnchainSend(f.receipt(amount = 900uL)) { OnchainSendOutcome.Unknown(firstTxid) }, + PreparedOnchainSend(f.receipt(amount = 1_050uL)) { OnchainSendOutcome.Unknown(firstTxid) }, ) - whenever(native.prepareFixed(initial.address, 900uL, 2uL, listOf(input), 0)).thenReturn( - PreparedOnchainSend(f.receipt(nextTxid, 900uL)) { OnchainSendOutcome.Unknown(nextTxid) }, + whenever(native.prepareFixed(initial.address, 1_050uL, 2uL, listOf(input), 0)).thenReturn( + PreparedOnchainSend(f.receipt(nextTxid, 1_050uL)) { OnchainSendOutcome.Unknown(nextTxid) }, ) val coordinator = OnchainSendCoordinator(f.store, OnchainPreparedSenderAdapter(native), testDispatcher) coordinator.sendInitial(initial).getOrThrow() coordinator.retryOriginal(initial.attemptId, initial.walletId, 2uL) {}.getOrThrow() verify(native).prepareMax(initial.address, true, 1uL, 0) - verify(native).prepareFixed(initial.address, 900uL, 2uL, listOf(input), 0) + verify(native).prepareFixed(initial.address, 1_050uL, 2uL, listOf(input), 0) } @Test From 34424e579fa004e3e1afef819c352c073036700a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 13:45:37 +0200 Subject: [PATCH 077/108] fix: reject understated funding backups --- .../main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt | 3 ++- .../java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt index d615c292ae..2da2041171 100644 --- a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt +++ b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt @@ -102,9 +102,10 @@ data class ActiveOnchainAttemptBackup( require(orderId != null) val originalFee = unsigned(it.originalOrderFeeSats) val recipientAmount = unsigned(amountSats) + require(recipientAmount <= unsigned(it.txTotalSats)) require( if (isMaxAmount) { - recipientAmount >= originalFee && recipientAmount <= unsigned(it.txTotalSats) + recipientAmount >= originalFee } else { recipientAmount == originalFee } diff --git a/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt index 8fe18f438d..31b227d1d7 100644 --- a/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt @@ -102,6 +102,10 @@ class ActiveOnchainAttemptBackupTest : BaseUnitTest() { wire.copy(amountSats = amount).restored("regtest", binding, "wallet0", 0) } } + assertFailsWith { + wire.copy(amountSats = "1000", transfer = wire.transfer?.copy(txTotalSats = "1")) + .restored("regtest", binding, "wallet0", 0) + } val restored = wire.copy(amountSats = "1000").restored("regtest", binding, "wallet0", 0) assertEquals(1000uL, restored.amountSats) assertEquals(restored.amountSats, restored.transferContext?.originalOrderFeeSats) From 2179bcdb11adc8443c62ce48c092a229f71e0b5a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 19:00:06 +0200 Subject: [PATCH 078/108] fix: release unsigned payments abandoned before dispatch --- .../repositories/OnchainSendAttemptStore.kt | 22 ++++++++++++--- .../OnchainSendAttemptStoreTest.kt | 27 +++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index 60c769a288..844bf0e1f7 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -142,12 +142,14 @@ class OnchainSendAttemptStore @Inject constructor( private val firstSubmissions = mutableSetOf() suspend fun current(): OnchainSendAttempt? = withContext(ioDispatcher) { - mutex.withLock { loadWithRetainedAccepted(lightningService.currentWalletIndex) } + mutex.withLock { + releaseInterruptedUnsignedPreparation(loadWithRetainedAccepted(lightningService.currentWalletIndex)) + } } /** Read only restart-safe evidence, excluding a positive result retained after a failed write. */ suspend fun currentDurable(): OnchainSendAttempt? = withContext(ioDispatcher) { - mutex.withLock { load(lightningService.currentWalletIndex) } + mutex.withLock { releaseInterruptedUnsignedPreparation(load(lightningService.currentWalletIndex)) } } @Suppress("LongParameterList") @@ -169,7 +171,7 @@ class OnchainSendAttemptStore @Inject constructor( ): OnchainSendAttempt = withContext(ioDispatcher) { mutex.withLock { val walletIndex = lightningService.currentWalletIndex - val previous = load(walletIndex) + val previous = releaseInterruptedUnsignedPreparation(load(walletIndex)) if (previous != null && ( previous.walletId != walletId || previous.blocksNextSend || @@ -216,6 +218,17 @@ class OnchainSendAttemptStore @Inject constructor( } } + private suspend fun releaseInterruptedUnsignedPreparation(current: OnchainSendAttempt?): OnchainSendAttempt? { + if (current == null || current.attemptId in inFlightPreparations || current.restoredFromBackup || + current.walletId != WalletScope.default || current.requestId != null || + current.evidence != OnchainSendEvidence.Pending || current.txid != null || + current.candidateTxids.isNotEmpty() || current.originalInputs != null + ) return current + keychain.delete(KEY, current.walletIndex) + _backupStateVersion.update { it + 1 } + return null + } + suspend fun releaseInterruptedShopPreparation( removeOriginalProof: suspend (OnchainSendAttempt) -> Boolean, ): Boolean = withContext(ioDispatcher + NonCancellable) { @@ -417,7 +430,8 @@ class OnchainSendAttemptStore @Inject constructor( captureProofs: suspend () -> List, ): Pair> = withContext(ioDispatcher) { mutex.withLock { - loadWithRetainedAccepted(walletIndex)?.takeIf { it.blocksNextSend } to captureProofs() + val attempt = releaseInterruptedUnsignedPreparation(loadWithRetainedAccepted(walletIndex)) + attempt?.takeIf { it.blocksNextSend } to captureProofs() } } diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index 7bc972672e..f0e82d3044 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -12,6 +12,7 @@ import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import to.bitkit.models.WalletScope import to.bitkit.data.keychain.Keychain import to.bitkit.services.LightningService import to.bitkit.test.BaseUnitTest @@ -175,6 +176,32 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { assertFailsWith { store.admitForTest() } } + @Test + fun `restart releases unsigned ordinary sends and transfers but keeps live preparation`() = test { + for (isTransfer in listOf(false, true)) { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + whenever(keychain.delete(key, 0)).doSuspendableAnswer { saved = null } + val store = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val original = store.admit( + walletId = WalletScope.default, + requestId = null, orderId = if (isTransfer) "original-order" else null, + address = "bcrt1qrecipient", amountSats = 1_000uL, isMaxAmount = false, + feeRateSatsPerVByte = 1uL, isTransfer = isTransfer, channelId = null, + tags = emptyList(), beforeSendAttempt = {}, + ) + assertEquals(original, store.current(), "Current process must retain its live preparation") + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) + val snapshot = reopened.backupSnapshot(0) { emptyList() } + assertNull(snapshot.first, "An unsigned abandoned attempt must not stall wallet backup") + assertNull(reopened.current(), "An unsigned crash guard cannot represent a dispatched transaction") + assertNull(saved) + reopened.admitForTest() + } + } + @Test fun `restart never releases legacy empty guards or recorded candidates`() = test { var saved: String? = null From dd95573013a2c13417dcf7e83cc5b3f28884ea11 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 19:11:15 +0200 Subject: [PATCH 079/108] fix: reject transfer backup totals outside database range --- .../models/ActiveOnchainAttemptBackup.kt | 2 ++ .../models/ActiveOnchainAttemptBackupTest.kt | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt index 2da2041171..89c4c9956c 100644 --- a/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt +++ b/app/src/main/java/to/bitkit/models/ActiveOnchainAttemptBackup.kt @@ -100,6 +100,8 @@ data class ActiveOnchainAttemptBackup( } val transferContext = transfer?.let { require(orderId != null) + require(unsigned(it.txTotalSats) <= Long.MAX_VALUE.toULong()) + require(unsigned(it.preTransferOnchainSats) <= Long.MAX_VALUE.toULong()) val originalFee = unsigned(it.originalOrderFeeSats) val recipientAmount = unsigned(amountSats) require(recipientAmount <= unsigned(it.txTotalSats)) diff --git a/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt index 31b227d1d7..f815f2a2e7 100644 --- a/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt +++ b/app/src/test/java/to/bitkit/models/ActiveOnchainAttemptBackupTest.kt @@ -89,6 +89,28 @@ class ActiveOnchainAttemptBackupTest : BaseUnitTest() { } } + @Test + fun `restored transfer totals must fit the activity database range`() { + val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt).copy( + requestId = null, payerIdentity = null, orderId = "original-order", amountSats = "1000", + transfer = ActiveOnchainAttemptBackup.Transfer("2000", "3000", "900", "1000"), + ) + val overflow = (Long.MAX_VALUE.toULong() + 1uL).toString() + for (transfer in listOf( + requireNotNull(wire.transfer).copy(txTotalSats = overflow), + requireNotNull(wire.transfer).copy(preTransferOnchainSats = overflow), + )) { + assertFailsWith { + wire.copy(transfer = transfer).restored("regtest", binding, "wallet0", 0) + } + } + val maximum = Long.MAX_VALUE.toString() + val accepted = wire.copy(transfer = wire.transfer?.copy(txTotalSats = maximum, preTransferOnchainSats = maximum)) + .restored("regtest", binding, "wallet0", 0) + assertEquals(Long.MAX_VALUE.toULong(), accepted.transferContext?.txTotalSats) + assertEquals(Long.MAX_VALUE.toULong(), accepted.transferContext?.preTransferOnchainSats) + } + @Test fun `restored funding amount must equal its original order fee`() { val wire = requireNotNull(golden().paykitPaymentState?.activeOnchainAttempt).copy( From 0976499d07d9e030fcfe5a2cf0a42abd051a4655 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 19:22:18 +0200 Subject: [PATCH 080/108] fix: validate proof backups before installing payment guards --- .../java/to/bitkit/repositories/BackupRepo.kt | 2 ++ .../to/bitkit/repositories/BackupRepoTest.kt | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index f79a762b59..a29de1a52f 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -832,6 +832,8 @@ class BackupRepo @Inject constructor( val parsed = json.decodeFromString(String(dataBytes)) var restoredAttempt: OnchainSendAttempt? = null parsed.paykitPaymentState?.let { + // Validate every proof before writing a guard or replacing existing payment state. + it.pendingProofs.forEach { proof -> proof.restored() } val walletIndex = backupWalletIndex() it.activeOnchainAttempt?.let { wire -> validateActiveProof(wire, it.pendingProofs) diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 7d4ead8c31..19b95195ae 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -512,6 +512,23 @@ class BackupRepoTest : BaseUnitTest() { ordered.verify(paykitPaymentProofRepo).restoreBackup(state.pendingProofs) } + @Test + fun `invalid unrelated proof fails restore before installing the guard`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + val invalid = state.pendingProofs.first().copy( + requestId = state.pendingProofs.first().requestId.copy(paymentRequestId = "550e8400-e29b-41d4-a716-446655440099"), + kind = "unsupported-proof", + ) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + stubWalletBackup(paykitPaymentState = state.copy(pendingProofs = state.pendingProofs + invalid)) + assertTrue(sut.performFullRestoreFromLatestBackup().isFailure) + verify(onchainSendAttemptStore, never()).restoreActive(any()) + verify(paykitPaymentProofRepo, never()).restoreBackup(any()) + verify(vssBackupClient, never()).putObject(any(), any()) + } + @Test fun `active guard uses captured backup namespace index instead of assuming index zero`() = test { val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() From 5d98660bf619f7115edd823c3802b0962ba8d7da Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 19:24:27 +0200 Subject: [PATCH 081/108] fix: retry funding recovery after overlapping reconnects --- .../to/bitkit/repositories/TransferRepo.kt | 3 +- .../bitkit/repositories/TransferRepoTest.kt | 67 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index 7a10763ab0..b92a95cd6b 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -9,6 +9,7 @@ import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.SortDirection import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop @@ -76,7 +77,7 @@ class TransferRepo @Inject constructor( } repoScope.launch { connectivityRepo.isOnline.map { it == ConnectivityState.CONNECTED }.distinctUntilChanged().drop(1) - .collect { connected -> + .collectLatest { connected -> if (connected && lightningRepo.lightningState.value.nodeLifecycleState.isRunning()) { resumeAcceptedFunding() } diff --git a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt index 9be9591d29..2100e80118 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -12,6 +12,7 @@ import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.SortDirection +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf @@ -192,6 +193,72 @@ class TransferRepoTest : BaseUnitTest() { assertNotNull(sut) } + @Test + fun `reconnect during suspended funding recovery starts another attempt`() = test { + val order = previewBtOrder() + val txid = "ab".repeat(32) + val attempt = OnchainSendAttempt( + WalletScope.default, + "attempt", + null, + order.id, + requireNotNull(order.payment?.onchain?.address), + order.feeSat, + false, + 1uL, + true, + null, + emptyList(), + OnchainSendEvidence.Accepted, + txid, + transferContext = OnchainTransferContext(99_000uL, 125_000uL, order.clientBalanceSat, order.feeSat), + ) + setupClockNowMock() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + val firstFetch = CompletableDeferred() + var fetches = 0 + whenever(blocktankRepo.fetchOrders(listOf(order.id))).doSuspendableAnswer { + fetches += 1 + if (fetches == 1) { + firstFetch.await() + Result.failure(AppError("offline")) + } else { + Result.success(listOf(order)) + } + } + whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState( + nodeLifecycleState = NodeLifecycleState.Running, + ))) + runCurrent() + online.value = ConnectivityState.CONNECTED + runCurrent() + assertEquals(1, fetches) + online.value = ConnectivityState.DISCONNECTED + runCurrent() + online.value = ConnectivityState.CONNECTED + runCurrent() + firstFetch.complete(Unit) + runCurrent() + assertEquals(2, fetches) + + verify(transferDao).insert( + org.mockito.kotlin.check { + assertEquals(order.id, it.lspOrderId) + assertEquals(txid, it.fundingTxId) + assertEquals(99_000L, it.txTotalSats) + assertEquals(125_000L, it.preTransferOnchainSats) + } + ) + verify(lightningRepo).completeAcceptedTransferFollowup(order.id, txid) + verify(lightningRepo, never()).sendOnChain( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), + anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ) + assertNotNull(sut) + } + @Test fun `startup resumes accepted original funding without a confirmation or native resend`() = test { val order = previewBtOrder() From bef0388d7c97a3f4aa3663e2c01847c0f53cc2d9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 19:31:45 +0200 Subject: [PATCH 082/108] fix: preserve recovery winners across stale transaction events --- .../to/bitkit/repositories/LightningRepo.kt | 4 ++- .../repositories/OnchainSendAttemptStore.kt | 4 ++- .../bitkit/repositories/LightningRepoTest.kt | 2 +- .../OnchainSendAttemptStoreTest.kt | 27 +++++++++++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index f09267c8b5..b7824c96a0 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -564,7 +564,9 @@ class LightningRepo @Inject constructor( } if (observedTxid != null) { runSuspendCatching { - val observed = onchainSendAttemptStore.observeExactTransaction(observedTxid) + val observed = onchainSendAttemptStore.observeExactTransaction( + observedTxid, isConfirmed = event is Event.OnchainTransactionConfirmed, + ) val attempt = observed ?: onchainSendAttemptStore.current() if (attempt != null && attempt.txid.equals(observedTxid, ignoreCase = true) && attempt.hasPositiveEvidence && !attempt.localFollowupComplete && diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index 844bf0e1f7..912c70fb2f 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -404,13 +404,15 @@ class OnchainSendAttemptStore @Inject constructor( } } - suspend fun observeExactTransaction(txid: String): OnchainSendAttempt? = + suspend fun observeExactTransaction(txid: String, isConfirmed: Boolean = true): OnchainSendAttempt? = withContext(ioDispatcher + NonCancellable) { mutex.withLock { val current = loadWithRetainedAccepted(lightningService.currentWalletIndex) ?: return@withLock null val matchesCandidate = current.txid.equals(txid, ignoreCase = true) || txid.lowercase() in current.candidateTxids if (!matchesCandidate || !current.isUnresolved) return@withLock null + // A queued Received event cannot choose between same-input recovery candidates. + if (!isConfirmed && current.candidateTxids.size > 1) return@withLock null val observed = current.copy(evidence = OnchainSendEvidence.Observed, txid = txid.lowercase()) retainedPositive[current.walletIndex] = observed persist(observed) diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 2727e24196..7d2b5ef521 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -2177,7 +2177,7 @@ class LightningRepoTest : BaseUnitTest() { val activityService = mock() whenever(coreService.activity).thenReturn(activityService) whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) - whenever(onchainSendAttemptStore.observeExactTransaction(txid)).thenReturn(attempt).thenReturn(null) + whenever(onchainSendAttemptStore.observeExactTransaction(txid, isConfirmed = false)).thenReturn(attempt).thenReturn(null) whenever(onchainSendAttemptStore.current()).thenReturn(attempt) val eventHandler = startNodeAndCaptureEvents() val event = Event.OnchainTransactionReceived(txid = txid, details = mock()) diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index f0e82d3044..2369727a3e 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -270,6 +270,33 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { assertEquals("123456", store.admitForTest().backupFollowup?.createdAtMillis) } + @Test + fun `unconfirmed original cannot win while a successor is retained`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val backup = kotlinx.serialization.json.Json { ignoreUnknownKeys = true } + .decodeFromString(bytes.decodeToString()) + val wire = requireNotNull(requireNotNull(backup.paykitPaymentState).activeOnchainAttempt) + val original = wire.restored("regtest", wire.wallet.binding, "wallet0", 0) + val values = mutableMapOf() + val keychain = mock() + val service = mock() + whenever(service.currentWalletIndex).thenReturn(0) + whenever(keychain.loadString(eq(key), any())).thenAnswer { values[it.getArgument(1)] } + whenever(keychain.upsertString(eq(key), any(), any())).doSuspendableAnswer { + values[it.getArgument(2)] = it.getArgument(1) + } + val store = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) + store.restoreActive(original) + val first = original.candidateTxids.first() + val successor = original.candidateTxids.last() + assertNull(store.observeExactTransaction(first, isConfirmed = false)) + assertEquals(original, store.current()) + val accepted = store.recordOutcome(original.attemptId, OnchainSendOutcome.Accepted(successor), 0) + assertEquals(successor, accepted.txid) + assertNull(store.observeExactTransaction(first, isConfirmed = false)) + assertEquals(successor, store.current()?.txid) + } + @Test fun `restored exact candidate with contact observes without resend and acknowledges`() = test { val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() From 4848182d28da48d7ddc4ff20b8e173b18536234d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 19:41:29 +0200 Subject: [PATCH 083/108] fix: bound prepared funding by the approved total --- .../to/bitkit/repositories/OnchainSendAttemptStore.kt | 5 ++++- app/src/main/java/to/bitkit/services/LightningService.kt | 1 + .../to/bitkit/repositories/OnchainSendAttemptStoreTest.kt | 8 ++++++++ gradle/libs.versions.toml | 2 +- 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index 912c70fb2f..8dd90d4682 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -62,6 +62,7 @@ data class OnchainPreparedReceipt( val address: String, val amountSats: ULong, val feeRateSatsPerVByte: ULong? = null, + val miningFeeSats: ULong? = null, ) @Serializable @@ -278,7 +279,9 @@ class OnchainSendAttemptStore @Inject constructor( if (current.isTransfer && current.isMaxAmount) { val context = requireNotNull(current.transferContext) val originalFee = requireNotNull(context.originalOrderFeeSats) - require(receipt.amountSats >= originalFee && receipt.amountSats <= context.txTotalSats) + val miningFee = requireNotNull(receipt.miningFeeSats) + require(miningFee <= context.txTotalSats) + require(receipt.amountSats >= originalFee && receipt.amountSats <= context.txTotalSats - miningFee) } } val feeRate = receipt.feeRateSatsPerVByte ?: current.feeRateSatsPerVByte diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 3034751783..d64d3b39ab 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -996,6 +996,7 @@ class LightningService internal constructor( prepared.inputs().map { OnchainSendInput(it.txid, it.vout) }, address, prepared.recipientAmountSats(), + miningFeeSats = prepared.miningFeeSats(), ) PreparedOnchainSend(receipt) { callOnchainSend { diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index 2369727a3e..719b3b73ac 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -439,6 +439,7 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { ) val receipt = OnchainPreparedReceipt( "ab".repeat(32), listOf(OnchainSendInput("11".repeat(32), 0u)), attempt.address, 99_500uL, + miningFeeSats = 500uL, ) for (amount in listOf(98_999uL, 100_001uL)) { assertFailsWith { @@ -455,6 +456,13 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { } assertFalse(dispatched) } + for (fee in listOf(null, 501uL, ULong.MAX_VALUE)) { + assertFailsWith { + store.retainPreparedReceipt(attempt.attemptId, 0, receipt.copy(miningFeeSats = fee), false) + } + assertNull(store.current()?.txid) + assertTrue(store.current()?.candidateTxids?.isEmpty() == true) + } val retained = store.retainPreparedReceipt(attempt.attemptId, 0, receipt, false) assertEquals(99_500uL, retained.amountSats) assertEquals(context, retained.transferContext) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ee3d5f65a3..88cc5b949c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -65,7 +65,7 @@ ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "k ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.70" } +ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.71" } lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } From 125aca5c649e76ded09ee5c6c3891522086675a6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 19:47:02 +0200 Subject: [PATCH 084/108] fix: reuse the verified winning payment fee --- .../to/bitkit/repositories/LightningRepo.kt | 8 +++--- .../repositories/OnchainSendAttemptStore.kt | 7 +++++- .../bitkit/repositories/LightningRepoTest.kt | 25 +++++++++++++++++++ .../OnchainSendAttemptStoreTest.kt | 19 ++++++++++++++ 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index b7824c96a0..33ab80deac 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1746,9 +1746,9 @@ class LightningRepo @Inject constructor( } val txId = requireNotNull(attempt.txid) { "On-chain send has no transaction id" } val isSuccessor = attempt.candidateTxids.isNotEmpty() && !txId.equals(attempt.candidateTxids.first(), true) - val needsExactFee = isSuccessor || - (attempt.candidateTxids.isNotEmpty() && originalFollowup?.feeSats?.toULongOrNull() == 0uL) - val fee = if (needsExactFee) { + val needsExactFee = attempt.verifiedWinningFeeSats == null && (isSuccessor || + (attempt.candidateTxids.isNotEmpty() && originalFollowup?.feeSats?.toULongOrNull() == 0uL)) + val fee = attempt.verifiedWinningFeeSats ?: if (needsExactFee) { requireNotNull(lightningService.observedOriginalSendFee(attempt)) { "Winning transaction fee is unavailable" }.also { onchainSendAttemptStore.retainWinningFee(attempt.attemptId, attempt.walletIndex, txId, it) } @@ -1782,7 +1782,7 @@ class LightningRepo @Inject constructor( if (originalContact != null) { coreService.activity.restoreSentOnchainContact(txId, attempt.walletId, originalContact) } - if (needsExactFee) { + if (needsExactFee || attempt.verifiedWinningFeeSats != null) { coreService.activity.repairVerifiedSentOnchainFee(txId, attempt.walletId, fee, attempt.winningFeeRateSatsPerVByte) } check(coreService.activity.getOnchainActivityByTxId(txId, attempt.walletId) != null) { diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index 8dd90d4682..b4e8fceaa3 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -92,6 +92,7 @@ data class OnchainSendAttempt( val backupFollowup: ActiveOnchainAttemptBackup.Followup? = null, val restoredFromBackup: Boolean = false, val preparationPending: Boolean = false, + val verifiedWinningFeeSats: ULong? = null, ) { val winningFeeRateSatsPerVByte: ULong get() = candidateFeeRates[txid?.lowercase()] ?: feeRateSatsPerVByte.takeIf { @@ -388,7 +389,10 @@ class OnchainSendAttemptStore @Inject constructor( check(current?.attemptId == attemptId && walletIndex == lightningService.currentWalletIndex && current.hasPositiveEvidence && current.txid.equals(txid, true)) val followup = requireNotNull(current.backupFollowup) - persist(current.copy(backupFollowup = followup.copy(feeSats = feeSats.toString()))) + persist(current.copy( + backupFollowup = followup.copy(feeSats = feeSats.toString()), + verifiedWinningFeeSats = feeSats, + )) } } @@ -456,6 +460,7 @@ class OnchainSendAttemptStore @Inject constructor( backupFollowup = existing.backupFollowup?.copy(feeSats = attempt.backupFollowup?.feeSats.orEmpty()), restoredFromBackup = attempt.restoredFromBackup, preparationPending = attempt.preparationPending, + verifiedWinningFeeSats = attempt.verifiedWinningFeeSats, ) == attempt check(sameOperation && existing.candidateTxids.containsAll(attempt.candidateTxids)) { "Cannot change a restored on-chain operation's original context" diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 7d2b5ef521..2c94ffd349 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -1873,6 +1873,31 @@ class LightningRepoTest : BaseUnitTest() { verify(onchainSendAttemptStore).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) } + @Test + fun `successor followup reuses its retained exact fee when backend details disappear`() = test { + val txid = "cd".repeat(32) + val attempt = pendingSendAttempt().copy( + evidence = OnchainSendEvidence.Accepted, txid = txid, + candidateTxids = listOf("ab".repeat(32), txid), + candidateFeeRates = mapOf("ab".repeat(32) to 1uL, txid to 3uL), + backupFollowup = to.bitkit.models.ActiveOnchainAttemptBackup.Followup("281", emptyList(), createdAtMillis = "123"), + verifiedWinningFeeSats = 281uL, + ) + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(onchainSendAttemptStore.current()).thenReturn(attempt) + whenever(lightningService.observedOriginalSendFee(any())).thenReturn(null) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + whenever(activityService.getOnchainActivityByTxId(txid, attempt.walletId)).thenReturn(mock()) + sut.completeAcceptedOrdinaryFollowup(txid) + verify(lightningService, never()).observedOriginalSendFee(any()) + verify(activityService).createSentOnchainActivityFromSendResult( + txid, attempt.address, attempt.amountSats, 281uL, 3uL, false, null, attempt.walletId, + ) + verify(activityService).repairVerifiedSentOnchainFee(txid, attempt.walletId, 281uL, 3uL) + verify(onchainSendAttemptStore).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + } + @Test fun `accepted successor metadata uses the winning candidate authorized fee`() = test { val txid = "cd".repeat(32) diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index 719b3b73ac..c3f6cec2eb 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -612,6 +612,25 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { assertFailsWith { store.admitForTest() } } + @Test + fun `verified winning fee remains durable after reopening the attempt store`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val service = mock() + val store = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) + val attempt = store.admitForTest() + val txid = "ab".repeat(32) + store.recordOutcome(attempt.attemptId, OnchainSendOutcome.Accepted(txid), 0) + store.retainWinningFee(attempt.attemptId, 0, txid, 281uL) + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) + val durable = requireNotNull(reopened.current()) + assertEquals(281uL, durable.verifiedWinningFeeSats) + assertEquals("281", durable.backupFollowup?.feeSats) + assertEquals(txid, durable.txid) + } + private suspend fun OnchainSendAttemptStore.admitForTest( contactPublicKey: String? = null, beforeSendAttempt: suspend () -> Unit = { From f3ec6ed103aef1296c3e84df82cc84f68812f66c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 19:55:01 +0200 Subject: [PATCH 085/108] fix: complete ordinary payments after transient failures --- .../to/bitkit/repositories/LightningRepo.kt | 27 ++++++++++++++++++- .../bitkit/repositories/LightningRepoTest.kt | 22 +++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 33ab80deac..3714436f65 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -17,6 +17,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async @@ -161,6 +162,7 @@ class LightningRepo @Inject constructor( private val syncMutex = Mutex() private val syncPending = AtomicBoolean(false) private val syncRetryJob = AtomicReference(null) + private val ordinaryFollowupRetryJob = AtomicReference(null) private val pendingStopJob = AtomicReference(null) private val pendingStopLock = Any() private val lifecycleMutex = Mutex() @@ -194,6 +196,7 @@ class LightningRepo @Inject constructor( if (_lightningState.value.nodeLifecycleState.isRunning()) { connectToTrustedPeers() + scheduleOrdinaryFollowupRetry() } // Start retry loop if sync is failing @@ -514,6 +517,7 @@ class LightningRepo @Inject constructor( private suspend fun adoptRunningNode(): Result { _lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Running) } + scheduleOrdinaryFollowupRetry() return lightningService.startEventListener(::onEvent).onFailure { Logger.warn("Failed to start event listener", it, context = TAG) } @@ -576,7 +580,10 @@ class LightningRepo @Inject constructor( onchainSendAttemptStore.markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) } } - .onFailure { Logger.warn("Failed to record exact on-chain transaction observation", it, context = TAG) } + .onFailure { + Logger.warn("Failed to record exact on-chain transaction observation", it, context = TAG) + scheduleOrdinaryFollowupRetry() + } } handleLdkEvent(event) recordProbeOutcome(event) @@ -1790,6 +1797,23 @@ class LightningRepo @Inject constructor( } } + private fun scheduleOrdinaryFollowupRetry() { + val previous = ordinaryFollowupRetryJob.get() + if (previous?.isActive == true) return + val retry = scope.launch(start = CoroutineStart.LAZY) { + repeat(3) { + delay(1.seconds) + if (!_lightningState.value.nodeLifecycleState.isRunning()) return@launch + val attempt = runSuspendCatching { onchainSendAttemptStore.current() }.getOrNull() ?: return@launch + if (!attempt.hasPositiveEvidence || attempt.localFollowupComplete || + attempt.isTransfer || attempt.requestId != null + ) return@launch + completeAcceptedOrdinaryFollowup(requireNotNull(attempt.txid)) + } + } + if (ordinaryFollowupRetryJob.compareAndSet(previous, retry)) retry.start() else retry.cancel() + } + suspend fun completeAcceptedOrdinaryFollowup(txid: String) { val attempt = onchainSendAttemptStore.current() if (attempt != null && !attempt.isTransfer && attempt.requestId == null && @@ -2026,6 +2050,7 @@ class LightningRepo @Inject constructor( balances = getBalances(), ) } + if (_lightningState.value.nodeLifecycleState.isRunning()) scheduleOrdinaryFollowupRetry() } private fun logNodeSupportSummary(reason: String) { diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 2c94ffd349..bb6d405730 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -2215,6 +2215,28 @@ class LightningRepoTest : BaseUnitTest() { verify(onchainSendAttemptStore).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) } + @Test + fun `observed ordinary send retries transient followup without another node event`() = test { + val txid = "ab".repeat(32) + var attempt = pendingSendAttempt().copy(evidence = OnchainSendEvidence.Observed, txid = txid) + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + whenever(onchainSendAttemptStore.observeExactTransaction(txid, isConfirmed = false)).thenReturn(attempt) + whenever(onchainSendAttemptStore.current()).doSuspendableAnswer { attempt } + whenever(onchainSendAttemptStore.markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex)) + .doSuspendableAnswer { attempt = attempt.copy(localFollowupComplete = true) } + val handler = startNodeAndCaptureEvents() + handler(Event.OnchainTransactionReceived(txid = txid, details = mock())) + verify(onchainSendAttemptStore, never()).markLocalFollowupComplete(any(), any()) + whenever(activityService.getOnchainActivityByTxId(txid, attempt.walletId)).thenReturn(mock()) + testScheduler.advanceTimeBy(1_001) + runCurrent() + verify(onchainSendAttemptStore).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + verify(lightningService, never()).prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), + paymentDeadlineAt = anyOrNull()) + } + @Test fun `registerForNotifications should fail when node is not running`() = test { val result = sut.registerForNotifications() From bf8b7bebc9d1439c62151a818886efb9ee11a410 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 20:13:46 +0200 Subject: [PATCH 086/108] fix: preserve unsent hardware payment recovery --- .../to/bitkit/repositories/HwWalletRepo.kt | 13 +++++ .../repositories/PaykitPaymentProofRepo.kt | 27 ++++++++++- .../java/to/bitkit/repositories/TrezorRepo.kt | 16 +++++++ .../java/to/bitkit/services/TrezorService.kt | 16 +++++++ .../screens/wallets/send/HwSendViewModel.kt | 22 ++++++++- .../PaykitPaymentProofRepoTest.kt | 13 +++++ .../services/TrezorServiceDispatchTest.kt | 48 +++++++++++++++++++ .../wallets/send/HwSendViewModelTest.kt | 25 ++++++++++ 8 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 app/src/test/java/to/bitkit/services/TrezorServiceDispatchTest.kt diff --git a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt index 4c7bfdb985..18af1857fa 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -884,6 +884,19 @@ class HwWalletRepo @Inject constructor( } } + suspend fun broadcastFundingAtBoundary( + signedTx: HwFundingSignedTx, + paymentDeadlineAt: Instant?, + beforeNativeBroadcast: suspend () -> Unit, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + val txId = trezorRepo.broadcastRawTxAtBoundary( + signedTx.serializedTx, paymentDeadlineAt, beforeNativeBroadcast + ).getOrThrow() + HwFundingBroadcastResult(txId, signedTx.miningFeeSats, signedTx.feeRate, signedTx.totalSpent) + } + } + /** Fresh backend observation of this exact transaction in the original hardware wallet. */ suspend fun observeExactTransaction( walletId: String, diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 228e056561..b09782b347 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -572,8 +572,7 @@ class PaykitPaymentProofRepo @Inject constructor( hardwareSignedTransaction = signedTx?.serializedTx ?: it.hardwareSignedTransaction, hardwareMiningFeeSats = signedTx?.miningFeeSats ?: it.hardwareMiningFeeSats, hardwareFeeRate = signedTx?.feeRate ?: it.hardwareFeeRate, - hardwareTotalSpent = signedTx?.totalSpent ?: it.hardwareTotalSpent, - hardwareDispatchAttempted = true + hardwareTotalSpent = signedTx?.totalSpent ?: it.hardwareTotalSpent ) } else { it @@ -584,6 +583,30 @@ class PaykitPaymentProofRepo @Inject constructor( } } + suspend fun markHardwareOnchainDispatch( + requestId: PaykitPaymentRequestId, + walletId: String, + txid: String, + identity: String?, + address: String, + amountSats: ULong, + ): Boolean = withContext(ioDispatcher) { + operationMutex.withLock { + if (!PubkyPublicKeyFormat.matches(currentIdentity(), identity)) return@withLock false + val proofs = loadProofs() + val original = proofs.singleOrNull { + PubkyPublicKeyFormat.matches(it.identity, identity) && it.requestId == requestId && + it.onchainWalletId == walletId && it.kind == PaykitPaymentProofKind.Onchain && + it.paymentStarted && !it.hardwareDispatchDenied && !it.onchainAcceptanceVerified && + it.proofData == null && it.paymentIdentifier.equals(txid, true) && + it.onchainAddress == address && it.onchainAmountSats == amountSats && + it.retainedSignedHardwareReceipt() != null + } ?: return@withLock false + persist(proofs.map { if (it == original) it.copy(hardwareDispatchAttempted = true) else it }) + true + } + } + suspend fun hasRetainedHardwareOnchainPayment(walletId: String): Boolean = withContext(ioDispatcher) { operationMutex.withLock { loadProofs().any { diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index dc627ffbb5..8831d90d9c 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -628,6 +628,22 @@ class TrezorRepo @Inject constructor( } } + suspend fun broadcastRawTxAtBoundary( + serializedTx: String, + paymentDeadlineAt: Instant?, + beforeNativeBroadcast: suspend () -> Unit, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + awaitSetup() + trezorService.broadcastRawTxAtBoundary( + serializedTx, currentElectrumUrl(), paymentDeadlineAt, beforeNativeBroadcast + ) + }.onFailure { + Logger.error("Trezor broadcastRawTx failed", it, context = TAG) + _state.update { s -> s.copy(error = trezorErrorMessage(it)) } + } + } + suspend fun disconnect(): Result = withContext(ioDispatcher) { val deviceId = _state.value.connectedDeviceId() TrezorDebugLog.log("DISCONNECT", "disconnect() called, connectedDeviceId=$deviceId") diff --git a/app/src/main/java/to/bitkit/services/TrezorService.kt b/app/src/main/java/to/bitkit/services/TrezorService.kt index dc0dc0f12c..c71fc385fc 100644 --- a/app/src/main/java/to/bitkit/services/TrezorService.kt +++ b/app/src/main/java/to/bitkit/services/TrezorService.kt @@ -256,6 +256,22 @@ class TrezorService @Inject constructor( } } + suspend fun broadcastRawTxAtBoundary( + serializedTx: String, + electrumUrl: String, + paymentDeadlineAt: Instant?, + beforeNativeBroadcast: suspend () -> Unit, + ): String = ServiceQueue.CORE.background { + if (paymentDeadlineAt != null && clock.now() > paymentDeadlineAt) { + throw ServiceError.PaymentDeadlineExpired() + } + beforeNativeBroadcast() + if (paymentDeadlineAt != null && clock.now() > paymentDeadlineAt) { + throw ServiceError.PaymentDeadlineExpired() + } + onchainBroadcastRawTx(serializedTx = serializedTx, electrumUrl = electrumUrl) + } + suspend fun getTransactionDetail( extendedKey: String, electrumUrl: String, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt index 775e2b1f34..fd2ad14377 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt @@ -221,9 +221,27 @@ class HwSendViewModel @Inject constructor( } } _uiState.update { it.copy(isBroadcastUnresolved = true) } - pendingBroadcast = payment.copy(hasAttemptedBroadcast = true) return withTimeout(BROADCAST_TIMEOUT) { - hwWalletRepo.broadcastFunding(payment.signedTx, payment.request.paymentDeadlineAt) + val request = payment.request + val requestId = request.paymentRequestId + if (requestId == null) { + pendingBroadcast = payment.copy(hasAttemptedBroadcast = true) + hwWalletRepo.broadcastFunding(payment.signedTx, request.paymentDeadlineAt) + } else { + hwWalletRepo.broadcastFundingAtBoundary(payment.signedTx, request.paymentDeadlineAt) { + check( + paykitPaymentProofRepo.markHardwareOnchainDispatch( + requestId, + request.walletId, + SignedTransactionId.fromHex(payment.signedTx.serializedTx), + request.paymentIdentity, + request.address, + request.amountSats, + ) + ) { "Original hardware payment is no longer eligible for dispatch" } + pendingBroadcast = payment.copy(hasAttemptedBroadcast = true) + } + } }.getOrElse { error -> if (generateSequence(error) { it.cause }.any { it is ServiceError.PaymentDeadlineExpired }) { val request = payment.request diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index e859a9abef..359a0ce3f7 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -1685,6 +1685,13 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { signed ) ) + assertEquals(false, storedProofs.single().hardwareDispatchAttempted, + "Retaining the signed candidate is not native dispatch") + assertFalse(reopened.markHardwareOnchainDispatch(request.id, walletId, txid, COUNTERPARTY, + ONCHAIN_ADDRESS, request.amountSats)) + assertEquals(false, storedProofs.single().hardwareDispatchAttempted) + assertTrue(reopened.markHardwareOnchainDispatch(request.id, walletId, txid, LOCAL_IDENTITY, + ONCHAIN_ADDRESS, request.amountSats)) val dispatched = Json.encodeToString(reopened.backupSnapshot()) storedProofs = emptyList() reopened.restoreBackup(Json.decodeFromString>(dispatched)) @@ -1846,6 +1853,11 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(hwWalletRepo.signFunding(walletId, funding)).thenReturn(Result.success(signed)) whenever(hwWalletRepo.broadcastFunding(signed)) .thenReturn(Result.failure(BroadcastException.ElectrumException("response lost after dispatch"))) + whenever(hwWalletRepo.broadcastFundingAtBoundary(any(), org.mockito.kotlin.anyOrNull(), any())) + .doSuspendableAnswer { + it.getArgument Unit>(2)() + hwWalletRepo.broadcastFunding(it.getArgument(0), it.getArgument(1)) + } val send = HwSendViewModel(context, hwWalletRepo, mock(), mock(), mock(), repo) send.signAndBroadcast( HwSendRequest( @@ -1886,6 +1898,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(send.uiState.value.isBroadcastUnresolved) assertTrue(storedProofs.single().paymentStarted) assertEquals("605fe246a6d51450ecff51ac3d0415f8824964e06a60ed6e186fa163cf1e9d4e", storedProofs.single().paymentIdentifier) + assertEquals(true, storedProofs.single().hardwareDispatchAttempted) // Both generic failure and preparation cancellation must preserve an already dispatched Shop payment. repo.failOnchainPayment(request) diff --git a/app/src/test/java/to/bitkit/services/TrezorServiceDispatchTest.kt b/app/src/test/java/to/bitkit/services/TrezorServiceDispatchTest.kt new file mode 100644 index 0000000000..ddda975ce0 --- /dev/null +++ b/app/src/test/java/to/bitkit/services/TrezorServiceDispatchTest.kt @@ -0,0 +1,48 @@ +package to.bitkit.services + +import org.junit.Test +import org.mockito.kotlin.mock +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.ServiceError +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Instant + +class TrezorServiceDispatchTest : BaseUnitTest() { + @Test + fun `expired native queue skips the durable dispatch callback`() = test { + val deadline = Instant.parse("2026-10-08T12:00:00Z") + val service = TrezorService(mock(), mock(), object : Clock { + override fun now() = Instant.parse("2026-10-08T12:00:01Z") + }) + var markers = 0 + val result = runCatching { + service.broadcastRawTxAtBoundary("not-a-transaction", "unused", deadline) { markers++ } + } + assertEquals(0, markers) + assertTrue( + generateSequence(result.exceptionOrNull()) { it.cause }.any { it is ServiceError.PaymentDeadlineExpired } + ) + } + + @Test + fun `expiry while the durable marker is saved skips native submission`() = test { + val deadline = Instant.parse("2026-10-08T12:00:00Z") + var now = Instant.parse("2026-10-08T11:59:59Z") + val service = TrezorService(mock(), mock(), object : Clock { + override fun now() = now + }) + var markers = 0 + val result = runCatching { + service.broadcastRawTxAtBoundary("not-a-transaction", "unused", deadline) { + markers++ + now = Instant.parse("2026-10-08T12:00:01Z") + } + } + assertEquals(1, markers) + assertTrue( + generateSequence(result.exceptionOrNull()) { it.cause }.any { it is ServiceError.PaymentDeadlineExpired } + ) + } +} diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt index 989239f91b..7c26051117 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt @@ -89,6 +89,16 @@ class HwSendViewModelTest : BaseUnitTest() { any() ) }.thenReturn(false) + whenever { + proofRepo.markHardwareOnchainDispatch(any(), any(), any(), org.mockito.kotlin.anyOrNull(), any(), any()) + } + .thenReturn(true) + whenever { hwWalletRepo.broadcastFundingAtBoundary(any(), org.mockito.kotlin.anyOrNull(), any()) } + .doSuspendableAnswer { + val beforeDispatch = it.getArgument Unit>(2) + beforeDispatch() + hwWalletRepo.broadcastFunding(it.getArgument(0), it.getArgument(1)) + } whenever { hwWalletRepo.reconnectTimeout(any()) }.thenReturn(30.seconds) sut = HwSendViewModel( context = context, @@ -568,6 +578,21 @@ class HwSendViewModelTest : BaseUnitTest() { assertFalse(sut.uiState.value.hasPendingBroadcast) } + @Test + fun `changed Shop receipt at native boundary prevents hardware submission`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + whenever(proofRepo.markHardwareOnchainDispatch( + any(), any(), any(), org.mockito.kotlin.anyOrNull(), any(), any() + )) + .thenReturn(false) + sut.signAndBroadcast(request().copy(paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity")) + advanceUntilIdle() + verify(proofRepo).markHardwareOnchainDispatch(any(), any(), any(), org.mockito.kotlin.anyOrNull(), any(), any()) + verify(hwWalletRepo, never()).broadcastFunding(fixture.signedTx) + } + @Test fun `first queued Shop expiry clears exact retained candidate before releasing preparation`() = test { val fixture = stubSuccessfulPayment() From d77eb879a3e3ae54fa316092f86367136b7e8d6f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 20:58:21 +0200 Subject: [PATCH 087/108] fix: keep completed hardware payments out of pending --- .../screens/wallets/send/HwSendViewModel.kt | 7 +++ .../wallets/send/HwSendViewModelTest.kt | 51 +++++++++++++++---- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt index fd2ad14377..784cf5c0ab 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt @@ -153,8 +153,10 @@ class HwSendViewModel @Inject constructor( return@runCatching } val result = broadcast(payment, onPaymentDeadlineExpired) ?: return@runCatching + if (signingAttempt != attempt) return@runCatching runSuspendCatching { persistResult(request, result) } .onFailure { Logger.error("Failed to persist hardware send result", it, context = TAG) } + if (signingAttempt != attempt) return@runCatching pendingResult.update { HwSendResult(request.walletId, result.txId, request.amountSats, request.paymentRequestId, request.paymentIdentity) @@ -343,6 +345,11 @@ class HwSendViewModel @Inject constructor( HwSendResult(it.request.walletId, requireNotNull(txid).lowercase(), it.request.amountSats, it.request.paymentRequestId, it.request.paymentIdentity) } ?: return false + // Exact observation owns completion; a late native response belongs to the old attempt. + signingAttempt++ + signingJob?.cancel() + signingJob = null + _uiState.update { it.copy(isSigning = false, isConnectingDevice = false) } completeBroadcast() onCompleted(result) return true diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt index 7c26051117..71470d352d 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt @@ -3,13 +3,23 @@ package to.bitkit.ui.screens.wallets.send import android.content.Context import com.synonym.bitkitcore.BroadcastException import com.synonym.bitkitcore.TrezorException +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import org.junit.Before import org.junit.Test @@ -30,22 +40,15 @@ import to.bitkit.models.Toast import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.HwWalletMismatchError import to.bitkit.repositories.HwWalletRepo -import to.bitkit.repositories.PreActivityMetadataRepo -import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PaykitPaymentProofRepo +import to.bitkit.repositories.PaykitPaymentRequestId +import to.bitkit.repositories.PreActivityMetadataRepo import to.bitkit.services.ActivityService import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.AppError import to.bitkit.utils.ServiceError -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue -import kotlin.time.Clock -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds -import kotlin.time.Instant @OptIn(ExperimentalCoroutinesApi::class) class HwSendViewModelTest : BaseUnitTest() { @@ -983,6 +986,36 @@ class HwSendViewModelTest : BaseUnitTest() { verify(hwWalletRepo, times(1)).broadcastFunding(fixture.signedTx) } + @Test + fun `observed Shop completion ignores a late suspended broadcast result`() = test { + val fixture = stubSuccessfulPayment() + val resume = CompletableDeferred() + val txid = "605fe246a6d51450ecff51ac3d0415f8824964e06a60ed6e186fa163cf1e9d4e" + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)).doSuspendableAnswer { + withContext(NonCancellable) { resume.await() } + Result.success(fixture.broadcast.copy(txId = txid)) + } + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("original", "counterparty"), + paymentIdentity = "original-identity", + ) + var emitted = 0 + backgroundScope.launch { sut.results.collect { emitted++ } } + sut.signAndBroadcast(original) + runCurrent() + assertTrue(sut.uiState.value.isBroadcastUnresolved) + var completed: HwSendResult? = null + assertTrue(sut.completeReconciledBroadcast(WALLET_ID, txid) { completed = it }) + assertEquals(original.paymentRequestId, completed?.paymentRequestId) + resume.complete(Unit) + advanceUntilIdle() + assertEquals(0, emitted) + assertFalse(sut.uiState.value.hasPendingBroadcast) + assertFalse(sut.uiState.value.isBroadcastUnresolved) + assertFalse(sut.uiState.value.isSigning) + assertFalse(sut.completeReconciledBroadcast(WALLET_ID, txid)) + } + @Test fun `exact asynchronous completion clears a timed out Shop broadcast without a result`() = test { whenever(context.getString(any())).thenReturn("message") From 77e6e75177e2264ce880b73fcef38b31869a54b8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 21:02:35 +0200 Subject: [PATCH 088/108] fix: reopen the original blocked transfer --- .../to/bitkit/viewmodels/TransferViewModel.kt | 8 +-- .../viewmodels/TransferViewModelTest.kt | 49 +++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 1d91ed88cd..fa340a8d12 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -453,7 +453,7 @@ class TransferViewModel @Inject constructor( .fold( onSuccess = { outcome -> if (outcome !is OnchainSendOutcome.Accepted) { - showRetainedFundingPending(order) + showRetainedFundingPending() return@fold false } // Survive ViewModel clearance between accepted broadcast and paid-order cache write. @@ -472,15 +472,15 @@ class TransferViewModel @Inject constructor( true }, onFailure = { - if (!showRetainedFundingPending(order)) ToastEventBus.send(it) + if (!showRetainedFundingPending()) ToastEventBus.send(it) false } ) } - private suspend fun showRetainedFundingPending(order: IBtOrder): Boolean { + private suspend fun showRetainedFundingPending(): Boolean { val attempt = lightningRepo.currentOnchainSendAttempt() ?: return false - if (attempt.orderId != order.id || !attempt.isTransfer || !attempt.blocksNextSend) return false + if (!attempt.isTransfer || !attempt.blocksNextSend) return false transferEffects.emit(TransferEffect.OnFundingPending(attempt)) return true } diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 2ed415bec5..2a661ffe5f 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -1575,6 +1575,55 @@ class TransferViewModelTest : BaseUnitTest() { ) } + @Test + fun `blocked transfer opens the retained previous order`() = test { + val order = spendingOrder(feeSat = 98_000uL) + stubSpendableBalances(spendable = 100_000u) + whenever(lightningRepo.estimateSendAllFee(any(), any(), anyOrNull())) + .thenReturn(Result.success(1_000uL)) + whenever { lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) } + .thenReturn(Result.success(listOf(stubUtxo(100_000u)))) + whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(1_000uL)) + whenever( + lightningRepo.sendOnChain( + address = any(), + sats = any(), + speed = any(), + utxosToSpend = anyOrNull(), + feeRates = anyOrNull(), + isTransfer = any(), + channelId = anyOrNull(), + isMaxAmount = any(), + tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), + payerIdentity = anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ), + ).thenReturn(Result.success(OnchainSendOutcome.Unknown(TXID))) + quoteOrder(order) + + prepareConfirm() + val retained = acceptedFundingAttempt(order, null).copy(orderId = "previous-order", evidence = OnchainSendEvidence.Unknown) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(null, retained) + val effects = mutableListOf() + val collector = launch { sut.transferEffects.collect { effects += it } } + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + assertEquals(listOf(TransferEffect.OnFundingPending(retained)), effects) + collector.cancel() + + verify(cacheStore, never()).addPaidOrder(any(), any()) + verify(transferRepo, never()).createTransfer( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + ) + } + @Test fun `confirmation blocks replacing or clearing the transfer while creating its order`() = test { val order = spendingOrder(feeSat = 98_000uL) From c866762a54ff19b768e0e1fa788c2ce2defe848f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 21:09:01 +0200 Subject: [PATCH 089/108] fix: preserve newer contacts during shop completion --- .../java/to/bitkit/ui/sheets/SendSheet.kt | 9 ++++---- .../java/to/bitkit/viewmodels/AppViewModel.kt | 3 ++- .../viewmodels/AppViewModelSendFlowTest.kt | 21 +++++++++++++++++++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index a7ff753075..2ab32fbbd0 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -156,7 +156,7 @@ fun SendSheet( .testTag("SendSheet"), ) { val navController = rememberNavController() - fun showHardwareSuccess(txId: String, amountSats: ULong, walletId: String) { + fun showHardwareSuccess(txId: String, amountSats: ULong, walletId: String, isShopPayment: Boolean) { appViewModel.onSendSuccess( details = NewTransactionSheetDetails( type = NewTransactionSheetType.ONCHAIN, @@ -167,6 +167,7 @@ fun SendSheet( ), walletId = walletId, navigate = false, + syncContact = !isShopPayment, ) appViewModel.clearClipboardForAutoRead() navController.navigateTo(SendRoute.Success) { @@ -178,7 +179,7 @@ fun SendSheet( resolutions.values.forEach { resolution -> if (appViewModel.resolvedHardwarePaymentFor(resolution.walletId, resolution.transactionId) == resolution && hwSendViewModel.completeReconciledBroadcast(resolution.walletId, resolution.transactionId) { - showHardwareSuccess(it.txId, it.amountSats, it.walletId) + showHardwareSuccess(it.txId, it.amountSats, it.walletId, it.paymentRequestId != null) } ) { appViewModel.consumeResolvedHardwarePayment(resolution) @@ -194,7 +195,7 @@ fun SendSheet( ) ) { appViewModel.consumeResolvedHardwarePayment(resolved) - showHardwareSuccess(result.txId, result.amountSats, result.walletId) + showHardwareSuccess(result.txId, result.amountSats, result.walletId, result.paymentRequestId != null) return@collect } val proofComplete = appViewModel.completeHardwareContactPayment( @@ -214,7 +215,7 @@ fun SendSheet( // and retain the signed operation so another request cannot sign or broadcast. return@collect } - showHardwareSuccess(result.txId, result.amountSats, result.walletId) + showHardwareSuccess(result.txId, result.amountSats, result.walletId, result.paymentRequestId != null) hwSendViewModel.completeBroadcast() } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index f841caf71d..cfed8d6a9b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -6089,13 +6089,14 @@ class AppViewModel @Inject constructor( allowDuplicateHash: Boolean = false, walletId: String = WalletScope.default, navigate: Boolean = true, + syncContact: Boolean = true, ) { details.paymentHashOrTxId?.let { val isNewPayment = synchronized(processedPaymentsLock) { processedPayments.add(it) } when { - isNewPayment -> syncContactForActivity(it, walletId) + isNewPayment -> if (syncContact) syncContactForActivity(it, walletId) !allowDuplicateHash -> { Logger.debug("Skipped duplicate processed payment '$it'", context = TAG) return diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index d525ce592d..6da4b35594 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9692,6 +9692,27 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } + @Test + fun `late hardware Shop success preserves a newer active contact`() = test { + val txid = "ef".repeat(32) + setActiveContactPaymentContext(testPublicKey) + sut.onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.ONCHAIN, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = txid, + sats = 1_000, + ), + walletId = "hardware-original", + navigate = false, + syncContact = false, + ) + advanceUntilIdle() + assertEquals(testPublicKey, activeContactPaymentContext()?.publicKey) + verify(activityRepo, never()).setContact(any(), eq(txid), any(), any()) + assertEquals(txid, sut.successSendUiState.value.paymentHashOrTxId) + } + @Test fun `blocked new contact cannot be assigned to recovered older payment`() = test { val txid = "ef".repeat(32) From d0e54c7a830c800fb7e31eb82ad01b82baaf72b6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 21:13:02 +0200 Subject: [PATCH 090/108] fix: preserve original refusal before retry submission --- .../bitkit/repositories/OnchainSendAttemptStore.kt | 11 +++++++---- .../repositories/OnchainSendCoordinatorTest.kt | 12 +++++++++--- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index b4e8fceaa3..9d6326b7a5 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -294,9 +294,9 @@ class OnchainSendAttemptStore @Inject constructor( originalInputs = current.originalInputs ?: receipt.inputs, candidateTxids = (current.candidateTxids + receipt.txid.lowercase()).distinct(), candidateFeeRates = current.candidateFeeRates + (receipt.txid.lowercase() to feeRate), - txid = receipt.txid.lowercase(), - evidence = OnchainSendEvidence.Pending, - refusalReason = null, + txid = if (isRecovery) current.txid else receipt.txid.lowercase(), + evidence = if (isRecovery) current.evidence else OnchainSendEvidence.Pending, + refusalReason = if (isRecovery) current.refusalReason else null, preparationPending = false, ).also { persist(it) @@ -318,8 +318,11 @@ class OnchainSendAttemptStore @Inject constructor( throw OnchainSendBlockedError(current) } if (current.hasPositiveEvidence) return@withLock OnchainSendOutcome.Accepted(requireNotNull(current.txid)) - require(current.txid.equals(txid, ignoreCase = true) && txid.lowercase() in current.candidateTxids) + require(txid.lowercase() in current.candidateTxids) val firstSubmission = firstSubmissions.remove(attemptId) + if (!firstSubmission) { + persist(current.copy(txid = txid.lowercase(), evidence = OnchainSendEvidence.Pending, refusalReason = null)) + } try { broadcast() } catch (error: ServiceError.PaymentDeadlineExpired) { diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt index d070bf2641..1523adcb82 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt @@ -62,7 +62,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { ) fun receipt(txid: String = firstTxid, amount: ULong = 1_000uL) = - OnchainPreparedReceipt(txid, listOf(input), "bcrt1qrecipient", amount) + OnchainPreparedReceipt(txid, listOf(input), "bcrt1qrecipient", amount, miningFeeSats = 50uL) suspend fun unresolved(): OnchainSendAttempt { val attempt = admit() @@ -343,7 +343,10 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { @Test fun `original authorization failure retains prepared candidate without broadcasting`() = test { val f = Fixture() - val attempt = f.unresolved() + val unknown = f.unresolved() + val attempt = f.store.recordOutcome( + unknown.attemptId, OnchainSendOutcome.Rejected(firstTxid, "mempool min fee not met"), 0, + ) var prepares = 0 var broadcasts = 0 val sender = object : OnchainPreparedSender { @@ -373,6 +376,9 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { assertEquals(1, prepares) assertEquals(0, broadcasts) assertEquals(listOf(firstTxid, nextTxid), f.store.current()?.candidateTxids) + assertEquals(firstTxid, f.store.current()?.txid) + assertEquals(OnchainSendEvidence.Rejected, f.store.current()?.evidence) + assertEquals("mempool min fee not met", f.store.current()?.refusalReason) } @Test @@ -420,7 +426,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { assertEquals(original.requestId, retained?.requestId) assertEquals(original.orderId, retained?.orderId) assertEquals(original.originalInputs, retained?.originalInputs) - assertEquals(OnchainSendEvidence.Pending, retained?.evidence) + assertEquals(original.evidence, retained?.evidence) val restarted = OnchainSendAttemptStore(testDispatcher, f.keychain, f.service, kotlin.time.Clock.System) assertEquals(retained, restarted.current()) assertFailsWith { f.admit() } From 2653e81a25387c9c72b5daf3028f16248316c1c2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 21:20:40 +0200 Subject: [PATCH 091/108] fix: replay payment completion when its payer returns --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 5 +++-- .../viewmodels/AppViewModelSendFlowTest.kt | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index cfed8d6a9b..9abfd9c27e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -785,7 +785,6 @@ class AppViewModel @Inject constructor( val identityChanged = !PubkyPublicKeyFormat.matches(paymentRequestIdentity, state.publicKey) if (identityChanged) { - paykitPaymentProofRepo.clearOnchainPaymentResolutions() resetPaykitPresentationState( dismissActiveRequest = paymentRequestIdentity != null, preserveRequestedPaymentRequest = paymentRequestIdentity == null, @@ -880,7 +879,9 @@ class AppViewModel @Inject constructor( private fun observePaykitOnchainPaymentResolution() { viewModelScope.launch { - paykitPaymentProofRepo.onchainPaymentResolutions.collect { resolutions -> + combine(paykitPaymentProofRepo.onchainPaymentResolutions, pubkyRepo.publicKey) { resolutions, _ -> + resolutions + }.collect { resolutions -> resolutions.forEach(::handlePaykitOnchainPaymentResolution) } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 6da4b35594..86ddac7d79 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -8463,6 +8463,23 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) } + @Test + fun `retained resolution replays when its original payer becomes active`() = test { + val request = paymentRequest() + val resolution = PaykitOnchainPaymentProofResolution( + testPublicKey, request.id, "ab".repeat(32), "hardware-original", request.amountSats, + ) + onchainPaymentResolutions.value = listOf(resolution) + runCurrent() + assertNull(sut.resolvedHardwarePaymentFor(resolution.walletId, resolution.transactionId)) + verify(paykitPaymentProofRepo, never()).consumeOnchainPaymentResolution(resolution) + pubkyPublicKey.value = testPublicKey + runCurrent() + assertEquals(resolution, sut.resolvedHardwarePaymentFor(resolution.walletId, resolution.transactionId)) + verify(paykitPaymentProofRepo).consumeOnchainPaymentResolution(resolution) + verify(paykitPaymentProofRepo, never()).clearOnchainPaymentResolutions() + } + @Test fun `closed hardware sheet retains earlier completion when multiple proofs resolve`() = test { pubkyPublicKey.value = testPublicKey From 09eef0d76bf76c90e49439302048c8f8f5602421 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 21:22:46 +0200 Subject: [PATCH 092/108] fix: rebuild original hardware activity before proof delivery --- .../repositories/PaykitPaymentProofRepo.kt | 17 ++++++------ .../PaykitPaymentProofRepoTest.kt | 27 +++++++++++++++++++ 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index b09782b347..1fa5c328df 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -957,8 +957,8 @@ class PaykitPaymentProofRepo @Inject constructor( if (!proof.paymentStarted || !proof.proofData.isHex(HASH_BYTE_COUNT) || !proof.paymentIdentifier.equals(proof.proofData, ignoreCase = true) ) return false + if (proof.onchainWalletId != WalletScope.default) return reconcileHardwareOnchainProof(proof) if (proof.onchainAcceptanceVerified != true) { - if (proof.onchainWalletId != WalletScope.default) return reconcileHardwareOnchainProof(proof) if (!attempt.matchesPositiveShopProof(proof)) return false val proofs = loadProofs().toMutableList() val index = proofs.indexOf(proof) @@ -1039,14 +1039,13 @@ class PaykitPaymentProofRepo @Inject constructor( if (!proof.paymentStarted || proof.onchainWalletId == WalletScope.default || (proof.proofData != null && !proof.proofData.equals(txid, true)) ) return false - if (!proof.onchainAcceptanceVerified) { - val address = proof.onchainAddress?.takeIf { it.isNotBlank() } ?: return false - val amount = proof.onchainAmountSats ?: return false - val observed = withTimeoutOrNull(HARDWARE_OBSERVATION_TIMEOUT) { - hwWalletRepo.observeExactTransaction(proof.onchainWalletId, txid, address, amount).getOrDefault(false) - } == true - if (!observed) return false - } + // A restored accepted proof can be newer than the independently uploaded activity backup. + val address = proof.onchainAddress?.takeIf { it.isNotBlank() } ?: return false + val amount = proof.onchainAmountSats ?: return false + val observed = withTimeoutOrNull(HARDWARE_OBSERVATION_TIMEOUT) { + hwWalletRepo.observeExactTransaction(proof.onchainWalletId, txid, address, amount).getOrDefault(false) + } == true + if (!observed) return false val proofs = loadProofs().toMutableList() val index = proofs.indexOf(proof) if (index < 0) return false diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 359a0ce3f7..00c3a60cb6 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -1767,6 +1767,33 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) } + @Test + fun `restored verified hardware proof waits for original activity reconstruction`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val txid = "ab".repeat(32) + val walletId = "original-hardware-wallet" + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId).getOrThrow() + storedProofs = listOf(storedProofs.single().copy( + paymentIdentifier = txid, proofData = txid, onchainAcceptanceVerified = true, + )) + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(paymentRequestRecord())) + whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)) + .thenReturn(Result.success(false)) + paymentProofRepo().reconcile() + assertEquals(txid, storedProofs.single().proofData) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + whenever(hwWalletRepo.observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats)) + .thenReturn(Result.success(true)) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any(), isNull())) + .thenReturn(paymentRequestRecord()) + paymentProofRepo().reconcile() + assertTrue(storedProofs.isEmpty()) + verify(hwWalletRepo, times(2)).observeExactTransaction(walletId, txid, ONCHAIN_ADDRESS, request.amountSats) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + } + @Test fun `hardware fresh observation submits completed durable proof with original wallet`() = test { val request = paymentRequest(MethodId.P2wpkh.rawValue) From 116f6b56805741cc727b371003f317cf58a370cf Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 21:29:00 +0200 Subject: [PATCH 093/108] fix: reject invalid payment recipients before wallet restore --- .../java/to/bitkit/repositories/BackupRepo.kt | 8 ++++ .../to/bitkit/repositories/BackupRepoTest.kt | 38 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index a29de1a52f..4512fdda2a 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -61,10 +61,13 @@ import to.bitkit.models.Toast import to.bitkit.models.WalletBackupV1 import to.bitkit.models.WalletScope import to.bitkit.models.WidgetsBackupV1 +import to.bitkit.models.toLdkNetwork +import to.bitkit.services.CoreService import to.bitkit.services.LightningService import to.bitkit.services.PaykitSdkService import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger +import to.bitkit.utils.NetworkValidationHelper import to.bitkit.utils.jsonLogOf import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject @@ -105,6 +108,7 @@ class BackupRepo @Inject constructor( private val hwWalletStore: HwWalletStore, private val blocktankRepo: BlocktankRepo, private val activityRepo: ActivityRepo, + private val coreService: CoreService, private val pubkyRepo: PubkyRepo, private val paykitSdkService: PaykitSdkService, private val privatePaykitRepo: Provider, @@ -843,6 +847,10 @@ class BackupRepo @Inject constructor( WalletScope.default, walletIndex, ) + val recipient = coreService.validateBitcoinAddress(attempt.address) + require(!NetworkValidationHelper.isNetworkMismatch(recipient.network.toLdkNetwork(), Env.network)) { + "Restored payment recipient is on another network" + } check(backupWalletIndex() == walletIndex) { "Backup wallet changed during restore" } onchainSendAttemptStore.restoreActive(attempt) restoredAttempt = attempt diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 19b95195ae..685da84e68 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -89,6 +89,7 @@ class BackupRepoTest : BaseUnitTest() { private val hwWalletStore = mock() private val blocktankRepo = mock() private val activityRepo = mock() + private val coreService = mock() private val lightningRepo = mock() private val pubkyRepo = mock() private val paykitSdkService = mock() @@ -118,6 +119,12 @@ class BackupRepoTest : BaseUnitTest() { @Before fun setUp() = test { + whenever(coreService.validateBitcoinAddress(any())).thenAnswer { + com.synonym.bitkitcore.ValidationResult( + address = it.getArgument(0), network = com.synonym.bitkitcore.NetworkType.REGTEST, + addressType = com.synonym.bitkitcore.AddressType.P2WPKH, + ) + } whenever(paykitSdkService.isPaymentSubmissionActive).thenReturn(paymentSubmissionActive) whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(1_000)) whenever(db.transferDao()).thenReturn(transferDao) @@ -633,6 +640,36 @@ class BackupRepoTest : BaseUnitTest() { verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) } + @Test + fun `invalid restored recipient cannot install a blocking wallet guard`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + whenever(coreService.validateBitcoinAddress(wire.address)).thenThrow(IllegalArgumentException("invalid address")) + stubWalletBackup(paykitPaymentState = state) + assertTrue(sut.performFullRestoreFromLatestBackup().isFailure) + verify(onchainSendAttemptStore, never()).restoreActive(any()) + verify(paykitPaymentProofRepo, never()).restoreBackup(any()) + } + + @Test + fun `wrong network restored recipient cannot install a blocking wallet guard`() = test { + val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() + val state = requireNotNull(json.decodeFromString(bytes.decodeToString()).paykitPaymentState) + val wire = requireNotNull(state.activeOnchainAttempt) + whenever(vssStoreIdProvider.getBackupWalletBinding(0)).thenReturn(wire.wallet.binding) + whenever(coreService.validateBitcoinAddress(wire.address)).thenReturn( + com.synonym.bitkitcore.ValidationResult( + wire.address, com.synonym.bitkitcore.NetworkType.BITCOIN, com.synonym.bitkitcore.AddressType.P2WPKH, + ) + ) + stubWalletBackup(paykitPaymentState = state) + assertTrue(sut.performFullRestoreFromLatestBackup().isFailure) + verify(onchainSendAttemptStore, never()).restoreActive(any()) + verify(paykitPaymentProofRepo, never()).restoreBackup(any()) + } + @Test fun `missing active followup blocks restore before installing a wallet guard`() = test { val bytes = requireNotNull(javaClass.getResourceAsStream("/candidate-fee-rates-golden.json")).readBytes() @@ -1679,6 +1716,7 @@ class BackupRepoTest : BaseUnitTest() { hwWalletStore = hwWalletStore, blocktankRepo = blocktankRepo, activityRepo = activityRepo, + coreService = coreService, pubkyRepo = pubkyRepo, paykitSdkService = paykitSdkService, privatePaykitRepo = Provider { privatePaykitRepo }, From 6ba9d1184ef73f8b6aa6b88aa768ca73ea4844cf Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 22:36:50 +0200 Subject: [PATCH 094/108] fix: keep selected tags on hardware shop payments --- .../to/bitkit/repositories/ActivityRepo.kt | 2 + .../java/to/bitkit/services/CoreService.kt | 18 +++++ .../bitkit/repositories/ActivityRepoTest.kt | 2 + .../to/bitkit/services/ActivityServiceTest.kt | 67 +++++++++++++++++++ 4 files changed, 89 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 7a4ffd8b26..479b414c2b 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -228,7 +228,9 @@ class ActivityRepo @Inject constructor( val activity = getOnchainActivityByTxId(txid, walletId) check(activity?.walletId == walletId && activity.txId.equals(txid, true) && activity.txType == PaymentType.SENT) { "Original hardware payment activity is not durable" } + val tagsChanged = coreService.activity.restoreSentOnchainTags(txid, walletId) notifyPaymentActivityChanged() + if (tagsChanged) notifyActivitiesChanged(tagsChanged = true) } } diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 06af5ac484..5b3288c043 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -1544,6 +1544,24 @@ class ActivityService( } } + /** Apply saved original hardware tags once after exact outgoing activity verification. */ + suspend fun restoreSentOnchainTags(txid: String, walletId: String): Boolean = + ServiceQueue.CORE.background { + val activity = requireNotNull(getActivityByTxId(walletId = walletId, txId = txid)) + check(activity.walletId == walletId && activity.txId.equals(txid, true) && + activity.txType == PaymentType.SENT) + val metadata = com.synonym.bitkitcore.getAllPreActivityMetadata().filter { + it.walletId == walletId && !it.isReceive && it.paymentId.equals(txid, true) && + it.txId?.equals(txid, true) == true && it.tags.isNotEmpty() + } + if (metadata.isEmpty()) return@background false + val existingTags = getTags(walletId = walletId, activityId = activity.id) + val newTags = metadata.flatMap { it.tags }.distinct().filter { it.isNotBlank() && it !in existingTags } + if (newTags.isNotEmpty()) addTags(walletId = walletId, activityId = activity.id, tags = newTags) + com.synonym.bitkitcore.upsertPreActivityMetadata(metadata.map { it.copy(tags = emptyList()) }) + newTags.isNotEmpty() + } + /** Restore original attribution only when no contact has been saved for the accepted transaction. */ suspend fun restoreSentOnchainContact(txid: String, walletId: String, contact: String) = ServiceQueue.CORE.background { diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index 0a78b64b46..df2f8edab2 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -163,6 +163,7 @@ class ActivityRepoTest : BaseUnitTest() { val txid = "ab".repeat(32) val wallet = "original-hardware-wallet" val original = baseOnchainActivity.copy(walletId = wallet, txId = txid) + whenever(coreService.activity.restoreSentOnchainTags(txid, wallet)).thenReturn(false) whenever(coreService.activity.getOnchainActivityByTxId(txid, wallet)) .thenReturn(null) .thenReturn(original.copy(walletId = "different-wallet")) @@ -178,6 +179,7 @@ class ActivityRepoTest : BaseUnitTest() { sut.completeObservedHardwarePayment(wallet, txid, original.address, original.value, original.fee, original.feeRate).getOrThrow() assertTrue(sut.activitiesChanged.value > before) + verify(coreService.activity).restoreSentOnchainTags(txid, wallet) verify(coreService.activity, times(3)).createSentOnchainActivityFromSendResult( txid, original.address, original.value, original.fee, original.feeRate, false, null, wallet, ) diff --git a/app/src/test/java/to/bitkit/services/ActivityServiceTest.kt b/app/src/test/java/to/bitkit/services/ActivityServiceTest.kt index 2689f722be..abe467b5e4 100644 --- a/app/src/test/java/to/bitkit/services/ActivityServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/ActivityServiceTest.kt @@ -2,6 +2,7 @@ package to.bitkit.services import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.OnchainActivity +import com.synonym.bitkitcore.PreActivityMetadata import com.synonym.bitkitcore.PaymentType import org.junit.Test import org.mockito.ArgumentMatchers.any @@ -116,6 +117,72 @@ class ActivityServiceTest : BaseUnitTest() { } } + @Test + fun `observed hardware tags use original wallet once and preserve later edits`() = test { + ServiceQueue.CORE.background { + val getByTx = binding.getMethod("getActivityByTxId", String::class.java, String::class.java) + val getMetadata = binding.getMethod("getAllPreActivityMetadata") + val getTags = binding.getMethod("getTags", String::class.java, String::class.java) + val addTags = binding.getMethod("addTags", String::class.java, String::class.java, List::class.java) + val upsertMetadata = binding.getMethod("upsertPreActivityMetadata", List::class.java) + val original = tagMetadata(WALLET_ID) + val foreign = tagMetadata("another-wallet").copy(tags = listOf("foreign")) + var metadata = listOf(original, foreign) + var tags = listOf("existing") + mockStatic(binding).use { native -> + native.`when` { getByTx.invoke(null, WALLET_ID, ACTIVITY_ID) } + .thenReturn(activity().v1.copy(txType = PaymentType.SENT)) + native.`when` { getMetadata.invoke(null) }.thenAnswer { metadata } + native.`when` { getTags.invoke(null, WALLET_ID, ACTIVITY_ID) }.thenAnswer { tags } + native.`when` { + addTags.invoke(null, WALLET_ID, ACTIVITY_ID, listOf("original")) + }.thenAnswer { tags = tags + "original"; Unit } + native.`when` { upsertMetadata.invoke(null, any(List::class.java)) }.thenAnswer { + val updated = it.getArgument>(0) + metadata = metadata.map { row -> + updated.firstOrNull { it.walletId == row.walletId && it.paymentId == row.paymentId } ?: row + } + Unit + } + + kotlin.test.assertTrue(sut.restoreSentOnchainTags(ACTIVITY_ID, WALLET_ID)) + kotlin.test.assertEquals(listOf("existing", "original"), tags) + kotlin.test.assertEquals(original.copy(tags = emptyList()), metadata.first()) + kotlin.test.assertEquals(foreign, metadata.last()) + tags = listOf("later-edit") + kotlin.test.assertFalse(sut.restoreSentOnchainTags(ACTIVITY_ID, WALLET_ID)) + kotlin.test.assertEquals(listOf("later-edit"), tags) + } + } + } + + @Test + fun `failed hardware tag write retains saved tags for recovery`() = test { + ServiceQueue.CORE.background { + val getByTx = binding.getMethod("getActivityByTxId", String::class.java, String::class.java) + val getMetadata = binding.getMethod("getAllPreActivityMetadata") + val getTags = binding.getMethod("getTags", String::class.java, String::class.java) + val addTags = binding.getMethod("addTags", String::class.java, String::class.java, List::class.java) + val upsertMetadata = binding.getMethod("upsertPreActivityMetadata", List::class.java) + mockStatic(binding).use { native -> + native.`when` { getByTx.invoke(null, WALLET_ID, ACTIVITY_ID) } + .thenReturn(activity().v1.copy(txType = PaymentType.SENT)) + native.`when` { getMetadata.invoke(null) }.thenReturn(listOf(tagMetadata(WALLET_ID))) + native.`when` { getTags.invoke(null, WALLET_ID, ACTIVITY_ID) }.thenReturn(emptyList()) + native.`when` { addTags.invoke(null, WALLET_ID, ACTIVITY_ID, listOf("original")) } + .thenThrow(IllegalStateException("tag write failed")) + kotlin.test.assertFails { sut.restoreSentOnchainTags(ACTIVITY_ID, WALLET_ID) } + native.verify({ upsertMetadata.invoke(null, any(List::class.java)) }, never()) + } + } + } + + private fun tagMetadata(walletId: String) = PreActivityMetadata( + walletId = walletId, paymentId = ACTIVITY_ID, tags = listOf("original"), + paymentHash = null, txId = ACTIVITY_ID, address = "address", isReceive = false, + feeRate = 0uL, isTransfer = false, channelId = null, createdAt = 1uL, + ) + private fun activity() = Activity.Onchain( OnchainActivity.create( walletId = WALLET_ID, From 5082ab27d043e53768785280ab2225ef4742ce50 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 22:48:10 +0200 Subject: [PATCH 095/108] fix: approve exact recovery fee before authentication --- .../to/bitkit/repositories/LightningRepo.kt | 5 +- .../repositories/OnchainSendCoordinator.kt | 6 +- .../screens/wallets/send/SendFeeViewModel.kt | 3 +- .../screens/wallets/send/SendPendingScreen.kt | 75 ++++++++++++------- .../wallets/send/SendPendingViewModel.kt | 67 +++++++++++++++++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 12 +++ .../OnchainSendCoordinatorTest.kt | 30 +++++++- .../wallets/send/SendPendingViewModelTest.kt | 31 ++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 4 +- .../send/onchain-original-payment-retry.xml | 10 +-- 10 files changed, 205 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 3714436f65..8332463b3f 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1717,9 +1717,12 @@ class LightningRepo @Inject constructor( walletId: String, feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant? = null, + approvePrepared: suspend (OnchainPreparedReceipt) -> Unit = {}, authorizeOriginal: suspend (OnchainSendAttempt) -> Unit, ): Result = executeWhenNodeRunning("retryOriginalOnchainSend") { - val result = recoveryCoordinator.retryOriginal(attemptId, walletId, feeRateSatsPerVByte, paymentDeadlineAt, authorizeOriginal) + val result = recoveryCoordinator.retryOriginal( + attemptId, walletId, feeRateSatsPerVByte, paymentDeadlineAt, approvePrepared, authorizeOriginal, + ) val accepted = result.getOrNull() as? OnchainSendOutcome.Accepted if (accepted != null) { runSuspendCatching { diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt index 502fdc57f0..1a6f0d820c 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt @@ -11,8 +11,8 @@ import to.bitkit.ext.runSuspendCatching /** Rust converts sat/vB to sat/kwu by multiplying by 250; validate before the unchecked FFI factory. */ object OnchainRecoveryFeeRate { - // Native sat/vB conversion multiplies by 250; shared backup/iOS fees also require UInt32. - val maximum: ULong = minOf(ULong.MAX_VALUE / 250uL, UInt.MAX_VALUE.toULong()) + // Match the normal custom-fee field and iOS ceiling. + val maximum: ULong = 999uL fun isValid(rate: ULong): Boolean = rate > 0uL && rate <= maximum @@ -132,6 +132,7 @@ class OnchainSendCoordinator( walletId: String, feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant? = null, + approvePrepared: suspend (OnchainPreparedReceipt) -> Unit = {}, authorizeOriginal: suspend (OnchainSendAttempt) -> Unit, ): Result = withContext(ioDispatcher) { val result = runSuspendCatching { @@ -150,6 +151,7 @@ class OnchainSendCoordinator( } require(OnchainRecoveryFeeRate.isValid(feeRateSatsPerVByte)) val prepared = sender.prepareRecovery(attempt, feeRateSatsPerVByte, paymentDeadlineAt) + approvePrepared(prepared.receipt.copy(feeRateSatsPerVByte = feeRateSatsPerVByte)) val retained = store.retainPreparedReceipt( attempt.attemptId, attempt.walletIndex, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModel.kt index 0a42cfab7b..ad7178e3b7 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModel.kt @@ -23,6 +23,7 @@ import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.OnchainRecoveryFeeRate import to.bitkit.repositories.WalletRepo import to.bitkit.ui.components.KEY_DELETE import to.bitkit.ui.shared.toast.ToastEventBus @@ -30,7 +31,7 @@ import to.bitkit.viewmodels.SendUiState import javax.inject.Inject private const val MAX_DIGITS = 3 -private const val MAX_VALUE = 999u +private val MAX_VALUE = OnchainRecoveryFeeRate.maximum.toUInt() private const val MAX_RATIO = 0.5 @HiltViewModel diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt index 45428c4398..afe13bbb41 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt @@ -41,9 +41,11 @@ import kotlinx.coroutines.flow.filterNotNull import to.bitkit.R import to.bitkit.models.WalletScope import to.bitkit.repositories.OnchainRecoveryFeeRate +import to.bitkit.repositories.OnchainPreparedReceipt import to.bitkit.repositories.OnchainSendAttempt import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.PendingPaymentResolution +import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.BiometricsView import to.bitkit.ui.components.BodyM @@ -59,6 +61,7 @@ import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.rememberBiometricAuthSupported +import to.bitkit.viewmodels.SanityWarning const val RECOVERY_PIN_CHECK_RESULT_KEY = "RECOVERY_PIN_CHECK_RESULT_KEY" @@ -75,7 +78,7 @@ fun SendPendingScreen( viewModel: SendPendingViewModel, walletId: String = WalletScope.default, refusalReason: String? = null, - retryOriginal: suspend (OnchainSendAttempt, ULong) -> Result, + retryOriginal: suspend (OnchainSendAttempt, ULong, suspend (OnchainPreparedReceipt, List) -> Unit) -> Result, onRecovered: (String, Long) -> Unit, savedStateHandle: SavedStateHandle, onNavigateToPin: () -> Unit, @@ -115,7 +118,8 @@ fun SendPendingScreen( uiState, savedStateHandle, onNavigateToPin, - { viewModel.retryOriginal(it, retryOriginal) }, + viewModel, + { rate, approve -> viewModel.retryOriginal(rate) { attempt, fee -> retryOriginal(attempt, fee, approve) } }, ) { onRetry -> SendPendingContent( amount = if (observeResolution || isOnchain) uiState.amount else amount, @@ -149,56 +153,75 @@ private fun RecoveryAuthorization( uiState: SendPendingUiState, savedStateHandle: SavedStateHandle, onNavigateToPin: () -> Unit, - retryOriginal: (ULong) -> Unit, + viewModel: SendPendingViewModel, + retryOriginal: (ULong, suspend (OnchainPreparedReceipt, List) -> Unit) -> Unit, content: @Composable (() -> Unit) -> Unit, ) { var showFeeApproval by remember { mutableStateOf(false) } var feeInput by rememberSaveable { mutableStateOf("") } - var approvedRate by rememberSaveable { mutableStateOf(null) } - var showBiometrics by remember { mutableStateOf(false) } val settings = settingsViewModel ?: return val isPinEnabled by settings.isPinEnabled.collectAsStateWithLifecycle() val pinForPayments by settings.isPinForPaymentsEnabled.collectAsStateWithLifecycle() val isBiometricEnabled by settings.isBiometricEnabled.collectAsStateWithLifecycle() val isBiometrySupported = rememberBiometricAuthSupported() - fun submitAuthorizedRetry() { - val rate = approvedRate?.let(OnchainRecoveryFeeRate::parse) ?: return - approvedRate = null - retryOriginal(rate) - } LaunchedEffect(savedStateHandle) { savedStateHandle.getStateFlow(RECOVERY_PIN_CHECK_RESULT_KEY, null) .filterNotNull().collect { successful -> savedStateHandle.remove(RECOVERY_PIN_CHECK_RESULT_KEY) - if (successful) submitAuthorizedRetry() else approvedRate = null + viewModel.answerRecoveryPin(successful) } } - if (showBiometrics) { - BiometricsView( - onSuccess = { - showBiometrics = false - submitAuthorizedRetry() + LaunchedEffect(uiState.recoveryApproval) { + if (viewModel.openRecoveryPin()) onNavigateToPin() + } + when (val approval = uiState.recoveryApproval) { + is RecoveryApproval.Fee -> AlertDialog( + onDismissRequest = { viewModel.answerRecoveryApproval(false) }, + title = { BodyM(stringResource(R.string.wallet__send_pending__retry_title)) }, + text = { + Column { + SelectionContainer { BodyM(approval.receipt.address, color = Colors.White64) } + BalanceHeaderView(sats = approval.receipt.amountSats.toLong()) + VerticalSpacer(16.dp) + BodyM(stringResource(R.string.wallet__send_fee_total) + .replace("{feeSats}", requireNotNull(approval.receipt.miningFeeSats).toString())) + BodyM("${approval.receipt.feeRateSatsPerVByte} " + stringResource(R.string.common__sat_vbyte)) + } }, - onFailure = { - showBiometrics = false - onNavigateToPin() + confirmButton = { + PrimaryButton(text = stringResource(R.string.wallet__send_pending__retry_authorize), + onClick = { viewModel.answerRecoveryApproval(true) }) }, + dismissButton = { + SecondaryButton(text = stringResource(R.string.common__cancel), + onClick = { viewModel.answerRecoveryApproval(false) }) + }, + ) + is RecoveryApproval.Warning -> AppAlertDialog( + title = stringResource(R.string.common__are_you_sure), + text = stringResource(approval.warning.message), + confirmText = stringResource(R.string.wallet__send_yes), + dismissText = stringResource(R.string.common__cancel), + onConfirm = { viewModel.answerRecoveryApproval(true) }, + onDismiss = { viewModel.answerRecoveryApproval(false) }, ) + RecoveryApproval.Biometrics -> BiometricsView( + onSuccess = { viewModel.answerRecoveryApproval(true) }, + onFailure = { viewModel.useRecoveryPin() }, + ) + else -> Unit } if (showFeeApproval) { uiState.recoveryAttempt?.let { original -> RecoveryFeeDialog(original, feeInput, { feeInput = it }, { showFeeApproval = false }) { rate -> showFeeApproval = false - approvedRate = rate.toString() - if (isPinEnabled && pinForPayments) { - if (isBiometricEnabled && isBiometrySupported) showBiometrics = true else onNavigateToPin() - } else { - submitAuthorizedRetry() + retryOriginal(rate) { receipt, warnings -> + viewModel.approveRecoveryFee(receipt, warnings, isPinEnabled && pinForPayments, + isBiometricEnabled && isBiometrySupported) } } } } - content { feeInput = uiState.recoveryAttempt?.feeRateSatsPerVByte?.toString().orEmpty() showFeeApproval = true @@ -241,7 +264,7 @@ private fun RecoveryFeeDialog( }, confirmButton = { PrimaryButton( - text = stringResource(R.string.wallet__send_pending__retry_authorize), + text = stringResource(R.string.common__continue), enabled = OnchainRecoveryFeeRate.parse(feeInput) != null, onClick = { val rate = OnchainRecoveryFeeRate.parse(feeInput) ?: return@PrimaryButton diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt index f8cb0236e4..e510d36947 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt @@ -1,5 +1,6 @@ package to.bitkit.ui.screens.wallets.send +import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.ActivityFilter @@ -10,17 +11,23 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import to.bitkit.ext.rawId import to.bitkit.ext.runSuspendCatching import to.bitkit.models.WalletScope import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.OnchainRecoveryFeeRate +import to.bitkit.repositories.OnchainPreparedReceipt import to.bitkit.repositories.OnchainSendAttempt import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.utils.Logger +import to.bitkit.viewmodels.SanityWarning import javax.inject.Inject @HiltViewModel @@ -37,6 +44,8 @@ class SendPendingViewModel @Inject constructor( private val _uiState = MutableStateFlow(SendPendingUiState()) val uiState = _uiState.asStateFlow() + private var recoveryApproval: CompletableDeferred? = null + private var recoveryPinOpened = false private var isInitialized = false fun init(paymentHash: String, amount: Long) { @@ -158,7 +167,56 @@ class SendPendingViewModel @Inject constructor( } } + suspend fun approveRecoveryFee( + receipt: OnchainPreparedReceipt, + warnings: List, + requiresPin: Boolean, + useBiometrics: Boolean, + ) = withContext(Dispatchers.Main.immediate) { + requireNotNull(receipt.miningFeeSats) + requestRecoveryApproval(RecoveryApproval.Fee(receipt)) + warnings.forEach { requestRecoveryApproval(RecoveryApproval.Warning(it)) } + if (requiresPin) { + requestRecoveryApproval(if (useBiometrics) RecoveryApproval.Biometrics else RecoveryApproval.Pin) + } + } + + private suspend fun requestRecoveryApproval(step: RecoveryApproval) { + val answer = CompletableDeferred() + recoveryApproval = answer + recoveryPinOpened = false + _uiState.update { it.copy(recoveryApproval = step) } + try { + if (!answer.await()) throw CancellationException("Original payment retry cancelled") + } finally { + if (recoveryApproval === answer) { + recoveryApproval = null + _uiState.update { it.copy(recoveryApproval = null) } + } + } + } + + fun answerRecoveryApproval(approved: Boolean) { + recoveryApproval?.complete(approved) + } + + fun answerRecoveryPin(approved: Boolean) { + if (_uiState.value.recoveryApproval == RecoveryApproval.Pin) answerRecoveryApproval(approved) + } + + fun useRecoveryPin() { + recoveryPinOpened = false + _uiState.update { it.copy(recoveryApproval = RecoveryApproval.Pin) } + } + + fun openRecoveryPin(): Boolean { + if (_uiState.value.recoveryApproval != RecoveryApproval.Pin || recoveryPinOpened) return false + recoveryPinOpened = true + return true + } + override fun onCleared() { + recoveryApproval?.cancel() pendingPaymentRepo.setActiveHash(null) } @@ -193,15 +251,24 @@ class SendPendingViewModel @Inject constructor( } } +@Stable data class SendPendingUiState( val amount: Long = 0L, val activityId: String? = null, val resolution: PendingPaymentResolution? = null, val recoveryAttempt: OnchainSendAttempt? = null, val isRecovering: Boolean = false, + val recoveryApproval: RecoveryApproval? = null, val invalidFeeRate: Boolean = false, val recoveryError: String? = null, val currentTxid: String? = null, val recoveredTxid: String? = null, val recoveredTransfer: OnchainSendAttempt? = null, ) + +sealed interface RecoveryApproval { + data class Fee(val receipt: OnchainPreparedReceipt) : RecoveryApproval + data class Warning(val warning: SanityWarning) : RecoveryApproval + data object Biometrics : RecoveryApproval + data object Pin : RecoveryApproval +} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 9abfd9c27e..d9f52f70e5 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -160,6 +160,7 @@ import to.bitkit.repositories.LnurlPayInvoiceMismatchError import to.bitkit.repositories.MethodId import to.bitkit.repositories.NodeEventUpdate import to.bitkit.repositories.OnchainSendAttempt +import to.bitkit.repositories.OnchainPreparedReceipt import to.bitkit.repositories.OnchainSendAttemptUnreadableError import to.bitkit.repositories.OnchainSendBlockedError import to.bitkit.repositories.OnchainSendNotDispatchedError @@ -4710,6 +4711,7 @@ class AppViewModel @Inject constructor( suspend fun retryOriginalOnchainSend( original: OnchainSendAttempt, feeRateSatsPerVByte: ULong, + approvePrepared: suspend (OnchainPreparedReceipt, List) -> Unit = { _, _ -> }, ): Result { val request = original.requestId?.let { id -> (paykitPaymentRequestRepo.pendingRequests.value + paykitPaymentRequestRepo.paymentRequestHistory.value) @@ -4723,6 +4725,16 @@ class AppViewModel @Inject constructor( original.walletId, feeRateSatsPerVByte, paymentDeadlineAt = request?.paymentDeadlineAt, + approvePrepared = { receipt -> + val fee = requireNotNull(receipt.miningFeeSats) + check(fee <= Long.MAX_VALUE.toULong()) + val feeInUsd = currencyRepo.convertSatsToFiat(fee.toLong(), USD).getOrThrow() + val warnings = buildList { + if (fee > original.amountSats / 2uL) add(SanityWarning.FEE_OVER_HALF_VALUE) + if (feeInUsd.value > BigDecimal(TEN_USD)) add(SanityWarning.FEE_OVER_10_USD) + } + approvePrepared(receipt, warnings) + }, ) { attempt -> check( attempt.attemptId == original.attemptId && attempt.requestId == original.requestId && diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt index 1523adcb82..4b6c5035b0 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt @@ -71,6 +71,34 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { } } + @Test + fun `retry fee approval uses the exact prepared object before retention and dispatch`() = test { + val f = Fixture() + val original = f.unresolved() + var preparations = 0 + var broadcasts = 0 + val receipt = f.receipt(nextTxid) + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error("unused") + override suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong, + paymentDeadlineAt: Instant?): PreparedOnchainSend { + preparations++ + return PreparedOnchainSend(receipt) { broadcasts++; OnchainSendOutcome.Unknown(nextTxid) } + } + } + val coordinator = OnchainSendCoordinator(f.store, sender, testDispatcher) + var approved = false + coordinator.retryOriginal(original.attemptId, original.walletId, 4uL, + approvePrepared = { preview -> + assertEquals(receipt.copy(feeRateSatsPerVByte = 4uL), preview) + assertEquals(listOf(firstTxid), f.store.current()?.candidateTxids) + assertEquals(0, broadcasts) + approved = true + }) { assertTrue(approved) }.getOrThrow() + assertEquals(1, preparations) + assertEquals(1, broadcasts) + } + @Test fun `accepted retry without durable acceptance stays pending after restart`() = test { val f = Fixture() @@ -588,7 +616,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { @Test fun `overflowing authorized recovery fee never reaches native preparation`() = test { - assertEquals(UInt.MAX_VALUE.toULong(), OnchainRecoveryFeeRate.maximum) + assertEquals(999uL, OnchainRecoveryFeeRate.maximum) assertEquals(null, OnchainRecoveryFeeRate.parse("4294967296")) assertEquals( OnchainRecoveryFeeRate.maximum, diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt index b71ea62d2f..f557d4d0fb 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt @@ -5,6 +5,7 @@ import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType +import kotlinx.coroutines.async import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -18,6 +19,7 @@ import org.mockito.kotlin.whenever import to.bitkit.models.WalletScope import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.OnchainPreparedReceipt import to.bitkit.repositories.OnchainSendAttempt import to.bitkit.repositories.OnchainSendEvidence import to.bitkit.repositories.OnchainSendInput @@ -25,6 +27,7 @@ import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.test.BaseUnitTest +import to.bitkit.viewmodels.SanityWarning import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNull @@ -54,6 +57,34 @@ class SendPendingViewModelTest : BaseUnitTest() { sut = createViewModel() } + @Test + fun `exact prepared fee and warnings precede original retry payment authentication`() = test { + val receipt = OnchainPreparedReceipt("ab".repeat(32), emptyList(), "original", 1_000uL, + feeRateSatsPerVByte = 10uL, miningFeeSats = 600uL) + val authorization = async { + sut.approveRecoveryFee(receipt, listOf(SanityWarning.FEE_OVER_HALF_VALUE, SanityWarning.FEE_OVER_10_USD), + requiresPin = true, useBiometrics = true) + } + advanceUntilIdle() + assertEquals(RecoveryApproval.Fee(receipt), sut.uiState.value.recoveryApproval) + sut.answerRecoveryApproval(true) + advanceUntilIdle() + assertEquals(RecoveryApproval.Warning(SanityWarning.FEE_OVER_HALF_VALUE), sut.uiState.value.recoveryApproval) + sut.answerRecoveryApproval(true) + advanceUntilIdle() + assertEquals(RecoveryApproval.Warning(SanityWarning.FEE_OVER_10_USD), sut.uiState.value.recoveryApproval) + sut.answerRecoveryApproval(true) + advanceUntilIdle() + assertEquals(RecoveryApproval.Biometrics, sut.uiState.value.recoveryApproval) + sut.useRecoveryPin() + assertEquals(true, sut.openRecoveryPin()) + assertEquals(false, sut.openRecoveryPin(), "Returning from PIN must not navigate to PIN again") + sut.answerRecoveryPin(true) + advanceUntilIdle() + authorization.await() + assertNull(sut.uiState.value.recoveryApproval) + } + @Test fun `pending original retry is explicit serialized and retains original amount`() = test { val txid = "ab".repeat(32) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 86ddac7d79..eb6fff046c 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9842,8 +9842,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { evidence = OnchainSendEvidence.Unknown, ) var dispatched = 0 - whenever(lightningRepo.retryOriginalOnchainSend(any(), any(), any(), anyOrNull(), any())).doSuspendableAnswer { invocation -> - val authorize = invocation.getArgument Unit>(4) + whenever(lightningRepo.retryOriginalOnchainSend(any(), any(), any(), anyOrNull(), any(), any())).doSuspendableAnswer { invocation -> + val authorize = invocation.getArgument Unit>(5) runCatching { authorize(original) dispatched += 1 diff --git a/journeys/send/onchain-original-payment-retry.xml b/journeys/send/onchain-original-payment-retry.xml index e7dc80905e..2983032f3b 100644 --- a/journeys/send/onchain-original-payment-retry.xml +++ b/journeys/send/onchain-original-payment-retry.xml @@ -8,18 +8,18 @@ as state injection, never as proof of a native fault. Manual Tests must verify the exact backend winning transaction retains the complete original input set and recipient amount, and any original Shop request or Blocktank order completes once without a new request or order. Local activity alone - cannot prove these checks. This journey is currently unrun on rc69. + cannot prove these checks. The funded native fault journey remains unrun. Open the retained original payment's Pending screen and record its displayed transaction ID Verify Pending describes the unresolved outcome and offers Retry original payment Tap Retry original payment Verify the dialog shows the original recipient and amount and prefills the original fee rate - Enter an invalid fee rate of 73786976294838207 sat/vB and verify valid positive fee feedback appears and Authorize retry is disabled - Enter a valid authorized fee rate that the funded fixture can support and tap Authorize retry - Verify the normal payment PIN screen appears before dispatch + Enter an invalid fee rate of 1000 sat/vB and verify valid positive fee feedback appears and Continue is disabled + Enter a valid authorized fee rate that the funded fixture can support and tap Continue + Verify the exact prepared mining fee in sats is shown beside the original recipient amount; tap Authorize retry, accept any normal fee-over-half or fee-over-10-USD warnings, and verify the normal payment PIN screen appears before dispatch Go Back without entering the PIN and verify the original Pending payment remains retained - Tap Retry original payment again, choose the fixture-supported fee and tap Authorize retry + Tap Retry original payment again, choose the fixture-supported fee, tap Continue, approve the exact prepared fee and any normal fee warnings Enter the fixture's normal payment PIN without recording entered digits Wait for Bitcoin Sent only after exact positive evidence, or verify Pending remains with a readable error if fee headroom is insufficient If successful, tap Details then Explore and record the exact winning transaction ID shown by TXID From 6f46682538275232b0b09e0189ab563518c50d96 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 22:59:44 +0200 Subject: [PATCH 096/108] fix: persist original payment dispatch boundary --- .../repositories/OnchainSendAttemptStore.kt | 51 +++++++++------- .../repositories/OnchainSendCoordinator.kt | 6 +- .../to/bitkit/services/LightningService.kt | 3 +- .../bitkit/repositories/LightningRepoTest.kt | 16 ++--- .../OnchainSendAttemptStoreTest.kt | 59 ++++++++++++++++++- .../OnchainSendCoordinatorTest.kt | 26 ++++++-- .../PaykitPaymentProofRepoTest.kt | 6 +- 7 files changed, 125 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index 9d6326b7a5..875de574c7 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -93,6 +93,8 @@ data class OnchainSendAttempt( val restoredFromBackup: Boolean = false, val preparationPending: Boolean = false, val verifiedWinningFeeSats: ULong? = null, + // Write-ahead boundary: false is proof that this original prepared payment never dispatched. + val initialDispatchAttempted: Boolean = true, ) { val winningFeeRateSatsPerVByte: ULong get() = candidateFeeRates[txid?.lowercase()] ?: feeRateSatsPerVByte.takeIf { @@ -140,18 +142,16 @@ class OnchainSendAttemptStore @Inject constructor( // One known positive result per existing wallet guard; entries disappear after a successful durable write. private val retainedPositive = mutableMapOf() private val inFlightPreparations = mutableSetOf() - // Process-local proof that this initial receipt has never entered the native submission boundary. - private val firstSubmissions = mutableSetOf() suspend fun current(): OnchainSendAttempt? = withContext(ioDispatcher) { mutex.withLock { - releaseInterruptedUnsignedPreparation(loadWithRetainedAccepted(lightningService.currentWalletIndex)) + releaseInterruptedUnsubmittedPreparation(loadWithRetainedAccepted(lightningService.currentWalletIndex)) } } /** Read only restart-safe evidence, excluding a positive result retained after a failed write. */ suspend fun currentDurable(): OnchainSendAttempt? = withContext(ioDispatcher) { - mutex.withLock { releaseInterruptedUnsignedPreparation(load(lightningService.currentWalletIndex)) } + mutex.withLock { releaseInterruptedUnsubmittedPreparation(load(lightningService.currentWalletIndex)) } } @Suppress("LongParameterList") @@ -173,7 +173,7 @@ class OnchainSendAttemptStore @Inject constructor( ): OnchainSendAttempt = withContext(ioDispatcher) { mutex.withLock { val walletIndex = lightningService.currentWalletIndex - val previous = releaseInterruptedUnsignedPreparation(load(walletIndex)) + val previous = releaseInterruptedUnsubmittedPreparation(load(walletIndex)) if (previous != null && ( previous.walletId != walletId || previous.blocksNextSend || @@ -199,6 +199,7 @@ class OnchainSendAttemptStore @Inject constructor( transferContext = transferContext, payerIdentity = payerIdentity, preparationPending = requestId != null, + initialDispatchAttempted = false, backupFollowup = ActiveOnchainAttemptBackup.Followup( feeSats = "0", tags = tags, @@ -220,11 +221,10 @@ class OnchainSendAttemptStore @Inject constructor( } } - private suspend fun releaseInterruptedUnsignedPreparation(current: OnchainSendAttempt?): OnchainSendAttempt? { + private suspend fun releaseInterruptedUnsubmittedPreparation(current: OnchainSendAttempt?): OnchainSendAttempt? { if (current == null || current.attemptId in inFlightPreparations || current.restoredFromBackup || current.walletId != WalletScope.default || current.requestId != null || - current.evidence != OnchainSendEvidence.Pending || current.txid != null || - current.candidateTxids.isNotEmpty() || current.originalInputs != null + current.evidence != OnchainSendEvidence.Pending || current.initialDispatchAttempted ) return current keychain.delete(KEY, current.walletIndex) _backupStateVersion.update { it + 1 } @@ -237,12 +237,11 @@ class OnchainSendAttemptStore @Inject constructor( mutex.withLock { val current = loadWithRetainedAccepted(lightningService.currentWalletIndex) ?: return@withLock false if (current.attemptId in inFlightPreparations) return@withLock false - if (!current.preparationPending || current.restoredFromBackup || current.requestId == null) { + if (current.initialDispatchAttempted || current.restoredFromBackup || current.requestId == null) { return@withLock false } if (current.walletId != WalletScope.default || current.payerIdentity.isNullOrBlank()) return@withLock false - if (current.evidence != OnchainSendEvidence.Pending || current.txid != null) return@withLock false - if (current.candidateTxids.isNotEmpty() || current.originalInputs != null) return@withLock false + if (current.evidence != OnchainSendEvidence.Pending) return@withLock false if (!removeOriginalProof(current)) return@withLock false keychain.delete(KEY, current.walletIndex) _backupStateVersion.update { it + 1 } @@ -300,8 +299,8 @@ class OnchainSendAttemptStore @Inject constructor( preparationPending = false, ).also { persist(it) - inFlightPreparations -= attemptId - if (!isRecovery) firstSubmissions += attemptId + // Keep a live initial preparation protected until its queued dispatch exits. + if (isRecovery) inFlightPreparations -= attemptId } } } @@ -310,7 +309,7 @@ class OnchainSendAttemptStore @Inject constructor( attemptId: String, walletIndex: Int, txid: String, - broadcast: suspend () -> OnchainSendOutcome, + broadcast: suspend (beforeDispatch: suspend () -> Unit) -> OnchainSendOutcome, ): OnchainSendOutcome = withContext(ioDispatcher + NonCancellable) { mutex.withLock { val current = loadWithRetainedAccepted(walletIndex) @@ -319,17 +318,23 @@ class OnchainSendAttemptStore @Inject constructor( } if (current.hasPositiveEvidence) return@withLock OnchainSendOutcome.Accepted(requireNotNull(current.txid)) require(txid.lowercase() in current.candidateTxids) - val firstSubmission = firstSubmissions.remove(attemptId) + val firstSubmission = !current.initialDispatchAttempted if (!firstSubmission) { persist(current.copy(txid = txid.lowercase(), evidence = OnchainSendEvidence.Pending, refusalReason = null)) } try { - broadcast() + broadcast { + // Called on the native queue after wallet/deadline validation, while this mutex is owned. + val live = loadWithRetainedAccepted(walletIndex) + check(live?.attemptId == attemptId && !live.hasPositiveEvidence) + persist(live.copy(initialDispatchAttempted = true)) + inFlightPreparations -= attemptId + } } catch (error: ServiceError.PaymentDeadlineExpired) { - // Only the queued deadline check raises this before prepared.broadcast(). A retry, - // reopened guard or any earlier dispatch has no process-local first-submission proof. - if (!firstSubmission || current.restoredFromBackup || - current.evidence != OnchainSendEvidence.Pending || current.candidateTxids.size != 1 + // The native queue checks the deadline before the durable dispatch boundary. + // Any earlier submission or imported backup remains uncertain and guarded. + if (!firstSubmission || loadWithRetainedAccepted(walletIndex)?.initialDispatchAttempted != false || + current.restoredFromBackup || current.evidence != OnchainSendEvidence.Pending || current.candidateTxids.size != 1 ) throw error persist(current.copy( txid = null, @@ -340,6 +345,8 @@ class OnchainSendAttemptStore @Inject constructor( )) // Shop proof cleanup still precedes guard deletion through the existing preparation path. throw OnchainSendNotDispatchedError(error) + } finally { + inFlightPreparations -= attemptId } } } @@ -364,6 +371,7 @@ class OnchainSendAttemptStore @Inject constructor( evidence = evidence, txid = outcome.txid, refusalReason = (outcome as? OnchainSendOutcome.Rejected)?.reason, + initialDispatchAttempted = true, ) if (outcome is OnchainSendOutcome.Accepted) retainedPositive[walletIndex] = recorded persist(recorded) @@ -442,7 +450,7 @@ class OnchainSendAttemptStore @Inject constructor( captureProofs: suspend () -> List, ): Pair> = withContext(ioDispatcher) { mutex.withLock { - val attempt = releaseInterruptedUnsignedPreparation(loadWithRetainedAccepted(walletIndex)) + val attempt = releaseInterruptedUnsubmittedPreparation(loadWithRetainedAccepted(walletIndex)) attempt?.takeIf { it.blocksNextSend } to captureProofs() } } @@ -464,6 +472,7 @@ class OnchainSendAttemptStore @Inject constructor( restoredFromBackup = attempt.restoredFromBackup, preparationPending = attempt.preparationPending, verifiedWinningFeeSats = attempt.verifiedWinningFeeSats, + initialDispatchAttempted = attempt.initialDispatchAttempted, ) == attempt check(sameOperation && existing.candidateTxids.containsAll(attempt.candidateTxids)) { "Cannot change a restored on-chain operation's original context" diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt index 1a6f0d820c..80cde29128 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt @@ -22,14 +22,14 @@ object OnchainRecoveryFeeRate { /** One native prepared transaction; its direct submission is cached even if it fails. */ class PreparedOnchainSend( val receipt: OnchainPreparedReceipt, - private val submit: suspend () -> OnchainSendOutcome, + private val submit: suspend (beforeDispatch: suspend () -> Unit) -> OnchainSendOutcome, ) { private val mutex = Mutex() private var result: Result? = null - suspend fun broadcast(): OnchainSendOutcome = withContext(NonCancellable) { + suspend fun broadcast(beforeDispatch: suspend () -> Unit = {}): OnchainSendOutcome = withContext(NonCancellable) { mutex.withLock { - val cached = result ?: runSuspendCatching { submit() }.also { result = it } + val cached = result ?: runSuspendCatching { submit(beforeDispatch) }.also { result = it } cached.getOrThrow() } } diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index d64d3b39ab..fe7308ce56 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -998,11 +998,12 @@ class LightningService internal constructor( prepared.recipientAmountSats(), miningFeeSats = prepared.miningFeeSats(), ) - PreparedOnchainSend(receipt) { + PreparedOnchainSend(receipt) { beforeDispatch -> callOnchainSend { // Dispatch only through the handle that signed the original receipt. if (currentWalletIndex != walletIndex || node !== originalNode) throw ServiceError.NodeNotSetup() ensurePaymentDeadline(paymentDeadlineAt) + beforeDispatch() when (val result = prepared.broadcast()) { is OnchainSendResult.Accepted -> OnchainSendOutcome.Accepted(result.txid) is OnchainSendResult.Rejected -> OnchainSendOutcome.Rejected(result.txid, result.reason) diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index bb6d405730..a91c8210e9 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -1538,7 +1538,7 @@ class LightningRepoTest : BaseUnitTest() { @Test fun `sendOnChain should cache activity meta data`() = test { whenever(onchainSendAttemptStore.broadcastPreparedCandidate(any(), any(), any(), any())) - .doSuspendableAnswer { (it.getArgument OnchainSendOutcome>(3))() } + .doSuspendableAnswer { (it.getArgument Unit) -> OnchainSendOutcome>(3))({}) } val mockSettingsData = SettingsData( defaultTransactionSpeed = TransactionSpeed.Fast, coinSelectAuto = false // Disable auto coin selection to simplify the test @@ -1714,7 +1714,7 @@ class LightningRepoTest : BaseUnitTest() { private fun preparedOutcome(outcome: OnchainSendOutcome) = PreparedOnchainSend( OnchainPreparedReceipt(outcome.txid, listOf(OnchainSendInput("11".repeat(32), 0u)), "address", 1_000uL), - ) { outcome } + ) { it(); outcome } @Test fun `original retry delegates saved outpoints to native validation when fresh outputs are absent`() = test { @@ -1769,7 +1769,7 @@ class LightningRepoTest : BaseUnitTest() { private suspend fun prepareGuardedSend(attempt: OnchainSendAttempt): LightningRepo { whenever(onchainSendAttemptStore.broadcastPreparedCandidate(any(), any(), any(), any())) - .doSuspendableAnswer { (it.getArgument OnchainSendOutcome>(3))() } + .doSuspendableAnswer { (it.getArgument Unit) -> OnchainSendOutcome>(3))({}) } whenever(settingsStore.data).thenReturn(flowOf(SettingsData(coinSelectAuto = false))) whenever( onchainSendAttemptStore.admit( @@ -1959,7 +1959,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(keychain.loadString(key, 0)).thenAnswer { saved } whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { writes++ - if (writes == 3) error("transient accepted write failure") + if (writes == 4) error("transient accepted write failure") saved = it.getArgument(1) } val store = OnchainSendAttemptStore(testDispatcher, keychain, lightningService, kotlin.time.Clock.System) @@ -2013,7 +2013,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(keychain.loadString(key, 0)).thenAnswer { saved } whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { writes++ - if (writes >= 3) error("transient accepted write failure") + if (writes >= 4) error("transient accepted write failure") saved = it.getArgument(1) } val store = OnchainSendAttemptStore(testDispatcher, keychain, lightningService, kotlin.time.Clock.System) @@ -2068,11 +2068,13 @@ class LightningRepoTest : BaseUnitTest() { whenever(keychain.loadString(key, 0)).thenAnswer { saved } whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { writes++ - if (writes >= 3) error("transient accepted write failure") + if (writes >= 4) error("transient accepted write failure") saved = it.getArgument(1) } - val store = spy(OnchainSendAttemptStore(testDispatcher, keychain, lightningService, kotlin.time.Clock.System)) + val durableStore = OnchainSendAttemptStore(testDispatcher, keychain, lightningService, kotlin.time.Clock.System) + val store = spy(durableStore) doSuspendableAnswer { + durableStore.broadcastPreparedCandidate(it.getArgument(0), it.getArgument(1), it.getArgument(2), it.getArgument(3)) store.recordOutcome(it.getArgument(0), OnchainSendOutcome.Accepted(txid), it.getArgument(1)) error("broadcast result unavailable") }.whenever(store).broadcastPreparedCandidate(any(), any(), any(), any()) diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index c3f6cec2eb..5aabb0446c 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -95,6 +95,57 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { assertNull(reopened.current()) } + @Test + fun `restart releases prepared candidate only when native dispatch was never entered`() = test { + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + whenever(keychain.delete(key, 0)).doSuspendableAnswer { saved = null } + val service = mock() + val store = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) + val original = store.admitForTest(walletId = WalletScope.default) + val receipt = OnchainPreparedReceipt("ab".repeat(32), listOf(OnchainSendInput("11".repeat(32), 0u)), + original.address, original.amountSats) + store.retainPreparedReceipt(original.attemptId, 0, receipt, false) + assertEquals(receipt.txid, store.current()?.txid) + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) + assertNull(reopened.current()) + reopened.admitForTest(walletId = WalletScope.default) + } + + @Test + fun `failed durable native dispatch marker stops broadcast`() = test { + var saved: String? = null + var failMarker = false + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { + if (failMarker) error("dispatch marker unavailable") + saved = it.getArgument(1) + } + whenever(keychain.delete(key, 0)).doSuspendableAnswer { saved = null } + val service = mock() + val store = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) + val original = store.admitForTest(walletId = WalletScope.default) + val receipt = OnchainPreparedReceipt("ab".repeat(32), listOf(OnchainSendInput("11".repeat(32), 0u)), + original.address, original.amountSats) + store.retainPreparedReceipt(original.attemptId, 0, receipt, false) + failMarker = true + var broadcasts = 0 + assertFailsWith { + store.broadcastPreparedCandidate(original.attemptId, 0, receipt.txid) { beforeDispatch -> + beforeDispatch() + broadcasts++ + OnchainSendOutcome.Unknown(receipt.txid) + } + } + assertEquals(0, broadcasts) + failMarker = false + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) + assertNull(reopened.current()) + } + @Test fun `first submission deadline failure clears only never dispatched receipt`() = test { var saved: String? = null @@ -132,6 +183,7 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { ) val retained = store.retainPreparedReceipt(attempt.attemptId, 0, receipt, false) store.broadcastPreparedCandidate(attempt.attemptId, 0, receipt.txid) { + it() OnchainSendOutcome.Unknown(receipt.txid) } // Even before outcome persistence, this process knows dispatch was attempted. @@ -140,14 +192,14 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { throw to.bitkit.utils.ServiceError.PaymentDeadlineExpired() } } - assertEquals(retained, store.current()) + assertEquals(retained.copy(initialDispatchAttempted = true), store.current()) val reopened = OnchainSendAttemptStore(testDispatcher, keychain, service, kotlin.time.Clock.System) assertFailsWith { reopened.broadcastPreparedCandidate(attempt.attemptId, 0, receipt.txid) { throw to.bitkit.utils.ServiceError.PaymentDeadlineExpired() } } - assertEquals(retained, reopened.current()) + assertEquals(retained.copy(initialDispatchAttempted = true), reopened.current()) } @Test @@ -632,11 +684,12 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { } private suspend fun OnchainSendAttemptStore.admitForTest( + walletId: String = "wallet-1", contactPublicKey: String? = null, beforeSendAttempt: suspend () -> Unit = { } ): OnchainSendAttempt = admit( - walletId = "wallet-1", + walletId = walletId, requestId = null, orderId = null, address = "bcrt1qrecipient", diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt index 4b6c5035b0..9864b18c90 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt @@ -83,7 +83,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { override suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?): PreparedOnchainSend { preparations++ - return PreparedOnchainSend(receipt) { broadcasts++; OnchainSendOutcome.Unknown(nextTxid) } + return PreparedOnchainSend(receipt) { it(); broadcasts++; OnchainSendOutcome.Unknown(nextTxid) } } } val coordinator = OnchainSendCoordinator(f.store, sender, testDispatcher) @@ -110,6 +110,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?, ) = PreparedOnchainSend(f.receipt(nextTxid)) { + it() f.failWrite = true OnchainSendOutcome.Accepted(nextTxid) } @@ -138,6 +139,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?, ) = PreparedOnchainSend(f.receipt(nextTxid)) { + it() if (paymentDeadlineAt != null && dispatchTime > paymentDeadlineAt) { throw PaykitPaymentRequestError.RequestExpired } @@ -166,6 +168,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { var sends = 0 val sender = object : OnchainPreparedSender { override suspend fun prepareInitial(attempt: OnchainSendAttempt) = PreparedOnchainSend(f.receipt()) { + it() assertTrue(requireNotNull(f.saved).contains(firstTxid)) sends++ OnchainSendOutcome.Unknown(firstTxid) @@ -191,6 +194,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { f.failWrite = true val sender = object : OnchainPreparedSender { override suspend fun prepareInitial(attempt: OnchainSendAttempt) = PreparedOnchainSend(f.receipt()) { + it() sends++ OnchainSendOutcome.Accepted(firstTxid) } @@ -214,6 +218,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { override suspend fun prepareInitial(attempt: OnchainSendAttempt) = PreparedOnchainSend( f.receipt(amount = 1_050uL) ) { + it() OnchainSendOutcome.Unknown(firstTxid) } override suspend fun prepareRecovery( @@ -227,7 +232,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { assertEquals(null, attempt.requestId) assertEquals("channel-1", attempt.channelId) assertEquals(OnchainTransferContext(1_100uL, 2_000uL, 900uL, 1_000uL), attempt.transferContext) - return PreparedOnchainSend(f.receipt(nextTxid, 1_050uL)) { OnchainSendOutcome.Unknown(nextTxid) } + return PreparedOnchainSend(f.receipt(nextTxid, 1_050uL)) { it(); OnchainSendOutcome.Unknown(nextTxid) } } } val coordinator = OnchainSendCoordinator(f.store, sender, testDispatcher) @@ -258,6 +263,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { else -> f.receipt(nextTxid) } return PreparedOnchainSend(receipt) { + it() sends++ OnchainSendOutcome.Accepted(nextTxid) } @@ -285,6 +291,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { ) override suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?) = PreparedOnchainSend(f.receipt(nextTxid)) { + it() entered.complete(Unit) finish.await() OnchainSendOutcome.Unknown(nextTxid) @@ -317,6 +324,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { ): PreparedOnchainSend { f.store.observeExactTransaction(firstTxid) return PreparedOnchainSend(f.receipt(nextTxid)) { + it() sends++ OnchainSendOutcome.Accepted(nextTxid) } @@ -356,10 +364,10 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { val initial = f.admit(isMax = true) val native = mock() whenever(native.prepareMax(initial.address, true, 1uL, 0)).thenReturn( - PreparedOnchainSend(f.receipt(amount = 1_050uL)) { OnchainSendOutcome.Unknown(firstTxid) }, + PreparedOnchainSend(f.receipt(amount = 1_050uL)) { it(); OnchainSendOutcome.Unknown(firstTxid) }, ) whenever(native.prepareFixed(initial.address, 1_050uL, 2uL, listOf(input), 0)).thenReturn( - PreparedOnchainSend(f.receipt(nextTxid, 1_050uL)) { OnchainSendOutcome.Unknown(nextTxid) }, + PreparedOnchainSend(f.receipt(nextTxid, 1_050uL)) { it(); OnchainSendOutcome.Unknown(nextTxid) }, ) val coordinator = OnchainSendCoordinator(f.store, OnchainPreparedSenderAdapter(native), testDispatcher) coordinator.sendInitial(initial).getOrThrow() @@ -387,6 +395,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { ): PreparedOnchainSend { prepares++ return PreparedOnchainSend(f.receipt(nextTxid)) { + it() broadcasts++ OnchainSendOutcome.Accepted(nextTxid) } @@ -428,6 +437,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { entered.complete(Unit) finish.await() return PreparedOnchainSend(f.receipt(nextTxid)) { + it() broadcasts++ OnchainSendOutcome.Accepted(nextTxid) } @@ -481,6 +491,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { prepares++ val txid = if (prepares == 1) nextTxid else "ef".repeat(32) return PreparedOnchainSend(f.receipt(txid)) { + it() active++ assertEquals(1, active) broadcasts++ @@ -536,6 +547,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { f.failWrite = true runCatching { f.store.observeExactTransaction(firstTxid) } return PreparedOnchainSend(f.receipt(nextTxid)) { + it() sends++ error("must not broadcast after positive observation") } @@ -580,7 +592,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { val sender = object : OnchainPreparedSender { override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error("unused") override suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?) = - PreparedOnchainSend(f.receipt(nextTxid)) { OnchainSendOutcome.Unknown(nextTxid) } + PreparedOnchainSend(f.receipt(nextTxid)) { it(); OnchainSendOutcome.Unknown(nextTxid) } } val result = OnchainSendCoordinator(store, sender, testDispatcher) .retryOriginal(original.attemptId, original.walletId, 2uL) {} @@ -598,6 +610,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error("unused") override suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?) = PreparedOnchainSend(f.receipt(nextTxid)) { + it() val saved = kotlinx.serialization.json.Json.decodeFromString( requireNotNull(f.saved) ) @@ -634,7 +647,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?, ): PreparedOnchainSend { preparations++ - return PreparedOnchainSend(f.receipt(nextTxid)) { OnchainSendOutcome.Unknown(nextTxid) } + return PreparedOnchainSend(f.receipt(nextTxid)) { it(); OnchainSendOutcome.Unknown(nextTxid) } } } for (invalidRate in listOf(4_294_967_296uL, ULong.MAX_VALUE)) { @@ -651,6 +664,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { for (fails in listOf(false, true)) { var sends = 0 val prepared = PreparedOnchainSend(Fixture().receipt()) { + it() sends++ if (fails) error("native failed") OnchainSendOutcome.Unknown(firstTxid) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 00c3a60cb6..7915d7d922 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -240,7 +240,7 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { val attempts = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) val repo = paymentProofRepo() repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() - val admitted = attempts.admit( + var admitted = attempts.admit( walletId = WalletScope.default, requestId = request.id, orderId = null, address = ONCHAIN_ADDRESS, amountSats = request.amountSats, isMaxAmount = false, feeRateSatsPerVByte = 1uL, isTransfer = false, channelId = null, tags = emptyList(), @@ -257,6 +257,10 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(admitted.preparationPending) assertTrue(storedProofs.single().paymentStarted) assertFalse(attempts.releaseInterruptedShopPreparation { error("live preparation must not be cleared") }) + admitted = attempts.retainPreparedReceipt(admitted.attemptId, 0, + OnchainPreparedReceipt("ab".repeat(32), listOf(OnchainSendInput("11".repeat(32), 0u)), + ONCHAIN_ADDRESS, request.amountSats), false) + assertFalse(attempts.releaseInterruptedShopPreparation { error("live prepared receipt must not be cleared") }) val reopened = OnchainSendAttemptStore(testDispatcher, keychain, mock(), kotlin.time.Clock.System) whenever(lightningRepo.currentOnchainSendAttempt()).doSuspendableAnswer { reopened.current() } whenever(lightningRepo.releaseInterruptedShopPreparation(any())).doSuspendableAnswer { From 645f01d1d4aafc0c0925493cc2edf89cf8808c2a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 23:15:37 +0200 Subject: [PATCH 097/108] fix: keep original payments observable after unsent retries --- .../repositories/OnchainSendAttemptStore.kt | 23 ++++++++++++++++ .../repositories/OnchainSendCoordinator.kt | 27 +++++++++++++++---- .../OnchainSendCoordinatorTest.kt | 18 ++++++++----- 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index 875de574c7..91fa7f2d3a 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -305,6 +305,29 @@ class OnchainSendAttemptStore @Inject constructor( } } + suspend fun discardUnsubmittedRetryCandidate(original: OnchainSendAttempt, txid: String) = + withContext(ioDispatcher + NonCancellable) { + mutex.withLock { + val candidate = txid.lowercase() + if (candidate in original.candidateTxids) return@withLock + val current = loadWithRetainedAccepted(original.walletIndex) + if (current?.attemptId != original.attemptId || current.walletId != original.walletId) { + throw OnchainSendBlockedError(current) + } + if (candidate !in current.candidateTxids || + current.hasPositiveEvidence && current.txid.equals(candidate, ignoreCase = true) + ) return@withLock + check(current.candidateTxids.containsAll(original.candidateTxids)) + persist(current.copy( + txid = if (current.hasPositiveEvidence) current.txid else original.txid, + evidence = if (current.hasPositiveEvidence) current.evidence else original.evidence, + refusalReason = if (current.hasPositiveEvidence) current.refusalReason else original.refusalReason, + candidateTxids = current.candidateTxids - candidate, + candidateFeeRates = current.candidateFeeRates - candidate, + )) + } + } + suspend fun broadcastPreparedCandidate( attemptId: String, walletIndex: Int, diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt index 80cde29128..cee54cb8e4 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendCoordinator.kt @@ -158,8 +158,13 @@ class OnchainSendCoordinator( prepared.receipt.copy(feeRateSatsPerVByte = feeRateSatsPerVByte), isRecovery = true, ) - authorizeOriginal(retained) - submit(retained, prepared) + try { + authorizeOriginal(retained) + } catch (error: Throwable) { + store.discardUnsubmittedRetryCandidate(attempt, prepared.receipt.txid) + throw error + } + submit(retained, prepared, recoveryOriginal = attempt) } } if (result.isFailure) { @@ -177,15 +182,27 @@ class OnchainSendCoordinator( result } - private suspend fun submit(attempt: OnchainSendAttempt, prepared: PreparedOnchainSend): OnchainSendOutcome { + private suspend fun submit( + attempt: OnchainSendAttempt, + prepared: PreparedOnchainSend, + recoveryOriginal: OnchainSendAttempt? = null, + ): OnchainSendOutcome { + var nativeDispatchEntered = false val outcome = runSuspendCatching { store.broadcastPreparedCandidate( attempt.attemptId, attempt.walletIndex, prepared.receipt.txid, - prepared::broadcast, - ) + ) { beforeDispatch -> + prepared.broadcast { + beforeDispatch() + nativeDispatchEntered = true + } + } }.getOrElse { error -> + if (!nativeDispatchEntered && recoveryOriginal != null) { + store.discardUnsubmittedRetryCandidate(recoveryOriginal, prepared.receipt.txid) + } val winner = runSuspendCatching { store.current() }.getOrNull() if (winner?.attemptId == attempt.attemptId && winner.walletId == attempt.walletId && winner.hasPositiveEvidence) { return OnchainSendOutcome.Accepted(requireNotNull(winner.txid)) diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt index 9864b18c90..5909039854 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt @@ -139,10 +139,10 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?, ) = PreparedOnchainSend(f.receipt(nextTxid)) { - it() if (paymentDeadlineAt != null && dispatchTime > paymentDeadlineAt) { throw PaykitPaymentRequestError.RequestExpired } + it() broadcasts++ OnchainSendOutcome.Unknown(nextTxid) } @@ -157,7 +157,12 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { assertEquals(original.amountSats, retained.amountSats) assertEquals(original.originalInputs, retained.originalInputs) assertTrue(retained.blocksNextSend) - assertTrue(nextTxid in retained.candidateTxids) + assertEquals(!expired, nextTxid in retained.candidateTxids) + if (expired) { + assertEquals(original.candidateTxids, retained.candidateTxids) + assertEquals(original.txid, retained.txid) + assertEquals(original.evidence, retained.evidence) + } } } @@ -377,7 +382,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { } @Test - fun `original authorization failure retains prepared candidate without broadcasting`() = test { + fun `original authorization failure drops only unsent retry candidate`() = test { val f = Fixture() val unknown = f.unresolved() val attempt = f.store.recordOutcome( @@ -412,7 +417,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { assertTrue(result.isFailure) assertEquals(1, prepares) assertEquals(0, broadcasts) - assertEquals(listOf(firstTxid, nextTxid), f.store.current()?.candidateTxids) + assertEquals(listOf(firstTxid), f.store.current()?.candidateTxids) assertEquals(firstTxid, f.store.current()?.txid) assertEquals(OnchainSendEvidence.Rejected, f.store.current()?.evidence) assertEquals("mempool min fee not met", f.store.current()?.refusalReason) @@ -458,8 +463,9 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { assertEquals(0, broadcasts) assertEquals(1, authorizations) val retained = f.store.current() - assertEquals(retained, authorizedRecord) - assertEquals(listOf(firstTxid, nextTxid), retained?.candidateTxids) + assertEquals(retained?.attemptId, authorizedRecord?.attemptId) + assertEquals(listOf(firstTxid, nextTxid), authorizedRecord?.candidateTxids) + assertEquals(listOf(firstTxid), retained?.candidateTxids) assertEquals(original.payerIdentity, retained?.payerIdentity) assertEquals(original.requestId, retained?.requestId) assertEquals(original.orderId, retained?.orderId) From bc6b5bf196eb3bda172f3d808f9c2fe5f1d1f0d6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 23:19:00 +0200 Subject: [PATCH 098/108] fix: recover payments without fiat rates after node restarts --- .../to/bitkit/repositories/TransferRepo.kt | 2 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 4 +- .../bitkit/repositories/TransferRepoTest.kt | 68 +++++++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 32 +++++++++ 4 files changed, 103 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index b92a95cd6b..8deaba1877 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -66,7 +66,7 @@ class TransferRepo @Inject constructor( init { repoScope.launch { lightningRepo.lightningState.map { it.nodeLifecycleState.isRunning() }.distinctUntilChanged() - .collect { running -> if (running) resumeAcceptedFunding() } + .collectLatest { running -> if (running) resumeAcceptedFunding() } } repoScope.launch { lightningRepo.nodeEvents.collect { event -> diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index fbef3f02fd..7906248d0b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4753,10 +4753,10 @@ class AppViewModel @Inject constructor( approvePrepared = { receipt -> val fee = requireNotNull(receipt.miningFeeSats) check(fee <= Long.MAX_VALUE.toULong()) - val feeInUsd = currencyRepo.convertSatsToFiat(fee.toLong(), USD).getOrThrow() + val feeInUsd = currencyRepo.convertSatsToFiat(fee.toLong(), USD).getOrNull() val warnings = buildList { if (fee > original.amountSats / 2uL) add(SanityWarning.FEE_OVER_HALF_VALUE) - if (feeInUsd.value > BigDecimal(TEN_USD)) add(SanityWarning.FEE_OVER_10_USD) + if (feeInUsd != null && feeInUsd.value > BigDecimal(TEN_USD)) add(SanityWarning.FEE_OVER_10_USD) } approvePrepared(receipt, warnings) }, diff --git a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt index 2100e80118..d53f50af41 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -259,6 +259,74 @@ class TransferRepoTest : BaseUnitTest() { assertNotNull(sut) } + @Test + fun `node restart during suspended funding recovery starts another attempt`() = test { + val order = previewBtOrder() + val txid = "ab".repeat(32) + val attempt = OnchainSendAttempt( + WalletScope.default, + "attempt", + null, + order.id, + requireNotNull(order.payment?.onchain?.address), + order.feeSat, + false, + 1uL, + true, + null, + emptyList(), + OnchainSendEvidence.Accepted, + txid, + transferContext = OnchainTransferContext(99_000uL, 125_000uL, order.clientBalanceSat, order.feeSat), + ) + setupClockNowMock() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + val firstFetch = CompletableDeferred() + var fetches = 0 + whenever(blocktankRepo.fetchOrders(listOf(order.id))).doSuspendableAnswer { + fetches += 1 + if (fetches == 1) { + firstFetch.await() + Result.failure(AppError("offline")) + } else { + Result.success(listOf(order)) + } + } + val state = MutableStateFlow(LightningState()) + whenever(lightningRepo.lightningState).thenReturn(state) + val restarted = TransferRepo( + testDispatcher, lightningRepo, blocktankRepo, coreService, transferDao, clock, cacheStore, connectivityRepo, + ) + runCurrent() + state.value = state.value.copy(nodeLifecycleState = NodeLifecycleState.Running) + runCurrent() + assertEquals(1, fetches) + state.value = state.value.copy(nodeLifecycleState = NodeLifecycleState.Stopped) + runCurrent() + state.value = state.value.copy(nodeLifecycleState = NodeLifecycleState.Running) + runCurrent() + firstFetch.complete(Unit) + runCurrent() + assertEquals(2, fetches) + + verify(transferDao).insert( + org.mockito.kotlin.check { + assertEquals(order.id, it.lspOrderId) + assertEquals(txid, it.fundingTxId) + assertEquals(99_000L, it.txTotalSats) + assertEquals(125_000L, it.preTransferOnchainSats) + } + ) + verify(lightningRepo).completeAcceptedTransferFollowup(order.id, txid) + verify(lightningRepo, never()).sendOnChain( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), + anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + paymentDeadlineAt = anyOrNull(), + contactPublicKey = anyOrNull(), + ) + assertNotNull(restarted) + } + @Test fun `startup resumes accepted original funding without a confirmation or native resend`() = test { val order = previewBtOrder() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index b6fcf64957..d73c40ddab 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -10079,6 +10079,38 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals("bcrt1qother", sut.sendUiState.value.address) } + @Test + fun `retry fee approval works without USD rate and keeps the Bitcoin fee warning`() = test { + val original = OnchainSendAttempt( + walletId = WalletScope.default, attemptId = "original", requestId = null, orderId = null, + address = "bcrt1qoriginal", amountSats = 1_000uL, isMaxAmount = false, + feeRateSatsPerVByte = 1uL, isTransfer = false, channelId = null, tags = emptyList(), + evidence = OnchainSendEvidence.Unknown, + ) + val receipt = to.bitkit.repositories.OnchainPreparedReceipt( + txid = "ab".repeat(32), inputs = emptyList(), address = original.address, + amountSats = original.amountSats, feeRateSatsPerVByte = 2uL, miningFeeSats = 600uL, + ) + whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())) + .thenReturn(Result.failure(Exception("USD unavailable"))) + whenever(lightningRepo.retryOriginalOnchainSend(any(), any(), any(), anyOrNull(), any(), any())) + .doSuspendableAnswer { invocation -> + val approve = invocation.getArgument Unit>(4) + runCatching { + approve(receipt) + OnchainSendOutcome.Unknown(receipt.txid) + } + } + var approved = false + val result = sut.retryOriginalOnchainSend(original, 2uL) { prepared, warnings -> + assertEquals(receipt, prepared) + assertEquals(listOf(SanityWarning.FEE_OVER_HALF_VALUE), warnings) + approved = true + } + assertTrue(result.isSuccess) + assertTrue(approved) + } + @Test fun `recovery denies paid expired or changed original Blocktank order before broadcast`() = test { val order = to.bitkit.ext.mockOrder().copy( From 2e5ffeefff6b0855fbcc81bd28bebe3f1dadca78 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 8 Oct 2026 23:35:25 +0200 Subject: [PATCH 099/108] fix: allow leaving hardware sends after definite refusal --- .../ui/screens/wallets/send/HwSendViewModel.kt | 18 ++++++++++++++---- .../wallets/send/HwSendViewModelTest.kt | 4 +++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt index 784cf5c0ab..231bd34520 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import to.bitkit.R import to.bitkit.ext.isBroadcastConnectivityFailure +import to.bitkit.ext.isDefiniteHardwarePreBroadcastFailure import to.bitkit.ext.isHwDeviceBusy import to.bitkit.ext.isHwFirmwareError import to.bitkit.ext.isHwSessionFailure @@ -323,8 +324,13 @@ class HwSendViewModel @Inject constructor( signingJob?.cancel() signingJob = null signingAttempt++ - pendingBroadcast = null - _uiState.update { it.copy(isSigning = false, isConnectingDevice = false, hasPendingBroadcast = false) } + // A refused Shop payment may leave the sheet, but its signed operation stays guarded. + if (pendingBroadcast?.let { it.request.paymentRequestId != null && it.hasAttemptedBroadcast } != true) { + pendingBroadcast = null + } + _uiState.update { + it.copy(isSigning = false, isConnectingDevice = false, hasPendingBroadcast = pendingBroadcast != null) + } val walletId = signingWalletId ?: return signingWalletId = null viewModelScope.launch { hwWalletRepo.disconnectStaleSession(walletId) } @@ -459,7 +465,10 @@ class HwSendViewModel @Inject constructor( val retainedShopPayment = pendingBroadcast?.let { it.request.paymentRequestId != null && it.hasAttemptedBroadcast } == true - _uiState.update { it.copy(isBroadcastUnresolved = retainedShopPayment) } + // This changes navigation only: a protocol refusal never releases the durable wallet guard. + _uiState.update { + it.copy(isBroadcastUnresolved = retainedShopPayment && !error.isDefiniteHardwarePreBroadcastFailure()) + } when { error.isHwUserCancellation() -> { Logger.info("Hardware send cancelled on device for '$walletId'", context = TAG) @@ -522,7 +531,8 @@ data class HwSendUiState( ) { /** * Whether the sign sheet may be dismissed. Connecting or unlocking can be abandoned, and leaving - * cancels it; once the device is asked to sign, or a broadcast may have gone out, it cannot. + * cancels it. A definite refusal may dismiss the sheet while the signed payment remains guarded. + * Active signing and uncertain broadcasts still prevent dismissal. */ val canLeave: Boolean get() = (!isSigning || isConnectingDevice) && !isBroadcastUnresolved diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt index 71470d352d..074a05cb29 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt @@ -385,10 +385,12 @@ class HwSendViewModelTest : BaseUnitTest() { ) sut.signAndBroadcast(original) advanceUntilIdle() - assertTrue(sut.uiState.value.isBroadcastUnresolved) + assertFalse(sut.uiState.value.isBroadcastUnresolved) + assertTrue(sut.uiState.value.canLeave) sut.cancel() advanceUntilIdle() assertTrue(sut.uiState.value.hasPendingBroadcast) + assertTrue(sut.uiState.value.canLeave) sut.signAndBroadcast(original) advanceUntilIdle() verify(hwWalletRepo, times(1)).signFunding(WALLET_ID, fixture.funding) From 1c539f5555cedcb1e7999fb67b7143c5f1f8a4ee Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 9 Oct 2026 04:41:21 +0200 Subject: [PATCH 100/108] fix: keep refused hardware payment sheets dismissible --- .../to/bitkit/ext/BroadcastExceptionExt.kt | 12 +++++ .../screens/wallets/send/HwSendViewModel.kt | 4 +- .../bitkit/ext/BroadcastExceptionExtTest.kt | 31 +++++++++++++ .../wallets/send/HwSendViewModelTest.kt | 45 +++++++++++++++++++ .../hardware-wallet/shop-onchain-proof.xml | 3 ++ 5 files changed, 93 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/BroadcastExceptionExt.kt b/app/src/main/java/to/bitkit/ext/BroadcastExceptionExt.kt index 192354dc73..5aa69026d5 100644 --- a/app/src/main/java/to/bitkit/ext/BroadcastExceptionExt.kt +++ b/app/src/main/java/to/bitkit/ext/BroadcastExceptionExt.kt @@ -11,3 +11,15 @@ fun Throwable.isBroadcastConnectivityFailure(): Boolean = fun Throwable.isDefiniteHardwarePreBroadcastFailure(): Boolean = generateSequence(this) { it.cause }.any { it is BroadcastException.InvalidHex || it is BroadcastException.InvalidTransaction } + +// A recognized node refusal permits leaving the sheet, but never proves non-delivery. +fun Throwable.isHardwareBroadcastRefusalForNavigation(): Boolean = + isDefiniteHardwarePreBroadcastFailure() || generateSequence(this) { it.cause }.any { + if (it !is BroadcastException.ElectrumException) return@any false + val details = it.errorDetails.lowercase() + if (!details.startsWith("broadcast failed: ")) return@any false + val reason = details.removePrefix("broadcast failed: ") + reason == "min relay fee not met" || reason == "mempool min fee not met" || + reason == "bad-txns-inputs-missingorspent" || reason == "txn-mempool-conflict" || + reason == "non-final" + } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt index 231bd34520..ca848c2604 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt @@ -19,7 +19,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import to.bitkit.R import to.bitkit.ext.isBroadcastConnectivityFailure -import to.bitkit.ext.isDefiniteHardwarePreBroadcastFailure +import to.bitkit.ext.isHardwareBroadcastRefusalForNavigation import to.bitkit.ext.isHwDeviceBusy import to.bitkit.ext.isHwFirmwareError import to.bitkit.ext.isHwSessionFailure @@ -467,7 +467,7 @@ class HwSendViewModel @Inject constructor( } == true // This changes navigation only: a protocol refusal never releases the durable wallet guard. _uiState.update { - it.copy(isBroadcastUnresolved = retainedShopPayment && !error.isDefiniteHardwarePreBroadcastFailure()) + it.copy(isBroadcastUnresolved = retainedShopPayment && !error.isHardwareBroadcastRefusalForNavigation()) } when { error.isHwUserCancellation() -> { diff --git a/app/src/test/java/to/bitkit/ext/BroadcastExceptionExtTest.kt b/app/src/test/java/to/bitkit/ext/BroadcastExceptionExtTest.kt index cba5b16adc..405fe6b0b8 100644 --- a/app/src/test/java/to/bitkit/ext/BroadcastExceptionExtTest.kt +++ b/app/src/test/java/to/bitkit/ext/BroadcastExceptionExtTest.kt @@ -46,4 +46,35 @@ class BroadcastExceptionExtTest { ) assertFalse(AppError("unknown result").isDefiniteHardwarePreBroadcastFailure()) } + + @Test + fun `recognized backend refusals allow navigation without proving no dispatch`() { + val reasons = listOf( + "min relay fee not met", + "mempool min fee not met", + "bad-txns-inputs-missingorspent", + "txn-mempool-conflict", + "non-final", + ) + for (reason in reasons) { + val error = AppError(BroadcastException.ElectrumException("broadcast failed: $reason")) + assertTrue(error.isHardwareBroadcastRefusalForNavigation()) + assertFalse(error.isDefiniteHardwarePreBroadcastFailure()) + } + } + + @Test + fun `unknown and connectivity errors retain the navigation guard`() { + val errors = listOf( + "broadcast failed: disconnected", + "broadcast failed: unknown refusal", + "response lost after dispatch", + "min relay fee not met", + ) + for (details in errors) { + val error = AppError(BroadcastException.ElectrumException(details)) + assertFalse(error.isHardwareBroadcastRefusalForNavigation()) + } + assertFalse(AppError("unknown result").isHardwareBroadcastRefusalForNavigation()) + } } diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt index 074a05cb29..c127c358f2 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt @@ -49,6 +49,7 @@ import to.bitkit.test.BaseUnitTest import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.AppError import to.bitkit.utils.ServiceError +import to.bitkit.utils.SignedTransactionId @OptIn(ExperimentalCoroutinesApi::class) class HwSendViewModelTest : BaseUnitTest() { @@ -399,6 +400,50 @@ class HwSendViewModelTest : BaseUnitTest() { assertEquals(original.paymentIdentity, sut.results.first().paymentIdentity) } + @Test + fun `Shop backend refusal allows leaving while retaining original signed payment`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + ) + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)).thenReturn( + Result.failure(BroadcastException.ElectrumException("broadcast failed: min relay fee not met")), + ) + sut.signAndBroadcast(original) + advanceUntilIdle() + assertTrue(sut.uiState.value.canLeave) + assertTrue(sut.uiState.value.hasPendingBroadcast) + sut.cancel() + advanceUntilIdle() + assertTrue(sut.uiState.value.canLeave) + assertTrue(sut.uiState.value.hasPendingBroadcast) + sut.signAndBroadcast(original) + advanceUntilIdle() + verify(hwWalletRepo, times(1)).signFunding(WALLET_ID, fixture.funding) + verify(hwWalletRepo, times(2)).broadcastFunding(fixture.signedTx) + verify(proofRepo, times(2)).markHardwareOnchainDispatch( + requireNotNull(original.paymentRequestId), + WALLET_ID, + SignedTransactionId.fromHex(fixture.signedTx.serializedTx), + original.paymentIdentity, + original.address, + original.amountSats, + ) + verify(proofRepo, never()).clearHardwareOnchainCandidateBeforeDispatch( + any(), + any(), + any(), + org.mockito.kotlin.anyOrNull(), + any(), + any(), + any(), + ) + assertTrue(sut.uiState.value.canLeave) + assertTrue(sut.uiState.value.hasPendingBroadcast) + } + @Test fun `broadcast connectivity failure unblocks navigation`() = test { whenever(context.getString(any())).thenReturn("message") diff --git a/journeys/hardware-wallet/shop-onchain-proof.xml b/journeys/hardware-wallet/shop-onchain-proof.xml index ab8cd68999..d54020dd61 100644 --- a/journeys/hardware-wallet/shop-onchain-proof.xml +++ b/journeys/hardware-wallet/shop-onchain-proof.xml @@ -6,6 +6,9 @@ in the original hardware account and match the issuer's delivered proof to that transaction. If Core fails after payment has started and returns no transaction ID, dismissing and reopening must keep that original request pending; this journey cannot inject that response-loss condition. + A recognized backend refusal or invalid transaction permits leaving the sign sheet while retaining + the original signed payment and durable wallet guard; reopening may retry only that payment + without signing again. Connectivity and unrecognized errors keep navigation guarded. This journey does not inject native broadcast faults or prove physical USB/BLE behavior. From 30504cbe85326f38321e37320ff312ecd0bcc90f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 9 Oct 2026 11:48:37 +0200 Subject: [PATCH 101/108] fix: preserve original shop retries and signed send details --- .../to/bitkit/ext/BroadcastExceptionExt.kt | 45 +++++++- .../to/bitkit/repositories/LightningRepo.kt | 19 +++- .../repositories/OnchainSendAttemptStore.kt | 2 +- .../repositories/PaykitPaymentProofRepo.kt | 45 +++++++- .../to/bitkit/services/LightningService.kt | 3 +- .../screens/wallets/send/HwSendViewModel.kt | 53 +++++++-- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../bitkit/ext/BroadcastExceptionExtTest.kt | 32 ++++++ .../bitkit/repositories/LightningRepoTest.kt | 63 +++++++++++ .../PaykitPaymentProofRepoTest.kt | 67 +++++++++++ .../bitkit/services/LightningServiceTest.kt | 24 ++++ .../wallets/send/HwSendViewModelTest.kt | 105 ++++++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 15 +++ 13 files changed, 449 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/BroadcastExceptionExt.kt b/app/src/main/java/to/bitkit/ext/BroadcastExceptionExt.kt index 5aa69026d5..f81fcb95da 100644 --- a/app/src/main/java/to/bitkit/ext/BroadcastExceptionExt.kt +++ b/app/src/main/java/to/bitkit/ext/BroadcastExceptionExt.kt @@ -2,6 +2,10 @@ package to.bitkit.ext import com.synonym.bitkitcore.BroadcastException import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive fun Throwable.isBroadcastConnectivityFailure(): Boolean = generateSequence(this) { it.cause }.any { @@ -16,10 +20,39 @@ fun Throwable.isDefiniteHardwarePreBroadcastFailure(): Boolean = generateSequenc fun Throwable.isHardwareBroadcastRefusalForNavigation(): Boolean = isDefiniteHardwarePreBroadcastFailure() || generateSequence(this) { it.cause }.any { if (it !is BroadcastException.ElectrumException) return@any false - val details = it.errorDetails.lowercase() - if (!details.startsWith("broadcast failed: ")) return@any false - val reason = details.removePrefix("broadcast failed: ") - reason == "min relay fee not met" || reason == "mempool min fee not met" || - reason == "bad-txns-inputs-missingorspent" || reason == "txn-mempool-conflict" || - reason == "non-final" + val details = it.errorDetails + val prefix = "broadcast failed: " + if (!details.startsWith(prefix, ignoreCase = true)) return@any false + val reason = details.drop(prefix.length) + val serverPrefix = "electrum server error: " + val message = if (reason.startsWith(serverPrefix, ignoreCase = true)) { + refusalMessageFromJson(reason.drop(serverPrefix.length)) ?: return@any false + } else { + reason + } + message.isKnownHardwareRefusal() } + +private fun refusalMessageFromJson(payload: String): String? = runCatching { + Json.parseToJsonElement(payload).jsonObject["message"]?.jsonPrimitive?.contentOrNull +}.getOrNull() + +private fun String.isKnownHardwareRefusal(): Boolean { + val rpcPrefix = "sendrawtransaction RPC error: " + val message = if (startsWith(rpcPrefix, ignoreCase = true)) { + refusalMessageFromJson(drop(rpcPrefix.length)) ?: return false + } else { + this + } + val reasons = listOf( + "min relay fee not met", + "mempool min fee not met", + "bad-txns-inputs-missingorspent", + "txn-mempool-conflict", + "non-final", + ) + return message.lineSequence().any { line -> + val reason = line.trim().lowercase() + reasons.any { known -> reason == known || reason.startsWith("$known,") || reason.startsWith("$known ") } + } +} diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 8332463b3f..083ce0ebc8 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1613,12 +1613,18 @@ class LightningRepo @Inject constructor( return@executeWhenNodeRunning Result.failure(OnchainSendNotDispatchedError(it)) } var outcomePersisted = true + var dispatchStarted = false val outcome = runSuspendCatching { onchainSendAttemptStore.broadcastPreparedCandidate( attempt.attemptId, attempt.walletIndex, prepared.receipt.txid, - prepared::broadcast, + broadcast = { beforeDispatch -> + prepared.broadcast { + beforeDispatch() + dispatchStarted = true + } + }, ) }.getOrElse { error -> if (error is OnchainSendNotDispatchedError) { @@ -1627,12 +1633,19 @@ class LightningRepo @Inject constructor( }.onFailure { Logger.warn("Failed to clear expired preparation", it, context = TAG) } return@executeWhenNodeRunning Result.failure(error) } - val winner = runSuspendCatching { onchainSendAttemptStore.current() }.getOrNull() + val winnerResult = runSuspendCatching { onchainSendAttemptStore.current() } + val winner = winnerResult.getOrNull() + val releasedWithoutDispatch = !dispatchStarted && winnerResult.isSuccess && winner == null if (winner?.attemptId == attempt.attemptId && winner.walletId == attempt.walletId && winner.hasPositiveEvidence ) { outcomePersisted = false OnchainSendOutcome.Accepted(requireNotNull(winner.txid)) + } else if (releasedWithoutDispatch && + lightningService.currentWalletIndex == attempt.walletIndex + ) { + // current() released the exact local preparation whose dispatch marker was never written. + return@executeWhenNodeRunning Result.failure(OnchainSendNotDispatchedError(error)) } else { return@executeWhenNodeRunning Result.failure(OnchainSendPendingError(error, prepared.receipt.txid)) } @@ -1677,7 +1690,7 @@ class LightningRepo @Inject constructor( } runSuspendCatching { syncState() } .onFailure { Logger.warn("Failed to sync after accepted on-chain send", it, context = TAG) } - Result.success(winningOutcome) + Result.success(winningOutcome.copy(amountSats = recorded.amountSats.takeIf { recorded.isMaxAmount })) } private val recoveryCoordinator by lazy { diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index 91fa7f2d3a..607f30d3e0 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -31,7 +31,7 @@ import kotlin.time.ExperimentalTime sealed interface OnchainSendOutcome { val txid: String - data class Accepted(override val txid: String) : OnchainSendOutcome + data class Accepted(override val txid: String, val amountSats: ULong? = null) : OnchainSendOutcome data class Rejected(override val txid: String, val reason: String) : OnchainSendOutcome data class Unknown(override val txid: String) : OnchainSendOutcome } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 1fa5c328df..7d940aa571 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -66,6 +66,7 @@ enum class PaykitPaymentProofKind(val type: String) { data class RetainedHardwareOnchainPayment( val signedTx: HwFundingSignedTx, val hasAttemptedBroadcast: Boolean, + val isRefusedForNavigation: Boolean = false, ) @Serializable @@ -86,6 +87,8 @@ data class PendingPaykitPaymentProof( val onchainAcceptanceVerified: Boolean = false, val hardwareDispatchDenied: Boolean = false, val hardwareDispatchAttempted: Boolean? = null, + // Local navigation hint only; wallet backup restore deliberately defaults to conservative navigation. + val hardwareRefusedForNavigation: Boolean = false, val hardwareSignedTransaction: String? = null, val hardwareMiningFeeSats: ULong? = null, val hardwareFeeRate: ULong? = null, @@ -602,7 +605,41 @@ class PaykitPaymentProofRepo @Inject constructor( it.onchainAddress == address && it.onchainAmountSats == amountSats && it.retainedSignedHardwareReceipt() != null } ?: return@withLock false - persist(proofs.map { if (it == original) it.copy(hardwareDispatchAttempted = true) else it }) + persist( + proofs.map { + if (it == original) { + it.copy(hardwareDispatchAttempted = true, hardwareRefusedForNavigation = false) + } else { + it + } + } + ) + true + } + } + + // All captured payment fields are required to keep this hint bound to the original signed receipt. + @Suppress("LongParameterList", "CyclomaticComplexMethod") + suspend fun markHardwareRefusedForNavigation( + requestId: PaykitPaymentRequestId, + walletId: String, + txid: String, + identity: String?, + address: String, + amountSats: ULong, + ): Boolean = withContext(ioDispatcher) { + operationMutex.withLock { + if (!PubkyPublicKeyFormat.matches(currentIdentity(), identity)) return@withLock false + val proofs = loadProofs() + val original = proofs.singleOrNull { + PubkyPublicKeyFormat.matches(it.identity, identity) && it.requestId == requestId && + it.onchainWalletId == walletId && it.kind == PaykitPaymentProofKind.Onchain && + it.paymentStarted && it.hardwareDispatchAttempted == true && + !it.hardwareDispatchDenied && !it.onchainAcceptanceVerified && it.proofData == null && + it.paymentIdentifier.equals(txid, true) && it.onchainAddress == address && + it.onchainAmountSats == amountSats && it.retainedSignedHardwareReceipt() != null + } ?: return@withLock false + persist(proofs.map { if (it == original) it.copy(hardwareRefusedForNavigation = true) else it }) true } } @@ -652,7 +689,11 @@ class PaykitPaymentProofRepo @Inject constructor( val error = consumption.exceptionOrNull() if (error != null && error !is PrivatePaykitError.PaymentListAlreadyConsumed) throw error } - RetainedHardwareOnchainPayment(it, proof.hardwareDispatchAttempted != false) + RetainedHardwareOnchainPayment( + it, + proof.hardwareDispatchAttempted != false, + proof.hardwareRefusedForNavigation, + ) } } } diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index fe7308ce56..80cf654b01 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -71,7 +71,6 @@ import to.bitkit.models.WatchOnlyAccountSetupState import to.bitkit.models.msatFloorOf import to.bitkit.models.toAddressType import to.bitkit.repositories.OnchainPreparedReceipt -import to.bitkit.repositories.OnchainRecoveryFeeRate import to.bitkit.repositories.OnchainSendInput import to.bitkit.repositories.OnchainSendAttempt import to.bitkit.repositories.OnchainSendOutcome @@ -974,7 +973,7 @@ class LightningService internal constructor( paymentDeadlineAt: Instant? = null, ): PreparedOnchainSend = callOnchainSend { ensurePaymentDeadline(paymentDeadlineAt) - require(OnchainRecoveryFeeRate.isValid(satsPerVByte)) + require(satsPerVByte > 0uL && satsPerVByte <= UInt.MAX_VALUE.toULong()) if (currentWalletIndex != walletIndex) throw ServiceError.NodeNotSetup() val originalNode = node ?: throw ServiceError.NodeNotSetup() val prepared = if (isMaxAmount) { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt index ca848c2604..e8f3a96805 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt @@ -93,13 +93,23 @@ class HwSendViewModel @Inject constructor( onPaymentDeadlineExpired: suspend (hasAttemptedBroadcast: Boolean) -> Unit = {}, ) { if (pendingResult.value != null || _uiState.value.isSigning || signingJob?.isActive == true) return - if (pendingBroadcast?.matches(request) == false) return signingWalletId = request.walletId _uiState.update { it.copy(isSigning = true) } val attempt = ++signingAttempt signingJob = viewModelScope.launch { try { runCatching { + if (pendingBroadcast?.matches(request) == false) { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__error), + description = HwErrorPresenter.userMessage( + context, + PaykitPaymentRequestError.OperationInProgress, + ), + ) + return@runCatching + } var pending = pendingBroadcast?.takeIf { it.matches(request) } if (pending == null && request.paymentRequestId != null) { val restored = paykitPaymentProofRepo.retainedHardwareOnchainPayment( @@ -115,13 +125,15 @@ class HwSendViewModel @Inject constructor( request, restored.signedTx, isPreparedForBroadcast = true, - hasAttemptedBroadcast = restored.hasAttemptedBroadcast + hasAttemptedBroadcast = restored.hasAttemptedBroadcast, + isRefusedForNavigation = restored.isRefusedForNavigation, ) pendingBroadcast = pending _uiState.update { it.copy( hasPendingBroadcast = true, - isBroadcastUnresolved = restored.hasAttemptedBroadcast + isBroadcastUnresolved = + restored.hasAttemptedBroadcast && !restored.isRefusedForNavigation, ) } } @@ -183,12 +195,12 @@ class HwSendViewModel @Inject constructor( onPaymentDeadlineExpired: suspend (Boolean) -> Unit, ): Boolean { if (payment.request.paymentDeadlineAt?.let { clock.now() > it } == true) { - _uiState.update { it.copy(isBroadcastUnresolved = payment.hasAttemptedBroadcast) } + _uiState.update { it.copy(isBroadcastUnresolved = payment.blocksNavigation) } onPaymentDeadlineExpired(payment.hasAttemptedBroadcast) return false } val authorized = authorizeContactPayment(payment.hasAttemptedBroadcast) - if (!authorized && payment.hasAttemptedBroadcast && + if (!authorized && payment.blocksNavigation && payment.request.paymentDeadlineAt?.let { clock.now() > it } == true ) { _uiState.update { it.copy(isBroadcastUnresolved = true) } @@ -228,7 +240,7 @@ class HwSendViewModel @Inject constructor( val request = payment.request val requestId = request.paymentRequestId if (requestId == null) { - pendingBroadcast = payment.copy(hasAttemptedBroadcast = true) + pendingBroadcast = payment.copy(hasAttemptedBroadcast = true, isRefusedForNavigation = false) hwWalletRepo.broadcastFunding(payment.signedTx, request.paymentDeadlineAt) } else { hwWalletRepo.broadcastFundingAtBoundary(payment.signedTx, request.paymentDeadlineAt) { @@ -242,7 +254,7 @@ class HwSendViewModel @Inject constructor( request.amountSats, ) ) { "Original hardware payment is no longer eligible for dispatch" } - pendingBroadcast = payment.copy(hasAttemptedBroadcast = true) + pendingBroadcast = payment.copy(hasAttemptedBroadcast = true, isRefusedForNavigation = false) } } }.getOrElse { error -> @@ -256,7 +268,7 @@ class HwSendViewModel @Inject constructor( } ?: !payment.hasAttemptedBroadcast val unresolved = payment.hasAttemptedBroadcast || !cleared pendingBroadcast = payment.copy(hasAttemptedBroadcast = unresolved) - _uiState.update { it.copy(isBroadcastUnresolved = unresolved) } + _uiState.update { it.copy(isBroadcastUnresolved = unresolved && !payment.isRefusedForNavigation) } onPaymentDeadlineExpired(unresolved) return null } @@ -466,9 +478,23 @@ class HwSendViewModel @Inject constructor( it.request.paymentRequestId != null && it.hasAttemptedBroadcast } == true // This changes navigation only: a protocol refusal never releases the durable wallet guard. - _uiState.update { - it.copy(isBroadcastUnresolved = retainedShopPayment && !error.isHardwareBroadcastRefusalForNavigation()) + val refused = retainedShopPayment && error.isHardwareBroadcastRefusalForNavigation() + pendingBroadcast = pendingBroadcast?.copy(isRefusedForNavigation = refused) + if (refused) { + val payment = requireNotNull(pendingBroadcast) + val request = payment.request + runSuspendCatching { + paykitPaymentProofRepo.markHardwareRefusedForNavigation( + requireNotNull(request.paymentRequestId), + request.walletId, + SignedTransactionId.fromHex(payment.signedTx.serializedTx), + request.paymentIdentity, + request.address, + request.amountSats, + ) + }.onFailure { Logger.warn("Failed to save hardware refusal navigation state", it, context = TAG) } } + _uiState.update { it.copy(isBroadcastUnresolved = retainedShopPayment && !refused) } when { error.isHwUserCancellation() -> { Logger.info("Hardware send cancelled on device for '$walletId'", context = TAG) @@ -562,6 +588,11 @@ private data class PendingHwSendBroadcast( val signedTx: HwFundingSignedTx, val isPreparedForBroadcast: Boolean = false, val hasAttemptedBroadcast: Boolean = false, + val isRefusedForNavigation: Boolean = false, ) { - fun matches(request: HwSendRequest): Boolean = this.request == request + val blocksNavigation: Boolean get() = hasAttemptedBroadcast && !isRefusedForNavigation + + fun matches(request: HwSendRequest): Boolean = this.request == request || + hasAttemptedBroadcast && this.request.paymentRequestId != null && + this.request.copy(satsPerVByte = request.satsPerVByte) == request } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 7906248d0b..b2a27d02d6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4585,7 +4585,7 @@ class AppViewModel @Inject constructor( type = NewTransactionSheetType.ONCHAIN, direction = NewTransactionSheetDirection.SENT, paymentHashOrTxId = outcome.txid, - sats = amount.toLong(), + sats = (outcome.amountSats ?: amount).toLong(), isLoadingDetails = true, ) ) diff --git a/app/src/test/java/to/bitkit/ext/BroadcastExceptionExtTest.kt b/app/src/test/java/to/bitkit/ext/BroadcastExceptionExtTest.kt index 405fe6b0b8..1ecbbb9d2b 100644 --- a/app/src/test/java/to/bitkit/ext/BroadcastExceptionExtTest.kt +++ b/app/src/test/java/to/bitkit/ext/BroadcastExceptionExtTest.kt @@ -77,4 +77,36 @@ class BroadcastExceptionExtTest { } assertFalse(AppError("unknown result").isHardwareBroadcastRefusalForNavigation()) } + + @Test + fun `wrapped Electrum server refusals allow navigation`() { + val messages = listOf( + "the transaction was rejected by network rules.\n\nmin relay fee not met, 100 < 110", + "sendrawtransaction RPC error: {\"code\":-26,\"message\":\"bad-txns-inputs-missingorspent\"}", + "mempool min fee not met, 110 < 300", + ) + for (message in messages) { + val payload = kotlinx.serialization.json.buildJsonObject { + put("code", kotlinx.serialization.json.JsonPrimitive(1)) + put("message", kotlinx.serialization.json.JsonPrimitive(message)) + } + val error = BroadcastException.ElectrumException("Broadcast failed: Electrum server error: $payload") + assertTrue(error.isHardwareBroadcastRefusalForNavigation()) + assertFalse(error.isDefiniteHardwarePreBroadcastFailure()) + } + } + + @Test + fun `malformed unknown and incidental Electrum messages stay guarded`() { + val payloads = listOf( + "Broadcast failed: Electrum server error: {invalid JSON min relay fee not met}", + "Broadcast failed: Electrum server error: {\"message\":\"disconnected\"}", + "Broadcast failed: Electrum server error: {\"message\":\"unknown refusal\"}", + "Broadcast failed: Electrum server error: {\"message\":{},\"reason\":\"min relay fee not met\"}", + "Broadcast failed: Electrum server error: {\"message\":\"non-finalized response lost\"}", + ) + for (payload in payloads) { + assertFalse(BroadcastException.ElectrumException(payload).isHardwareBroadcastRefusalForNavigation()) + } + } } diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index a91c8210e9..6a4fbf4fad 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -1712,6 +1712,69 @@ class LightningRepoTest : BaseUnitTest() { } } + @Test + fun `pre-marker failure is not dispatched after exact unsent guard cleanup`() = test { + val txid = "ab".repeat(32) + val key = Keychain.Key.ONCHAIN_SEND_ATTEMPT.name + var saved: String? = null + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + whenever(keychain.delete(key, 0)).doSuspendableAnswer { + saved = null + Unit + } + val store = OnchainSendAttemptStore(testDispatcher, keychain, lightningService, kotlin.time.Clock.System) + sut = LightningRepo( + bgDispatcher = testDispatcher, + lightningService = lightningService, + settingsStore = settingsStore, + coreService = coreService, + lspNotificationsService = lspNotificationsService, + firebaseMessaging = firebaseMessaging, + keychain = keychain, + lnurlService = lnurlService, + cacheStore = cacheStore, + preActivityMetadataRepo = preActivityMetadataRepo, + onchainSendAttemptStore = store, + connectivityRepo = connectivityRepo, + vssBackupClientLdk = vssBackupClientLdk, + urlValidator = urlValidator, + electrumProbeService = electrumProbeService, + ) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(coinSelectAuto = false))) + whenever(coreService.activity).thenReturn(mock()) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + val prepared = PreparedOnchainSend( + OnchainPreparedReceipt(txid, listOf(OnchainSendInput("11".repeat(32), 0u)), "address", 1_000uL), + ) { throw to.bitkit.utils.ServiceError.NodeNotSetup() } + whenever(lightningService.prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), anyOrNull())) + .thenReturn(prepared) + startNodeForTesting() + val repo = spy(sut).also { doReturn(Result.success(1uL)).whenever(it).getFeeRateForSpeed(any(), anyOrNull()) } + val error = repo.sendOnChain("address", 1_000uL).exceptionOrNull() + assertNull(store.current()) + assertIs(error) + } + + @Test + fun `accepted Max result carries recorded prepared recipient amount`() = test { + val original = pendingSendAttempt().copy(isMaxAmount = true) + val repo = prepareGuardedSend(original) + val txid = "ab".repeat(32) + val prepared = PreparedOnchainSend( + OnchainPreparedReceipt(txid, listOf(OnchainSendInput("11".repeat(32), 0u)), "address", 900uL), + ) { beforeDispatch -> + beforeDispatch() + OnchainSendOutcome.Accepted(txid) + } + whenever(lightningService.prepareOnchainSend(any(), any(), any(), anyOrNull(), any(), any(), anyOrNull())) + .thenReturn(prepared) + whenever(onchainSendAttemptStore.recordOutcome(any(), any(), any())) + .thenReturn(original.copy(amountSats = 900uL, evidence = OnchainSendEvidence.Accepted, txid = txid)) + val result = repo.sendOnChain("address", 1_000uL, isMaxAmount = true).getOrThrow() + assertEquals(OnchainSendOutcome.Accepted(txid, 900uL), result) + } + private fun preparedOutcome(outcome: OnchainSendOutcome) = PreparedOnchainSend( OnchainPreparedReceipt(outcome.txid, listOf(OnchainSendInput("11".repeat(32), 0u)), "address", 1_000uL), ) { it(); outcome } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 7915d7d922..9ff85589b5 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -1713,6 +1713,73 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(1, storedProofs.size) } + @Test + @Suppress("LongMethod") // One lifecycle proves persisted refusal, reopen, guard retention and redispatch. + fun `hardware refusal navigation hint survives reopen and clears on dispatch`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val walletId = "original-hardware-wallet" + val raw = requireNotNull(javaClass.getResourceAsStream("/hardware-signed-transaction.hex")) + .bufferedReader().readText().trim() + val signed = HwFundingSignedTx(raw, 141uL, 2uL, request.amountSats + 141uL) + val txid = SignedTransactionId.fromHex(raw) + val repo = paymentProofRepo() + repo.prepare(request, MethodId.P2wpkh.rawValue, "bitkit", PaykitPaymentProofKind.Onchain).getOrThrow() + repo.markOnchainPaymentStarted(request, ONCHAIN_ADDRESS, walletId, signedTx = signed).getOrThrow() + assertTrue( + repo.markHardwareOnchainDispatch( + request.id, + walletId, + txid, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amountSats, + ) + ) + assertFalse( + repo.markHardwareRefusedForNavigation( + request.id, + walletId, + txid, + COUNTERPARTY, + ONCHAIN_ADDRESS, + request.amountSats, + ) + ) + assertTrue( + repo.markHardwareRefusedForNavigation( + request.id, + walletId, + txid, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amountSats, + ) + ) + val reopened = paymentProofRepo() + val restored = reopened.retainedHardwareOnchainPayment( + request.id, + walletId, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amountSats, + ) + assertEquals(RetainedHardwareOnchainPayment(signed, true, true), restored) + assertFalse(reopened.failHardwareOnchainPaymentBeforeDispatch(request, walletId, LOCAL_IDENTITY, true)) + assertTrue( + reopened.markHardwareOnchainDispatch( + request.id, + walletId, + txid, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amountSats, + ) + ) + assertFalse(storedProofs.single().hardwareRefusedForNavigation) + assertEquals(txid, storedProofs.single().paymentIdentifier) + assertFalse(storedProofs.single().onchainAcceptanceVerified) + } + @Test fun `prebroadcast hardware candidate survives repository reopen without claiming acceptance`() = test { val request = paymentRequest(MethodId.P2wpkh.rawValue) diff --git a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt index 3a689710b6..28be352690 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -189,6 +189,30 @@ class LightningServiceTest : BaseUnitTest() { verify(onchainPayment, never()).sendAllToAddressWithBroadcastResult(any(), any(), anyOrNull()) } + @Test + fun `initial fixed send accepts confirmed preset above recovery fee ceiling`() = test { + val txid = "ab".repeat(32) + val native = preparedNative(txid) + whenever(node.onchainPayment()).thenReturn(onchainPayment) + whenever(onchainPayment.prepareSendToAddress("address", 1_000uL, sendFeeRate, null)).thenReturn(native) + whenever(native.broadcast()).thenReturn(OnchainSendResult.Accepted(txid)) + val prepared = sut.prepareOnchainSend("address", 1_000uL, 1_000uL) + assertEquals(OnchainSendOutcome.Accepted(txid), prepared.broadcast()) + verify(native).broadcast() + } + + @Test + fun `initial Max send accepts confirmed preset above recovery fee ceiling`() = test { + val txid = "ab".repeat(32) + val native = preparedNative(txid) + whenever(node.onchainPayment()).thenReturn(onchainPayment) + whenever(onchainPayment.prepareSendAllToAddress("address", true, sendFeeRate)).thenReturn(native) + whenever(native.broadcast()).thenReturn(OnchainSendResult.Accepted(txid)) + val prepared = sut.prepareOnchainSend("address", 1_000uL, 1_000uL, isMaxAmount = true) + assertEquals(OnchainSendOutcome.Accepted(txid), prepared.broadcast()) + verify(native).broadcast() + } + @Test fun `onchain send refuses a different wallet before invoking native payment`() = test { assertFailsWith { diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt index c127c358f2..da82b077d5 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt @@ -43,6 +43,7 @@ import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.PaykitPaymentProofRepo import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PreActivityMetadataRepo +import to.bitkit.repositories.RetainedHardwareOnchainPayment import to.bitkit.services.ActivityService import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest @@ -1159,6 +1160,110 @@ class HwSendViewModelTest : BaseUnitTest() { ) } + @Test + fun `refused Shop payment stays dismissable after its deadline`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val deadline = Instant.parse("2026-10-06T12:00:00Z") + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + paymentDeadlineAt = deadline, + ) + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx, deadline)) + .thenReturn(Result.failure(BroadcastException.InvalidTransaction("bad-txns-inputs-missingorspent"))) + sut.signAndBroadcast(original) + advanceUntilIdle() + assertTrue(sut.uiState.value.canLeave) + now = deadline + 1.seconds + var expiredAttempted: Boolean? = null + sut.signAndBroadcast(original, onPaymentDeadlineExpired = { expiredAttempted = it }) + advanceUntilIdle() + assertEquals(true, expiredAttempted) + assertTrue(sut.uiState.value.canLeave) + sut.cancel() + advanceUntilIdle() + assertTrue(sut.uiState.value.canLeave) + assertTrue(sut.uiState.value.hasPendingBroadcast) + verify(hwWalletRepo, times(1)).broadcastFunding(fixture.signedTx, deadline) + } + + @Test + fun `retained Shop payment retries original bytes after display fee changes`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + ) + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)) + .thenReturn(Result.failure(BroadcastException.InvalidTransaction("bad-txns-inputs-missingorspent"))) + sut.signAndBroadcast(original) + advanceUntilIdle() + assertTrue(sut.uiState.value.canLeave) + sut.cancel() + advanceUntilIdle() + sut.signAndBroadcast(original.copy(satsPerVByte = SATS_PER_VBYTE + 1uL)) + advanceUntilIdle() + verify(hwWalletRepo, times(1)).signFunding(WALLET_ID, fixture.funding) + verify(hwWalletRepo, times(2)).broadcastFunding(fixture.signedTx) + } + + @Test + fun `restored refused Shop receipt remains dismissable after expiry without sending`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + paymentDeadlineAt = now - 1.seconds, + ) + whenever( + proofRepo.retainedHardwareOnchainPayment( + requireNotNull(original.paymentRequestId), + WALLET_ID, + original.paymentIdentity, + original.address, + original.amountSats, + ) + ).thenReturn(RetainedHardwareOnchainPayment(fixture.signedTx, true, true)) + var attempted: Boolean? = null + sut.signAndBroadcast(original, onPaymentDeadlineExpired = { attempted = it }) + advanceUntilIdle() + assertEquals(true, attempted) + assertTrue(sut.uiState.value.canLeave) + assertTrue(sut.uiState.value.hasPendingBroadcast) + verify(hwWalletRepo, never()).signFunding(any(), any()) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + } + + @Test + fun `fresh unknown retry after refusal re-arms navigation protection`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + paymentDeadlineAt = now + 1.seconds, + ) + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx, original.paymentDeadlineAt)).thenReturn( + Result.failure(BroadcastException.InvalidTransaction("invalid transaction")), + Result.failure(BroadcastException.ElectrumException("response lost after dispatch")), + ) + sut.signAndBroadcast(original) + advanceUntilIdle() + assertTrue(sut.uiState.value.canLeave) + sut.signAndBroadcast(original) + advanceUntilIdle() + assertFalse(sut.uiState.value.canLeave) + now += 2.seconds + sut.signAndBroadcast(original) + advanceUntilIdle() + assertFalse(sut.uiState.value.canLeave) + verify(hwWalletRepo, times(1)).signFunding(WALLET_ID, fixture.funding) + verify(hwWalletRepo, times(2)).broadcastFunding(fixture.signedTx, original.paymentDeadlineAt) + } + private fun signedFixtureHex() = requireNotNull(javaClass.getResourceAsStream("/hardware-signed-transaction.hex")) .bufferedReader().readText().trim() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index d73c40ddab..6e7c30c355 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9845,6 +9845,21 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } + @Test + fun `send-all Success uses accepted prepared recipient amount`() = test { + val address = "bcrt1qmaxrecipient" + val txid = "ab".repeat(32) + balanceState.value = BalanceState(maxSendOnchainSats = 1_000uL) + stubOnchainSend(address, 1_000uL, Result.success(OnchainSendOutcome.Accepted(txid, 900uL))) + setSendState(SendUiState(address = address, amount = 1_000uL, payMethod = SendMethod.ONCHAIN)) + sut.sendEffect.test { + confirmCurrentPayment() + awaitItem() + } + assertEquals(txid, sut.successSendUiState.value.paymentHashOrTxId) + assertEquals(900L, sut.successSendUiState.value.sats) + } + @Test fun `generic outer error after ordinary send remains unresolved`() = test { val address = "bcrt1qoutererror" From 45358b6115bace6301bec037e936d618a0a6a829 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 9 Oct 2026 13:03:31 +0200 Subject: [PATCH 102/108] fix: retain confirmed initial preset fees --- .../repositories/OnchainSendAttemptStore.kt | 6 +- .../OnchainSendCoordinatorTest.kt | 27 +++++- .../bitkit/services/LightningServiceTest.kt | 85 ++++++++++++++++--- 3 files changed, 103 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index 607f30d3e0..abb23cefbf 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -285,7 +285,11 @@ class OnchainSendAttemptStore @Inject constructor( } } val feeRate = receipt.feeRateSatsPerVByte ?: current.feeRateSatsPerVByte - require(OnchainRecoveryFeeRate.isValid(feeRate)) + if (isRecovery) { + require(OnchainRecoveryFeeRate.isValid(feeRate)) + } else { + require(feeRate > 0uL && feeRate <= UInt.MAX_VALUE.toULong()) + } val existingFeeRate = current.candidateFeeRates[receipt.txid.lowercase()] require(existingFeeRate == null || existingFeeRate == feeRate) current.copy( diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt index 5909039854..0940058c4d 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt @@ -633,6 +633,23 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { assertEquals(mapOf(firstTxid to 1uL, nextTxid to 3uL), reopened.current()?.candidateFeeRates) } + @Test + fun `initial retention rejects zero and rates beyond the confirmed UInt32 range`() = test { + val f = Fixture() + val original = f.admit() + for (feeRate in listOf(0uL, UInt.MAX_VALUE.toULong() + 1uL, ULong.MAX_VALUE)) { + assertFailsWith { + f.store.retainPreparedReceipt( + original.attemptId, + 0, + f.receipt().copy(feeRateSatsPerVByte = feeRate), + false, + ) + } + assertTrue(f.store.current()?.candidateTxids?.isEmpty() == true) + } + } + @Test fun `overflowing authorized recovery fee never reaches native preparation`() = test { assertEquals(999uL, OnchainRecoveryFeeRate.maximum) @@ -656,12 +673,20 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { return PreparedOnchainSend(f.receipt(nextTxid)) { it(); OnchainSendOutcome.Unknown(nextTxid) } } } - for (invalidRate in listOf(4_294_967_296uL, ULong.MAX_VALUE)) { + for (invalidRate in listOf(0uL, 1_000uL, 4_294_967_296uL, ULong.MAX_VALUE)) { val result = OnchainSendCoordinator(f.store, sender, testDispatcher) .retryOriginal(original.attemptId, original.walletId, invalidRate) {} assertTrue(result.isFailure) } assertEquals(0, preparations) + assertFailsWith { + f.store.retainPreparedReceipt( + original.attemptId, + 0, + f.receipt(nextTxid).copy(feeRateSatsPerVByte = 1_000uL), + true, + ) + } assertEquals(listOf(firstTxid), f.store.current()?.candidateTxids) } diff --git a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt index 28be352690..63934ce38c 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -30,6 +30,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.timeout @@ -48,6 +49,16 @@ import to.bitkit.models.WATCH_ONLY_ACCOUNT_HIGHEST_PRE_REVEALED_ADDRESS_INDEX import to.bitkit.models.WatchOnlyAccountRecord import to.bitkit.models.WatchOnlyAccountSetupState import to.bitkit.repositories.OnchainSendOutcome +import to.bitkit.repositories.OnchainSendAttempt +import to.bitkit.repositories.OnchainSendAttemptStore +import to.bitkit.repositories.OnchainSendCoordinator +import to.bitkit.repositories.OnchainPreparedSender +import to.bitkit.repositories.PreparedOnchainSend +import to.bitkit.repositories.OnchainTransferContext +import to.bitkit.models.ActiveOnchainAttemptBackup +import to.bitkit.models.WalletScope +import kotlinx.serialization.json.Json +import kotlin.time.Instant import to.bitkit.test.BaseUnitTest import to.bitkit.utils.LoggerLdk import to.bitkit.utils.ServiceError @@ -190,27 +201,75 @@ class LightningServiceTest : BaseUnitTest() { } @Test - fun `initial fixed send accepts confirmed preset above recovery fee ceiling`() = test { - val txid = "ab".repeat(32) - val native = preparedNative(txid) - whenever(node.onchainPayment()).thenReturn(onchainPayment) - whenever(onchainPayment.prepareSendToAddress("address", 1_000uL, sendFeeRate, null)).thenReturn(native) - whenever(native.broadcast()).thenReturn(OnchainSendResult.Accepted(txid)) - val prepared = sut.prepareOnchainSend("address", 1_000uL, 1_000uL) - assertEquals(OnchainSendOutcome.Accepted(txid), prepared.broadcast()) - verify(native).broadcast() + fun `initial fixed preset above 999 dispatches through durable receipt retention`() = test { + initialPresetThroughRetention(isMax = false, isTransfer = false) + initialPresetThroughRetention(isMax = false, isTransfer = false, feeRate = UInt.MAX_VALUE.toULong()) + } + + @Test + fun `initial send-all preset above 999 dispatches through durable receipt retention`() = test { + initialPresetThroughRetention(isMax = true, isTransfer = false) } @Test - fun `initial Max send accepts confirmed preset above recovery fee ceiling`() = test { + fun `initial Fast transfer preset above 999 dispatches through durable receipt retention`() = test { + initialPresetThroughRetention(isMax = true, isTransfer = true) + } + + private suspend fun initialPresetThroughRetention(isMax: Boolean, isTransfer: Boolean, feeRate: ULong = 1_000uL) { + var saved: String? = null + val key = Keychain.Key.ONCHAIN_SEND_ATTEMPT.name + whenever(keychain.loadString(key, 0)).thenAnswer { saved } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { saved = it.getArgument(1) } + val store = OnchainSendAttemptStore(testDispatcher, keychain, sut, kotlin.time.Clock.System) val txid = "ab".repeat(32) val native = preparedNative(txid) + val amount = if (isMax) 900uL else 1_000uL + whenever(native.recipientAmountSats()).thenReturn(amount) + whenever(native.miningFeeSats()).thenReturn(50uL) whenever(node.onchainPayment()).thenReturn(onchainPayment) + whenever(onchainPayment.prepareSendToAddress("address", 1_000uL, sendFeeRate, null)).thenReturn(native) whenever(onchainPayment.prepareSendAllToAddress("address", true, sendFeeRate)).thenReturn(native) - whenever(native.broadcast()).thenReturn(OnchainSendResult.Accepted(txid)) - val prepared = sut.prepareOnchainSend("address", 1_000uL, 1_000uL, isMaxAmount = true) - assertEquals(OnchainSendOutcome.Accepted(txid), prepared.broadcast()) + whenever(native.broadcast()).thenAnswer { + val retained = Json.decodeFromString(requireNotNull(saved)) + assertTrue(retained.initialDispatchAttempted) + assertEquals(feeRate, retained.candidateFeeRates[txid]) + assertEquals(amount, retained.amountSats) + OnchainSendResult.Accepted(txid) + } + val attempt = store.admit( + walletId = WalletScope.default, requestId = null, orderId = if (isTransfer) "order-1" else null, + address = "address", amountSats = 1_000uL, isMaxAmount = isMax, feeRateSatsPerVByte = feeRate, + isTransfer = isTransfer, channelId = null, tags = listOf("original-tag"), + transferContext = if (isTransfer) OnchainTransferContext(2_000uL, 2_000uL, 900uL, 900uL) else null, + beforeSendAttempt = {}, + ) + val sender = object : OnchainPreparedSender { + override suspend fun prepareInitial(attempt: OnchainSendAttempt) = sut.prepareOnchainSend( + attempt.address, + attempt.amountSats, + attempt.feeRateSatsPerVByte, + isMaxAmount = attempt.isMaxAmount, + ) + override suspend fun prepareRecovery( + attempt: OnchainSendAttempt, + feeRateSatsPerVByte: ULong, + paymentDeadlineAt: Instant?, + ): PreparedOnchainSend = error("unused") + } + val coordinator = OnchainSendCoordinator(store, sender, testDispatcher) + assertEquals(OnchainSendOutcome.Accepted(txid), coordinator.sendInitial(attempt).getOrThrow()) verify(native).broadcast() + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, sut, kotlin.time.Clock.System) + val recorded = requireNotNull(reopened.current()) + val wire = ActiveOnchainAttemptBackup.from(recorded, "regtest", "12".repeat(32)) + val restoredWire = Json.decodeFromString(Json.encodeToString(wire)) + val restored = restoredWire.restored("regtest", wire.wallet.binding, WalletScope.default, 0) + assertEquals(feeRate, restored.feeRateSatsPerVByte) + assertEquals(mapOf(txid to feeRate), restored.candidateFeeRates) + assertEquals(recorded.originalInputs, restored.originalInputs) + assertEquals(amount, restored.amountSats) + assertEquals(recorded.transferContext, restored.transferContext) } @Test From a11b479155aaeec84c113b88e4be2067b2fc4216 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 9 Oct 2026 13:15:57 +0200 Subject: [PATCH 103/108] fix: preserve retained hardware payment retry navigation --- .../screens/wallets/send/HwSendViewModel.kt | 8 +- .../wallets/send/HwSendViewModelTest.kt | 133 ++++++++++++++++++ 2 files changed, 138 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt index e8f3a96805..427ad1a4e3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt @@ -479,7 +479,7 @@ class HwSendViewModel @Inject constructor( } == true // This changes navigation only: a protocol refusal never releases the durable wallet guard. val refused = retainedShopPayment && error.isHardwareBroadcastRefusalForNavigation() - pendingBroadcast = pendingBroadcast?.copy(isRefusedForNavigation = refused) + if (refused) pendingBroadcast = pendingBroadcast?.copy(isRefusedForNavigation = true) if (refused) { val payment = requireNotNull(pendingBroadcast) val request = payment.request @@ -494,7 +494,9 @@ class HwSendViewModel @Inject constructor( ) }.onFailure { Logger.warn("Failed to save hardware refusal navigation state", it, context = TAG) } } - _uiState.update { it.copy(isBroadcastUnresolved = retainedShopPayment && !refused) } + _uiState.update { + it.copy(isBroadcastUnresolved = retainedShopPayment && pendingBroadcast?.blocksNavigation == true) + } when { error.isHwUserCancellation() -> { Logger.info("Hardware send cancelled on device for '$walletId'", context = TAG) @@ -594,5 +596,5 @@ private data class PendingHwSendBroadcast( fun matches(request: HwSendRequest): Boolean = this.request == request || hasAttemptedBroadcast && this.request.paymentRequestId != null && - this.request.copy(satsPerVByte = request.satsPerVByte) == request + this.request.copy(satsPerVByte = request.satsPerVByte, tags = request.tags) == request } diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt index da82b077d5..024108eab6 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt @@ -1209,6 +1209,139 @@ class HwSendViewModelTest : BaseUnitTest() { verify(hwWalletRepo, times(2)).broadcastFunding(fixture.signedTx) } + @Test + fun `retained Shop payment retries original bytes after tags change`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + tags = listOf("original-tag"), + ) + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)).thenReturn( + Result.failure(BroadcastException.InvalidTransaction("bad-txns-inputs-missingorspent")), + Result.failure(BroadcastException.ElectrumException("response lost after dispatch")), + ) + sut.signAndBroadcast(original) + advanceUntilIdle() + assertTrue(sut.uiState.value.canLeave) + sut.cancel() + advanceUntilIdle() + sut.signAndBroadcast(original.copy(tags = listOf("new-display-tag"))) + advanceUntilIdle() + verify(hwWalletRepo, times(1)).signFunding(WALLET_ID, fixture.funding) + verify(hwWalletRepo, times(2)).broadcastFunding(fixture.signedTx) + verify(proofRepo, times(2)).retainHardwareOnchainCandidate( + requireNotNull(original.paymentRequestId), + original.walletId, + SignedTransactionId.fromHex(fixture.signedTx.serializedTx), + original.paymentIdentity, + original.address, + original.amountSats, + fixture.signedTx, + ) + assertTrue(sut.uiState.value.hasPendingBroadcast) + assertFalse(sut.uiState.value.canLeave) + } + + @Test + fun `pre-dispatch retry marker denial after refusal keeps the sheet dismissable`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + ) + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)) + .thenReturn(Result.failure(BroadcastException.InvalidTransaction("bad-txns-inputs-missingorspent"))) + sut.signAndBroadcast(original) + advanceUntilIdle() + assertTrue(sut.uiState.value.canLeave) + whenever( + proofRepo.markHardwareOnchainDispatch( + any(), + any(), + any(), + org.mockito.kotlin.anyOrNull(), + any(), + any(), + ) + ).thenReturn(false) + sut.signAndBroadcast(original) + advanceUntilIdle() + verify(hwWalletRepo, times(1)).broadcastFunding(fixture.signedTx) + verify(hwWalletRepo, times(1)).signFunding(WALLET_ID, fixture.funding) + assertTrue(sut.uiState.value.canLeave) + assertTrue(sut.uiState.value.hasPendingBroadcast) + verify(proofRepo, never()).clearHardwareOnchainCandidateBeforeDispatch( + any(), + any(), + any(), + org.mockito.kotlin.anyOrNull(), + any(), + any(), + any(), + ) + } + + @Test + fun `retry setup failure before boundary after refusal keeps the sheet dismissable`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + ) + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)) + .thenReturn(Result.failure(BroadcastException.InvalidTransaction("bad-txns-inputs-missingorspent"))) + sut.signAndBroadcast(original) + advanceUntilIdle() + assertTrue(sut.uiState.value.canLeave) + whenever(hwWalletRepo.broadcastFundingAtBoundary(any(), org.mockito.kotlin.anyOrNull(), any())) + .thenReturn(Result.failure(IllegalStateException("device setup failed before callback"))) + sut.signAndBroadcast(original) + advanceUntilIdle() + verify(hwWalletRepo, times(1)).broadcastFunding(fixture.signedTx) + verify(hwWalletRepo, times(1)).signFunding(WALLET_ID, fixture.funding) + verify(proofRepo, times(1)).markHardwareOnchainDispatch( + any(), + any(), + any(), + org.mockito.kotlin.anyOrNull(), + any(), + any(), + ) + assertTrue(sut.uiState.value.canLeave) + assertTrue(sut.uiState.value.hasPendingBroadcast) + } + + @Test + fun `retained Shop retry still rejects changed payment identity or recipient`() = test { + whenever(context.getString(any())).thenReturn("message") + val fixture = stubSuccessfulPayment() + val original = request().copy( + paymentRequestId = PaykitPaymentRequestId("request", "counterparty"), + paymentIdentity = "original-identity", + ) + whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)) + .thenReturn(Result.failure(BroadcastException.InvalidTransaction("bad-txns-inputs-missingorspent"))) + sut.signAndBroadcast(original) + advanceUntilIdle() + for (changed in listOf( + original.copy(paymentIdentity = "different-payer"), + original.copy(paymentRequestId = PaykitPaymentRequestId("different-request", "counterparty")), + original.copy(address = "different-recipient"), + original.copy(amountSats = original.amountSats + 1uL), + )) { + sut.signAndBroadcast(changed) + advanceUntilIdle() + assertTrue(sut.uiState.value.canLeave) + assertTrue(sut.uiState.value.hasPendingBroadcast) + } + verify(hwWalletRepo, times(1)).signFunding(WALLET_ID, fixture.funding) + verify(hwWalletRepo, times(1)).broadcastFunding(fixture.signedTx) + } + @Test fun `restored refused Shop receipt remains dismissable after expiry without sending`() = test { whenever(context.getString(any())).thenReturn("message") From 65588274c65e382cba4cf3a4b1d279bb67d068d7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 9 Oct 2026 15:03:16 +0200 Subject: [PATCH 104/108] fix: recognize hardware payment refusals --- .../to/bitkit/ext/BroadcastExceptionExt.kt | 21 +++++- .../bitkit/ext/BroadcastExceptionExtTest.kt | 70 +++++++++++++++++++ .../wallets/send/HwSendViewModelTest.kt | 23 +++++- 3 files changed, 110 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/BroadcastExceptionExt.kt b/app/src/main/java/to/bitkit/ext/BroadcastExceptionExt.kt index f81fcb95da..076410ed36 100644 --- a/app/src/main/java/to/bitkit/ext/BroadcastExceptionExt.kt +++ b/app/src/main/java/to/bitkit/ext/BroadcastExceptionExt.kt @@ -3,9 +3,9 @@ package to.bitkit.ext import com.synonym.bitkitcore.BroadcastException import kotlinx.coroutines.TimeoutCancellationException import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive fun Throwable.isBroadcastConnectivityFailure(): Boolean = generateSequence(this) { it.cause }.any { @@ -34,16 +34,31 @@ fun Throwable.isHardwareBroadcastRefusalForNavigation(): Boolean = } private fun refusalMessageFromJson(payload: String): String? = runCatching { - Json.parseToJsonElement(payload).jsonObject["message"]?.jsonPrimitive?.contentOrNull + when (val element = Json.parseToJsonElement(payload)) { + is JsonPrimitive -> element.takeIf { it.isString }?.contentOrNull + is JsonObject -> (element["message"] as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull + else -> null + } }.getOrNull() private fun String.isKnownHardwareRefusal(): Boolean { val rpcPrefix = "sendrawtransaction RPC error: " val message = if (startsWith(rpcPrefix, ignoreCase = true)) { refusalMessageFromJson(drop(rpcPrefix.length)) ?: return false + } else if (startsWith("sendrawtransaction RPC error ", ignoreCase = true)) { + val coded = drop("sendrawtransaction RPC error ".length) + val code = coded.substringBefore(": ") + if (code !in listOf("-25", "-26") || !coded.startsWith("$code: ")) return false + coded.drop(code.length + 2) } else { this } + // Match the complete Bitcoin Core replacement refusal; unknown suffixes remain guarded. + val replacementRefusal = Regex( + """insufficient fee, rejecting replacement [0-9a-f]{64}; new feerate """ + + """[0-9]+\.[0-9]{8} btc/kvb <= old feerate [0-9]+\.[0-9]{8} btc/kvb""", + ) + if (replacementRefusal.matches(message.lowercase())) return true val reasons = listOf( "min relay fee not met", "mempool min fee not met", diff --git a/app/src/test/java/to/bitkit/ext/BroadcastExceptionExtTest.kt b/app/src/test/java/to/bitkit/ext/BroadcastExceptionExtTest.kt index 1ecbbb9d2b..9ab2bf9118 100644 --- a/app/src/test/java/to/bitkit/ext/BroadcastExceptionExtTest.kt +++ b/app/src/test/java/to/bitkit/ext/BroadcastExceptionExtTest.kt @@ -109,4 +109,74 @@ class BroadcastExceptionExtTest { assertFalse(BroadcastException.ElectrumException(payload).isHardwareBroadcastRefusalForNavigation()) } } + + @Test + fun `reported missing inputs JSON string coded RPC refusal allows navigation only`() { + assertReportedRefusal( + """Broadcast failed: Electrum server error: "sendrawtransaction RPC error -25: """ + + """bad-txns-inputs-missingorspent"""", + ) + } + + @Test + fun `reported replacement fee JSON string coded RPC refusal allows navigation only`() { + assertReportedRefusal( + """Broadcast failed: Electrum server error: "sendrawtransaction RPC error -26: """ + + """insufficient fee, rejecting replacement """ + + """b3f63e62aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; new feerate """ + + """0.00001000 BTC/kvB <= old feerate 0.00001018 BTC/kvB"""", + ) + } + + private fun assertReportedRefusal(payload: String) { + val error = BroadcastException.ElectrumException(payload) + assertTrue(error.isHardwareBroadcastRefusalForNavigation(), payload) + assertFalse(error.isDefiniteHardwarePreBroadcastFailure()) + } + + @Test + fun `coded RPC unknown malformed and replacement lookalikes stay guarded`() { + val messages = listOf( + "sendrawtransaction RPC error -25: disconnected", + "sendrawtransaction RPC error unknown: bad-txns-inputs-missingorspent", + "sendrawtransaction RPC error -25 bad-txns-inputs-missingorspent", + "sendrawtransaction RPC error -25: bad-txns-inputs-missingorspentish", + "sendrawtransaction RPC error -26: insufficient fee information unavailable", + "sendrawtransaction RPC error -26: insufficient fee, rejecting replacementish", + "sendrawtransaction RPC error -26: unknown result containing insufficient fee, rejecting replacement tx", + ) + for (message in messages) { + val payload = kotlinx.serialization.json.JsonPrimitive(message) + val error = BroadcastException.ElectrumException("Broadcast failed: Electrum server error: $payload") + assertFalse(error.isHardwareBroadcastRefusalForNavigation(), message) + } + val malformed = "Broadcast failed: Electrum server error: \"sendrawtransaction RPC error -25: " + + "bad-txns-inputs-missingorspent" + assertFalse(BroadcastException.ElectrumException(malformed).isHardwareBroadcastRefusalForNavigation()) + } + + @Test + fun `replacement refusal requires complete txid and exact feerate grammar`() { + val txid = "b3f63e62" + "a".repeat(56) + val known = "insufficient fee, rejecting replacement $txid; " + + "new feerate 0.00001000 BTC/kvB <= old feerate 0.00001018 BTC/kvB" + val messages = listOf( + known.replace(txid, txid.dropLast(1)), + known.replace(txid, "g" + txid.drop(1)), + known.replace(txid, "b3f63e62…"), + known.replace("0.00001000", "0.0000100"), + known.replace("0.00001018", "0.000010180"), + known.replace("0.00001000", "-0.00001000"), + known.replace("BTC/kvB", "sats/vB"), + known.replace("<=", "<"), + "$known disconnected", + "$known\nunknown result", + "insufficient fee, rejecting replacement disconnected", + ) + for (message in messages) { + val payload = kotlinx.serialization.json.JsonPrimitive("sendrawtransaction RPC error -26: $message") + val error = BroadcastException.ElectrumException("Broadcast failed: Electrum server error: $payload") + assertFalse(error.isHardwareBroadcastRefusalForNavigation(), message) + } + } } diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt index 024108eab6..c71d629d9e 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt @@ -410,7 +410,20 @@ class HwSendViewModelTest : BaseUnitTest() { paymentIdentity = "original-identity", ) whenever(hwWalletRepo.broadcastFunding(fixture.signedTx)).thenReturn( - Result.failure(BroadcastException.ElectrumException("broadcast failed: min relay fee not met")), + Result.failure( + BroadcastException.ElectrumException( + """Broadcast failed: Electrum server error: "sendrawtransaction RPC error -26: """ + + """insufficient fee, rejecting replacement """ + + """b3f63e62aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; new feerate """ + + """0.00001000 BTC/kvB <= old feerate 0.00001018 BTC/kvB"""", + ), + ), + Result.failure( + BroadcastException.ElectrumException( + """Broadcast failed: Electrum server error: "sendrawtransaction RPC error -25: """ + + """bad-txns-inputs-missingorspent"""", + ), + ), ) sut.signAndBroadcast(original) advanceUntilIdle() @@ -443,6 +456,14 @@ class HwSendViewModelTest : BaseUnitTest() { ) assertTrue(sut.uiState.value.canLeave) assertTrue(sut.uiState.value.hasPendingBroadcast) + verify(proofRepo, times(2)).markHardwareRefusedForNavigation( + requireNotNull(original.paymentRequestId), + WALLET_ID, + SignedTransactionId.fromHex(fixture.signedTx.serializedTx), + original.paymentIdentity, + original.address, + original.amountSats, + ) } @Test From 37e4405b8c8cf8703eabf29f55b62e74dfa550b6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 9 Oct 2026 15:30:25 +0200 Subject: [PATCH 105/108] fix: reopen retained hardware payments --- .../repositories/PaykitPaymentProofRepo.kt | 43 +++++++++-- .../repositories/PaykitPaymentRequestRepo.kt | 17 ++++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 59 +++++++++++++++ .../PaykitPaymentProofRepoTest.kt | 60 +++++++++++++++ .../PaykitPaymentRequestRepoTest.kt | 75 +++++++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 54 +++++++++++++ 6 files changed, 299 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 7d940aa571..2d6983e556 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -132,6 +132,8 @@ class PaykitPaymentProofRepo @Inject constructor( identity = publicKey, completedProofKinds = proofs.filter { it.proofData != null }.associate { it.requestId to it.kind }, inFlightRequestIds = proofs.filter { it.paymentStarted }.mapTo(mutableSetOf()) { it.requestId }, + reopenableHardwareRequestIds = proofs.filter { it.isReopenableHardwareReceipt(publicKey) } + .mapTo(mutableSetOf()) { it.requestId }, ) } } @@ -144,6 +146,7 @@ class PaykitPaymentProofRepo @Inject constructor( val identity: String, val completedProofKinds: Map, val inFlightRequestIds: Set, + val reopenableHardwareRequestIds: Set, ) suspend fun backupSnapshot(): List = withContext(ioDispatcher) { @@ -655,6 +658,16 @@ class PaykitPaymentProofRepo @Inject constructor( } } + suspend fun retainedHardwareOnchainRequest(request: PaykitPaymentRequest): PendingPaykitPaymentProof? = + withContext(ioDispatcher) { + operationMutex.withLock { + val identity = currentIdentity() ?: return@withLock null + loadProofs().singleOrNull { + it.isReopenableHardwareReceipt(identity) && it.matchesHardwareRetryRequest(request) + } + } + } + suspend fun retainedHardwareOnchainPayment( requestId: PaykitPaymentRequestId, walletId: String, @@ -698,14 +711,6 @@ class PaykitPaymentProofRepo @Inject constructor( } } - private fun PendingPaykitPaymentProof.retainedSignedHardwareReceipt(): HwFundingSignedTx? { - val raw = hardwareSignedTransaction ?: return null - val txid = runCatching { SignedTransactionId.fromHex(raw) }.getOrNull() ?: return null - if (!txid.equals(paymentIdentifier, true)) return null - if (hardwareMiningFeeSats == null || hardwareFeeRate == null || hardwareTotalSpent == null) return null - return HwFundingSignedTx(raw, hardwareMiningFeeSats, hardwareFeeRate, hardwareTotalSpent) - } - suspend fun clearHardwareOnchainCandidateBeforeDispatch( requestId: PaykitPaymentRequestId, walletId: String, @@ -1372,3 +1377,25 @@ private fun String.matchesPaymentHash(paymentHash: String): Boolean { private fun String.isHex(byteCount: Int): Boolean = hexBytes()?.size == byteCount private fun String.hexBytes(): ByteArray? = runCatching { fromHex() }.getOrNull() + +internal fun PendingPaykitPaymentProof.retainedSignedHardwareReceipt(): HwFundingSignedTx? { + val raw = hardwareSignedTransaction ?: return null + val txid = runCatching { SignedTransactionId.fromHex(raw) }.getOrNull() ?: return null + if (!txid.equals(paymentIdentifier, true)) return null + if (hardwareMiningFeeSats == null || hardwareFeeRate == null || hardwareTotalSpent == null) return null + return HwFundingSignedTx(raw, hardwareMiningFeeSats, hardwareFeeRate, hardwareTotalSpent) +} + +@Suppress("CyclomaticComplexMethod") +internal fun PendingPaykitPaymentProof.isReopenableHardwareReceipt(identity: String): Boolean = + PubkyPublicKeyFormat.matches(this.identity, identity) && kind == PaykitPaymentProofKind.Onchain && + paymentStarted && onchainWalletId != WalletScope.default && hardwareDispatchAttempted == true && + hardwareRefusedForNavigation && !hardwareDispatchDenied && !onchainAcceptanceVerified && proofData == null && + billingPeriod == null && !onchainAddress.isNullOrBlank() && onchainAmountSats != null && + hardwareFeeRate != null && hardwareFeeRate in 1uL..UInt.MAX_VALUE.toULong() && + hardwareMiningFeeSats != null && hardwareMiningFeeSats <= Long.MAX_VALUE.toULong() && + retainedSignedHardwareReceipt() != null + +internal fun PendingPaykitPaymentProof.matchesHardwareRetryRequest(request: PaykitPaymentRequest): Boolean = + requestId == request.id && billingPeriod == request.billingPeriod && onchainAmountSats == request.amountSats && + paymentEndpointIdentifier in request.acceptedPaymentEndpointIdentifiers diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index c993539ef4..bcef5184c7 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -1168,6 +1168,9 @@ class PaykitPaymentRequestRepo @Inject constructor( val locallyInFlightRequestIds = expectedIdentity ?.let(paymentProofStore::inFlightRequestIds) .orEmpty() + val reopenableHardwareProofs = expectedIdentity?.let { identity -> + paymentProofStore.load().filter { it.isReopenableHardwareReceipt(identity) } + }.orEmpty() val allSubscriptions = records.mapNotNull(PaymentRequestRecord::toPaykitSubscription) .map { it.withExpiredLifecycle(subscriptionNow) } val blockedSubscriptionIds = allSubscriptions.mapNotNull { subscription -> @@ -1245,7 +1248,12 @@ class PaykitPaymentRequestRepo @Inject constructor( } } }.filter { - isLocallyPayable(it) && it.id !in locallyCompletedRequestIds && it.id !in locallyInFlightRequestIds + isLocallyPayable(it) && it.id !in locallyCompletedRequestIds && + ( + it.id !in locallyInFlightRequestIds || reopenableHardwareProofs.any { proof -> + proof.matchesHardwareRetryRequest(it) + } + ) } val incoming = (dueRequests + oneTimeIncoming).sortedBy { it.createdAt } val oneTimeHistory = records.mapNotNull { it.toPaykitPaymentRequestHistory(now) }.map { request -> @@ -1423,6 +1431,13 @@ class PaykitPaymentRequestRepo @Inject constructor( val current = _pendingRequests.value.firstOrNull { it.id == request.id } ?: throw PaykitPaymentRequestError.RequestUnavailable + if (resultingState in listOf( + PaymentRequestLifecycleState.CANCELED, + PaymentRequestLifecycleState.REJECTED, + ) && activeIdentity?.let { current.id in paymentProofStore.inFlightRequestIds(it) } == true + ) { + throw PaykitPaymentRequestError.OperationInProgress + } actionVersion++ operation(current) if (resultingState != PaymentRequestLifecycleState.ACCEPTED) processPendingMessages() diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index b2a27d02d6..ba80b2e234 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1315,6 +1315,7 @@ class AppViewModel @Inject constructor( } if (!ownsPaymentRequestPreparation(preparation)) return true } + if (reopenRetainedHardwarePayment(request, generation, preparation)) return true val presentationResult = privatePaykitRepo.beginPaymentRequest(request) return finishIncomingPaymentRequestPreparation(request, generation, preparation, presentationResult) } finally { @@ -1326,6 +1327,64 @@ class AppViewModel @Inject constructor( } } + @Suppress("ReturnCount") // Keep presentation and identity guards as early exits. + private suspend fun reopenRetainedHardwarePayment( + request: PaykitPaymentRequest, + generation: Long, + preparation: PaymentRequestPreparation?, + ): Boolean { + val retained = runSuspendCatching { paykitPaymentProofRepo.retainedHardwareOnchainRequest(request) } + retained.exceptionOrNull()?.let { + toast(it) + return true + } + val proof = retained.getOrNull() ?: return false + val identity = proof.identity + if (!isCurrentPaymentRequestPresentation(request, generation, preparation) || + !PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value) + ) { + return true + } + paykitPaymentRequestRepo.ensurePaymentAllowed(request).exceptionOrNull()?.let { + toast(it) + return true + } + resetSendState( + isPaymentRequest = true, + paymentRequestNote = request.note, + hardwareWalletId = proof.onchainWalletId, + incomingPaymentRequestId = request.id, + selectedTags = requestedPaymentRequestTags, + ) + if (!isCurrentPaymentRequestPresentation(request, generation, preparation) || + !PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value) + ) { + return true + } + // The retained receipt authorizes only reopening the same signed payment, not preparing another spend. + setActiveContactPaymentContext( + ContactPaymentContext(publicKey = request.counterparty, incomingPaymentRequest = request), + ) + _sendUiState.update { + it.copy( + contactPaymentProfile = activeContactPaymentProfile(), + address = requireNotNull(proof.onchainAddress), + addressInput = requireNotNull(proof.onchainAddress), + isAddressInputValid = true, + amount = requireNotNull(proof.onchainAmountSats), + isAmountInputValid = true, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Custom(requireNotNull(proof.hardwareFeeRate).toUInt()), + onchainFeeUi = OnchainFeeUi( + rate = FeeRate.CUSTOM, + sats = requireNotNull(proof.hardwareMiningFeeSats).toLong(), + ), + ) + } + showSheet(Sheet.Send(SendRoute.Confirm, hardwareWalletId = proof.onchainWalletId)) + return true + } + private fun finishIncomingPaymentRequestPreparation( request: PaykitPaymentRequest, generation: Long, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index 9ff85589b5..05d3c032d2 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -2041,6 +2041,66 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { runCurrent() } + @Test + fun `retained refused hardware reopen preserves exact receipt and blocks new preparation`() = test { + val request = paymentRequest(MethodId.P2wpkh.rawValue) + val raw = requireNotNull(javaClass.getResourceAsStream("/hardware-signed-transaction.hex")) + .bufferedReader().use { it.readText().trim() } + val proof = PendingPaykitPaymentProof( + identity = LOCAL_IDENTITY, + requestId = request.id, + paymentEndpointIdentifier = MethodId.P2wpkh.rawValue, + paymentAppId = "bitkit", + kind = PaykitPaymentProofKind.Onchain, + paymentStarted = true, + paymentIdentifier = SignedTransactionId.fromHex(raw), + onchainAddress = ONCHAIN_ADDRESS, + onchainAmountSats = request.amountSats, + onchainWalletId = "trezor:original", + hardwareDispatchAttempted = true, + hardwareRefusedForNavigation = true, + hardwareSignedTransaction = raw, + hardwareMiningFeeSats = 1_000uL, + hardwareFeeRate = 2uL, + hardwareTotalSpent = 2_000uL, + ) + storedProofs = listOf(proof) + val reopened = paymentProofRepo() + assertEquals(proof, reopened.retainedHardwareOnchainRequest(request)) + assertEquals( + raw, + reopened.retainedHardwareOnchainPayment( + request.id, + proof.onchainWalletId, + LOCAL_IDENTITY, + ONCHAIN_ADDRESS, + request.amountSats, + )?.signedTx?.serializedTx, + ) + assertNull(reopened.retainedHardwareOnchainRequest(request.copy(amountSats = request.amountSats + 1uL))) + assertNull(reopened.retainedHardwareOnchainRequest(request.copy(paymentRequestId = "foreign-request"))) + assertTrue(reopened.hasRetainedHardwareOnchainPayment(proof.onchainWalletId)) + assertEquals( + PaykitPaymentRequestError.OperationInProgress, + reopened.prepare( + request, + MethodId.P2wpkh.rawValue, + "bitkit", + PaykitPaymentProofKind.Onchain, + ).exceptionOrNull(), + ) + storedProofs = listOf(proof.copy(hardwareRefusedForNavigation = false)) + assertNull(reopened.retainedHardwareOnchainRequest(request)) + assertTrue(reopened.hasRetainedHardwareOnchainPayment(proof.onchainWalletId)) + storedProofs = listOf(proof) + whenever(paykitSdkService.identityStatus()) + .thenReturn(IdentityStatus(COUNTERPARTY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE)) + assertNull(reopened.retainedHardwareOnchainRequest(request)) + assertEquals(listOf(proof), storedProofs) + verify(hwWalletRepo, never()).broadcastFunding(any(), org.mockito.kotlin.anyOrNull()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any(), isNull()) + } + private fun paymentProofRepo(store: PaykitPaymentProofStore = this.store) = PaykitPaymentProofRepo( ioDispatcher = testDispatcher, paykitSdkService = paykitSdkService, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index cb40b5cf41..e109cd02e1 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -292,6 +292,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { ).thenReturn(PaykitSubscriptionPresentationState()) whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)).thenReturn(emptyMap()) whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(emptySet()) + whenever(paymentProofStore.load()).thenReturn(emptyList()) whenever(paymentProofStore.backupStateVersion).thenReturn(proofStateVersion) whenever(paymentProofRepo.protectedRequestIdsForSubscriptionCancellation(any(), any())) .thenReturn(Result.success(emptySet())) @@ -2497,6 +2498,80 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { return PaykitPaymentRequestTarget(COUNTERPARTY) } + @Test + @Suppress("LongMethod") // Keep the reload, original receipt and guarded variants in one regression. + fun `refused retained hardware request remains reopenable after proof store reload`() = test { + restoreAcceptedRequest() + val record = paymentRequestRecord( + state = PaymentRequestLifecycleState.ACCEPTED, + amount = "0.00001000", + endpoints = listOf(MethodId.P2wpkh.rawValue), + ) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + val requestId = PaykitPaymentRequestId(record.paymentRequestId, record.counterparty) + val raw = requireNotNull(javaClass.getResourceAsStream("/hardware-signed-transaction.hex")) + .bufferedReader().use { it.readText().trim() } + val proof = PendingPaykitPaymentProof( + identity = LOCAL_IDENTITY, + requestId = requestId, + paymentEndpointIdentifier = MethodId.P2wpkh.rawValue, + paymentAppId = "bitkit", + kind = PaykitPaymentProofKind.Onchain, + paymentStarted = true, + paymentIdentifier = to.bitkit.utils.SignedTransactionId.fromHex(raw), + onchainAddress = "original-address", + onchainAmountSats = 1_000uL, + onchainWalletId = "trezor:original", + hardwareDispatchAttempted = true, + hardwareRefusedForNavigation = true, + hardwareSignedTransaction = raw, + hardwareMiningFeeSats = 1_000uL, + hardwareFeeRate = 2uL, + hardwareTotalSpent = 2_000uL, + ) + var saved: String? = null + val keychain = mock() + whenever(keychain.loadString(any())).thenAnswer { saved } + whenever(keychain.upsertString(any(), any())).doSuspendableAnswer { saved = it.getArgument(1) } + PaykitPaymentProofStore(keychain).save(listOf(proof)) + val reopened = PaykitPaymentProofStore(keychain) + whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)) + .thenAnswer { reopened.inFlightRequestIds(LOCAL_IDENTITY) } + whenever(paymentProofStore.load()).thenAnswer { reopened.load() } + + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() + + assertEquals(requestId, sut.pendingRequests.value.single().id) + sut.ensurePaymentAllowed(sut.pendingRequests.value.single()).getOrThrow() + assertEquals(listOf(proof), reopened.load()) + assertEquals(setOf(requestId), reopened.inFlightRequestIds(LOCAL_IDENTITY)) + assertTrue(reopened.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY).isEmpty()) + assertEquals( + PaykitPaymentRequestError.OperationInProgress, + sut.dismiss(sut.pendingRequests.value.single()).exceptionOrNull(), + ) + verify(paykitSdkService, never()).cancelPaymentRequest(any(), any(), anyOrNull()) + verify(paykitSdkService, never()).rejectPaymentRequest(any(), any(), anyOrNull()) + verify(paykitSdkService, never()).acceptPaymentRequest(any(), any()) + val guarded = listOf( + proof.copy(hardwareRefusedForNavigation = false), + proof.copy(hardwareDispatchDenied = true), + proof.copy(onchainAcceptanceVerified = true), + proof.copy(hardwareSignedTransaction = null), + proof.copy(paymentIdentifier = "00".repeat(32)), + proof.copy(onchainAmountSats = 1_001uL), + proof.copy(onchainWalletId = to.bitkit.models.WalletScope.default), + ) + for (candidate in guarded) { + reopened.save(listOf(candidate)) + proofStateVersion.value += 1 + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() + assertTrue(sut.pendingRequests.value.isEmpty()) + assertEquals(listOf(candidate), reopened.load()) + assertEquals(setOf(requestId), reopened.inFlightRequestIds(LOCAL_IDENTITY)) + } + } + private suspend fun restoreAcceptedRequest() { whenever(presentationStore.loadAcceptedOneTimeIds(LOCAL_IDENTITY)).thenReturn( setOf(PaykitPaymentRequestId(PAYMENT_REQUEST_ID, COUNTERPARTY)), diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 6e7c30c355..16fea3ce50 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1468,6 +1468,60 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).beginPaymentRequest(nextRequest) } + @Test + fun `reopened refused hardware request uses original receipt without new payment preparation`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest().copy( + lifecycleState = PaymentRequestLifecycleState.ACCEPTED, + amountSats = 1_000uL, + amountValue = "0.00001000", + ) + val signed = hardwareSignedReceipt() + val proof = to.bitkit.repositories.PendingPaykitPaymentProof( + identity = testPublicKey, + requestId = request.id, + paymentEndpointIdentifier = MethodId.P2wpkh.rawValue, + paymentAppId = "bitkit", + kind = PaykitPaymentProofKind.Onchain, + paymentStarted = true, + paymentIdentifier = to.bitkit.utils.SignedTransactionId.fromHex(signed.serializedTx), + onchainAddress = REGTEST_ADDRESS, + onchainAmountSats = request.amountSats, + onchainWalletId = HARDWARE_WALLET_ID, + hardwareDispatchAttempted = true, + hardwareRefusedForNavigation = true, + hardwareSignedTransaction = signed.serializedTx, + hardwareMiningFeeSats = signed.miningFeeSats, + hardwareFeeRate = signed.feeRate, + hardwareTotalSpent = signed.totalSpent, + ) + whenever(paykitPaymentProofRepo.retainedHardwareOnchainRequest(request)).thenReturn(proof) + whenever(privatePaykitRepo.beginPaymentRequest(request)) + .thenReturn(Result.failure(PaykitPaymentRequestError.OperationInProgress)) + hwWallets.value = persistentListOf(hardwareWallet(fundingBalanceSats = 0uL)) + pendingPaykitPaymentRequests.value = listOf(request) + surfacedPaykitPaymentRequestIds += request.id + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + sut.showPaymentRequests() + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + + assertEquals(HARDWARE_WALLET_ID, sut.sendUiState.value.hardwareWalletId) + assertEquals(REGTEST_ADDRESS, sut.sendUiState.value.address) + assertEquals(request.amountSats, sut.sendUiState.value.amount) + assertTrue(sut.sendUiState.value.isAmountInputValid) + assertFalse(sut.sendUiState.value.canSwitchFundingSource) + assertEquals(request.id, sut.sendUiState.value.incomingPaymentRequestId) + assertEquals(signed.miningFeeSats.toLong(), sut.sendUiState.value.onchainFeeUi.sats) + verify(privatePaykitRepo, never()).beginPaymentRequest(request) + verify(paykitPaymentRequestRepo, never()).accept(any()) + verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any()) + verify(hwWalletRepo, never()).signFunding(any(), any()) + } + @Test fun `opened request passes its note to the confirm sheet`() = test { sut.setIsAuthenticated(true) From b33dc941af997c33c077e155d99746ec24aca501 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 9 Oct 2026 17:07:12 +0200 Subject: [PATCH 106/108] fix: preserve original hardware retry confirmation --- .../repositories/PaykitPaymentRequestRepo.kt | 14 +- .../IncomingPaymentRequestDetailsScreen.kt | 3 +- .../paymentrequests/PaymentRequestsScreen.kt | 4 +- .../screens/wallets/send/SendConfirmScreen.kt | 6 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 15 ++- ...aykitPaymentRequestRepoSubscriptionTest.kt | 3 + .../PaykitPaymentRequestRepoTest.kt | 4 + .../viewmodels/AppViewModelSendFlowTest.kt | 126 ++++++++++++------ 8 files changed, 129 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index bcef5184c7..bc24602548 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -94,6 +94,7 @@ data class PaykitPaymentRequest( val billingPeriod: PaykitBillingPeriod? = null, val paymentProofKind: PaykitPaymentProofKind? = null, val paymentDeadlineAt: Instant? = null, + val isPaymentInFlight: Boolean = false, ) { enum class ParseFailure( val logValue: String, @@ -123,6 +124,9 @@ data class PaykitPaymentRequest( billingPeriod?.startsAt?.toString(), ) + val canDismiss: Boolean + get() = !isPaymentInFlight + val requiresAcceptance: Boolean get() = billingPeriod == null && lifecycleState == PaymentRequestLifecycleState.PROPOSED @@ -1255,7 +1259,9 @@ class PaykitPaymentRequestRepo @Inject constructor( } ) } - val incoming = (dueRequests + oneTimeIncoming).sortedBy { it.createdAt } + val incoming = (dueRequests + oneTimeIncoming) + .map { it.copy(isPaymentInFlight = it.id in locallyInFlightRequestIds) } + .sortedBy { it.createdAt } val oneTimeHistory = records.mapNotNull { it.toPaykitPaymentRequestHistory(now) }.map { request -> val proofKind = locallyCompletedProofKinds[request.id] ?: return@map request request.copy( @@ -1264,6 +1270,12 @@ class PaykitPaymentRequestRepo @Inject constructor( ) } val history = (recurringHistory + oneTimeHistory) + .map { + it.copy( + isPaymentInFlight = it.direction == PaykitPaymentRequestDirection.Incoming && + it.id in locallyInFlightRequestIds, + ) + } .sortedByDescending { it.createdAt } if (!isCurrentState(generation, expectedIdentity) || expectedIdentity == null) return pruneAcceptedOneTimeRequestIds(records, expectedIdentity, generation) diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt index 172131cc33..46411d7fa6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt @@ -231,9 +231,10 @@ internal fun IncomingPaymentRequestDetailsContent( ) { SecondaryButton( text = stringResource(R.string.wallet__payment_request_dismiss), - enabled = !isDismissing, + enabled = !isDismissing && request.canDismiss, isLoading = isDismissing, onClick = { + if (!request.canDismiss) return@SecondaryButton val dismiss = onDismiss ?: return@SecondaryButton isDismissing = true scope.launch { diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt index 90a5523e75..e1216b18d0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreen.kt @@ -599,7 +599,7 @@ internal fun PaymentRequestCard( SecondaryButton( text = stringResource(R.string.wallet__payment_request_dismiss), onClick = { - if (isBusy || onDismiss == null) return@SecondaryButton + if (isBusy || onDismiss == null || !request.canDismiss) return@SecondaryButton isDismissingLocally = true scope.launch { try { @@ -610,7 +610,7 @@ internal fun PaymentRequestCard( } }, isLoading = isBusy, - enabled = !isBusy, + enabled = !isBusy && request.canDismiss && onDismiss != null, icon = { Icon( painter = painterResource(R.drawable.ic_x), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt index e39418165b..3805fe021c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt @@ -567,7 +567,7 @@ private fun OnChainDetails( color = if (uiState.hardwareWalletId != null) Colors.Blue else Colors.Brand, enabled = uiState.canSwitchFundingSource && interactionsEnabled, isLoading = uiState.isFundingSourceLoading, - clickable = interactionsEnabled, + clickable = interactionsEnabled && !uiState.isRetainedHardwarePayment, icon = R.drawable.ic_transfer.takeIf { uiState.canSwitchFundingSource }, onClick = { onEvent(SendEvent.PaymentMethodSwitch) }, modifier = Modifier.testTag("SendConfirmAssetButton") @@ -608,7 +608,9 @@ private fun OnChainDetails( modifier = Modifier .weight(1f) .fillMaxHeight() - .clickableAlpha(enabled = interactionsEnabled) { onEvent(SendEvent.SpeedAndFee) } + .clickableAlpha(enabled = interactionsEnabled && !uiState.isRetainedHardwarePayment) { + onEvent(SendEvent.SpeedAndFee) + } ) { SendCell(caption = stringResource(R.string.wallet__send_fee_and_speed)) { Row( diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index ba80b2e234..82a5251b53 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1368,6 +1368,7 @@ class AppViewModel @Inject constructor( _sendUiState.update { it.copy( contactPaymentProfile = activeContactPaymentProfile(), + isRetainedHardwarePayment = true, address = requireNotNull(proof.onchainAddress), addressInput = requireNotNull(proof.onchainAddress), isAddressInputValid = true, @@ -1381,6 +1382,7 @@ class AppViewModel @Inject constructor( ), ) } + preparation?.let { paymentRequestPreparation.compareAndSet(it, null) } showSheet(Sheet.Send(SendRoute.Confirm, hardwareWalletId = proof.onchainWalletId)) return true } @@ -2436,6 +2438,7 @@ class AppViewModel @Inject constructor( is SendEvent.CommentChange -> onCommentChange(it.value) SendEvent.SpeedAndFee -> { + if (_sendUiState.value.isRetainedHardwarePayment) return@collect if (_sendUiState.value.onchainFeeUi.estimates.isEmpty()) { viewModelScope.launch { refreshOnchainFeeUi() @@ -3010,6 +3013,7 @@ class AppViewModel @Inject constructor( } fun onSelectSpeed(speed: TransactionSpeed) { + if (_sendUiState.value.isRetainedHardwarePayment) return if (speed is TransactionSpeed.Custom && speed.satsPerVByte == 0u) { setSendEffect(SendEffect.NavigateToFeeCustom) } else { @@ -3018,6 +3022,7 @@ class AppViewModel @Inject constructor( } fun setTransactionSpeed(speed: TransactionSpeed) { + if (_sendUiState.value.isRetainedHardwarePayment) return onchainSendRefreshJob?.cancel() val previous = _sendUiState.value _sendUiState.update { @@ -3077,6 +3082,7 @@ class AppViewModel @Inject constructor( private suspend fun onPaymentMethodSwitch() { val current = _sendUiState.value + if (current.isRetainedHardwarePayment) return val sources = availableFundingSources(current) if (sources.size < 2) return sendStateGeneration++ @@ -3997,6 +4003,10 @@ class AppViewModel @Inject constructor( if (hardwareWalletId == null) maxOf(selectedMax, maximumHardwareFundingBalanceSats()) else selectedMax private fun availableFundingSources(state: SendUiState): List = buildList { + if (state.isRetainedHardwarePayment) { + state.hardwareWalletId?.let { add(SendFundingSource.Hardware(it)) } + return@buildList + } if (state.isUnified || state.decodedInvoice != null) add(SendFundingSource.Spending) if (state.address.isNotEmpty()) { add(SendFundingSource.Savings) @@ -5397,6 +5407,7 @@ class AppViewModel @Inject constructor( /** Reselect utxos for current amount & speed then refresh fees using updated utxos */ private fun refreshOnchainSendIfNeeded(): Job? { val currentState = _sendUiState.value + if (currentState.isRetainedHardwarePayment) return null if (currentState.payMethod != SendMethod.ONCHAIN || currentState.amount == 0uL || currentState.address.isEmpty() @@ -5436,6 +5447,7 @@ class AppViewModel @Inject constructor( private suspend fun refreshOnchainFeeUi() = withContext(bgDispatcher) { val currentState = _sendUiState.value + if (currentState.isRetainedHardwarePayment) return@withContext updateOnchainFeeUi { it.copy(isLoading = true) } val speeds = listOf( @@ -5471,7 +5483,7 @@ class AppViewModel @Inject constructor( private fun updateOnchainFeeUi(transform: (OnchainFeeUi) -> OnchainFeeUi) { _sendUiState.update { - it.copy(onchainFeeUi = transform(it.onchainFeeUi)) + if (it.isRetainedHardwarePayment) it else it.copy(onchainFeeUi = transform(it.onchainFeeUi)) } } @@ -6914,6 +6926,7 @@ data class SendUiState( val paymentRequestNote: String? = null, val hardwareWalletId: String? = null, val resolvedHardwarePaymentTxId: String? = null, + val isRetainedHardwarePayment: Boolean = false, val hardwareWalletName: String? = null, val hardwareAvailableSats: ULong = 0uL, val isSubscriptionPayment: Boolean = false, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index 2c348ba81e..412ec3f700 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -847,6 +847,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat whenever(paymentProofStore.completedRequestProofKindsAwaitingSubmission(LOCAL_IDENTITY)) .thenReturn(mapOf(requestId to PaykitPaymentProofKind.Onchain)) + whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(setOf(requestId)) sut.refreshAfterStateChange(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() assertTrue(sut.pendingRequests.value.isEmpty()) @@ -854,6 +855,8 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat verify(paykitSdkService, never()).processPendingPrivateMessages() verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() assertEquals(requestId, sut.paymentRequestHistory.value.single().id) + assertTrue(sut.paymentRequestHistory.value.single().isPaymentInFlight) + assertFalse(sut.paymentRequestHistory.value.single().canDismiss) assertEquals( PaymentRequestLifecycleState.PROOF_SUBMITTED, sut.paymentRequestHistory.value.single().lifecycleState, diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index e109cd02e1..e11987608a 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -2542,6 +2542,10 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() assertEquals(requestId, sut.pendingRequests.value.single().id) + assertTrue(sut.pendingRequests.value.single().isPaymentInFlight) + assertFalse(sut.pendingRequests.value.single().canDismiss) + assertFalse(sut.paymentRequestHistory.value.single { it.id == requestId }.canDismiss) + assertTrue(sut.pendingRequests.value.single().copy(isPaymentInFlight = false).canDismiss) sut.ensurePaymentAllowed(sut.pendingRequests.value.single()).getOrThrow() assertEquals(listOf(proof), reopened.load()) assertEquals(setOf(requestId), reopened.inFlightRequestIds(LOCAL_IDENTITY)) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 16fea3ce50..f4e34e7b6f 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1470,45 +1470,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `reopened refused hardware request uses original receipt without new payment preparation`() = test { - sut.setIsAuthenticated(true) - val request = paymentRequest().copy( - lifecycleState = PaymentRequestLifecycleState.ACCEPTED, - amountSats = 1_000uL, - amountValue = "0.00001000", - ) - val signed = hardwareSignedReceipt() - val proof = to.bitkit.repositories.PendingPaykitPaymentProof( - identity = testPublicKey, - requestId = request.id, - paymentEndpointIdentifier = MethodId.P2wpkh.rawValue, - paymentAppId = "bitkit", - kind = PaykitPaymentProofKind.Onchain, - paymentStarted = true, - paymentIdentifier = to.bitkit.utils.SignedTransactionId.fromHex(signed.serializedTx), - onchainAddress = REGTEST_ADDRESS, - onchainAmountSats = request.amountSats, - onchainWalletId = HARDWARE_WALLET_ID, - hardwareDispatchAttempted = true, - hardwareRefusedForNavigation = true, - hardwareSignedTransaction = signed.serializedTx, - hardwareMiningFeeSats = signed.miningFeeSats, - hardwareFeeRate = signed.feeRate, - hardwareTotalSpent = signed.totalSpent, - ) - whenever(paykitPaymentProofRepo.retainedHardwareOnchainRequest(request)).thenReturn(proof) - whenever(privatePaykitRepo.beginPaymentRequest(request)) - .thenReturn(Result.failure(PaykitPaymentRequestError.OperationInProgress)) - hwWallets.value = persistentListOf(hardwareWallet(fundingBalanceSats = 0uL)) - pendingPaykitPaymentRequests.value = listOf(request) - surfacedPaykitPaymentRequestIds += request.id - enablePaykitUi() - pubkyPublicKey.value = testPublicKey - runCurrent() - sut.showPaymentRequests() - sut.openIncomingPaymentRequest(request.id) - advanceTimeBy(TRANSITION_SCREEN_MS) - runCurrent() - + val (request, signed) = reopenRetainedHardwareRequest(balanceSats = 0uL) assertEquals(HARDWARE_WALLET_ID, sut.sendUiState.value.hardwareWalletId) assertEquals(REGTEST_ADDRESS, sut.sendUiState.value.address) assertEquals(request.amountSats, sut.sendUiState.value.amount) @@ -1520,6 +1482,46 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentRequestRepo, never()).accept(any()) verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any()) verify(hwWalletRepo, never()).signFunding(any(), any()) + assertTrue(sut.sendUiState.value.isRetainedHardwarePayment) + sut.resetSendState(hardwareWalletId = null) + assertFalse(sut.sendUiState.value.isRetainedHardwarePayment) + sut.setTransactionSpeed(TransactionSpeed.Fast) + assertEquals(TransactionSpeed.Fast, sut.sendUiState.value.speed) + } + + @Test + fun `reopened refused hardware request locks terms and reaches hardware sign on swipe`() = test { + reopenRetainedHardwareRequest(balanceSats = 1_000_000uL, amountSats = 30_000uL) + val sheet = sut.currentSheet.value + assertEquals(Sheet.Send(SendRoute.Confirm, hardwareWalletId = HARDWARE_WALLET_ID), sheet) + sut.onSheetVisible(sheet) + clearInvocations(toastManager) + val original = sut.sendUiState.value + assertTrue(original.isRetainedHardwarePayment) + sut.sendEffect.test { + sut.setSendEvent(SendEvent.SpeedAndFee) + sut.onSelectSpeed(TransactionSpeed.Custom(0u)) + sut.onSelectSpeed(TransactionSpeed.Fast) + sut.setTransactionSpeed(TransactionSpeed.Fast) + sut.setSendEvent(SendEvent.PaymentMethodSwitch) + advanceUntilIdle() + assertEquals(original, sut.sendUiState.value, "Retained fee, wallet and exact payment terms stay fixed") + expectNoEvents() + sut.setSendEvent(SendEvent.SwipeToPay) + advanceUntilIdle() + assertNull(sut.sendUiState.value.showSanityWarningDialog) + assertTrue(sut.sendUiState.value.shouldConfirmPay) + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + val toasts = argumentCaptor() + verify(toastManager, atLeast(0)).enqueue(toasts.capture()) + assertEquals(emptyList(), toasts.allValues, "Reopened payment must preserve request context on swipe") + assertEquals(SendEffect.NavigateToHardwareSign, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + verify(privatePaykitRepo, never()).beginPaymentRequest(any()) + verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any()) + verify(hwWalletRepo, never()).signFunding(any(), any()) } @Test @@ -11357,6 +11359,52 @@ class AppViewModelSendFlowTest : BaseUnitTest() { wasPublishedToActiveState = wasPublishedToActiveState, ) + private suspend fun TestScope.reopenRetainedHardwareRequest( + balanceSats: ULong, + amountSats: ULong = 1_000uL, + ): Pair { + sut.setIsAuthenticated(true) + val request = paymentRequest().copy( + lifecycleState = PaymentRequestLifecycleState.ACCEPTED, + amountSats = amountSats, + amountValue = java.math.BigDecimal(amountSats.toString()).movePointLeft(8).toPlainString(), + ) + val signed = hardwareSignedReceipt() + val proof = to.bitkit.repositories.PendingPaykitPaymentProof( + identity = testPublicKey, + requestId = request.id, + paymentEndpointIdentifier = MethodId.P2wpkh.rawValue, + paymentAppId = "bitkit", + kind = PaykitPaymentProofKind.Onchain, + paymentStarted = true, + paymentIdentifier = to.bitkit.utils.SignedTransactionId.fromHex(signed.serializedTx), + onchainAddress = REGTEST_ADDRESS, + onchainAmountSats = request.amountSats, + onchainWalletId = HARDWARE_WALLET_ID, + hardwareDispatchAttempted = true, + hardwareRefusedForNavigation = true, + hardwareSignedTransaction = signed.serializedTx, + hardwareMiningFeeSats = signed.miningFeeSats, + hardwareFeeRate = signed.feeRate, + hardwareTotalSpent = signed.totalSpent, + ) + whenever(paykitPaymentProofRepo.retainedHardwareOnchainRequest(request)).thenReturn(proof) + whenever(privatePaykitRepo.beginPaymentRequest(request)) + .thenReturn(Result.failure(PaykitPaymentRequestError.OperationInProgress)) + hwWallets.value = persistentListOf(hardwareWallet(fundingBalanceSats = balanceSats)) + pendingPaykitPaymentRequests.value = listOf(request) + surfacedPaykitPaymentRequestIds += request.id + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + sut.showPaymentRequests() + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + + return request to signed + } + private fun hardwareSignedReceipt() = HwFundingSignedTx( requireNotNull(javaClass.getResourceAsStream("/hardware-signed-transaction.hex")) .bufferedReader().use { it.readText().trim() }, From aa330959dd34ab51fb46025a021b4155980676b5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 10 Oct 2026 02:39:22 +0200 Subject: [PATCH 107/108] docs: make fallback payment checks reproducible --- journeys/pubky-marketplace/README.md | 7 + .../local-paykit-fallback.md | 121 ++++++++++++++++++ .../local-paykit-fallback.xml | 23 ++++ 3 files changed, 151 insertions(+) create mode 100644 journeys/pubky-marketplace/local-paykit-fallback.md create mode 100644 journeys/pubky-marketplace/local-paykit-fallback.xml diff --git a/journeys/pubky-marketplace/README.md b/journeys/pubky-marketplace/README.md index e5da87d9b9..00f77d4024 100644 --- a/journeys/pubky-marketplace/README.md +++ b/journeys/pubky-marketplace/README.md @@ -5,6 +5,13 @@ Paykit access and a watch-only account, a linked buyer receives the Payment Requ the request on regtest through confirmation. It does not cover marketplace browsing, Locks content delivery, fiat payment, or Hypercolor. +## Local-only fallback + +Remote Shop staging remains the default. After an actual staging Paykit pairing failure, see +[`local-paykit-fallback.md`](local-paykit-fallback.md) and `local-paykit-fallback.xml` for the +conditional standalone fixture. It is unrun and does not establish Shop order-paid acceptance. +The companion journey is shared with iOS; only platform build instructions differ. + ## Companion consent and reconnect - `paykit-only-approval.xml` checks Paykit-only consent and cancellation without account creation. diff --git a/journeys/pubky-marketplace/local-paykit-fallback.md b/journeys/pubky-marketplace/local-paykit-fallback.md new file mode 100644 index 0000000000..b931bb2382 --- /dev/null +++ b/journeys/pubky-marketplace/local-paykit-fallback.md @@ -0,0 +1,121 @@ +# Local-only Paykit fallback + +Status: **unrun**. The default is a purchase on `https://shop.staging.pubky.app` with its deployed +Paykit service. Run this companion only after an actual staging Paykit pairing attempt fails and +its URL, timestamp, HTTP outcome and wallet error have been recorded. A pending signup prerequisite, +unknown server version, invoice-delivery failure or unpaid Shop order does not trigger fallback. + +This exercises an operator-owned Paykit server with staging identities and staging regtest Bitcoin. +It does not replace the deployed Shop backend. Standalone pairing, request delivery, accepted payment +and server observation are separate results; none proves that the original Shop order is paid or +that Locks released its content. Follow `local-paykit-fallback.xml` after this setup. + +## Prerequisites + +- Confirm the homeserver fix PR is open and signup has been rerun successfully against + `ufibwbmed6jeq9k4p583go95wofakh9fwpp4k734trq79pd9u1uy` before starting this fixture. +- Use two isolated wallets, fresh seller/buyer staging invites and Bitkit-owned identities registered + on that homeserver. Preserve existing identities. Use the grant signup journey for a fresh + `pubkyauth://signup_grant` URL naming that homeserver; invites and authorization URLs are secrets. +- Record the app commit, APK hash and installed package. Android uses `devDebug`, `to.bitkit.dev`, + Paykit `0.1.0-rc72` and native LDK `0.7.0-rc.71`. Use a normal build (`E2E=false`) or explicitly + network-backed E2E build; the local E2E default selects a different chain endpoint. +- Both wallets and the server must use `ssl://electrs.bitkit.stag0.blocktank.to:9999`, the regtest + endpoint in `app/src/main/java/to/bitkit/env/Env.kt`. Verify this before funding or creating a request. +- Provide an owned disposable PostgreSQL database, an operator-controlled secret store, and a fixture + service signer with its exact public key allowlisted. Never point migrations at staging/shared + databases. The server automatically applies migrations at startup. +- A signed fixture driver must create and observe one request using the pinned server's documented + routes and signature contract. Record its source revision. Without that driver, stop at pairing; + do not invent unsigned invoice endpoints or borrow deployed service signing secrets. + +## Pinned server and configuration + +Use [`pubky/paykit-server` tag `v0.1.0-rc11`](https://github.com/pubky/paykit-server/tree/v0.1.0-rc11), +commit `662dca0619a9aa2962bcd677bd5ddd4563cd2784`. Its `Cargo.toml` pins Paykit to +`ad3c72248d18587bb5b6ef3c99b063fa9bf31551`, matching client rc72. A matching pin is not pairing evidence. +Verify `git rev-parse HEAD` in the server checkout before running it. + +Copy `config/paykit-server.example.toml` from that revision to an operator-controlled file and set: + +| Key | Value | +| --- | --- | +| `http.listen_addr` | `127.0.0.1:8080` on the fixture host | +| `http.trusted_proxy_hops` | `0`; do not trust forwarded headers without verifying the proxy chain | +| `signed_services.trusted_public_keys` | Nonempty list containing only the fixture service's canonical public key | +| `setup.allowed_origins` | Exact HTTPS origin of the fixture's requesting page; no wildcard | +| `setup.log_authorization_url` | `false` | +| `paykit.client_id` | `app.paykit.server` | +| `paykit.app_id` | `paykit-server` | +| `paykit.network` | `mainnet` | +| `bitcoin.network` | `regtest` | +| `electrum.endpoint` | `ssl://electrs.bitkit.stag0.blocktank.to:9999` | + +Retain the example's remaining supported keys. `paykit.network = "mainnet"` means normal Pkarr and +homeserver resolution, including the staging identities above; it does not select Bitcoin mainnet. +`"testnet"` instead selects fixed localhost Pubky services and is wrong for this fixture. The server +accepts no arbitrary homeserver/relay config keys: select the homeserver during identity signup. + +Supply `PAYKIT_CONFIG` (file path), `PAYKIT_DATABASE_URL` (owned database) and `PAYKIT_MASTER_KEY` +(exactly 32 bytes encoded as unpadded base64url) through the secret store, not command arguments, +source files or captured logs. From the pinned server checkout, the documented commands are: + +```sh +cargo run --locked -p paykit-server -- --check-config +cargo run --locked -p paykit-server +``` + +The first command must print `configuration valid`; it does not connect to the database or prove +runtime readiness. In a separate owned process, expose only this listener: + +```sh +cloudflared tunnel --url http://127.0.0.1:8080 +``` + +Record the assigned HTTPS tunnel origin and exact process identities. Require successful +`GET /health/live` and `GET /health/ready` through the tunnel before pairing. A changed tunnel URL +requires updating the fixture URL; do not restart the server or reset identities to retry a payment. + +Open the tunnel's `/setup?return_to=&state=` in the +fixture page. Upstream rc11 initial setup accepts only `return_to` and `state`, not `creator`. +The page supplies the real Pubky Auth QR/deep link and polls `POST /setup/{flow_id}/complete`. +Approve its Paykit and watch-only account claims in the seller wallet. Existing stored bindings use +`/setup/reconnect` with `creator`, `return_to` and `state`; do not replace or reset the binding. +Do not change the deployed Shop configuration to point at this tunnel. + +## Payment and evidence + +Enable contact payments and save both contacts as in `wallet-leg.xml`. Fund only the fresh buyer's +verified staging-regtest savings address. The repository's staging-default `lsp` helper supports: + +```sh +./lsp POST /regtest/chain/deposit '{"address":"","amountSat":100000}' +./lsp POST /regtest/chain/mine '{"count":1}' +``` + +Wait for the confirmed balance, then create one small request within it. Freeze the original buyer, +seller, request ID, fixture invoice/bundle ID, amount, network, derived recipient and deadline before +authentication. Keep the failed remote Shop order separately identified; a fixture invoice is not a +replacement order. Capture the original selected inputs, signed transaction ID and retained-receipt +hash where available, without logging raw secrets. On uncertainty, retain the original request and +payment guard; any authenticated retry must preserve the original amount, inputs and retained bytes. +Never substitute payer, endpoint, order, request or inputs, or use absence of evidence to unlock. + +Record wallet acceptance, the exact transaction/output on staging regtest, server delivery/payment +status and confirmation height separately. `connected` alone is not payment completion. Preserve +sanitized evidence of each boundary; omit invites, auth URLs, private keys, sessions and signatures. + +## Failure classification and teardown + +Report the first failed boundary: `signup prerequisite`, `config/startup`, `tunnel/transport`, +`pairing/claim`, `contact/link`, `request delivery`, `wallet pre-dispatch`, `broadcast rejected`, +`broadcast unknown`, `proof delivery`, `server observation`, or `Shop order completion`. +Capture the timestamp, original IDs, HTTP status/request ID and typed error where available. +Do not relabel a transport/storage error as revoked authority or a transaction ID as acceptance. +If no Shop order was exercised, report Shop acceptance **unrun**, not failed or passed. + +Save evidence before stopping owned server/tunnel processes and test devices. Verify their exact +process/device identities and shutdown; never stop a shared database, adb server or another fixture. +Retain the owned database and original recovery state while a payment outcome is unresolved, with +its location and owner recorded privately. Remove disposable binaries/browser profiles and temporary +files only after evidence and required recovery state are secured. Record every incomplete step. diff --git a/journeys/pubky-marketplace/local-paykit-fallback.xml b/journeys/pubky-marketplace/local-paykit-fallback.xml new file mode 100644 index 0000000000..ec49230a64 --- /dev/null +++ b/journeys/pubky-marketplace/local-paykit-fallback.xml @@ -0,0 +1,23 @@ + + + LOCAL-ONLY and unrun. Use the setup recipe in local-paykit-fallback.md only after actual + remote Shop staging Paykit pairing fails and the staging homeserver signup prerequisite passes. + This verifies a standalone Paykit fixture, not Shop order-paid or Locks content acceptance. + + + Record the failed remote staging pairing evidence and successful homeserver signup prerequisite + Verify the pinned server revision, app binary, staging homeserver identities and matching regtest Electrum endpoint + Validate the owned server configuration, start the server and Cloudflare tunnel, and verify both health endpoints + Open the fixture setup page in a browser, send its generated Pubky Auth URL to the seller wallet, and verify Paykit access and watch-only account consent + Authorize the claims and verify setup completes for the original seller without sharing root identity or spending keys + Enable contact payments in both wallets, save both contacts and verify their linked state + Fund the fresh buyer staging-regtest savings address and verify the confirmed balance + Create exactly one fixture request and record its original payer, seller, request, invoice, amount, recipient, network and deadline + Verify the received request and payment review match those original values before authenticating + Authenticate the payment once and record the retained transaction identity and backend outcome + If the outcome is uncertain, retain the original guard and request without creating a replacement payment + Verify the exact transaction output and server payment observation, then mine and record confirmation + Report standalone pairing, delivery and payment results separately from the original Shop order, marking unexercised Shop acceptance unrun + Save sanitized evidence, classify incomplete boundaries and verify teardown of only owned resources while preserving unresolved recovery state + + From 6b000fe861ccecb1e756fdc2443c1700271887eb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 10 Oct 2026 10:52:40 +0200 Subject: [PATCH 108/108] test: exercise unresolved send recovery --- .../send/SendPendingObservationDeviceTest.kt | 25 +++++++++++++------ .../OnchainSendAttemptStoreTest.kt | 3 +++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingObservationDeviceTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingObservationDeviceTest.kt index 3828423194..300e704c60 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingObservationDeviceTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingObservationDeviceTest.kt @@ -24,6 +24,7 @@ import org.junit.runner.RunWith import to.bitkit.data.SettingsStore import to.bitkit.data.WidgetsStore import to.bitkit.data.backup.VssStoreIdProvider +import to.bitkit.data.keychain.Keychain import to.bitkit.env.Env import to.bitkit.models.ActiveOnchainAttemptBackup import to.bitkit.repositories.ActivityRepo @@ -57,6 +58,8 @@ class SendPendingObservationDeviceTest { @Inject lateinit var store: OnchainSendAttemptStore + @Inject lateinit var keychain: Keychain + @Inject lateinit var lightningRepo: LightningRepo @Inject lateinit var activityRepo: ActivityRepo @@ -96,8 +99,19 @@ class SendPendingObservationDeviceTest { wire.restored(Env.network.name.lowercase(), "22".repeat(32), original.walletId, original.walletIndex) }.isFailure, ) + val attemptKey = Keychain.Key.ONCHAIN_SEND_ATTEMPT.name + val savedAttempt = requireNotNull(keychain.loadString(attemptKey, original.walletIndex)) try { - store.restoreActive(restored.copy(evidence = OnchainSendEvidence.Unknown)) + // Test-only storage seam: production restore deliberately cannot downgrade positive evidence. + keychain.upsertString( + attemptKey, + Json.encodeToString(restored.copy(evidence = OnchainSendEvidence.Unknown)), + original.walletIndex, + ) + val pending = requireNotNull(store.current()) + assertTrue(pending.isUnresolved && !pending.hasPositiveEvidence && !pending.localFollowupComplete) + assertTrue(pending.restoredFromBackup) + assertEquals(original.attemptId, pending.attemptId) assertEquals(original.originalInputs, store.current()?.originalInputs) assertEquals(original.candidateTxids, store.current()?.candidateTxids) val vm = SendPendingViewModel(PendingPaymentRepo(), activityRepo, lightningRepo) @@ -124,7 +138,7 @@ class SendPendingObservationDeviceTest { onClose = {}, onViewDetails = {}, viewModel = vm, - retryOriginal = { _, _ -> error("Observation must not invoke retry") }, + retryOriginal = { _, _, _ -> error("Observation must not invoke retry") }, onRecovered = { txid, amount -> recovered = txid to amount }, savedStateHandle = SavedStateHandle(), onNavigateToPin = { error("Observation must not request spend authentication") }, @@ -149,11 +163,8 @@ class SendPendingObservationDeviceTest { File(context.getExternalFilesDir(null), "current-observation-integration.json") .writeText(Json.encodeToString(requireNotNull(store.current()))) } finally { - // Preserve the owned fixture's original known-positive operation and completion flag. - store.observeExactTransaction(expected) - store.markLocalFollowupComplete(original.attemptId, original.walletIndex) - store.restoreActive(original) - store.markLocalFollowupComplete(original.attemptId, original.walletIndex) + keychain.upsertString(attemptKey, savedAttempt, original.walletIndex) + assertEquals(savedAttempt, keychain.loadString(attemptKey, original.walletIndex)) } assertEquals(original, store.current()) } diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index 5aabb0446c..4205e9cbdc 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -409,6 +409,9 @@ class OnchainSendAttemptStoreTest : BaseUnitTest() { val completed = store.current() store.restoreActive(accepted) assertEquals(completed, store.current()) + store.restoreActive(accepted.copy(evidence = OnchainSendEvidence.Unknown)) + assertEquals(completed, store.current()) + assertFalse(requireNotNull(store.current()).isUnresolved) } @Test