diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9e10fd8dae..f0a59e0611 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -363,7 +363,7 @@ androidComponents { buildConfigFields.put("E2E_HOMEGATE_URL", e2eHomegateUrlEnv.stringField()) buildConfigFields.put("E2E_HOMESERVER_PUBKY", e2eHomeserverPubkyEnv.stringField()) buildConfigFields.put("E2E_HOMEGATE_OVERRIDE", e2eHomegateOverrideEnv.stringField()) - for (key in listOf("E2E_VSS_SERVER_URL", "E2E_LNURL_AUTH_SERVER_URL")) { + for (key in listOf("E2E_VSS_SERVER_URL", "E2E_LNURL_AUTH_SERVER_URL", "E2E_BLOCKTANK_API_URL")) { val value = if (variant.buildType == "debug") localProp(key).orElse("") else provider { "" } buildConfigFields.put(key, value.stringField()) } diff --git a/app/src/main/java/to/bitkit/data/WalletConnectionRecovery.kt b/app/src/main/java/to/bitkit/data/WalletConnectionRecovery.kt index 8baac5ad61..e1c70bbffb 100644 --- a/app/src/main/java/to/bitkit/data/WalletConnectionRecovery.kt +++ b/app/src/main/java/to/bitkit/data/WalletConnectionRecovery.kt @@ -62,6 +62,13 @@ class WalletConnectionRecovery @Inject constructor( } } + /** A queued UI intent remains bound to its original wallet, including same-seed replacement. */ + internal fun currentGeneration(): Long? = synchronized(stateLock) { generation.takeUnless { wiping } } + + internal fun isCurrentGeneration(expected: Long?): Boolean = synchronized(stateLock) { + expected != null && !wiping && generation == expected + } + private fun admittedGeneration(): Long = synchronized(stateLock) { checkAdmission() generation diff --git a/app/src/main/java/to/bitkit/env/BlocktankTestEndpoint.kt b/app/src/main/java/to/bitkit/env/BlocktankTestEndpoint.kt new file mode 100644 index 0000000000..4df446b51d --- /dev/null +++ b/app/src/main/java/to/bitkit/env/BlocktankTestEndpoint.kt @@ -0,0 +1,17 @@ +package to.bitkit.env + +import java.net.URI + +internal object BlocktankTestEndpoint { + fun resolve(enabled: Boolean, value: String?): String? { + if (!enabled) return null + val endpoint = value.orEmpty().trim().takeIf { it.isNotEmpty() } ?: return null + val valid = runCatching { + val url = URI(endpoint) + url.scheme in listOf("http", "https") && url.host == "localhost" && url.port in 1..65535 && + url.userInfo == null && url.query == null && url.fragment == null && !url.path.isNullOrEmpty() + }.getOrDefault(false) + require(valid) { "E2E Blocktank endpoint must be a localhost URL with an explicit port and path" } + return endpoint + } +} diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index e86f7c4a9a..cc45b27db8 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -141,10 +141,13 @@ internal object Env { Network.BITCOIN -> "https://api1.blocktank.to" else -> "https://api.stag0.blocktank.to" } - val blocktankApiUrl - get() = when (network) { - Network.BITCOIN -> "$blocktankBaseUrl/api" - else -> "$blocktankBaseUrl/blocktank/api/v2" + val blocktankApiUrl: String + get() { + val fixture = BlocktankTestEndpoint.resolve(isDebug && isE2eTest, BuildConfig.E2E_BLOCKTANK_API_URL) + return fixture ?: when (network) { + Network.BITCOIN -> "$blocktankBaseUrl/api" + else -> "$blocktankBaseUrl/blocktank/api/v2" + } } val blocktankNotificationApiUrl get() = when (network) { diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 445365a6d4..cdda68ee66 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -5,6 +5,7 @@ import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.ActivityTags import com.synonym.bitkitcore.AddressType +import com.synonym.bitkitcore.BlocktankException import com.synonym.bitkitcore.BtOrderState2 import com.synonym.bitkitcore.CJitStateEnum import com.synonym.bitkitcore.ClosedChannelDetails @@ -70,6 +71,8 @@ import io.ktor.client.request.get import io.ktor.http.HttpStatusCode import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.first import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.ConfirmationStatus @@ -2170,18 +2173,40 @@ class BlocktankService( } suspend fun getFees(): Result { - var fees = fees(refresh = true) + val fresh = runSuspendCatching { fees(refresh = true) } + val error = fresh.exceptionOrNull() + if (error != null && !isInfoBadGateway(error)) throw error + var fees = fresh.getOrNull() if (fees == null) { + currentCoroutineContext().ensureActive() Logger.warn("Failed to fetch fresh fee rate, using cached rate.") fees = fees(refresh = false) + currentCoroutineContext().ensureActive() } if (fees == null) { - return Result.failure(AppError("Fees unavailable from bitkit-core")) + return Result.failure(error ?: AppError("Fees unavailable from bitkit-core")) } return Result.success(fees) } + private fun isInfoBadGateway(error: Throwable): Boolean { + var cause = error + repeat(8) { + when (val current = cause) { + is BlocktankException.DataException -> { + val prefix = "Failed to fetch info from Blocktank: Blocktank error: " + + "Failed to get info: Client error: " + return current.errorDetails.startsWith(prefix + "Request failed with status 502 Bad Gateway:") || + current.errorDetails.startsWith(prefix + "Request failed (502 Bad Gateway):") + } + is AppError -> cause = current.cause ?: return false + else -> return false + } + } + return false + } + suspend fun createCjit( channelSizeSat: ULong, invoiceSat: ULong, 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 6d0edaf3f2..f4c7137438 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -171,7 +171,10 @@ fun SendSheet( LaunchedEffect(startDestination) { // always reset state on new user-initiated send if (startDestination == SendRoute.Recipient) { - appViewModel.resetSendState(hardwareWalletId = hardwareWalletId) + if (!appViewModel.resetSendState(hardwareWalletId = hardwareWalletId)) { + appViewModel.hideSheet() + return@LaunchedEffect + } appViewModel.resetQuickPay() routingCacheResetAttempted = false } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 999e5d46fd..8e023e93d2 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -91,7 +91,9 @@ import to.bitkit.BuildConfig import to.bitkit.R import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore +import to.bitkit.data.WalletConnectionRecovery import to.bitkit.data.keychain.Keychain +import to.bitkit.data.keychain.hasPendingWalletRestore import to.bitkit.data.resetPin import to.bitkit.di.BgDispatcher import to.bitkit.domain.commands.NotifyChannelReady @@ -282,6 +284,7 @@ class AppViewModel @Inject constructor( @ApplicationContext private val context: Context, @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val keychain: Keychain, + private val connectionRecovery: WalletConnectionRecovery, private val lightningRepo: LightningRepo, private val pendingPaymentRepo: PendingPaymentRepo, private val walletRepo: WalletRepo, @@ -357,18 +360,28 @@ class AppViewModel @Inject constructor( private val _sendEffect = MutableSharedFlow(extraBufferCapacity = 1) val sendEffect = _sendEffect.asSharedFlow() - private fun setSendEffect(effect: SendEffect) = viewModelScope.launch { _sendEffect.emit(effect) } + private fun setSendEffect(effect: SendEffect, canPresent: () -> Boolean = { true }) = viewModelScope.launch { + if (canPresent()) _sendEffect.emit(effect) + } private val _isCriticalUpdateRequired = MutableStateFlow(false) val isCriticalUpdateRequired = _isCriticalUpdateRequired.asStateFlow() private val _mainScreenEffect = MutableSharedFlow(extraBufferCapacity = 1) val mainScreenEffect = _mainScreenEffect.asSharedFlow() - private fun mainScreenEffect(effect: MainScreenEffect) = viewModelScope.launch { _mainScreenEffect.emit(effect) } + private fun mainScreenEffect( + effect: MainScreenEffect, + canPresent: () -> Boolean = { true } + ) = viewModelScope.launch { + if (canPresent()) _mainScreenEffect.emit(effect) + } - private suspend fun waitForUsableChannels() { + private suspend fun waitForUsableChannels(walletGeneration: Long? = connectionRecovery.currentGeneration()) { + requireCurrentPaymentPreparation(walletGeneration) lightningRepo.waitForUsableChannels() + requireCurrentPaymentPreparation(walletGeneration) walletRepo.refreshMaxSendLightning() + requireCurrentPaymentPreparation(walletGeneration) } private val sendEvents = MutableSharedFlow() @@ -1573,6 +1586,8 @@ class AppViewModel @Inject constructor( } private suspend fun presentNextIncomingPaykitPaymentRequest() { + val walletGeneration = connectionRecovery.currentGeneration() + if (!canPrepareSend(walletGeneration)) return val preparation = paymentRequestPreparation.value if (isPresentingPaymentRequest || isPaymentRequestPresentationBlocked(preparation)) return if (preparation == null && requestedPaymentRequestId.value == null) { @@ -1598,7 +1613,10 @@ class AppViewModel @Inject constructor( try { for (request in requests) { // Checked right before the sheet shows: an allowance refresh may have started since the filter ran. - if (!isAllowancePending(request) && presentIncomingPaymentRequestOrStop(request, generation)) { + if (!canPrepareSend(walletGeneration)) return + if (!isAllowancePending(request) && + presentIncomingPaymentRequestOrStop(request, generation, walletGeneration) + ) { stopped = true break } @@ -1717,6 +1735,7 @@ class AppViewModel @Inject constructor( private suspend fun presentIncomingPaymentRequestOrStop( request: PaykitPaymentRequest, generation: Long, + walletGeneration: Long?, ): Boolean { val preparation = paymentRequestPreparation.value?.takeIf { it.request == request && it.generation == generation && ownsPaymentRequestPreparation(it) @@ -1730,14 +1749,16 @@ class AppViewModel @Inject constructor( try { if (preparation != null) { if (currentSheet.value !== preparation.sheet) { - showSheet(preparation.sheet) + showSheet(preparation.sheet) { canPrepareSend(walletGeneration) } sheetTransitionJob?.join() } if (!ownsPaymentRequestPreparation(preparation)) return true } - if (reopenRetainedHardwarePayment(request, generation, preparation)) return true + if (!canPrepareSend(walletGeneration)) return true + if (reopenRetainedHardwarePayment(request, generation, preparation, walletGeneration)) return true val started = timeSource.markNow() val presentationResult = privatePaykitRepo.beginPaymentRequest(request) + if (!canPrepareSend(walletGeneration)) return true val resolvedAfter = started.elapsedNow() val result = finishIncomingPaymentRequestPreparation(request, generation, preparation, presentationResult) Logger.debug( @@ -1759,12 +1780,16 @@ class AppViewModel @Inject constructor( request: PaykitPaymentRequest, generation: Long, preparation: PaymentRequestPreparation?, + walletGeneration: Long?, ): Boolean { + if (!canPrepareSend(walletGeneration)) return true val retained = runSuspendCatching { paykitPaymentProofRepo.retainedHardwareOnchainRequest(request) } + if (!canPrepareSend(walletGeneration)) return true retained.exceptionOrNull()?.let { toast(it) return true } + if (!canPrepareSend(walletGeneration)) return true val proof = retained.getOrNull() ?: return false val identity = proof.identity if (!isCurrentPaymentRequestPresentation(request, generation, preparation) || @@ -1772,7 +1797,9 @@ class AppViewModel @Inject constructor( ) { return true } - paykitPaymentRequestRepo.ensurePaymentAllowed(request).exceptionOrNull()?.let { + val permission = paykitPaymentRequestRepo.ensurePaymentAllowed(request) + if (!canPrepareSend(walletGeneration)) return true + permission.exceptionOrNull()?.let { toast(it) return true } @@ -1780,14 +1807,18 @@ class AppViewModel @Inject constructor( toast(it) return true } - resetSendState( - isPaymentRequest = true, - paymentRequestNote = request.note, - isMerchantPaymentRequest = request.isMerchantRequest, - hardwareWalletId = proof.onchainWalletId, - incomingPaymentRequestId = request.id, - selectedTags = requestedPaymentRequestTags, - ) + if (!resetSendState( + isPaymentRequest = true, + paymentRequestNote = request.note, + isMerchantPaymentRequest = request.isMerchantRequest, + hardwareWalletId = proof.onchainWalletId, + incomingPaymentRequestId = request.id, + selectedTags = requestedPaymentRequestTags, + walletGeneration = walletGeneration, + ) + ) { + return true + } if (!isCurrentPaymentRequestPresentation(request, generation, preparation) || !PubkyPublicKeyFormat.matches(identity, pubkyRepo.publicKey.value) ) { @@ -1817,7 +1848,9 @@ class AppViewModel @Inject constructor( ) } preparation?.let { paymentRequestPreparation.compareAndSet(it, null) } - showSheet(Sheet.Send(SendRoute.Confirm, hardwareWalletId = proof.onchainWalletId)) + showSheet( + Sheet.Send(SendRoute.Confirm, hardwareWalletId = proof.onchainWalletId) + ) { canPrepareSend(walletGeneration) } return true } @@ -2919,60 +2952,68 @@ class AppViewModel @Inject constructor( private fun observeSendEvents() { viewModelScope.launch { sendEvents.collect { - when (it) { - SendEvent.EnterManually -> onEnterManuallyClick() - SendEvent.Paste -> onPasteClick() - SendEvent.Scan -> onScanClick() - - is SendEvent.AddressChange -> onAddressChange(it.value) - SendEvent.AddressReset -> resetAddressInput() - is SendEvent.AddressContinue -> onAddressContinue(it.data) - - is SendEvent.AmountChange -> onAmountChange(it.amount) - SendEvent.AmountReset -> resetAmountInput() - SendEvent.AmountContinue -> try { - onAmountContinue() - } finally { - amountContinuePending = false - } - SendEvent.PaymentMethodSwitch, is SendEvent.PaymentMethodSelect -> try { - onPaymentMethodSwitch((it as? SendEvent.PaymentMethodSelect)?.source) - } finally { - fundingSourceSwitchPending = false - _sendUiState.update { state -> state.copy(isFundingSourceLoading = false) } - } + try { + when (it) { + SendEvent.EnterManually -> onEnterManuallyClick() + SendEvent.Paste -> onPasteClick() + SendEvent.Scan -> onScanClick() + + is SendEvent.AddressChange -> onAddressChange(it.value) + SendEvent.AddressReset -> resetAddressInput() + is SendEvent.AddressContinue -> onAddressContinue(it.data) + + is SendEvent.AmountChange -> onAmountChange(it.amount) + SendEvent.AmountReset -> resetAmountInput() + SendEvent.AmountContinue -> try { + onAmountContinue() + } finally { + amountContinuePending = false + } + SendEvent.PaymentMethodSwitch, is SendEvent.PaymentMethodSelect -> try { + onPaymentMethodSwitch((it as? SendEvent.PaymentMethodSelect)?.source) + } finally { + fundingSourceSwitchPending = false + _sendUiState.update { state -> state.copy(isFundingSourceLoading = false) } + } - is SendEvent.CoinSelectionContinue -> onCoinSelectionContinue(it.utxos) + is SendEvent.CoinSelectionContinue -> onCoinSelectionContinue(it.utxos) - is SendEvent.CommentChange -> onCommentChange(it.value) + is SendEvent.CommentChange -> onCommentChange(it.value) - SendEvent.SpeedAndFee -> { - if (_sendUiState.value.isRetainedHardwarePayment) return@collect - if (_sendUiState.value.onchainFeeUi.estimates.isEmpty()) { - viewModelScope.launch { - refreshOnchainFeeUi() + SendEvent.SpeedAndFee -> { + if (_sendUiState.value.isRetainedHardwarePayment) return@collect + if (_sendUiState.value.onchainFeeUi.estimates.isEmpty()) { + viewModelScope.launch { + refreshOnchainFeeUi() + setSendEffect(SendEffect.NavigateToFee) + } + } else { setSendEffect(SendEffect.NavigateToFee) } - } else { - setSendEffect(SendEffect.NavigateToFee) } - } - SendEvent.SwipeToPay -> onSwipeToPay() - SendEvent.StartInitialSubscriptionPayment -> onStartInitialSubscriptionPayment() - SendEvent.CancelInitialSubscriptionPayment -> onCancelInitialSubscriptionPayment() - is SendEvent.ConfirmAmountWarning -> onConfirmAmountWarning(it.warning) - SendEvent.DismissAmountWarning -> onDismissAmountWarning() - SendEvent.EstimateMaxRoutingFee -> viewModelScope.launch { - estimateMaxAmountRoutingFee() - } + SendEvent.SwipeToPay -> onSwipeToPay() + SendEvent.StartInitialSubscriptionPayment -> onStartInitialSubscriptionPayment() + SendEvent.CancelInitialSubscriptionPayment -> onCancelInitialSubscriptionPayment() + is SendEvent.ConfirmAmountWarning -> onConfirmAmountWarning(it.warning) + SendEvent.DismissAmountWarning -> onDismissAmountWarning() + SendEvent.EstimateMaxRoutingFee -> viewModelScope.launch { + estimateMaxAmountRoutingFee() + } - SendEvent.PayConfirmed -> onConfirmPay() - SendEvent.ClearPayConfirmation -> _sendUiState.update { s -> s.copy(shouldConfirmPay = false) } - SendEvent.BackToAmount -> setSendEffect(SendEffect.PopBack(SendRoute.Amount)) - SendEvent.NavToAddress -> setSendEffect(SendEffect.NavigateToAddress) - SendEvent.Contacts -> setSendEffect( - if (isPaykitEnabled.value) SendEffect.NavigateToContacts else SendEffect.NavigateToComingSoon - ) + SendEvent.PayConfirmed -> onConfirmPay() + SendEvent.ClearPayConfirmation -> _sendUiState.update { s -> s.copy(shouldConfirmPay = false) } + SendEvent.BackToAmount -> setSendEffect(SendEffect.PopBack(SendRoute.Amount)) + SendEvent.NavToAddress -> setSendEffect(SendEffect.NavigateToAddress) + SendEvent.Contacts -> setSendEffect( + if (isPaykitEnabled.value) { + SendEffect.NavigateToContacts + } else { + SendEffect.NavigateToComingSoon + } + ) + } + } catch (_: CancellationException) { + currentCoroutineContext().ensureActive() } } } @@ -3225,8 +3266,11 @@ class AppViewModel @Inject constructor( } } - private suspend fun extractViableLightningInvoice(params: Map?): LightningInvoice? = - decodeLightningParam(params) + private suspend fun extractViableLightningInvoice( + params: Map?, + walletGeneration: Long? = connectionRecovery.currentGeneration(), + ): LightningInvoice? = + decodeLightningParam(params).also { requireCurrentPaymentPreparation(walletGeneration) } ?.takeIf { lnInv -> if (lnInv.isExpired) { Logger.debug( @@ -3235,14 +3279,16 @@ class AppViewModel @Inject constructor( ) return@takeIf false } - if (lnInv.isOwnInvoice()) { + val isOwnInvoice = lnInv.isOwnInvoice() + requireCurrentPaymentPreparation(walletGeneration) + if (isOwnInvoice) { Logger.debug( "Skipped own lightning invoice in unified URI, defaulting to onchain", context = TAG, ) return@takeIf false } - waitForUsableChannels() + waitForUsableChannels(walletGeneration) val canSend = lightningRepo.canSend(lnInv.amountSatoshis.coerceAtLeast(1u)) if (!canSend) { val nodeState = lightningRepo.lightningState.value.nodeLifecycleState @@ -3268,8 +3314,11 @@ class AppViewModel @Inject constructor( return nodeId != null && payee.equals(nodeId, ignoreCase = true) } - private suspend fun hasOwnLightningInvoice(params: Map?): Boolean = - decodeLightningParam(params)?.isPayee(lightningRepo.getLastKnownNodeId()) == true + private suspend fun hasOwnLightningInvoice( + params: Map?, + walletGeneration: Long? = connectionRecovery.currentGeneration(), + ): Boolean = decodeLightningParam(params).also { requireCurrentPaymentPreparation(walletGeneration) } + ?.isPayee(lightningRepo.getLastKnownNodeId()) == true private suspend fun decodeLightningParam(params: Map?): LightningInvoice? = params?.get("lightning")?.let { bolt11 -> @@ -3306,7 +3355,9 @@ class AppViewModel @Inject constructor( preserveUntilComplete: Boolean = false, allowPubkyAuth: Boolean = isMainScanner, suppressQuickPay: Boolean = false, + walletGeneration: Long? = connectionRecovery.currentGeneration(), ): Job? { + if (!canPrepareSend(walletGeneration)) return null if (contactPaymentContext == null && isUsdtPaymentInput(data.trim()) && hasActiveUsdtSend() ) { @@ -3323,6 +3374,7 @@ class AppViewModel @Inject constructor( contactPaymentContext = contactPaymentContext, allowPubkyAuth = allowPubkyAuth, suppressQuickPay = suppressQuickPay, + walletGeneration = walletGeneration, ) if (!_isAuthenticated.value) { enqueueDeferredScan(deferrable) @@ -3332,7 +3384,8 @@ class AppViewModel @Inject constructor( val normalized = data.removeLightningSchemes() val scheduled = scheduledScan val scanId = scanLogId(data, contactPaymentContext ?: scheduled?.contactPaymentContext) - val isSameActiveScan = normalized == scheduled?.normalizedInput && + val isSameActiveScan = walletGeneration == scheduled?.walletGeneration && + normalized == scheduled?.normalizedInput && scheduled.job.isActive && (scheduled.contactPaymentContext == contactPaymentContext || contactPaymentContext == null) if (isSameActiveScan) { @@ -3355,11 +3408,20 @@ class AppViewModel @Inject constructor( clearPaymentRequestPreparation(previousPreparation) scanMutex.withLock { try { + if (!canPrepareSend(walletGeneration)) return@withLock if (!awaitPubkyDeeplinkInitialization(source, data, allowPubkyAuth)) return@withLock + if (!canPrepareSend(walletGeneration)) return@withLock if (deferLockedScan(deferrable)) return@withLock prepareContactPaymentContextForScan(normalized, allowPubkyAuth, contactPaymentContext) if (startDelay > Duration.ZERO) delay(startDelay) - handleScan(data, routePubkyKeys, contactPaymentContext, allowPubkyAuth, suppressQuickPay) + handleScan( + data, + routePubkyKeys, + contactPaymentContext, + allowPubkyAuth, + suppressQuickPay, + walletGeneration + ) } finally { hidePubkyAuthWaitToast() } @@ -3370,6 +3432,7 @@ class AppViewModel @Inject constructor( normalizedInput = normalized, contactPaymentContext = contactPaymentContext, mustComplete = preserveUntilComplete, + walletGeneration = walletGeneration, ) scheduledScan = nextScheduledScan @@ -3519,13 +3582,17 @@ class AppViewModel @Inject constructor( val normalized = scan.data.removeLightningSchemes() synchronized(deferredScanLock) { val queued = deferredScan - if (queued?.data?.removeLightningSchemes() == normalized) { + if (queued?.walletGeneration == scan.walletGeneration && + queued?.data?.removeLightningSchemes() == normalized + ) { deferredScan = queued.mergeDuplicate(scan, scanId) return } if (!scan.isPubkyAuth && (queued?.isPubkyAuth == true || scanAfterPubkyAuth != null)) { val followUp = scanAfterPubkyAuth - if (followUp?.data?.removeLightningSchemes() == normalized) { + if (followUp?.walletGeneration == scan.walletGeneration && + followUp?.data?.removeLightningSchemes() == normalized + ) { scanAfterPubkyAuth = followUp.mergeDuplicate(scan, scanId) return } @@ -3598,6 +3665,7 @@ class AppViewModel @Inject constructor( preserveUntilComplete = true, allowPubkyAuth = pending.allowPubkyAuth, suppressQuickPay = pending.suppressQuickPay, + walletGeneration = pending.walletGeneration, ) } @@ -3709,10 +3777,15 @@ class AppViewModel @Inject constructor( private fun needsUsdtEnable(hasUsdt: Boolean, resumesPayment: Boolean, usdtOnly: Boolean) = hasUsdt && !isUsdtEnabled.value && !resumesPayment && usdtOnly - private fun enableUsdtForContactPayment(payment: ContactPaymentContext, fromMainScanner: Boolean) { + private fun enableUsdtForContactPayment( + payment: ContactPaymentContext, + fromMainScanner: Boolean, + walletGeneration: Long?, + ) { requestUsdtEnable(onCancel = { cancelUsdtContactPayment(payment) }) { - if (synchronized(contactPaymentContextLock) { activeContactPaymentContext } == payment) { - openPaykitEndpoints(payment, fromMainScanner) + val currentPayment = synchronized(contactPaymentContextLock) { activeContactPaymentContext } + if (canPrepareSend(walletGeneration) && currentPayment == payment) { + openPaykitEndpoints(payment, fromMainScanner, walletGeneration) } } } @@ -3734,21 +3807,29 @@ class AppViewModel @Inject constructor( } } - private suspend fun openPaykitEndpoints(payment: ContactPaymentContext, fromMainScanner: Boolean) { + private suspend fun openPaykitEndpoints( + payment: ContactPaymentContext, + fromMainScanner: Boolean, + walletGeneration: Long?, + ) { + requireCurrentPaymentPreparation(walletGeneration) val decoded = payment.endpoints.filter { it.methodId != MethodId.UsdtArbitrum } - .map { coreService.decode(it.paymentRequest) } + .map { + coreService.decode(it.paymentRequest).also { requireCurrentPaymentPreparation(walletGeneration) } + } val address = decoded.filterIsInstance().lastOrNull()?.invoice?.address.orEmpty() val invoice = decoded.filterIsInstance().lastOrNull()?.invoice val lnurl = decoded.filterIsInstance().lastOrNull()?.let { LnurlParams.LnurlPay(it.data) } val usdtEndpoint = payment.endpoints.firstOrNull { it.methodId == MethodId.UsdtArbitrum } val resumesUsdtPayment = payment.incomingPaymentRequest?.let { paykitUsdtPayments.hasBinding(it.id) } == true if (needsUsdtEnable(usdtEndpoint != null, resumesUsdtPayment, decoded.isEmpty())) { - enableUsdtForContactPayment(payment, fromMainScanner) + enableUsdtForContactPayment(payment, fromMainScanner, walletGeneration) return } val recipient = usdtEndpoint?.value?.takeIf { isUsdtEnabled.value || resumesUsdtPayment } val hasBitcoinEndpoint = address.isNotEmpty() || invoice != null || lnurl != null val canUseLightning = canFundPaykitLightning(invoice, lnurl) + requireCurrentPaymentPreparation(walletGeneration) val canUseOnchain = address.isNotEmpty() && walletRepo.balanceState.value.maxSendOnchainSats > Defaults.dustLimit.toULong() val useUsdt = recipient != null && prefersUsdt(payment, hasBitcoinEndpoint, canUseLightning || canUseOnchain) @@ -3763,6 +3844,7 @@ class AppViewModel @Inject constructor( ) } if (usdtRepo.shouldRefresh(isUsdtEnabled.value)) usdtRepo.refresh(history = false) + requireCurrentPaymentPreparation(walletGeneration) _sendUiState.update { it.copy( isAmountInputValid = if (it.paykitUsesUsdt) { @@ -3775,15 +3857,21 @@ class AppViewModel @Inject constructor( ) } refreshPaykitRequestAmount() + requireCurrentPaymentPreparation(walletGeneration) updateCanSwitchWallet() val initialPayment = payment.initialUsdtSubscriptionPayment(Clock.System.now())?.takeIf { useUsdt } if (initialPayment != null) { paykitReviewedAmount = initialPayment.amount paykitPaymentTerms = initialPayment _sendUiState.update { it.copy(requiresAmountReview = false) } - navigateToSendRoute(fromMainScanner, SendRoute.UsdtReview, SendEffect.NavigateToUsdtReview) - } else if (!openBitcoinRequestConfirmation(fromMainScanner)) { - navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount) + navigateToSendRoute( + fromMainScanner, + SendRoute.UsdtReview, + SendEffect.NavigateToUsdtReview, + walletGeneration + ) + } else if (!openBitcoinRequestConfirmation(fromMainScanner, walletGeneration)) { + navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount, walletGeneration) } } @@ -3796,12 +3884,16 @@ class AppViewModel @Inject constructor( return state.shouldAutomaticallyPay && payment == paykitPaymentTerms && payment.amount == paykitReviewedAmount } - private suspend fun openBitcoinRequestConfirmation(fromMainScanner: Boolean): Boolean { + private suspend fun openBitcoinRequestConfirmation( + fromMainScanner: Boolean, + walletGeneration: Long? = connectionRecovery.currentGeneration(), + ): Boolean { if (activeIncomingPaymentRequest() == null || _sendUiState.value.usdtRecipient != null) return false if (!bindBitcoinRequestTerms()) return false - navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm) - refreshOnchainSendIfNeeded() - estimateLightningRoutingFeesIfNeeded() + requireCurrentPaymentPreparation(walletGeneration) + navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm, walletGeneration) + refreshOnchainSendIfNeeded(walletGeneration) + estimateLightningRoutingFeesIfNeeded(walletGeneration) return true } @@ -4115,10 +4207,13 @@ class AppViewModel @Inject constructor( refreshOnchainSendIfNeeded()?.join() } - private suspend fun selectHardwareFundingSourceForAmount(amount: ULong): Boolean { + private suspend fun selectHardwareFundingSourceForAmount( + amount: ULong, + walletGeneration: Long? = connectionRecovery.currentGeneration(), + ): Boolean { val state = _sendUiState.value for (wallet in hwWalletRepo.wallets.value) { - val available = hardwareMaxSpendable(wallet.id, state.address, state.speed) + val available = hardwareMaxSpendable(wallet.id, state.address, state.speed, walletGeneration) if (amount > available) continue _sendUiState.update { it.copy( @@ -4132,7 +4227,7 @@ class AppViewModel @Inject constructor( ) } updateCanSwitchWallet() - refreshOnchainSendIfNeeded() + refreshOnchainSendIfNeeded(walletGeneration) return true } return false @@ -4425,13 +4520,18 @@ class AppViewModel @Inject constructor( contactPaymentContext: ContactPaymentContext?, allowPubkyAuth: Boolean, suppressQuickPay: Boolean, + walletGeneration: Long?, ) = withContext(bgDispatcher) { + if (!canPrepareSend(walletGeneration)) { + clearCancelledContactContext(contactPaymentContext) + return@withContext + } if (rejectPubkyAuthScan(result, allowPubkyAuth, contactPaymentContext)) return@withContext val input = normalizedScanInput(result, routePubkyKeys) ?: return@withContext if (contactPaymentContext == null && isUsdtPaymentInput(input.trim())) { - handleUsdtPaymentInput(input.trim()) + handleUsdtPaymentInput(input.trim(), walletGeneration) return@withContext } @@ -4439,25 +4539,30 @@ class AppViewModel @Inject constructor( val incomingPaymentRequest = activeIncomingPaymentRequest() val isPaymentRequest = incomingPaymentRequest != null // always reset state on new scan - resetSendState( - contactPaymentProfile = contactPaymentProfile, - isPaymentRequest = isPaymentRequest, - paymentRequestNote = incomingPaymentRequest?.note, - isMerchantPaymentRequest = incomingPaymentRequest?.isMerchantRequest == true, - isSubscriptionPayment = incomingPaymentRequest?.billingPeriod != null, - isInitialSubscriptionPayment = synchronized(contactPaymentContextLock) { - activeContactPaymentContext?.isInitialSubscriptionPayment == true - }, - incomingPaymentRequestId = incomingPaymentRequest?.id, - selectedTags = synchronized(contactPaymentContextLock) { - activeContactPaymentContext?.selectedTags ?: persistentListOf() - }, - ) + if (!resetSendState( + contactPaymentProfile = contactPaymentProfile, + isPaymentRequest = isPaymentRequest, + paymentRequestNote = incomingPaymentRequest?.note, + isMerchantPaymentRequest = incomingPaymentRequest?.isMerchantRequest == true, + isSubscriptionPayment = incomingPaymentRequest?.billingPeriod != null, + isInitialSubscriptionPayment = synchronized(contactPaymentContextLock) { + activeContactPaymentContext?.isInitialSubscriptionPayment == true + }, + incomingPaymentRequestId = incomingPaymentRequest?.id, + selectedTags = synchronized(contactPaymentContextLock) { + activeContactPaymentContext?.selectedTags ?: persistentListOf() + }, + walletGeneration = walletGeneration, + ) + ) { + clearCancelledContactContext(contactPaymentContext) + return@withContext + } resetQuickPay() val fromMainScanner = isMainScanner if (contactPaymentContext != null && requiresPaykitAssetSelection(contactPaymentContext)) { - openPaykitContactPayment(contactPaymentContext, fromMainScanner, suppressQuickPay) + openPaykitContactPayment(contactPaymentContext, fromMainScanner, suppressQuickPay, walletGeneration) return@withContext } @@ -4534,7 +4639,11 @@ class AppViewModel @Inject constructor( .getOrNull() currentCoroutineContext().ensureActive() - handleDecodedScan(scan, input, fromMainScanner, suppressQuickPay) + if (!canPrepareSend(walletGeneration)) { + clearCancelledContactContext(contactPaymentContext) + return@withContext + } + handleDecodedScan(scan, input, fromMainScanner, suppressQuickPay, walletGeneration) } private fun normalizedScanInput(result: String, routePubkyKeys: Boolean): String? { @@ -4552,7 +4661,8 @@ class AppViewModel @Inject constructor( } } - private fun handleUsdtPaymentInput(input: String) { + private fun handleUsdtPaymentInput(input: String, walletGeneration: Long?) { + if (!canPrepareSend(walletGeneration)) return val request = if (Env.isUsdtEnabled && activeHardwareWalletId == null && !usdtRepo.isSendPresented) { parseUsdtPaymentRequest(input).getOrNull() } else { @@ -4567,12 +4677,14 @@ class AppViewModel @Inject constructor( return } if (!isUsdtEnabled.value) { - requestUsdtEnable { handleUsdtPaymentInput(input) } + requestUsdtEnable { handleUsdtPaymentInput(input, walletGeneration) } return } val amount = request.amount?.let { PaykitAmount(PaykitAsset.USDT, it).value }.orEmpty() hideSheet() - mainScreenEffect(MainScreenEffect.Navigate(Routes.UsdtPayment(request.recipient, amount))) + mainScreenEffect(MainScreenEffect.Navigate(Routes.UsdtPayment(request.recipient, amount))) { + canPrepareSend(walletGeneration) + } } private fun requiresPaykitAssetSelection(payment: ContactPaymentContext): Boolean = @@ -4596,16 +4708,30 @@ class AppViewModel @Inject constructor( input: String, fromMainScanner: Boolean, suppressQuickPay: Boolean, + walletGeneration: Long?, ) { + requireCurrentPaymentPreparation(walletGeneration) if (activeHardwareWalletId != null && scan != null && scan !is Scanner.OnChain) { rejectHardwarePaymentTarget() return } when (scan) { - is Scanner.OnChain -> onScanOnchain(scan.invoice, input, fromMainScanner, suppressQuickPay) - is Scanner.Lightning -> onScanLightning(scan.invoice, input, fromMainScanner, suppressQuickPay) - is Scanner.LnurlPay -> onScanLnurlPay(scan.data, fromMainScanner, suppressQuickPay) + is Scanner.OnChain -> onScanOnchain( + scan.invoice, + input, + fromMainScanner, + suppressQuickPay, + walletGeneration + ) + is Scanner.Lightning -> onScanLightning( + scan.invoice, + input, + fromMainScanner, + suppressQuickPay, + walletGeneration + ) + is Scanner.LnurlPay -> onScanLnurlPay(scan.data, fromMainScanner, suppressQuickPay, walletGeneration) is Scanner.LnurlWithdraw -> handleNonPaymentScan { onScanLnurlWithdraw(scan.data, fromMainScanner) } is Scanner.LnurlAuth -> handleNonPaymentScan { onScanLnurlAuth(scan.data, fromMainScanner) } is Scanner.LnurlChannel -> handleNonPaymentScan { onScanLnurlChannel(scan.data) } @@ -4822,18 +4948,21 @@ class AppViewModel @Inject constructor( scanResult: String, fromMainScanner: Boolean, suppressQuickPay: Boolean, + walletGeneration: Long?, ) { - val validatedAddress = runCatching { coreService.validateBitcoinAddress(invoice.address) } - .getOrElse { - if (clearIncomingPaymentRequestTarget()) return - toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.other__scan_err_decoding), - description = context.getString(R.string.wallet__error_invalid_bitcoin_address), - testTag = "InvalidAddressToast", - ) - return - } + requireCurrentPaymentPreparation(walletGeneration) + val validation = runSuspendCatching { coreService.validateBitcoinAddress(invoice.address) } + requireCurrentPaymentPreparation(walletGeneration) + val validatedAddress = validation.getOrElse { + if (clearIncomingPaymentRequestTarget()) return + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.other__scan_err_decoding), + description = context.getString(R.string.wallet__error_invalid_bitcoin_address), + testTag = "InvalidAddressToast", + ) + return + } if (NetworkValidationHelper.isNetworkMismatch(validatedAddress.network.toLdkNetwork(), Env.network)) { if (clearIncomingPaymentRequestTarget()) return @@ -4847,7 +4976,7 @@ class AppViewModel @Inject constructor( } val hardwareWalletId = activeHardwareWalletId val selectedMaxSendOnchain = if (hardwareWalletId != null) { - hardwareMaxSpendable(hardwareWalletId, invoice.address, _sendUiState.value.speed) + hardwareMaxSpendable(hardwareWalletId, invoice.address, _sendUiState.value.speed, walletGeneration) } else { walletRepo.balanceState.value.maxSendOnchainSats } @@ -4855,7 +4984,7 @@ class AppViewModel @Inject constructor( val incomingPaymentRequest = activeIncomingPaymentRequest() val lnInvoice = if (hardwareWalletId == null) { - extractViableLightningInvoice(invoice.params)?.takeIf { + extractViableLightningInvoice(invoice.params, walletGeneration)?.takeIf { incomingPaymentRequest?.acceptsLightningInvoiceAmountSats(it.amountSatoshis) != false } } else { @@ -4887,7 +5016,7 @@ class AppViewModel @Inject constructor( if (incomingPaymentRequest != null) { if (lnInvoice != null) { - waitForUsableChannels() + waitForUsableChannels(walletGeneration) val onchainAmount = incomingPaymentRequest.payment( PublicPaykitRepo.onchainMethodId(invoice.address), Clock.System.now() @@ -4913,7 +5042,7 @@ class AppViewModel @Inject constructor( _sendUiState.value.payMethod == SendMethod.ONCHAIN && _sendUiState.value.hardwareWalletId == null ) { - selectHardwareFundingSourceForAmount(amount) + selectHardwareFundingSourceForAmount(amount, walletGeneration) } if (!validateAmount(amount)) { val isLightning = _sendUiState.value.payMethod == SendMethod.LIGHTNING @@ -4948,9 +5077,9 @@ class AppViewModel @Inject constructor( } _sendUiState.update { it.copy(isAmountInputValid = validateAmount(amount)) } - navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm) - refreshOnchainSendIfNeeded() - estimateLightningRoutingFeesIfNeeded() + navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm, walletGeneration) + refreshOnchainSendIfNeeded(walletGeneration) + estimateLightningRoutingFeesIfNeeded(walletGeneration) return } @@ -4964,18 +5093,19 @@ class AppViewModel @Inject constructor( invoice = lnInvoice, fromMainScanner = fromMainScanner, suppressQuickPay = suppressQuickPay, + walletGeneration = walletGeneration, ) if (quickPayHandled) return - navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm) - refreshOnchainSendIfNeeded() - estimateLightningRoutingFeesIfNeeded() + navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm, walletGeneration) + refreshOnchainSendIfNeeded(walletGeneration) + estimateLightningRoutingFeesIfNeeded(walletGeneration) return } // Check on-chain balance before proceeding to amount screen if (maxSendOnchain == 0uL && _sendUiState.value.payMethod == SendMethod.ONCHAIN) { - if (hardwareWalletId == null && hasOwnLightningInvoice(invoice.params)) { + if (hardwareWalletId == null && hasOwnLightningInvoice(invoice.params, walletGeneration)) { toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.other__pay_self_invoice_title), @@ -5021,20 +5151,22 @@ class AppViewModel @Inject constructor( context = TAG, ) - navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount) + navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount, walletGeneration) } private suspend fun hardwareMaxSpendable( walletId: String, address: String, speed: TransactionSpeed, + walletGeneration: Long? = connectionRecovery.currentGeneration(), ): ULong { + requireCurrentPaymentPreparation(walletGeneration) val satsPerVByte = hardwareSatsPerVByte(speed) return hwWalletRepo.maxSpendableFunding( walletId = walletId, address = address, satsPerVByte = satsPerVByte, - ).getOrElse { + ).also { requireCurrentPaymentPreparation(walletGeneration) }.getOrElse { hardwareEstimatedAvailable(walletId, speed) } } @@ -5082,9 +5214,11 @@ class AppViewModel @Inject constructor( payment: ContactPaymentContext, fromMainScanner: Boolean, suppressQuickPay: Boolean, + walletGeneration: Long?, ) { + requireCurrentPaymentPreparation(walletGeneration) if (activeHardwareWalletId == null) { - openPaykitEndpoints(payment, fromMainScanner) + openPaykitEndpoints(payment, fromMainScanner, walletGeneration) return } val endpoint = payment.endpoints.firstOrNull { it.methodId.isOnchain } @@ -5097,6 +5231,7 @@ class AppViewModel @Inject constructor( endpoint.paymentRequest, fromMainScanner, suppressQuickPay, + walletGeneration, ) } @@ -5177,7 +5312,9 @@ class AppViewModel @Inject constructor( scanResult: String, fromMainScanner: Boolean, suppressQuickPay: Boolean, + walletGeneration: Long?, ) { + requireCurrentPaymentPreparation(walletGeneration) if (invoice.isExpired) { if (clearIncomingPaymentRequestTarget()) return toast( @@ -5189,7 +5326,9 @@ class AppViewModel @Inject constructor( return } - if (invoice.isOwnInvoice()) { + val isOwnInvoice = invoice.isOwnInvoice() + requireCurrentPaymentPreparation(walletGeneration) + if (isOwnInvoice) { rejectOwnInvoiceScan() return } @@ -5210,10 +5349,11 @@ class AppViewModel @Inject constructor( invoice = invoice, fromMainScanner = fromMainScanner, suppressQuickPay = suppressQuickPay, + walletGeneration = walletGeneration, ) if (quickPayHandled) return - waitForUsableChannels() + waitForUsableChannels(walletGeneration) if (!lightningRepo.canSend(amount)) { val maxSendLightning = walletRepo.balanceState.value.maxSendLightningSats val shortfall = amount.safe() - maxSendLightning.safe() @@ -5243,13 +5383,13 @@ class AppViewModel @Inject constructor( if (amount > 0uL) { Logger.info("Found amount in invoice, proceeding with payment", context = TAG) - navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm) - estimateLightningRoutingFeesIfNeeded() + navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm, walletGeneration) + estimateLightningRoutingFeesIfNeeded(walletGeneration) return } Logger.info("No amount found in invoice, proceeding to enter amount", context = TAG) - navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount) + navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount, walletGeneration) } private fun rejectOwnInvoiceScan() { @@ -5263,7 +5403,13 @@ class AppViewModel @Inject constructor( hideSheet() } - private suspend fun onScanLnurlPay(data: LnurlPayData, fromMainScanner: Boolean, suppressQuickPay: Boolean) { + private suspend fun onScanLnurlPay( + data: LnurlPayData, + fromMainScanner: Boolean, + suppressQuickPay: Boolean, + walletGeneration: Long?, + ) { + requireCurrentPaymentPreparation(walletGeneration) Logger.debug("LNURL: $data", context = TAG) val isFixed = data.isFixedAmount() @@ -5287,7 +5433,7 @@ class AppViewModel @Inject constructor( } val paymentAmount = incomingAmount ?: displaySats - waitForUsableChannels() + waitForUsableChannels(walletGeneration) if (!lightningRepo.canSend(paymentAmount.coerceAtLeast(1u))) { toast( type = Toast.ToastType.WARNING, @@ -5318,15 +5464,16 @@ class AppViewModel @Inject constructor( lnurlPay = data, fromMainScanner = fromMainScanner, suppressQuickPay = suppressQuickPay, + walletGeneration = walletGeneration, ) if (quickPayHandled) return - navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm) + navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm, walletGeneration) return } Logger.info("No amount found in lnurlPay, proceeding to enter amount manually", context = TAG) - navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount) + navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount, walletGeneration) } private suspend fun onScanLnurlWithdraw(data: LnurlWithdrawData, fromMainScanner: Boolean) { @@ -5382,7 +5529,9 @@ class AppViewModel @Inject constructor( fromMainScanner: Boolean, route: SendRoute, effect: SendEffect, + walletGeneration: Long? = connectionRecovery.currentGeneration(), ) { + if (!canPrepareSend(walletGeneration)) return val preparation = paymentRequestPreparation.value if (preparation != null) { if (!ownsPaymentRequestPreparation(preparation)) return @@ -5394,11 +5543,11 @@ class AppViewModel @Inject constructor( return } if (fromMainScanner) { - showSheet(Sheet.Send(route)) + showSheet(Sheet.Send(route)) { canPrepareSend(walletGeneration) } return } - setSendEffect(effect) + setSendEffect(effect) { canPrepareSend(walletGeneration) } } fun requestLnurlAuth(callback: String, k1: String, domain: String) { @@ -5472,7 +5621,9 @@ class AppViewModel @Inject constructor( suppressQuickPay: Boolean, lnurlPay: LnurlPayData? = null, invoice: LightningInvoice? = null, + walletGeneration: Long?, ): Boolean { + requireCurrentPaymentPreparation(walletGeneration) if (suppressQuickPay) { Logger.info("Skipping QuickPay for '$amountSats' sats deferred across the lock", context = TAG) return false @@ -5480,7 +5631,9 @@ class AppViewModel @Inject constructor( if (hasActiveContactPaymentContext()) return false val invoiceHash = invoice?.paymentHash?.toHex()?.takeIf { it.isNotBlank() } val open = invoiceHash != null && quickPayRepo.hasOpen(invoiceHash) - if (!open && !canApplyQuickPay(amountSats)) return false + val canApply = open || canApplyQuickPay(amountSats) + requireCurrentPaymentPreparation(walletGeneration) + if (!canApply) return false Logger.info("Using QuickPay for '$amountSats' sats", context = TAG) @@ -5524,7 +5677,7 @@ class AppViewModel @Inject constructor( Logger.debug("QuickPayData: $quickPayData", context = TAG) - navigateToSendRoute(fromMainScanner, SendRoute.QuickPay, SendEffect.NavigateToQuickPay) + navigateToSendRoute(fromMainScanner, SendRoute.QuickPay, SendEffect.NavigateToQuickPay, walletGeneration) return true } @@ -6704,7 +6857,11 @@ class AppViewModel @Inject constructor( } /** Reselect utxos for current amount & speed then refresh fees using updated utxos */ - private fun refreshOnchainSendIfNeeded(): Job? { + private fun refreshOnchainSendIfNeeded( + walletGeneration: Long? = connectionRecovery.currentGeneration(), + ): Job? { + if (!canPrepareSend(walletGeneration)) return null + val generation = sendStateGeneration val currentState = _sendUiState.value if (currentState.isRetainedHardwarePayment) return null if (currentState.payMethod != SendMethod.ONCHAIN || @@ -6717,24 +6874,35 @@ class AppViewModel @Inject constructor( updateOnchainFeeUi { it.copy(isLoading = true) } onchainSendRefreshJob?.cancel() val job = viewModelScope.launch(bgDispatcher, start = CoroutineStart.LAZY) { + requireCurrentPaymentPreparation(walletGeneration) + if (generation != sendStateGeneration) return@launch + val settings = settingsStore.data.first() + requireCurrentPaymentPreparation(walletGeneration) + if (generation != sendStateGeneration) return@launch // preselect utxos for deterministic fee estimation if ( currentState.hardwareWalletId == null && - settingsStore.data.first().coinSelectAuto && + settings.coinSelectAuto && currentState.selectedUtxos == null ) { lightningRepo.getFeeRateForSpeed(currentState.speed, currentState.feeRates) .mapCatching { satsPerVByte -> + requireCurrentPaymentPreparation(walletGeneration) + if (generation != sendStateGeneration) throw CancellationException("Send state changed") lightningRepo.determineUtxosToSpend( sats = currentState.amount, satsPerVByte = satsPerVByte, ) } .onSuccess { utxos -> + requireCurrentPaymentPreparation(walletGeneration) + if (generation != sendStateGeneration) return@onSuccess _sendUiState.update { it.copy(selectedUtxos = utxos?.toImmutableList()) } } } - refreshOnchainFeeUi() + requireCurrentPaymentPreparation(walletGeneration) + if (generation != sendStateGeneration) return@launch + refreshOnchainFeeUi(walletGeneration) } onchainSendRefreshJob = job job.invokeOnCompletion { @@ -6744,7 +6912,11 @@ class AppViewModel @Inject constructor( return job } - private suspend fun refreshOnchainFeeUi() = withContext(bgDispatcher) { + private suspend fun refreshOnchainFeeUi( + walletGeneration: Long? = connectionRecovery.currentGeneration(), + ) = withContext(bgDispatcher) { + requireCurrentPaymentPreparation(walletGeneration) + val generation = sendStateGeneration val currentState = _sendUiState.value if (currentState.isRetainedHardwarePayment) return@withContext updateOnchainFeeUi { it.copy(isLoading = true) } @@ -6763,11 +6935,17 @@ class AppViewModel @Inject constructor( speeds.map { speed -> async { val rate = FeeRate.fromSpeed(speed) - val fee = if (currentState.feeRates?.getSatsPerVByteFor(speed) != 0u) getFeeEstimate(speed) else 0 + val fee = if (currentState.feeRates?.getSatsPerVByteFor(speed) != 0u) { + getFeeEstimate(speed, walletGeneration) + } else { + 0 + } rate to fee } }.awaitAll().toMap() } + requireCurrentPaymentPreparation(walletGeneration) + if (generation != sendStateGeneration) return@withContext val rate = FeeRate.fromSpeed(currentState.speed) updateOnchainFeeUi { @@ -6786,7 +6964,10 @@ class AppViewModel @Inject constructor( } } - private suspend fun estimateLightningRoutingFeesIfNeeded() { + private suspend fun estimateLightningRoutingFeesIfNeeded( + walletGeneration: Long? = connectionRecovery.currentGeneration(), + ) { + requireCurrentPaymentPreparation(walletGeneration) val generation = sendStateGeneration val currentState = _sendUiState.value if (currentState.payMethod != SendMethod.LIGHTNING) return @@ -6798,6 +6979,7 @@ class AppViewModel @Inject constructor( lightningRepo.estimateRoutingFeesForAmount(decodedInvoice.bolt11, currentState.amount) } + requireCurrentPaymentPreparation(walletGeneration) feeResult.onSuccess { fee -> _sendUiState.update { if (generation != sendStateGeneration) return@update it @@ -6840,7 +7022,11 @@ class AppViewModel @Inject constructor( } } - private suspend fun getFeeEstimate(speed: TransactionSpeed? = null): Long { + private suspend fun getFeeEstimate( + speed: TransactionSpeed? = null, + walletGeneration: Long? = connectionRecovery.currentGeneration(), + ): Long { + requireCurrentPaymentPreparation(walletGeneration) val currentState = _sendUiState.value val hardwareWalletId = currentState.hardwareWalletId if (hardwareWalletId != null) { @@ -6852,7 +7038,7 @@ class AppViewModel @Inject constructor( address = currentState.address, sats = currentState.amount, satsPerVByte = satsPerVByte.toULong(), - ).getOrNull()?.toLong() ?: 0 + ).also { requireCurrentPaymentPreparation(walletGeneration) }.getOrNull()?.toLong() ?: 0 } return lightningRepo.calculateTotalFee( amountSats = currentState.amount, @@ -6860,7 +7046,7 @@ class AppViewModel @Inject constructor( speed = speed ?: currentState.speed, utxosToSpend = currentState.selectedUtxos, feeRates = currentState.feeRates, - ).getOrDefault(0u).toLong() + ).also { requireCurrentPaymentPreparation(walletGeneration) }.getOrDefault(0u).toLong() } suspend fun resetSendState( @@ -6873,19 +7059,21 @@ class AppViewModel @Inject constructor( isInitialSubscriptionPayment: Boolean = false, incomingPaymentRequestId: PaykitPaymentRequestId? = null, selectedTags: ImmutableList = persistentListOf(), - ) { + walletGeneration: Long? = connectionRecovery.currentGeneration(), + ): Boolean { + if (!canPrepareSend(walletGeneration)) return false + val speed = settingsStore.data.first().defaultTransactionSpeed + if (!canPrepareSend(walletGeneration)) return false + blocktankRepo.refreshInfo() + if (!canPrepareSend(walletGeneration)) return false + val rates = blocktankRepo.blocktankState.value.info?.onchain?.feeRates + val hardwareAvailableSats = hardwareWalletId?.let { hardwareEstimatedAvailable(it, speed, rates) } ?: 0uL + if (!canPrepareSend(walletGeneration)) return false sendStateGeneration++ addressValidationJob?.cancel() paykitReviewedAmount = null paykitAmountConversion = PaykitAmountConversion() paykitPaymentTerms = null - val speed = settingsStore.data.first().defaultTransactionSpeed - val rates = let { - // Refresh blocktank info to get latest fee rates - blocktankRepo.refreshInfo() - blocktankRepo.blocktankState.value.info?.onchain?.feeRates - } - _sendUiState.update { SendUiState( speed = speed, @@ -6899,9 +7087,7 @@ class AppViewModel @Inject constructor( hardwareWalletName = hardwareWalletId?.let { walletId -> hwWalletRepo.wallets.value.find { it.id == walletId }?.name }, - hardwareAvailableSats = hardwareWalletId?.let { walletId -> - hardwareEstimatedAvailable(walletId, speed, rates) - } ?: 0uL, + hardwareAvailableSats = hardwareAvailableSats, isFundingSourceLoading = it.isFundingSourceLoading, isSubscriptionPayment = isSubscriptionPayment, isInitialSubscriptionPayment = isInitialSubscriptionPayment, @@ -6911,6 +7097,24 @@ class AppViewModel @Inject constructor( selectedTags = selectedTags, ) } + return true + } + + private suspend fun requireCurrentPaymentPreparation(walletGeneration: Long?) { + currentCoroutineContext().ensureActive() + if (!canPrepareSend(walletGeneration)) throw CancellationException("Payment preparation owner changed") + } + + private fun canPrepareSend(walletGeneration: Long?): Boolean { + if (!connectionRecovery.isCurrentGeneration(walletGeneration)) return false + return try { + !keychain.hasPendingWalletRestore() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Throwable) { + Logger.warn("Unable to read required wallet recovery before payment entry", error, context = TAG) + false + } } // endregion @@ -7054,12 +7258,14 @@ class AppViewModel @Inject constructor( } } - fun showSheet(sheetType: Sheet) { + fun showSheet(sheetType: Sheet, canPresent: () -> Boolean = { true }) { + if (!canPresent()) return timedSheetManager.cancelPendingCheck() if (sheetType !== paymentRequestPreparation.value?.sheet) clearPaymentRequestPreparation() val previousJob = sheetTransitionJob transitionSheet = sheetType val nextJob = viewModelScope.launch(start = CoroutineStart.LAZY) { + if (!canPresent()) return@launch receiveSheetContext = null if (_currentSheet.value != null && !replacesSubscriptionReview(sheetType)) { val replacesTimedSheet = _currentSheet.value is Sheet.TimedSheet @@ -7067,6 +7273,7 @@ class AppViewModel @Inject constructor( if (replacesTimedSheet) dismissTimedSheet() delay(SCREEN_TRANSITION_DELAY) } + if (!canPresent()) return@launch receiveSheetContext = (sheetType as? Sheet.Receive)?.let { receiveSheet -> ReceiveSheetContext( sheet = receiveSheet, @@ -7074,6 +7281,7 @@ class AppViewModel @Inject constructor( onchainAddress = walletRepo.getOnchainAddress(), ) } + if (!canPresent()) return@launch // A stale amount above inbound liquidity would hide the Auto tab until the receive state refreshes if (sheetType is Sheet.Receive) walletRepo.setBip21AmountSats(null) _currentSheet.update { sheetType } @@ -7676,6 +7884,8 @@ class AppViewModel @Inject constructor( suspend fun acceptSubscriptionAndStartPayment( displayedSubscription: PaykitSubscription, ): Result { + val walletGeneration = connectionRecovery.currentGeneration() + if (!canPrepareSend(walletGeneration)) return Result.failure(PaykitPaymentRequestError.RequestUnavailable) if (!_isAcceptingSubscription.compareAndSet(false, true)) { return Result.failure(PaykitPaymentRequestError.OperationInProgress).onFailure(::toast) } @@ -7685,7 +7895,9 @@ class AppViewModel @Inject constructor( val subscription = subscription(displayedSubscription.id) ?.takeIf { it == displayedSubscription } ?: throw PaykitPaymentRequestError.RequestUnavailable - val acceptedDueRequest = paykitPaymentRequestRepo.accept(subscription).getOrThrow() + val acceptance = paykitPaymentRequestRepo.accept(subscription) + if (!canPrepareSend(walletGeneration)) throw PaykitPaymentRequestError.RequestUnavailable + val acceptedDueRequest = acceptance.getOrThrow() if (acceptedDueRequest == null) { val accepted = subscription(displayedSubscription.id) if (accepted?.lifecycleState != PaymentRequestLifecycleState.ACTIVE_RECURRING) { @@ -7696,14 +7908,21 @@ class AppViewModel @Inject constructor( val resolution = privatePaykitRepo.beginPaymentRequestWaitingForUpdatedList(acceptedDueRequest) .getOrElse { error -> - showInitialSubscriptionPaymentFailure(acceptedDueRequest, error) + if (!showInitialSubscriptionPaymentFailure(acceptedDueRequest, error, walletGeneration)) { + throw PaykitPaymentRequestError.RequestUnavailable + } return@runSuspendCatching true } + if (!canPrepareSend(walletGeneration)) throw PaykitPaymentRequestError.RequestUnavailable if (resolution !is PublicPaykitPaymentResult.Opened) { - showInitialSubscriptionPaymentFailure( - acceptedDueRequest, - PaykitPaymentRequestError.RequestUnavailable, - ) + if (!showInitialSubscriptionPaymentFailure( + acceptedDueRequest, + PaykitPaymentRequestError.RequestUnavailable, + walletGeneration, + ) + ) { + throw PaykitPaymentRequestError.RequestUnavailable + } return@runSuspendCatching true } val scanJob = openContactPayment( @@ -7716,17 +7935,23 @@ class AppViewModel @Inject constructor( ) scanJob?.join() sheetTransitionJob?.join() + if (!canPrepareSend(walletGeneration)) throw PaykitPaymentRequestError.RequestUnavailable if (_currentSheet.value !is Sheet.Send && !showUsdtEnablePrompt) { + val paymentContext = synchronized(contactPaymentContextLock) { activeContactPaymentContext } paykitPaymentRequestRepo.markPresented(acceptedDueRequest) + if (!canPrepareSend(walletGeneration)) { + clearCancelledContactContext(paymentContext) + throw PaykitPaymentRequestError.RequestUnavailable + } val error = PaykitPaymentRequestError.RequestUnavailable val failure = error.toSendFailureDetails( context, _sendUiState.value.currentLightningPaymentRequest() ) - showSheet(Sheet.Send(SendRoute.errorFromFailure(failure))) + showSheet(Sheet.Send(SendRoute.errorFromFailure(failure))) { canPrepareSend(walletGeneration) } } true - }.onFailure(::toast) + }.onFailure { if (canPrepareSend(walletGeneration)) toast(it) } } finally { _isAcceptingSubscription.update { false } } @@ -7735,27 +7960,42 @@ class AppViewModel @Inject constructor( private suspend fun showInitialSubscriptionPaymentFailure( request: PaykitPaymentRequest, error: Throwable, - ) { + walletGeneration: Long?, + ): Boolean { + if (!canPrepareSend(walletGeneration)) return false val paymentContext = ContactPaymentContext( publicKey = request.counterparty, incomingPaymentRequest = request, isInitialSubscriptionPayment = true, ) + val profile = pubkyRepo.contacts.value.firstOrNull { + PubkyPublicKeyFormat.matches(it.publicKey, request.counterparty) + } ?: PubkyProfile.placeholder(request.counterparty) + if (!resetSendState( + contactPaymentProfile = profile, + isPaymentRequest = true, + isSubscriptionPayment = true, + isInitialSubscriptionPayment = true, + incomingPaymentRequestId = request.id, + walletGeneration = walletGeneration, + ) + ) { + return false + } setActiveContactPaymentContext(paymentContext) - resetSendState( - contactPaymentProfile = activeContactPaymentProfile(), - isPaymentRequest = true, - isSubscriptionPayment = true, - isInitialSubscriptionPayment = true, - incomingPaymentRequestId = request.id, - ) + _sendUiState.update { it.copy(requiresAmountReview = request.pricing.conversion != null) } paykitPaymentRequestRepo.markPresented(request) + if (!canPrepareSend(walletGeneration)) { + clearCancelledContactContext(paymentContext) + return false + } val failure = error.toSendFailureDetails(context, paymentRequest = null) if (_currentSheet.value is Sheet.Send) { - setSendEffect(SendEffect.NavigateToError(failure)) + setSendEffect(SendEffect.NavigateToError(failure)) { canPrepareSend(walletGeneration) } } else { - showSheet(Sheet.Send(SendRoute.errorFromFailure(failure))) + showSheet(Sheet.Send(SendRoute.errorFromFailure(failure))) { canPrepareSend(walletGeneration) } } + return true } suspend fun cancelSubscription(id: PaykitSubscriptionId): Result = viewModelScope.async { @@ -7862,6 +8102,8 @@ class AppViewModel @Inject constructor( id: PaykitPaymentRequestId, tags: ImmutableList, ) { + val walletGeneration = connectionRecovery.currentGeneration() + if (!canPrepareSend(walletGeneration)) return if (!_isRetryingInitialSubscriptionPayment.compareAndSet(false, true)) { toast(PaykitPaymentRequestError.OperationInProgress) return @@ -7869,17 +8111,24 @@ class AppViewModel @Inject constructor( clearActiveContactPaymentContext() viewModelScope.launch { try { + if (!canPrepareSend(walletGeneration)) return@launch refreshIncomingPaykitPaymentRequests(forceFresh = true) + if (!canPrepareSend(walletGeneration)) return@launch val request = paykitPaymentRequestRepo.pendingRequest(id) ?: run { toast(PaykitPaymentRequestError.RequestUnavailable) return@launch } val resolution = privatePaykitRepo.beginPaymentRequestWaitingForUpdatedList(request).getOrElse { - showInitialSubscriptionPaymentFailure(request, it) + showInitialSubscriptionPaymentFailure(request, it, walletGeneration) return@launch } + if (!canPrepareSend(walletGeneration)) return@launch if (resolution !is PublicPaykitPaymentResult.Opened) { - showInitialSubscriptionPaymentFailure(request, PaykitPaymentRequestError.RequestUnavailable) + showInitialSubscriptionPaymentFailure( + request, + PaykitPaymentRequestError.RequestUnavailable, + walletGeneration, + ) return@launch } val scanJob = openContactPayment( @@ -8052,13 +8301,21 @@ class AppViewModel @Inject constructor( } } - private fun processDeeplink(uri: Uri) = viewModelScope.launch { + private fun processDeeplink( + uri: Uri, + walletGeneration: Long? = connectionRecovery.currentGeneration(), + ) = viewModelScope.launch { val value = uri.toString() val isContactLink = PubkyContactLink.matches(uri) if (SamRockSetupRequest.isProtocolUrl(value)) { if (!walletRepo.walletExists()) return@launch - launchScan(source = ScanSource.DEEPLINK, data = value, startDelay = SCREEN_TRANSITION_DELAY) + launchScan( + source = ScanSource.DEEPLINK, + data = value, + startDelay = SCREEN_TRANSITION_DELAY, + walletGeneration = walletGeneration, + ) return@launch } @@ -8087,6 +8344,7 @@ class AppViewModel @Inject constructor( if (PubkyAuthRequest.isProtocolUrl(value)) { launchScan( source = ScanSource.DEEPLINK, + walletGeneration = walletGeneration, data = value, startDelay = SCREEN_TRANSITION_DELAY, allowPubkyAuth = true, @@ -8098,6 +8356,7 @@ class AppViewModel @Inject constructor( launchScan( source = ScanSource.DEEPLINK, + walletGeneration = walletGeneration, data = value, startDelay = SCREEN_TRANSITION_DELAY, routePubkyKeys = isContactLink, @@ -8314,6 +8573,7 @@ private data class ScheduledScan( val normalizedInput: String, val contactPaymentContext: ContactPaymentContext?, val mustComplete: Boolean, + val walletGeneration: Long?, ) private data class DeferredScan( @@ -8324,6 +8584,7 @@ private data class DeferredScan( val contactPaymentContext: ContactPaymentContext?, val allowPubkyAuth: Boolean, val suppressQuickPay: Boolean, + val walletGeneration: Long?, ) { val isPubkyAuth: Boolean get() = allowPubkyAuth && PubkyAuthRequest.isProtocolUrl(data) } diff --git a/app/src/test/java/to/bitkit/data/WalletConnectionRecoveryTest.kt b/app/src/test/java/to/bitkit/data/WalletConnectionRecoveryTest.kt index 97c536a3cb..5324176595 100644 --- a/app/src/test/java/to/bitkit/data/WalletConnectionRecoveryTest.kt +++ b/app/src/test/java/to/bitkit/data/WalletConnectionRecoveryTest.kt @@ -84,6 +84,20 @@ class WalletConnectionRecoveryTest : BaseUnitTest() { private fun instance(store: SettingsStore = settings) = WalletConnectionRecovery(keychain, db, binding, store) + @Test + fun `queued wallet generation is revoked during same seed replacement`() = test { + val original = sut.currentGeneration() + assertTrue(sut.isCurrentGeneration(original)) + + sut.withIdentityChange { + assertFalse(sut.isCurrentGeneration(original)) + assertNull(sut.currentGeneration()) + } + + assertFalse(sut.isCurrentGeneration(original)) + assertTrue(sut.isCurrentGeneration(sut.currentGeneration())) + } + @Test fun `new instance preserves selected fields through remote restore without changing unrelated settings`() = test { sut.withOperation(sut.captureOwner()) { it.save(electrum = "ssl://chosen.example:50002") } diff --git a/app/src/test/java/to/bitkit/env/BlocktankTestEndpointTest.kt b/app/src/test/java/to/bitkit/env/BlocktankTestEndpointTest.kt new file mode 100644 index 0000000000..41b9ea4205 --- /dev/null +++ b/app/src/test/java/to/bitkit/env/BlocktankTestEndpointTest.kt @@ -0,0 +1,45 @@ +package to.bitkit.env + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class BlocktankTestEndpointTest { + @Test + fun `disabled fixture ignores configuration`() { + assertNull(BlocktankTestEndpoint.resolve(false, "https://remote.example/api")) + } + + @Test + fun `empty configuration retains the ordinary endpoint`() { + assertNull(BlocktankTestEndpoint.resolve(true, null)) + assertNull(BlocktankTestEndpoint.resolve(true, " ")) + } + + @Test + fun `localhost fixture preserves explicit transport port and path`() { + listOf("http", "https").forEach { scheme -> + val endpoint = "$scheme://localhost:18685/api/v2" + assertEquals(endpoint, BlocktankTestEndpoint.resolve(true, " $endpoint ")) + } + } + + @Test + fun `unsafe or incomplete fixture configuration fails closed`() { + listOf( + "https://remote.example:18685/api", + "http://127.0.0.1:18685/api", + "http://localhost/api", + "http://localhost:18685", + "http://localhost:0/api", + "http://localhost:65536/api", + "file://localhost:18685/api", + "http://user@localhost:18685/api", + "http://localhost:18685/api?token=x", + "http://localhost:18685/api#fragment", + ).forEach { endpoint -> + assertFailsWith { BlocktankTestEndpoint.resolve(true, endpoint) } + } + } +} diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 1bf37156d7..b37a3f3b74 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -3,8 +3,10 @@ package to.bitkit.repositories import app.cash.turbine.test import com.google.firebase.messaging.FirebaseMessaging import com.synonym.bitkitcore.AddressType +import com.synonym.bitkitcore.BlocktankException import com.synonym.bitkitcore.FeeRates import com.synonym.bitkitcore.IBtInfo +import com.synonym.bitkitcore.IBtInfoOnchain import com.synonym.bitkitcore.ILspNode import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.LnurlException @@ -49,8 +51,10 @@ import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.doCallRealMethod import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.doThrow import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.isNull @@ -3176,6 +3180,93 @@ class LightningRepoTest : BaseUnitTest() { assertEquals(30uL, result.getOrNull()) } + @Test + fun `cached Blocktank rates preserve each selected speed after a gateway failure`() = test { + useCachedFeesAfterGatewayFailure() + + assertEquals(37uL, sut.getFeeRateForSpeed(TransactionSpeed.Fast).getOrThrow()) + assertEquals(19uL, sut.getFeeRateForSpeed(TransactionSpeed.Medium).getOrThrow()) + assertEquals(7uL, sut.getFeeRateForSpeed(TransactionSpeed.Slow).getOrThrow()) + } + + @Test + fun `ordinary send uses the selected cached fee without bypassing preparation`() = test { + val attempt = pendingSendAttempt().copy(feeRateSatsPerVByte = 37uL) + val repo = prepareGuardedSend(attempt) + doCallRealMethod().whenever(repo).getFeeRateForSpeed(any(), anyOrNull()) + val blocktank = useCachedFeesAfterGatewayFailure() + val accepted = OnchainSendOutcome.Accepted("ab".repeat(32)) + whenever( + lightningService.prepareOnchainSend( + any(), + any(), + any(), + anyOrNull(), + any(), + any(), + paymentDeadlineAt = anyOrNull(), + ) + ).thenReturn(preparedOutcome(accepted)) + whenever(onchainSendAttemptStore.recordOutcome(any(), any(), any())) + .thenReturn(attempt.copy(evidence = OnchainSendEvidence.Accepted, txid = accepted.txid)) + + assertEquals(accepted, repo.sendOnChain("address", 1_000uL, speed = TransactionSpeed.Fast).getOrThrow()) + + verify(blocktank).info(true) + verify(blocktank).info(false) + verify(lightningService).prepareOnchainSend( + "address", + 1_000uL, + 37uL, + null, + false, + attempt.walletIndex, + null, + ) + verify(onchainSendAttemptStore).broadcastPreparedCandidate(any(), any(), any(), any()) + } + + @Test + fun `either required restore marker blocks send before cached fee recovery`() = test { + startNodeForTesting() + val blocktank = useCachedFeesAfterGatewayFailure() + for (marker in listOf(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE, Keychain.Key.USDT_PENDING_BACKUP_RESTORE)) { + whenever(keychain.exists(marker.name)).thenReturn(true) + assertIs(sut.sendOnChain("address", 1_000uL).exceptionOrNull()) + whenever(keychain.exists(marker.name)).thenReturn(false) + } + verify(blocktank, never()).info(any()) + verify(onchainSendAttemptStore, never()).current() + verify(onchainSendAttemptStore, never()).broadcastPreparedCandidate(any(), any(), any(), any()) + verify(lightningService, never()).prepareOnchainSend( + any(), + any(), + any(), + anyOrNull(), + any(), + any(), + paymentDeadlineAt = anyOrNull(), + ) + } + + private suspend fun useCachedFeesAfterGatewayFailure(): BlocktankService { + val blocktank = spy(BlocktankService(coreService, lightningService)) + val onchain = mock() + whenever(onchain.feeRates).thenReturn(FeeRates(fast = 37u, mid = 19u, slow = 7u)) + val info = mock() + whenever(info.onchain).thenReturn(onchain) + val error = AppError( + BlocktankException.DataException( + "Failed to fetch info from Blocktank: Blocktank error: Failed to get info: " + + "Client error: Request failed with status 502 Bad Gateway: gateway unavailable" + ) + ) + doThrow(error).whenever(blocktank).info(true) + doReturn(info).whenever(blocktank).info(false) + whenever(coreService.blocktank).thenReturn(blocktank) + return blocktank + } + @Test fun `determineUtxosToSpend should return null when coinSelectAuto is false`() = test { val mockSettingsData = SettingsData(coinSelectAuto = false) diff --git a/app/src/test/java/to/bitkit/services/BlocktankFeeFallbackTest.kt b/app/src/test/java/to/bitkit/services/BlocktankFeeFallbackTest.kt new file mode 100644 index 0000000000..0045c89f4e --- /dev/null +++ b/app/src/test/java/to/bitkit/services/BlocktankFeeFallbackTest.kt @@ -0,0 +1,190 @@ +package to.bitkit.services + +import com.synonym.bitkitcore.BlocktankException +import com.synonym.bitkitcore.FeeRates +import com.synonym.bitkitcore.IBtInfo +import com.synonym.bitkitcore.IBtInfoOnchain +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.spy +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame + +class BlocktankFeeFallbackTest : BaseUnitTest() { + private val rates = FeeRates(fast = 37u, mid = 19u, slow = 7u) + private val service = spy(BlocktankService(mock(), mock())) + + @Test + fun `fresh rates do not read the cache`() = test { + doReturn(infoWithRates()).whenever(service).info(true) + + assertEquals(rates, service.getFees().getOrThrow()) + + verify(service, never()).info(false) + } + + @Test + fun `missing fresh rates use the cached schedule once`() = test { + doReturn(null).whenever(service).info(true) + doReturn(infoWithRates()).whenever(service).info(false) + + assertEquals(rates, service.getFees().getOrThrow()) + + inOrder(service) { + verify(service).info(true) + verify(service).info(false) + } + } + + @Test + fun `Blocktank info HTML 502 uses the cached schedule`() = test { + val error = AppError( + BlocktankException.DataException( + "Failed to fetch info from Blocktank: Blocktank error: Failed to get info: " + + "Client error: Request failed with status 502 Bad Gateway: Bad Gateway" + ) + ) + doThrow(error).whenever(service).info(true) + doReturn(infoWithRates()).whenever(service).info(false) + + assertEquals(rates, service.getFees().getOrThrow()) + + inOrder(service) { + verify(service).info(true) + verify(service).info(false) + } + } + + @Test + fun `Blocktank info JSON 502 uses the cached schedule`() = test { + val error = AppError( + BlocktankException.DataException( + "Failed to fetch info from Blocktank: Blocktank error: Failed to get info: " + + "Client error: Request failed (502 Bad Gateway): gateway unavailable" + ) + ) + doThrow(error).whenever(service).info(true) + doReturn(infoWithRates()).whenever(service).info(false) + + assertEquals(rates, service.getFees().getOrThrow()) + + inOrder(service) { + verify(service).info(true) + verify(service).info(false) + } + } + + @Test + fun `missing cache preserves the original gateway failure`() = test { + val error = badGateway() + doThrow(error).whenever(service).info(true) + doReturn(null).whenever(service).info(false) + + assertSame(error, service.getFees().exceptionOrNull()) + verify(service).info(false) + } + + @Test + fun `cache read failure remains a failure without another lookup`() = test { + val error = AppError("local cache unavailable") + doThrow(badGateway()).whenever(service).info(true) + doThrow(error).whenever(service).info(false) + + assertSame(error, assertFailsWith { service.getFees() }) + verify(service).info(false) + } + + @Test + fun `other typed data errors and unrelated 502 text do not use cached rates`() = test { + val prefix = "Failed to fetch info from Blocktank: Blocktank error: Failed to get info: Client error: " + val errors = listOf( + AppError(BlocktankException.DataException(prefix + "Response parsing failed: 502")), + AppError( + BlocktankException.DataException( + prefix + "Request failed with status 401 Unauthorized: 502 Bad Gateway" + ) + ), + AppError(BlocktankException.DataException(prefix + "Request failed with status 5020: Bad Gateway")), + AppError(BlocktankException.DataException("502 Bad Gateway")), + AppError(BlocktankException.DataException("Invalid parameters: 502")), + AppError("Request failed with status 502 Bad Gateway"), + IllegalStateException("database not initialized"), + ) + for (error in errors) { + doThrow(error).whenever(service).info(true) + assertSame(error, assertFailsWith { service.getFees() }) + } + verify(service, never()).info(false) + } + + @Test + fun `fresh cancellation never reads cached rates`() = test { + doThrow(CancellationException("cancelled")).whenever(service).info(true) + + assertFailsWith { service.getFees() } + verify(service, never()).info(false) + } + + @Test + fun `cached lookup cancellation is preserved`() = test { + doThrow(badGateway()).whenever(service).info(true) + doThrow(CancellationException("cancelled")).whenever(service).info(false) + + assertFailsWith { service.getFees() } + verify(service).info(false) + } + + @Test + fun `cancellation before cached fallback prevents its lookup`() = test { + doSuspendableAnswer { + currentCoroutineContext().cancel() + throw badGateway() + }.whenever(service).info(true) + + val attempt = async { service.getFees() } + assertFailsWith { attempt.await() } + verify(service, never()).info(false) + } + + @Test + fun `cancellation while cached lookup returns cannot expose successful rates`() = test { + doThrow(badGateway()).whenever(service).info(true) + doSuspendableAnswer { + currentCoroutineContext().cancel() + infoWithRates() + }.whenever(service).info(false) + + val attempt = async { service.getFees() } + assertFailsWith { attempt.await() } + verify(service).info(false) + } + + private fun badGateway() = AppError( + BlocktankException.DataException( + "Failed to fetch info from Blocktank: Blocktank error: Failed to get info: " + + "Client error: Request failed with status 502 Bad Gateway: gateway unavailable" + ) + ) + + private fun infoWithRates(): IBtInfo { + val onchain = mock() + whenever(onchain.feeRates).thenReturn(rates) + val info = mock() + whenever(info.onchain).thenReturn(onchain) + return info + } +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index b21e8ee307..56c5f144fe 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -103,6 +103,7 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.UsdtPreference +import to.bitkit.data.WalletConnectionRecovery import to.bitkit.data.dto.PlatformDetails import to.bitkit.data.dto.Platforms import to.bitkit.data.dto.ReleaseInfoDTO @@ -677,7 +678,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertNull(pubkyContactImportFailure.value) } + private val connectionRecovery: WalletConnectionRecovery = mock() + private var walletGeneration = 0L + private fun stubRepositories() { + whenever(connectionRecovery.currentGeneration()).thenAnswer { walletGeneration } + whenever(connectionRecovery.isCurrentGeneration(anyOrNull())).thenAnswer { + it.getArgument(0) == walletGeneration + } whenever(currencyRepo.currencyState).thenReturn(MutableStateFlow(CurrencyState())) whenever(context.getString(any())).thenReturn("") whenever(context.getSystemService(Context.CLIPBOARD_SERVICE)).thenReturn(clipboardManager) @@ -897,6 +905,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { context = context, bgDispatcher = testDispatcher, keychain = keychain, + connectionRecovery = connectionRecovery, lightningRepo = lightningRepo, pendingPaymentRepo = pendingPaymentRepo, walletRepo = walletRepo, @@ -4295,6 +4304,347 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(toastManager, never()).enqueue(any()) } + @Test + fun `required wallet recovery rejects a payment deeplink before fee lookup`() = test { + sut.setIsAuthenticated(true) + whenever(keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + val input = "bitcoin:$REGTEST_ADDRESS?amount=0.0001" + whenever(coreService.decode(input)).thenReturn( + Scanner.OnChain(OnChainInvoice(REGTEST_ADDRESS, 10_000u, null, null, null)), + ) + clearInvocations(blocktankRepo) + + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, input.toUri())) + advanceUntilIdle() + + verify(blocktankRepo, never()).refreshInfo() + assertNull(sut.currentSheet.value) + } + + @Test + fun `required USDT recovery rejects a payment deeplink before fee lookup`() = test { + sut.setIsAuthenticated(true) + whenever(keychain.exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + val input = "bitcoin:$REGTEST_ADDRESS?amount=0.0001" + whenever(coreService.decode(input)).thenReturn( + Scanner.OnChain(OnChainInvoice(REGTEST_ADDRESS, 10_000u, null, null, null)), + ) + clearInvocations(blocktankRepo) + + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, input.toUri())) + advanceUntilIdle() + + verify(blocktankRepo, never()).refreshInfo() + assertNull(sut.currentSheet.value) + } + + @Test + fun `unreadable required recovery rejects send initialization before fee lookup`() = test { + whenever(keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)) + .thenAnswer { throw AppError("Keychain unavailable") } + clearInvocations(blocktankRepo) + val before = sut.sendUiState.value + + sut.resetSendState() + advanceUntilIdle() + + verify(blocktankRepo, never()).refreshInfo() + assertEquals(before, sut.sendUiState.value) + } + + @Test + fun `recovery beginning during a delayed deeplink blocks its fee lookup`() = test { + sut.setIsAuthenticated(true) + val input = "bitcoin:$REGTEST_ADDRESS?amount=0.0001" + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, input.toUri())) + runCurrent() + whenever(keychain.exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + clearInvocations(blocktankRepo) + + advanceUntilIdle() + + verify(blocktankRepo, never()).refreshInfo() + assertNull(sut.currentSheet.value) + } + + @Test + fun `unlock does not replay a payment queued for a replaced wallet`() = test { + sut.setIsAuthenticated(false) + val input = "bitcoin:$REGTEST_ADDRESS?amount=0.0001" + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, input.toUri())) + runCurrent() + walletGeneration++ + clearInvocations(blocktankRepo) + + sut.setIsAuthenticated(true) + advanceUntilIdle() + + verify(blocktankRepo, never()).refreshInfo() + assertNull(sut.currentSheet.value) + } + + @Test + fun `payment received during recovery is discarded but a new request after recovery is allowed`() = test { + whenever(coreService.validateBitcoinAddress(REGTEST_ADDRESS)).thenReturn( + ValidationResult(REGTEST_ADDRESS, NetworkType.REGTEST, AddressType.P2WPKH), + ) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + sut.setIsAuthenticated(false) + val input = "bitcoin:$REGTEST_ADDRESS?amount=0.0001" + whenever(coreService.decode(input)).thenReturn( + Scanner.OnChain(OnChainInvoice(REGTEST_ADDRESS, 10_000u, null, null, null)), + ) + whenever(keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, input.toUri())) + runCurrent() + whenever(keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)).thenReturn(false) + clearInvocations(blocktankRepo) + + sut.setIsAuthenticated(true) + advanceUntilIdle() + verify(blocktankRepo, never()).refreshInfo() + assertNull(sut.currentSheet.value) + + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, input.toUri())) + advanceUntilIdle() + verify(blocktankRepo).refreshInfo() + assertTrue(sut.currentSheet.value is Sheet.Send) + } + + @Test + fun `recovery beginning during settings load stops send initialization before fees`() = test { + val entered = CompletableDeferred() + val release = CompletableDeferred() + whenever(settingsStore.data).thenReturn( + flow { + entered.complete(Unit) + release.await() + emit(SettingsData()) + } + ) + val before = sut.sendUiState.value + clearInvocations(blocktankRepo) + val reset = async { sut.resetSendState() } + entered.await() + whenever(keychain.exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + release.complete(Unit) + + assertFalse(reset.await()) + verify(blocktankRepo, never()).refreshInfo() + assertEquals(before, sut.sendUiState.value) + } + + @Test + fun `fee completion from an ended wallet cannot replace current send state`() = test { + val entered = CompletableDeferred() + val release = CompletableDeferred() + whenever(blocktankRepo.refreshInfo()).doSuspendableAnswer { + entered.complete(Unit) + release.await() + Result.success(Unit) + } + val before = sut.sendUiState.value + val reset = async { sut.resetSendState() } + entered.await() + walletGeneration++ + release.complete(Unit) + + assertFalse(reset.await()) + assertEquals(before, sut.sendUiState.value) + } + + @Test + fun `rejected contact scan releases only its installed context`() = test { + val entered = CompletableDeferred() + val release = CompletableDeferred() + whenever(blocktankRepo.refreshInfo()).doSuspendableAnswer { + entered.complete(Unit) + release.await() + Result.success(Unit) + } + sut.setIsAuthenticated(true) + val scan = sut.openContactPayment("bitcoin:$REGTEST_ADDRESS", testPublicKey) + entered.await() + whenever(keychain.exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + release.complete(Unit) + scan?.join() + advanceUntilIdle() + + assertNull(activeContactPaymentContext()) + assertNull(sut.currentSheet.value) + verify(paykitPaymentRequestRepo, never()).markPresented(any()) + } + + @Test + fun `subscription resolution cannot present a failure after required recovery begins`() = test { + val subscription = subscriptionStartingAt(Clock.System.now()) + val request = paymentRequest() + paykitSubscriptions.value = listOf(subscription) + whenever(paykitPaymentRequestRepo.accept(subscription)).thenReturn(Result.success(request)) + whenever(privatePaykitRepo.beginPaymentRequestWaitingForUpdatedList(request)).doSuspendableAnswer { + whenever(keychain.exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + Result.success(PublicPaykitPaymentResult.Opened("bitcoin:$REGTEST_ADDRESS")) + } + clearInvocations(blocktankRepo) + + val result = sut.acceptSubscriptionAndStartPayment(subscription) + advanceUntilIdle() + + assertTrue(result.isFailure) + assertNull(activeContactPaymentContext()) + assertNull(sut.currentSheet.value) + verify(blocktankRepo, never()).refreshInfo() + verify(paykitPaymentRequestRepo, never()).markPresented(any()) + } + + @Test + fun `subscription error cannot install context when recovery starts during fee refresh`() = test { + val subscription = subscriptionStartingAt(Clock.System.now()) + val request = paymentRequest() + paykitSubscriptions.value = listOf(subscription) + whenever(paykitPaymentRequestRepo.accept(subscription)).thenReturn(Result.success(request)) + whenever(privatePaykitRepo.beginPaymentRequestWaitingForUpdatedList(request)) + .thenReturn(Result.failure(PaykitPaymentRequestError.RequestUnavailable)) + whenever(blocktankRepo.refreshInfo()).doSuspendableAnswer { + whenever(keychain.exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + Result.success(Unit) + } + + val result = sut.acceptSubscriptionAndStartPayment(subscription) + advanceUntilIdle() + + assertTrue(result.isFailure) + assertNull(activeContactPaymentContext()) + assertNull(sut.currentSheet.value) + verify(paykitPaymentRequestRepo, never()).markPresented(any()) + } + + @Test + fun `retained hardware lookup cannot adopt a replacement wallet generation`() = test { + clearInvocations(blocktankRepo) + reopenRetainedHardwareRequest(balanceSats = 10_000uL, beforeRetainedLookup = { walletGeneration++ }) + advanceUntilIdle() + + verify(blocktankRepo, never()).refreshInfo() + assertFalse(sut.currentSheet.value is Sheet.Send) + } + + @Test + fun `retained hardware confirmation cannot appear after wallet replacement during transition`() = test { + val entered = CompletableDeferred() + val release = CompletableDeferred() + reopenRetainedHardwareRequest( + balanceSats = 10_000uL, + beforeRetainedLookup = { + entered.complete(Unit) + release.await() + }, + ) + assertTrue(entered.isCompleted) + release.complete(Unit) + runCurrent() + assertTrue(sut.sendUiState.value.isRetainedHardwarePayment) + assertNull(sut.currentSheet.value) + + walletGeneration++ + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + } + + @Test + fun `retained hardware confirmation cannot appear when recovery starts during transition`() = test { + val entered = CompletableDeferred() + val release = CompletableDeferred() + reopenRetainedHardwareRequest( + balanceSats = 10_000uL, + beforeRetainedLookup = { + entered.complete(Unit) + release.await() + }, + ) + assertTrue(entered.isCompleted) + release.complete(Unit) + runCurrent() + assertTrue(sut.sendUiState.value.isRetainedHardwarePayment) + assertNull(sut.currentSheet.value) + + whenever(keychain.exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + } + + @Test + fun `blocked subscription acceptance does not report a successful subscription`() = test { + val subscription = subscriptionStartingAt(Clock.System.now()) + paykitSubscriptions.value = listOf(subscription) + whenever(keychain.exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + + assertTrue(sut.acceptSubscriptionAndStartPayment(subscription).isFailure) + + verify(paykitPaymentRequestRepo, never()).accept(any()) + } + + @Test + fun `subscription acceptance failure is silent after wallet replacement`() = test { + val subscription = subscriptionStartingAt(Clock.System.now()) + paykitSubscriptions.value = listOf(subscription) + whenever(paykitPaymentRequestRepo.accept(subscription)).doSuspendableAnswer { + walletGeneration++ + Result.failure(PaykitPaymentRequestError.RequestUnavailable) + } + clearInvocations(toastManager) + + assertTrue(sut.acceptSubscriptionAndStartPayment(subscription).isFailure) + advanceUntilIdle() + + verify(toastManager, never()).enqueue(any()) + } + + @Test + fun `retained hardware lookup failure is silent after wallet replacement`() = test { + clearInvocations(toastManager) + reopenRetainedHardwareRequest( + balanceSats = 10_000uL, + beforeRetainedLookup = { + walletGeneration++ + throw PaykitPaymentRequestError.RequestUnavailable + }, + ) + advanceUntilIdle() + + verify(toastManager, never()).enqueue(any()) + assertFalse(sut.currentSheet.value is Sheet.Send) + } + + @Test + fun `subscription fallback cannot reopen send after presentation storage changes wallet`() = test { + val subscription = subscriptionStartingAt(Clock.System.now()) + val request = paymentRequest() + paykitSubscriptions.value = listOf(subscription) + whenever(paykitPaymentRequestRepo.accept(subscription)).thenReturn(Result.success(request)) + whenever(privatePaykitRepo.beginPaymentRequestWaitingForUpdatedList(request)).doSuspendableAnswer { + sut.setIsAuthenticated(false) + Result.success(PublicPaykitPaymentResult.Opened("bitcoin:$REGTEST_ADDRESS")) + } + whenever(paykitPaymentRequestRepo.markPresented(request)).doSuspendableAnswer { + walletGeneration++ + true + } + sut.setIsAuthenticated(true) + clearInvocations(blocktankRepo) + + sut.acceptSubscriptionAndStartPayment(subscription) + advanceUntilIdle() + assertNull(sut.currentSheet.value) + + sut.setIsAuthenticated(true) + advanceUntilIdle() + verify(blocktankRepo, never()).refreshInfo() + assertNull(sut.currentSheet.value) + } + @Test fun `recovery mode deeplink enables recovery mode`() = test { sut.handleDeeplinkIntent(recoveryModeIntent()) @@ -6386,45 +6736,293 @@ class AppViewModelSendFlowTest : BaseUnitTest() { scans.forEach { (input, scan) -> whenever { coreService.decode(input) }.thenReturn(scan) } - sut.showSheet(Sheet.Send(hardwareWalletId = HARDWARE_WALLET_ID)) - advanceUntilIdle() - clearInvocations(toastManager) + sut.showSheet(Sheet.Send(hardwareWalletId = HARDWARE_WALLET_ID)) + advanceUntilIdle() + clearInvocations(toastManager) + + scans.forEach { (input, _) -> + sut.onScanResult(input) + advanceUntilIdle() + + assertEquals(HARDWARE_WALLET_ID, sut.sendUiState.value.hardwareWalletId) + assertEquals(SendMethod.ONCHAIN, sut.sendUiState.value.payMethod) + } + verify(toastManager, times(scans.size)).enqueue(check { assertEquals("On-chain only", it.title) }) + } + + @Test + fun `hardware send rejects pasted non-onchain payment requests`() = test { + val scans = nonOnchainPaymentScans() + whenever(context.getString(R.string.hardware__send_onchain_only_title)).thenReturn("On-chain only") + scans.forEach { (input, scan) -> + whenever { coreService.decode(input) }.thenReturn(scan) + } + sut.showSheet(Sheet.Send(hardwareWalletId = HARDWARE_WALLET_ID)) + advanceUntilIdle() + clearInvocations(toastManager) + + scans.forEach { (input, _) -> + val clipData = mock() + val item = mock() + whenever(item.text).thenReturn(input) + whenever(clipData.getItemAt(0)).thenReturn(item) + whenever(clipboardManager.primaryClip).thenReturn(clipData) + + sut.setSendEvent(SendEvent.Paste) + advanceUntilIdle() + + assertEquals(HARDWARE_WALLET_ID, sut.sendUiState.value.hardwareWalletId) + assertEquals(SendMethod.ONCHAIN, sut.sendUiState.value.payMethod) + } + verify(toastManager, times(scans.size)).enqueue(check { assertEquals("On-chain only", it.title) }) + } + + @Test + fun `send collector accepts fresh amount continue after wallet recovery refusal`() = test { + assertSendCollectorSurvivesRecovery( + marker = Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE, + event = SendEvent.AmountContinue, + unreadable = false, + ) + } + + @Test + fun `send collector accepts fresh amount continue after USDT recovery refusal`() = test { + assertSendCollectorSurvivesRecovery( + marker = Keychain.Key.USDT_PENDING_BACKUP_RESTORE, + event = SendEvent.AmountContinue, + unreadable = false, + ) + } + + @Test + fun `send collector accepts fresh amount continue after unreadable wallet recovery refusal`() = test { + assertSendCollectorSurvivesRecovery( + marker = Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE, + event = SendEvent.AmountContinue, + unreadable = true, + ) + } + + @Test + fun `send collector accepts fresh amount continue after unreadable USDT recovery refusal`() = test { + assertSendCollectorSurvivesRecovery( + marker = Keychain.Key.USDT_PENDING_BACKUP_RESTORE, + event = SendEvent.AmountContinue, + unreadable = true, + ) + } + + @Test + fun `send collector accepts fresh coin selection continue after wallet recovery refusal`() = test { + assertSendCollectorSurvivesRecovery( + marker = Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE, + event = SendEvent.CoinSelectionContinue(listOf(mock())), + unreadable = false, + ) + } + + @Test + fun `send collector accepts fresh coin selection continue after USDT recovery refusal`() = test { + assertSendCollectorSurvivesRecovery( + marker = Keychain.Key.USDT_PENDING_BACKUP_RESTORE, + event = SendEvent.CoinSelectionContinue(listOf(mock())), + unreadable = false, + ) + } + + @Test + fun `send collector accepts fresh coin selection continue after unreadable wallet recovery refusal`() = test { + assertSendCollectorSurvivesRecovery( + marker = Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE, + event = SendEvent.CoinSelectionContinue(listOf(mock())), + unreadable = true, + ) + } + + @Test + fun `send collector accepts fresh coin selection continue after unreadable USDT recovery refusal`() = test { + assertSendCollectorSurvivesRecovery( + marker = Keychain.Key.USDT_PENDING_BACKUP_RESTORE, + event = SendEvent.CoinSelectionContinue(listOf(mock())), + unreadable = true, + ) + } + + @Test + fun `send collector handles amount fee wallet replacement without late confirmation`() = test { + assertSendCollectorHandlesSuspendedFee( + coinSelection = false, + cancelScope = false, + ) + } + + @Test + fun `send collector handles amount fee scope cancellation without late confirmation`() = test { + assertSendCollectorHandlesSuspendedFee( + coinSelection = false, + cancelScope = true, + ) + } + + @Test + fun `send collector handles coin selection fee wallet replacement without late confirmation`() = test { + assertSendCollectorHandlesSuspendedFee( + coinSelection = true, + cancelScope = false, + ) + } + + @Test + fun `send collector handles coin selection fee scope cancellation without late confirmation`() = test { + assertSendCollectorHandlesSuspendedFee( + coinSelection = true, + cancelScope = true, + ) + } + + private suspend fun TestScope.assertSendCollectorSurvivesRecovery( + marker: Keychain.Key, + event: SendEvent, + unreadable: Boolean, + ) { + val coinSelection = event is SendEvent.CoinSelectionContinue + settingsData.value = SettingsData(coinSelectAuto = !coinSelection) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + val sheet = Sheet.Send(if (coinSelection) SendRoute.CoinSelection else SendRoute.Amount) + sut.showSheet(sheet) + runCurrent() + setSendState( + SendUiState(address = REGTEST_ADDRESS, amount = 1_000u, isAmountInputValid = true), + ) + var blocked = true + whenever(keychain.exists(marker.name)).thenAnswer { + if (blocked && unreadable) throw AppError("Keychain unavailable") + blocked + } + clearInvocations(lightningRepo, toastManager) + + sut.sendEffect.test { + sut.setSendEvent(event) + advanceUntilIdle() + + expectNoEvents() + assertEquals(sheet, sut.currentSheet.value) + assertFalse(sut.sendUiState.value.isLoading) + assertFalse(sut.sendUiState.value.shouldConfirmPay) + verify(lightningRepo, never()).calculateTotalFee(any(), anyOrNull(), any(), anyOrNull(), anyOrNull()) + assertNoNativeSend() + + blocked = false + advanceUntilIdle() + expectNoEvents() + verify(lightningRepo, never()).calculateTotalFee(any(), anyOrNull(), any(), anyOrNull(), anyOrNull()) + + sut.setSendEvent(SendEvent.AmountChange(2_000u)) + advanceUntilIdle() + assertEquals(2_000uL, sut.sendUiState.value.amount) + sut.setSendEvent(event) + advanceUntilIdle() + assertEquals(SendEffect.NavigateToConfirm, awaitItem()) + assertFalse(sut.sendUiState.value.isLoading) + assertFalse(sut.sendUiState.value.shouldConfirmPay) + expectNoEvents() + } + assertNoNativeSend() + verify(toastManager, never()).enqueue(any()) + } + + private suspend fun TestScope.assertSendCollectorHandlesSuspendedFee( + coinSelection: Boolean, + cancelScope: Boolean, + ) { + settingsData.value = SettingsData(coinSelectAuto = !coinSelection) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u, maxSendLightningSats = 100_000u) + val sheet = Sheet.Send(if (coinSelection) SendRoute.CoinSelection else SendRoute.Amount) + sut.showSheet(sheet) + runCurrent() + val bolt11 = "lnbcrt1pendingcontinue" + setSendState( + SendUiState( + address = REGTEST_ADDRESS, + amount = 1_000u, + isAmountInputValid = true, + payMethod = if (coinSelection) SendMethod.ONCHAIN else SendMethod.LIGHTNING, + decodedInvoice = if (coinSelection) null else lightningInvoice(bolt11, 0u), + ), + ) + val estimateStarted = CompletableDeferred() + val feeEstimate = CompletableDeferred>() + var estimateCancelled = false + val estimate: suspend () -> Result = { + estimateStarted.complete(Unit) + try { + feeEstimate.await() + } catch (cancelled: CancellationException) { + estimateCancelled = true + throw cancelled + } + } + if (coinSelection) { + whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), any(), anyOrNull(), anyOrNull())) + .doSuspendableAnswer { estimate() } + } else { + whenever(lightningRepo.estimateRoutingFeesForAmount(any(), any())).doSuspendableAnswer { estimate() } + } + val event = if (coinSelection) SendEvent.CoinSelectionContinue(listOf(mock())) else SendEvent.AmountContinue - scans.forEach { (input, _) -> - sut.onScanResult(input) + sut.sendEffect.test { + sut.setSendEvent(event) + runCurrent() + assertTrue(estimateStarted.isCompleted) + if (!coinSelection) assertTrue(sut.sendUiState.value.isLoading) + expectNoEvents() + if (cancelScope) { + sut.viewModelScope.cancel() + } else { + walletGeneration++ + } + feeEstimate.complete(Result.success(999u)) advanceUntilIdle() - assertEquals(HARDWARE_WALLET_ID, sut.sendUiState.value.hardwareWalletId) - assertEquals(SendMethod.ONCHAIN, sut.sendUiState.value.payMethod) + expectNoEvents() + assertEquals(sheet, sut.currentSheet.value) + assertNull(sut.sendUiState.value.lightningFeeSats) + assertNull(sut.sendUiState.value.onchainFeeUi.sats) + assertFalse(sut.sendUiState.value.isLoading) + if (cancelScope) assertTrue(estimateCancelled) + assertNoNativeSend() + + whenever(lightningRepo.calculateTotalFee(any(), anyOrNull(), any(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(100u)) + whenever(lightningRepo.estimateRoutingFeesForAmount(any(), any())).thenReturn(Result.success(7u)) + sut.setSendEvent(SendEvent.AmountChange(2_000u)) + advanceUntilIdle() + sut.setSendEvent(event) + advanceUntilIdle() + if (cancelScope) { + assertEquals(1_000uL, sut.sendUiState.value.amount) + } else { + assertEquals(2_000uL, sut.sendUiState.value.amount) + assertEquals(SendEffect.NavigateToConfirm, awaitItem()) + if (coinSelection) { + assertEquals(100L, sut.sendUiState.value.onchainFeeUi.sats) + } else { + assertEquals(7L, sut.sendUiState.value.lightningFeeSats) + } + } + expectNoEvents() } - verify(toastManager, times(scans.size)).enqueue(check { assertEquals("On-chain only", it.title) }) + assertNoNativeSend() } - @Test - fun `hardware send rejects pasted non-onchain payment requests`() = test { - val scans = nonOnchainPaymentScans() - whenever(context.getString(R.string.hardware__send_onchain_only_title)).thenReturn("On-chain only") - scans.forEach { (input, scan) -> - whenever { coreService.decode(input) }.thenReturn(scan) - } - sut.showSheet(Sheet.Send(hardwareWalletId = HARDWARE_WALLET_ID)) - advanceUntilIdle() - clearInvocations(toastManager) - - scans.forEach { (input, _) -> - val clipData = mock() - val item = mock() - whenever(item.text).thenReturn(input) - whenever(clipData.getItemAt(0)).thenReturn(item) - whenever(clipboardManager.primaryClip).thenReturn(clipData) - - sut.setSendEvent(SendEvent.Paste) - advanceUntilIdle() - - assertEquals(HARDWARE_WALLET_ID, sut.sendUiState.value.hardwareWalletId) - assertEquals(SendMethod.ONCHAIN, sut.sendUiState.value.payMethod) - } - verify(toastManager, times(scans.size)).enqueue(check { assertEquals("On-chain only", it.title) }) + private suspend fun assertNoNativeSend() { + verify(lightningRepo, never()).sendOnChain( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), any(), + anyOrNull(), any(), any(), any(), any(), anyOrNull(), + anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), + ) + verify(lightningRepo, never()).payInvoice(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } @Test @@ -7815,6 +8413,306 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.Amount), sut.currentSheet.value) } + @Test + fun `onchain validation mismatch is silent after wallet replacement`() = test { + val input = "bitcoin:$REGTEST_ADDRESS?amount=0.0001" + whenever(coreService.decode(input)).thenReturn( + Scanner.OnChain(OnChainInvoice(REGTEST_ADDRESS, 10_000u, null, null, null)), + ) + val entered = CompletableDeferred() + val release = CompletableDeferred() + whenever(coreService.validateBitcoinAddress(REGTEST_ADDRESS)).doSuspendableAnswer { + entered.complete(Unit) + release.await() + ValidationResult(REGTEST_ADDRESS, NetworkType.BITCOIN, AddressType.P2WPKH) + } + sut.setIsAuthenticated(true) + sut.onScanResult(input) + runCurrent() + assertTrue(entered.isCompleted) + val stateBefore = sut.sendUiState.value + val sheetBefore = sut.currentSheet.value + clearInvocations(toastManager) + walletGeneration++ + + release.complete(Unit) + advanceUntilIdle() + + assertEquals(stateBefore, sut.sendUiState.value) + assertEquals(sheetBefore, sut.currentSheet.value) + verify(toastManager, never()).enqueue(any()) + } + + @Test + fun `onchain validation mismatch is silent after recovery begins`() = test { + val input = "bitcoin:$REGTEST_ADDRESS?amount=0.0001" + whenever(coreService.decode(input)).thenReturn( + Scanner.OnChain(OnChainInvoice(REGTEST_ADDRESS, 10_000u, null, null, null)), + ) + val entered = CompletableDeferred() + val release = CompletableDeferred() + whenever(coreService.validateBitcoinAddress(REGTEST_ADDRESS)).doSuspendableAnswer { + entered.complete(Unit) + release.await() + ValidationResult(REGTEST_ADDRESS, NetworkType.BITCOIN, AddressType.P2WPKH) + } + sut.setIsAuthenticated(true) + sut.onScanResult(input) + runCurrent() + assertTrue(entered.isCompleted) + val stateBefore = sut.sendUiState.value + val sheetBefore = sut.currentSheet.value + clearInvocations(toastManager) + whenever(keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + + release.complete(Unit) + advanceUntilIdle() + + assertEquals(stateBefore, sut.sendUiState.value) + assertEquals(sheetBefore, sut.currentSheet.value) + verify(toastManager, never()).enqueue(any()) + } + + @Test + fun `onchain validation failure is silent after wallet replacement`() = test { + val input = "bitcoin:$REGTEST_ADDRESS?amount=0.0001" + whenever(coreService.decode(input)).thenReturn( + Scanner.OnChain(OnChainInvoice(REGTEST_ADDRESS, 10_000u, null, null, null)), + ) + val entered = CompletableDeferred() + val release = CompletableDeferred() + whenever(coreService.validateBitcoinAddress(REGTEST_ADDRESS)).doSuspendableAnswer { + entered.complete(Unit) + release.await() + throw IllegalArgumentException("invalid test address") + } + sut.setIsAuthenticated(true) + sut.onScanResult(input) + runCurrent() + assertTrue(entered.isCompleted) + val stateBefore = sut.sendUiState.value + val sheetBefore = sut.currentSheet.value + clearInvocations(toastManager) + walletGeneration++ + + release.complete(Unit) + advanceUntilIdle() + + assertEquals(stateBefore, sut.sendUiState.value) + assertEquals(sheetBefore, sut.currentSheet.value) + verify(toastManager, never()).enqueue(any()) + } + + @Test + fun `onchain validation failure is silent after recovery begins`() = test { + val input = "bitcoin:$REGTEST_ADDRESS?amount=0.0001" + whenever(coreService.decode(input)).thenReturn( + Scanner.OnChain(OnChainInvoice(REGTEST_ADDRESS, 10_000u, null, null, null)), + ) + val entered = CompletableDeferred() + val release = CompletableDeferred() + whenever(coreService.validateBitcoinAddress(REGTEST_ADDRESS)).doSuspendableAnswer { + entered.complete(Unit) + release.await() + throw IllegalArgumentException("invalid test address") + } + sut.setIsAuthenticated(true) + sut.onScanResult(input) + runCurrent() + assertTrue(entered.isCompleted) + val stateBefore = sut.sendUiState.value + val sheetBefore = sut.currentSheet.value + clearInvocations(toastManager) + whenever(keychain.exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + + release.complete(Unit) + advanceUntilIdle() + + assertEquals(stateBefore, sut.sendUiState.value) + assertEquals(sheetBefore, sut.currentSheet.value) + verify(toastManager, never()).enqueue(any()) + } + + @Test + fun `onchain native validation cancellation remains cancellation`() = test { + val input = "bitcoin:$REGTEST_ADDRESS?amount=0.0001" + whenever(coreService.decode(input)).thenReturn( + Scanner.OnChain(OnChainInvoice(REGTEST_ADDRESS, 10_000u, null, null, null)), + ) + val entered = CompletableDeferred() + val release = CompletableDeferred() + whenever(coreService.validateBitcoinAddress(REGTEST_ADDRESS)).doSuspendableAnswer { + entered.complete(Unit) + release.await() + throw CancellationException("native validation canceled") + } + sut.setIsAuthenticated(true) + sut.onScanResult(input) + runCurrent() + assertTrue(entered.isCompleted) + val stateBefore = sut.sendUiState.value + val sheetBefore = sut.currentSheet.value + clearInvocations(toastManager) + + release.complete(Unit) + advanceUntilIdle() + + assertEquals(stateBefore, sut.sendUiState.value) + assertEquals(sheetBefore, sut.currentSheet.value) + verify(toastManager, never()).enqueue(any()) + } + + @Test + fun `lightning channel wait cannot continue for a replaced wallet`() = test { + val input = "lnbcrt1heldscan" + stubLightningScan(bolt11 = input, amountSats = 500u) + val entered = CompletableDeferred() + val release = CompletableDeferred() + whenever(lightningRepo.waitForUsableChannels()).doSuspendableAnswer { + entered.complete(Unit) + release.await() + Unit + } + sut.setIsAuthenticated(true) + sut.onScanResult(input) + runCurrent() + assertTrue(entered.isCompleted) + val stateBefore = sut.sendUiState.value + val sheetBefore = sut.currentSheet.value + clearInvocations(toastManager) + walletGeneration++ + + release.complete(Unit) + advanceUntilIdle() + + assertEquals(stateBefore, sut.sendUiState.value) + assertEquals(sheetBefore, sut.currentSheet.value) + assertNull(sut.quickPayData.value) + verify(toastManager, never()).enqueue(any()) + } + + @Test + fun `lightning channel wait cannot continue during required recovery`() = test { + val input = "lnbcrt1heldscan" + stubLightningScan(bolt11 = input, amountSats = 500u) + val entered = CompletableDeferred() + val release = CompletableDeferred() + whenever(lightningRepo.waitForUsableChannels()).doSuspendableAnswer { + entered.complete(Unit) + release.await() + Unit + } + sut.setIsAuthenticated(true) + sut.onScanResult(input) + runCurrent() + assertTrue(entered.isCompleted) + val stateBefore = sut.sendUiState.value + val sheetBefore = sut.currentSheet.value + clearInvocations(toastManager) + whenever(keychain.exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + + release.complete(Unit) + advanceUntilIdle() + + assertEquals(stateBefore, sut.sendUiState.value) + assertEquals(sheetBefore, sut.currentSheet.value) + assertNull(sut.quickPayData.value) + verify(toastManager, never()).enqueue(any()) + } + + @Test + fun `lightning node identity wait cannot reject a replacement wallet`() = test { + val input = "lnbcrt1heldscan" + stubLightningScan(bolt11 = input, amountSats = 500u, payeeNodeId = OWN_NODE_ID.fromHex()) + val entered = CompletableDeferred() + val release = CompletableDeferred() + whenever(lightningRepo.awaitNodeId()).doSuspendableAnswer { + entered.complete(Unit) + release.await() + OWN_NODE_ID + } + sut.setIsAuthenticated(true) + sut.onScanResult(input) + runCurrent() + assertTrue(entered.isCompleted) + val stateBefore = sut.sendUiState.value + val sheetBefore = sut.currentSheet.value + clearInvocations(toastManager) + walletGeneration++ + + release.complete(Unit) + advanceUntilIdle() + + assertEquals(stateBefore, sut.sendUiState.value) + assertEquals(sheetBefore, sut.currentSheet.value) + assertNull(sut.quickPayData.value) + verify(toastManager, never()).enqueue(any()) + } + + @Test + fun `QuickPay eligibility wait cannot present payment for a replaced wallet`() = test { + val input = "lnbcrt1heldscan" + enableQuickPay() + stubLightningScan(bolt11 = input, amountSats = 500u) + val entered = CompletableDeferred() + val release = CompletableDeferred() + whenever(quickPayRepo.canApply(any())).doSuspendableAnswer { + entered.complete(Unit) + release.await() + Result.success(true) + } + sut.setIsAuthenticated(true) + sut.onScanResult(input) + runCurrent() + assertTrue(entered.isCompleted) + val stateBefore = sut.sendUiState.value + val sheetBefore = sut.currentSheet.value + clearInvocations(toastManager) + walletGeneration++ + + release.complete(Unit) + advanceUntilIdle() + + assertEquals(stateBefore, sut.sendUiState.value) + assertEquals(sheetBefore, sut.currentSheet.value) + assertNull(sut.quickPayData.value) + verify(toastManager, never()).enqueue(any()) + } + + @Test + fun `LNURL channel wait cannot present a payment during required recovery`() = test { + val input = "lnbcrt1heldscan" + whenever(coreService.decode(input)).thenReturn( + Scanner.LnurlPay( + LnurlPayData(input, "https://example.com/callback", 1_000_000uL, 1_000_000uL, "[]", null, false, null) + ) + ) + whenever(lightningRepo.canSend(1_000uL)).thenReturn(true) + val entered = CompletableDeferred() + val release = CompletableDeferred() + whenever(lightningRepo.waitForUsableChannels()).doSuspendableAnswer { + entered.complete(Unit) + release.await() + Unit + } + sut.setIsAuthenticated(true) + sut.onScanResult(input) + runCurrent() + assertTrue(entered.isCompleted) + val stateBefore = sut.sendUiState.value + val sheetBefore = sut.currentSheet.value + clearInvocations(toastManager) + whenever(keychain.exists(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name)).thenReturn(true) + + release.complete(Unit) + advanceUntilIdle() + + assertEquals(stateBefore, sut.sendUiState.value) + assertEquals(sheetBefore, sut.currentSheet.value) + assertNull(sut.quickPayData.value) + verify(toastManager, never()).enqueue(any()) + } + @Test fun `main scanner lightning scan opens QuickPay when enabled`() = test { val bolt11 = "lnbcrt1scannerquickpay" @@ -11778,6 +12676,47 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } + @Test + fun `initial subscription retry silently rejects a replaced wallet during resolution`() = test { + val request = paymentRequest() + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + whenever(paykitPaymentRequestRepo.refreshAfterStateChange()).doSuspendableAnswer { + pendingPaykitPaymentRequests.value = listOf(request) + Result.success(Unit) + } + whenever(privatePaykitRepo.beginPaymentRequestWaitingForUpdatedList(request)).doSuspendableAnswer { + walletGeneration++ + Result.failure(PaykitPaymentRequestError.RequestUnavailable) + } + val sendSheet = Sheet.Send( + SendRoute.errorFromFailure( + IllegalStateException("failed").toSendFailureDetails(context, paymentRequest = null) + ) + ) + setSendState( + SendUiState( + isPaymentRequest = true, + isSubscriptionPayment = true, + isInitialSubscriptionPayment = true, + incomingPaymentRequestId = request.id, + ) + ) + sut.showSheet(sendSheet) + advanceUntilIdle() + clearInvocations(toastManager) + + sut.retryIncomingPaymentRequest(request.id) + advanceUntilIdle() + + assertTrue(sendSheet === sut.currentSheet.value) + assertFalse(sut.isRetryingInitialSubscriptionPayment.value) + assertNull(activeContactPaymentContext()) + verify(paykitPaymentRequestRepo, never()).markPresented(any()) + verify(toastManager, never()).enqueue(any()) + } + @Test fun `initial subscription retry keeps the send sheet presented`() = test { val endpoint = Endpoint(MethodId.UsdtArbitrum, "0x1111111111111111111111111111111111111111", null, null, "") @@ -15185,6 +16124,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { balanceSats: ULong, amountSats: ULong = 1_000uL, quote: PaymentConversionQuoteRecord? = null, + beforeRetainedLookup: suspend () -> Unit = {}, ): Pair { sut.setIsAuthenticated(true) val request = paymentRequest().copy( @@ -15219,7 +16159,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { hardwareFeeRate = signed.feeRate, hardwareTotalSpent = signed.totalSpent, ) - whenever(paykitPaymentProofRepo.retainedHardwareOnchainRequest(request)).thenReturn(proof) + whenever(paykitPaymentProofRepo.retainedHardwareOnchainRequest(request)).doSuspendableAnswer { + beforeRetainedLookup() + proof + } whenever(privatePaykitRepo.beginPaymentRequest(request)) .thenReturn(Result.failure(PaykitPaymentRequestError.OperationInProgress)) hwWallets.value = persistentListOf(hardwareWallet(fundingBalanceSats = balanceSats)) diff --git a/changelog.d/next/1701.fixed.md b/changelog.d/next/1701.fixed.md new file mode 100644 index 0000000000..c2f08c7359 --- /dev/null +++ b/changelog.d/next/1701.fixed.md @@ -0,0 +1 @@ +Use the saved Bitcoin fee schedule during temporary gateway errors while keeping payment links and requests blocked until required wallet recovery finishes.