From 47ca20f37e82a1f9fadeb55b9da067f5dd58ab70 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 26 Oct 2026 19:49:47 +0200 Subject: [PATCH 1/3] fix: use saved fees after gateway errors --- .../java/to/bitkit/services/CoreService.kt | 29 ++- .../bitkit/repositories/LightningRepoTest.kt | 91 +++++++++ .../services/BlocktankFeeFallbackTest.kt | 190 ++++++++++++++++++ changelog.d/next/blocktank-fee-cache.fixed.md | 1 + 4 files changed, 309 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/to/bitkit/services/BlocktankFeeFallbackTest.kt create mode 100644 changelog.d/next/blocktank-fee-cache.fixed.md diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index b59c70edd1..57a4c58166 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 @@ -2156,18 +2159,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/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/changelog.d/next/blocktank-fee-cache.fixed.md b/changelog.d/next/blocktank-fee-cache.fixed.md new file mode 100644 index 0000000000..b9bc2f0bec --- /dev/null +++ b/changelog.d/next/blocktank-fee-cache.fixed.md @@ -0,0 +1 @@ +Use the saved Bitcoin fee schedule when the fee service returns a temporary gateway error. From c32c67f90cecd8d3146bbc75831ef508117db9b7 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 26 Oct 2026 19:54:02 +0200 Subject: [PATCH 2/3] chore: rename fee recovery changelog fragment --- changelog.d/next/{blocktank-fee-cache.fixed.md => 1701.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{blocktank-fee-cache.fixed.md => 1701.fixed.md} (100%) diff --git a/changelog.d/next/blocktank-fee-cache.fixed.md b/changelog.d/next/1701.fixed.md similarity index 100% rename from changelog.d/next/blocktank-fee-cache.fixed.md rename to changelog.d/next/1701.fixed.md From bfa61d31b222bce0f3465b450af02bc076d28a6d Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 27 Oct 2026 20:10:30 +0200 Subject: [PATCH 3/3] fix: keep send events active after recovery refusal --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 102 +++---- .../viewmodels/AppViewModelSendFlowTest.kt | 248 ++++++++++++++++++ 2 files changed, 303 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 4f57dfb73b..8e023e93d2 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2952,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() } } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 68d46d94f1..56c5f144fe 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -6777,6 +6777,254 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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 + + 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() + + 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() + } + assertNoNativeSend() + } + + 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 fun `amount continue shows loading and ignores duplicate request`() = test { val estimateStarted = CompletableDeferred()