diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 728e878f5..6154f4203 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 6ce64be2a..421721e2d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2879,17 +2879,43 @@ 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 && - amount == walletRepo.balanceState.value.maxSendOnchainSats, + 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, 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, diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index e40315dc4..309823c87 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 181f88299..2e7ba4d78 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 @@ -2148,6 +2149,148 @@ 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 + val feeRates = FeeRates(fast = 20u, mid = 10u, slow = 5u) + balanceState.value = BalanceState(maxSendOnchainSats = maxAmount) + whenever { + lightningRepo.estimateMaxSendOnchain( + address = address, + speed = TransactionSpeed.Fast, + feeRates = feeRates, + ) + }.thenReturn(Result.success(maxAmount)) + whenever { + lightningRepo.sendOnChain( + address = address, + sats = maxAmount, + speed = TransactionSpeed.Fast, + utxosToSpend = null, + feeRates = feeRates, + isMaxAmount = true, + tags = emptyList(), + ) + }.thenReturn(Result.success("txid")) + setSendState( + SendUiState( + address = address, + 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(), + ) + } + + @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 000000000..26adef325 --- /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.