From 678082274024b675ae06f25b9e78d7c4a6a72eec Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Tue, 11 Aug 2026 18:07:56 -0400 Subject: [PATCH 1/5] fix: verify max onchain send at selected speed --- .../to/bitkit/repositories/LightningRepo.kt | 15 ++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 27 +++- .../bitkit/repositories/LightningRepoTest.kt | 49 +++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 132 ++++++++++++++++++ changelog.d/next/1144.fixed.md | 1 + 5 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 changelog.d/next/1144.fixed.md diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 728e878f55..6154f42039 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1481,6 +1481,21 @@ class LightningRepo @Inject constructor( } } + /** Max onchain amount sendable at [speed], i.e. the spendable balance minus the send-all mining fee */ + suspend fun estimateMaxSendOnchain( + address: Address? = null, + speed: TransactionSpeed? = null, + feeRates: FeeRates? = null, + ): Result = withContext(bgDispatcher) { + runSuspendCatching { + val spendableSats = getBalancesAsync().getOrThrow().spendableOnchainBalanceSats + if (spendableSats == 0uL) return@runSuspendCatching 0uL + + val fee = estimateSendAllFee(address = address, speed = speed, feeRates = feeRates).getOrThrow() + spendableSats.safe() - fee.safe() + } + } + suspend fun getFeeRateForSpeed( speed: TransactionSpeed, feeRates: FeeRates? = null, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 6ce64be2ae..586bb5f4b7 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2885,11 +2885,36 @@ class AppViewModel @Inject constructor( speed = _sendUiState.value.speed, utxosToSpend = _sendUiState.value.selectedUtxos, isMaxAmount = _sendUiState.value.payMethod == SendMethod.ONCHAIN && - amount == walletRepo.balanceState.value.maxSendOnchainSats, + shouldDrainOnchain(address, amount), tags = tags, ) } + private suspend fun shouldDrainOnchain(address: String, amount: ULong): Boolean { + // cached max is computed at the default speed, so drain only if it still holds for the selected one + if (amount != walletRepo.balanceState.value.maxSendOnchainSats) return false + + val state = _sendUiState.value + val maxAtSelectedSpeed = lightningRepo.estimateMaxSendOnchain( + address = address, + speed = state.speed, + feeRates = state.feeRates, + ).onFailure { + Logger.warn("Failed to recompute max send amount for speed '${state.speed}'", it, context = TAG) + }.getOrNull() ?: return false + + if (amount != maxAtSelectedSpeed) { + Logger.info( + "Sending exact amount '$amount' instead of draining, " + + "max at speed '${state.speed}' is '$maxAtSelectedSpeed'", + context = TAG, + ) + return false + } + + return true + } + private suspend fun sendLightning( bolt11: String, amount: ULong? = null, diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index e40315dc46..309823c874 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -1386,6 +1386,55 @@ class LightningRepoTest : BaseUnitTest() { assertEquals(80_000uL, result) } + @Test + fun `estimateMaxSendOnchain should subtract the send-all fee for the given speed`() = test { + startNodeForTesting() + whenever(lightningService.balances).thenReturn( + BalanceDetails( + totalOnchainBalanceSats = 100_000uL, + spendableOnchainBalanceSats = 80_000uL, + totalAnchorChannelsReserveSats = 0uL, + totalLightningBalanceSats = 0uL, + lightningBalances = emptyList(), + pendingBalancesFromChannelClosures = emptyList(), + ), + ) + whenever { lightningService.estimateSendAllFee(any(), any()) }.thenReturn(2_000uL) + + val result = sut.estimateMaxSendOnchain( + address = "bcrt1qtest", + speed = TransactionSpeed.Fast, + feeRates = FeeRates(fast = 20u, mid = 10u, slow = 5u), + ) + + assertEquals(78_000uL, result.getOrNull()) + verify(lightningService).estimateSendAllFee(address = "bcrt1qtest", satsPerVByte = 20uL) + } + + @Test + fun `estimateMaxSendOnchain should return zero when nothing is spendable`() = test { + startNodeForTesting() + whenever(lightningService.balances).thenReturn( + BalanceDetails( + totalOnchainBalanceSats = 100_000uL, + spendableOnchainBalanceSats = 0uL, + totalAnchorChannelsReserveSats = 0uL, + totalLightningBalanceSats = 0uL, + lightningBalances = emptyList(), + pendingBalancesFromChannelClosures = emptyList(), + ), + ) + + val result = sut.estimateMaxSendOnchain( + address = "bcrt1qtest", + speed = TransactionSpeed.Fast, + feeRates = FeeRates(fast = 20u, mid = 10u, slow = 5u), + ) + + assertEquals(0uL, result.getOrNull()) + verify(lightningService, never()).estimateSendAllFee(any(), any()) + } + @Test fun `updateAddressType should fail when already in progress`() = test { startNodeForTesting() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 181f882999..ed931cb0a3 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -2148,6 +2148,138 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() } + @Test + fun `max onchain send drains when max still matches the selected speed`() = test { + val address = "bcrt1qmaxsend" + val maxAmount = 100_000uL + balanceState.value = BalanceState(maxSendOnchainSats = maxAmount) + whenever { + lightningRepo.estimateMaxSendOnchain( + address = address, + speed = TransactionSpeed.Fast, + feeRates = null, + ) + }.thenReturn(Result.success(maxAmount)) + whenever { + lightningRepo.sendOnChain( + address = address, + sats = maxAmount, + speed = TransactionSpeed.Fast, + utxosToSpend = null, + isMaxAmount = true, + tags = emptyList(), + ) + }.thenReturn(Result.success("txid")) + setSendState( + SendUiState( + address = address, + amount = maxAmount, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Fast, + ), + ) + + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + + verify(lightningRepo).sendOnChain( + address = address, + sats = maxAmount, + speed = TransactionSpeed.Fast, + utxosToSpend = null, + isMaxAmount = true, + tags = emptyList(), + ) + } + + @Test + fun `max onchain send falls back to exact amount when selected speed changes the max`() = test { + val address = "bcrt1qmaxsendstale" + val maxAmount = 100_000uL + balanceState.value = BalanceState(maxSendOnchainSats = maxAmount) + whenever { + lightningRepo.estimateMaxSendOnchain( + address = address, + speed = TransactionSpeed.Fast, + feeRates = null, + ) + }.thenReturn(Result.success(maxAmount - 500uL)) + whenever { + lightningRepo.sendOnChain( + address = address, + sats = maxAmount, + speed = TransactionSpeed.Fast, + utxosToSpend = null, + isMaxAmount = false, + tags = emptyList(), + ) + }.thenReturn(Result.success("txid")) + setSendState( + SendUiState( + address = address, + amount = maxAmount, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Fast, + ), + ) + + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + + verify(lightningRepo).sendOnChain( + address = address, + sats = maxAmount, + speed = TransactionSpeed.Fast, + utxosToSpend = null, + isMaxAmount = false, + tags = emptyList(), + ) + } + + @Test + fun `max onchain send falls back to exact amount when max cannot be recomputed`() = test { + val address = "bcrt1qmaxsendfailure" + val maxAmount = 100_000uL + balanceState.value = BalanceState(maxSendOnchainSats = maxAmount) + whenever { + lightningRepo.estimateMaxSendOnchain( + address = address, + speed = TransactionSpeed.Medium, + feeRates = null, + ) + }.thenReturn(Result.failure(AppError("no estimate"))) + whenever { + lightningRepo.sendOnChain( + address = address, + sats = maxAmount, + speed = TransactionSpeed.Medium, + utxosToSpend = null, + isMaxAmount = false, + tags = emptyList(), + ) + }.thenReturn(Result.success("txid")) + setSendState( + SendUiState( + address = address, + amount = maxAmount, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + ), + ) + + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + + verify(lightningRepo).sendOnChain( + address = address, + sats = maxAmount, + speed = TransactionSpeed.Medium, + utxosToSpend = null, + isMaxAmount = false, + tags = emptyList(), + ) + } + @Test fun `private lightning contact payment consumes private list before send`() = test { val bolt11 = "lnbcrt1privatecontact" diff --git a/changelog.d/next/1144.fixed.md b/changelog.d/next/1144.fixed.md new file mode 100644 index 0000000000..26adef3256 --- /dev/null +++ b/changelog.d/next/1144.fixed.md @@ -0,0 +1 @@ +Fixed max on-chain sends so the wallet no longer drains at a fee speed the confirmed amount did not account for. From e00b55367541292d985ac244c784a15d900ac78b Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Tue, 11 Aug 2026 18:16:39 -0400 Subject: [PATCH 2/5] fix: reuse send fee rates for drain check --- .../main/java/to/bitkit/viewmodels/AppViewModel.kt | 13 +++++++------ .../bitkit/viewmodels/AppViewModelSendFlowTest.kt | 13 ++++++++++++- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 586bb5f4b7..421721e2dd 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2879,22 +2879,23 @@ class AppViewModel @Inject constructor( amount: ULong, tags: List = emptyList(), ): Result { + val state = _sendUiState.value return lightningRepo.sendOnChain( address = address, sats = amount, - speed = _sendUiState.value.speed, - utxosToSpend = _sendUiState.value.selectedUtxos, - isMaxAmount = _sendUiState.value.payMethod == SendMethod.ONCHAIN && - shouldDrainOnchain(address, amount), + speed = state.speed, + utxosToSpend = state.selectedUtxos, + feeRates = state.feeRates, + isMaxAmount = state.payMethod == SendMethod.ONCHAIN && + shouldDrainOnchain(address, amount, state), tags = tags, ) } - private suspend fun shouldDrainOnchain(address: String, amount: ULong): Boolean { + private suspend fun shouldDrainOnchain(address: String, amount: ULong, state: SendUiState): Boolean { // cached max is computed at the default speed, so drain only if it still holds for the selected one if (amount != walletRepo.balanceState.value.maxSendOnchainSats) return false - val state = _sendUiState.value val maxAtSelectedSpeed = lightningRepo.estimateMaxSendOnchain( address = address, speed = state.speed, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index ed931cb0a3..2e7ba4d78d 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9,6 +9,7 @@ import android.net.Uri import android.nfc.NfcAdapter import androidx.core.net.toUri import app.cash.turbine.test +import com.synonym.bitkitcore.FeeRates import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner @@ -2152,12 +2153,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { fun `max onchain send drains when max still matches the selected speed`() = test { val address = "bcrt1qmaxsend" val maxAmount = 100_000uL + val feeRates = FeeRates(fast = 20u, mid = 10u, slow = 5u) balanceState.value = BalanceState(maxSendOnchainSats = maxAmount) whenever { lightningRepo.estimateMaxSendOnchain( address = address, speed = TransactionSpeed.Fast, - feeRates = null, + feeRates = feeRates, ) }.thenReturn(Result.success(maxAmount)) whenever { @@ -2166,6 +2168,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sats = maxAmount, speed = TransactionSpeed.Fast, utxosToSpend = null, + feeRates = feeRates, isMaxAmount = true, tags = emptyList(), ) @@ -2176,17 +2179,25 @@ class AppViewModelSendFlowTest : BaseUnitTest() { amount = maxAmount, payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Fast, + feeRates = feeRates, ), ) sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() + // same rates must back both the drain check and the send + verify(lightningRepo).estimateMaxSendOnchain( + address = address, + speed = TransactionSpeed.Fast, + feeRates = feeRates, + ) verify(lightningRepo).sendOnChain( address = address, sats = maxAmount, speed = TransactionSpeed.Fast, utxosToSpend = null, + feeRates = feeRates, isMaxAmount = true, tags = emptyList(), ) From 96d17bddaa699c3e0737d8810418ed36d1768eb3 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Wed, 2 Sep 2026 15:39:39 -0400 Subject: [PATCH 3/5] fix: pin max onchain send to selected speed --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 79 +++++++++------- .../viewmodels/AppViewModelSendFlowTest.kt | 92 ++++++++++++------- changelog.d/next/1144.fixed.md | 2 +- 3 files changed, 109 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 421721e2dd..7732508805 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1615,6 +1615,7 @@ class AppViewModel @Inject constructor( it.copy( amount = amount, isAmountInputValid = validateAmount(amount), + isMaxAmount = false, confirmedWarnings = persistentListOf(), ) } @@ -1768,6 +1769,7 @@ class AppViewModel @Inject constructor( _sendUiState.update { it.copy(selectedUtxos = utxos.toImmutableList()) } + refreshMaxSendOnchain() refreshFeeEstimates() setSendEffect(SendEffect.NavigateToConfirm) } @@ -2502,6 +2504,7 @@ class AppViewModel @Inject constructor( state.copy( amount = 0u, isAmountInputValid = false, + isMaxAmount = false, ) } } @@ -2886,36 +2889,11 @@ class AppViewModel @Inject constructor( speed = state.speed, utxosToSpend = state.selectedUtxos, feeRates = state.feeRates, - isMaxAmount = state.payMethod == SendMethod.ONCHAIN && - shouldDrainOnchain(address, amount, state), + isMaxAmount = state.payMethod == SendMethod.ONCHAIN && state.isMaxAmount, tags = tags, ) } - private suspend fun shouldDrainOnchain(address: String, amount: ULong, state: SendUiState): Boolean { - // cached max is computed at the default speed, so drain only if it still holds for the selected one - if (amount != walletRepo.balanceState.value.maxSendOnchainSats) return false - - val maxAtSelectedSpeed = lightningRepo.estimateMaxSendOnchain( - address = address, - speed = state.speed, - feeRates = state.feeRates, - ).onFailure { - Logger.warn("Failed to recompute max send amount for speed '${state.speed}'", it, context = TAG) - }.getOrNull() ?: return false - - if (amount != maxAtSelectedSpeed) { - Logger.info( - "Sending exact amount '$amount' instead of draining, " + - "max at speed '${state.speed}' is '$maxAtSelectedSpeed'", - context = TAG, - ) - return false - } - - return true - } - private suspend fun sendLightning( bolt11: String, amount: ULong? = null, @@ -2954,16 +2932,18 @@ class AppViewModel @Inject constructor( } } - /** Reselect utxos for current amount & speed then refresh fees using updated utxos */ - private fun refreshOnchainSendIfNeeded() { - val currentState = _sendUiState.value - if (currentState.payMethod != SendMethod.ONCHAIN || - currentState.amount == 0uL || - currentState.address.isEmpty() + /** Recheck the max sendable, reselect utxos for current amount & speed, then refresh fees */ + private suspend fun refreshOnchainSendIfNeeded() { + if (_sendUiState.value.payMethod != SendMethod.ONCHAIN || + _sendUiState.value.amount == 0uL || + _sendUiState.value.address.isEmpty() ) { return } + refreshMaxSendOnchain() + val currentState = _sendUiState.value + // refresh in background viewModelScope.launch(bgDispatcher) { // preselect utxos for deterministic fee estimation @@ -2983,6 +2963,40 @@ class AppViewModel @Inject constructor( } } + /** + * Flags the send as a drain when the amount reaches the max sendable to this recipient at the selected speed, + * lowering the amount to that max so the confirmed figure matches what the drain delivers. + */ + private suspend fun refreshMaxSendOnchain() { + val state = _sendUiState.value + if (state.payMethod != SendMethod.ONCHAIN || state.amount == 0uL || state.address.isEmpty()) return + + val max = lightningRepo.estimateMaxSendOnchain( + address = state.address, + speed = state.speed, + feeRates = state.feeRates, + ).getOrNull()?.takeIf { it > 0uL } + + if (max == null) { + // without an estimate the cached max is the only max-send signal left + _sendUiState.update { + it.copy(isMaxAmount = it.amount == walletRepo.balanceState.value.maxSendOnchainSats) + } + return + } + + val isMaxAmount = state.amount >= max + if (isMaxAmount && state.amount != max) { + Logger.info( + "Lowering amount '${state.amount}' to max '$max' at speed '${state.speed.serialized()}'", + context = TAG, + ) + } + _sendUiState.update { + it.copy(amount = if (isMaxAmount) max else it.amount, isMaxAmount = isMaxAmount) + } + } + private suspend fun refreshFeeEstimates() = withContext(bgDispatcher) { val currentState = _sendUiState.value @@ -3770,6 +3784,7 @@ data class SendUiState( val isAddressInputValid: Boolean = false, val amount: ULong = 0u, val isAmountInputValid: Boolean = false, + val isMaxAmount: Boolean = false, val isUnified: Boolean = false, val canSwitchWallet: Boolean = false, val payMethod: SendMethod = SendMethod.ONCHAIN, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 2e7ba4d78d..06f5bbc1f0 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -2150,22 +2150,24 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `max onchain send drains when max still matches the selected speed`() = test { + fun `max onchain send drains at the max for the newly selected speed`() = test { val address = "bcrt1qmaxsend" - val maxAmount = 100_000uL + val cachedMax = 100_000uL + val maxAtFast = 99_500uL val feeRates = FeeRates(fast = 20u, mid = 10u, slow = 5u) - balanceState.value = BalanceState(maxSendOnchainSats = maxAmount) + balanceState.value = BalanceState(maxSendOnchainSats = cachedMax) whenever { lightningRepo.estimateMaxSendOnchain( address = address, speed = TransactionSpeed.Fast, feeRates = feeRates, ) - }.thenReturn(Result.success(maxAmount)) + }.thenReturn(Result.success(maxAtFast)) + whenever { lightningRepo.getFeeRateForSpeed(any(), anyOrNull()) }.thenReturn(Result.success(20uL)) whenever { lightningRepo.sendOnChain( address = address, - sats = maxAmount, + sats = maxAtFast, speed = TransactionSpeed.Fast, utxosToSpend = null, feeRates = feeRates, @@ -2176,17 +2178,23 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = address, - amount = maxAmount, + amount = cachedMax, payMethod = SendMethod.ONCHAIN, - speed = TransactionSpeed.Fast, + speed = TransactionSpeed.Medium, feeRates = feeRates, ), ) + sut.setTransactionSpeed(TransactionSpeed.Fast) + advanceUntilIdle() + + assertEquals(maxAtFast, sut.sendUiState.value.amount) + assertTrue(sut.sendUiState.value.isMaxAmount) + sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - // same rates must back both the drain check and the send + // same rates must back both the max estimate and the send verify(lightningRepo).estimateMaxSendOnchain( address = address, speed = TransactionSpeed.Fast, @@ -2194,7 +2202,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) verify(lightningRepo).sendOnChain( address = address, - sats = maxAmount, + sats = maxAtFast, speed = TransactionSpeed.Fast, utxosToSpend = null, feeRates = feeRates, @@ -2204,23 +2212,26 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `max onchain send falls back to exact amount when selected speed changes the max`() = test { - val address = "bcrt1qmaxsendstale" - val maxAmount = 100_000uL - balanceState.value = BalanceState(maxSendOnchainSats = maxAmount) + fun `onchain send below the max is not a drain`() = test { + val address = "bcrt1qbelowmax" + val amount = 50_000uL + val feeRates = FeeRates(fast = 20u, mid = 10u, slow = 5u) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000uL) whenever { lightningRepo.estimateMaxSendOnchain( address = address, speed = TransactionSpeed.Fast, - feeRates = null, + feeRates = feeRates, ) - }.thenReturn(Result.success(maxAmount - 500uL)) + }.thenReturn(Result.success(99_500uL)) + whenever { lightningRepo.getFeeRateForSpeed(any(), anyOrNull()) }.thenReturn(Result.success(20uL)) whenever { lightningRepo.sendOnChain( address = address, - sats = maxAmount, + sats = amount, speed = TransactionSpeed.Fast, utxosToSpend = null, + feeRates = feeRates, isMaxAmount = false, tags = emptyList(), ) @@ -2228,65 +2239,84 @@ class AppViewModelSendFlowTest : BaseUnitTest() { setSendState( SendUiState( address = address, - amount = maxAmount, + amount = amount, payMethod = SendMethod.ONCHAIN, - speed = TransactionSpeed.Fast, + speed = TransactionSpeed.Medium, + feeRates = feeRates, ), ) + sut.setTransactionSpeed(TransactionSpeed.Fast) + advanceUntilIdle() + + assertEquals(amount, sut.sendUiState.value.amount) + assertFalse(sut.sendUiState.value.isMaxAmount) + sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() verify(lightningRepo).sendOnChain( address = address, - sats = maxAmount, + sats = amount, speed = TransactionSpeed.Fast, utxosToSpend = null, + feeRates = feeRates, isMaxAmount = false, tags = emptyList(), ) } @Test - fun `max onchain send falls back to exact amount when max cannot be recomputed`() = test { + fun `max onchain send still drains when the max cannot be estimated`() = test { val address = "bcrt1qmaxsendfailure" - val maxAmount = 100_000uL - balanceState.value = BalanceState(maxSendOnchainSats = maxAmount) + val cachedMax = 100_000uL + val feeRates = FeeRates(fast = 20u, mid = 10u, slow = 5u) + balanceState.value = BalanceState(maxSendOnchainSats = cachedMax) whenever { lightningRepo.estimateMaxSendOnchain( address = address, - speed = TransactionSpeed.Medium, - feeRates = null, + speed = TransactionSpeed.Fast, + feeRates = feeRates, ) }.thenReturn(Result.failure(AppError("no estimate"))) + whenever { lightningRepo.getFeeRateForSpeed(any(), anyOrNull()) }.thenReturn(Result.success(20uL)) whenever { lightningRepo.sendOnChain( address = address, - sats = maxAmount, - speed = TransactionSpeed.Medium, + sats = cachedMax, + speed = TransactionSpeed.Fast, utxosToSpend = null, - isMaxAmount = false, + feeRates = feeRates, + isMaxAmount = true, tags = emptyList(), ) }.thenReturn(Result.success("txid")) setSendState( SendUiState( address = address, - amount = maxAmount, + amount = cachedMax, payMethod = SendMethod.ONCHAIN, speed = TransactionSpeed.Medium, + feeRates = feeRates, ), ) + sut.setTransactionSpeed(TransactionSpeed.Fast) + advanceUntilIdle() + + assertEquals(cachedMax, sut.sendUiState.value.amount) + assertTrue(sut.sendUiState.value.isMaxAmount) + sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() verify(lightningRepo).sendOnChain( address = address, - sats = maxAmount, - speed = TransactionSpeed.Medium, + sats = cachedMax, + speed = TransactionSpeed.Fast, utxosToSpend = null, - isMaxAmount = false, + feeRates = feeRates, + isMaxAmount = true, tags = emptyList(), ) } diff --git a/changelog.d/next/1144.fixed.md b/changelog.d/next/1144.fixed.md index 26adef3256..9fc4447d21 100644 --- a/changelog.d/next/1144.fixed.md +++ b/changelog.d/next/1144.fixed.md @@ -1 +1 @@ -Fixed max on-chain sends so the wallet no longer drains at a fee speed the confirmed amount did not account for. +Fixed max on-chain sends so the amount always matches the fee speed and recipient it is sent at. From b27f23d2c24347ee930da3bdef0d92d3e52a2335 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Thu, 3 Sep 2026 14:46:27 -0400 Subject: [PATCH 4/5] fix: skip max recheck for hardware sends --- app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 7ec71f6c9b..8f9ccc7559 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -3839,7 +3839,8 @@ class AppViewModel @Inject constructor( */ private suspend fun refreshMaxSendOnchain() { val state = _sendUiState.value - if (state.payMethod != SendMethod.ONCHAIN || state.amount == 0uL || state.address.isEmpty()) return + if (state.payMethod != SendMethod.ONCHAIN || state.hardwareWalletId != null) return + if (state.amount == 0uL || state.address.isEmpty()) return val max = lightningRepo.estimateMaxSendOnchain( address = state.address, From bdf6963080706e986c3641b2c9dd5c99968fa84c Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Thu, 3 Sep 2026 20:54:17 -0400 Subject: [PATCH 5/5] fix: settle max send before onchain pay --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 10 ++ .../viewmodels/AppViewModelSendFlowTest.kt | 143 ++++++++++++++++++ 2 files changed, 153 insertions(+) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 8f9ccc7559..8184b984ef 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -3358,6 +3358,9 @@ class AppViewModel @Inject constructor( return } + // pay the amount & drain flag the refresh settled on, not the ones it is about to replace + onchainSendRefreshJob?.join() + val amount = _sendUiState.value.amount val lnurl = _sendUiState.value.lnurl @@ -3851,6 +3854,7 @@ class AppViewModel @Inject constructor( if (max == null) { // without an estimate the cached max is the only max-send signal left _sendUiState.update { + if (it.divergedFrom(state)) return@update it it.copy(isMaxAmount = it.amount == walletRepo.balanceState.value.maxSendOnchainSats) } return @@ -3864,10 +3868,16 @@ class AppViewModel @Inject constructor( ) } _sendUiState.update { + if (it.divergedFrom(state)) return@update it it.copy(amount = if (isMaxAmount) max else it.amount, isMaxAmount = isMaxAmount) } } + private fun SendUiState.divergedFrom(snapshot: SendUiState) = amount != snapshot.amount || + address != snapshot.address || + speed != snapshot.speed || + hardwareWalletId != snapshot.hardwareWalletId + private suspend fun refreshOnchainFeeUi() = withContext(bgDispatcher) { val currentState = _sendUiState.value updateOnchainFeeUi { it.copy(isLoading = true) } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 358996e9f1..9dfece134d 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -4688,6 +4688,137 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) } + @Test + fun `max onchain send waits for the in-flight max refresh before paying`() = test { + val address = "bcrt1qmaxsendrace" + val cachedMax = 100_000uL + val maxAtFast = 99_500uL + val feeRates = FeeRates(fast = 20u, mid = 10u, slow = 5u) + val finishEstimate = CompletableDeferred() + balanceState.value = BalanceState(maxSendOnchainSats = cachedMax) + whenever { + lightningRepo.estimateMaxSendOnchain( + address = address, + speed = TransactionSpeed.Fast, + feeRates = feeRates, + ) + }.doSuspendableAnswer { + finishEstimate.await() + Result.success(maxAtFast) + } + whenever { lightningRepo.getFeeRateForSpeed(any(), anyOrNull()) }.thenReturn(Result.success(20uL)) + whenever { + lightningRepo.sendOnChain( + address = address, + sats = maxAtFast, + speed = TransactionSpeed.Fast, + utxosToSpend = null, + feeRates = feeRates, + isMaxAmount = true, + tags = emptyList(), + ) + }.thenReturn(Result.success("txid")) + setSendState( + SendUiState( + address = address, + amount = cachedMax, + isMaxAmount = true, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + feeRates = feeRates, + ), + ) + + sut.setTransactionSpeed(TransactionSpeed.Fast) + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + + // the send must not go out while the max for the new speed is still being estimated + verifyNoOnchainSend() + + finishEstimate.complete(Unit) + advanceUntilIdle() + + verify(lightningRepo).sendOnChain( + address = address, + sats = maxAtFast, + speed = TransactionSpeed.Fast, + utxosToSpend = null, + feeRates = feeRates, + isMaxAmount = true, + tags = emptyList(), + ) + } + + @Test + fun `max refresh does not restore the amount after it was edited`() = test { + val address = "bcrt1qmaxsendedited" + val cachedMax = 100_000uL + val feeRates = FeeRates(fast = 20u, mid = 10u, slow = 5u) + val finishEstimate = CompletableDeferred() + balanceState.value = BalanceState(maxSendOnchainSats = cachedMax) + whenever { + lightningRepo.estimateMaxSendOnchain( + address = address, + speed = TransactionSpeed.Fast, + feeRates = feeRates, + ) + }.doSuspendableAnswer { + finishEstimate.await() + Result.success(99_500uL) + } + whenever { lightningRepo.getFeeRateForSpeed(any(), anyOrNull()) }.thenReturn(Result.success(20uL)) + setSendState( + SendUiState( + address = address, + amount = cachedMax, + isMaxAmount = true, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + feeRates = feeRates, + ), + ) + + sut.setTransactionSpeed(TransactionSpeed.Fast) + sut.setSendEvent(SendEvent.AmountChange(1_000uL)) + advanceUntilIdle() + finishEstimate.complete(Unit) + advanceUntilIdle() + + assertEquals(1_000uL, sut.sendUiState.value.amount) + assertFalse(sut.sendUiState.value.isMaxAmount) + } + + @Test + fun `hardware max send keeps its available amount and skips the onchain max estimate`() = test { + val available = 50_000uL + hwWallets.value = persistentListOf(hardwareWallet(fundingBalanceSats = available)) + whenever { hwWalletRepo.maxSpendableFunding(any(), any(), any()) }.thenReturn(Result.success(available)) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000uL) + setSendState( + SendUiState( + address = REGTEST_ADDRESS, + amount = available, + isAmountInputValid = true, + isMaxAmount = true, + hardwareWalletId = HARDWARE_WALLET_ID, + hardwareWalletName = "Trezor", + hardwareAvailableSats = available, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + feeRates = FeeRates(fast = 20u, mid = 10u, slow = 5u), + ), + ) + + sut.setTransactionSpeed(TransactionSpeed.Fast) + advanceUntilIdle() + + assertEquals(available, sut.sendUiState.value.amount) + assertEquals(available, sut.sendUiState.value.hardwareAvailableSats) + assertTrue(sut.sendUiState.value.isMaxAmount) + verify(lightningRepo, never()).estimateMaxSendOnchain(anyOrNull(), anyOrNull(), anyOrNull()) + } + @Test fun `private lightning contact payment consumes private list before send`() = test { val bolt11 = "lnbcrt1privatecontact" @@ -5316,6 +5447,18 @@ class AppViewModelSendFlowTest : BaseUnitTest() { method.invoke(sut) } + private suspend fun verifyNoOnchainSend() = verify(lightningRepo, never()).sendOnChain( + address = any(), + sats = any(), + speed = anyOrNull(), + utxosToSpend = anyOrNull(), + feeRates = anyOrNull(), + isTransfer = any(), + channelId = anyOrNull(), + isMaxAmount = any(), + tags = any(), + ) + private fun hardwareWallet(fundingBalanceSats: ULong) = HwWallet( id = HARDWARE_WALLET_ID, name = "Trezor",