Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2a2f798
fix: open confirm for scanned onchain requests with an amount
jvsena42 Nov 3, 2026
ab67cb4
fix: keep swipe disabled while direct confirm prepares coins
jvsena42 Nov 3, 2026
d6ffc48
fix: keep swipe disabled while the onchain fee is calculating
jvsena42 Nov 3, 2026
32d870b
fix: let only the newest fee calculation hold the swipe
jvsena42 Nov 4, 2026
28828c2
fix: open amount for onchain requests below the minimum
jvsena42 Nov 4, 2026
d8c5cc9
docs: update usdt clipboard journey for direct review
jvsena42 Nov 4, 2026
4f8ed4c
chore: narrow changelog to bip21 routing
jvsena42 Nov 4, 2026
6674aa2
fix: clear coins chosen for an edited amount on restore
jvsena42 Nov 4, 2026
fe8a33a
fix: return to amount when a hardware fee preview fails
jvsena42 Nov 4, 2026
41b3656
fix: keep a scanned amount fixed on confirm
jvsena42 Nov 4, 2026
03b6b56
fix: clear a stale max choice when restoring the scanned amount
jvsena42 Nov 4, 2026
122d07a
fix: reject a scanned onchain amount below the minimum
jvsena42 Nov 4, 2026
4484434
fix: open the usdt review for a scanned amount
jvsena42 Nov 4, 2026
94b3f5d
docs: update journeys for fixed scanned amounts
jvsena42 Nov 4, 2026
19d6be4
fix: keep a scanned usdt amount fixed after back
jvsena42 Nov 4, 2026
f361337
docs: cover back and zero amounts in the usdt journey
jvsena42 Nov 4, 2026
af79332
fix: keep confirm when a funding switch cannot be prepared
jvsena42 Nov 4, 2026
9706c11
fix: tie a fixed usdt amount to the recipient that set it
jvsena42 Nov 4, 2026
e29bdc5
fix: reset confirmed warnings for a new fixed usdt review
jvsena42 Nov 4, 2026
3b7b5c3
fix: pay a fixed amount from a hardware wallet savings cannot cover
jvsena42 Nov 4, 2026
6a06f57
test: scan a payable amount in the delayed image scan test
jvsena42 Nov 4, 2026
f39ae55
test: expect the fixed review for a usdt uri with an amount
jvsena42 Nov 4, 2026
1730836
fix: move to a hardware wallet only when savings is short
jvsena42 Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Bitkit/Managers/ScannerManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
34 changes: 21 additions & 13 deletions Bitkit/Utilities/PaymentNavigationHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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 {
Comment thread
jvsena42 marked this conversation as resolved.
Resolved
// 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
Comment thread
jvsena42 marked this conversation as resolved.
Resolved
}

static func confirmRouteAfterQuickPayCap(app: AppViewModel) -> SendRoute {
if let lnurlPayData = app.lnurlPayData {
return lnurlPayData.isFixedAmount ? .lnurlPayConfirm : .lnurlPayAmount
Expand Down
6 changes: 4 additions & 2 deletions Bitkit/ViewModels/AppViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
}
Expand Down
18 changes: 18 additions & 0 deletions Bitkit/ViewModels/WalletViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Bitkit/Views/Wallets/Send/SendAmountView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
134 changes: 127 additions & 7 deletions Bitkit/Views/Wallets/Send/SendConfirmationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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? {
Expand Down Expand Up @@ -447,6 +459,7 @@ struct SendConfirmationView: View {
if prepareRequestCoinsIfNeeded() {
return
}
await selectCoinsForDirectOnchainSendIfNeeded()
await calculateTransactionFee()
await calculateRoutingFee()
guard !Task.isCancelled else { return }
Expand All @@ -465,6 +478,7 @@ struct SendConfirmationView: View {
.onChange(of: wallet.selectedFeeRateSatsPerVByte) {
guard preparingRequest == nil, !prepareRequestCoinsIfNeeded() else { return }
Task {
await selectCoinsForDirectOnchainSendIfNeeded()
await calculateTransactionFee()
}
}
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}

Expand All @@ -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,
Expand Down Expand Up @@ -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() {
Comment thread
jvsena42 marked this conversation as resolved.
Resolved
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) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
)
Expand All @@ -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()
}
Expand Down
Loading
Loading