From 83eb8994fd69a42307848ed56557c1dfec511a9d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 02:27:10 +0200 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 07/10] 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 08/10] 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 09/10] 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 10/10] 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