diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index da1d01f8af..bebee077db 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -716,6 +716,7 @@ class BlocktankRepo @Inject constructor( companion object { private const val TAG = "BlocktankRepo" private const val REFUND_ADDRESS_ALLOCATION_LIMIT = 20 + private const val DEFAULT_CHANNEL_EXPIRY_WEEKS = 6u private const val DEFAULT_SOURCE = "bitkit-android" private const val PEER_CONNECTION_DELAY_MS = 2_000L diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index d903bebf26..03298af8cd 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -926,7 +926,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( @@ -945,7 +945,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 -> @@ -981,8 +981,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 { 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 e125e82ae1..c46465aa7b 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 ec1b4349d4..96867768d5 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 e4c67598aa..338303d60b 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 @@ -30,17 +31,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 @@ -65,6 +55,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 @Composable @@ -81,17 +72,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() @@ -111,12 +104,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, @@ -147,7 +140,7 @@ private fun Content( onSwitchClick: () -> Unit, hasNotificationPermission: Boolean, onTransferToSpendingConfirm: () -> Unit, - order: IBtOrder, + state: TransferToSpendingUiState, miningFeeSats: ULong, isConfirmFeeReady: Boolean, isConfirmPaying: Boolean, @@ -157,7 +150,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) { @@ -179,11 +172,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)) @@ -271,7 +263,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), @@ -298,64 +289,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, @@ -377,64 +315,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, @@ -456,64 +341,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, @@ -535,64 +367,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 eef71f8391..81395ccc57 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..053c6788c0 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 (_spendingUiState.value.isBusy) 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 (_spendingUiState.value.isBusy) 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,38 @@ class TransferViewModel @Inject constructor( ) return@runSuspendCatching } - val newOrder = blocktankRepo.createOrder( - spendingBalanceSats = oldOrder.clientBalanceSat, - receivingBalanceSats = receivingAmountSats.toULong(), - ).getOrThrow() + if (_spendingUiState.value.isBusy) 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 +313,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 +330,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 +340,23 @@ class TransferViewModel @Inject constructor( } } + private suspend fun ensureSpendingOrder(): IBtOrder? { + _spendingUiState.value.order?.let { return it } + val state = _spendingUiState.value + val order = blocktankRepo.createOrder( + spendingBalanceSats = state.clientBalanceSat, + receivingBalanceSats = state.lspBalanceSat, + ).getOrElse { + ToastEventBus.send(it) + return null + } + _spendingUiState.update { it.copy(order = order) } + 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 +415,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 +431,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 +446,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 +462,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 +471,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 +487,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, @@ -626,21 +638,26 @@ 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 (_spendingUiState.value.isBusy) return pendingHwFundingBroadcast = null hwFeeEstimateJob?.cancel() hwFeeEstimateJob = null _spendingUiState.update { it.copy( - order = order, + clientBalanceSat = clientBalanceSat, + lspBalanceSat = lspBalanceSat, + feeSat = feeSat, + order = it.order.takeIf { order -> + order != null && order.clientBalanceSat == clientBalanceSat && order.lspBalanceSat == lspBalanceSat + }, isAdvanced = false, - defaultOrder = null, hasPendingHwBroadcast = false, hwMiningFeeSats = 0uL, ) } - setTransferEffect(TransferEffect.OnOrderCreated) + setTransferEffect(TransferEffect.OnQuoteReady) } private fun updateAvailableAmount() { @@ -867,33 +884,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 +907,40 @@ 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 (_spendingUiState.value.isBusy) 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 (_spendingUiState.value.isBusy) return@onSuccess + hwFeeEstimateJob?.cancel() + _spendingUiState.update { + it.copy( + lspBalanceSat = lspBalance, + feeSat = feeSat, + order = it.order.takeIf { order -> order?.lspBalanceSat == lspBalance }, + isAdvanced = 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 +1001,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 +1035,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 +1086,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 +1096,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 +1381,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 +1395,7 @@ class TransferViewModel @Inject constructor( } val affordableMax = resolveAffordableLspBalance( - clientBalance = order.clientBalanceSat, + clientBalance = clientBalanceSat, budget = budget, minLspBalance = values.minLspBalance, maxLspBalance = values.maxLspBalance, @@ -1881,10 +1887,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 +1902,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 +1941,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 df3a1e3891..8b2f558b30 100644 --- a/app/src/test/java/to/bitkit/ui/ContentViewTest.kt +++ b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt @@ -33,7 +33,7 @@ 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 diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 8398b30c3a..bb733bba6f 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,173 @@ 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 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 +1385,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 +1418,7 @@ 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 @@ -1273,7 +1450,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 +1468,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 +1487,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 +1527,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 +1548,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) @@ -1406,10 +1592,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 +1607,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 +1614,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 +1626,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 +1635,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 +1671,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 +1694,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 +1712,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 +1742,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 +1774,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 +1820,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 +1854,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 +1885,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 +1914,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 +1943,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 +1970,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 +1999,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 +2043,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 +2053,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 +2066,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 +2091,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 +2145,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 +2199,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 +2217,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 +2258,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 +2288,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 +2643,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, @@ -2470,6 +2725,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.