diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 57488f528c..9d3a1d3154 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -226,6 +226,7 @@ import to.bitkit.ui.utils.AutoReadClipboardHandler import to.bitkit.ui.utils.RequestNotificationPermissions import to.bitkit.ui.utils.ScreenDeepLinks import to.bitkit.ui.utils.SheetDeepLinks +import to.bitkit.ui.utils.SpendingHwSignLink import to.bitkit.ui.utils.composableWithDefaultTransitions import to.bitkit.ui.utils.deepLinkableComposable import to.bitkit.ui.utils.navigationWithDefaultTransitions @@ -334,23 +335,46 @@ fun ContentView( val uri = pendingScreenDeepLink ?: return@LaunchedEffect navController.currentBackStackEntryFlow.first() - appViewModel.consumeScreenDeepLink() - SheetDeepLinks.sheetFor(uri)?.let { - appViewModel.showSheet(it) - return@LaunchedEffect - } + val sheet = SheetDeepLinks.sheetFor(uri) + val hwSignLink = if (sheet == null) ScreenDeepLinks.spendingHwSignLink(uri) else null + + val shouldNavigate = when { + sheet != null -> { + appViewModel.showSheet(sheet) + false + } - val request = Intent(Intent.ACTION_VIEW, uri) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) - val handled = navController.handleDeepLink(request) + hwSignLink is SpendingHwSignLink.Malformed -> { + Logger.warn("Refused spending hw sign deeplink, malformed '$uri'", context = "ContentView") + false + } + + // Refusals are logged with their specific reason inside prepareSpendingHwSign. + hwSignLink is SpendingHwSignLink.Valid -> transferViewModel.prepareSpendingHwSign( + walletId = hwSignLink.walletId, + amountSats = hwSignLink.amountSats, + ) - if (shouldDismissSheetForScreenLink(handled, appViewModel.currentSheet.value)) { - appViewModel.hideSheet() + else -> true } - if (!handled) { - Logger.warn("Unhandled screen deeplink '$uri'", context = "ContentView") + + if (shouldNavigate) { + val request = Intent(Intent.ACTION_VIEW, uri) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + val handled = navController.handleDeepLink(request) + + if (shouldDismissSheetForScreenLink(handled, appViewModel.currentSheet.value)) { + appViewModel.hideSheet() + } + if (!handled) { + Logger.warn("Unhandled screen deeplink '$uri'", context = "ContentView") + } } + + // Consumed once, at the end: the effect is keyed on pendingScreenDeepLink, so clearing it + // early cancels this coroutine mid-prepareSpendingHwSign. + appViewModel.consumeScreenDeepLink() } LaunchedEffect(appViewModel) { @@ -1004,13 +1028,15 @@ private fun RootNavHost( viewModel = transferViewModel, isOffline = connectivityState != ConnectivityState.CONNECTED, onBackClick = { navController.popBackStack() }, - onQuoteReady = { navController.navigateTo(Routes.SpendingHwSign(walletId)) }, + onQuoteReady = { + val amountSats = transferViewModel.spendingUiState.value.clientBalanceSat.toLong() + navController.navigateTo(Routes.SpendingHwSign(walletId, amountSats)) + }, ) } - composableWithDefaultTransitions { entry -> - val walletId = entry.toRoute().walletId + deepLinkableComposable { entry -> SpendingHwSignScreen( - walletId = walletId, + walletId = entry.toRoute().walletId, viewModel = transferViewModel, onBackClick = { navController.popBackStack() }, onCloseClick = { navController.navigateToHome() }, @@ -2335,7 +2361,7 @@ sealed interface Routes { data class SpendingAmountHw(val walletId: String) : Routes.DeepLinkable @Serializable - data class SpendingHwSign(val walletId: String) : Routes.InternalOnly + data class SpendingHwSign(val walletId: String, val amountSats: Long) : Routes.DeepLinkable @Serializable data object SpendingHwSigned : Routes.InternalOnly diff --git a/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt b/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt index e727b0f2df..560850fe3d 100644 --- a/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt +++ b/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt @@ -38,6 +38,33 @@ object ScreenDeepLinks { fun isScreenDeepLink(uri: Uri): Boolean = uri.scheme?.lowercase() == SCHEME && uri.host?.lowercase() == HOST + /** + * Parses `bitkit://screen/spending-hw-sign/{walletId}/{amountSats}`. + * + * Returns null when the URI is not for that screen at all, so the caller can fall through to the + * ordinary nav handling, and [SpendingHwSignLink.Malformed] when it is but carries unusable + * arguments - those must be refused rather than navigated to, or the sign screen opens without a + * quote and immediately bounces the user home. + * + * Gated on [isEnabled] so a release build cannot reach live transfer state through a dev-only URI + * even if a caller forgets to check [shouldQueue] first. + */ + fun spendingHwSignLink(uri: Uri): SpendingHwSignLink? { + if (!isEnabled || !isScreenDeepLink(uri)) return null + val segments = uri.pathSegments.orEmpty() + val screenId = kebabId(Routes.SpendingHwSign::class) + if (segments.isEmpty() || screenId == null || !segments[0].equals(screenId, ignoreCase = true)) { + return null + } + if (segments.size != 3) return SpendingHwSignLink.Malformed + + val walletId = segments[1] + val amountSats = segments[2].toLongOrNull() + if (walletId.isBlank() || amountSats == null || amountSats <= 0) return SpendingHwSignLink.Malformed + + return SpendingHwSignLink.Valid(walletId = walletId, amountSats = amountSats) + } + fun detachScreenUri(intent: Intent): Boolean { val uri = intent.data ?: return false if (!isScreenDeepLink(uri)) return false @@ -46,3 +73,9 @@ object ScreenDeepLinks { return true } } + +sealed interface SpendingHwSignLink { + data class Valid(val walletId: String, val amountSats: Long) : SpendingHwSignLink + + data object Malformed : SpendingHwSignLink +} diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 06dd7397c6..5bc8fd40c0 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -129,6 +129,10 @@ class TransferViewModel @Inject constructor( fun onConfirmAmount(satsAmount: Long) { if (confirmPayJob?.isActive == true || hwTransferSignJob?.isActive == true) return + viewModelScope.launch { quoteSpendingAmount(satsAmount) } + } + + private suspend fun quoteSpendingAmount(satsAmount: Long): Boolean { val values = blocktankRepo.calculateLiquidityOptions(satsAmount.toULong()).getOrNull() if (values == null || values.maxLspBalanceSat == 0uL) { setTransferEffect( @@ -139,46 +143,45 @@ class TransferViewModel @Inject constructor( ), ) ) - return + return false } val lspBalance = maxOf(values.defaultLspBalanceSat, values.minLspBalanceSat) - viewModelScope.launch { - _spendingUiState.update { it.copy(isLoading = true) } + _spendingUiState.update { it.copy(isLoading = true) } - withTimeoutOrNull(1.minutes) { - isNodeRunning.first { it } - } + withTimeoutOrNull(1.minutes) { + isNodeRunning.first { it } + } - 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 - } + val feeSat = estimateSpendingFee( + clientBalanceSat = satsAmount.toULong(), + lspBalanceSat = lspBalance, + ).getOrElse { e -> + setTransferEffect(TransferEffect.ToastException(e)) + delay(1.seconds) + _spendingUiState.update { it.copy(isLoading = false) } + return false + } - if (!canFundOrder(feeSat)) { - Logger.info("Rejected spending amount '$satsAmount' over funding budget", context = TAG) - setTransferEffect( - TransferEffect.ToastError( - title = context.getString(R.string.lightning__spending_amount__error_balance__title), - description = context.getString( - R.string.lightning__spending_amount__error_balance__description - ), - ) + if (!canFundOrder(feeSat)) { + Logger.info("Rejected spending amount '$satsAmount' over funding budget", context = TAG) + setTransferEffect( + TransferEffect.ToastError( + title = context.getString(R.string.lightning__spending_amount__error_balance__title), + description = context.getString( + R.string.lightning__spending_amount__error_balance__description + ), ) - _spendingUiState.update { it.copy(isLoading = false) } - return@launch - } - - onEstimateReady(satsAmount.toULong(), lspBalance, feeSat) - delay(1.seconds) + ) _spendingUiState.update { it.copy(isLoading = false) } + return false } + + val quoted = onEstimateReady(satsAmount.toULong(), lspBalance, feeSat) + delay(1.seconds) + _spendingUiState.update { it.copy(isLoading = false) } + return quoted } private suspend fun estimateSpendingFee( @@ -644,9 +647,9 @@ class TransferViewModel @Inject constructor( } } - private suspend fun onEstimateReady(clientBalanceSat: ULong, lspBalanceSat: ULong, feeSat: ULong) { + private suspend fun onEstimateReady(clientBalanceSat: ULong, lspBalanceSat: ULong, feeSat: ULong): Boolean { settingsStore.update { it.copy(lightningSetupStep = 0) } - if (confirmPayJob?.isActive == true || hwTransferSignJob?.isActive == true) return + if (confirmPayJob?.isActive == true || hwTransferSignJob?.isActive == true) return false pendingHwFundingBroadcast = null hwFeeEstimateJob?.cancel() hwFeeEstimateJob = null @@ -663,6 +666,7 @@ class TransferViewModel @Inject constructor( ) } setTransferEffect(TransferEffect.OnQuoteReady) + return true } private fun updateAvailableAmount() { @@ -975,31 +979,66 @@ class TransferViewModel @Inject constructor( // region Hardware Wallet fun updateHwLimits(walletId: String) { - viewModelScope.launch { - _spendingUiState.update { it.copy(isLoading = true) } + viewModelScope.launch { loadHwLimits(walletId) } + } - val account = hwWalletRepo.getFundingAccount(walletId).getOrElse { - Logger.error("Failed to load hardware funding account", it, context = TAG) - _spendingUiState.update { s -> s.copy(isLoading = false, maxAllowedToSend = 0, balanceAfterFee = 0) } - setTransferEffect(TransferEffect.ToastException(it)) - return@launch - } + private suspend fun loadHwLimits(walletId: String) { + _spendingUiState.update { it.copy(isLoading = true) } - awaitNodeRunning() - updateTransferValues(0uL) + val account = hwWalletRepo.getFundingAccount(walletId).getOrElse { + Logger.error("Failed to load hardware funding account", it, context = TAG) + _spendingUiState.update { s -> s.copy(isLoading = false, maxAllowedToSend = 0, balanceAfterFee = 0) } + setTransferEffect(TransferEffect.ToastException(it)) + return + } - val availableAmount = account.balanceSats.safe() - hwFundingFeeReserve(account.balanceSats).safe() - _spendingUiState.update { it.copy(fundingBudgetSats = availableAmount, hwFundingWalletId = walletId) } + awaitNodeRunning() + updateTransferValues(0uL) - val initialLspFees = estimateInitialLspFees(availableAmount) - if (initialLspFees == null) { - _spendingUiState.update { it.copy(isLoading = false) } - return@launch - } + val availableAmount = account.balanceSats.safe() - hwFundingFeeReserve(account.balanceSats).safe() + _spendingUiState.update { it.copy(fundingBudgetSats = availableAmount, hwFundingWalletId = walletId) } - val balanceAfterLspFee = availableAmount.safe() - initialLspFees.safe() - estimateFinalMaxSendAmount(availableAmount, balanceAfterLspFee) + val initialLspFees = estimateInitialLspFees(availableAmount) + if (initialLspFees == null) { + _spendingUiState.update { it.copy(isLoading = false) } + return } + + val balanceAfterLspFee = availableAmount.safe() - initialLspFees.safe() + estimateFinalMaxSendAmount(availableAmount, balanceAfterLspFee) + } + + /** + * Admits a `spending-hw-sign` deep link by producing the same quote the amount screen does, so the + * sign screen opens on live state instead of a route argument. Returns false when the link is + * refused, having logged why. Dev-mode only, gated by ScreenDeepLinks.isEnabled. + */ + suspend fun prepareSpendingHwSign(walletId: String, amountSats: Long): Boolean { + if (walletId.isBlank() || amountSats <= 0) return false + + if (hwWalletRepo.wallets.value.none { it.id == walletId }) { + Logger.warn("Refused spending hw sign deeplink, unknown wallet '$walletId'", context = TAG) + return false + } + + // An external link must never discard a signed-but-unbroadcast funding tx, or cancel a sign + // that is already running - the user would have to re-approve the spend on the device. + val isTransferInFlight = confirmPayJob?.isActive == true || + hwTransferSignJob?.isActive == true || + pendingHwFundingBroadcast != null + if (isTransferInFlight) { + Logger.warn("Refused spending hw sign deeplink, transfer in flight for '$walletId'", context = TAG) + return false + } + + loadHwLimits(walletId) + + if (!quoteSpendingAmount(amountSats)) { + Logger.warn("Refused spending hw sign deeplink, no quote for '$amountSats' sats", context = TAG) + return false + } + + return true } /** Pays for the order by composing and signing the funding send on the Trezor, then watches it. */ diff --git a/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt b/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt index 03803aa528..5a3770054b 100644 --- a/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt +++ b/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt @@ -45,6 +45,58 @@ class ScreenDeepLinksTest : BaseUnitTest() { assertEquals("bitkit://screen/activity-assign-contact/{id}", links.single().uriPattern) } + @Test + fun `SpendingHwSign required arguments are wallet and amount path segments`() { + if (!ScreenDeepLinks.isEnabled) return + val links = ScreenDeepLinks.linksFor(Routes.SpendingHwSign::class) + + assertEquals( + "bitkit://screen/spending-hw-sign/{walletId}/{amountSats}", + links.single().uriPattern, + ) + } + + @Test + fun `spendingHwSignLink reads the wallet id and amount from the path`() { + if (!ScreenDeepLinks.isEnabled) return + val screenId = ScreenDeepLinks.kebabId(Routes.SpendingHwSign::class) + val uri = Uri.parse("bitkit://screen/$screenId/hardware-wallet/100000") + + val link = ScreenDeepLinks.spendingHwSignLink(uri) + + assertEquals(SpendingHwSignLink.Valid(walletId = "hardware-wallet", amountSats = 100_000L), link) + } + + @Test + fun `spendingHwSignLink returns null when the uri is for another screen`() { + if (!ScreenDeepLinks.isEnabled) return + + assertNull(ScreenDeepLinks.spendingHwSignLink(Uri.parse("bitkit://screen/settings"))) + } + + @Test + fun `spendingHwSignLink reports malformed arguments rather than falling through to nav`() { + // Falling through would navigate to a sign screen with no quote, which bounces the user home. + if (!ScreenDeepLinks.isEnabled) return + + for (uri in listOf( + "bitkit://screen/spending-hw-sign/hardware-wallet", + "bitkit://screen/spending-hw-sign/hw/abc", + "bitkit://screen/spending-hw-sign/hw/0", + "bitkit://screen/spending-hw-sign/hw/-1", + )) { + assertEquals(SpendingHwSignLink.Malformed, ScreenDeepLinks.spendingHwSignLink(Uri.parse(uri)), uri) + } + } + + @Test + fun `spendingHwSignLink returns null while screen deep links are disabled`() { + if (ScreenDeepLinks.isEnabled) return + val uri = Uri.parse("bitkit://screen/spending-hw-sign/hardware-wallet/100000") + + assertNull(ScreenDeepLinks.spendingHwSignLink(uri)) + } + @Test fun `a route with both argument kinds keeps the required one in the path`() { if (!ScreenDeepLinks.isEnabled) return diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 606d47768d..c7875fbdfc 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -642,6 +642,99 @@ class TransferViewModelTest : BaseUnitTest() { verify(blocktankRepo, never()).createOrder(any(), any(), any()) } + @Test + fun `prepareSpendingHwSign quotes the linked amount when the wallet is known`() = test { + val amount = 100_000uL + stubHwFundingAccount() + whenever(hwWalletRepo.wallets) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) + + val prepared = sut.prepareSpendingHwSign(HARDWARE_WALLET_ID, amount.toLong()) + advanceUntilIdle() + + assertTrue(prepared) + assertEquals(amount, sut.spendingUiState.value.clientBalanceSat) + assertTrue(sut.spendingUiState.value.feeSat > 0uL) + // the link opens a quote, never an order - nothing is bought before the user swipes + verify(blocktankRepo, never()).createOrder(any(), any(), any()) + } + + @Test + fun `prepareSpendingHwSign refuses an unknown wallet`() = test { + stubHwFundingAccount() + whenever(hwWalletRepo.wallets).thenReturn(MutableStateFlow(persistentListOf())) + + val prepared = sut.prepareSpendingHwSign(HARDWARE_WALLET_ID, 100_000L) + advanceUntilIdle() + + assertFalse(prepared) + assertEquals(0uL, sut.spendingUiState.value.feeSat) + verify(hwWalletRepo, never()).getFundingAccount(any(), any()) + } + + @Test + fun `prepareSpendingHwSign refuses a non positive amount`() = test { + stubHwFundingAccount() + whenever(hwWalletRepo.wallets) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) + + val prepared = sut.prepareSpendingHwSign(HARDWARE_WALLET_ID, 0L) + advanceUntilIdle() + + assertFalse(prepared) + assertEquals(0uL, sut.spendingUiState.value.feeSat) + verify(hwWalletRepo, never()).getFundingAccount(any(), any()) + } + + @Test + fun `prepareSpendingHwSign refuses an amount the device balance cannot fund`() = test { + stubHwFundingAccount() + whenever(hwWalletRepo.wallets) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) + + val prepared = sut.prepareSpendingHwSign(HARDWARE_WALLET_ID, (ON_CHAIN_BALANCE * 10u).toLong()) + advanceUntilIdle() + + assertFalse(prepared) + assertEquals(0uL, sut.spendingUiState.value.feeSat) + } + + @Test + fun `prepareSpendingHwSign keeps a pending hardware broadcast`() = test { + // Regression: an external link must not discard a signed-but-unbroadcast funding tx, or the + // user has to approve the whole spend on the device again. + val order = previewBtOrder() + val funding = HwFundingTransaction( + psbt = "psbt", + miningFeeSats = MINING_FEE, + feeRate = FEE_RATE.toFloat(), + totalSpent = order.feeSat + MINING_FEE, + satsPerVByte = FEE_RATE, + ) + val signed = signedFunding(funding) + whenever(hwWalletRepo.wallets) + .thenReturn(MutableStateFlow(persistentListOf(hwWallet(HARDWARE_WALLET_ID, connected = true)))) + whenever(hwWalletRepo.ensureConnected(HARDWARE_WALLET_ID)) + .thenReturn(Result.success(mock())) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(FEE_RATE)) + whenever(hwWalletRepo.composeFundingTransaction(any(), any(), any(), any())).thenReturn(Result.success(funding)) + whenever(hwWalletRepo.signFunding(any(), any())).thenReturn(Result.success(signed)) + whenever(hwWalletRepo.broadcastFunding(signed)) + .thenReturn(Result.failure(AppError(BroadcastException.ElectrumException("DNS lookup failed")))) + + quoteOrder(order) + sut.onTransferToSpendingHwConfirm(HARDWARE_WALLET_ID) + advanceUntilIdle() + assertTrue(sut.spendingUiState.value.hasPendingHwBroadcast) + + val prepared = sut.prepareSpendingHwSign(HARDWARE_WALLET_ID, order.clientBalanceSat.toLong() * 2) + advanceUntilIdle() + + assertFalse(prepared) + assertTrue(sut.spendingUiState.value.hasPendingHwBroadcast) + assertEquals(order.clientBalanceSat, sut.spendingUiState.value.clientBalanceSat) + } + @Test fun `updateHwFundingFeeEstimate ignores superseded estimate`() = test { val raisedCapacity = LSP_BALANCE * 2u @@ -2733,6 +2826,25 @@ class TransferViewModelTest : BaseUnitTest() { deviceIds = persistentSetOf("dev1"), ) + /** Stubs the device account, fee rate and LSP quotes a hardware spending quote needs. */ + private suspend fun stubHwFundingAccount() { + stubSpendableBalances(0uL) // empty on-chain wallet: the budget must come from the device + blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) + whenever(hwWalletRepo.getFundingAccount(HARDWARE_WALLET_ID)).thenReturn( + Result.success( + HwFundingAccount.Trezor( + xpub = XPUB, + addressType = HwFundingAddressType.NATIVE_SEGWIT, + 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.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) + } + private fun liquidityOptions(maxClientBalanceSat: ULong) = ChannelLiquidityOptions( defaultLspBalanceSat = LSP_BALANCE, minLspBalanceSat = LSP_BALANCE, diff --git a/changelog.d/next/1176.added.md b/changelog.d/next/1176.added.md new file mode 100644 index 0000000000..729fdcc33b --- /dev/null +++ b/changelog.d/next/1176.added.md @@ -0,0 +1 @@ +Dev-mode deep links can open the hardware-wallet transfer sign screen for a wallet and spending amount. diff --git a/journeys/deeplinks/spending-hw-sign-deeplink.xml b/journeys/deeplinks/spending-hw-sign-deeplink.xml new file mode 100644 index 0000000000..2ce77a17ff --- /dev/null +++ b/journeys/deeplinks/spending-hw-sign-deeplink.xml @@ -0,0 +1,23 @@ + + Precondition: onboarded debug build, dev mode on, a paired hardware wallet with an on-chain balance, and a reachable LSP. The link carries a spending amount, not an order: the order is only bought when the user swipes on the sign screen. + + Open Settings ▸ Hardware Wallets and note the paired wallet's id from the app log line "Loaded hardware wallet" + Run `adb shell am start -a android.intent.action.VIEW -d "bitkit://screen/spending-hw-sign/<walletId>/100000" to.bitkit.dev` + Verify that the Transfer sign screen is visible with a non-zero network fee, and that the amount shown matches 100 000 sats + Verify that logcat contains no "Refused spending hw sign deeplink" line for that URI + Verify that no Blocktank order was created yet — the app log has no "Buying channel with lspBalanceSat" line since the link was opened + Press the device back button and verify the wallet overview is visible + Run `adb shell am start -a android.intent.action.VIEW -d "bitkit://screen/spending-hw-sign/not-a-wallet/100000" to.bitkit.dev` + Verify that the screen does not change and logcat contains "Refused spending hw sign deeplink, unknown wallet 'not-a-wallet'" + Verify that logcat contains no "Unhandled screen deeplink" line for that URI, since the link was recognized and deliberately refused + Run `adb shell am start -a android.intent.action.VIEW -d "bitkit://screen/spending-hw-sign/<walletId>/0" to.bitkit.dev` + Verify that the screen does not change, since a non-positive amount is not a valid link + Run `adb shell am start -a android.intent.action.VIEW -d "bitkit://screen/spending-hw-sign/<walletId>/999999999" to.bitkit.dev` + Verify that the screen does not change and logcat contains "Refused spending hw sign deeplink, no quote for '999999999' sats" + Open Settings ▸ Support and tap the version row five times to turn dev mode off + Run `adb shell am force-stop to.bitkit.dev` + Run `adb shell am start -a android.intent.action.VIEW -d "bitkit://screen/spending-hw-sign/<walletId>/100000" to.bitkit.dev` + Verify that the wallet overview is visible and the sign screen is not, on a cold start with dev mode off + Open Settings ▸ Support and tap the version row five times to turn dev mode back on + +