From 2a2f798ef5bf75134d831c4fed2452ac61440c29 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 3 Nov 2026 10:31:10 -0300 Subject: [PATCH 01/23] fix: open confirm for scanned onchain requests with an amount A BIP21 request with an amount now opens Confirm instead of Amount when scanned, pasted or opened as a link. Confirm runs the saved automatic coin selection when it is reached without Amount and opens Amount if selection fails. In-sheet scans that open Confirm directly drop the amount and coin choices left by an earlier recipient. Zero-amount requests and contact payments still open Amount. Co-Authored-By: Claude Opus 5.5 --- Bitkit/Managers/ScannerManager.swift | 3 + .../Utilities/PaymentNavigationHelper.swift | 31 +++++---- Bitkit/ViewModels/WalletViewModel.swift | 8 +++ .../Wallets/Send/SendConfirmationView.swift | 28 +++++++++ .../PaymentNavigationHelperTests.swift | 63 +++++++++++++++++++ changelog.d/next/1198.fixed.md | 1 + journeys/boost/rbf-own-send.xml | 4 +- journeys/send/onchain-accepted-result.xml | 4 +- .../send/scan-onchain-amount-to-confirm.xml | 26 ++++++++ .../send/scan-onchain-no-amount-to-amount.xml | 21 +++++++ 10 files changed, 172 insertions(+), 17 deletions(-) create mode 100644 changelog.d/next/1198.fixed.md create mode 100644 journeys/send/scan-onchain-amount-to-confirm.xml create mode 100644 journeys/send/scan-onchain-no-amount-to-amount.xml diff --git a/Bitkit/Managers/ScannerManager.swift b/Bitkit/Managers/ScannerManager.swift index b8444d1b2..6072b640f 100644 --- a/Bitkit/Managers/ScannerManager.swift +++ b/Bitkit/Managers/ScannerManager.swift @@ -231,6 +231,9 @@ class ScannerManager: ObservableObject { currency: currency, settings: settings ) + if route == .confirm, app.scannedLightningInvoice == nil, let invoice = app.scannedOnchainInvoice { + wallet?.prepareFixedAmountOnchainSend(amountSats: invoice.amountSatoshis) + } completion(route) } catch { diff --git a/Bitkit/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index 454e4b7a7..b1d7cfaa7 100644 --- a/Bitkit/Utilities/PaymentNavigationHelper.swift +++ b/Bitkit/Utilities/PaymentNavigationHelper.swift @@ -92,19 +92,17 @@ struct PaymentNavigationHelper { if amount > 0 && shouldUseQuickpay { sheetViewModel.showSheet(.send, data: SendConfig(view: .quickpay)) + } else if amount == 0 { + sheetViewModel.showSheet(.send, data: SendConfig(view: .amount)) } else { - if amount == 0 { - sheetViewModel.showSheet(.send, data: SendConfig(view: .amount)) - } else { - sheetViewModel.showSheet(.send, data: SendConfig(view: .confirm)) - } + sheetViewModel.showSheet(.send, data: SendConfig(view: .confirm)) } return } // Handle onchain invoice if app.scannedOnchainInvoice != nil { - sheetViewModel.showSheet(.send, data: SendConfig(view: .amount)) + sheetViewModel.showSheet(.send, data: SendConfig(view: onchainInvoiceRoute(app: app))) return } } @@ -155,24 +153,31 @@ struct PaymentNavigationHelper { if amount > 0 && shouldUseQuickpay { return .quickpay + } else if amount == 0 { + return .amount } else { - if amount == 0 { - return .amount - } else { - return .confirm - } + return .confirm } } // Handle onchain invoice - if let _ = app.scannedOnchainInvoice { - return .amount + if app.scannedOnchainInvoice != nil { + return onchainInvoiceRoute(app: app) } // No valid invoice data return nil } + /// A BIP21 request with an amount skips Amount; the balance was already checked when it was scanned. + /// Contact payments keep Amount because it records the reviewed amount for the payment. + static func onchainInvoiceRoute(app: AppViewModel) -> SendRoute { + guard let invoice = app.scannedOnchainInvoice, invoice.amountSatoshis > 0, app.contactPaymentContext == nil else { + return .amount + } + return .confirm + } + static func confirmRouteAfterQuickPayCap(app: AppViewModel) -> SendRoute { if let lnurlPayData = app.lnurlPayData { return lnurlPayData.isFixedAmount ? .lnurlPayConfirm : .lnurlPayAmount diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 940e92d29..dbdd4d44b 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -1793,6 +1793,14 @@ class WalletViewModel: ObservableObject { return formatter.string(from: NSNumber(value: btcAmount)) ?? "0" } + /// An in-sheet scan can open Confirm directly, so amount and coin choices from an earlier recipient must not carry over. + func prepareFixedAmountOnchainSend(amountSats: UInt64) { + sendAmountSats = amountSats + isMaxAmountSend = false + selectedUtxos = nil + availableUtxos = [] + } + func resetSendState(speed: TransactionSpeed) { sendAmountSats = nil selectedFeeRateSatsPerVByte = nil diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 9e5f470f3..c7b4e5472 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -447,6 +447,7 @@ struct SendConfirmationView: View { if prepareRequestCoinsIfNeeded() { return } + await selectCoinsForDirectOnchainSendIfNeeded() await calculateTransactionFee() await calculateRoutingFee() guard !Task.isCancelled else { return } @@ -465,6 +466,7 @@ struct SendConfirmationView: View { .onChange(of: wallet.selectedFeeRateSatsPerVByte) { guard preparingRequest == nil, !prepareRequestCoinsIfNeeded() else { return } Task { + await selectCoinsForDirectOnchainSendIfNeeded() await calculateTransactionFee() } } @@ -1949,6 +1951,32 @@ struct SendConfirmationView: View { await calculateTransactionFee() } + /// A BIP21 request with an amount opens here without Amount, which normally runs automatic coin selection. + /// Manual selection is left to the swipe, which opens UTXO selection when no coins are chosen. + @MainActor + private func selectCoinsForDirectOnchainSendIfNeeded() async { + guard preparingRequest == nil, walletSwitchContext == nil, walletSwitchRun == nil, + app.contactPaymentContext == nil, app.scannedLightningInvoice == nil, + app.selectedWalletToPayFrom == .onchain, !hwSend.isActive, + settings.coinSelectionMethod == .autopilot, wallet.selectedUtxos == nil, + wallet.selectedFeeRateSatsPerVByte != nil, + let context = confirmationContext + else { return } + + func isCurrentSend() -> Bool { + !Task.isCancelled && confirmationContext == context + } + + do { + try await wallet.setUtxoSelection(coinSelectionAlgorythm: settings.coinSelectionAlgorithm, isCurrentSend: isCurrentSend) + } catch { + guard isCurrentSend(), !(error is CancellationError) else { return } + Logger.error("Failed to select UTXOs for on-chain request: \(error)", context: "SendConfirmationView") + app.toast(type: .error, title: t("other__try_again"), description: error.localizedDescription) + navigateToAmount() + } + } + /// Recalculates until the newest calculation is the one that finished, so a concurrent one cannot leave the fee unset. @MainActor private func settleTransactionFee() async { diff --git a/BitkitTests/PaymentNavigationHelperTests.swift b/BitkitTests/PaymentNavigationHelperTests.swift index dd51c00d0..5392901d7 100644 --- a/BitkitTests/PaymentNavigationHelperTests.swift +++ b/BitkitTests/PaymentNavigationHelperTests.swift @@ -320,6 +320,45 @@ final class PaymentNavigationHelperTests: XCTestCase { } } + func testOnchainInvoiceWithAmountOpensConfirm() { + let app = appWithOnchainInvoice(amountSatoshis: 10000) + + XCTAssertEqual(sendRoute(for: app), .confirm) + XCTAssertEqual(paymentSheetRoute(for: app), .confirm) + } + + func testOnchainInvoiceWithoutAmountOpensAmount() { + let app = appWithOnchainInvoice(amountSatoshis: 0) + + XCTAssertEqual(sendRoute(for: app), .amount) + XCTAssertEqual(paymentSheetRoute(for: app), .amount) + } + + func testOnchainInvoiceWithAmountSkipsQuickpay() { + let app = appWithOnchainInvoice(amountSatoshis: 1000) + + XCTAssertEqual(sendRoute(for: app), .confirm) + } + + func testContactOnchainInvoiceWithAmountKeepsAmount() { + let app = appWithOnchainInvoice(amountSatoshis: 10000) + app.contactPaymentContext = ContactPaymentContext(publicKey: "pubkycontact", endpoints: []) + + XCTAssertEqual(contactPaymentRoute(for: app), .amount) + } + + func testIncomingRequestWithOnchainAmountOpensConfirm() throws { + let app = appWithOnchainInvoice(amountSatoshis: 1000) + app.contactPaymentContext = try ContactPaymentContext( + publicKey: "pubkycontact", + incomingPaymentRequest: incomingPaymentRequest( + endpointIdentifier: PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue + ) + ) + + XCTAssertEqual(contactPaymentRoute(for: app), .confirm) + } + func testContactWithUsdtLocksOnlyFixedLightningFunding() { let app = appWithInvoice(paymentHash: "fixed", amountSatoshis: 10000) app.contactPaymentContext = ContactPaymentContext(publicKey: "pubkycontact", endpoints: [ @@ -383,6 +422,30 @@ final class PaymentNavigationHelperTests: XCTestCase { ) } + private func paymentSheetRoute(for app: AppViewModel) -> SendRoute? { + let sheets = SheetViewModel() + PaymentNavigationHelper.openPaymentSheet( + app: app, + currency: CurrencyViewModel(currencyService: OfflineCurrencyService()), + settings: settings, + sheetViewModel: sheets, + spendStore: spendStore + ) + return (sheets.activeSheetConfiguration?.data as? SendConfig)?.initialRoute + } + + private func appWithOnchainInvoice(amountSatoshis: UInt64) -> AppViewModel { + let app = AppViewModel() + app.scannedOnchainInvoice = OnChainInvoice( + address: "bcrt1qexample", + amountSatoshis: amountSatoshis, + label: nil, + message: nil, + params: nil + ) + return app + } + private var appWithEligibleInvoice: AppViewModel { appWithInvoice(paymentHash: "") } diff --git a/changelog.d/next/1198.fixed.md b/changelog.d/next/1198.fixed.md new file mode 100644 index 000000000..34aa74c06 --- /dev/null +++ b/changelog.d/next/1198.fixed.md @@ -0,0 +1 @@ +Scanning or pasting a Bitcoin payment request that includes an amount, or receiving a fixed-price Payment Request that can only be paid in Bitcoin, now opens the review screen directly instead of the amount screen. diff --git a/journeys/boost/rbf-own-send.xml b/journeys/boost/rbf-own-send.xml index eb92733ca..db5d838c0 100644 --- a/journeys/boost/rbf-own-send.xml +++ b/journeys/boost/rbf-own-send.xml @@ -7,8 +7,8 @@ the configured regtest backend's block-mining helper. - Hand the app the address as a URI with xcrun simctl openurl <UDID> "bitcoin:ADDRESS?amount=0.0001", which opens the amount screen at 10,000 sats - Verify the amount screen shows 10,000 sats, tap Continue (id "ContinueAmount") and verify the review screen (id "SendConfirm") is visible + Hand the app the address as a URI with xcrun simctl openurl <UDID> "bitcoin:ADDRESS?amount=0.0001", which opens the review screen at 10,000 sats + Verify the review screen (id "SendConfirm") opens directly, without the amount screen, and shows 10,000 sats (id "ReviewAmount-primary") Swipe the send handle (id "GRAB") fully to the right and wait for the success screen (id "SendSuccess"), then tap Close Tap the newest outgoing activity entry (id "ActivityShort-0") and verify it shows 10,000 sats; record the fee from id "ActivityFee" Tap Transaction details (id "ActivityTxDetails"), record the TXID, then go back diff --git a/journeys/send/onchain-accepted-result.xml b/journeys/send/onchain-accepted-result.xml index 0cf24d518..08d259bf6 100644 --- a/journeys/send/onchain-accepted-result.xml +++ b/journeys/send/onchain-accepted-result.xml @@ -18,8 +18,8 @@ Hand the receiving test wallet address to the app with xcrun simctl openurl <simulator-id> "bitcoin:<test recipient address>?amount=0.00001" - Verify the Send Amount screen is visible with 1 000 sats and Savings selected - Tap Continue (id "ContinueAmount"), expand Show Details (id "SendConfirmToggleDetails") if collapsed, and verify the Confirm screen shows the receiving test wallet address (id "ReviewUri") + Verify the Confirm screen (id "SendConfirm") opens directly, without the Send Amount screen, with 1 000 sats (id "ReviewAmount-primary") + Expand Show Details (id "SendConfirmToggleDetails") if collapsed, and verify Savings is selected and the Confirm screen shows the receiving test wallet address (id "ReviewUri") Swipe the send handle (id "GRAB") fully to the right Wait for the Bitcoin Sent success screen (id "SendSuccess") and verify it shows the accepted payment amount Tap Details (id "Details"), then Explore (id "ActivityTxDetails"), and verify the outbound transaction shows a transaction ID (id "TXID"); record that exact ID without inferring a different transaction from address or amount diff --git a/journeys/send/scan-onchain-amount-to-confirm.xml b/journeys/send/scan-onchain-amount-to-confirm.xml new file mode 100644 index 000000000..f09c39be2 --- /dev/null +++ b/journeys/send/scan-onchain-amount-to-confirm.xml @@ -0,0 +1,26 @@ + + + Verifies that pasting or scanning an on-chain BIP21 request that carries an amount, with enough + Savings balance, opens the Confirm screen directly instead of the Amount screen, and that the send + completes. Also checks that the amount can still be edited from Confirm. + + Precondition: onboarded dev wallet on regtest with at least 50 000 confirmed sats in Savings, + a running synced node, Coin Selection set to Autopilot, and a regtest address the wallet does not + own. Start on the wallet home screen. + + iOS: the simulator has no camera, so set the clipboard with + xcrun simctl pbcopy <device> and use Paste Invoice inside Send. + + + Copy "bitcoin:<test recipient address>?amount=0.0001" to the simulator clipboard + Tap the Send button (id "Send"), and if a camera permission dialog appears, choose "Don't Allow" + Tap Paste Invoice (id "RecipientInvoice") + Verify the Confirm screen (id "SendConfirm") is visible without passing through the Send amount screen (id "SendAmount") + Verify the review amount (id "ReviewAmount-primary") shows 10 000 + Tap Show Details (id "SendConfirmToggleDetails") and verify the recipient address (id "ReviewUri") and a non-zero network fee are shown + Tap the review amount (id "ReviewAmount-primary") and verify the Send amount screen (id "SendAmount") opens with 10 000 sats + Tap Continue (id "ContinueAmount") and verify the Confirm screen (id "SendConfirm") is visible again with 10 000 and the recipient address (id "ReviewUri") after expanding Show Details (id "SendConfirmToggleDetails") if collapsed + Swipe the send handle (id "GRAB") fully to the right, then tap Yes, Send (id "DialogConfirm") if a confirmation dialog appears + Wait for the success screen (id "SendSuccess") and verify it shows 10 000 sats + + diff --git a/journeys/send/scan-onchain-no-amount-to-amount.xml b/journeys/send/scan-onchain-no-amount-to-amount.xml new file mode 100644 index 000000000..2e5127cb9 --- /dev/null +++ b/journeys/send/scan-onchain-no-amount-to-amount.xml @@ -0,0 +1,21 @@ + + + Verifies that pasting or scanning an on-chain BIP21 request without an amount still opens the + Send amount screen, so the user can choose the amount before Confirm. + + Precondition: onboarded dev wallet on regtest with confirmed Savings funds, a running synced node, + and a regtest address the wallet does not own. Start on the wallet home screen. + + iOS: the simulator has no camera, so set the clipboard with + xcrun simctl pbcopy <device> and use Paste Invoice inside Send. + + + Copy "bitcoin:<test recipient address>" to the simulator clipboard + Tap the Send button (id "Send"), and if a camera permission dialog appears, choose "Don't Allow" + Tap Paste Invoice (id "RecipientInvoice") + Verify the Send amount screen (id "SendAmount") is visible and the Confirm screen (id "SendConfirm") is not + Tap "1" (id "N1") and "0" (id "N0") four times, then tap Continue (id "ContinueAmount") + Verify the Confirm screen (id "SendConfirm") is visible and the review amount (id "ReviewAmount-primary") shows 10 000 + Close the sheet without sending + + From ab67cb43a7624af389f974df63805ebb582cfb53 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 3 Nov 2026 11:09:07 -0300 Subject: [PATCH 02/23] fix: keep swipe disabled while direct confirm prepares coins Co-Authored-By: Claude Opus 5.5 --- .../Wallets/Send/SendConfirmationView.swift | 16 ++++++++++++---- BitkitTests/SendConfirmationSwipeTests.swift | 10 ++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index c7b4e5472..d2c145f3b 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -42,6 +42,7 @@ struct SendConfirmationView: View { @State private var walletSwitchContext: ConfirmationContext? @State private var walletSwitchRun: WalletSwitchRun? @State private var manualCoinsRequestedContext: ConfirmationContext? + @State private var isPreparingDirectOnchainSend = false @State private var presentationID: UUID? private struct WalletSwitchRun { @@ -858,7 +859,7 @@ struct SendConfirmationView: View { private func submitPayment(isAutomatic: Bool = false) async throws { guard !isSubmittingPayment else { return } - guard !requiresAmountReview, preparingRequest == nil, !isRequestSetupPending, walletSwitchContext == nil, + guard !requiresAmountReview, preparingRequest == nil, !isRequestSetupPending, walletSwitchContext == nil, !isPreparingDirectOnchainSend, isAutomatic || !prepareRequestCoinsIfNeeded() else { throw CancellationError() } if isAutomatic, isFeeRateMissing { guard !Self.automaticPaymentAwaitsFeeRate(isFeeRateMissing: isFeeRateMissing, feeRateLoadFailed: wallet.feeRateLoadFailed) else { @@ -1146,9 +1147,11 @@ struct SendConfirmationView: View { isPreparingRequest: Bool = false, isSetupPending: Bool = false, isPreparingWallet: Bool = false, - isAmountReviewRequired: Bool = false + isAmountReviewRequired: Bool = false, + isPreparingDirectOnchainSend: Bool = false ) -> Bool { - isAmountReviewRequired || isPreparingRequest || isSetupPending || isPreparingWallet || isHardwareConfirmationUnavailable + isAmountReviewRequired || isPreparingRequest || isSetupPending || isPreparingWallet || isPreparingDirectOnchainSend + || isHardwareConfirmationUnavailable || isFeeRateMissing(walletType: walletType, isHardwarePayment: isHardwarePayment, feeRate: feeRate) } @@ -1169,7 +1172,8 @@ struct SendConfirmationView: View { isPreparingRequest: preparingRequest != nil, isSetupPending: isRequestSetupPending, isPreparingWallet: walletSwitchContext != nil, - isAmountReviewRequired: requiresAmountReview + isAmountReviewRequired: requiresAmountReview, + isPreparingDirectOnchainSend: isPreparingDirectOnchainSend ) } @@ -1953,6 +1957,7 @@ struct SendConfirmationView: View { /// A BIP21 request with an amount opens here without Amount, which normally runs automatic coin selection. /// Manual selection is left to the swipe, which opens UTXO selection when no coins are chosen. + /// The swipe stays disabled until the coins and the fee for them are known. @MainActor private func selectCoinsForDirectOnchainSendIfNeeded() async { guard preparingRequest == nil, walletSwitchContext == nil, walletSwitchRun == nil, @@ -1967,8 +1972,11 @@ struct SendConfirmationView: View { !Task.isCancelled && confirmationContext == context } + isPreparingDirectOnchainSend = true + defer { isPreparingDirectOnchainSend = false } do { try await wallet.setUtxoSelection(coinSelectionAlgorythm: settings.coinSelectionAlgorithm, isCurrentSend: isCurrentSend) + await calculateTransactionFee() } catch { guard isCurrentSend(), !(error is CancellationError) else { return } Logger.error("Failed to select UTXOs for on-chain request: \(error)", context: "SendConfirmationView") diff --git a/BitkitTests/SendConfirmationSwipeTests.swift b/BitkitTests/SendConfirmationSwipeTests.swift index f0ef49241..76be687b6 100644 --- a/BitkitTests/SendConfirmationSwipeTests.swift +++ b/BitkitTests/SendConfirmationSwipeTests.swift @@ -163,6 +163,16 @@ final class SendConfirmationSwipeTests: XCTestCase { )) } + func testDirectOnchainPreparationKeepsSwipeDisabledWithKnownFeeRate() { + XCTAssertTrue(SendConfirmationView.isSwipeDisabled( + walletType: .onchain, + isHardwarePayment: false, + isHardwareConfirmationUnavailable: false, + feeRate: 5, + isPreparingDirectOnchainSend: true + )) + } + func testPendingRequestPreparationKeepsSwipeDisabledAndLoadingWithKnownFees() { for (preparingRequest, setupPending) in [(true, false), (false, true)] { for walletType in [WalletType.lightning, .onchain] { From d6ffc48b7cfc62e4a4a2f9e4773cf9785d5df738 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 3 Nov 2026 11:21:30 -0300 Subject: [PATCH 03/23] fix: keep swipe disabled while the onchain fee is calculating Co-Authored-By: Claude Opus 5.5 --- .../Wallets/Send/SendConfirmationView.swift | 27 ++++++++++++++----- BitkitTests/SendConfirmationSwipeTests.swift | 4 +-- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index d2c145f3b..03be41bdd 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -43,6 +43,7 @@ struct SendConfirmationView: View { @State private var walletSwitchRun: WalletSwitchRun? @State private var manualCoinsRequestedContext: ConfirmationContext? @State private var isPreparingDirectOnchainSend = false + @State private var isCalculatingOnchainFee = false @State private var presentationID: UUID? private struct WalletSwitchRun { @@ -859,8 +860,12 @@ struct SendConfirmationView: View { private func submitPayment(isAutomatic: Bool = false) async throws { guard !isSubmittingPayment else { return } - guard !requiresAmountReview, preparingRequest == nil, !isRequestSetupPending, walletSwitchContext == nil, !isPreparingDirectOnchainSend, + guard !requiresAmountReview, preparingRequest == nil, !isRequestSetupPending, walletSwitchContext == nil, isAutomatic || !prepareRequestCoinsIfNeeded() else { throw CancellationError() } + // An automatic payment settles the fee itself below. + if !isAutomatic, isPreparingOnchainSend { + throw CancellationError() + } if isAutomatic, isFeeRateMissing { guard !Self.automaticPaymentAwaitsFeeRate(isFeeRateMissing: isFeeRateMissing, feeRateLoadFailed: wallet.feeRateLoadFailed) else { // The rate is still loading; the automatic start runs again when send setup finishes. @@ -1148,9 +1153,9 @@ struct SendConfirmationView: View { isSetupPending: Bool = false, isPreparingWallet: Bool = false, isAmountReviewRequired: Bool = false, - isPreparingDirectOnchainSend: Bool = false + isPreparingOnchainSend: Bool = false ) -> Bool { - isAmountReviewRequired || isPreparingRequest || isSetupPending || isPreparingWallet || isPreparingDirectOnchainSend + isAmountReviewRequired || isPreparingRequest || isSetupPending || isPreparingWallet || isPreparingOnchainSend || isHardwareConfirmationUnavailable || isFeeRateMissing(walletType: walletType, isHardwarePayment: isHardwarePayment, feeRate: feeRate) } @@ -1173,10 +1178,14 @@ struct SendConfirmationView: View { isSetupPending: isRequestSetupPending, isPreparingWallet: walletSwitchContext != nil, isAmountReviewRequired: requiresAmountReview, - isPreparingDirectOnchainSend: isPreparingDirectOnchainSend + isPreparingOnchainSend: isPreparingOnchainSend ) } + private var isPreparingOnchainSend: Bool { + isPreparingDirectOnchainSend || isCalculatingOnchainFee + } + private func requiresManualConfirmation(isAutomatic: Bool) -> Bool { Self.requiresManualConfirmation( isAutomatic: isAutomatic, @@ -1957,7 +1966,7 @@ struct SendConfirmationView: View { /// A BIP21 request with an amount opens here without Amount, which normally runs automatic coin selection. /// Manual selection is left to the swipe, which opens UTXO selection when no coins are chosen. - /// The swipe stays disabled until the coins and the fee for them are known. + /// The swipe stays disabled until the coins are chosen; the fee calculation that follows keeps it disabled. @MainActor private func selectCoinsForDirectOnchainSendIfNeeded() async { guard preparingRequest == nil, walletSwitchContext == nil, walletSwitchRun == nil, @@ -1976,7 +1985,6 @@ struct SendConfirmationView: View { defer { isPreparingDirectOnchainSend = false } do { try await wallet.setUtxoSelection(coinSelectionAlgorythm: settings.coinSelectionAlgorithm, isCurrentSend: isCurrentSend) - await calculateTransactionFee() } catch { guard isCurrentSend(), !(error is CancellationError) else { return } Logger.error("Failed to select UTXOs for on-chain request: \(error)", context: "SendConfirmationView") @@ -2071,6 +2079,13 @@ struct SendConfirmationView: View { return } + // A swipe before the fee is known would skip the fee warnings and record a zero fee. + isCalculatingOnchainFee = true + defer { + if feeCalculationId == requestId { + isCalculatingOnchainFee = false + } + } let useMaxAmount = await shouldUseMaxOnchainSend( address: address, amountSats: amountSats, feeRate: feeRate, isCurrentSend: isCurrentSend ) diff --git a/BitkitTests/SendConfirmationSwipeTests.swift b/BitkitTests/SendConfirmationSwipeTests.swift index 76be687b6..904e844f3 100644 --- a/BitkitTests/SendConfirmationSwipeTests.swift +++ b/BitkitTests/SendConfirmationSwipeTests.swift @@ -163,13 +163,13 @@ final class SendConfirmationSwipeTests: XCTestCase { )) } - func testDirectOnchainPreparationKeepsSwipeDisabledWithKnownFeeRate() { + func testOnchainPreparationKeepsSwipeDisabledWithKnownFeeRate() { XCTAssertTrue(SendConfirmationView.isSwipeDisabled( walletType: .onchain, isHardwarePayment: false, isHardwareConfirmationUnavailable: false, feeRate: 5, - isPreparingDirectOnchainSend: true + isPreparingOnchainSend: true )) } From 32d870bb6bcb4dbd79a81876a3ac6e2751a010f4 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 05:54:26 -0300 Subject: [PATCH 04/23] fix: let only the newest fee calculation hold the swipe Co-Authored-By: Claude Opus 5.5 --- .../Wallets/Send/SendConfirmationView.swift | 16 +++++++++++----- BitkitTests/SendConfirmationSwipeTests.swift | 7 +++++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 03be41bdd..b2938ca58 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -43,7 +43,7 @@ struct SendConfirmationView: View { @State private var walletSwitchRun: WalletSwitchRun? @State private var manualCoinsRequestedContext: ConfirmationContext? @State private var isPreparingDirectOnchainSend = false - @State private var isCalculatingOnchainFee = false + @State private var onchainFeeCalculationId: Int? @State private var presentationID: UUID? private struct WalletSwitchRun { @@ -1183,7 +1183,13 @@ struct SendConfirmationView: View { } private var isPreparingOnchainSend: Bool { - isPreparingDirectOnchainSend || isCalculatingOnchainFee + isPreparingDirectOnchainSend + || Self.isCalculatingOnchainFee(calculationId: onchainFeeCalculationId, latestCalculationId: feeCalculationId) + } + + /// Only the newest fee calculation can hold the swipe, so a calculation superseded by a funding switch never leaves it disabled. + static func isCalculatingOnchainFee(calculationId: Int?, latestCalculationId: Int) -> Bool { + calculationId == latestCalculationId } private func requiresManualConfirmation(isAutomatic: Bool) -> Bool { @@ -2080,10 +2086,10 @@ struct SendConfirmationView: View { } // A swipe before the fee is known would skip the fee warnings and record a zero fee. - isCalculatingOnchainFee = true + onchainFeeCalculationId = requestId defer { - if feeCalculationId == requestId { - isCalculatingOnchainFee = false + if onchainFeeCalculationId == requestId { + onchainFeeCalculationId = nil } } let useMaxAmount = await shouldUseMaxOnchainSend( diff --git a/BitkitTests/SendConfirmationSwipeTests.swift b/BitkitTests/SendConfirmationSwipeTests.swift index 904e844f3..6a0a86bdb 100644 --- a/BitkitTests/SendConfirmationSwipeTests.swift +++ b/BitkitTests/SendConfirmationSwipeTests.swift @@ -163,6 +163,13 @@ final class SendConfirmationSwipeTests: XCTestCase { )) } + func testOnlyTheNewestFeeCalculationHoldsTheSwipe() { + XCTAssertTrue(SendConfirmationView.isCalculatingOnchainFee(calculationId: 3, latestCalculationId: 3)) + // A funding switch starts a newer calculation that may not be on-chain at all. + XCTAssertFalse(SendConfirmationView.isCalculatingOnchainFee(calculationId: 3, latestCalculationId: 4)) + XCTAssertFalse(SendConfirmationView.isCalculatingOnchainFee(calculationId: nil, latestCalculationId: 4)) + } + func testOnchainPreparationKeepsSwipeDisabledWithKnownFeeRate() { XCTAssertTrue(SendConfirmationView.isSwipeDisabled( walletType: .onchain, From 28828c2e85cc5c02a8b100062d526bcee8ba5452 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 05:54:28 -0300 Subject: [PATCH 05/23] fix: open amount for onchain requests below the minimum Co-Authored-By: Claude Opus 5.5 --- Bitkit/Utilities/PaymentNavigationHelper.swift | 5 ++++- BitkitTests/PaymentNavigationHelperTests.swift | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Bitkit/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index b1d7cfaa7..cf5fcab78 100644 --- a/Bitkit/Utilities/PaymentNavigationHelper.swift +++ b/Bitkit/Utilities/PaymentNavigationHelper.swift @@ -172,7 +172,10 @@ struct PaymentNavigationHelper { /// A BIP21 request with an amount skips Amount; the balance was already checked when it was scanned. /// Contact payments keep Amount because it records the reviewed amount for the payment. static func onchainInvoiceRoute(app: AppViewModel) -> SendRoute { - guard let invoice = app.scannedOnchainInvoice, invoice.amountSatoshis > 0, app.contactPaymentContext == nil else { + // Below the on-chain minimum, Amount shows why it cannot be sent. + guard let invoice = app.scannedOnchainInvoice, invoice.amountSatoshis >= UInt64(Env.dustLimit), + app.contactPaymentContext == nil + else { return .amount } return .confirm diff --git a/BitkitTests/PaymentNavigationHelperTests.swift b/BitkitTests/PaymentNavigationHelperTests.swift index 5392901d7..394df2eea 100644 --- a/BitkitTests/PaymentNavigationHelperTests.swift +++ b/BitkitTests/PaymentNavigationHelperTests.swift @@ -334,6 +334,19 @@ final class PaymentNavigationHelperTests: XCTestCase { XCTAssertEqual(paymentSheetRoute(for: app), .amount) } + func testOnchainInvoiceBelowMinimumOpensAmount() { + let app = appWithOnchainInvoice(amountSatoshis: UInt64(Env.dustLimit) - 1) + + XCTAssertEqual(sendRoute(for: app), .amount) + XCTAssertEqual(paymentSheetRoute(for: app), .amount) + } + + func testOnchainInvoiceAtMinimumOpensConfirm() { + let app = appWithOnchainInvoice(amountSatoshis: UInt64(Env.dustLimit)) + + XCTAssertEqual(sendRoute(for: app), .confirm) + } + func testOnchainInvoiceWithAmountSkipsQuickpay() { let app = appWithOnchainInvoice(amountSatoshis: 1000) From d8c5cc9b80ab55e23ffafdf7417b381101a85afb Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 06:02:46 -0300 Subject: [PATCH 06/23] docs: update usdt clipboard journey for direct review Co-Authored-By: Claude Opus 5.5 --- journeys/usdt-payment-links-and-scans.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/journeys/usdt-payment-links-and-scans.xml b/journeys/usdt-payment-links-and-scans.xml index fd21ddf03..a2b81c6e3 100644 --- a/journeys/usdt-payment-links-and-scans.xml +++ b/journeys/usdt-payment-links-and-scans.xml @@ -3,7 +3,7 @@ Enable USDT in the introductory sheet or Settings, General, Payments before starting this flow. Open an ethereum: USDT receive link with chain 42161, the pinned Arbitrum token, an owned recipient and uint256=10001. Verify the USDT amount screen preserves the recipient and exact input 0.010001 USDT; the review rounds its displayed value to $0.01. Verify no payment occurs without approval. - Copy the USDT receive URI, return to Home and accept the clipboard prompt. Verify the USDT amount screen opens with the copied recipient and amount. Cancel without paying. Repeat with a wrong-chain URI and verify no payment prompt appears. + Copy the USDT receive URI, return to Home and accept the clipboard prompt. Verify it opens on payment review without tapping Review, with the copied recipient and amount; with a USDT balance below the amount plus fees, verify the amount screen stays open with the copied recipient and amount and the quote error. Cancel without paying. Repeat with a wrong-chain URI and verify no payment prompt appears. Cancel the send and remain on the USDT wallet without returning Home, then reopen the identical URI. Verify the amount or review screen opens again. Cancel back to the USDT wallet and open a different owned recipient and amount, verifying only the new values appear. Close the send sheet, open Settings from the drawer, then go back. Verify the dismissed link does not reopen its Amount screen. Explicitly reopen the same link and verify a new send flow is available, then cancel without paying. While a USDT contact amount screen, contact review or direct-send review is open, copy another owned bare USDT address and verify no automatic clipboard prompt appears. Repeat with a USDT payment URI. Open another USDT link, then scan another owned USDT address. Verify the active recipient, amount and contact or request are preserved, with no new send flow or invalid-QR error. Where PIN is enabled, repeat while its approval screen is visible, then cancel without paying. From 4f8ed4c01edb7c79130f0d95b8925ae218bad2dc Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 06:17:58 -0300 Subject: [PATCH 07/23] chore: narrow changelog to bip21 routing Co-Authored-By: Claude Opus 5.5 --- changelog.d/next/1198.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/next/1198.fixed.md b/changelog.d/next/1198.fixed.md index 34aa74c06..8420998ba 100644 --- a/changelog.d/next/1198.fixed.md +++ b/changelog.d/next/1198.fixed.md @@ -1 +1 @@ -Scanning or pasting a Bitcoin payment request that includes an amount, or receiving a fixed-price Payment Request that can only be paid in Bitcoin, now opens the review screen directly instead of the amount screen. +Scanning or pasting a Bitcoin payment request that includes an amount now opens the review screen directly instead of the amount screen. From 6674aa2feee3affd6d5f60fb095671af75a94f80 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 14:35:08 -0300 Subject: [PATCH 08/23] fix: clear coins chosen for an edited amount on restore Co-Authored-By: Claude Opus 5.5 --- Bitkit/ViewModels/WalletViewModel.swift | 9 +++++++++ Bitkit/Views/Wallets/Send/SendAmountView.swift | 2 +- BitkitTests/WalletViewModelSendTests.swift | 15 +++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index dbdd4d44b..1168a0789 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -1801,6 +1801,15 @@ class WalletViewModel: ObservableObject { availableUtxos = [] } + /// Amount restores the scanned amount over an edited one; coins chosen for the edit no longer fit, + /// and a Confirm opened directly from the scan keeps them unless they are cleared here. + func restoreScannedSendAmount(_ amountSats: UInt64) { + if sendAmountSats != amountSats { + selectedUtxos = nil + } + sendAmountSats = amountSats + } + func resetSendState(speed: TransactionSpeed) { sendAmountSats = nil selectedFeeRateSatsPerVByte = nil diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index ba37f24e7..ee0895511 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -278,7 +278,7 @@ struct SendAmountView: View { if let invoice = app.scannedOnchainInvoice, invoice.amountSatoshis > 0 { // Set the amount to the scanned onchain invoice amount if it exists amountViewModel.updateFromSats(invoice.amountSatoshis, currency: currency) - wallet.sendAmountSats = invoice.amountSatoshis + wallet.restoreScannedSendAmount(invoice.amountSatoshis) } else if let lightningInvoice = app.scannedLightningInvoice, lightningInvoice.amountSatoshis > 0, wallet.sendAmountSats == nil || wallet.sendAmountSats == 0 diff --git a/BitkitTests/WalletViewModelSendTests.swift b/BitkitTests/WalletViewModelSendTests.swift index 0671ce917..c2dea7c8e 100644 --- a/BitkitTests/WalletViewModelSendTests.swift +++ b/BitkitTests/WalletViewModelSendTests.swift @@ -60,6 +60,21 @@ final class WalletViewModelSendTests: XCTestCase { } } + func testRestoringTheScannedAmountClearsCoinsChosenForAnEditedAmount() { + let wallet = WalletViewModel() + let utxo = SpendableUtxo(outpoint: OutPoint(txid: String(repeating: "a", count: 64), vout: 0), valueSats: 20000) + + wallet.sendAmountSats = 10000 + wallet.selectedUtxos = [utxo] + wallet.restoreScannedSendAmount(50000) + XCTAssertEqual(wallet.sendAmountSats, 50000) + XCTAssertNil(wallet.selectedUtxos) + + wallet.selectedUtxos = [utxo] + wallet.restoreScannedSendAmount(50000) + XCTAssertEqual(wallet.selectedUtxos?.map(\.valueSats), [20000]) + } + func testSoftwareMaxUsesSpendableOutputsInsteadOfFixedAmountSelection() async throws { let wallet = WalletViewModel() wallet.sendAmountSats = 99819 From fe8a33af3319ec9b0b1448d89374ae6a8cc8b0db Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 14:52:58 -0300 Subject: [PATCH 09/23] fix: return to amount when a hardware fee preview fails Co-Authored-By: Claude Opus 5.5 --- Bitkit/Views/Wallets/Send/SendConfirmationView.swift | 11 +++++++++++ BitkitTests/SendConfirmationSwipeTests.swift | 6 ++++++ 2 files changed, 17 insertions(+) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index b2938ca58..5c1493025 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -1187,6 +1187,12 @@ struct SendConfirmationView: View { || Self.isCalculatingOnchainFee(calculationId: onchainFeeCalculationId, latestCalculationId: feeCalculationId) } + /// A hardware preview that fails leaves no fee to send with. Amount shows the reason, as its Continue does; + /// a contact payment has its own amount review. + static func returnsToAmountAfterFeeFailure(isHardwarePayment: Bool, isContactPayment: Bool) -> Bool { + isHardwarePayment && !isContactPayment + } + /// Only the newest fee calculation can hold the swipe, so a calculation superseded by a funding switch never leaves it disabled. static func isCalculatingOnchainFee(calculationId: Int?, latestCalculationId: Int) -> Bool { calculationId == latestCalculationId @@ -2117,6 +2123,11 @@ struct SendConfirmationView: View { guard isCurrentSend(), !(error is CancellationError) else { return } Logger.error("Failed to calculate actual fee: \(error)") transactionFee = 0 + if Self.returnsToAmountAfterFeeFailure(isHardwarePayment: hwSend.isActive, isContactPayment: app.contactPaymentContext != nil) { + app.toast(type: .error, title: t("other__try_again"), description: error.localizedDescription) + navigateToAmount() + return + } if app.contactPaymentContext?.incomingPaymentRequest != nil { returnToAmountReview() } diff --git a/BitkitTests/SendConfirmationSwipeTests.swift b/BitkitTests/SendConfirmationSwipeTests.swift index 6a0a86bdb..c176b284f 100644 --- a/BitkitTests/SendConfirmationSwipeTests.swift +++ b/BitkitTests/SendConfirmationSwipeTests.swift @@ -163,6 +163,12 @@ final class SendConfirmationSwipeTests: XCTestCase { )) } + func testAFailedHardwarePreviewReturnsToAmountOutsideContactPayments() { + XCTAssertTrue(SendConfirmationView.returnsToAmountAfterFeeFailure(isHardwarePayment: true, isContactPayment: false)) + XCTAssertFalse(SendConfirmationView.returnsToAmountAfterFeeFailure(isHardwarePayment: true, isContactPayment: true)) + XCTAssertFalse(SendConfirmationView.returnsToAmountAfterFeeFailure(isHardwarePayment: false, isContactPayment: false)) + } + func testOnlyTheNewestFeeCalculationHoldsTheSwipe() { XCTAssertTrue(SendConfirmationView.isCalculatingOnchainFee(calculationId: 3, latestCalculationId: 3)) // A funding switch starts a newer calculation that may not be on-chain at all. From 41b365625da8ee9204a45474c692ee17bfefb59e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 16:52:51 -0300 Subject: [PATCH 10/23] fix: keep a scanned amount fixed on confirm Co-Authored-By: Claude Opus 5.5 --- .../Wallets/Send/SendConfirmationView.swift | 32 ++++++++++++++++--- BitkitTests/SendConfirmationSwipeTests.swift | 8 +++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 5c1493025..e2828869b 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -248,9 +248,19 @@ struct SendConfirmationView: View { private var canEditAmount: Bool { guard preparingRequest == nil, app.contactPaymentContext?.incomingPaymentRequest == nil else { return false } - guard app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice else { return true } + return Self.canEditScannedAmount( + walletToPayFrom: app.selectedWalletToPayFrom, + lightningAmountSats: app.scannedLightningInvoice?.amountSatoshis, + onchainAmountSats: app.scannedOnchainInvoice?.amountSatoshis + ) + } - return invoice.amountSatoshis == 0 + /// The payee set the amount of a scanned request that carries one, so Confirm does not offer Amount to change it. + static func canEditScannedAmount(walletToPayFrom: WalletType, lightningAmountSats: UInt64?, onchainAmountSats: UInt64?) -> Bool { + switch walletToPayFrom { + case .lightning: lightningAmountSats.map { $0 == 0 } ?? true + case .onchain: onchainAmountSats.map { $0 == 0 } ?? true + } } private var contactPaymentContact: PubkyContact? { @@ -1854,7 +1864,21 @@ struct SendConfirmationView: View { private func navigateToAmount() { guard canEditAmount else { return } + showAmount() + } + + /// After a failure, an editable amount goes back to Amount; a fixed one leaves Confirm, as Amount cannot change it. + private func leaveAfterPreparationFailure() { + if canEditAmount { + showAmount() + } else if let confirmIndex = navigationPath.lastIndex(of: .confirm) { + navigationPath = Array(navigationPath.prefix(confirmIndex)) + } else if let presentationID { + sheets.hideSheetIfActive(presentationID: presentationID, reason: "Fixed-amount payment could not be prepared") + } + } + private func showAmount() { if amountIsRoot { navigationPath = [] } else if let amountIndex = navigationPath.lastIndex(of: .amount) { @@ -2001,7 +2025,7 @@ struct SendConfirmationView: View { guard isCurrentSend(), !(error is CancellationError) else { return } Logger.error("Failed to select UTXOs for on-chain request: \(error)", context: "SendConfirmationView") app.toast(type: .error, title: t("other__try_again"), description: error.localizedDescription) - navigateToAmount() + leaveAfterPreparationFailure() } } @@ -2125,7 +2149,7 @@ struct SendConfirmationView: View { transactionFee = 0 if Self.returnsToAmountAfterFeeFailure(isHardwarePayment: hwSend.isActive, isContactPayment: app.contactPaymentContext != nil) { app.toast(type: .error, title: t("other__try_again"), description: error.localizedDescription) - navigateToAmount() + leaveAfterPreparationFailure() return } if app.contactPaymentContext?.incomingPaymentRequest != nil { diff --git a/BitkitTests/SendConfirmationSwipeTests.swift b/BitkitTests/SendConfirmationSwipeTests.swift index c176b284f..1ff827897 100644 --- a/BitkitTests/SendConfirmationSwipeTests.swift +++ b/BitkitTests/SendConfirmationSwipeTests.swift @@ -163,6 +163,14 @@ final class SendConfirmationSwipeTests: XCTestCase { )) } + func testAScannedAmountCannotBeEditedFromConfirm() { + XCTAssertFalse(SendConfirmationView.canEditScannedAmount(walletToPayFrom: .onchain, lightningAmountSats: nil, onchainAmountSats: 10000)) + XCTAssertFalse(SendConfirmationView.canEditScannedAmount(walletToPayFrom: .lightning, lightningAmountSats: 10000, onchainAmountSats: 0)) + XCTAssertTrue(SendConfirmationView.canEditScannedAmount(walletToPayFrom: .onchain, lightningAmountSats: 10000, onchainAmountSats: 0)) + XCTAssertTrue(SendConfirmationView.canEditScannedAmount(walletToPayFrom: .lightning, lightningAmountSats: 0, onchainAmountSats: 10000)) + XCTAssertTrue(SendConfirmationView.canEditScannedAmount(walletToPayFrom: .onchain, lightningAmountSats: nil, onchainAmountSats: nil)) + } + func testAFailedHardwarePreviewReturnsToAmountOutsideContactPayments() { XCTAssertTrue(SendConfirmationView.returnsToAmountAfterFeeFailure(isHardwarePayment: true, isContactPayment: false)) XCTAssertFalse(SendConfirmationView.returnsToAmountAfterFeeFailure(isHardwarePayment: true, isContactPayment: true)) From 03b6b565ad581e82a9f3865e55865190425aefa3 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 16:52:51 -0300 Subject: [PATCH 11/23] fix: clear a stale max choice when restoring the scanned amount Co-Authored-By: Claude Opus 5.5 --- Bitkit/ViewModels/WalletViewModel.swift | 3 ++- BitkitTests/WalletViewModelSendTests.swift | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 1168a0789..5ee055bc1 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -1801,11 +1801,12 @@ class WalletViewModel: ObservableObject { availableUtxos = [] } - /// Amount restores the scanned amount over an edited one; coins chosen for the edit no longer fit, + /// Amount restores the scanned amount over an edited one; coins and a Max choice made for the edit no longer fit, /// and a Confirm opened directly from the scan keeps them unless they are cleared here. func restoreScannedSendAmount(_ amountSats: UInt64) { if sendAmountSats != amountSats { selectedUtxos = nil + isMaxAmountSend = false } sendAmountSats = amountSats } diff --git a/BitkitTests/WalletViewModelSendTests.swift b/BitkitTests/WalletViewModelSendTests.swift index c2dea7c8e..c4b134ac3 100644 --- a/BitkitTests/WalletViewModelSendTests.swift +++ b/BitkitTests/WalletViewModelSendTests.swift @@ -66,9 +66,11 @@ final class WalletViewModelSendTests: XCTestCase { wallet.sendAmountSats = 10000 wallet.selectedUtxos = [utxo] + wallet.isMaxAmountSend = true wallet.restoreScannedSendAmount(50000) XCTAssertEqual(wallet.sendAmountSats, 50000) XCTAssertNil(wallet.selectedUtxos) + XCTAssertFalse(wallet.isMaxAmountSend) wallet.selectedUtxos = [utxo] wallet.restoreScannedSendAmount(50000) From 122d07a4a74d0f3fb3b283240ac9ca476b8a00bd Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 16:52:51 -0300 Subject: [PATCH 12/23] fix: reject a scanned onchain amount below the minimum Co-Authored-By: Claude Opus 5.5 --- Bitkit/ViewModels/AppViewModel.swift | 6 ++++-- BitkitTests/PaymentNavigationHelperTests.swift | 6 ++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 1c6fc301e..11778d3ee 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -448,8 +448,10 @@ class AppViewModel: ObservableObject { ) } + /// A scanned amount below the on-chain minimum cannot be paid, and Amount does not open to change it. private func validateIncomingOnchainPaymentRequestAmount(_ amount: UInt64) -> Bool { - guard contactPaymentContext?.incomingPaymentRequest != nil, amount < Env.dustLimit else { return true } + let isPaymentRequest = contactPaymentContext?.incomingPaymentRequest != nil + guard amount < Env.dustLimit, isPaymentRequest || amount > 0 else { return true } didRejectScannedPayment = true toast( type: .error, @@ -458,7 +460,7 @@ class AppViewModel: ObservableObject { "wallet__lnurl_pay__error_min__description", variables: ["amount": CurrencyFormatter.formatSats(UInt64(Env.dustLimit))] ), - accessibilityIdentifier: "PaymentRequestAmountTooLowToast" + accessibilityIdentifier: isPaymentRequest ? "PaymentRequestAmountTooLowToast" : "AmountTooLowToast" ) return false } diff --git a/BitkitTests/PaymentNavigationHelperTests.swift b/BitkitTests/PaymentNavigationHelperTests.swift index 394df2eea..f0f0afb21 100644 --- a/BitkitTests/PaymentNavigationHelperTests.swift +++ b/BitkitTests/PaymentNavigationHelperTests.swift @@ -334,6 +334,12 @@ final class PaymentNavigationHelperTests: XCTestCase { XCTAssertEqual(paymentSheetRoute(for: app), .amount) } + func testScannedOnchainAmountBelowMinimumIsRejected() async throws { + let app = AppViewModel() + try await app.handleScannedData("bitcoin:bcrt1q6rhpng9evdsfnn833a4f4vej0asu6dk5srld6x?amount=0.000005") + XCTAssertTrue(app.didRejectScannedPayment) + } + func testOnchainInvoiceBelowMinimumOpensAmount() { let app = appWithOnchainInvoice(amountSatoshis: UInt64(Env.dustLimit) - 1) From 44844347493e8ed9a0cd1f93dc8219c8fa59537b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 16:52:52 -0300 Subject: [PATCH 13/23] fix: open the usdt review for a scanned amount Co-Authored-By: Claude Opus 5.5 --- Bitkit/Views/Wallets/UsdtSendView.swift | 70 ++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 7 deletions(-) diff --git a/Bitkit/Views/Wallets/UsdtSendView.swift b/Bitkit/Views/Wallets/UsdtSendView.swift index 3c8c4f9dc..9624aee18 100644 --- a/Bitkit/Views/Wallets/UsdtSendView.swift +++ b/Bitkit/Views/Wallets/UsdtSendView.swift @@ -34,6 +34,8 @@ struct UsdtSendView: View { @State private var preparingMaximum = false @State private var editingRecipient = false @State private var editingAmount = false + /// A scanned or linked request that names an amount fixes it; the payee set it. + @State private var isAmountFixed = false @State private var busy = false @State private var error: String? @State private var submissionError: SubmissionFailure? @@ -81,6 +83,7 @@ struct UsdtSendView: View { _recipient = State(initialValue: initialRecipient) _amount = State(initialValue: (try? usdtParseAmount(value: initialAmount)) == 0 ? "" : initialAmount) _editingAmount = State(initialValue: !initialRecipient.isEmpty) + _isAmountFixed = State(initialValue: (try? usdtParseAmount(value: initialAmount)).map { $0 > 0 } ?? false) } private var paymentRequest: PaykitPaymentRequest? { @@ -137,7 +140,7 @@ struct UsdtSendView: View { } return t(submissionFailed ? "wallet__send_error_tx_failed" : "usdt__submitted") } - if quote != nil { + if quote != nil || (editingAmount && isAmountFixed) { return t(paymentRequest == nil ? "wallet__send_review" : "wallet__payment_request") } return t(editingAmount ? "usdt__amount" : "usdt__send_title") @@ -247,6 +250,8 @@ struct UsdtSendView: View { submittedContent } else if let quote { confirmation(quote) + } else if editingAmount, isAmountFixed { + fixedAmountPreparation } else if editingAmount { amountContent } else if editingRecipient { @@ -387,6 +392,41 @@ struct UsdtSendView: View { .onChange(of: recipient) { _, _ in error = nil } } + /// A fixed amount waits for its quote on the review, since Amount cannot change it. + private var fixedAmountPreparation: some View { + VStack(alignment: .leading, spacing: 0) { + UsdtAmountHeader(amount: amount.isEmpty ? "0" : amount) + .accessibilityIdentifier("UsdtAmount") + Spacer(minLength: 0) + PaymentReviewIllustration(swipeProgress: 0, imageSize: 329) + .padding(.top, 16).frame(height: 256) + Spacer(minLength: 0) + if let error { + BodySText(error, textColor: .brandAccent).accessibilityIdentifier("UsdtError").padding(.bottom, 16) + CustomButton(title: t("common__try_again"), variant: .secondary, isDisabled: busy) { + await quoteFixedAmount() + } + .accessibilityIdentifier("UsdtRetryQuote") + .padding(.bottom, 16) + } + SwipeButton(title: t("wallet__send_swipe"), accentColor: .usdtAccent, isDisabled: true, isLoading: busy) {} + } + .accessibilityIdentifier("UsdtConfirmLoading") + } + + private func quoteFixedAmount() async { + guard !busy, quote == nil else { return } + busy = true + error = nil + defer { busy = false } + do { + quote = try await prepareQuote() + } catch is CancellationError { + } catch { + self.error = UsdtWalletManager.message(for: error) + } + } + private var amountContent: some View { VStack(alignment: .leading, spacing: 0) { UsdtAmountHeader(amount: amount.isEmpty ? "0" : amount) @@ -413,7 +453,7 @@ struct UsdtSendView: View { CustomDivider() NumberPad( type: .decimal, - isDisabled: busy || embedded, + isDisabled: busy || embedded || isAmountFixed, onDeleteLongPress: { amount = ""; maximumQuote = nil; isMaximumAmount = false; error = nil }, onPress: enterAmount ) @@ -463,9 +503,14 @@ struct UsdtSendView: View { HStack(spacing: 8) { NumberPadActionButton(text: "USDT", titleKerning: 1, titleLineHeight: 18, color: .usdtAccent, variant: .secondary, disabled: true) {} if !embedded { - NumberPadActionButton(text: t("common__max"), titleKerning: 1, titleLineHeight: 18, color: .usdtAccent, disabled: busy, - isLoading: preparingMaximum) - { + NumberPadActionButton( + text: t("common__max"), + titleKerning: 1, + titleLineHeight: 18, + color: .usdtAccent, + disabled: busy || isAmountFixed, + isLoading: preparingMaximum + ) { Task { await useMaximum() } } .accessibilityIdentifier("UsdtMax") @@ -492,7 +537,7 @@ struct UsdtSendView: View { VStack(alignment: .leading, spacing: 0) { UsdtAmountHeader(amount: usdtFormatAmount(amount: quote.amount), displayAmount: usdtExactAmount(quote.amount)) .contentShape(Rectangle()).onTapGesture(perform: goBack) - .allowsHitTesting(!busy && !embedded) + .allowsHitTesting(!busy && !embedded && !isAmountFixed) Spacer().frame(height: 24) if showDetails { quoteDetails(quote) @@ -675,12 +720,16 @@ struct UsdtSendView: View { if let requestedAmount = request.amount { amount = requestedAmount == 0 ? "" : usdtFormatAmount(amount: requestedAmount) } + isAmountFixed = (request.amount ?? 0) > 0 } catch { self.error = UsdtWalletManager.message(for: error); return } maximumQuote = nil isMaximumAmount = false recipientFocused = false editingAmount = true error = nil + if isAmountFixed { + Task { await quoteFixedAmount() } + } } private func readImage(_ item: PhotosPickerItem?) async { @@ -705,7 +754,14 @@ struct UsdtSendView: View { if let onBack, embedded || (hosted && quote == nil) { onBack(); return } - if quote != nil { + if quote != nil, isAmountFixed { + // Amount cannot change the fixed amount, so Back leaves it as well. + quote = nil + if let onBack, hosted { + onBack(); return + } + editingAmount = false; editingRecipient = true + } else if quote != nil { quote = nil } else if editingAmount { editingAmount = false; editingRecipient = true From 94b3f5d942e611ec3229282a32b5b88199f0b505 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 16:52:52 -0300 Subject: [PATCH 14/23] docs: update journeys for fixed scanned amounts Co-Authored-By: Claude Opus 5.5 --- journeys/send/scan-onchain-amount-to-confirm.xml | 7 ++++--- journeys/usdt-payment-links-and-scans.xml | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/journeys/send/scan-onchain-amount-to-confirm.xml b/journeys/send/scan-onchain-amount-to-confirm.xml index f09c39be2..8ff9e73bb 100644 --- a/journeys/send/scan-onchain-amount-to-confirm.xml +++ b/journeys/send/scan-onchain-amount-to-confirm.xml @@ -2,7 +2,7 @@ Verifies that pasting or scanning an on-chain BIP21 request that carries an amount, with enough Savings balance, opens the Confirm screen directly instead of the Amount screen, and that the send - completes. Also checks that the amount can still be edited from Confirm. + completes. The amount is fixed: tapping it on Confirm does not open Amount. Precondition: onboarded dev wallet on regtest with at least 50 000 confirmed sats in Savings, a running synced node, Coin Selection set to Autopilot, and a regtest address the wallet does not @@ -18,9 +18,10 @@ Verify the Confirm screen (id "SendConfirm") is visible without passing through the Send amount screen (id "SendAmount") Verify the review amount (id "ReviewAmount-primary") shows 10 000 Tap Show Details (id "SendConfirmToggleDetails") and verify the recipient address (id "ReviewUri") and a non-zero network fee are shown - Tap the review amount (id "ReviewAmount-primary") and verify the Send amount screen (id "SendAmount") opens with 10 000 sats - Tap Continue (id "ContinueAmount") and verify the Confirm screen (id "SendConfirm") is visible again with 10 000 and the recipient address (id "ReviewUri") after expanding Show Details (id "SendConfirmToggleDetails") if collapsed + Tap the review amount (id "ReviewAmount-primary") and verify the Confirm screen (id "SendConfirm") stays open with 10 000 and the Send amount screen (id "SendAmount") does not open Swipe the send handle (id "GRAB") fully to the right, then tap Yes, Send (id "DialogConfirm") if a confirmation dialog appears Wait for the success screen (id "SendSuccess") and verify it shows 10 000 sats + Run xcrun simctl openurl <device> "bitcoin:<test recipient address>?amount=0.000005" + Verify the Amount Too Low toast (id "AmountTooLowToast") says the minimum is 547 sats, and neither the Send amount screen (id "SendAmount") nor the Confirm screen (id "SendConfirm") opens diff --git a/journeys/usdt-payment-links-and-scans.xml b/journeys/usdt-payment-links-and-scans.xml index a2b81c6e3..5ad0d3a73 100644 --- a/journeys/usdt-payment-links-and-scans.xml +++ b/journeys/usdt-payment-links-and-scans.xml @@ -2,13 +2,13 @@ Use an onboarded wallet with USDT configured and another owned wallet's Arbitrum receive URI. No payment is submitted. Hardware-wallet send remains Bitcoin-only. A full-balance quote can fail at balance validation or paymaster fee collection; either error can occur on either platform, depending on which validation fails first. Both must reject before signing. Enable USDT in the introductory sheet or Settings, General, Payments before starting this flow. - Open an ethereum: USDT receive link with chain 42161, the pinned Arbitrum token, an owned recipient and uint256=10001. Verify the USDT amount screen preserves the recipient and exact input 0.010001 USDT; the review rounds its displayed value to $0.01. Verify no payment occurs without approval. - Copy the USDT receive URI, return to Home and accept the clipboard prompt. Verify it opens on payment review without tapping Review, with the copied recipient and amount; with a USDT balance below the amount plus fees, verify the amount screen stays open with the copied recipient and amount and the quote error. Cancel without paying. Repeat with a wrong-chain URI and verify no payment prompt appears. + Open an ethereum: USDT receive link with chain 42161, the pinned Arbitrum token, an owned recipient and uint256=10001. Verify the USDT amount screen preserves the recipient and exact input 0.010001 USDT; the review rounds its displayed value to $0.01. With a USDT balance below the amount plus fees, verify the review (id "UsdtConfirmLoading") shows the insufficient-balance or fee error with Try Again (id "UsdtRetryQuote") and the amount screen does not open. Verify the amount is fixed: tapping the amount on review does not open the amount screen, and Back from review returns to the recipient step instead of the amount screen. Verify no payment occurs without approval. + Copy the USDT receive URI, return to Home and accept the clipboard prompt. Verify it opens on payment review without tapping Review, with the copied recipient and amount; with a USDT balance below the amount plus fees, verify the review shows the copied amount and the quote error, and the amount screen does not open. Cancel without paying. Repeat with a wrong-chain URI and verify no payment prompt appears. Cancel the send and remain on the USDT wallet without returning Home, then reopen the identical URI. Verify the amount or review screen opens again. Cancel back to the USDT wallet and open a different owned recipient and amount, verifying only the new values appear. Close the send sheet, open Settings from the drawer, then go back. Verify the dismissed link does not reopen its Amount screen. Explicitly reopen the same link and verify a new send flow is available, then cancel without paying. While a USDT contact amount screen, contact review or direct-send review is open, copy another owned bare USDT address and verify no automatic clipboard prompt appears. Repeat with a USDT payment URI. Open another USDT link, then scan another owned USDT address. Verify the active recipient, amount and contact or request are preserved, with no new send flow or invalid-QR error. Where PIN is enabled, repeat while its approval screen is visible, then cancel without paying. Open USDT from Home, open a USDT link, then open a second link with another owned recipient and amount. Verify the first amount and recipient remain. Cancel without paying. - Cancel the send. Open the same link without uint256. Verify the amount screen opens with no amount and accepts a new amount. + Cancel the send. Open the same link without uint256. Verify the amount screen opens with no amount and accepts a new amount, and that Back from review returns to the amount screen. Cancel the send. From the main scanner, paste or import a QR containing the same amount-bearing URI. Verify it opens the USDT send flow with the same recipient and amount. Cancel the send. Scan another owned wallet's bare 0x USDT address from the main scanner, then repeat through ordinary Send paste and Enter Manually. Verify each opens USDT with the same recipient, an empty amount, Arbitrum One selected and the network chooser available. In hardware-wallet manual entry, verify the same address shows the Bitcoin-address-required warning and Continue stays disabled. Cancel without paying. Cancel the send. From the ordinary Send options, paste the URI. Verify it opens the USDT send flow. Cancel, choose Enter Manually, enter the same URI and continue. Verify the USDT recipient and exact amount are preserved. Repeat with a wrong-chain URI and in hardware-wallet Send; verify neither opens a USDT payment. In hardware-wallet manual entry, verify the Bitcoin-address-required warning appears and Continue stays disabled. Replace the URI with a valid Bitcoin address and verify Continue becomes available. Cancel, then open Send again from the USDT wallet and verify the previous recipient and amount are cleared. From 19d6be4b0b4376d9f18732fa248ce0c0e3197daf Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 17:37:18 -0300 Subject: [PATCH 15/23] fix: keep a scanned usdt amount fixed after back Co-Authored-By: Claude Opus 5.5 --- Bitkit/Views/Wallets/UsdtSendView.swift | 15 ++++++++++++++- BitkitTests/UsdtPaymentLinkTests.swift | 8 ++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/Bitkit/Views/Wallets/UsdtSendView.swift b/Bitkit/Views/Wallets/UsdtSendView.swift index 9624aee18..fe6c1d3ed 100644 --- a/Bitkit/Views/Wallets/UsdtSendView.swift +++ b/Bitkit/Views/Wallets/UsdtSendView.swift @@ -716,11 +716,15 @@ struct UsdtSendView: View { if request.chainId != nil { destination = .arbitrum } + isAmountFixed = Self.isAmountFixed( + requestedAmount: request.amount, + wasFixed: isAmountFixed, + isSameRecipient: request.recipient == recipient + ) recipient = request.recipient if let requestedAmount = request.amount { amount = requestedAmount == 0 ? "" : usdtFormatAmount(amount: requestedAmount) } - isAmountFixed = (request.amount ?? 0) > 0 } catch { self.error = UsdtWalletManager.message(for: error); return } maximumQuote = nil isMaximumAmount = false @@ -732,6 +736,15 @@ struct UsdtSendView: View { } } + /// A request that names a positive amount fixes it. Continuing with the same recipient and no amount, + /// as after Back from review, keeps an earlier fixed amount. + static func isAmountFixed(requestedAmount: UInt64?, wasFixed: Bool, isSameRecipient: Bool) -> Bool { + if let requestedAmount { + return requestedAmount > 0 + } + return wasFixed && isSameRecipient + } + private func readImage(_ item: PhotosPickerItem?) async { guard let item else { return } do { diff --git a/BitkitTests/UsdtPaymentLinkTests.swift b/BitkitTests/UsdtPaymentLinkTests.swift index 46e007c93..d3299c6b9 100644 --- a/BitkitTests/UsdtPaymentLinkTests.swift +++ b/BitkitTests/UsdtPaymentLinkTests.swift @@ -94,4 +94,12 @@ final class UsdtPaymentLinkTests: XCTestCase { XCTAssertNil(try resolveUsdtPaymentRoute(input: input, isEnabled: true), input) } } + + func testFixedAmountSurvivesContinuingWithTheSameRecipient() { + XCTAssertTrue(UsdtSendView.isAmountFixed(requestedAmount: 10001, wasFixed: false, isSameRecipient: false)) + XCTAssertFalse(UsdtSendView.isAmountFixed(requestedAmount: 0, wasFixed: true, isSameRecipient: true)) + XCTAssertTrue(UsdtSendView.isAmountFixed(requestedAmount: nil, wasFixed: true, isSameRecipient: true)) + XCTAssertFalse(UsdtSendView.isAmountFixed(requestedAmount: nil, wasFixed: true, isSameRecipient: false)) + XCTAssertFalse(UsdtSendView.isAmountFixed(requestedAmount: nil, wasFixed: false, isSameRecipient: true)) + } } From f361337eebb453a5b1bfa430f7f31a7df47619e9 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 17:37:18 -0300 Subject: [PATCH 16/23] docs: cover back and zero amounts in the usdt journey Co-Authored-By: Claude Opus 5.5 --- journeys/usdt-payment-links-and-scans.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/journeys/usdt-payment-links-and-scans.xml b/journeys/usdt-payment-links-and-scans.xml index 5ad0d3a73..b0b3c8466 100644 --- a/journeys/usdt-payment-links-and-scans.xml +++ b/journeys/usdt-payment-links-and-scans.xml @@ -2,13 +2,13 @@ Use an onboarded wallet with USDT configured and another owned wallet's Arbitrum receive URI. No payment is submitted. Hardware-wallet send remains Bitcoin-only. A full-balance quote can fail at balance validation or paymaster fee collection; either error can occur on either platform, depending on which validation fails first. Both must reject before signing. Enable USDT in the introductory sheet or Settings, General, Payments before starting this flow. - Open an ethereum: USDT receive link with chain 42161, the pinned Arbitrum token, an owned recipient and uint256=10001. Verify the USDT amount screen preserves the recipient and exact input 0.010001 USDT; the review rounds its displayed value to $0.01. With a USDT balance below the amount plus fees, verify the review (id "UsdtConfirmLoading") shows the insufficient-balance or fee error with Try Again (id "UsdtRetryQuote") and the amount screen does not open. Verify the amount is fixed: tapping the amount on review does not open the amount screen, and Back from review returns to the recipient step instead of the amount screen. Verify no payment occurs without approval. + Open an ethereum: USDT receive link with chain 42161, the pinned Arbitrum token, an owned recipient and uint256=10001. Verify the USDT amount screen preserves the recipient and exact input 0.010001 USDT; the review rounds its displayed value to $0.01. With a USDT balance below the amount plus fees, verify the review (id "UsdtConfirmLoading") shows the insufficient-balance or fee error with Try Again (id "UsdtRetryQuote") and the amount screen does not open. Verify the amount is fixed: tapping the amount on review does not open the amount screen, and Back from review returns to the recipient step instead of the amount screen. Continue on that step without changing the recipient and verify the review opens again with the same fixed amount, not an editable amount screen. Verify no payment occurs without approval. Copy the USDT receive URI, return to Home and accept the clipboard prompt. Verify it opens on payment review without tapping Review, with the copied recipient and amount; with a USDT balance below the amount plus fees, verify the review shows the copied amount and the quote error, and the amount screen does not open. Cancel without paying. Repeat with a wrong-chain URI and verify no payment prompt appears. Cancel the send and remain on the USDT wallet without returning Home, then reopen the identical URI. Verify the amount or review screen opens again. Cancel back to the USDT wallet and open a different owned recipient and amount, verifying only the new values appear. Close the send sheet, open Settings from the drawer, then go back. Verify the dismissed link does not reopen its Amount screen. Explicitly reopen the same link and verify a new send flow is available, then cancel without paying. While a USDT contact amount screen, contact review or direct-send review is open, copy another owned bare USDT address and verify no automatic clipboard prompt appears. Repeat with a USDT payment URI. Open another USDT link, then scan another owned USDT address. Verify the active recipient, amount and contact or request are preserved, with no new send flow or invalid-QR error. Where PIN is enabled, repeat while its approval screen is visible, then cancel without paying. Open USDT from Home, open a USDT link, then open a second link with another owned recipient and amount. Verify the first amount and recipient remain. Cancel without paying. - Cancel the send. Open the same link without uint256. Verify the amount screen opens with no amount and accepts a new amount, and that Back from review returns to the amount screen. + Cancel the send. Open the same link without uint256. Verify the amount screen opens with no amount and accepts a new amount, and that Back from review returns to the amount screen. Repeat with uint256=0 and verify the same. Cancel the send. From the main scanner, paste or import a QR containing the same amount-bearing URI. Verify it opens the USDT send flow with the same recipient and amount. Cancel the send. Scan another owned wallet's bare 0x USDT address from the main scanner, then repeat through ordinary Send paste and Enter Manually. Verify each opens USDT with the same recipient, an empty amount, Arbitrum One selected and the network chooser available. In hardware-wallet manual entry, verify the same address shows the Bitcoin-address-required warning and Continue stays disabled. Cancel without paying. Cancel the send. From the ordinary Send options, paste the URI. Verify it opens the USDT send flow. Cancel, choose Enter Manually, enter the same URI and continue. Verify the USDT recipient and exact amount are preserved. Repeat with a wrong-chain URI and in hardware-wallet Send; verify neither opens a USDT payment. In hardware-wallet manual entry, verify the Bitcoin-address-required warning appears and Continue stays disabled. Replace the URI with a valid Bitcoin address and verify Continue becomes available. Cancel, then open Send again from the USDT wallet and verify the previous recipient and amount are cleared. From af79332bc27185d9b4074f639ae0ab04c91aec82 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 18:34:15 -0300 Subject: [PATCH 17/23] fix: keep confirm when a funding switch cannot be prepared Co-Authored-By: Claude Opus 5.5 --- .../Wallets/Send/SendConfirmationView.swift | 22 ++++++++++++------- BitkitTests/SendConfirmationSwipeTests.swift | 14 ++++++++---- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index e2828869b..e4f97f005 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -523,7 +523,7 @@ struct SendConfirmationView: View { } .onChange(of: hwSend.walletId) { guard preparingRequest == nil else { return } - Task { await calculateTransactionFee() } + Task { await calculateTransactionFee(afterFundingSwitch: true) } } .alert( t("security__bio_error_title"), @@ -1197,10 +1197,10 @@ struct SendConfirmationView: View { || Self.isCalculatingOnchainFee(calculationId: onchainFeeCalculationId, latestCalculationId: feeCalculationId) } - /// A hardware preview that fails leaves no fee to send with. Amount shows the reason, as its Continue does; - /// a contact payment has its own amount review. - static func returnsToAmountAfterFeeFailure(isHardwarePayment: Bool, isContactPayment: Bool) -> Bool { - isHardwarePayment && !isContactPayment + /// A hardware preview that fails leaves no fee to send with, so Confirm is left with the reason; a contact payment + /// has its own amount review. After a funding switch Confirm stays, so another source can still be picked. + static func leavesConfirmAfterFeeFailure(isHardwarePayment: Bool, isContactPayment: Bool, afterFundingSwitch: Bool) -> Bool { + isHardwarePayment && !isContactPayment && !afterFundingSwitch } /// Only the newest fee calculation can hold the swipe, so a calculation superseded by a funding switch never leaves it disabled. @@ -2042,7 +2042,7 @@ struct SendConfirmationView: View { } @MainActor - private func calculateTransactionFee() async { + private func calculateTransactionFee(afterFundingSwitch: Bool = false) async { guard !Task.isCancelled, let context = confirmationContext else { return } feeCalculationId += 1 let requestId = feeCalculationId @@ -2147,9 +2147,15 @@ struct SendConfirmationView: View { guard isCurrentSend(), !(error is CancellationError) else { return } Logger.error("Failed to calculate actual fee: \(error)") transactionFee = 0 - if Self.returnsToAmountAfterFeeFailure(isHardwarePayment: hwSend.isActive, isContactPayment: app.contactPaymentContext != nil) { + if hwSend.isActive, app.contactPaymentContext == nil { app.toast(type: .error, title: t("other__try_again"), description: error.localizedDescription) - leaveAfterPreparationFailure() + if Self.leavesConfirmAfterFeeFailure( + isHardwarePayment: true, + isContactPayment: false, + afterFundingSwitch: afterFundingSwitch + ) { + leaveAfterPreparationFailure() + } return } if app.contactPaymentContext?.incomingPaymentRequest != nil { diff --git a/BitkitTests/SendConfirmationSwipeTests.swift b/BitkitTests/SendConfirmationSwipeTests.swift index 1ff827897..618042a87 100644 --- a/BitkitTests/SendConfirmationSwipeTests.swift +++ b/BitkitTests/SendConfirmationSwipeTests.swift @@ -171,10 +171,16 @@ final class SendConfirmationSwipeTests: XCTestCase { XCTAssertTrue(SendConfirmationView.canEditScannedAmount(walletToPayFrom: .onchain, lightningAmountSats: nil, onchainAmountSats: nil)) } - func testAFailedHardwarePreviewReturnsToAmountOutsideContactPayments() { - XCTAssertTrue(SendConfirmationView.returnsToAmountAfterFeeFailure(isHardwarePayment: true, isContactPayment: false)) - XCTAssertFalse(SendConfirmationView.returnsToAmountAfterFeeFailure(isHardwarePayment: true, isContactPayment: true)) - XCTAssertFalse(SendConfirmationView.returnsToAmountAfterFeeFailure(isHardwarePayment: false, isContactPayment: false)) + func testAFailedHardwarePreviewLeavesConfirmOnlyForItsInitialSource() { + XCTAssertTrue(SendConfirmationView.leavesConfirmAfterFeeFailure(isHardwarePayment: true, isContactPayment: false, afterFundingSwitch: false)) + // A switch to a device that cannot pay keeps Confirm, so another source can be picked. + XCTAssertFalse(SendConfirmationView.leavesConfirmAfterFeeFailure(isHardwarePayment: true, isContactPayment: false, afterFundingSwitch: true)) + XCTAssertFalse(SendConfirmationView.leavesConfirmAfterFeeFailure(isHardwarePayment: true, isContactPayment: true, afterFundingSwitch: false)) + XCTAssertFalse(SendConfirmationView.leavesConfirmAfterFeeFailure( + isHardwarePayment: false, + isContactPayment: false, + afterFundingSwitch: false + )) } func testOnlyTheNewestFeeCalculationHoldsTheSwipe() { From 9706c1197ad1f2a3f326a871ed9e4133c849dffc Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 18:34:46 -0300 Subject: [PATCH 18/23] fix: tie a fixed usdt amount to the recipient that set it Co-Authored-By: Claude Opus 5.5 --- Bitkit/Views/Wallets/UsdtSendView.swift | 28 +++++++++++++++---------- BitkitTests/UsdtPaymentLinkTests.swift | 16 ++++++++------ 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/Bitkit/Views/Wallets/UsdtSendView.swift b/Bitkit/Views/Wallets/UsdtSendView.swift index fe6c1d3ed..f8dae8f48 100644 --- a/Bitkit/Views/Wallets/UsdtSendView.swift +++ b/Bitkit/Views/Wallets/UsdtSendView.swift @@ -34,8 +34,8 @@ struct UsdtSendView: View { @State private var preparingMaximum = false @State private var editingRecipient = false @State private var editingAmount = false - /// A scanned or linked request that names an amount fixes it; the payee set it. - @State private var isAmountFixed = false + /// The recipient whose scanned or linked request named the amount; the payee set it, so it cannot be edited. + @State private var fixedAmountRecipient: String? @State private var busy = false @State private var error: String? @State private var submissionError: SubmissionFailure? @@ -83,7 +83,9 @@ struct UsdtSendView: View { _recipient = State(initialValue: initialRecipient) _amount = State(initialValue: (try? usdtParseAmount(value: initialAmount)) == 0 ? "" : initialAmount) _editingAmount = State(initialValue: !initialRecipient.isEmpty) - _isAmountFixed = State(initialValue: (try? usdtParseAmount(value: initialAmount)).map { $0 > 0 } ?? false) + _fixedAmountRecipient = State( + initialValue: (try? usdtParseAmount(value: initialAmount)).map { $0 > 0 } == true ? initialRecipient : nil + ) } private var paymentRequest: PaykitPaymentRequest? { @@ -716,10 +718,10 @@ struct UsdtSendView: View { if request.chainId != nil { destination = .arbitrum } - isAmountFixed = Self.isAmountFixed( + fixedAmountRecipient = Self.fixedAmountRecipient( + recipient: request.recipient, requestedAmount: request.amount, - wasFixed: isAmountFixed, - isSameRecipient: request.recipient == recipient + fixedRecipient: fixedAmountRecipient ) recipient = request.recipient if let requestedAmount = request.amount { @@ -736,13 +738,17 @@ struct UsdtSendView: View { } } - /// A request that names a positive amount fixes it. Continuing with the same recipient and no amount, - /// as after Back from review, keeps an earlier fixed amount. - static func isAmountFixed(requestedAmount: UInt64?, wasFixed: Bool, isSameRecipient: Bool) -> Bool { + private var isAmountFixed: Bool { + fixedAmountRecipient != nil + } + + /// The recipient whose request fixes the amount. A positive requested amount fixes it for that recipient. + /// Continuing with the same recipient and no amount, as after Back from review, keeps it; any other recipient clears it. + static func fixedAmountRecipient(recipient: String, requestedAmount: UInt64?, fixedRecipient: String?) -> String? { if let requestedAmount { - return requestedAmount > 0 + return requestedAmount > 0 ? recipient : nil } - return wasFixed && isSameRecipient + return fixedRecipient?.caseInsensitiveCompare(recipient) == .orderedSame ? fixedRecipient : nil } private func readImage(_ item: PhotosPickerItem?) async { diff --git a/BitkitTests/UsdtPaymentLinkTests.swift b/BitkitTests/UsdtPaymentLinkTests.swift index d3299c6b9..2f414cc49 100644 --- a/BitkitTests/UsdtPaymentLinkTests.swift +++ b/BitkitTests/UsdtPaymentLinkTests.swift @@ -95,11 +95,15 @@ final class UsdtPaymentLinkTests: XCTestCase { } } - func testFixedAmountSurvivesContinuingWithTheSameRecipient() { - XCTAssertTrue(UsdtSendView.isAmountFixed(requestedAmount: 10001, wasFixed: false, isSameRecipient: false)) - XCTAssertFalse(UsdtSendView.isAmountFixed(requestedAmount: 0, wasFixed: true, isSameRecipient: true)) - XCTAssertTrue(UsdtSendView.isAmountFixed(requestedAmount: nil, wasFixed: true, isSameRecipient: true)) - XCTAssertFalse(UsdtSendView.isAmountFixed(requestedAmount: nil, wasFixed: true, isSameRecipient: false)) - XCTAssertFalse(UsdtSendView.isAmountFixed(requestedAmount: nil, wasFixed: false, isSameRecipient: true)) + func testFixedAmountStaysWithTheRecipientThatSetIt() { + let payee = "0x1111111111111111111111111111111111111111" + let other = "0x2222222222222222222222222222222222222222" + XCTAssertEqual(UsdtSendView.fixedAmountRecipient(recipient: payee, requestedAmount: 10001, fixedRecipient: nil), payee) + XCTAssertNil(UsdtSendView.fixedAmountRecipient(recipient: payee, requestedAmount: 0, fixedRecipient: payee)) + // Back from review then Continue with the same address keeps the fixed amount. + XCTAssertEqual(UsdtSendView.fixedAmountRecipient(recipient: payee, requestedAmount: nil, fixedRecipient: payee), payee) + // A different address typed after Back does not inherit it. + XCTAssertNil(UsdtSendView.fixedAmountRecipient(recipient: other, requestedAmount: nil, fixedRecipient: payee)) + XCTAssertNil(UsdtSendView.fixedAmountRecipient(recipient: payee, requestedAmount: nil, fixedRecipient: nil)) } } From e29bdc5cbc1c3e90cd0568524c73d9ff3fa14d83 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 18:34:46 -0300 Subject: [PATCH 19/23] fix: reset confirmed warnings for a new fixed usdt review Co-Authored-By: Claude Opus 5.5 --- Bitkit/Views/Wallets/UsdtSendView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Bitkit/Views/Wallets/UsdtSendView.swift b/Bitkit/Views/Wallets/UsdtSendView.swift index f8dae8f48..6d380402d 100644 --- a/Bitkit/Views/Wallets/UsdtSendView.swift +++ b/Bitkit/Views/Wallets/UsdtSendView.swift @@ -423,6 +423,9 @@ struct UsdtSendView: View { defer { busy = false } do { quote = try await prepareQuote() + // A new review must ask again for warnings accepted on an earlier one. + showDetails = false + confirmedWarnings = [] } catch is CancellationError { } catch { self.error = UsdtWalletManager.message(for: error) From 3b7b5c365d3d99633ad5e18a0142677f0b4e522d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 19:38:58 -0300 Subject: [PATCH 20/23] fix: pay a fixed amount from a hardware wallet savings cannot cover Co-Authored-By: Claude Opus 5.5 --- .../Views/Wallets/Send/SendConfirmationView.swift | 15 +++++++++++++++ BitkitTests/SendConfirmationSwipeTests.swift | 7 +++++++ 2 files changed, 22 insertions(+) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index e4f97f005..f7b350407 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -1203,6 +1203,11 @@ struct SendConfirmationView: View { isHardwarePayment && !isContactPayment && !afterFundingSwitch } + /// The first hardware wallet whose balance covers the amount; its fee preview decides whether it can pay. + static func hardwareWalletCovering(amount: UInt64, balances: [(walletId: String, balanceSats: UInt64)]) -> String? { + balances.first { $0.balanceSats >= amount }?.walletId + } + /// Only the newest fee calculation can hold the swipe, so a calculation superseded by a funding switch never leaves it disabled. static func isCalculatingOnchainFee(calculationId: Int?, latestCalculationId: Int) -> Bool { calculationId == latestCalculationId @@ -2024,6 +2029,16 @@ struct SendConfirmationView: View { } catch { guard isCurrentSend(), !(error is CancellationError) else { return } Logger.error("Failed to select UTXOs for on-chain request: \(error)", context: "SendConfirmationView") + // Savings cannot pay the fixed amount, but a hardware wallet can, so Confirm switches to it instead of leaving. + if let amount = wallet.sendAmountSats, + let walletId = Self.hardwareWalletCovering( + amount: amount, + balances: hwWalletManager.wallets.map { ($0.walletId, hwWalletManager.fundingBalance(walletId: $0.walletId)) } + ) + { + selectFundingSource(.hardware(walletId: walletId)) + return + } app.toast(type: .error, title: t("other__try_again"), description: error.localizedDescription) leaveAfterPreparationFailure() } diff --git a/BitkitTests/SendConfirmationSwipeTests.swift b/BitkitTests/SendConfirmationSwipeTests.swift index 618042a87..63b0511b5 100644 --- a/BitkitTests/SendConfirmationSwipeTests.swift +++ b/BitkitTests/SendConfirmationSwipeTests.swift @@ -183,6 +183,13 @@ final class SendConfirmationSwipeTests: XCTestCase { )) } + func testAFixedAmountSavingsCannotPayMovesToAHardwareWalletThatCovers() { + let balances: [(walletId: String, balanceSats: UInt64)] = [("small", 20000), ("large", 549_577)] + XCTAssertEqual(SendConfirmationView.hardwareWalletCovering(amount: 300_000, balances: balances), "large") + XCTAssertNil(SendConfirmationView.hardwareWalletCovering(amount: 600_000, balances: balances)) + XCTAssertNil(SendConfirmationView.hardwareWalletCovering(amount: 300_000, balances: [])) + } + func testOnlyTheNewestFeeCalculationHoldsTheSwipe() { XCTAssertTrue(SendConfirmationView.isCalculatingOnchainFee(calculationId: 3, latestCalculationId: 3)) // A funding switch starts a newer calculation that may not be on-chain at all. From 6a06f57777ffb0e9da620e43b34b25e6ade06883 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 19:38:58 -0300 Subject: [PATCH 21/23] test: scan a payable amount in the delayed image scan test Co-Authored-By: Claude Opus 5.5 --- BitkitTests/ScannerManagerTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/BitkitTests/ScannerManagerTests.swift b/BitkitTests/ScannerManagerTests.swift index 6e1e93706..762c1fbdf 100644 --- a/BitkitTests/ScannerManagerTests.swift +++ b/BitkitTests/ScannerManagerTests.swift @@ -171,12 +171,12 @@ final class ScannerManagerTests: XCTestCase { }) { staleRoutes.append($0) } } await fulfillment(of: [started], timeout: 2) - await scanner.handleSendScan(paymentURI + "?amount=0.000002") { route in + await scanner.handleSendScan(paymentURI + "?amount=0.00001") { route in if let route { flow.navigationPath.append(route) } } - XCTAssertEqual(app.scannedOnchainInvoice?.amountSatoshis, 200) + XCTAssertEqual(app.scannedOnchainInvoice?.amountSatoshis, 1000) let generation = app.sendStateGeneration let path = flow.navigationPath XCTAssertFalse(path.isEmpty) @@ -188,7 +188,7 @@ final class ScannerManagerTests: XCTestCase { await task.value XCTAssertEqual(sheets.activeSheetConfiguration?.presentationID, presentation.presentationID) XCTAssertEqual(app.sendStateGeneration, generation) - XCTAssertEqual(app.scannedOnchainInvoice?.amountSatoshis, 200) + XCTAssertEqual(app.scannedOnchainInvoice?.amountSatoshis, 1000) XCTAssertEqual(flow.navigationPath, path) XCTAssertTrue(staleRoutes.isEmpty) XCTAssertEqual(haptics, 1) From f39ae55e678680e6439eb019090945827e355798 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 20:21:08 -0300 Subject: [PATCH 22/23] test: expect the fixed review for a usdt uri with an amount Co-Authored-By: Claude Opus 5.5 --- BitkitUITests/UsdtWalletUITests.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/BitkitUITests/UsdtWalletUITests.swift b/BitkitUITests/UsdtWalletUITests.swift index 6ad670d7c..067eda4cf 100644 --- a/BitkitUITests/UsdtWalletUITests.swift +++ b/BitkitUITests/UsdtWalletUITests.swift @@ -300,6 +300,17 @@ final class UsdtWalletUITests: XCTestCase { "ethereum:0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9@42161/transfer?address=0x0000000000000000000000000000000000000001&uint256=1.23e6" ) app.buttons["UsdtRecipientContinue"].tap() + // A URI with an amount fixes it: the review opens instead of the editable Amount step. + XCTAssertTrue(app.descendants(matching: .any)["UsdtConfirmLoading"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["$ 1.23"].exists) + XCTAssertFalse(app.buttons["N1"].exists) + // Back, then a different recipient: Amount opens editable with the earlier amount. + try XCTUnwrap(app.buttons.matching(identifier: "NavigationBack").allElementsBoundByIndex.first(where: \.isHittable)).tap() + XCTAssertTrue(recipient.waitForExistence(timeout: 5)) + recipient.tap() + recipient.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 60)) + recipient.typeText("0x0000000000000000000000000000000000000002") + app.buttons["UsdtRecipientContinue"].tap() XCTAssertTrue(app.buttons["N1"].waitForExistence(timeout: 5)) XCTAssertTrue(app.staticTexts["$ 1.23"].exists) for digit in 4 ... 8 { From 1730836d020915f8f36e7092be005aafb118c837 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 5 Nov 2026 06:36:56 -0300 Subject: [PATCH 23/23] fix: move to a hardware wallet only when savings is short Co-Authored-By: Claude Opus 5.5 --- .../Views/Wallets/Send/SendConfirmationView.swift | 13 ++++++++++--- BitkitTests/SendConfirmationSwipeTests.swift | 15 ++++++++++++--- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index f7b350407..6d2e09aa8 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -1203,9 +1203,15 @@ struct SendConfirmationView: View { isHardwarePayment && !isContactPayment && !afterFundingSwitch } - /// The first hardware wallet whose balance covers the amount; its fee preview decides whether it can pay. - static func hardwareWalletCovering(amount: UInt64, balances: [(walletId: String, balanceSats: UInt64)]) -> String? { - balances.first { $0.balanceSats >= amount }?.walletId + /// The first hardware wallet whose balance covers the amount, used only when Savings is short of it, so any + /// other coin-selection failure is reported instead. The wallet's fee preview decides whether it can pay. + static func hardwareWalletCovering( + amount: UInt64, + savingsBalanceSats: UInt64, + balances: [(walletId: String, balanceSats: UInt64)] + ) -> String? { + guard savingsBalanceSats < amount else { return nil } + return balances.first { $0.balanceSats >= amount }?.walletId } /// Only the newest fee calculation can hold the swipe, so a calculation superseded by a funding switch never leaves it disabled. @@ -2033,6 +2039,7 @@ struct SendConfirmationView: View { if let amount = wallet.sendAmountSats, let walletId = Self.hardwareWalletCovering( amount: amount, + savingsBalanceSats: UInt64(max(wallet.spendableOnchainBalanceSats, 0)), balances: hwWalletManager.wallets.map { ($0.walletId, hwWalletManager.fundingBalance(walletId: $0.walletId)) } ) { diff --git a/BitkitTests/SendConfirmationSwipeTests.swift b/BitkitTests/SendConfirmationSwipeTests.swift index 63b0511b5..d7ebce872 100644 --- a/BitkitTests/SendConfirmationSwipeTests.swift +++ b/BitkitTests/SendConfirmationSwipeTests.swift @@ -185,9 +185,18 @@ final class SendConfirmationSwipeTests: XCTestCase { func testAFixedAmountSavingsCannotPayMovesToAHardwareWalletThatCovers() { let balances: [(walletId: String, balanceSats: UInt64)] = [("small", 20000), ("large", 549_577)] - XCTAssertEqual(SendConfirmationView.hardwareWalletCovering(amount: 300_000, balances: balances), "large") - XCTAssertNil(SendConfirmationView.hardwareWalletCovering(amount: 600_000, balances: balances)) - XCTAssertNil(SendConfirmationView.hardwareWalletCovering(amount: 300_000, balances: [])) + XCTAssertEqual( + SendConfirmationView.hardwareWalletCovering(amount: 300_000, savingsBalanceSats: 209_714, balances: balances), + "large" + ) + XCTAssertNil(SendConfirmationView.hardwareWalletCovering(amount: 600_000, savingsBalanceSats: 209_714, balances: balances)) + XCTAssertNil(SendConfirmationView.hardwareWalletCovering(amount: 300_000, savingsBalanceSats: 209_714, balances: [])) + } + + func testACoinSelectionFailureWithEnoughSavingsDoesNotMoveToAHardwareWallet() { + let balances: [(walletId: String, balanceSats: UInt64)] = [("large", 549_577)] + XCTAssertNil(SendConfirmationView.hardwareWalletCovering(amount: 10000, savingsBalanceSats: 209_714, balances: balances)) + XCTAssertNil(SendConfirmationView.hardwareWalletCovering(amount: 209_714, savingsBalanceSats: 209_714, balances: balances)) } func testOnlyTheNewestFeeCalculationHoldsTheSwipe() {