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..cf5fcab78 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,34 @@ 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 { + // 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 + } + static func confirmRouteAfterQuickPayCap(app: AppViewModel) -> SendRoute { if let lnurlPayData = app.lnurlPayData { return lnurlPayData.isFixedAmount ? .lnurlPayConfirm : .lnurlPayAmount 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/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 940e92d29..5ee055bc1 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -1793,6 +1793,24 @@ 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 = [] + } + + /// 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 + } + 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/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 9e5f470f3..6d2e09aa8 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -42,6 +42,8 @@ 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 onchainFeeCalculationId: Int? @State private var presentationID: UUID? private struct WalletSwitchRun { @@ -246,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? { @@ -447,6 +459,7 @@ struct SendConfirmationView: View { if prepareRequestCoinsIfNeeded() { return } + await selectCoinsForDirectOnchainSendIfNeeded() await calculateTransactionFee() await calculateRoutingFee() guard !Task.isCancelled else { return } @@ -465,6 +478,7 @@ struct SendConfirmationView: View { .onChange(of: wallet.selectedFeeRateSatsPerVByte) { guard preparingRequest == nil, !prepareRequestCoinsIfNeeded() else { return } Task { + await selectCoinsForDirectOnchainSendIfNeeded() await calculateTransactionFee() } } @@ -509,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"), @@ -858,6 +872,10 @@ struct SendConfirmationView: View { guard !isSubmittingPayment else { return } 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. @@ -1144,9 +1162,11 @@ struct SendConfirmationView: View { isPreparingRequest: Bool = false, isSetupPending: Bool = false, isPreparingWallet: Bool = false, - isAmountReviewRequired: Bool = false + isAmountReviewRequired: Bool = false, + isPreparingOnchainSend: Bool = false ) -> Bool { - isAmountReviewRequired || isPreparingRequest || isSetupPending || isPreparingWallet || isHardwareConfirmationUnavailable + isAmountReviewRequired || isPreparingRequest || isSetupPending || isPreparingWallet || isPreparingOnchainSend + || isHardwareConfirmationUnavailable || isFeeRateMissing(walletType: walletType, isHardwarePayment: isHardwarePayment, feeRate: feeRate) } @@ -1167,10 +1187,38 @@ struct SendConfirmationView: View { isPreparingRequest: preparingRequest != nil, isSetupPending: isRequestSetupPending, isPreparingWallet: walletSwitchContext != nil, - isAmountReviewRequired: requiresAmountReview + isAmountReviewRequired: requiresAmountReview, + isPreparingOnchainSend: isPreparingOnchainSend ) } + private var isPreparingOnchainSend: Bool { + isPreparingDirectOnchainSend + || Self.isCalculatingOnchainFee(calculationId: onchainFeeCalculationId, latestCalculationId: feeCalculationId) + } + + /// 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 + } + + /// 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. + static func isCalculatingOnchainFee(calculationId: Int?, latestCalculationId: Int) -> Bool { + calculationId == latestCalculationId + } + private func requiresManualConfirmation(isAutomatic: Bool) -> Bool { Self.requiresManualConfirmation( isAutomatic: isAutomatic, @@ -1827,7 +1875,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) { @@ -1949,6 +2011,46 @@ 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. + /// 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, + 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 + } + + isPreparingDirectOnchainSend = true + defer { isPreparingDirectOnchainSend = false } + 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") + // 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, + savingsBalanceSats: UInt64(max(wallet.spendableOnchainBalanceSats, 0)), + 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() + } + } + /// Recalculates until the newest calculation is the one that finished, so a concurrent one cannot leave the fee unset. @MainActor private func settleTransactionFee() async { @@ -1962,7 +2064,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 @@ -2035,6 +2137,13 @@ struct SendConfirmationView: View { return } + // A swipe before the fee is known would skip the fee warnings and record a zero fee. + onchainFeeCalculationId = requestId + defer { + if onchainFeeCalculationId == requestId { + onchainFeeCalculationId = nil + } + } let useMaxAmount = await shouldUseMaxOnchainSend( address: address, amountSats: amountSats, feeRate: feeRate, isCurrentSend: isCurrentSend ) @@ -2060,6 +2169,17 @@ struct SendConfirmationView: View { guard isCurrentSend(), !(error is CancellationError) else { return } Logger.error("Failed to calculate actual fee: \(error)") transactionFee = 0 + if hwSend.isActive, app.contactPaymentContext == nil { + app.toast(type: .error, title: t("other__try_again"), description: error.localizedDescription) + if Self.leavesConfirmAfterFeeFailure( + isHardwarePayment: true, + isContactPayment: false, + afterFundingSwitch: afterFundingSwitch + ) { + leaveAfterPreparationFailure() + } + return + } if app.contactPaymentContext?.incomingPaymentRequest != nil { returnToAmountReview() } diff --git a/Bitkit/Views/Wallets/UsdtSendView.swift b/Bitkit/Views/Wallets/UsdtSendView.swift index 3c8c4f9dc..6d380402d 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 + /// 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? @@ -81,6 +83,9 @@ struct UsdtSendView: View { _recipient = State(initialValue: initialRecipient) _amount = State(initialValue: (try? usdtParseAmount(value: initialAmount)) == 0 ? "" : initialAmount) _editingAmount = State(initialValue: !initialRecipient.isEmpty) + _fixedAmountRecipient = State( + initialValue: (try? usdtParseAmount(value: initialAmount)).map { $0 > 0 } == true ? initialRecipient : nil + ) } private var paymentRequest: PaykitPaymentRequest? { @@ -137,7 +142,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 +252,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 +394,44 @@ 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() + // 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) + } + } + private var amountContent: some View { VStack(alignment: .leading, spacing: 0) { UsdtAmountHeader(amount: amount.isEmpty ? "0" : amount) @@ -413,7 +458,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 +508,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 +542,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) @@ -671,6 +721,11 @@ struct UsdtSendView: View { if request.chainId != nil { destination = .arbitrum } + fixedAmountRecipient = Self.fixedAmountRecipient( + recipient: request.recipient, + requestedAmount: request.amount, + fixedRecipient: fixedAmountRecipient + ) recipient = request.recipient if let requestedAmount = request.amount { amount = requestedAmount == 0 ? "" : usdtFormatAmount(amount: requestedAmount) @@ -681,6 +736,22 @@ struct UsdtSendView: View { recipientFocused = false editingAmount = true error = nil + if isAmountFixed { + Task { await quoteFixedAmount() } + } + } + + 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 ? recipient : nil + } + return fixedRecipient?.caseInsensitiveCompare(recipient) == .orderedSame ? fixedRecipient : nil } private func readImage(_ item: PhotosPickerItem?) async { @@ -705,7 +776,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 diff --git a/BitkitTests/PaymentNavigationHelperTests.swift b/BitkitTests/PaymentNavigationHelperTests.swift index dd51c00d0..f0f0afb21 100644 --- a/BitkitTests/PaymentNavigationHelperTests.swift +++ b/BitkitTests/PaymentNavigationHelperTests.swift @@ -320,6 +320,64 @@ 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 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) + + 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) + + 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 +441,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/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) diff --git a/BitkitTests/SendConfirmationSwipeTests.swift b/BitkitTests/SendConfirmationSwipeTests.swift index f0ef49241..d7ebce872 100644 --- a/BitkitTests/SendConfirmationSwipeTests.swift +++ b/BitkitTests/SendConfirmationSwipeTests.swift @@ -163,6 +163,59 @@ 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 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 testAFixedAmountSavingsCannotPayMovesToAHardwareWalletThatCovers() { + let balances: [(walletId: String, balanceSats: UInt64)] = [("small", 20000), ("large", 549_577)] + 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() { + 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, + isHardwarePayment: false, + isHardwareConfirmationUnavailable: false, + feeRate: 5, + isPreparingOnchainSend: true + )) + } + func testPendingRequestPreparationKeepsSwipeDisabledAndLoadingWithKnownFees() { for (preparingRequest, setupPending) in [(true, false), (false, true)] { for walletType in [WalletType.lightning, .onchain] { diff --git a/BitkitTests/UsdtPaymentLinkTests.swift b/BitkitTests/UsdtPaymentLinkTests.swift index 46e007c93..2f414cc49 100644 --- a/BitkitTests/UsdtPaymentLinkTests.swift +++ b/BitkitTests/UsdtPaymentLinkTests.swift @@ -94,4 +94,16 @@ final class UsdtPaymentLinkTests: XCTestCase { XCTAssertNil(try resolveUsdtPaymentRoute(input: input, isEnabled: true), input) } } + + 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)) + } } diff --git a/BitkitTests/WalletViewModelSendTests.swift b/BitkitTests/WalletViewModelSendTests.swift index 0671ce917..c4b134ac3 100644 --- a/BitkitTests/WalletViewModelSendTests.swift +++ b/BitkitTests/WalletViewModelSendTests.swift @@ -60,6 +60,23 @@ 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.isMaxAmountSend = true + wallet.restoreScannedSendAmount(50000) + XCTAssertEqual(wallet.sendAmountSats, 50000) + XCTAssertNil(wallet.selectedUtxos) + XCTAssertFalse(wallet.isMaxAmountSend) + + wallet.selectedUtxos = [utxo] + wallet.restoreScannedSendAmount(50000) + XCTAssertEqual(wallet.selectedUtxos?.map(\.valueSats), [20000]) + } + func testSoftwareMaxUsesSpendableOutputsInsteadOfFixedAmountSelection() async throws { let wallet = WalletViewModel() wallet.sendAmountSats = 99819 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 { diff --git a/changelog.d/next/1198.fixed.md b/changelog.d/next/1198.fixed.md new file mode 100644 index 000000000..8420998ba --- /dev/null +++ b/changelog.d/next/1198.fixed.md @@ -0,0 +1 @@ +Scanning or pasting a Bitcoin payment request that includes an amount 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..8ff9e73bb --- /dev/null +++ b/journeys/send/scan-onchain-amount-to-confirm.xml @@ -0,0 +1,27 @@ + + + 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. 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 + 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 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/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 + + diff --git a/journeys/usdt-payment-links-and-scans.xml b/journeys/usdt-payment-links-and-scans.xml index fd21ddf03..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. 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. + 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. + 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.