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/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, ) } } 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..cf9c00b1af --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingScreenTest.kt @@ -0,0 +1,103 @@ +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 { + companion object { + private const val SCREENSHOT_FRAME_WAIT_MS = 500L + } + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + 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 { + AppThemeSurface { + CompositionLocalProvider(LocalInspectionMode provides true) { + if (visible) { + SendPendingContent( + amount = 1_000L, + isOnchain = true, + activityId = null, + txid = "ab".repeat(32), + refusalReason = "Test backend refusal", + onClose = { closeCount++; visible = false }, + onViewDetails = { error("Unresolved send has no activity") }, + ) + } + } + } + } + assertUnresolved() + saveScreenshot("ln112-babysit-pending-refused-component.png") + composeTestRule.onNodeWithText("Close").performClick() + composeTestRule.runOnIdle { assertEquals(1, closeCount); visible = true } + assertUnresolved() + saveScreenshot("ln112-babysit-pending-refused-visible-component.png") + } + + @Test + fun exactCandidateWithLocalActivityOffersDetailsWithoutClaimingAcceptance() { + var detailsId: String? = null + val txid = "cd".repeat(32) + composeTestRule.setContent { + AppThemeSurface { + CompositionLocalProvider(LocalInspectionMode provides true) { + SendPendingContent(amount = 1_000L, isOnchain = true, activityId = "queued-local-activity", + txid = txid, onClose = {}, onViewDetails = { detailsId = it }) + } + } + } + composeTestRule.onNodeWithText(txid, substring = true).assertIsDisplayed() + composeTestRule.onNodeWithText("Payment Pending").assertIsDisplayed() + composeTestRule.onNodeWithText("Payment Sent").assertDoesNotExist() + saveScreenshot("ln112-babysit-pending-details-component.png") + composeTestRule.onNodeWithText("Details").performClick() + composeTestRule.runOnIdle { assertEquals("queued-local-activity", detailsId) } + } + + private fun assertUnresolved() { + composeTestRule.onNodeWithText("Test backend refusal", substring = true).assertIsDisplayed() + composeTestRule.onNodeWithText("ab".repeat(32), substring = true).assertIsDisplayed() + composeTestRule.onNodeWithText("Payment Pending").assertIsDisplayed() + composeTestRule.onNodeWithText("Bitkit will block another send", substring = true).assertIsDisplayed() + composeTestRule.onNodeWithText("Details").assertIsNotEnabled() + composeTestRule.onNodeWithText("Retry").assertDoesNotExist() + composeTestRule.onNodeWithText("Payment Sent").assertDoesNotExist() + } + + private fun saveScreenshot(name: String) { + val instrumentation = InstrumentationRegistry.getInstrumentation() + composeTestRule.waitForIdle() + instrumentation.waitForIdleSync() + // Semantics may be committed before the emulator compositor presents that frame. + android.os.SystemClock.sleep(SCREENSHOT_FRAME_WAIT_MS) + 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/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/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 8e804a1204..86dd236729 100644 --- a/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/HwWalletRepo.kt @@ -548,6 +548,37 @@ class HwWalletRepo @Inject constructor( } } + /** Fresh backend observation of this exact transaction in the original hardware wallet. */ + 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() + val detail = trezorRepo.getTransactionDetail( + extendedKey = account.xpub, + txid = txid, + network = Env.network.toCoreNetwork(), + scriptType = account.accountType, + ).getOrThrow() + 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 + } + } + 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/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 9f04373d65..6e61a0e5d9 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,25 @@ 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) + onchainSendAttemptStore.markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + } + } + .onFailure { Logger.warn("Failed to record exact on-chain transaction observation", it, context = TAG) } + } handleLdkEvent(event) recordProbeOutcome(event) val settledReceiveInvoice: SettledReceiveInvoice? @@ -1457,7 +1478,10 @@ class LightningRepo @Inject constructor( tags: List = emptyList(), beforeSendAttempt: suspend () -> Unit = {}, onBroadcast: suspend (Txid) -> Unit = {}, - ): Result = executeWhenNodeRunning("sendOnChain") { + requestId: PaykitPaymentRequestId? = null, + orderId: String? = null, + transferContext: OnchainTransferContext? = 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 +1505,124 @@ 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, + transferContext = transferContext, + 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 + ) { + finishOnchainSendLocally(attempt) + 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..f53af889e4 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -0,0 +1,234 @@ +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 +data class OnchainTransferContext( + val txTotalSats: ULong, + val preTransferOnchainSats: ULong, +) + +@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 transferContext: OnchainTransferContext? = null, +) { + 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() + // 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 { loadWithRetainedAccepted(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, + transferContext: OnchainTransferContext? = null, + 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(loadWithRetainedAccepted(walletIndex) ?: 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, + transferContext = transferContext, + ) + 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 + } + val recorded = current.copy( + evidence = evidence, + txid = outcome.txid, + refusalReason = (outcome as? OnchainSendOutcome.Rejected)?.reason, + ) + 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 = loadWithRetainedAccepted(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 = loadWithRetainedAccepted(walletIndex) + if (current?.attemptId == attemptId && current.hasPositiveEvidence) { + persist(current.copy(localFollowupComplete = true)) + retainedAccepted.remove(walletIndex) + } + } + } + + suspend fun observeExactTransaction(txid: String): OnchainSendAttempt? = + withContext(ioDispatcher + NonCancellable) { + mutex.withLock { + val current = loadWithRetainedAccepted(lightningService.currentWalletIndex) ?: return@withLock null + if (!current.txid.equals(txid, ignoreCase = true) || !current.isUnresolved) return@withLock null + current.copy(evidence = OnchainSendEvidence.Observed).also { persist(it) } + } + } + + 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 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 7520856c17..d5011a8f1d 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 @@ -11,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 @@ -39,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 @@ -68,56 +67,30 @@ data class PendingPaykitPaymentProof( val onchainAmountSats: ULong? = null, val onchainWalletId: String = WalletScope.default, val onchainMatchingTransactionIdsBeforeAttempt: Set = emptySet(), + val onchainAcceptanceVerified: Boolean = false, ) data class PaykitOnchainPaymentProofResolution( val identity: String, val requestId: PaykitPaymentRequestId, val transactionId: String, + val walletId: String = WalletScope.default, + val amountSats: ULong? = null, ) -@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, + 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() @@ -139,12 +112,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 +142,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 +183,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 +202,6 @@ class PaykitPaymentProofRepo @Inject constructor( onchainAddress = address, onchainAmountSats = request.amountSats, onchainWalletId = walletId, - onchainMatchingTransactionIdsBeforeAttempt = existingTransactionIds, ) persist(proofs) } @@ -262,17 +246,26 @@ class PaykitPaymentProofRepo @Inject constructor( request: PaykitPaymentRequest, txid: String, paymentEndpointIdentifier: String, - ) = withContext(ioDispatcher) { + 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) - return@withContext + return@withContext false } - val identity = currentIdentity() ?: return@withContext + 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() - operationMutex.withLock { + val completed = operationMutex.withLock { val completion = runSuspendCatching { val proofs = loadProofs().toMutableList() val index = proofs.indexOfLast { @@ -287,17 +280,21 @@ 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 - persistAndSubmit(listOf(proof), proofs) - publishOnchainResolution(proof, txid) + val retained = persistAndSubmit(listOf(proof), proofs) + if (retained) publishOnchainResolution(proof, txid) + retained } completion.onFailure { Logger.warn( @@ -306,17 +303,27 @@ 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, ) - 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 { @@ -339,9 +346,41 @@ 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 && + it.onchainWalletId == WalletScope.default && it.paymentStarted && it.paymentIdentifier == null && it.proofData == null @@ -386,8 +425,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 +456,8 @@ class PaykitPaymentProofRepo @Inject constructor( } proofs.forEach { proof -> - runSuspendCatching { reconcileProof(proof, payments) } + runSuspendCatching { reconcileProof(proof, payments, attempt) } + .onSuccess { if (it && proof.onchainWalletId == WalletScope.default) completedShopTxid = attempt?.txid } .onFailure { Logger.warn( "Failed to reconcile a pending Paykit payment proof", @@ -418,16 +468,48 @@ 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 (!proof.paymentStarted || !proof.proofData.isHex(HASH_BYTE_COUNT) || + !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) + if (index < 0) return false + val verified = proof.copy(onchainAcceptanceVerified = true) + proofs[index] = verified + return persistAndSubmit(listOf(verified), proofs) + } + submitReady(proof) + attempt.matchesPositiveShopProof(proof) + } + 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 +543,71 @@ 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 (proof.onchainWalletId != WalletScope.default) return reconcileHardwareOnchainProof(proof) + 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 - val completed = proof.copy(paymentIdentifier = txid.lowercase(), proofData = txid.lowercase()) + if (index < 0) return false + val completed = proof.copy( + paymentIdentifier = txid.lowercase(), proofData = txid.lowercase(), onchainAcceptanceVerified = true, + ) proofs[index] = completed - persistAndSubmit(listOf(completed), proofs) - publishOnchainResolution(proof, txid) + val retained = persistAndSubmit(listOf(completed), proofs) + if (retained) publishOnchainResolution(proof, txid) + 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 address = proof.onchainAddress?.takeIf { it.isNotBlank() } ?: return false + val amount = proof.onchainAmountSats ?: return false + val observed = withTimeoutOrNull(HARDWARE_OBSERVATION_TIMEOUT) { + hwWalletRepo.observeExactTransaction(proof.onchainWalletId, txid, address, amount).getOrDefault(false) + } == true + if (!observed) return false + } + val proofs = loadProofs().toMutableList() + val index = proofs.indexOf(proof) + if (index < 0) return false + 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 && + (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) { @@ -486,6 +615,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 @@ -505,6 +636,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 ( @@ -591,7 +723,7 @@ class PaykitPaymentProofRepo @Inject constructor( private suspend fun persistAndSubmit( completedProofs: List, allProofs: List, - ) { + ): Boolean { val didPersist = runSuspendCatching { persist(allProofs) } .onFailure { Logger.warn( @@ -608,15 +740,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/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/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index e1eac9bb8c..c055aad0eb 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -10,11 +10,19 @@ import com.synonym.bitkitcore.SortDirection import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.ChannelDetails +import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PendingSweepBalance +import to.bitkit.async.appScope +import to.bitkit.data.CacheStore import to.bitkit.data.dao.TransferDao import to.bitkit.data.entities.TransferEntity import to.bitkit.di.BgDispatcher @@ -25,6 +33,7 @@ import to.bitkit.ext.runSuspendCatching import to.bitkit.models.TransferType import to.bitkit.models.WalletScope import to.bitkit.services.CoreService +import to.bitkit.utils.AppError import to.bitkit.utils.BlockTimeHelpers import to.bitkit.utils.Logger import java.util.UUID @@ -36,6 +45,7 @@ import kotlin.time.ExperimentalTime @OptIn(ExperimentalTime::class) @Singleton +@Suppress("LongParameterList") class TransferRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val lightningRepo: LightningRepo, @@ -43,9 +53,74 @@ class TransferRepo @Inject constructor( private val coreService: CoreService, private val transferDao: TransferDao, private val clock: Clock, + private val cacheStore: CacheStore, ) { private val lastOrdersFetchMs = AtomicLong(0L) + private val fundingMutex = Mutex() + private val repoScope = appScope(bgDispatcher, TAG) + + init { + repoScope.launch { + lightningRepo.lightningState.map { it.nodeLifecycleState.isRunning() }.distinctUntilChanged() + .collect { running -> if (running) resumeAcceptedFunding() } + } + repoScope.launch { + lightningRepo.nodeEvents.collect { event -> + if (event is Event.OnchainTransactionReceived || event is Event.OnchainTransactionConfirmed) { + resumeAcceptedFunding() + } + } + } + } + + /** Uses only the bounded attempt's positive evidence and original order/balance context. */ + suspend fun resumeAcceptedFunding(): Result = runSuspendCatching { + val attempt = lightningRepo.currentOnchainSendAttempt() ?: return@runSuspendCatching + if (!attempt.isTransfer || !attempt.hasPositiveEvidence || attempt.localFollowupComplete) { + return@runSuspendCatching + } + val txid = requireNotNull(attempt.txid) + val orderId = requireNotNull(attempt.orderId) + val order = blocktankRepo.fetchOrders(listOf(orderId)).getOrThrow().firstOrNull { it.id == orderId } + ?: throw AppError("Original funding order is unavailable") + check(order.payment?.onchain?.address == attempt.address) { "Original funding order address changed" } + persistAcceptedFunding(order, txid, attempt.transferContext).getOrThrow() + lightningRepo.completeAcceptedTransferFollowup(orderId, txid) + }.onFailure { Logger.warn("Failed to resume accepted funding", it, context = TAG) } + + /** Serialized across startup/events and the confirmation screen; never broadcasts. */ + suspend fun persistAcceptedFunding( + order: IBtOrder, + txid: String, + originalContext: OnchainTransferContext?, + ): Result = withContext(bgDispatcher) { + runSuspendCatching { + fundingMutex.withLock { + val context = requireNotNull( + originalContext + ) { "Accepted transfer is missing its original balance context" } + val existing = transferDao.getByFundingTxId(txid) + check(existing == null || existing.lspOrderId == order.id) { + "Funding transaction is already assigned to another order" + } + val paid = cacheStore.data.first().paidOrders[order.id] + check(paid == null || paid == txid) { "Order is already assigned to another funding transaction" } + if (existing == null) { + createTransfer( + type = TransferType.TO_SPENDING, + amountSats = order.clientBalanceSat.toLong(), + fundingTxId = txid, + lspOrderId = order.id, + txTotalSats = context.txTotalSats.toLong(), + preTransferOnchainSats = context.preTransferOnchainSats.toLong(), + ).getOrThrow() + } + cacheStore.addPaidOrder(orderId = order.id, txId = txid) + } + } + } + val activeTransfers: Flow> = transferDao.getActiveTransfers() val forceCloseRemainingDuration: Flow = combine( 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/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/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 53fd44b879..97e65d54c9 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, @@ -54,6 +55,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 f3f84ca4c6..3c7e28cbec 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 @@ -73,7 +74,7 @@ class HwSendViewModel @Inject constructor( prepareContactPayment: suspend () -> Boolean = { true }, authorizeContactPayment: suspend (hasAttemptedBroadcast: Boolean) -> 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) } @@ -111,7 +112,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) @@ -255,6 +259,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, @@ -324,6 +331,8 @@ data class HwSendResult( val walletId: String, val txId: String, val amountSats: ULong, + val paymentRequestId: PaykitPaymentRequestId? = null, + val paymentIdentity: String? = null, ) data class HwSendRequest( @@ -332,6 +341,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/screens/wallets/send/SendPendingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt index d5f0a9f5cc..5e7b934f84 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -27,6 +28,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R +import to.bitkit.models.WalletScope import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.BodyM @@ -46,16 +48,24 @@ fun SendPendingScreen( paymentHash: String, amount: Long, observeResolution: Boolean = true, + isOnchain: Boolean = false, onPaymentSuccess: (String, Long) -> Unit, onPaymentError: (PendingPaymentResolution.Failure) -> Unit, onClose: () -> Unit, onViewDetails: (String) -> Unit, viewModel: SendPendingViewModel, + walletId: String = WalletScope.default, + refusalReason: String? = null, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - if (observeResolution) { - LaunchedEffect(Unit) { viewModel.init(paymentHash, amount) } + val txid = paymentHash.takeIf { isOnchain && it.matches(Regex("[0-9a-fA-F]{64}")) } + LaunchedEffect(Unit) { + if (isOnchain) { + viewModel.initOnchain(txid, amount, walletId) + } else if (observeResolution) { + viewModel.init(paymentHash, amount) + } } uiState.resolution?.takeIf { observeResolution }?.let { resolution -> @@ -71,21 +81,27 @@ fun SendPendingScreen( } } - Content( + SendPendingContent( amount = if (observeResolution) uiState.amount else amount, + isOnchain = isOnchain, activityId = uiState.activityId, + txid = txid, + refusalReason = refusalReason, onClose = onClose, onViewDetails = onViewDetails, ) } @Composable -private fun Content( +internal fun SendPendingContent( amount: Long, + isOnchain: Boolean, activityId: String?, onClose: () -> Unit, onViewDetails: (String) -> Unit, modifier: Modifier = Modifier, + txid: String? = null, + refusalReason: String? = null, ) { Column( modifier = modifier @@ -104,7 +120,26 @@ 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, + ) + + if (isOnchain) { + refusalReason?.let { + VerticalSpacer(16.dp) + BodyM(stringResource(R.string.wallet__send_pending__refusal, it), color = Colors.White64) + } + txid?.let { + VerticalSpacer(16.dp) + SelectionContainer { + BodyM(stringResource(R.string.wallet__send_pending__txid, it), color = Colors.White64) + } + } + } FillHeight() HourglassAnimation(modifier = Modifier.align(Alignment.CenterHorizontally)) @@ -154,8 +189,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/screens/wallets/send/SendPendingViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt index 301d622f01..3523796f6f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModel.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.ext.rawId +import to.bitkit.models.WalletScope import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PendingPaymentResolution @@ -44,6 +45,18 @@ class SendPendingViewModel @Inject constructor( observeResolution(paymentHash) } + // Local activity is only a Details target; it cannot resolve or acknowledge an on-chain send. + fun initOnchain(txid: String?, amount: Long, walletId: String = WalletScope.default) { + if (isInitialized) return + isInitialized = true + _uiState.update { it.copy(amount = amount) } + if (txid == null || !txid.matches(Regex("[0-9a-fA-F]{64}"))) return + viewModelScope.launch { + activityRepo.findActivityByPaymentId(txid, ActivityFilter.ONCHAIN, PaymentType.SENT, true, walletId) + .onSuccess { activity -> _uiState.update { it.copy(activityId = activity.rawId()) } } + } + } + override fun onCleared() { pendingPaymentRepo.setActiveHash(null) } diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 121fa808df..28826dbdb3 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -39,6 +39,7 @@ import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.NodeLifecycleState import to.bitkit.models.SendFailureDetails +import to.bitkit.models.WalletScope import to.bitkit.repositories.ConnectivityState import to.bitkit.ui.components.ConnectionIssuesView import to.bitkit.ui.components.SyncNodeView @@ -128,7 +129,22 @@ 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(), + walletId = result.walletId, + observeResolution = false, + isOnchain = true, + ) + ) { popUpTo(navController.graph.id) { inclusive = true } } + hwSendViewModel.completeBroadcast() + return@collect + } appViewModel.onSendSuccess( details = NewTransactionSheetDetails( type = NewTransactionSheetType.ONCHAIN, @@ -174,7 +190,13 @@ 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, + refusalReason = it.refusalReason, + ) ) { popUpTo(startDestination) { inclusive = true } } is SendEffect.NavigateToError -> navController.navigateTo( SendRoute.errorFromFailure( @@ -314,13 +336,23 @@ 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, - authorizeContactPayment = appViewModel::authorizeHardwareContactPayment, + prepareContactPayment = { + appViewModel.prepareHardwareContactPayment( + walletId, uiState.address, uiState.incomingPaymentRequestId, paymentIdentity, + ) + }, + authorizeContactPayment = { hasAttemptedBroadcast -> + appViewModel.authorizeHardwareContactPayment( + hasAttemptedBroadcast, uiState.incomingPaymentRequestId, paymentIdentity, + ) + }, onBack = { navController.previousBackStackEntry ?.savedStateHandle @@ -463,6 +495,9 @@ fun SendSheet( paymentHash = route.paymentHash, amount = route.amount, observeResolution = route.observeResolution, + isOnchain = route.isOnchain, + walletId = route.walletId, + refusalReason = route.refusalReason, onPaymentSuccess = { paymentHash, amountWithFee -> appViewModel.onSendSuccess( NewTransactionSheetDetails( @@ -487,7 +522,7 @@ fun SendSheet( } }, onClose = { appViewModel.hideSheet() }, - onViewDetails = { rawId -> appViewModel.navigateToActivity(rawId) }, + onViewDetails = { rawId -> appViewModel.navigateToActivity(rawId, route.walletId) }, viewModel = hiltViewModel(), ) } @@ -661,6 +696,9 @@ sealed interface SendRoute { val paymentHash: String, val amount: Long, val observeResolution: Boolean = true, + val isOnchain: Boolean = false, + val walletId: String = WalletScope.default, + val refusalReason: String? = null, 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 a869b9fe0e..482403325b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -39,6 +39,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -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,11 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LnurlPayInvoiceMismatchError import to.bitkit.repositories.MethodId import to.bitkit.repositories.NodeEventUpdate +import to.bitkit.repositories.OnchainSendAttemptUnreadableError +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 +209,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 @@ -839,7 +843,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, ) ) @@ -857,6 +862,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) } @@ -4025,8 +4031,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() @@ -4112,6 +4139,7 @@ class AppViewModel @Inject constructor( address = address, amount = amount, tags = tags, + requestId = incomingPaymentRequest?.id, beforeSendAttempt = { if (preparedPaymentProofRequest != null) { markOnchainPaymentStarted(incomingPaymentRequest, address).getOrThrow() @@ -4120,24 +4148,47 @@ class AppViewModel @Inject constructor( incomingPaymentRequest?.let { paykitPaymentRequestRepo.ensurePaymentAllowed(it).getOrThrow() } sendAttempted = true }, - onBroadcast = { txId -> - 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) + completeOnchainPaymentProofInBackground(incomingPaymentRequest, outcome.txid, outcome) + 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, + refusalReason = outcome.reason + ) + } + + is OnchainSendOutcome.Unknown -> showUnresolvedOnchainSend(outcome.txid, amount, incomingPaymentRequest) + } }.onFailure { error -> handleOnchainPaymentFailure( error = error, @@ -4159,23 +4210,54 @@ class AppViewModel @Inject constructor( contactPaymentContext: ContactPaymentContext?, ) { val amount = _sendUiState.value.amount - if ( - incomingPaymentRequest != null && - sendAttempted && - !error.isDefiniteOnchainPreBroadcastFailure() - ) { + val unresolved = error is OnchainSendPendingError || error is OnchainSendBlockedError || + error is OnchainSendAttemptUnreadableError || + (sendAttempted && error !is OnchainSendNotDispatchedError) + if (unresolved) { Logger.warn("On-chain payment outcome is uncertain after send started", error, context = TAG) - uncertainOnchainPaymentRequestId = incomingPaymentRequest.id + if (!paymentProofStarted) cancelPaymentProofPreparation(preparedPaymentProofRequest) + val previous = (error as? OnchainSendBlockedError)?.attempt + val priorAccepted = previous?.takeIf { + it.hasPositiveEvidence && it.txid != null && !it.isTransfer && + incomingPaymentRequest != null && 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, + ) + ) + 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, + 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, - amount = amount.toLong(), - observeResolution = false, - ) + showUnresolvedOnchainSend( + txid = (error as? OnchainSendPendingError)?.txid ?: previous?.txid, + amount = previous?.amountSats ?: amount, + requestId = unresolvedRequestId, + refusalReason = previous?.refusalReason, ) return } @@ -4197,6 +4279,25 @@ class AppViewModel @Inject constructor( } } + private fun showUnresolvedOnchainSend( + txid: String?, + amount: ULong, + request: PaykitPaymentRequest? = null, + requestId: PaykitPaymentRequestId? = request?.id, + refusalReason: String? = null, + ) { + uncertainOnchainPaymentRequestId = requestId + setSendEffect( + SendEffect.NavigateToPending( + paymentHash = txid ?: requestId?.paymentRequestId.orEmpty(), + amount = amount.toLong(), + observeResolution = false, + isOnchain = true, + refusalReason = refusalReason, + ) + ) + } + @Suppress("LongMethod", "CyclomaticComplexMethod") private suspend fun proceedWithLightningPayment( incomingPaymentRequest: PaykitPaymentRequest?, @@ -4212,7 +4313,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 @@ -4355,7 +4457,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 { @@ -4363,6 +4469,7 @@ class AppViewModel @Inject constructor( request = paymentRequest, txid = txId, paymentEndpointIdentifier = endpointIdentifier, + acceptedOutcome = acceptedOutcome, ) if (paymentRequest.billingPeriod != null) refreshIncomingPaykitPaymentRequests() } @@ -4564,11 +4671,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, @@ -4576,13 +4682,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, @@ -4618,10 +4721,10 @@ class AppViewModel @Inject constructor( fun resetQuickPay() = _quickPayData.update { null } - fun navigateToActivity(activityRawId: String) { + fun navigateToActivity(activityRawId: String, walletId: String? = null) { viewModelScope.launch { hideSheet() - mainScreenEffect(MainScreenEffect.Navigate(Routes.ActivityDetail(activityRawId))) + mainScreenEffect(MainScreenEffect.Navigate(Routes.ActivityDetail(activityRawId, walletId))) } } @@ -5225,24 +5328,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 } @@ -5252,13 +5365,23 @@ class AppViewModel @Inject constructor( return false } } - return true + return incomingPaymentRequest == null || PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value) } - suspend fun authorizeHardwareContactPayment(hasAttemptedBroadcast: Boolean): Boolean { + suspend fun authorizeHardwareContactPayment( + hasAttemptedBroadcast: Boolean, + requestId: PaykitPaymentRequestId? = activeIncomingPaymentRequest()?.id, + identity: String? = hardwarePaymentIdentity(), + ): Boolean { val contactPaymentContext = synchronized(contactPaymentContextLock) { activeContactPaymentContext } + if (contactPaymentContext?.incomingPaymentRequest?.id != requestId) return false val request = contactPaymentContext?.incomingPaymentRequest ?: return true - val error = paykitPaymentRequestRepo.ensurePaymentAllowed(request).exceptionOrNull() ?: return true + if (identity == null || !PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value)) return false + val error = paykitPaymentRequestRepo.ensurePaymentAllowed(request).exceptionOrNull() + if (!PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value) || + synchronized(contactPaymentContextLock) { activeContactPaymentContext } != contactPaymentContext + ) return false + if (error == null) return true if (hasAttemptedBroadcast) { toast(error) return false @@ -5273,11 +5396,17 @@ class AppViewModel @Inject constructor( return false } - 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() { @@ -5290,14 +5419,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 } @@ -6074,6 +6205,8 @@ sealed class SendEffect { val paymentHash: String, val amount: Long, val observeResolution: Boolean = true, + val isOnchain: Boolean = false, + val refusalReason: String? = null, ) : SendEffect() } @@ -6125,25 +6258,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..5965e42e21 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -70,6 +70,8 @@ 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.OnchainTransferContext import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.BoltzService @@ -370,6 +372,33 @@ 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, + txTotalSats = previous.transferContext?.txTotalSats, + preTransferOnchainSats = previous.transferContext?.preTransferOnchainSats, + requireTransferPersisted = true, + ) + runSuspendCatching { lightningRepo.completeAcceptedTransferFollowup(order.id, txid) } + .onFailure { Logger.warn("Failed to finish accepted transfer locally", it, context = TAG) } + } + } + return true + } + ToastEventBus.send( + AppError( + previous.refusalReason ?: context.getString(R.string.wallet__send_pending__funding_description) + ) + ) + return false + } + if (cacheStore.data.first().paidOrders.containsKey(order.id)) return true val plan = resolveSpendingConfirmPlan(order, shown) ?: return false Logger.debug( @@ -382,6 +411,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, @@ -392,24 +429,36 @@ class TransferViewModel @Inject constructor( isTransfer = true, channelId = order.channel?.shortChannelId, isMaxAmount = plan.shouldUseSendAll, + orderId = order.id, + transferContext = transferContext, ) - .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(context.getString(R.string.wallet__send_pending__funding_description)) + ) + return@fold false + } + // Survive ViewModel clearance between accepted broadcast and paid-order cache write. + withContext(NonCancellable) { + fundPaidOrder( + order = order, + txId = outcome.txid, + txTotalSats = transferContext.txTotalSats, + preTransferOnchainSats = transferContext.preTransferOnchainSats, + requireTransferPersisted = true, + ) + runSuspendCatching { lightningRepo.completeAcceptedTransferFollowup(order.id, outcome.txid) } + .onFailure { Logger.warn("Failed to finish accepted transfer locally", it, context = TAG) } + } + true + }, + onFailure = { + ToastEventBus.send(it) + false } - } - .onFailure { ToastEventBus.send(it) } - .isSuccess + ) } private suspend fun resolveSpendingConfirmPlan( @@ -631,16 +680,26 @@ 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(), - ) + if (requireTransferPersisted) { + val originalContext = if (txTotalSats != null && preTransferOnchainSats != null) { + OnchainTransferContext(txTotalSats, preTransferOnchainSats) + } else { + null + } + transferRepo.persistAcceptedFunding(order, txId, originalContext).getOrThrow() + } else { + 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(), + ) + } 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 17d09e7795..01ffa7c0e8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1457,6 +1457,10 @@ The maximum spendable amount is a bit lower due to a required reserve balance. Reserve Balance This payment is taking a bit longer than expected. You can continue using Bitkit. + The transaction outcome is unresolved. Check its status before trying again. Bitkit will block another send while this outcome is unresolved. + Funding transaction is unresolved. Check its transaction before trying again. + Broadcast refused: %1$s + Transaction ID: %1$s Payment Pending This invoice has already been paid. Currency conversion failed 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/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 5e31cda7f8..498006f9f0 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,89 @@ 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 `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) + 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/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index eea548e5a1..528b463e21 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -84,6 +84,7 @@ import to.bitkit.utils.LdkError import to.bitkit.utils.UrlValidator import kotlin.coroutines.cancellation.CancellationException import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNotNull @@ -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(), anyOrNull(), any(), + ) + } + .thenReturn(attempt) + whenever { onchainSendAttemptStore.recordOutcome(any(), any(), any()) } + .thenReturn(attempt.copy(evidence = OnchainSendEvidence.Accepted, txid = "testPaymentId")) startNodeForTesting() @@ -1416,12 +1442,217 @@ 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(), anyOrNull(), 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 + 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 `accepted transfer acknowledgement waits for original durable local activity`() = test { + val txid = "ab".repeat(32) + val attempt = pendingSendAttempt().copy( + isTransfer = true, + orderId = "order-1", + 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) + + assertTrue(runCatching { sut.completeAcceptedTransferFollowup("order-1", txid) }.isFailure) + verify(onchainSendAttemptStore, never()).markLocalFollowupComplete(any(), any()) + whenever(activityService.getOnchainActivityByTxId(txid, attempt.walletId)).thenReturn(mock()) + sut.completeAcceptedTransferFollowup("order-1", txid) + verify(onchainSendAttemptStore).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + verify(lightningService, never()).send(any(), any(), any(), anyOrNull(), any(), any()) + } + + @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 @@ -1703,6 +1934,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..2c8a619e47 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -0,0 +1,186 @@ +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 `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() + 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 `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 + 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 9079e92502..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 @@ -55,8 +64,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { private val paykitSdkService = mock() private val lightningRepo = mock() - private val onchainPaymentLookup = mock() private val store = mock() + private val hwWalletRepo = mock() private var storedProofs = emptyList() private var shouldFailNextLoad = false private var shouldFailNextSave = false @@ -71,7 +80,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 +102,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 +294,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 +308,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) @@ -357,7 +410,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() @@ -436,7 +489,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()) @@ -544,7 +597,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()) @@ -563,7 +616,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()) @@ -581,7 +634,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) @@ -600,7 +653,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() @@ -621,24 +674,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 +701,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,48 +713,154 @@ 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()) + } + + @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) @@ -766,12 +918,193 @@ 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, ONCHAIN_ADDRESS, request.amountSats)).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, 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()) + 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, 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) + 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, 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)) + repo.reconcile() + assertEquals(txid, storedProofs.single().paymentIdentifier) + assertNull(storedProofs.single().proofData) + assertFalse(storedProofs.single().onchainAcceptanceVerified) + 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()) + } + + @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, 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(), 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()) + } + + @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, lightningRepo = lightningRepo, - onchainPaymentLookup = onchainPaymentLookup, store = store, + hwWalletRepo = hwWalletRepo, + ) + + 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( 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/repositories/TransferRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt index aeab5f224c..81de568fc9 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -12,12 +12,15 @@ import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.SortDirection +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runCurrent import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.ChannelDetails +import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.LightningBalance import org.lightningdevkit.ldknode.OutPoint import org.lightningdevkit.ldknode.PendingSweepBalance @@ -31,11 +34,14 @@ import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore import to.bitkit.data.dao.TransferDao import to.bitkit.data.entities.TransferEntity import to.bitkit.env.Env import to.bitkit.ext.create import to.bitkit.ext.createChannelDetails +import to.bitkit.models.NodeLifecycleState import to.bitkit.models.TransferType import to.bitkit.models.WalletScope import to.bitkit.services.ActivityService @@ -64,6 +70,8 @@ class TransferRepoTest : BaseUnitTest() { on { activity } doReturn activityService } private val clock = mock() + private val cacheStore = mock() + private val nodeEvents = MutableSharedFlow() companion object Fixtures { private const val ID_ORDER = "test-order-id" @@ -74,6 +82,9 @@ class TransferRepoTest : BaseUnitTest() { @Before fun setUp() { + whenever(cacheStore.data).thenReturn(MutableStateFlow(AppCacheData())) + whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) + whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) whenever(transferDao.getActiveTransfers()).thenReturn(flowOf(emptyList())) sut = TransferRepo( @@ -83,9 +94,110 @@ class TransferRepoTest : BaseUnitTest() { coreService = coreService, transferDao = transferDao, clock = clock, + cacheStore = cacheStore, ) } + @Test + fun `startup resumes accepted original funding without a confirmation or native resend`() = test { + val order = previewBtOrder() + val txid = "ab".repeat(32) + val attempt = OnchainSendAttempt( + WalletScope.default, "attempt", null, order.id, + requireNotNull(order.payment?.onchain?.address), order.feeSat, false, 1uL, true, null, emptyList(), + OnchainSendEvidence.Accepted, txid, transferContext = OnchainTransferContext(99_000uL, 125_000uL) + ) + setupClockNowMock() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + whenever(blocktankRepo.fetchOrders(listOf(order.id))).thenReturn(Result.success(listOf(order))) + whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) + whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) + // Construct after the saved result exists, as on process startup. + val restarted = + TransferRepo(testDispatcher, lightningRepo, blocktankRepo, coreService, transferDao, clock, cacheStore) + runCurrent() + lightningRepo.lightningState.value.let { + (lightningRepo.lightningState as MutableStateFlow).value = it.copy( + nodeLifecycleState = NodeLifecycleState.Running, + ) + } + runCurrent() + + verify(transferDao).insert( + org.mockito.kotlin.check { + assertEquals(order.id, it.lspOrderId) + assertEquals(txid, it.fundingTxId) + assertEquals(99_000L, it.txTotalSats) + assertEquals(125_000L, it.preTransferOnchainSats) + } + ) + verify(lightningRepo).completeAcceptedTransferFollowup(order.id, txid) + verify(lightningRepo, never()).sendOnChain( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), + anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull() + ) + assertNotNull(restarted) + } + + @Test + fun `exact observed event retries partial original funding without duplicate transfer`() = test { + val order = previewBtOrder() + val txid = "ab".repeat(32) + val attempt = OnchainSendAttempt( + WalletScope.default, "attempt", null, order.id, + requireNotNull(order.payment?.onchain?.address), order.feeSat, false, 1uL, true, null, emptyList(), + OnchainSendEvidence.Observed, txid, transferContext = OnchainTransferContext(99_000uL, 125_000uL) + ) + setupClockNowMock() + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + whenever(blocktankRepo.fetchOrders(listOf(order.id))).thenReturn(Result.success(listOf(order))) + var persisted: TransferEntity? = null + whenever(transferDao.getByFundingTxId(txid)).thenAnswer { persisted } + whenever(transferDao.insert(any())).doSuspendableAnswer { + persisted = it.getArgument(0) + Unit + } + var failCache = true + whenever(cacheStore.addPaidOrder(order.id, txid)).doSuspendableAnswer { + if (failCache) { + failCache = false + error("cache storage unavailable") + } + Unit + } + assertTrue(sut.resumeAcceptedFunding().isFailure) + verify(lightningRepo, never()).completeAcceptedTransferFollowup(any(), any()) + + nodeEvents.emit(Event.OnchainTransactionReceived(txid, mock())) + runCurrent() + verify(transferDao, times(1)).insert(any()) + verify(cacheStore, times(2)).addPaidOrder(order.id, txid) + verify(lightningRepo).completeAcceptedTransferFollowup(order.id, txid) + assertEquals(99_000L, persisted?.txTotalSats) + assertEquals(125_000L, persisted?.preTransferOnchainSats) + verify(lightningRepo, never()).sendOnChain( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), + anyOrNull(), any(), any(), any(), any(), anyOrNull(), anyOrNull(), anyOrNull() + ) + } + + @Test + fun `unknown funding cannot resume from local records or another transaction event`() = test { + val order = previewBtOrder() + val attempt = OnchainSendAttempt( + WalletScope.default, "attempt", null, order.id, + requireNotNull(order.payment?.onchain?.address), order.feeSat, false, 1uL, true, null, emptyList(), + OnchainSendEvidence.Unknown, "ab".repeat(32), transferContext = OnchainTransferContext(99_000uL, 125_000uL) + ) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(attempt) + nodeEvents.emit(Event.OnchainTransactionReceived("cd".repeat(32), mock())) + runCurrent() + verify(transferDao, never()).insert(any()) + verify(cacheStore, never()).addPaidOrder(any(), any()) + verify(lightningRepo, never()).completeAcceptedTransferFollowup(any(), any()) + org.mockito.kotlin.verifyNoInteractions(blocktankRepo) + } + // MARK: - createTransfer @Test @@ -1575,6 +1687,7 @@ class TransferRepoTest : BaseUnitTest() { coreService = coreService, transferDao = transferDao, clock = clock, + cacheStore = cacheStore, ) testSut.activeTransfers.test { 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/ui/screens/wallets/send/HwSendViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/HwSendViewModelTest.kt index aa8ed4d018..81201bbbe7 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 @@ -376,6 +377,48 @@ 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) + } + + @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/ui/screens/wallets/send/SendPendingViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt index 34e86502db..4904738925 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendPendingViewModelTest.kt @@ -1,7 +1,10 @@ package to.bitkit.ui.screens.wallets.send import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.LightningActivity +import com.synonym.bitkitcore.OnchainActivity +import com.synonym.bitkitcore.PaymentType import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before @@ -37,6 +40,41 @@ class SendPendingViewModelTest : BaseUnitTest() { sut = createViewModel() } + @Test + fun `onchain activity enables original wallet Details without resolving acceptance`() = test { + val txid = "ab".repeat(32) + val onchainActivity = mock { on { id } doReturn "queued-local-activity" } + val activity = mock { on { v1 } doReturn onchainActivity } + whenever( + activityRepo.findActivityByPaymentId( + txid, + ActivityFilter.ONCHAIN, + PaymentType.SENT, + true, + "original-wallet" + ) + ) + .thenReturn(Result.success(activity)) + pendingPaymentRepo.resolve(PendingPaymentResolution.Success(txid, amountWithFeeSats = 9_999L)) + + sut.initOnchain(txid, amount, "original-wallet") + advanceUntilIdle() + + assertEquals("queued-local-activity", sut.uiState.value.activityId) + assertEquals(amount, sut.uiState.value.amount) + assertNull(sut.uiState.value.resolution) + assertEquals(false, pendingPaymentRepo.isActive(txid)) + } + + @Test + fun `missing onchain txid never uses request identifier as Details or acceptance`() = test { + sut.initOnchain("request-id", amount) + advanceUntilIdle() + assertNull(sut.uiState.value.activityId) + assertNull(sut.uiState.value.resolution) + org.mockito.kotlin.verifyNoInteractions(activityRepo) + } + @Test fun `init sets amount in uiState`() = test { sut.init(hash, amount) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index ca159fef0a..c9ac974579 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -126,6 +126,11 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.MethodId import to.bitkit.repositories.NodeEventUpdate +import to.bitkit.repositories.OnchainSendAttempt +import to.bitkit.repositories.OnchainSendBlockedError +import to.bitkit.repositories.OnchainSendEvidence +import to.bitkit.repositories.OnchainSendNotDispatchedError +import to.bitkit.repositories.OnchainSendOutcome import to.bitkit.repositories.PaykitBillingPeriod import to.bitkit.repositories.PaykitOnchainPaymentProofResolution import to.bitkit.repositories.PaykitPaymentProofKind @@ -6258,8 +6263,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), 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 @@ -6355,12 +6363,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentRequestRepo).accept(request) } + verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue, OnchainSendOutcome.Accepted("txid")) verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) - verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) } @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) @@ -6384,9 +6392,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(), @@ -6398,48 +6406,15 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), ) verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any()) } @Test - fun `uncertain onchain outcome without prepared proof keeps private payment details consumed`() = test { - val request = paymentRequest() - val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) - balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) - whenever(paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain)) - .thenReturn(Result.failure(IllegalStateException("proof unavailable"))) - whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) - whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) - .thenReturn(Result.success(Unit)) - stubOnchainSend( - address = "bcrt1quncertainwithoutproof", - sats = request.amountSats, - result = Result.failure(IllegalStateException("outcome unknown")), - ) - setActiveContactPaymentContext(testPublicKey, privateContext, request) - setSendState( - SendUiState( - address = "bcrt1quncertainwithoutproof", - amount = request.amountSats, - payMethod = SendMethod.ONCHAIN, - speed = TransactionSpeed.Medium, - isPaymentRequest = true, - ), - ) - - sut.sendEffect.test { - confirmCurrentPayment() - - assertTrue(awaitItem() is SendEffect.NavigateToPending) - } - verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) - verify(paykitPaymentProofRepo, never()).cancelPreparation(any()) - verify(privatePaykitRepo, never()).releasePrivatePaymentList(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" @@ -6472,7 +6447,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 @@ -6515,10 +6490,17 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - verify(paykitPaymentRequestRepo).accept(request) verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) - verify(paykitPaymentProofRepo).failLightningPayment(paymentHash) - verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) + if (preparationSucceeds) { + verify(paykitPaymentRequestRepo).accept(request) + verify(paykitPaymentProofRepo).failLightningPayment(paymentHash) + verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) + } else { + verify(paykitPaymentRequestRepo, never()).accept(request) + verify(privatePaykitRepo, never()).consumePrivatePaymentList(any(), any()) + verify(paykitPaymentProofRepo, never()).associateLightningPayment(any(), any(), any()) + verify(paykitPaymentProofRepo, never()).failLightningPayment(any()) + } clearInvocations(lightningRepo, paykitPaymentProofRepo, paykitPaymentRequestRepo, privatePaykitRepo) } } @@ -6752,6 +6734,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)) @@ -6770,6 +6753,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `hardware payment request releases private details when acceptance fails`() = test { + pubkyPublicKey.value = testPublicKey val request = paymentRequest() val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) whenever(paykitPaymentRequestRepo.accept(request)) @@ -6797,6 +6781,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `hardware payment request releases private details when proof start fails`() = test { + pubkyPublicKey.value = testPublicKey val request = paymentRequest() val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) @@ -6828,7 +6813,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)) @@ -6846,30 +6832,50 @@ 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") verify(paykitPaymentRequestRepo, never()).ensurePaymentAllowed(request) + } + @Test + fun `hardware authorization denial before first broadcast releases private payment details`() = test { + pubkyPublicKey.value = testPublicKey + val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = "bcrt1qpaymentrequest", + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + isPaymentRequest = true, + hardwareWalletId = "hardware-wallet", + ), + ) + assertTrue(sut.prepareHardwareContactPayment()) + verify(paykitPaymentRequestRepo, never()).ensurePaymentAllowed(request) whenever(paykitPaymentRequestRepo.ensurePaymentAllowed(request)) .thenReturn(Result.failure(PaykitPaymentRequestError.RequestUnavailable)) + assertFalse(sut.authorizeHardwareContactPayment(hasAttemptedBroadcast = false)) + verify(paykitPaymentProofRepo).failOnchainPayment(request) verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) } @Test - fun `hardware retry denial keeps the started proof until cancellation`() = test { + fun `hardware retry denial and cancellation preserve the started proof`() = test { + pubkyPublicKey.value = testPublicKey val request = paymentRequest() val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) whenever(context.getString(R.string.common__error)).thenReturn("Error") @@ -6912,12 +6918,51 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.onHardwareSignCancelled() advanceUntilIdle() - verify(paykitPaymentProofRepo).failOnchainPayment(request) + verify(paykitPaymentProofRepo).cancelPreparation(request) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(request) verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @Test - fun `cancelling hardware signing fails the started payment proof`() = test { + fun `hardware authorization denies an identity switch while checking the original request`() = test { + pubkyPublicKey.value = testPublicKey + val request = paymentRequest() + setActiveContactPaymentContext(testPublicKey, PrivatePaykitPaymentContext("bitkit/server", 7uL), request) + val authorization = CompletableDeferred>() + whenever(paykitPaymentRequestRepo.ensurePaymentAllowed(request)).doSuspendableAnswer { authorization.await() } + val completed = CompletableDeferred() + backgroundScope.launch { completed.complete(sut.authorizeHardwareContactPayment(hasAttemptedBroadcast = true)) } + runCurrent() + + pubkyPublicKey.value = "b".repeat(64) + authorization.complete(Result.success(Unit)) + runCurrent() + + assertFalse(completed.await()) + verify(paykitPaymentRequestRepo).ensurePaymentAllowed(request) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) + verify(paykitPaymentProofRepo, never()).cancelPreparation(any()) + } + + @Test + fun `captured hardware authorization cannot approve another active request`() = test { + pubkyPublicKey.value = testPublicKey + val original = paymentRequest() + val replacement = original.copy(paymentRequestId = "replacement-request") + setActiveContactPaymentContext( + testPublicKey, PrivatePaykitPaymentContext("bitkit/server", 7uL), replacement, + ) + + assertFalse(sut.authorizeHardwareContactPayment(true, original.id, testPublicKey)) + + verify(paykitPaymentRequestRepo, never()).ensurePaymentAllowed(any()) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) + verify(paykitPaymentProofRepo, never()).cancelPreparation(any()) + } + + @Test + fun `cancelling hardware signing only cancels unstarted preparation`() = test { + pubkyPublicKey.value = testPublicKey val request = paymentRequest() val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) @@ -6939,12 +6984,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.onHardwareSignCancelled() advanceUntilIdle() - verify(paykitPaymentProofRepo).failOnchainPayment(request) + verify(paykitPaymentProofRepo).cancelPreparation(request) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(request) verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @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) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) @@ -6968,12 +7015,47 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.hideSheet() advanceUntilIdle() - verify(paykitPaymentProofRepo).failOnchainPayment(request) + verify(paykitPaymentProofRepo).cancelPreparation(request) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(request) verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @Test - fun `proof preparation failure does not block hardware payment 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 + 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)) @@ -6992,15 +7074,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 completes proof in background after broadcast`() = 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() @@ -7008,10 +7091,11 @@ 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() + false } setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( @@ -7025,16 +7109,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(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) + 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 @@ -7081,7 +7212,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() } @@ -7268,12 +7399,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `uncertain ordinary onchain failure keeps existing error handling`() = test { + fun `proven predispatch ordinary onchain failure keeps existing error handling`() = test { balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) stubOnchainSend( address = "bcrt1quncertainordinarysend", sats = 1_000uL, - result = Result.failure(IllegalStateException("outcome unknown")), + result = Result.failure(OnchainSendNotDispatchedError(IllegalStateException("not dispatched"))), ) setSendState( SendUiState( @@ -7303,7 +7434,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(testPublicKey, privateContext, request, isInitialSubscriptionPayment = true) @@ -7370,7 +7501,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentProofRepo).failOnchainPayment(request) verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentProofRepo).cancelPreparation(request) - verify(paykitPaymentProofRepo, never()).completeOnchainPayment(any(), any(), any()) + verify(paykitPaymentProofRepo, never()).completeOnchainPayment(any(), any(), any(), anyOrNull()) } @Test @@ -7391,7 +7522,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { stubOnchainSend( address = "bcrt1qdefinitefailure", sats = request.amountSats, - result = Result.failure(error), + result = Result.failure(OnchainSendNotDispatchedError(error)), ) setActiveContactPaymentContext( testPublicKey, @@ -7455,9 +7586,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)) @@ -7562,7 +7694,7 @@ 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() val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) @@ -7572,7 +7704,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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, privateContext, request) @@ -7588,7 +7720,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()) verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @@ -7627,6 +7759,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), ) } @@ -7667,6 +7802,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), ) } @@ -7727,6 +7865,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), ) } @@ -7786,6 +7927,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), ) } @@ -7806,6 +7950,120 @@ 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, refusalReason = "broadcast refused"), + 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 `pre admission failure releases private context without navigating to pending`() = test { + val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + stubOnchainSend( + "bcrt1qpreadmission", + request.amountSats, + Result.failure(IllegalArgumentException("fee estimation failed")), + invokeBeforeSendAttempt = false + ) + setActiveContactPaymentContext(testPublicKey, privateContext, request, isInitialSubscriptionPayment = true) + setSendState( + SendUiState( + address = "bcrt1qpreadmission", + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + isPaymentRequest = true, + isInitialSubscriptionPayment = true + ) + ) + + sut.sendEffect.test { + confirmCurrentPayment() + assertTrue(awaitItem() is SendEffect.NavigateToError) + } + verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) + verify(paykitPaymentProofRepo).cancelPreparation(request) + verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) + } + + @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 `blocked ordinary payment never reports an earlier accepted send as its success`() = 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) + 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.NavigateToPending(txid, 1_000, false, isOnchain = true), awaitItem()) + } + assertNull(sut.successSendUiState.value.paymentHashOrTxId) + verify(lightningRepo).completeAcceptedOrdinaryFollowup(txid) + } + @Test fun `private lightning contact payment consumes private list before send`() = test { val bolt11 = "lnbcrt1privatecontact" @@ -8322,13 +8580,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, ) { @@ -8345,6 +8603,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), 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 8bbaccbb96..7c04926947 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -18,7 +18,6 @@ import com.synonym.bitkitcore.TrezorFeatures import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.TimeoutCancellationException @@ -26,6 +25,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy @@ -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 @@ -76,6 +77,7 @@ import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType import to.bitkit.models.TransportType +import to.bitkit.models.WalletScope import to.bitkit.models.formatToModernDisplay import to.bitkit.models.safe import to.bitkit.repositories.BlocktankRepo @@ -87,6 +89,10 @@ import to.bitkit.repositories.HwPassphraseRequiredError import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState +import to.bitkit.repositories.OnchainSendAttempt +import to.bitkit.repositories.OnchainSendEvidence +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 @@ -139,6 +145,19 @@ 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.persistAcceptedFunding(any(), any(), anyOrNull()) }.doSuspendableAnswer { + val originalOrder = it.getArgument(0) + cacheStore.addPaidOrder(originalOrder.id, it.getArgument(1)) + Result.success(Unit) + } + 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 +1163,9 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), 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()) @@ -1182,6 +1204,9 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = eq(OnchainTransferContext(txTotalSats = 38_171uL, preTransferOnchainSats = 41_000uL)), ) verify(lightningRepo, never()).sendOnChain( address = any(), @@ -1195,6 +1220,9 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), ) verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) } @@ -1225,6 +1253,9 @@ class TransferViewModelTest : BaseUnitTest() { any(), any(), any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), ), ).thenReturn(Result.failure(AppError("Coin selection failed"))) @@ -1247,6 +1278,9 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), ) verify(lightningRepo, never()).sendOnChain( address = any(), @@ -1260,8 +1294,175 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = 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 original = OnchainTransferContext(99_000uL, 125_000uL) + val attempt = acceptedFundingAttempt(order, original) + 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 + fun `accepted transfer recovery preserves original balance totals`() = test { + val order = spendingOrder(feeSat = 98_000uL) + val original = OnchainTransferContext(99_000uL, 125_000uL) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedFundingAttempt(order, original)) + quoteOrder(order) + prepareConfirm() + stubSpendableBalances(7_000uL) + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + + verify(transferRepo).persistAcceptedFunding(order, TXID, original) + verifySendOnChain(sats = order.feeSat, count = 0) + } + + @Test + fun `accepted transfer without original context stays blocked instead of inventing balance totals`() = test { + val order = spendingOrder(feeSat = 98_000uL) + whenever(lightningRepo.currentOnchainSendAttempt()).thenReturn(acceptedFundingAttempt(order, null)) + whenever(transferRepo.persistAcceptedFunding(order, TXID, null)) + .thenReturn(Result.failure(AppError("missing original context"))) + quoteOrder(order) + prepareConfirm() + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + verify(lightningRepo, never()).completeAcceptedTransferFollowup(any(), any()) + verifySendOnChain(sats = order.feeSat, count = 0) + assertFalse(sut.spendingUiState.value.isConfirmPaying) + } + + private fun acceptedFundingAttempt(order: IBtOrder, original: OnchainTransferContext?) = OnchainSendAttempt( + walletId = WalletScope.default, 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 = original, + ) + + @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(), + transferContext = 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 @@ -1316,8 +1517,14 @@ class TransferViewModelTest : BaseUnitTest() { any(), any(), any(), + anyOrNull(), + 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 +1780,9 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), ) } @@ -1754,6 +1964,9 @@ class TransferViewModelTest : BaseUnitTest() { any(), any(), any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), ) verify(cacheStore, never()).addPaidOrder(any(), any()) } @@ -1780,6 +1993,9 @@ class TransferViewModelTest : BaseUnitTest() { any(), any(), any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), ), ).thenReturn(Result.failure(AppError("Coin selection failed"))) quoteOrder(order) @@ -1926,6 +2142,9 @@ class TransferViewModelTest : BaseUnitTest() { any(), any(), any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), ) verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) } @@ -3167,6 +3386,9 @@ class TransferViewModelTest : BaseUnitTest() { tags = any(), beforeSendAttempt = any(), onBroadcast = any(), + requestId = anyOrNull(), + orderId = anyOrNull(), + transferContext = anyOrNull(), ) } @@ -3315,8 +3537,11 @@ class TransferViewModelTest : BaseUnitTest() { any(), any(), any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), ), - ).thenReturn(Result.success(TXID)) + ).thenReturn(Result.success(OnchainSendOutcome.Accepted(TXID))) } private companion object { diff --git a/changelog.d/next/1384.fixed.md b/changelog.d/next/1384.fixed.md new file mode 100644 index 0000000000..76d1bcc1be --- /dev/null +++ b/changelog.d/next/1384.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/gradle/libs.versions.toml b/gradle/libs.versions.toml index c3a4f08a16..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.66" } +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 new file mode 100644 index 0000000000..ab8cd68999 --- /dev/null +++ b/journeys/hardware-wallet/shop-onchain-proof.xml @@ -0,0 +1,25 @@ + + + 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. + 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. + + + 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 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 new file mode 100644 index 0000000000..9d18c74341 --- /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"), 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 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 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") + +