diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt index d91a05306b..e125e82ae1 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt @@ -78,7 +78,7 @@ fun SpendingAdvancedScreen( val currentCurrencies by rememberUpdatedState(currencies) LaunchedEffect(order.clientBalanceSat) { - viewModel.updateTransferValues(order.clientBalanceSat) + viewModel.updateAdvancedTransferValues(order) } LaunchedEffect(amountUiState.sats) { @@ -86,7 +86,11 @@ fun SpendingAdvancedScreen( } LaunchedEffect(transferValues.maxLspBalance) { - amountInputViewModel.setMaxAmount(transferValues.maxLspBalance.toLong()) + amountInputViewModel.applyMaxLspBalance( + maxLspBalance = transferValues.maxLspBalance.toLong(), + enteredSats = amountUiState.sats, + currencies = currentCurrencies, + ) } LaunchedEffect(Unit) { @@ -129,16 +133,17 @@ fun SpendingAdvancedScreen( } } - val isValid = transferValues.let { + val isInRange = transferValues.let { val amount = amountUiState.sats.toULong() amount > 0u && it.maxLspBalance > 0u && amount in it.minLspBalance..it.maxLspBalance } + val isValid = isInRange && state.canAfford(order.clientBalanceSat) Content( uiState = state, transferValues = transferValues, isValid = isValid, - isLoading = isLoading, + isLoading = isLoading || state.isLoading, amountInputViewModel = amountInputViewModel, currencies = currencies, onBack = onBackClick, @@ -149,6 +154,32 @@ fun SpendingAdvancedScreen( ) } +/** + * Settling the max can land it below what is already entered, so the amount comes down with it + * rather than leaving a capacity that no longer exists selected. + */ +private fun AmountInputViewModel.applyMaxLspBalance( + maxLspBalance: Long, + enteredSats: Long, + currencies: CurrencyState, +) { + setMaxAmount(maxLspBalance) + if (maxLspBalance in 1.. headroom) return null + + return settleCapacity( + clientBalance = clientBalance, + headroom = headroom, + affordable = minLspBalance, + affordableFee = minFee, + overBudget = maxLspBalance, + overBudgetFee = maxFee, + ) + } + + private suspend fun settleCapacity( + clientBalance: ULong, + headroom: ULong, + affordable: ULong, + affordableFee: ULong, + overBudget: ULong, + overBudgetFee: ULong, + ): ULong { + var settled = affordable + var settledFee = affordableFee + var ceiling = overBudget + var ceilingFee = overBudgetFee + repeat(MAX_AFFORDABILITY_ROUNDS) { + val feeSpan = ceilingFee.safe() - settledFee.safe() + if (feeSpan == 0uL) return settled + val span = ceiling.safe() - settled.safe() + val feeHeadroom = headroom.safe() - settledFee.safe() + val candidate = settled.safe() + ((span.safe() * feeHeadroom.safe()) / feeSpan).safe() + if (candidate <= settled) return settled + val candidateFee = quoteAdvancedOrderFee(clientBalance, candidate) ?: return settled + if (candidateFee <= headroom) { + settled = candidate + settledFee = candidateFee + } else { + ceiling = candidate + ceilingFee = candidateFee + } + } + return settled + } + /** * Order cost the on-chain balance can fund, or null when the balance itself is unreadable. * @@ -758,18 +854,29 @@ class TransferViewModel @Inject constructor( return spendable.safe() - miningFee.safe() } + private suspend fun currentFundingBudget(): ULong? { + val sizedBudget = _spendingUiState.value.fundingBudgetSats + val hwWalletId = _spendingUiState.value.hwFundingWalletId + val liveBudget = if (hwWalletId != null) loadHwFundingBudget(hwWalletId) else loadFundingBudget() + return liveBudget ?: sizedBudget + } + + private suspend fun loadHwFundingBudget(walletId: String): ULong? { + val balance = hwWalletRepo.getFundingAccount(walletId).getOrNull()?.balanceSats ?: return null + return balance.safe() - hwFundingFeeReserve(balance).safe() + } + /** * Whether an order at [clientBalance] still fits what the wallet can fund. * * The advertised max can be a settled estimate rather than a verified one when a re-quote fails - * or does not converge, so the fee is re-quoted live before the order is placed. The budget is - * the one the limits were sized against, which is the on-chain balance for a soft wallet and the - * device account for a hardware transfer — a fresh on-chain read would reject every hardware - * transfer, whose funds never sit in this wallet. A budget that was never sized, or a quote the - * LSP will not give, leaves the decision to the confirm step rather than blocking the user here. + * or does not converge, so both sides are taken fresh before the order is placed: the fee is + * re-quoted and the budget comes from [currentFundingBudget]. A budget that was never sized, or + * a quote the LSP will not give, leaves the decision to the confirm step rather than blocking + * the user here. */ private suspend fun canFundOrder(clientBalance: ULong): Boolean { - val budget = _spendingUiState.value.fundingBudgetSats + val budget = currentFundingBudget() if (budget == null) { Logger.warn("Skipped funding check, no sized budget available", context = TAG) return true @@ -859,7 +966,7 @@ class TransferViewModel @Inject constructor( updateTransferValues(0uL) val availableAmount = account.balanceSats.safe() - hwFundingFeeReserve(account.balanceSats).safe() - _spendingUiState.update { it.copy(fundingBudgetSats = availableAmount) } + _spendingUiState.update { it.copy(fundingBudgetSats = availableAmount, hwFundingWalletId = walletId) } val initialLspFees = estimateInitialLspFees(availableAmount) if (initialLspFees == null) { @@ -1257,6 +1364,36 @@ class TransferViewModel @Inject constructor( // region Balance Calc + fun updateAdvancedTransferValues(order: IBtOrder) { + advancedLimitsJob?.cancel() + advancedLimitsJob = viewModelScope.launch { + _spendingUiState.update { it.copy(isLoading = true) } + updateTransferValues(order.clientBalanceSat) + + val values = _transferValues.value + val budget = currentFundingBudget() + if (values.maxLspBalance == 0uL || budget == null) { + _spendingUiState.update { it.copy(isLoading = false) } + return@launch + } + + val affordableMax = resolveAffordableLspBalance( + clientBalance = order.clientBalanceSat, + budget = budget, + minLspBalance = values.minLspBalance, + maxLspBalance = values.maxLspBalance, + ) + if (affordableMax != null && affordableMax < values.maxLspBalance) { + Logger.info( + "Settled max capacity '${values.maxLspBalance}' on affordable '$affordableMax'", + context = TAG, + ) + _transferValues.update { it.copy(maxLspBalance = affordableMax) } + } + _spendingUiState.update { it.copy(isLoading = false) } + } + } + fun updateTransferValues(clientBalanceSat: ULong) { val options = blocktankRepo.calculateLiquidityOptions(clientBalanceSat).getOrNull() _transferValues.value = if (options != null) { @@ -1757,6 +1894,8 @@ data class TransferToSpendingUiState( val feeEstimate: Long? = null, /** Budget the transfer limits were sized against, or null while unknown. */ val fundingBudgetSats: ULong? = null, + /** Hardware wallet the budget was sized from, or null when it came from this wallet's savings. */ + val hwFundingWalletId: String? = null, ) private data class SpendingConfirmFundingPlan( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 563d4ce358..bd31c0b64f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -359,6 +359,8 @@ Please wait, your funds transfer is in progress. This should take <accent>±10 minutes.</accent> Spendable Onchain Spending + Your savings cannot cover the liquidity fee for this receiving capacity. Choose a smaller amount. + Not Enough Funds The receiving capacity is currently limited to ₿ {amount}. Receiving Capacity Maximum Liquidity fee diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index b90b64d428..bd7ef7ff7c 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -673,6 +673,337 @@ class TransferViewModelTest : BaseUnitTest() { assertEquals(999uL, sut.spendingUiState.value.hwMiningFeeSats) } + @Test + fun `onReceivingAmountChange discards a slower quote for an amount already left`() = test { + val staleAmount = 900_000uL + val freshAmount = 300_000uL + val staleQuote = CompletableDeferred>() + val staleResponse = stubFeeResponse(6_000uL) + val freshResponse = stubFeeResponse(1_000uL) + whenever(blocktankRepo.calculateLiquidityOptions(any())).thenReturn( + Result.success( + ChannelLiquidityOptions( + defaultLspBalanceSat = LSP_BALANCE, + minLspBalanceSat = LSP_BALANCE, + maxLspBalanceSat = 1_000_000uL, + maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE, + ) + ) + ) + whenever(blocktankRepo.estimateOrderFee(any(), eq(staleAmount), any())) + .doSuspendableAnswer { staleQuote.await() } + whenever(blocktankRepo.estimateOrderFee(any(), eq(freshAmount), any())) + .thenReturn(Result.success(freshResponse)) + sut.updateLimits() + advanceUntilIdle() + + sut.onReceivingAmountChange(staleAmount.toLong()) + runCurrent() + sut.onReceivingAmountChange(freshAmount.toLong()) + advanceUntilIdle() + + assertEquals(1_000L, sut.spendingUiState.value.feeEstimate) + + staleQuote.complete(Result.success(staleResponse)) + advanceUntilIdle() + + assertEquals(1_000L, sut.spendingUiState.value.feeEstimate) + assertEquals(freshAmount.toLong(), sut.spendingUiState.value.receivingAmount) + } + + @Test + fun `onSpendingAdvancedContinue rejects a receiving capacity the balance cannot fund`() = test { + val clientBalance = 260_000uL + val order = previewBtOrder(clientBalanceSat = clientBalance) + val budget = 265_000uL + val raisedCapacity = LSP_BALANCE * 2u + // the default capacity is affordable, the raised one is not + val affordable = stubFeeResponse(1_000uL) + val unaffordable = stubFeeResponse(6_000uL) + stubSpendableBalances(budget) + whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(0uL)) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) + whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(order)) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(affordable)) + whenever(blocktankRepo.estimateOrderFee(eq(clientBalance), eq(raisedCapacity), any())) + .thenReturn(Result.success(unaffordable)) + sut.updateLimits() + advanceUntilIdle() + sut.onConfirmAmount(clientBalance.toLong()) + advanceUntilIdle() + + sut.transferEffects.test { + sut.onSpendingAdvancedContinue(raisedCapacity.toLong()) + advanceUntilIdle() + + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + // only the initial order from onConfirmAmount, no unaffordable one on top of it + verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) + } + + @Test + fun `onSpendingAdvancedContinue creates the order when the capacity fits the budget`() = test { + val clientBalance = 260_000uL + val order = previewBtOrder(clientBalanceSat = clientBalance) + val budget = 265_000uL + val response = stubFeeResponse(1_000uL) + stubSpendableBalances(budget) + whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(0uL)) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) + whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(order)) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(response)) + sut.updateLimits() + advanceUntilIdle() + sut.onConfirmAmount(clientBalance.toLong()) + advanceUntilIdle() + + sut.onSpendingAdvancedContinue(LSP_BALANCE.toLong()) + advanceUntilIdle() + + assertTrue(sut.spendingUiState.value.isAdvanced) + verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) + } + + @Test + fun `onSpendingAdvancedContinue proceeds when no budget was sized`() = test { + val clientBalance = 260_000uL + val order = previewBtOrder(clientBalanceSat = clientBalance) + val raisedCapacity = LSP_BALANCE * 2u + // a capacity the sized budget would have rejected, had the limits ever been sized + val unaffordable = stubFeeResponse(6_000uL) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) + whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(order)) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(unaffordable)) + // deliberately no updateLimits call, so the budget stays unsized + sut.onConfirmAmount(clientBalance.toLong()) + advanceUntilIdle() + assertNull(sut.spendingUiState.value.fundingBudgetSats) + + sut.onSpendingAdvancedContinue(raisedCapacity.toLong()) + advanceUntilIdle() + + // an unsized budget must not block the user; confirm stays the authority + assertTrue(sut.spendingUiState.value.isAdvanced) + verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) + } + + @Test + fun `onSpendingAdvancedContinue proceeds when the capacity fee quote fails`() = test { + val clientBalance = 260_000uL + val order = previewBtOrder(clientBalanceSat = clientBalance) + val raisedCapacity = LSP_BALANCE * 2u + val affordable = stubFeeResponse(1_000uL) + stubSpendableBalances(265_000uL) + whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(0uL)) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) + whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(order)) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(affordable)) + sut.updateLimits() + advanceUntilIdle() + sut.onConfirmAmount(clientBalance.toLong()) + advanceUntilIdle() + // the budget is sized, so this is the failed-quote path rather than the unsized one + assertNotNull(sut.spendingUiState.value.fundingBudgetSats) + + // the LSP stops quoting only after the limits were sized + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())) + .thenReturn(Result.failure(AppError("lsp unreachable"))) + + sut.onSpendingAdvancedContinue(raisedCapacity.toLong()) + advanceUntilIdle() + + // a quote the LSP will not give must not block the user; confirm stays the authority + assertTrue(sut.spendingUiState.value.isAdvanced) + verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) + } + + @Test + fun `updateAdvancedTransferValues settles the max on a capacity the balance can fund`() = test { + val order = previewBtOrder(clientBalanceSat = ADVANCED_CLIENT_BALANCE) + stubSpendableBalances(ADVANCED_BUDGET) + whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(0uL)) + whenever(blocktankRepo.calculateLiquidityOptions(any())).thenReturn( + Result.success(advancedLiquidityOptions(maxLspBalanceSat = 2_000_000uL)) + ) + stubCapacityPricedFees() + + sut.updateAdvancedTransferValues(order) + advanceUntilIdle() + + // fee is 1_000 + 1% of the capacity, and the budget leaves 10_000 over the client balance + assertEquals(900_000uL, sut.transferValues.value.maxLspBalance) + assertFalse(sut.spendingUiState.value.isLoading) + } + + @Test + fun `updateAdvancedTransferValues leaves an affordable max untouched`() = test { + val order = previewBtOrder(clientBalanceSat = ADVANCED_CLIENT_BALANCE) + stubSpendableBalances(ADVANCED_BUDGET) + whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(0uL)) + whenever(blocktankRepo.calculateLiquidityOptions(any())).thenReturn( + Result.success(advancedLiquidityOptions(maxLspBalanceSat = 400_000uL)) + ) + stubCapacityPricedFees() + + sut.updateAdvancedTransferValues(order) + advanceUntilIdle() + + assertEquals(400_000uL, sut.transferValues.value.maxLspBalance) + } + + @Test + fun `updateAdvancedTransferValues holds the loading state while settling the max`() = test { + val order = previewBtOrder(clientBalanceSat = ADVANCED_CLIENT_BALANCE) + val pendingQuote = CompletableDeferred>() + stubSpendableBalances(ADVANCED_BUDGET) + whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(0uL)) + whenever(blocktankRepo.calculateLiquidityOptions(any())).thenReturn( + Result.success(advancedLiquidityOptions(maxLspBalanceSat = 2_000_000uL)) + ) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).doSuspendableAnswer { + pendingQuote.await() + } + + sut.updateAdvancedTransferValues(order) + advanceUntilIdle() + + assertTrue(sut.spendingUiState.value.isLoading) + + pendingQuote.complete(Result.failure(AppError("no quote"))) + advanceUntilIdle() + + assertFalse(sut.spendingUiState.value.isLoading) + } + + @Test + fun `onConfirmAmount rejects an order the balance can no longer fund`() = test { + val amount = 260_000uL + val response = stubFeeResponse(1_000uL) + stubSpendableBalances(265_000uL) + whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(0uL)) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(response)) + sut.updateLimits() + advanceUntilIdle() + // the savings drain after the limits were sized + stubSpendableBalances(100_000uL) + + sut.transferEffects.test { + sut.onConfirmAmount(amount.toLong()) + advanceUntilIdle() + + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + verify(blocktankRepo, never()).createOrder(any(), any(), any()) + } + + @Test + fun `onSpendingAdvancedContinue rejects a capacity the drained balance can no longer fund`() = test { + val clientBalance = 260_000uL + val order = previewBtOrder(clientBalanceSat = clientBalance) + val raisedCapacity = LSP_BALANCE * 2u + val response = stubFeeResponse(1_000uL) + stubSpendableBalances(265_000uL) + whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(0uL)) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) + whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(order)) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(response)) + sut.updateLimits() + advanceUntilIdle() + sut.onConfirmAmount(clientBalance.toLong()) + advanceUntilIdle() + // the savings drain after the order was placed, before the capacity is raised + stubSpendableBalances(100_000uL) + + sut.transferEffects.test { + sut.onSpendingAdvancedContinue(raisedCapacity.toLong()) + advanceUntilIdle() + + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + // only the initial order, no raised one on top of it + verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) + } + + @Test + fun `onSpendingAdvancedContinue rejects a capacity the drained device account cannot fund`() = test { + val clientBalance = 100_000uL + val order = previewBtOrder(clientBalanceSat = clientBalance) + val raisedCapacity = LSP_BALANCE * 2u + val response = stubFeeResponse(6_000uL) + stubSpendableBalances(0uL) // empty on-chain wallet, as in the hardware e2e + blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) + stubHwFundingAccount(balanceSats = ON_CHAIN_BALANCE) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(1uL)) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) + whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(order)) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(response)) + sut.updateHwLimits(HARDWARE_WALLET_ID) + advanceUntilIdle() + sut.onConfirmAmount(clientBalance.toLong()) + advanceUntilIdle() + // the device account is spent from elsewhere after the limits were sized + stubHwFundingAccount(balanceSats = 50_000uL) + + sut.transferEffects.test { + sut.onSpendingAdvancedContinue(raisedCapacity.toLong()) + advanceUntilIdle() + + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + // only the initial order, no raised one on top of it + verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) + } + + @Test + fun `onSpendingAdvancedContinue funds a hardware transfer from the device balance`() = test { + // Regression: the capacity check must not read on-chain savings here, or every hardware + // transfer is rejected because those funds live on the device. + val clientBalance = 100_000uL + val order = previewBtOrder(clientBalanceSat = clientBalance) + val raisedCapacity = LSP_BALANCE * 2u + // a fee the empty on-chain wallet could never cover, but the device account easily can + val deviceAffordable = stubFeeResponse(6_000uL) + stubSpendableBalances(0uL) // empty on-chain wallet, as in the hardware e2e + blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) + stubHwFundingAccount(balanceSats = ON_CHAIN_BALANCE) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(1uL)) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) + whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(order)) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(deviceAffordable)) + sut.updateHwLimits(HARDWARE_WALLET_ID) + advanceUntilIdle() + sut.onConfirmAmount(clientBalance.toLong()) + advanceUntilIdle() + + sut.onSpendingAdvancedContinue(raisedCapacity.toLong()) + advanceUntilIdle() + + assertTrue(sut.spendingUiState.value.isAdvanced) + verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) + } + @Test fun `prepareSpendingConfirmFunding exposes real mining fee for confirm UI`() = test { val order = previewBtOrder(feeSat = 98_000uL) @@ -1997,6 +2328,22 @@ class TransferViewModelTest : BaseUnitTest() { whenever(it.serviceFeeSat).thenReturn(0uL) } + private fun advancedLiquidityOptions(maxLspBalanceSat: ULong) = ChannelLiquidityOptions( + defaultLspBalanceSat = 100_000uL, + minLspBalanceSat = 50_000uL, + maxLspBalanceSat = maxLspBalanceSat, + maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE, + ) + + /** Prices an order at a flat 1_000 plus 1% of the receiving capacity, as the LSP charges both sides. */ + private suspend fun stubCapacityPricedFees() { + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).doSuspendableAnswer { invocation -> + // ULong params are erased to long across the mock boundary + val capacity = invocation.getArgument(1).toULong() + Result.success(stubFeeResponse(1_000uL + capacity / 100uL)) + } + } + private fun liquidityOptionsForCreate(maxClientBalanceSat: ULong) = ChannelLiquidityOptions( defaultLspBalanceSat = LSP_BALANCE, minLspBalanceSat = LSP_BALANCE, @@ -2010,6 +2357,18 @@ class TransferViewModelTest : BaseUnitTest() { return mock().also { whenever(it.options).thenReturn(options) } } + private suspend fun stubHwFundingAccount(balanceSats: ULong) { + whenever(hwWalletRepo.getFundingAccount(HARDWARE_WALLET_ID)).thenReturn( + Result.success( + HwFundingAccount.Trezor( + xpub = XPUB, + addressType = HwFundingAddressType.NATIVE_SEGWIT, + balanceSats = balanceSats, + ), + ), + ) + } + private suspend fun stubSpendableBalances(spendable: ULong) { val balances = BalanceDetails( totalOnchainBalanceSats = spendable, @@ -2048,6 +2407,8 @@ class TransferViewModelTest : BaseUnitTest() { const val LSP_MAX_CLIENT_BALANCE = 1_766_193uL const val OPTION_MAX_CLIENT_BALANCE = 1_687_598uL const val LSP_BALANCE = 252_368uL + const val ADVANCED_CLIENT_BALANCE = 100_000uL + const val ADVANCED_BUDGET = 110_000uL const val NETWORK_FEE = 2_112uL const val SERVICE_FEE = 286uL const val LSP_FEE = 2_398uL // NETWORK_FEE + SERVICE_FEE diff --git a/changelog.d/next/1180.fixed.md b/changelog.d/next/1180.fixed.md new file mode 100644 index 0000000000..080d2dbced --- /dev/null +++ b/changelog.d/next/1180.fixed.md @@ -0,0 +1 @@ +The advanced transfer screen now offers a maximum receiving capacity your balance can actually pay for, instead of one that fails later on the confirmation screen.