From 5620bef613ace627ccc633541e9741ffa91587ed Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 10 Sep 2026 20:02:26 +0200 Subject: [PATCH] fix: create order only on swipe --- app/src/main/java/to/bitkit/ui/ContentView.kt | 18 +- .../ui/screens/transfer/LiquidityScreen.kt | 8 +- .../transfer/SpendingAdvancedScreen.kt | 24 +- .../screens/transfer/SpendingAmountScreen.kt | 4 +- .../screens/transfer/SpendingConfirmScreen.kt | 299 +------ .../screens/transfer/TransferPreviewData.kt | 11 + .../hardware/SpendingAmountHwScreen.kt | 4 +- .../transfer/hardware/SpendingHwSignScreen.kt | 49 +- .../hardware/SpendingHwSignedScreen.kt | 15 +- .../to/bitkit/viewmodels/TransferViewModel.kt | 334 ++++---- .../test/java/to/bitkit/ui/ContentViewTest.kt | 15 +- .../viewmodels/TransferViewModelTest.kt | 728 ++++++++++++++---- changelog.d/next/1247.fixed.md | 1 + .../hardware-wallet/transfer-to-spending.xml | 4 +- 14 files changed, 884 insertions(+), 630 deletions(-) create mode 100644 changelog.d/next/1247.fixed.md diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index c7711c6071..1da30e1141 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -807,7 +807,7 @@ private fun RootNavHost( val scope = rememberCoroutineScope() LaunchedEffect(Unit) { transferViewModel.transferEffects.collect { effect -> - transferEffectDestination(effect)?.let { navController.navigateTo(it) } + navController.navigateForTransferEffect(effect) } } @@ -987,7 +987,7 @@ private fun RootNavHost( viewModel = transferViewModel, isOffline = connectivityState != ConnectivityState.CONNECTED, onBackClick = { navController.popBackStack() }, - onOrderCreated = { navController.navigateTo(Routes.SpendingConfirm) }, + onQuoteReady = { navController.navigateTo(Routes.SpendingConfirm) }, toastException = { appViewModel.toast(it) }, toast = { title, description -> appViewModel.toast( @@ -1006,7 +1006,7 @@ private fun RootNavHost( viewModel = transferViewModel, isOffline = connectivityState != ConnectivityState.CONNECTED, onBackClick = { navController.popBackStack() }, - onOrderCreated = { navController.navigateTo(Routes.SpendingHwSign(walletId)) }, + onQuoteReady = { navController.navigateTo(Routes.SpendingHwSign(walletId)) }, ) } composableWithDefaultTransitions { entry -> @@ -1042,8 +1042,7 @@ private fun RootNavHost( SpendingAdvancedScreen( viewModel = transferViewModel, onBackClick = { navController.popBackStack() }, - // Pops back to whoever opened Advanced: SpendingConfirm or SpendingHwSign. - onOrderCreated = { navController.popBackStack() }, + onQuoteReady = { navController.popBackStack() }, ) } deepLinkableComposable { @@ -2124,6 +2123,15 @@ internal fun transferEffectDestination(effect: TransferEffect): Routes? = when ( else -> null } +internal fun NavController.navigateForTransferEffect(effect: TransferEffect) { + val destination = transferEffectDestination(effect) ?: return + navigateTo(destination) { + if (effect is TransferEffect.OnSpendingFundingPaid) { + popUpTo { inclusive = true } + } + } +} + internal fun transferSpendingStartRoute(hasSeenSpendingIntro: Boolean): Routes = when { hasSeenSpendingIntro -> Routes.SpendingAmount else -> Routes.SpendingIntro diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/LiquidityScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/LiquidityScreen.kt index 42c106330b..aa4db0cf31 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/LiquidityScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/LiquidityScreen.kt @@ -35,11 +35,11 @@ fun LiquidityScreen( ) { val transfer = transferViewModel ?: return val state by transfer.spendingUiState.collectAsStateWithLifecycle() - val order = state.order ?: return + if (state.feeSat == 0uL) return - val channelSize = (order.clientBalanceSat + order.lspBalanceSat).toLong() - val localBalance = order.clientBalanceSat.toLong() - val remoteBalance = order.lspBalanceSat.toLong() + val channelSize = (state.clientBalanceSat + state.lspBalanceSat).toLong() + val localBalance = state.clientBalanceSat.toLong() + val remoteBalance = state.lspBalanceSat.toLong() LiquidityScreen( channelSize = channelSize, 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 1f2790e242..839f57dcd5 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 @@ -26,7 +26,6 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R -import to.bitkit.ext.mockOrder import to.bitkit.models.Toast import to.bitkit.models.formatToModernDisplay import to.bitkit.repositories.CurrencyState @@ -61,15 +60,15 @@ import to.bitkit.viewmodels.previewAmountInputViewModel fun SpendingAdvancedScreen( viewModel: TransferViewModel, onBackClick: () -> Unit = {}, - onOrderCreated: () -> Unit = {}, + onQuoteReady: () -> Unit = {}, currencies: CurrencyState = LocalCurrencies.current, amountInputViewModel: AmountInputViewModel = hiltViewModel(), ) { - val currentOnOrderCreated by rememberUpdatedState(onOrderCreated) + val currentOnQuoteReady by rememberUpdatedState(onQuoteReady) val app = appViewModel ?: return val context = LocalContext.current val state by viewModel.spendingUiState.collectAsStateWithLifecycle() - val order = state.order ?: return + if (state.feeSat == 0uL) return val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() var isLoading by remember { mutableStateOf(false) } @@ -77,8 +76,8 @@ fun SpendingAdvancedScreen( val currentMaxLspBalance by rememberUpdatedState(transferValues.maxLspBalance) val currentCurrencies by rememberUpdatedState(currencies) - LaunchedEffect(order.clientBalanceSat) { - viewModel.updateAdvancedTransferValues(order) + LaunchedEffect(state.clientBalanceSat) { + viewModel.updateAdvancedTransferValues(state.clientBalanceSat) } LaunchedEffect(amountUiState.sats) { @@ -96,7 +95,7 @@ fun SpendingAdvancedScreen( LaunchedEffect(Unit) { viewModel.transferEffects.collect { effect -> when (effect) { - TransferEffect.OnOrderCreated -> currentOnOrderCreated() + TransferEffect.OnQuoteReady -> currentOnQuoteReady() is TransferEffect.ToastException -> { isLoading = false app.toast(effect.e) @@ -137,7 +136,7 @@ fun SpendingAdvancedScreen( val amount = amountUiState.sats.toULong() amount > 0u && it.maxLspBalance > 0u && amount in it.minLspBalance..it.maxLspBalance } - val isValid = isInRange && state.canAfford(order.clientBalanceSat) + val isValid = isInRange && state.canAfford(state.clientBalanceSat) Content( uiState = state, @@ -169,11 +168,6 @@ private fun AmountInputViewModel.applyMaxLspBalance( } } -/** - * The max is settled on an affordable capacity before it is offered, so the quote for the typed - * amount only has to catch what moves after that. Until it lands the confirm step is the authority, - * so continue is left enabled. - */ private fun TransferToSpendingUiState.canAfford(clientBalanceSat: ULong): Boolean { val budget = fundingBudgetSats ?: return true val fee = feeEstimate ?: return true @@ -300,7 +294,7 @@ private fun Preview() { AppThemeSurface { Content( uiState = TransferToSpendingUiState( - order = mockOrder().copy(clientBalanceSat = 100_000u), + clientBalanceSat = 100_000uL, receivingAmount = 55_000L, feeEstimate = 2_500L, ), @@ -324,7 +318,7 @@ private fun PreviewLoading() { AppThemeSurface { Content( uiState = TransferToSpendingUiState( - order = mockOrder().copy(clientBalanceSat = 50_000u), + clientBalanceSat = 50_000uL, receivingAmount = 20_000L, feeEstimate = null, isLoading = true, diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt index bf7c7436af..1bebb319fc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt @@ -62,7 +62,7 @@ fun SpendingAmountScreen( viewModel: TransferViewModel, isOffline: Boolean, onBackClick: () -> Unit = {}, - onOrderCreated: () -> Unit = {}, + onQuoteReady: () -> Unit = {}, toastException: (Throwable) -> Unit, toast: (title: String, description: String) -> Unit, currencies: CurrencyState = LocalCurrencies.current, @@ -82,7 +82,7 @@ fun SpendingAmountScreen( LaunchedEffect(Unit) { viewModel.transferEffects.collect { effect -> when (effect) { - TransferEffect.OnOrderCreated -> onOrderCreated() + TransferEffect.OnQuoteReady -> onQuoteReady() is TransferEffect.ToastError -> toast(effect.title, effect.description) is TransferEffect.ToastException -> toastException(effect.e) else -> Unit diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt index 5591a954db..a9dd415119 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt @@ -1,5 +1,6 @@ package to.bitkit.ui.screens.transfer +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -33,17 +34,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.synonym.bitkitcore.BtBolt11InvoiceState -import com.synonym.bitkitcore.BtOrderState -import com.synonym.bitkitcore.BtOrderState2 -import com.synonym.bitkitcore.BtPaymentState -import com.synonym.bitkitcore.BtPaymentState2 -import com.synonym.bitkitcore.IBtBolt11Invoice -import com.synonym.bitkitcore.IBtOnchainTransaction -import com.synonym.bitkitcore.IBtOnchainTransactions -import com.synonym.bitkitcore.IBtOrder -import com.synonym.bitkitcore.IBtPayment -import com.synonym.bitkitcore.ILspNode import to.bitkit.R import to.bitkit.models.safe import to.bitkit.ui.components.ButtonSize @@ -68,6 +58,7 @@ import to.bitkit.ui.utils.RequestNotificationPermissions import to.bitkit.ui.utils.rememberNotificationToggleClick import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.SettingsViewModel +import to.bitkit.viewmodels.TransferToSpendingUiState import to.bitkit.viewmodels.TransferViewModel private const val SWIPE_ROTATION_DEGREES = 14f @@ -86,17 +77,19 @@ fun SpendingConfirmScreen( val state by viewModel.spendingUiState.collectAsStateWithLifecycle() - val order = state.order ?: run { + if (state.feeSat == 0uL) { onCloseClick() return } val isAdvanced = state.isAdvanced val miningFeeSats = state.miningFeeSats val isConfirmFeeReady = state.isConfirmFeeReady - val isConfirmPaying = state.isConfirmPaying + val isConfirmPaying = state.isBusy - LaunchedEffect(order.id, order.feeSat) { - viewModel.prepareSpendingConfirmFunding(order) + BackHandler(enabled = state.isBusy) {} + + LaunchedEffect(state.feeSat) { + viewModel.prepareSpendingConfirmFunding() } val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() @@ -116,12 +109,12 @@ fun SpendingConfirmScreen( Box { Content( - onBackClick = onBackClick, - onLearnMoreClick = onLearnMoreClick, - onAdvancedClick = onAdvancedClick, + onBackClick = { if (!state.isBusy) onBackClick() }, + onLearnMoreClick = { if (!state.isBusy) onLearnMoreClick() }, + onAdvancedClick = { if (!state.isBusy) onAdvancedClick() }, onUseDefaultLspBalanceClick = viewModel::onUseDefaultLspBalanceClick, - onTransferToSpendingConfirm = { viewModel.onTransferToSpendingConfirm(order) }, - order = order, + onTransferToSpendingConfirm = viewModel::onTransferToSpendingConfirm, + state = state, miningFeeSats = miningFeeSats, isConfirmFeeReady = isConfirmFeeReady, isConfirmPaying = isConfirmPaying, @@ -152,7 +145,7 @@ private fun Content( onSwitchClick: () -> Unit, hasNotificationPermission: Boolean, onTransferToSpendingConfirm: () -> Unit, - order: IBtOrder, + state: TransferToSpendingUiState, miningFeeSats: ULong, isConfirmFeeReady: Boolean, isConfirmPaying: Boolean, @@ -164,7 +157,7 @@ private fun Content( AppTopBar( titleText = stringResource(R.string.lightning__transfer__nav_title), onBackClick = onBackClick, - actions = { DrawerNavIcon() }, + actions = { if (!isConfirmPaying) DrawerNavIcon() }, ) Box(modifier = Modifier.fillMaxSize()) { if (!isAdvanced) { @@ -187,11 +180,10 @@ private fun Content( .fillMaxSize() .verticalScroll(rememberScrollState()) ) { - // Match iOS SpendingConfirm: network fee = mining fee, lsp fee = order fee - client. - val clientBalance = order.clientBalanceSat - val lspFee = order.feeSat.safe() - clientBalance.safe() - val total = order.feeSat.safe() + miningFeeSats.safe() - val lspBalance = order.lspBalanceSat + val clientBalance = state.clientBalanceSat + val lspFee = state.feeSat.safe() - clientBalance.safe() + val total = state.feeSat.safe() + miningFeeSats.safe() + val lspBalance = state.lspBalanceSat VerticalSpacer(32.dp) Display(stringResource(R.string.lightning__transfer__confirm).withAccent(accentColor = Colors.Purple)) @@ -279,7 +271,6 @@ private fun Content( FillHeight() - // Match iOS: keep swipe in loading state until mining fee is ready. val canConfirm = isConfirmFeeReady && miningFeeSats > 0uL && !isConfirmPaying SwipeToConfirm( text = stringResource(R.string.lightning__transfer__swipe), @@ -307,64 +298,11 @@ private fun Preview() { onAdvancedClick = {}, onUseDefaultLspBalanceClick = {}, onTransferToSpendingConfirm = {}, - order = IBtOrder( - id = "order_7e6f3b7c-486a-4f5a-8b1e-2c9d7f0a8b9d", - state = BtOrderState.CREATED, - state2 = BtOrderState2.CREATED, - feeSat = 1000UL, - networkFeeSat = 250UL, - serviceFeeSat = 750UL, - lspBalanceSat = 2000000UL, - clientBalanceSat = 500000UL, - zeroConf = false, - zeroReserve = true, - clientNodeId = null, - channelExpiryWeeks = 8u, - channelExpiresAt = "2025-09-22T08:29:03Z", - orderExpiresAt = "2025-07-29T08:29:03Z", - channel = null, - lspNode = ILspNode( - alias = "Bitkit LSP", - pubkey = "02f12451995802149b1855a7948305763328e9304337b51e45e7f1b637956424e8", - connectionStrings = listOf("mock@127.0.0.1:9735"), - readonly = null - ), - lnurl = null, - payment = IBtPayment( - state = BtPaymentState.CREATED, - state2 = BtPaymentState2.CREATED, - paidSat = 0UL, - bolt11Invoice = IBtBolt11Invoice( - request = "lnmock", - state = BtBolt11InvoiceState.PENDING, - expiresAt = "2025-07-28T12:00:00Z", - updatedAt = "2025-07-28T08:30:00Z" - ), - onchain = IBtOnchainTransactions( - address = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", - confirmedSat = 0UL, - requiredConfirmations = 1u, - transactions = listOf( - IBtOnchainTransaction( - amountSat = 50000UL, - txId = "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", - vout = 0u, - blockHeight = null, - blockConfirmationCount = 0u, - feeRateSatPerVbyte = 12.5, - confirmed = false, - suspicious0ConfReason = "" - ) - ) - ), - isManuallyPaid = null, - manualRefunds = null - ), - couponCode = null, - source = null, - discount = null, - updatedAt = "2025-07-28T08:29:03Z", - createdAt = "2025-07-28T08:29:03Z" + state = TransferToSpendingUiState( + clientBalanceSat = 500_000uL, + lspBalanceSat = 2_000_000uL, + feeSat = 1_000uL, + ), onSwitchClick = {}, hasNotificationPermission = true, @@ -386,64 +324,11 @@ private fun Preview2() { onAdvancedClick = {}, onUseDefaultLspBalanceClick = {}, onTransferToSpendingConfirm = {}, - order = IBtOrder( - id = "order_7e6f3b7c-486a-4f5a-8b1e-2c9d7f0a8b9d", - state = BtOrderState.CREATED, - state2 = BtOrderState2.CREATED, - feeSat = 1000UL, - networkFeeSat = 250UL, - serviceFeeSat = 750UL, - lspBalanceSat = 2000000UL, - clientBalanceSat = 500000UL, - zeroConf = false, - zeroReserve = true, - clientNodeId = null, - channelExpiryWeeks = 8u, - channelExpiresAt = "2025-09-22T08:29:03Z", - orderExpiresAt = "2025-07-29T08:29:03Z", - channel = null, - lspNode = ILspNode( - alias = "Bitkit LSP", - pubkey = "02f12451995802149b1855a7948305763328e9304337b51e45e7f1b637956424e8", - connectionStrings = listOf("mock@127.0.0.1:9735"), - readonly = null - ), - lnurl = null, - payment = IBtPayment( - state = BtPaymentState.CREATED, - state2 = BtPaymentState2.CREATED, - paidSat = 0UL, - bolt11Invoice = IBtBolt11Invoice( - request = "lnmock", - state = BtBolt11InvoiceState.PENDING, - expiresAt = "2025-07-28T12:00:00Z", - updatedAt = "2025-07-28T08:30:00Z" - ), - onchain = IBtOnchainTransactions( - address = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", - confirmedSat = 0UL, - requiredConfirmations = 1u, - transactions = listOf( - IBtOnchainTransaction( - amountSat = 50000UL, - txId = "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", - vout = 0u, - blockHeight = null, - blockConfirmationCount = 0u, - feeRateSatPerVbyte = 12.5, - confirmed = false, - suspicious0ConfReason = "" - ) - ) - ), - isManuallyPaid = null, - manualRefunds = null - ), - couponCode = null, - source = null, - discount = null, - updatedAt = "2025-07-28T08:29:03Z", - createdAt = "2025-07-28T08:29:03Z" + state = TransferToSpendingUiState( + clientBalanceSat = 500_000uL, + lspBalanceSat = 2_000_000uL, + feeSat = 1_000uL, + ), onSwitchClick = {}, hasNotificationPermission = true, @@ -465,64 +350,11 @@ private fun Preview3() { onAdvancedClick = {}, onUseDefaultLspBalanceClick = {}, onTransferToSpendingConfirm = {}, - order = IBtOrder( - id = "order_7e6f3b7c-486a-4f5a-8b1e-2c9d7f0a8b9d", - state = BtOrderState.CREATED, - state2 = BtOrderState2.CREATED, - feeSat = 1000UL, - networkFeeSat = 250UL, - serviceFeeSat = 750UL, - lspBalanceSat = 2000000UL, - clientBalanceSat = 500000UL, - zeroConf = false, - zeroReserve = true, - clientNodeId = null, - channelExpiryWeeks = 8u, - channelExpiresAt = "2025-09-22T08:29:03Z", - orderExpiresAt = "2025-07-29T08:29:03Z", - channel = null, - lspNode = ILspNode( - alias = "Bitkit LSP", - pubkey = "02f12451995802149b1855a7948305763328e9304337b51e45e7f1b637956424e8", - connectionStrings = listOf("mock@127.0.0.1:9735"), - readonly = null - ), - lnurl = null, - payment = IBtPayment( - state = BtPaymentState.CREATED, - state2 = BtPaymentState2.CREATED, - paidSat = 0UL, - bolt11Invoice = IBtBolt11Invoice( - request = "lnmock", - state = BtBolt11InvoiceState.PENDING, - expiresAt = "2025-07-28T12:00:00Z", - updatedAt = "2025-07-28T08:30:00Z" - ), - onchain = IBtOnchainTransactions( - address = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", - confirmedSat = 0UL, - requiredConfirmations = 1u, - transactions = listOf( - IBtOnchainTransaction( - amountSat = 50000UL, - txId = "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", - vout = 0u, - blockHeight = null, - blockConfirmationCount = 0u, - feeRateSatPerVbyte = 12.5, - confirmed = false, - suspicious0ConfReason = "" - ) - ) - ), - isManuallyPaid = null, - manualRefunds = null - ), - couponCode = null, - source = null, - discount = null, - updatedAt = "2025-07-28T08:29:03Z", - createdAt = "2025-07-28T08:29:03Z" + state = TransferToSpendingUiState( + clientBalanceSat = 500_000uL, + lspBalanceSat = 2_000_000uL, + feeSat = 1_000uL, + ), onSwitchClick = {}, hasNotificationPermission = false, @@ -544,64 +376,11 @@ private fun Preview4() { onAdvancedClick = {}, onUseDefaultLspBalanceClick = {}, onTransferToSpendingConfirm = {}, - order = IBtOrder( - id = "order_7e6f3b7c-486a-4f5a-8b1e-2c9d7f0a8b9d", - state = BtOrderState.CREATED, - state2 = BtOrderState2.CREATED, - feeSat = 1000UL, - networkFeeSat = 250UL, - serviceFeeSat = 750UL, - lspBalanceSat = 2000000UL, - clientBalanceSat = 500000UL, - zeroConf = false, - zeroReserve = true, - clientNodeId = null, - channelExpiryWeeks = 8u, - channelExpiresAt = "2025-09-22T08:29:03Z", - orderExpiresAt = "2025-07-29T08:29:03Z", - channel = null, - lspNode = ILspNode( - alias = "Bitkit LSP", - pubkey = "02f12451995802149b1855a7948305763328e9304337b51e45e7f1b637956424e8", - connectionStrings = listOf("mock@127.0.0.1:9735"), - readonly = null - ), - lnurl = null, - payment = IBtPayment( - state = BtPaymentState.CREATED, - state2 = BtPaymentState2.CREATED, - paidSat = 0UL, - bolt11Invoice = IBtBolt11Invoice( - request = "lnmock", - state = BtBolt11InvoiceState.PENDING, - expiresAt = "2025-07-28T12:00:00Z", - updatedAt = "2025-07-28T08:30:00Z" - ), - onchain = IBtOnchainTransactions( - address = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", - confirmedSat = 0UL, - requiredConfirmations = 1u, - transactions = listOf( - IBtOnchainTransaction( - amountSat = 50000UL, - txId = "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", - vout = 0u, - blockHeight = null, - blockConfirmationCount = 0u, - feeRateSatPerVbyte = 12.5, - confirmed = false, - suspicious0ConfReason = "" - ) - ) - ), - isManuallyPaid = null, - manualRefunds = null - ), - couponCode = null, - source = null, - discount = null, - updatedAt = "2025-07-28T08:29:03Z", - createdAt = "2025-07-28T08:29:03Z" + state = TransferToSpendingUiState( + clientBalanceSat = 500_000uL, + lspBalanceSat = 2_000_000uL, + feeSat = 1_000uL, + ), onSwitchClick = {}, hasNotificationPermission = true, diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/TransferPreviewData.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/TransferPreviewData.kt index 257519fe94..80f6981b53 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/TransferPreviewData.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/TransferPreviewData.kt @@ -10,6 +10,7 @@ import com.synonym.bitkitcore.IBtOnchainTransactions import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.IBtPayment import com.synonym.bitkitcore.ILspNode +import to.bitkit.viewmodels.TransferToSpendingUiState internal fun previewBtOrder( networkFeeSat: ULong = 2_483UL, @@ -64,3 +65,13 @@ internal fun previewBtOrder( updatedAt = "2025-07-28T08:29:03Z", createdAt = "2025-07-28T08:29:03Z", ) + +internal fun previewSpendingState( + clientBalanceSat: ULong = 967_724UL, + feeSat: ULong = 971_727UL, + lspBalanceSat: ULong = 2_000_000UL, +): TransferToSpendingUiState = TransferToSpendingUiState( + clientBalanceSat = clientBalanceSat, + lspBalanceSat = lspBalanceSat, + feeSat = feeSat, +) diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt index 07239c550e..db2fbd8db9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingAmountHwScreen.kt @@ -65,7 +65,7 @@ fun SpendingAmountHwScreen( viewModel: TransferViewModel, isOffline: Boolean, onBackClick: () -> Unit = {}, - onOrderCreated: () -> Unit = {}, + onQuoteReady: () -> Unit = {}, currencies: CurrencyState = LocalCurrencies.current, amountInputViewModel: AmountInputViewModel = hiltViewModel(), ) { @@ -83,7 +83,7 @@ fun SpendingAmountHwScreen( LaunchedEffect(Unit) { viewModel.transferEffects.collect { effect -> when (effect) { - TransferEffect.OnOrderCreated -> onOrderCreated() + TransferEffect.OnQuoteReady -> onQuoteReady() is TransferEffect.ToastError -> ToastEventBus.send( type = Toast.ToastType.ERROR, title = effect.title, diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt index 65901f5a12..5982307615 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt @@ -1,5 +1,6 @@ package to.bitkit.ui.screens.transfer.hardware +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -19,7 +20,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.synonym.bitkitcore.IBtOrder import to.bitkit.R import to.bitkit.models.safe import to.bitkit.ui.components.ButtonSize @@ -33,10 +33,11 @@ import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.screens.transfer.previewBtOrder +import to.bitkit.ui.screens.transfer.previewSpendingState import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.TransferToSpendingUiState import to.bitkit.viewmodels.TransferViewModel @Composable @@ -50,14 +51,16 @@ fun SpendingHwSignScreen( ) { val state by viewModel.spendingUiState.collectAsStateWithLifecycle() - val order = state.order ?: run { + if (state.feeSat == 0uL) { onCloseClick() return } - LaunchedEffect(walletId, order.id) { + BackHandler(enabled = state.isBusy) {} + + LaunchedEffect(walletId, state.feeSat) { viewModel.warmUpHardwareConnection(walletId) - viewModel.updateHwFundingFeeEstimate(order, walletId) + viewModel.updateHwFundingFeeEstimate(walletId) } DisposableEffect(viewModel) { @@ -65,22 +68,22 @@ fun SpendingHwSignScreen( } Content( - order = order, + state = state, miningFeeSats = state.hwMiningFeeSats, isAdvanced = state.isAdvanced, - isSigning = state.isSigning, + isSigning = state.isBusy, hasPendingBroadcast = state.hasPendingHwBroadcast, - onBackClick = onBackClick, + onBackClick = { if (!state.isBusy) onBackClick() }, onLearnMoreClick = onLearnMoreClick, onAdvancedClick = onAdvancedClick, onUseDefaultLspBalanceClick = viewModel::onUseDefaultLspBalanceClick, - onOpenConnect = { viewModel.onTransferToSpendingHwConfirm(order, walletId) }, + onOpenConnect = { viewModel.onTransferToSpendingHwConfirm(walletId) }, ) if (state.isHwPassphraseRequired) { HwPassphrasePromptSheet( isVerifying = state.isVerifyingHwPassphrase, - onSubmit = { viewModel.onHwPassphraseSubmit(order, walletId, it) }, + onSubmit = { viewModel.onHwPassphraseSubmit(walletId, it) }, onDismiss = viewModel::onHwPassphraseDismiss, ) } @@ -88,7 +91,7 @@ fun SpendingHwSignScreen( @Composable private fun Content( - order: IBtOrder, + state: TransferToSpendingUiState, miningFeeSats: ULong = 0uL, isAdvanced: Boolean = false, isSigning: Boolean = false, @@ -103,7 +106,7 @@ private fun Content( AppTopBar( titleText = stringResource(R.string.lightning__transfer__nav_title), onBackClick = onBackClick, - actions = { DrawerNavIcon() }, + actions = { if (!isSigning) DrawerNavIcon() }, ) Box(modifier = Modifier.fillMaxSize()) { HardwareTransferIllustration( @@ -132,7 +135,7 @@ private fun Content( VerticalSpacer(16.dp) SpendingHwFeeGrid( - order = order, + state = state, miningFeeSats = miningFeeSats, ) @@ -184,12 +187,12 @@ private fun Content( @Composable internal fun SpendingHwFeeGrid( - order: IBtOrder, + state: TransferToSpendingUiState, modifier: Modifier = Modifier, miningFeeSats: ULong = 0uL, ) { - val lspFee = order.feeSat.safe() - order.clientBalanceSat.safe() - val total = order.feeSat.safe() + miningFeeSats.safe() + val lspFee = state.feeSat.safe() - state.clientBalanceSat.safe() + val total = state.feeSat.safe() + miningFeeSats.safe() Column(modifier = modifier) { Row( @@ -211,7 +214,7 @@ internal fun SpendingHwFeeGrid( ) { FeeInfo( label = stringResource(R.string.lightning__spending_confirm__amount), - amount = order.clientBalanceSat.toLong(), + amount = state.clientBalanceSat.toLong(), ) FeeInfo( label = stringResource(R.string.lightning__spending_confirm__total), @@ -226,9 +229,7 @@ internal fun SpendingHwFeeGrid( private fun PreviewWithMiningFee() { AppThemeSurface { Content( - order = previewBtOrder( - networkFeeSat = 528uL, - serviceFeeSat = 132uL, + state = previewSpendingState( clientBalanceSat = 7_042uL, feeSat = 7_402uL, ), @@ -242,7 +243,7 @@ private fun PreviewWithMiningFee() { private fun Preview() { AppThemeSurface { Content( - order = previewBtOrder(), + state = previewSpendingState(), ) } } @@ -252,7 +253,7 @@ private fun Preview() { private fun PreviewAdvanced() { AppThemeSurface { Content( - order = previewBtOrder(), + state = previewSpendingState(), isAdvanced = true, ) } @@ -263,7 +264,7 @@ private fun PreviewAdvanced() { private fun PreviewSigning() { AppThemeSurface { Content( - order = previewBtOrder(), + state = previewSpendingState(), isSigning = true, ) } @@ -274,7 +275,7 @@ private fun PreviewSigning() { private fun PreviewPendingBroadcast() { AppThemeSurface { Content( - order = previewBtOrder(), + state = previewSpendingState(), hasPendingBroadcast = true, ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignedScreen.kt index 03098d3cb2..46996f213d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignedScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignedScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.synonym.bitkitcore.IBtOrder import kotlinx.coroutines.delay import to.bitkit.R import to.bitkit.ui.components.Display @@ -24,13 +23,13 @@ import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.screens.transfer.previewBtOrder +import to.bitkit.ui.screens.transfer.previewSpendingState import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.TransferToSpendingUiState import to.bitkit.viewmodels.TransferViewModel -/** Figma handoff delay before forwarding from signed confirmation. */ private const val SIGNED_AUTO_NAV_DELAY_MS = 1_000L @Composable @@ -41,7 +40,7 @@ fun SpendingHwSignedScreen( ) { val state by viewModel.spendingUiState.collectAsStateWithLifecycle() - val order = state.order ?: run { + if (state.feeSat == 0uL) { onCloseClick() return } @@ -52,7 +51,7 @@ fun SpendingHwSignedScreen( } Content( - order = order, + state = state, miningFeeSats = state.hwMiningFeeSats, onBackClick = onCloseClick, ) @@ -60,7 +59,7 @@ fun SpendingHwSignedScreen( @Composable private fun Content( - order: IBtOrder, + state: TransferToSpendingUiState, miningFeeSats: ULong = 0uL, onBackClick: () -> Unit = {}, ) { @@ -91,7 +90,7 @@ private fun Content( VerticalSpacer(16.dp) SpendingHwFeeGrid( - order = order, + state = state, miningFeeSats = miningFeeSats, ) } @@ -104,7 +103,7 @@ private fun Content( private fun Preview() { AppThemeSurface { Content( - order = previewBtOrder(), + state = previewSpendingState(), ) } } diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index a993616619..06dd7397c6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -127,9 +127,8 @@ class TransferViewModel @Inject constructor( private var pendingHwFundingBroadcast: PendingHwFundingBroadcast? = null private var activeHwTransferWalletId: String? = null - // region Spending - fun onConfirmAmount(satsAmount: Long) { + if (confirmPayJob?.isActive == true || hwTransferSignJob?.isActive == true) return val values = blocktankRepo.calculateLiquidityOptions(satsAmount.toULong()).getOrNull() if (values == null || values.maxLspBalanceSat == 0uL) { setTransferEffect( @@ -152,7 +151,17 @@ class TransferViewModel @Inject constructor( isNodeRunning.first { it } } - if (!canFundOrder(satsAmount.toULong())) { + val feeSat = estimateSpendingFee( + clientBalanceSat = satsAmount.toULong(), + lspBalanceSat = lspBalance, + ).getOrElse { e -> + setTransferEffect(TransferEffect.ToastException(e)) + delay(1.seconds) + _spendingUiState.update { it.copy(isLoading = false) } + return@launch + } + + if (!canFundOrder(feeSat)) { Logger.info("Rejected spending amount '$satsAmount' over funding budget", context = TAG) setTransferEffect( TransferEffect.ToastError( @@ -166,23 +175,22 @@ class TransferViewModel @Inject constructor( return@launch } - blocktankRepo.createOrder( - spendingBalanceSats = satsAmount.toULong(), - receivingBalanceSats = lspBalance, - ) - .onSuccess { order -> - settingsStore.update { it.copy(lightningSetupStep = 0) } - onOrderCreated(order) - delay(1.seconds) // Give time to settle the UI - _spendingUiState.update { it.copy(isLoading = false) } - }.onFailure { e -> - setTransferEffect(TransferEffect.ToastException(e)) - delay(1.seconds) // Give time to settle the UI - _spendingUiState.update { it.copy(isLoading = false) } - } + onEstimateReady(satsAmount.toULong(), lspBalance, feeSat) + delay(1.seconds) + _spendingUiState.update { it.copy(isLoading = false) } } } + private suspend fun estimateSpendingFee( + clientBalanceSat: ULong, + lspBalanceSat: ULong, + ): Result = blocktankRepo.estimateOrderFee( + spendingBalanceSats = clientBalanceSat, + receivingBalanceSats = lspBalanceSat, + ).map { estimate -> + (clientBalanceSat.safe() + estimate.networkFeeSat.safe()).safe() + estimate.serviceFeeSat.safe() + } + fun updateLimits(satsAmount: Long = 0) { updateTransferValues(satsAmount.toULong()) updateAvailableAmount() @@ -204,7 +212,7 @@ class TransferViewModel @Inject constructor( if (!isValid) return@launch val result = blocktankRepo.estimateOrderFee( - spendingBalanceSats = _spendingUiState.value.order?.clientBalanceSat ?: 0u, + spendingBalanceSats = _spendingUiState.value.clientBalanceSat, receivingBalanceSats = amount.toULong(), ) @@ -224,29 +232,16 @@ class TransferViewModel @Inject constructor( } } - private suspend fun canFundAdvancedOrder(clientBalance: ULong, receivingAmount: ULong): Boolean { - val budget = currentFundingBudget() - if (budget == null) { - Logger.warn("Skipped advanced capacity check, no sized budget available", context = TAG) - return true - } - val fee = quoteAdvancedOrderFee(clientBalance, receivingAmount) - if (fee == null) { - Logger.warn("Skipped advanced capacity check, fee quote unavailable", context = TAG) - return true - } - val canFund = clientBalance.safe() + fee.safe() <= budget - if (!canFund) { - Logger.info("Priced advanced capacity '$receivingAmount' over funding budget '$budget'", context = TAG) - } - return canFund - } - fun onSpendingAdvancedContinue(receivingAmountSats: Long) { + if (confirmPayJob?.isActive == true || hwTransferSignJob?.isActive == true) return viewModelScope.launch { runSuspendCatching { - val oldOrder = _spendingUiState.value.order ?: return@runSuspendCatching - if (!canFundAdvancedOrder(oldOrder.clientBalanceSat, receivingAmountSats.toULong())) { + val state = _spendingUiState.value + val feeSat = estimateSpendingFee( + clientBalanceSat = state.clientBalanceSat, + lspBalanceSat = receivingAmountSats.toULong(), + ).getOrThrow() + if (!canFundOrder(feeSat)) { Logger.info("Rejected advanced capacity '$receivingAmountSats' over funding budget", context = TAG) setTransferEffect( TransferEffect.ToastError( @@ -258,38 +253,40 @@ class TransferViewModel @Inject constructor( ) return@runSuspendCatching } - val newOrder = blocktankRepo.createOrder( - spendingBalanceSats = oldOrder.clientBalanceSat, - receivingBalanceSats = receivingAmountSats.toULong(), - ).getOrThrow() + if (confirmPayJob?.isActive == true || hwTransferSignJob?.isActive == true) { + return@runSuspendCatching + } hwFeeEstimateJob?.cancel() hwFeeEstimateJob = null _spendingUiState.update { it.copy( - order = newOrder, - defaultOrder = oldOrder, + lspBalanceSat = receivingAmountSats.toULong(), + feeSat = feeSat, + order = it.order.takeIf { order -> order?.lspBalanceSat == receivingAmountSats.toULong() }, isAdvanced = true, hwMiningFeeSats = 0uL, ) } - setTransferEffect(TransferEffect.OnOrderCreated) + setTransferEffect(TransferEffect.OnQuoteReady) }.onFailure { e -> setTransferEffect(TransferEffect.ToastException(e)) } } } - /** - * Match iOS SpendingConfirm.task: compute real mining fee + drain decision before swipe, - * so confirm UI can show fees up-front. - */ - fun prepareSpendingConfirmFunding(order: IBtOrder) { + fun prepareSpendingConfirmFunding() { confirmFeeJob?.cancel() confirmFeeJob = viewModelScope.launch { _spendingUiState.update { it.copy(isConfirmFeeReady = false, miningFeeSats = 0uL) } - buildSpendingConfirmFundingPlan(order) + val order = _spendingUiState.value.order + val target = SpendingFundingTarget( + feeSat = _spendingUiState.value.feeSat, + address = order?.fundingAddress ?: spendingSizingAddress() ?: return@launch, + orderId = order?.id, + ) + buildSpendingConfirmFundingPlan(target) .onSuccess { plan -> spendingConfirmFundingPlan = plan _spendingUiState.update { @@ -318,14 +315,16 @@ class TransferViewModel @Inject constructor( } } - /** Pays for the order using the prepared confirm plan and starts watching it. */ - fun onTransferToSpendingConfirm(order: IBtOrder) { - if (confirmPayJob?.isActive == true) return + fun onTransferToSpendingConfirm() { + if (confirmPayJob?.isActive == true || _spendingUiState.value.isBusy) return + val state = _spendingUiState.value + if (state.feeSat == 0uL) return + _spendingUiState.update { it.copy(isConfirmPaying = true) } confirmPayJob = viewModelScope.launch { - _spendingUiState.update { it.copy(isConfirmPaying = true) } try { val paid = runSuspendCatching { + val order = ensureSpendingOrder() ?: return@runSuspendCatching false paySpendingConfirmOrder(order) }.onFailure { Logger.error("Failed to pay spending confirm order", it, context = TAG) @@ -333,7 +332,6 @@ class TransferViewModel @Inject constructor( }.getOrDefault(false) if (paid) { - // Emit from this job (not a nested launch) so navigation is not raced/lost. transferEffects.emit(TransferEffect.OnSpendingFundingPaid) } else { _spendingUiState.update { it.copy(isConfirmPaying = false) } @@ -344,6 +342,26 @@ class TransferViewModel @Inject constructor( } } + private suspend fun ensureSpendingOrder(): IBtOrder? { + val state = _spendingUiState.value + val order = state.order ?: blocktankRepo.createOrder( + spendingBalanceSats = state.clientBalanceSat, + receivingBalanceSats = state.lspBalanceSat, + ).getOrElse { + ToastEventBus.send(it) + return null + }.also { created -> _spendingUiState.update { it.copy(order = created) } } + if (order.feeSat > state.feeSat) { + spendingConfirmFundingPlan = null + _spendingUiState.update { it.copy(feeSat = order.feeSat) } + return null + } + return order + } + + private suspend fun spendingSizingAddress(): String? = + walletRepo.getAddresses(count = 1).onFailure { ToastEventBus.send(it) }.getOrNull()?.firstOrNull()?.address + private suspend fun paySpendingConfirmOrder(order: IBtOrder): Boolean { val plan = spendingConfirmFundingPlan?.takeIf { it.orderId == order.id } ?: buildSpendingConfirmFundingPlan(order).getOrElse { @@ -402,11 +420,15 @@ class TransferViewModel @Inject constructor( .isSuccess } + private suspend fun buildSpendingConfirmFundingPlan(order: IBtOrder): Result = + buildSpendingConfirmFundingPlan( + SpendingFundingTarget(feeSat = order.feeSat, address = order.fundingAddress, orderId = order.id), + ) + private suspend fun buildSpendingConfirmFundingPlan( - order: IBtOrder, + target: SpendingFundingTarget, ): Result = runSuspendCatching { - val address = order.payment?.onchain?.address.orEmpty() - require(address.isNotEmpty()) { "Order payment onchain address is nil" } + require(target.address.isNotEmpty()) { "Funding address is empty" } val speed = TransactionSpeed.Fast val balanceDetails = lightningRepo.getBalancesAsync().getOrThrow() @@ -414,17 +436,14 @@ class TransferViewModel @Inject constructor( val totalOnchainBalance = balanceDetails.totalOnchainBalanceSats val satsPerVByte = lightningRepo.getFeeRateForSpeed(speed).getOrThrow() - // Match iOS SpendingConfirm: normal coin selection + fee first; drain only for real dust. resolveNormalSpendingConfirmFunding( - order = order, - address = address, + target = target, speed = speed, satsPerVByte = satsPerVByte, spendableBalance = spendableBalance, totalOnchainBalance = totalOnchainBalance, ) ?: resolveSendAllSpendingConfirmFunding( - order = order, - address = address, + target = target, speed = speed, spendableBalance = spendableBalance, totalOnchainBalance = totalOnchainBalance, @@ -432,15 +451,14 @@ class TransferViewModel @Inject constructor( } private suspend fun resolveNormalSpendingConfirmFunding( - order: IBtOrder, - address: String, + target: SpendingFundingTarget, speed: TransactionSpeed, satsPerVByte: ULong, spendableBalance: ULong, totalOnchainBalance: ULong, ): SpendingConfirmFundingPlan? { val utxos = lightningRepo.selectUtxosWithAlgorithm( - targetAmountSats = order.feeSat, + targetAmountSats = target.feeSat, satsPerVByte = satsPerVByte, algorithm = CoinSelectionAlgorithm.LARGEST_FIRST, ).getOrElse { @@ -449,8 +467,8 @@ class TransferViewModel @Inject constructor( } val normalFee = lightningRepo.calculateTotalFee( - amountSats = order.feeSat, - address = address, + amountSats = target.feeSat, + address = target.address, speed = speed, utxosToSpend = utxos, ).getOrElse { @@ -458,12 +476,12 @@ class TransferViewModel @Inject constructor( 0uL } val totalInput = utxos.fold(0uL) { acc, utxo -> acc.safe() + utxo.valueSats.safe() } - if (wouldCreateDustChange(totalInput = totalInput, amountSats = order.feeSat, normalFee = normalFee)) { + if (wouldCreateDustChange(totalInput = totalInput, amountSats = target.feeSat, normalFee = normalFee)) { return null } return SpendingConfirmFundingPlan( - orderId = order.id, + orderId = target.orderId, miningFeeSats = normalFee, shouldUseSendAll = false, selectedUtxos = utxos, @@ -474,23 +492,22 @@ class TransferViewModel @Inject constructor( } private suspend fun resolveSendAllSpendingConfirmFunding( - order: IBtOrder, - address: String, + target: SpendingFundingTarget, speed: TransactionSpeed, spendableBalance: ULong, totalOnchainBalance: ULong, ): SpendingConfirmFundingPlan { val sendAllFee = lightningRepo.estimateSendAllFee( - address = address, + address = target.address, speed = speed, ).getOrThrow() val maxSendable = spendableBalance.safe() - sendAllFee.safe() - if (maxSendable < order.feeSat) { + if (maxSendable < target.feeSat) { throw AppError(context.getString(R.string.other__pay_insufficient_savings)) } return SpendingConfirmFundingPlan( - orderId = order.id, + orderId = target.orderId, miningFeeSats = sendAllFee, shouldUseSendAll = true, selectedUtxos = null, @@ -539,6 +556,7 @@ class TransferViewModel @Inject constructor( walletId = activityWalletId, ) } + _spendingUiState.update { it.copy(order = null) } viewModelScope.launch { walletRepo.syncBalances() } viewModelScope.launch { watchOrder(order.id) } } @@ -626,21 +644,25 @@ class TransferViewModel @Inject constructor( } } - private suspend fun onOrderCreated(order: IBtOrder) { + private suspend fun onEstimateReady(clientBalanceSat: ULong, lspBalanceSat: ULong, feeSat: ULong) { settingsStore.update { it.copy(lightningSetupStep = 0) } + if (confirmPayJob?.isActive == true || hwTransferSignJob?.isActive == true) return pendingHwFundingBroadcast = null hwFeeEstimateJob?.cancel() hwFeeEstimateJob = null _spendingUiState.update { it.copy( - order = order, + clientBalanceSat = clientBalanceSat, + lspBalanceSat = lspBalanceSat, + feeSat = feeSat, + order = null, isAdvanced = false, - defaultOrder = null, + isConfirmPaying = false, hasPendingHwBroadcast = false, hwMiningFeeSats = 0uL, ) } - setTransferEffect(TransferEffect.OnOrderCreated) + setTransferEffect(TransferEffect.OnQuoteReady) } private fun updateAvailableAmount() { @@ -867,33 +889,19 @@ class TransferViewModel @Inject constructor( 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 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 { + private suspend fun canFundOrder(feeSat: ULong): Boolean { val budget = currentFundingBudget() if (budget == null) { Logger.warn("Skipped funding check, no sized budget available", context = TAG) return true } - val fee = quoteOrderFee(clientBalance) - if (fee == null) { - Logger.warn("Skipped funding check, fee quote unavailable", context = TAG) - return true + val canFund = feeSat <= budget + if (!canFund) { + Logger.info("Priced order '$feeSat' over funding budget '$budget'", context = TAG) } - return clientBalance.safe() + fee.safe() <= budget + return canFund } - /** - * LSP fee for an order at [clientBalance], priced against the channel split that order creation - * will pick for that same balance, so the settled max is checked against the order it produces. - */ private suspend fun quoteOrderFee(clientBalance: ULong): ULong? { val liquidity = blocktankRepo.calculateLiquidityOptions(clientBalance).getOrNull() ?: return null val receivingAmount = maxOf(liquidity.defaultLspBalanceSat, liquidity.minLspBalanceSat) @@ -904,27 +912,41 @@ class TransferViewModel @Inject constructor( } fun onUseDefaultLspBalanceClick() { - val defaultOrder = _spendingUiState.value.defaultOrder - hwFeeEstimateJob?.cancel() - hwFeeEstimateJob = null - _spendingUiState.update { - it.copy( - order = defaultOrder, - defaultOrder = null, - isAdvanced = false, - hwMiningFeeSats = 0uL, - ) + if (confirmPayJob?.isActive == true || hwTransferSignJob?.isActive == true) return + viewModelScope.launch { + val state = _spendingUiState.value + val values = blocktankRepo.calculateLiquidityOptions(state.clientBalanceSat).getOrNull() + ?: return@launch + val lspBalance = maxOf(values.defaultLspBalanceSat, values.minLspBalanceSat) + estimateSpendingFee(state.clientBalanceSat, lspBalance) + .onSuccess { feeSat -> + if (confirmPayJob?.isActive == true || hwTransferSignJob?.isActive == true) return@onSuccess + hwFeeEstimateJob?.cancel() + _spendingUiState.update { + it.copy( + lspBalanceSat = lspBalance, + feeSat = feeSat, + order = it.order.takeIf { order -> order?.lspBalanceSat == lspBalance }, + isAdvanced = false, + isConfirmPaying = false, + hwMiningFeeSats = 0uL, + ) + } + } + .onFailure { ToastEventBus.send(it) } } } fun resetSpendingState() { + if (confirmPayJob?.isActive == true || hwTransferSignJob?.isActive == true) { + return + } hwTransferSignJob?.cancel() hwTransferSignJob = null hwFeeEstimateJob?.cancel() hwFeeEstimateJob = null confirmFeeJob?.cancel() confirmFeeJob = null - // Do not cancel confirmPayJob: broadcast + paid-order cache must finish. spendingConfirmFundingPlan = null pendingHwFundingBroadcast = null activeHwTransferWalletId = null @@ -985,30 +1007,29 @@ class TransferViewModel @Inject constructor( hwWalletRepo.warmUpKnownDevice(walletId) } - /** Best-effort offline mining-fee estimate for the Sign screen (xpub compose, no device session). */ - fun updateHwFundingFeeEstimate(order: IBtOrder, walletId: String) { + fun updateHwFundingFeeEstimate(walletId: String) { hwFeeEstimateJob?.cancel() hwFeeEstimateJob = viewModelScope.launch { - if (_spendingUiState.value.hasPendingHwBroadcast) return@launch - val address = order.payment?.onchain?.address.orEmpty() + val state = _spendingUiState.value + if (state.hasPendingHwBroadcast) return@launch + if (state.feeSat == 0uL) return@launch + val address = state.order?.fundingAddress ?: spendingSizingAddress() ?: return@launch if (address.isEmpty()) return@launch - val orderId = order.id runSuspendCatching { val satsPerVByte = hwFundingSatsPerVByte() hwWalletRepo.composeFundingTransaction( walletId = walletId, address = address, - sats = order.feeSat, + sats = state.feeSat, satsPerVByte = satsPerVByte, ).getOrThrow().miningFeeSats }.onSuccess { miningFeeSats -> - _spendingUiState.update { state -> - val activeOrderId = state.order?.id - if ((activeOrderId != null && activeOrderId != orderId) || state.hasPendingHwBroadcast) { - state + _spendingUiState.update { current -> + if (current.feeSat != state.feeSat || current.hasPendingHwBroadcast) { + current } else { - state.copy(hwMiningFeeSats = miningFeeSats) + current.copy(hwMiningFeeSats = miningFeeSats) } } }.onFailure { @@ -1020,24 +1041,24 @@ class TransferViewModel @Inject constructor( } } - fun onTransferToSpendingHwConfirm(order: IBtOrder, walletId: String) { - if (hwTransferSignJob?.isActive == true) return + fun onTransferToSpendingHwConfirm(walletId: String) { + if (hwTransferSignJob?.isActive == true || _spendingUiState.value.isBusy) return + val state = _spendingUiState.value + if (state.feeSat == 0uL) return activeHwTransferWalletId = walletId + _spendingUiState.update { it.copy(isSigning = true) } hwTransferSignJob = viewModelScope.launch { - // A hidden wallet whose session is gone can only be reopened with its passphrase, and - // the device would otherwise sign from whichever wallet the current session holds. - // Rebroadcasting an already signed transaction never reaches the device, so it must not - // be held behind that prompt; a different order still asks. - val address = order.payment?.onchain?.address.orEmpty() - val isBroadcastRetry = pendingHwFundingBroadcast?.matches(order, walletId, address) == true - if (!isBroadcastRetry && hwWalletRepo.needsPassphrase(walletId)) { - _spendingUiState.update { it.copy(isHwPassphraseRequired = true) } - hwTransferSignJob = null - return@launch - } - _spendingUiState.update { it.copy(isSigning = true) } try { + val signedOrder = _spendingUiState.value.order + val isBroadcastRetry = signedOrder != null && + pendingHwFundingBroadcast?.matches(signedOrder, walletId, signedOrder.fundingAddress) == true + if (!isBroadcastRetry && hwWalletRepo.needsPassphrase(walletId)) { + _spendingUiState.update { it.copy(isHwPassphraseRequired = true) } + return@launch + } + val order = ensureSpendingOrder() ?: return@launch + val address = order.fundingAddress if (address.isEmpty()) { ToastEventBus.send(type = Toast.ToastType.ERROR, title = context.getString(R.string.common__error)) return@launch @@ -1071,12 +1092,7 @@ class TransferViewModel @Inject constructor( } } - /** - * Reopens the hidden wallet with the entered passphrase and, once its accounts prove it is the - * wallet the transfer is for, continues into signing. The passphrase is passed straight through - * to the device session; it is never kept in UI state. - */ - fun onHwPassphraseSubmit(order: IBtOrder, walletId: String, passphrase: String) { + fun onHwPassphraseSubmit(walletId: String, passphrase: String) { if (passphrase.isEmpty() || hwTransferSignJob?.isActive == true) return hwTransferSignJob = viewModelScope.launch { @@ -1086,11 +1102,9 @@ class TransferViewModel @Inject constructor( hwTransferSignJob = null result .onSuccess { - // The prompt can be swiped away while the device is still reopening the wallet, - // and the confirm below starts a new job that a late cancel would not reach. if (!_spendingUiState.value.isHwPassphraseRequired) return@launch _spendingUiState.update { it.copy(isHwPassphraseRequired = false) } - onTransferToSpendingHwConfirm(order, walletId) + onTransferToSpendingHwConfirm(walletId) } .onFailure { handleHardwarePassphraseFailure(it, walletId) } } @@ -1373,13 +1387,11 @@ class TransferViewModel @Inject constructor( // endregion - // region Balance Calc - - fun updateAdvancedTransferValues(order: IBtOrder) { + fun updateAdvancedTransferValues(clientBalanceSat: ULong) { advancedLimitsJob?.cancel() advancedLimitsJob = viewModelScope.launch { _spendingUiState.update { it.copy(isLoading = true) } - updateTransferValues(order.clientBalanceSat) + updateTransferValues(clientBalanceSat) val values = _transferValues.value val budget = currentFundingBudget() @@ -1389,7 +1401,7 @@ class TransferViewModel @Inject constructor( } val affordableMax = resolveAffordableLspBalance( - clientBalance = order.clientBalanceSat, + clientBalance = clientBalanceSat, budget = budget, minLspBalance = values.minLspBalance, maxLspBalance = values.maxLspBalance, @@ -1881,10 +1893,14 @@ private data class PendingHwFundingBroadcast( amountSats == order.feeSat } -// region state +private val IBtOrder.fundingAddress: String + get() = payment?.onchain?.address.orEmpty() + data class TransferToSpendingUiState( + val clientBalanceSat: ULong = 0uL, + val lspBalanceSat: ULong = 0uL, + val feeSat: ULong = 0uL, val order: IBtOrder? = null, - val defaultOrder: IBtOrder? = null, val isAdvanced: Boolean = false, val maxAllowedToSend: Long = 0, val balanceAfterFee: Long = 0, @@ -1892,25 +1908,29 @@ data class TransferToSpendingUiState( val isLoading: Boolean = false, val isSigning: Boolean = false, val hasPendingHwBroadcast: Boolean = false, - /** The hidden wallet needs its passphrase before the device can sign for it. */ val isHwPassphraseRequired: Boolean = false, val isVerifyingHwPassphrase: Boolean = false, val hwMiningFeeSats: ULong = 0uL, - /** Real on-chain mining fee for soft-wallet confirm (iOS transactionFee). */ val miningFeeSats: ULong = 0uL, val isConfirmFeeReady: Boolean = false, val isConfirmPaying: Boolean = false, val shouldUseSendAll: Boolean = false, val receivingAmount: Long = 0, 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, +) { + val isBusy: Boolean get() = isConfirmPaying || isSigning +} + +private data class SpendingFundingTarget( + val feeSat: ULong, + val address: String, + val orderId: String?, ) private data class SpendingConfirmFundingPlan( - val orderId: String, + val orderId: String?, val miningFeeSats: ULong, val shouldUseSendAll: Boolean, val selectedUtxos: List?, @@ -1927,7 +1947,7 @@ data class TransferValues( ) sealed interface TransferEffect { - data object OnOrderCreated : TransferEffect + data object OnQuoteReady : TransferEffect data object OnSpendingFundingPaid : TransferEffect data object OnHwTxSigned : TransferEffect data class ToastException(val e: Throwable) : TransferEffect diff --git a/app/src/test/java/to/bitkit/ui/ContentViewTest.kt b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt index 270bc474b5..39ce84f531 100644 --- a/app/src/test/java/to/bitkit/ui/ContentViewTest.kt +++ b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt @@ -63,7 +63,19 @@ class ContentViewTest { fun `transfer effect destinations cover funding paid and hw signed`() { assertEquals(Routes.SettingUp, transferEffectDestination(TransferEffect.OnSpendingFundingPaid)) assertEquals(Routes.SpendingHwSigned, transferEffectDestination(TransferEffect.OnHwTxSigned)) - assertNull(transferEffectDestination(TransferEffect.OnOrderCreated)) + assertNull(transferEffectDestination(TransferEffect.OnQuoteReady)) + } + + @Test + fun `funding paid removes spending confirmation from the back stack`() { + val navController = transferNavController() + navController.navigateTo(Routes.SpendingConfirm) + + navController.navigateForTransferEffect(TransferEffect.OnSpendingFundingPaid) + assertTrue(navController.currentDestination?.hasRoute() == true) + + navController.popBackStack() + assertFalse(navController.currentDestination?.hasRoute() == true) } @Test @@ -161,6 +173,7 @@ class ContentViewTest { composable {} composable {} composable {} + composable {} } } } diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 8398b30c3a..606d47768d 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -2,6 +2,7 @@ package to.bitkit.viewmodels import android.content.Context import app.cash.turbine.test +import com.synonym.bitkitcore.AddressType import com.synonym.bitkitcore.BoltzPairInfo import com.synonym.bitkitcore.BoltzSwapEvent import com.synonym.bitkitcore.BroadcastException @@ -9,6 +10,7 @@ import com.synonym.bitkitcore.ChannelLiquidityOptions import com.synonym.bitkitcore.IBtEstimateFeeResponse2 import com.synonym.bitkitcore.IBtInfo import com.synonym.bitkitcore.IBtInfoOptions +import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.ReverseSwapResponse import com.synonym.bitkitcore.TrezorException import com.synonym.bitkitcore.TrezorFeatures @@ -23,6 +25,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -54,6 +57,7 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.env.Defaults +import to.bitkit.models.AddressModel import to.bitkit.models.BalanceState import to.bitkit.models.HwFundingAccount import to.bitkit.models.HwFundingAddressType @@ -78,6 +82,7 @@ import to.bitkit.repositories.WalletRepo import to.bitkit.services.BoltzService import to.bitkit.test.BaseUnitTest import to.bitkit.ui.screens.transfer.previewBtOrder +import to.bitkit.ui.screens.transfer.previewSpendingState import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.AppError import kotlin.math.roundToLong @@ -119,6 +124,7 @@ class TransferViewModelTest : BaseUnitTest() { whenever(feeResponse.networkFeeSat).thenReturn(NETWORK_FEE) whenever(feeResponse.serviceFeeSat).thenReturn(SERVICE_FEE) whenever(context.getString(any())).thenReturn("") + whenever(walletRepo.getOnchainAddress()).thenReturn(WALLET_ADDRESS) whenever(settingsStore.data).thenReturn(MutableStateFlow(SettingsData())) whenever { hwWalletRepo.needsPassphrase(any()) }.thenReturn(false) val nodeStatus = mock() @@ -139,6 +145,10 @@ class TransferViewModelTest : BaseUnitTest() { lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) }.thenReturn(Result.success(listOf(stubUtxo(ON_CHAIN_BALANCE)))) + whenever { walletRepo.getAddresses(any(), any(), any(), any()) }.thenReturn( + Result.success(listOf(AddressModel(address = WALLET_ADDRESS, index = 0, path = "m/84"))), + ) + sut = TransferViewModel( context = context, lightningRepo = lightningRepo, @@ -341,10 +351,10 @@ class TransferViewModelTest : BaseUnitTest() { } @Test - fun `onConfirmAmount refuses to create an order the balance cannot fund`() = test { + fun `onConfirmAmount refuses to quote an order the balance cannot fund`() = test { val amount = 260_000uL val budget = 265_000uL - val response = stubFeeResponse(6_000uL) // 260_000 + 6_000 is over the budget + val response = stubFeeResponse(6_000uL) stubSpendableBalances(budget) whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } .thenReturn(Result.success(0uL)) @@ -361,12 +371,13 @@ class TransferViewModelTest : BaseUnitTest() { assertIs(awaitItem()) cancelAndIgnoreRemainingEvents() } + assertEquals(0uL, sut.spendingUiState.value.feeSat) verify(blocktankRepo, never()).createOrder(any(), any(), any()) assertFalse(sut.spendingUiState.value.isLoading) } @Test - fun `onConfirmAmount creates the order when it fits the funding budget`() = test { + fun `onConfirmAmount quotes the order when it fits the funding budget`() = test { val amount = 260_000uL val response = stubFeeResponse(1_000uL) stubSpendableBalances(265_000uL) @@ -375,15 +386,24 @@ class TransferViewModelTest : BaseUnitTest() { whenever(blocktankRepo.calculateLiquidityOptions(any())) .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(response)) - whenever(blocktankRepo.createOrder(any(), any(), any())) - .thenReturn(Result.success(previewBtOrder(clientBalanceSat = amount))) sut.updateLimits() advanceUntilIdle() - sut.onConfirmAmount(amount.toLong()) - advanceUntilIdle() + sut.transferEffects.test { + sut.onConfirmAmount(amount.toLong()) + advanceUntilIdle() + + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } - verify(blocktankRepo).createOrder(eq(amount), any(), any()) + val quote = sut.spendingUiState.value + assertEquals(amount, quote.clientBalanceSat) + assertEquals(LSP_BALANCE, quote.lspBalanceSat) + assertEquals(amount + 1_000uL, quote.feeSat) + assertNull(sut.spendingUiState.value.order) + verify(blocktankRepo).estimateOrderFee(eq(amount), eq(LSP_BALANCE), any()) + verify(blocktankRepo, never()).createOrder(any(), any(), any()) } @Test @@ -392,8 +412,7 @@ class TransferViewModelTest : BaseUnitTest() { whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.failure(AppError("node unavailable"))) whenever(blocktankRepo.calculateLiquidityOptions(any())) .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) - whenever(blocktankRepo.createOrder(any(), any(), any())) - .thenReturn(Result.success(previewBtOrder(clientBalanceSat = amount))) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) sut.updateLimits() advanceUntilIdle() assertNull(sut.spendingUiState.value.fundingBudgetSats) @@ -401,12 +420,12 @@ class TransferViewModelTest : BaseUnitTest() { sut.onConfirmAmount(amount.toLong()) advanceUntilIdle() - // an unreadable balance must not block the flow; confirm stays the authority - verify(blocktankRepo).createOrder(eq(amount), any(), any()) + assertEquals(amount, sut.spendingUiState.value.clientBalanceSat) + verify(blocktankRepo, never()).createOrder(any(), any(), any()) } @Test - fun `onConfirmAmount proceeds when the confirm-time fee estimate fails`() = test { + fun `onConfirmAmount stays on the amount step when the quote fails`() = test { val amount = 260_000uL val response = stubFeeResponse(1_000uL) stubSpendableBalances(265_000uL) @@ -415,22 +434,23 @@ class TransferViewModelTest : BaseUnitTest() { whenever(blocktankRepo.calculateLiquidityOptions(any())) .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(response)) - whenever(blocktankRepo.createOrder(any(), any(), any())) - .thenReturn(Result.success(previewBtOrder(clientBalanceSat = amount))) sut.updateLimits() advanceUntilIdle() - // the budget is sized, so this is the failed-quote path rather than the unset-budget 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.onConfirmAmount(amount.toLong()) - advanceUntilIdle() + sut.transferEffects.test { + sut.onConfirmAmount(amount.toLong()) + advanceUntilIdle() - // a quote the LSP will not give must not block the user; confirm stays the authority - verify(blocktankRepo).createOrder(eq(amount), any(), any()) + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + + assertEquals(0uL, sut.spendingUiState.value.feeSat) + assertFalse(sut.spendingUiState.value.isLoading) + verify(blocktankRepo, never()).createOrder(any(), any(), any()) } @Test @@ -556,15 +576,14 @@ class TransferViewModelTest : BaseUnitTest() { whenever(blocktankRepo.calculateLiquidityOptions(any())) .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) - whenever(blocktankRepo.createOrder(any(), any(), any())) - .thenReturn(Result.success(previewBtOrder(clientBalanceSat = amount))) sut.updateHwLimits(HARDWARE_WALLET_ID) advanceUntilIdle() sut.onConfirmAmount(amount.toLong()) advanceUntilIdle() - verify(blocktankRepo).createOrder(eq(amount), any(), any()) + assertEquals(amount, sut.spendingUiState.value.clientBalanceSat) + verify(blocktankRepo, never()).createOrder(any(), any(), any()) } @Test @@ -594,7 +613,8 @@ class TransferViewModelTest : BaseUnitTest() { } @Test - fun `updateHwFundingFeeEstimate sets mining fee before signing`() = test { + fun `updateHwFundingFeeEstimate uses native segwit independently of the receive preference`() = test { + whenever(walletRepo.getOnchainAddress()).thenReturn("bcrt1ptaproot") val order = previewBtOrder() val funding = HwFundingTransaction( psbt = "psbt", @@ -605,64 +625,64 @@ class TransferViewModelTest : BaseUnitTest() { ) whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) + quoteOrder(order) - sut.updateHwFundingFeeEstimate(order, HARDWARE_WALLET_ID) + sut.updateHwFundingFeeEstimate(HARDWARE_WALLET_ID) advanceUntilIdle() assertEquals(MINING_FEE, sut.spendingUiState.value.hwMiningFeeSats) + verify(walletRepo).getAddresses(0, false, 1, AddressType.P2WPKH) verify(hwWalletRepo).composeFundingTransaction( eq(HARDWARE_WALLET_ID), - eq(order.payment?.onchain?.address.orEmpty()), + eq(WALLET_ADDRESS), eq(order.feeSat), eq(FEE_RATE), ) verify(hwWalletRepo, never()).signFunding(any(), any()) + verify(blocktankRepo, never()).createOrder(any(), any(), any()) } @Test fun `updateHwFundingFeeEstimate ignores superseded estimate`() = test { - val orderA = previewBtOrder() - val orderB = previewBtOrder().copy(id = "order-b-id") + val raisedCapacity = LSP_BALANCE * 2u val staleCompose = CompletableDeferred>() val fundingB = HwFundingTransaction( psbt = "psbt-b", miningFeeSats = 999uL, feeRate = FEE_RATE.toFloat(), - totalSpent = orderB.feeSat + 999uL, + totalSpent = OPTION_MAX_CLIENT_BALANCE + LSP_FEE + 999uL, satsPerVByte = FEE_RATE, ) val staleFunding = HwFundingTransaction( psbt = "psbt-a", miningFeeSats = MINING_FEE, feeRate = FEE_RATE.toFloat(), - totalSpent = orderA.feeSat + MINING_FEE, + totalSpent = OPTION_MAX_CLIENT_BALANCE + LSP_FEE + MINING_FEE, satsPerVByte = FEE_RATE, ) whenever(blocktankRepo.calculateLiquidityOptions(any())) .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) - whenever(blocktankRepo.createOrder(any(), any(), any())) - .thenReturn(Result.success(orderA)) - .thenReturn(Result.success(orderB)) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).doSuspendableAnswer { - if (sut.spendingUiState.value.order?.id == orderA.id) { - staleCompose.await() - } else { + if (sut.spendingUiState.value.isAdvanced) { Result.success(fundingB) + } else { + staleCompose.await() } } sut.onConfirmAmount(OPTION_MAX_CLIENT_BALANCE.toLong()) advanceUntilIdle() - sut.updateHwFundingFeeEstimate(orderA, HARDWARE_WALLET_ID) + sut.updateHwFundingFeeEstimate(HARDWARE_WALLET_ID) runCurrent() - sut.onSpendingAdvancedContinue(LSP_BALANCE.toLong()) + sut.onSpendingAdvancedContinue(raisedCapacity.toLong()) advanceUntilIdle() - sut.updateHwFundingFeeEstimate(orderB, HARDWARE_WALLET_ID) + sut.updateHwFundingFeeEstimate(HARDWARE_WALLET_ID) advanceUntilIdle() assertEquals(999uL, sut.spendingUiState.value.hwMiningFeeSats) @@ -671,6 +691,7 @@ class TransferViewModelTest : BaseUnitTest() { advanceUntilIdle() assertEquals(999uL, sut.spendingUiState.value.hwMiningFeeSats) + verify(blocktankRepo, never()).createOrder(any(), any(), any()) } @Test @@ -714,10 +735,8 @@ class TransferViewModelTest : BaseUnitTest() { @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) @@ -725,7 +744,6 @@ class TransferViewModelTest : BaseUnitTest() { .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)) @@ -741,14 +759,13 @@ class TransferViewModelTest : BaseUnitTest() { assertIs(awaitItem()) cancelAndIgnoreRemainingEvents() } - // only the initial order from onConfirmAmount, no unaffordable one on top of it - verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) + assertEquals(LSP_BALANCE, sut.spendingUiState.value.lspBalanceSat) + verify(blocktankRepo, never()).createOrder(any(), any(), any()) } @Test - fun `onSpendingAdvancedContinue creates the order when the capacity fits the budget`() = test { + fun `onSpendingAdvancedContinue quotes the capacity when it fits the budget`() = test { val clientBalance = 260_000uL - val order = previewBtOrder(clientBalanceSat = clientBalance) val budget = 265_000uL val response = stubFeeResponse(1_000uL) stubSpendableBalances(budget) @@ -756,7 +773,6 @@ class TransferViewModelTest : BaseUnitTest() { .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() @@ -767,21 +783,18 @@ class TransferViewModelTest : BaseUnitTest() { advanceUntilIdle() assertTrue(sut.spendingUiState.value.isAdvanced) - verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) + assertEquals(clientBalance, sut.spendingUiState.value.clientBalanceSat) + verify(blocktankRepo, never()).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) @@ -789,15 +802,13 @@ class TransferViewModelTest : BaseUnitTest() { 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()) + verify(blocktankRepo, never()).createOrder(any(), any(), any()) } @Test - fun `onSpendingAdvancedContinue proceeds when the capacity fee quote fails`() = test { + fun `onSpendingAdvancedContinue stays on the advanced step when the capacity 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) @@ -805,30 +816,32 @@ class TransferViewModelTest : BaseUnitTest() { .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) + val defaultQuote = sut.spendingUiState.value - // 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() + sut.transferEffects.test { + 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()) + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + + assertFalse(sut.spendingUiState.value.isAdvanced) + assertEquals(defaultQuote.feeSat, sut.spendingUiState.value.feeSat) + verify(blocktankRepo, never()).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) + val quote = previewSpendingState(clientBalanceSat = ADVANCED_CLIENT_BALANCE) stubSpendableBalances(ADVANCED_BUDGET) whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } .thenReturn(Result.success(0uL)) @@ -837,17 +850,16 @@ class TransferViewModelTest : BaseUnitTest() { ) stubCapacityPricedFees() - sut.updateAdvancedTransferValues(order) + sut.updateAdvancedTransferValues(quote.clientBalanceSat) 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) + val quote = previewSpendingState(clientBalanceSat = ADVANCED_CLIENT_BALANCE) stubSpendableBalances(ADVANCED_BUDGET) whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } .thenReturn(Result.success(0uL)) @@ -856,7 +868,7 @@ class TransferViewModelTest : BaseUnitTest() { ) stubCapacityPricedFees() - sut.updateAdvancedTransferValues(order) + sut.updateAdvancedTransferValues(quote.clientBalanceSat) advanceUntilIdle() assertEquals(400_000uL, sut.transferValues.value.maxLspBalance) @@ -864,7 +876,7 @@ class TransferViewModelTest : BaseUnitTest() { @Test fun `updateAdvancedTransferValues holds the loading state while settling the max`() = test { - val order = previewBtOrder(clientBalanceSat = ADVANCED_CLIENT_BALANCE) + val quote = previewSpendingState(clientBalanceSat = ADVANCED_CLIENT_BALANCE) val pendingQuote = CompletableDeferred>() stubSpendableBalances(ADVANCED_BUDGET) whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } @@ -876,7 +888,7 @@ class TransferViewModelTest : BaseUnitTest() { pendingQuote.await() } - sut.updateAdvancedTransferValues(order) + sut.updateAdvancedTransferValues(quote.clientBalanceSat) advanceUntilIdle() assertTrue(sut.spendingUiState.value.isLoading) @@ -915,7 +927,6 @@ class TransferViewModelTest : BaseUnitTest() { @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) @@ -923,7 +934,6 @@ class TransferViewModelTest : BaseUnitTest() { .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() @@ -939,23 +949,20 @@ class TransferViewModelTest : BaseUnitTest() { assertIs(awaitItem()) cancelAndIgnoreRemainingEvents() } - // only the initial order, no raised one on top of it - verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) + verify(blocktankRepo, never()).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 + stubSpendableBalances(0uL) 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() @@ -971,18 +978,13 @@ class TransferViewModelTest : BaseUnitTest() { assertIs(awaitItem()) cancelAndIgnoreRemainingEvents() } - // only the initial order, no raised one on top of it - verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) + verify(blocktankRepo, never()).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)) @@ -990,7 +992,6 @@ class TransferViewModelTest : BaseUnitTest() { 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() @@ -1001,12 +1002,12 @@ class TransferViewModelTest : BaseUnitTest() { advanceUntilIdle() assertTrue(sut.spendingUiState.value.isAdvanced) - verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) + verify(blocktankRepo, never()).createOrder(any(), any(), any()) } @Test fun `prepareSpendingConfirmFunding exposes real mining fee for confirm UI`() = test { - val order = previewBtOrder(feeSat = 98_000uL) + quoteOrder(spendingOrder(feeSat = 98_000uL)) val selected = listOf(stubUtxo(100_000u)) stubSpendableBalances(spendable = 100_000u) whenever { @@ -1015,18 +1016,20 @@ class TransferViewModelTest : BaseUnitTest() { whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(1_000uL)) - sut.prepareSpendingConfirmFunding(order) + sut.prepareSpendingConfirmFunding() advanceUntilIdle() val state = sut.spendingUiState.value assertEquals(true, state.isConfirmFeeReady) assertEquals(1_000uL, state.miningFeeSats) assertEquals(false, state.shouldUseSendAll) + verify(lightningRepo).calculateTotalFee(eq(98_000uL), eq(WALLET_ADDRESS), any(), anyOrNull(), anyOrNull()) + verify(blocktankRepo, never()).createOrder(any(), any(), any()) } @Test fun `onTransferToSpendingConfirm uses send-all when selected inputs would create dust change`() = test { - val order = previewBtOrder(feeSat = 99_000uL) + val order = spendingOrder(feeSat = 99_000uL) val selected = listOf(stubUtxo(100_000u)) stubSpendableBalances(spendable = 100_000u) whenever(lightningRepo.estimateSendAllFee(any(), any(), anyOrNull())).thenReturn(Result.success(500uL)) @@ -1046,7 +1049,9 @@ class TransferViewModelTest : BaseUnitTest() { } } - sut.onTransferToSpendingConfirm(order) + quoteOrder(order) + + sut.onTransferToSpendingConfirm() advanceUntilIdle() assertEquals(true, sut.spendingUiState.value.isConfirmPaying) @@ -1071,13 +1076,12 @@ class TransferViewModelTest : BaseUnitTest() { onBroadcast = any(), ) verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) + verify(blocktankRepo, times(1)).createOrder(eq(order.clientBalanceSat), eq(order.lspBalanceSat), any()) } @Test fun `onTransferToSpendingConfirm does not drain when normal fee leaves non-dust change`() = test { - // Regression: 41x1k UTXOs — send-all fee made expectedChange look like 0, but normal - // coin selection fee leaves real change and must not wipe the wallet. - val order = previewBtOrder(feeSat = 35_341uL) + val order = spendingOrder(feeSat = 35_341uL) val selected = listOf(stubUtxo(41_000u)) stubSpendableBalances(spendable = 41_000u) whenever(lightningRepo.estimateSendAllFee(any(), any(), anyOrNull())) @@ -1089,7 +1093,9 @@ class TransferViewModelTest : BaseUnitTest() { .thenReturn(Result.success(2_830uL)) stubSendOnChainSuccess() - sut.onTransferToSpendingConfirm(order) + quoteOrder(order) + + sut.onTransferToSpendingConfirm() advanceUntilIdle() assertEquals(true, sut.spendingUiState.value.isConfirmPaying) @@ -1124,8 +1130,7 @@ class TransferViewModelTest : BaseUnitTest() { @Test fun `onTransferToSpendingConfirm surfaces error when fixed send fails without draining`() = test { - // Match iOS: dust was already decided up front; do not surprise-drain on send failure. - val order = previewBtOrder(feeSat = 98_000uL) + val order = spendingOrder(feeSat = 98_000uL) val selected = listOf(stubUtxo(100_000u)) stubSpendableBalances(spendable = 100_000u) whenever(lightningRepo.estimateSendAllFee(any(), any(), anyOrNull())) @@ -1152,7 +1157,9 @@ class TransferViewModelTest : BaseUnitTest() { ), ).thenReturn(Result.failure(AppError("Coin selection failed"))) - sut.onTransferToSpendingConfirm(order) + quoteOrder(order) + + sut.onTransferToSpendingConfirm() advanceUntilIdle() assertEquals(false, sut.spendingUiState.value.isConfirmPaying) @@ -1185,6 +1192,273 @@ class TransferViewModelTest : BaseUnitTest() { verify(cacheStore, never()).addPaidOrder(any(), any()) } + @Test + fun `confirmation blocks replacing or clearing the transfer while creating its order`() = test { + val order = spendingOrder(feeSat = 98_000uL) + val creation = CompletableDeferred>() + quoteOrder(order) + whenever(blocktankRepo.createOrder(any(), any(), any())).doSuspendableAnswer { creation.await() } + stubSpendableBalances(110_000uL) + whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(1_000uL)) + stubSendOnChainSuccess() + + sut.onTransferToSpendingConfirm() + runCurrent() + sut.onConfirmAmount(50_000) + sut.onSpendingAdvancedContinue(60_000) + sut.onUseDefaultLspBalanceClick() + sut.resetSpendingState() + runCurrent() + + assertEquals(order.clientBalanceSat, sut.spendingUiState.value.clientBalanceSat) + assertEquals(order.lspBalanceSat, sut.spendingUiState.value.lspBalanceSat) + assertTrue(sut.spendingUiState.value.isBusy) + + creation.complete(Result.success(order)) + advanceUntilIdle() + verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) + verify(cacheStore).addPaidOrder(order.id, TXID) + } + + @Test + fun `onTransferToSpendingConfirm pays the order it already created when swiped again`() = test { + val order = spendingOrder(feeSat = 98_000uL) + stubSpendableBalances(spendable = 110_000u) + whenever { + lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) + }.thenReturn(Result.success(listOf(stubUtxo(110_000u)))) + whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(1_000uL)) + whenever( + lightningRepo.sendOnChain( + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + any(), + anyOrNull(), + any(), + any(), + any(), + any(), + ), + ).thenReturn(Result.failure(AppError("Coin selection failed")), Result.success(TXID)) + quoteOrder(order) + + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + + verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) + verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) + } + + @Test + fun `onTransferToSpendingConfirm requires another swipe when the created order costs more`() = test { + val estimate = spendingOrder(feeSat = 98_000uL) + val createdOrder = estimate.copy( + feeSat = 99_000uL, + serviceFeeSat = estimate.serviceFeeSat + 1_000uL, + ) + stubSpendableBalances(spendable = 110_000uL) + whenever { + lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) + }.thenReturn(Result.success(listOf(stubUtxo(110_000uL)))) + whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(1_000uL)) + stubSendOnChainSuccess() + quoteOrder(estimate) + whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(createdOrder)) + + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + + assertEquals(createdOrder, sut.spendingUiState.value.order) + assertEquals(createdOrder.feeSat, sut.spendingUiState.value.feeSat) + assertFalse(sut.spendingUiState.value.isConfirmPaying) + verify(lightningRepo, never()).sendOnChain( + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + any(), + anyOrNull(), + any(), + any(), + any(), + any(), + ) + + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + + verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) + verify(cacheStore).addPaidOrder(createdOrder.id, TXID) + } + + @Test + fun `a new quote does not reuse an unpaid order from the previous confirmation`() = test { + val estimate = spendingOrder(feeSat = 98_000uL) + val firstOrder = estimate.copy( + id = "order_first", + feeSat = 99_000uL, + serviceFeeSat = estimate.serviceFeeSat + 1_000uL, + ) + val nextOrder = estimate.copy(id = "order_next") + stubSpendableBalances(spendable = 110_000uL) + whenever { + lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) + }.thenReturn(Result.success(listOf(stubUtxo(110_000uL)))) + whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(1_000uL)) + stubSendOnChainSuccess() + quoteOrder(estimate) + whenever(blocktankRepo.createOrder(any(), any(), any())) + .thenReturn(Result.success(firstOrder), Result.success(nextOrder)) + + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + assertEquals(firstOrder, sut.spendingUiState.value.order) + + sut.onConfirmAmount(estimate.clientBalanceSat.toLong()) + advanceUntilIdle() + assertNull(sut.spendingUiState.value.order) + + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + + verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) + verify(cacheStore).addPaidOrder(nextOrder.id, TXID) + } + + @Test + fun `a paid savings transfer does not block quoting the next transfer`() = test { + val paidOrder = spendingOrder(feeSat = 98_000uL) + stubSpendableBalances(spendable = 110_000uL) + whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(1_000uL)) + stubSendOnChainSuccess() + quoteOrder(paidOrder) + + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + verify(cacheStore).addPaidOrder(eq(paidOrder.id), eq(TXID)) + + val nextAmount = paidOrder.clientBalanceSat.toLong() - 1_000 + sut.onConfirmAmount(nextAmount) + advanceUntilIdle() + + assertEquals(nextAmount.toULong(), sut.spendingUiState.value.clientBalanceSat) + assertFalse(sut.spendingUiState.value.isConfirmPaying) + } + + @Test + fun `onTransferToSpendingConfirm stays on the confirm step when the order cannot be created`() = test { + val order = spendingOrder(feeSat = 98_000uL) + stubSpendableBalances(spendable = 110_000u) + stubSendOnChainSuccess() + val toasts = mutableListOf() + val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } + quoteOrder(order) + val quote = sut.spendingUiState.value + whenever(blocktankRepo.createOrder(any(), any(), any())) + .thenReturn(Result.failure(AppError("lsp unreachable"))) + + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + toastJob.cancel() + + val state = sut.spendingUiState.value + assertFalse(state.isConfirmPaying) + assertNull(state.order) + assertEquals(quote, state) + assertEquals(1, toasts.size) + verify(lightningRepo, never()).sendOnChain( + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + any(), + anyOrNull(), + any(), + any(), + any(), + any(), + ) + verify(cacheStore, never()).addPaidOrder(any(), any()) + } + + @Test + fun `onConfirmAmount drops the created order so the next confirm creates a fresh one`() = test { + val order = spendingOrder(feeSat = 98_000uL) + stubSpendableBalances(spendable = 110_000u) + whenever { + lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) + }.thenReturn(Result.success(listOf(stubUtxo(110_000u)))) + whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(1_000uL)) + whenever( + lightningRepo.sendOnChain( + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + any(), + anyOrNull(), + any(), + any(), + any(), + any(), + ), + ).thenReturn(Result.failure(AppError("Coin selection failed"))) + quoteOrder(order) + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + assertEquals(order, sut.spendingUiState.value.order) + + sut.onConfirmAmount(order.clientBalanceSat.toLong() + 1) + advanceUntilIdle() + + assertNull(sut.spendingUiState.value.order) + sut.spendingUiState.value + verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) + + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + + verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) + } + + @Test + fun `onUseDefaultLspBalanceClick restores the default quote without an order`() = test { + val order = spendingOrder(feeSat = 98_000uL) + val raisedCapacity = order.lspBalanceSat * 2u + quoteOrder(order) + val defaultQuote = sut.spendingUiState.value + + sut.onSpendingAdvancedContinue(raisedCapacity.toLong()) + advanceUntilIdle() + + val advanced = sut.spendingUiState.value + assertTrue(advanced.isAdvanced) + assertEquals(raisedCapacity, advanced.lspBalanceSat) + + sut.onUseDefaultLspBalanceClick() + advanceUntilIdle() + + val restored = sut.spendingUiState.value + assertFalse(restored.isAdvanced) + assertEquals(defaultQuote.lspBalanceSat, restored.lspBalanceSat) + assertNull(restored.order) + verify(blocktankRepo, never()).createOrder(any(), any(), any()) + } + @Test fun `onTransferToSpendingHwConfirm signs the funding send and records the paid order`() = test { val order = previewBtOrder() @@ -1211,7 +1485,9 @@ class TransferViewModelTest : BaseUnitTest() { whenever(hwWalletRepo.signFunding(any(), any())).thenReturn(Result.success(signed)) whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn(Result.success(broadcast)) - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + quoteOrder(order) + + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) advanceUntilIdle() assertEquals(MINING_FEE, sut.spendingUiState.value.hwMiningFeeSats) @@ -1242,6 +1518,50 @@ class TransferViewModelTest : BaseUnitTest() { eq(HARDWARE_WALLET_ID), ) verify(hwWalletRepo).ensureConnected(HARDWARE_WALLET_ID) + verify(blocktankRepo, times(1)).createOrder(eq(order.clientBalanceSat), eq(order.lspBalanceSat), any()) + } + + @Test + fun `a paid hardware order is not paid again by the next transfer of the same amount`() = test { + val paidOrder = previewBtOrder() + val nextOrder = paidOrder.copy( + id = "order_next", + payment = paidOrder.payment?.copy( + onchain = paidOrder.payment?.onchain?.copy(address = "bcrt1qnextorderaddress"), + ), + ) + stubHwFundingSuccess(paidOrder) + stubSpendableBalances(spendable = ON_CHAIN_BALANCE) + whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(1_000uL)) + stubSendOnChainSuccess() + quoteOrder(paidOrder) + whenever(blocktankRepo.createOrder(any(), any(), any())) + .thenReturn(Result.success(paidOrder), Result.success(nextOrder)) + + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) + advanceUntilIdle() + verify(cacheStore).addPaidOrder(eq(paidOrder.id), eq(TXID)) + + sut.onConfirmAmount(paidOrder.clientBalanceSat.toLong()) + advanceUntilIdle() + sut.onTransferToSpendingConfirm() + advanceUntilIdle() + + verify(lightningRepo, never()).sendOnChain( + eq(paidOrder.payment?.onchain?.address.orEmpty()), + any(), + any(), + anyOrNull(), + anyOrNull(), + any(), + anyOrNull(), + any(), + any(), + any(), + any(), + ) + verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) } @Test @@ -1273,7 +1593,9 @@ class TransferViewModelTest : BaseUnitTest() { ) whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn(Result.success(broadcast)) - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + quoteOrder(order) + + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) advanceUntilIdle() verify(hwWalletRepo, times(2)).ensureConnected(HARDWARE_WALLET_ID) @@ -1289,7 +1611,9 @@ class TransferViewModelTest : BaseUnitTest() { .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = false)))) whenever { hwWalletRepo.needsPassphrase(HARDWARE_WALLET_ID) }.thenReturn(true) - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + quoteOrder(order) + + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) advanceUntilIdle() assertTrue(sut.spendingUiState.value.isHwPassphraseRequired) @@ -1306,7 +1630,9 @@ class TransferViewModelTest : BaseUnitTest() { whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) .thenReturn(Result.failure(HwPassphraseRequiredError())) - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + quoteOrder(order) + + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) advanceUntilIdle() assertTrue(sut.spendingUiState.value.isHwPassphraseRequired) @@ -1344,13 +1670,14 @@ class TransferViewModelTest : BaseUnitTest() { .thenReturn(Result.failure(AppError(BroadcastException.ElectrumException("DNS lookup failed")))) .thenReturn(Result.success(broadcast)) - // sign once so a broadcast is left pending, then lose the session - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + quoteOrder(order) + + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) advanceUntilIdle() assertTrue(sut.spendingUiState.value.hasPendingHwBroadcast) whenever { hwWalletRepo.needsPassphrase(HARDWARE_WALLET_ID) }.thenReturn(true) - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) advanceUntilIdle() assertFalse(sut.spendingUiState.value.isHwPassphraseRequired) @@ -1364,7 +1691,9 @@ class TransferViewModelTest : BaseUnitTest() { whenever(hwWalletRepo.wallets) .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = false)))) whenever { hwWalletRepo.needsPassphrase(HARDWARE_WALLET_ID) }.thenReturn(true) - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + quoteOrder(order) + + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) advanceUntilIdle() assertTrue(sut.spendingUiState.value.isHwPassphraseRequired) @@ -1391,12 +1720,13 @@ class TransferViewModelTest : BaseUnitTest() { whenever { hwWalletRepo.needsPassphrase(HARDWARE_WALLET_ID) }.thenReturn(true, false) whenever { hwWalletRepo.reconnectWithPassphrase(HARDWARE_WALLET_ID, "secret") } .thenReturn(Result.success(Unit)) - whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) + whenever { hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID) } .thenReturn(Result.success(mock())) - whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) - whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) - whenever(hwWalletRepo.signFunding(any(), any())).thenReturn(Result.success(signed)) - whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn( + whenever { lightningRepo.getFeeRateForSpeed(any(), anyOrNull()) }.thenReturn(Result.success(FEE_RATE)) + whenever { hwWalletRepo.composeFundingTransaction(any(), any(), any(), any()) } + .thenReturn(Result.success(funding)) + whenever { hwWalletRepo.signFunding(any(), any()) }.thenReturn(Result.success(signed)) + whenever { hwWalletRepo.broadcastFunding(signed) }.thenReturn( Result.success( HwFundingBroadcastResult( txId = TXID, @@ -1406,10 +1736,12 @@ class TransferViewModelTest : BaseUnitTest() { ) ) ) - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + quoteOrder(order) + + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) advanceUntilIdle() - sut.onHwPassphraseSubmit(order, HARDWARE_WALLET_ID, "secret") + sut.onHwPassphraseSubmit(HARDWARE_WALLET_ID, "secret") advanceUntilIdle() assertFalse(sut.spendingUiState.value.isHwPassphraseRequired) @@ -1419,9 +1751,6 @@ class TransferViewModelTest : BaseUnitTest() { @Test fun `dismissing the passphrase prompt stops the reopen from starting a signature`() = test { - // The sheet can be swiped away while the device is still reopening the wallet; the transfer - // the user backed out of must not go on to ask the device for a signature. - val order = previewBtOrder() whenever(hwWalletRepo.wallets) .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = false)))) whenever { hwWalletRepo.reconnectWithPassphrase(HARDWARE_WALLET_ID, "secret") } @@ -1429,7 +1758,7 @@ class TransferViewModelTest : BaseUnitTest() { whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) .thenReturn(Result.success(mock())) - sut.onHwPassphraseSubmit(order, HARDWARE_WALLET_ID, "secret") + sut.onHwPassphraseSubmit(HARDWARE_WALLET_ID, "secret") sut.onHwPassphraseDismiss() advanceUntilIdle() @@ -1441,7 +1770,6 @@ class TransferViewModelTest : BaseUnitTest() { @Test fun `onHwPassphraseSubmit does not sign when the passphrase opens another wallet`() = test { - val order = previewBtOrder() whenever(hwWalletRepo.wallets) .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = false)))) whenever { hwWalletRepo.needsPassphrase(HARDWARE_WALLET_ID) }.thenReturn(true) @@ -1451,7 +1779,7 @@ class TransferViewModelTest : BaseUnitTest() { val toasts = mutableListOf() val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } - sut.onHwPassphraseSubmit(order, HARDWARE_WALLET_ID, "wrong") + sut.onHwPassphraseSubmit(HARDWARE_WALLET_ID, "wrong") advanceUntilIdle() toastJob.cancel() @@ -1487,7 +1815,9 @@ class TransferViewModelTest : BaseUnitTest() { whenever(hwWalletRepo.signFunding(any(), any())).thenReturn(Result.success(signed)) whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn(Result.success(broadcast)) - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + quoteOrder(order) + + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) advanceUntilIdle() verify(lightningRepo).getFeeRateForSpeed(eq(TransactionSpeed.Fast), anyOrNull()) @@ -1508,7 +1838,9 @@ class TransferViewModelTest : BaseUnitTest() { .thenReturn(Result.failure(AppError("no device"))) whenever(hwWalletRepo.isKnownBluetoothDevice(HARDWARE_WALLET_ID)).thenReturn(false) - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + quoteOrder(order) + + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) advanceUntilIdle() verify(hwWalletRepo).ensureConnected(HARDWARE_WALLET_ID) @@ -1524,7 +1856,9 @@ class TransferViewModelTest : BaseUnitTest() { whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)).doSuspendableAnswer { connectResult.await() } whenever(hwWalletRepo.disconnectStaleSession(HARDWARE_WALLET_ID)).thenReturn(Result.success(Unit)) - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + quoteOrder(order) + + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) runCurrent() assertEquals(true, sut.spendingUiState.value.isSigning) @@ -1552,7 +1886,9 @@ class TransferViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.hardware__connect_title)).thenReturn(CONNECT_TITLE) whenever(context.getString(R.string.hardware__connect_error)).thenReturn(CONNECT_DESCRIPTION) - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + quoteOrder(order) + + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) advanceUntilIdle() toastJob.cancel() @@ -1582,7 +1918,9 @@ class TransferViewModelTest : BaseUnitTest() { whenever(hwWalletRepo.signFunding(any(), any())).thenReturn(Result.failure(timeout)) whenever(hwWalletRepo.disconnectStaleSession(HARDWARE_WALLET_ID)).thenReturn(Result.success(Unit)) - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + quoteOrder(order) + + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) advanceUntilIdle() verify(hwWalletRepo).disconnectStaleSession(HARDWARE_WALLET_ID) @@ -1626,7 +1964,9 @@ class TransferViewModelTest : BaseUnitTest() { boltzService = boltzService, ) - viewModel.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + quoteOrder(order, viewModel) + + viewModel.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) runCurrent() advanceTimeBy(120.seconds.inWholeMilliseconds + 1) runCurrent() @@ -1658,7 +1998,9 @@ class TransferViewModelTest : BaseUnitTest() { whenever(hwWalletRepo.signFunding(any(), any())) .thenReturn(Result.failure(TrezorException.UserCancelled())) - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + quoteOrder(order) + + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) advanceUntilIdle() verify(cacheStore, never()).addPaidOrder(any(), any()) @@ -1687,7 +2029,9 @@ class TransferViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.hardware__device_busy)).thenReturn(DEVICE_BUSY_MESSAGE) whenever(context.getString(R.string.hardware__connect_error)).thenReturn("connect error") - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + quoteOrder(order) + + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) advanceUntilIdle() toastJob.cancel() @@ -1714,7 +2058,9 @@ class TransferViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.lightning__transfer_hw__reconnect_error_description)) .thenReturn(RECONNECT_DESCRIPTION) - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + quoteOrder(order) + + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) advanceUntilIdle() toastJob.cancel() @@ -1741,7 +2087,9 @@ class TransferViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.other__connection_issue)).thenReturn(CONNECTION_ISSUE_TITLE) whenever(context.getString(R.string.other__connection_issues_explain)).thenReturn(CONNECTION_ISSUE_DESCRIPTION) - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + quoteOrder(order) + + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) advanceUntilIdle() toastJob.cancel() @@ -1766,7 +2114,9 @@ class TransferViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.hardware__device_busy)).thenReturn(DEVICE_BUSY_MESSAGE) whenever(context.getString(R.string.hardware__connect_error)).thenReturn("connect error") - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + quoteOrder(order) + + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) advanceUntilIdle() toastJob.cancel() @@ -1793,7 +2143,9 @@ class TransferViewModelTest : BaseUnitTest() { context.getString(R.string.lightning__transfer_hw__reconnect_error_description) ).thenReturn("reconnect body") - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + quoteOrder(order) + + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) advanceUntilIdle() toastJob.cancel() @@ -1835,7 +2187,9 @@ class TransferViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.other__connection_issue)).thenReturn(CONNECTION_ISSUE_TITLE) whenever(context.getString(R.string.other__connection_issues_explain)).thenReturn(CONNECTION_ISSUE_DESCRIPTION) - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + quoteOrder(order) + + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) advanceUntilIdle() assertEquals(true, sut.spendingUiState.value.hasPendingHwBroadcast) @@ -1843,7 +2197,7 @@ class TransferViewModelTest : BaseUnitTest() { assertEquals(CONNECTION_ISSUE_TITLE, toasts.single().title) verify(cacheStore, never()).addPaidOrder(any(), any()) - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) advanceUntilIdle() toastJob.cancel() @@ -1856,8 +2210,14 @@ class TransferViewModelTest : BaseUnitTest() { } @Test - fun `onTransferToSpendingHwConfirm signs again when pending order address changes`() = test { - var order = previewBtOrder() + fun `onTransferToSpendingHwConfirm signs again when a new quote lands on another order address`() = test { + val order = previewBtOrder() + val reorder = order.copy( + id = "order-new", + payment = requireNotNull(order.payment).copy( + onchain = requireNotNull(order.payment?.onchain).copy(address = "bc1qnewdestination"), + ), + ) val funding = HwFundingTransaction( psbt = "psbt", miningFeeSats = MINING_FEE, @@ -1875,25 +2235,25 @@ class TransferViewModelTest : BaseUnitTest() { .thenReturn(Result.failure(AppError(BroadcastException.ElectrumException("DNS lookup failed")))) whenever(context.getString(R.string.other__connection_issue)).thenReturn(CONNECTION_ISSUE_TITLE) whenever(context.getString(R.string.other__connection_issues_explain)).thenReturn(CONNECTION_ISSUE_DESCRIPTION) + quoteOrder(order) - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) advanceUntilIdle() - order = order.copy( - payment = requireNotNull(order.payment).copy( - onchain = requireNotNull(order.payment?.onchain).copy(address = "bc1qnewdestination"), - ), - ) - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + sut.onSpendingAdvancedContinue((order.lspBalanceSat * 2u).toLong()) + advanceUntilIdle() + whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(reorder)) + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) advanceUntilIdle() verify(hwWalletRepo, times(2)).signFunding(HARDWARE_WALLET_ID, funding) verify(hwWalletRepo).composeFundingTransaction( HARDWARE_WALLET_ID, "bc1qnewdestination", - order.feeSat, + reorder.feeSat, FEE_RATE, ) + verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) } @Test @@ -1929,7 +2289,9 @@ class TransferViewModelTest : BaseUnitTest() { whenever(hwWalletRepo.signFunding(HARDWARE_WALLET_ID, funding)).thenReturn(Result.success(signed)) whenever(hwWalletRepo.broadcastFunding(signed)).doSuspendableAnswer { broadcastResult.await() } - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + quoteOrder(order) + + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) runCurrent() assertEquals(true, sut.spendingUiState.value.hasPendingHwBroadcast) @@ -1981,7 +2343,9 @@ class TransferViewModelTest : BaseUnitTest() { Unit } - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + quoteOrder(order) + + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) advanceUntilIdle() assertEquals(true, sut.spendingUiState.value.hasPendingHwBroadcast) @@ -1997,7 +2361,7 @@ class TransferViewModelTest : BaseUnitTest() { anyOrNull(), ) - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) advanceUntilIdle() assertEquals(false, sut.spendingUiState.value.hasPendingHwBroadcast) @@ -2038,7 +2402,9 @@ class TransferViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.other__connection_issue)).thenReturn(CONNECTION_ISSUE_TITLE) whenever(context.getString(R.string.other__connection_issues_explain)).thenReturn(CONNECTION_ISSUE_DESCRIPTION) - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + quoteOrder(order) + + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) advanceUntilIdle() toastJob.cancel() @@ -2066,7 +2432,9 @@ class TransferViewModelTest : BaseUnitTest() { whenever(hwWalletRepo.signFunding(HARDWARE_WALLET_ID, funding)).thenReturn(Result.success(signed)) whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn(Result.failure(AppError("invalid transaction"))) - sut.onTransferToSpendingHwConfirm(order, HARDWARE_WALLET_ID) + quoteOrder(order) + + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) advanceUntilIdle() assertEquals(false, sut.spendingUiState.value.hasPendingHwBroadcast) @@ -2419,6 +2787,37 @@ class TransferViewModelTest : BaseUnitTest() { ) } + private suspend fun TestScope.quoteOrder(order: IBtOrder, viewModel: TransferViewModel = sut) { + whenever(blocktankRepo.calculateLiquidityOptions(any())).thenReturn( + Result.success( + ChannelLiquidityOptions( + defaultLspBalanceSat = order.lspBalanceSat, + minLspBalanceSat = order.lspBalanceSat, + maxLspBalanceSat = order.lspBalanceSat, + maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE, + ), + ), + ) + val response = feeResponseFor(order) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(response)) + whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(order)) + viewModel.onConfirmAmount(order.clientBalanceSat.toLong()) + advanceUntilIdle() + } + + private fun feeResponseFor(order: IBtOrder): IBtEstimateFeeResponse2 = mock().also { + whenever(it.feeSat).thenReturn(order.networkFeeSat.safe() + order.serviceFeeSat.safe()) + whenever(it.networkFeeSat).thenReturn(order.networkFeeSat) + whenever(it.serviceFeeSat).thenReturn(order.serviceFeeSat) + } + + private fun spendingOrder(feeSat: ULong): IBtOrder = previewBtOrder( + feeSat = feeSat, + clientBalanceSat = feeSat.safe() - (NETWORK_FEE.safe() + SERVICE_FEE.safe()).safe(), + networkFeeSat = NETWORK_FEE, + serviceFeeSat = SERVICE_FEE, + ) + private suspend fun stubSpendableBalances(spendable: ULong) { val balances = BalanceDetails( totalOnchainBalanceSats = spendable, @@ -2431,6 +2830,34 @@ class TransferViewModelTest : BaseUnitTest() { whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balances)) } + private suspend fun stubHwFundingSuccess(order: IBtOrder) { + val funding = HwFundingTransaction( + psbt = "psbt", + miningFeeSats = MINING_FEE, + feeRate = FEE_RATE.toFloat(), + totalSpent = order.feeSat + MINING_FEE, + satsPerVByte = FEE_RATE, + ) + val signed = signedFunding(funding) + whenever(hwWalletRepo.wallets) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) + whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) + .thenReturn(Result.success(mock())) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) + whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) + whenever(hwWalletRepo.signFunding(any(), any())).thenReturn(Result.success(signed)) + whenever(hwWalletRepo.broadcastFunding(signed)).thenReturn( + Result.success( + HwFundingBroadcastResult( + txId = TXID, + miningFeeSats = MINING_FEE, + feeRate = FEE_RATE, + totalSpent = order.feeSat + MINING_FEE, + ), + ), + ) + } + private fun stubUtxo(valueSats: ULong): SpendableUtxo = SpendableUtxo( outpoint = OutPoint(txid = "stub-utxo-txid", vout = 0u), valueSats = valueSats, @@ -2470,6 +2897,7 @@ class TransferViewModelTest : BaseUnitTest() { const val CONNECT_TITLE = "Connect Device" const val CONNECT_DESCRIPTION = "Check the hardware device and try again." const val HARDWARE_WALLET_ID = "hardware-wallet" + const val WALLET_ADDRESS = "bcrt1qwalletaddress" const val PASSPHRASE_MISMATCH = "That passphrase opens a different wallet." const val RECONNECT_TITLE = "Reconnect Hardware Device" const val RECONNECT_DESCRIPTION = "Please reconnect your hardware device." diff --git a/changelog.d/next/1247.fixed.md b/changelog.d/next/1247.fixed.md new file mode 100644 index 0000000000..65c20e33aa --- /dev/null +++ b/changelog.d/next/1247.fixed.md @@ -0,0 +1 @@ +The transfer to spending flow now creates the Blocktank order only when you confirm, instead of on every Continue tap. diff --git a/journeys/hardware-wallet/transfer-to-spending.xml b/journeys/hardware-wallet/transfer-to-spending.xml index adf2e0ced0..a93a397e40 100644 --- a/journeys/hardware-wallet/transfer-to-spending.xml +++ b/journeys/hardware-wallet/transfer-to-spending.xml @@ -29,13 +29,13 @@ Tap the "25%" quick button (testTag "HardwareTransferAmountQuarter") to set a valid amount below the available limit - Tap "Continue" (testTag "HardwareTransferAmountContinue") and wait for the Blocktank order to be created + Tap "Continue" (testTag "HardwareTransferAmountContinue") and wait for the fee estimate Verify the sign screen opens (testTag "HardwareTransferSign"), titled "SIGN WITH YOUR DEVICE", showing the NETWORK FEES, SERVICE FEES, TO SPENDING and TOTAL cells, the Learn More and Advanced buttons, and the Trezor illustration - Tap "Open Trezor Connect" (testTag "HardwareTransferOpenTrezorConnect") + Tap "Open Trezor Connect" (testTag "HardwareTransferOpenTrezorConnect") to create the Blocktank order. If its fee is higher than the estimate, verify the fee cells refresh and tap "Open Trezor Connect" again; otherwise signing starts immediately Approve the Recipient, Amount, Locktime, and Summary prompts on the Bridge emulator in order