From 934e85ed069d9d2064ddd7414e8adee06a0f5ff3 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 27 Jul 2026 08:15:51 -0400 Subject: [PATCH] feat: pay onchain address from spending balance Sending to an onchain address for more than the savings balance currently dead-ends on an "insufficient savings" toast, even when spending could cover it. Add a fallback that pays the address out of spending through a Boltz reverse swap, using the recipient's address as the swap claim address so Boltz's payout lands on them directly. Ports bitkit-android #1101. - New SendSwapService owns the swap business logic: quoting, bounds, and the pay-then-await-claim orchestration. TransferViewModel keeps its own direct BoltzService use for the transfer to savings. - boltzCreateReverseSwap prices from the Lightning invoice amount, while a send starts from what the recipient must receive, so the quote inverts Boltz's forward formula. It ceils the percentage fee to match ReverseFees in boltz-client, not the round-to-nearest the savings quote uses. - Boltz reports the amount it will lock before anything is paid, so a swap that would short the recipient is abandoned before the invoice is paid. The hold invoice amount is checked against the quote, and the recipient address is re-validated for network, before paying. - isSwapSend is orthogonal to selectedWalletToPayFrom, which stays onchain: the recipient is still an address and the whole flow keys off the onchain invoice. - The amount screen raises its cap to what a swap can deliver so an amount above savings can be entered, then falls back to savings automatically once the amount fits there again while keeping the bounds so it can be raised again. - The review screen reads "From: Spending" with a single Service fee cell in place of "Confirming in", mirroring the savings confirm screen, and skips coin selection since a swap spends no utxos. - Per-keystroke pricing is sequence guarded so a slow re-price cannot overwrite the current quote, and an unpriceable swap surfaces an error instead of wedging the review screen. - Gated behind the existing isSwapEnabled dev flag, so nothing changes for users yet. Not in scope, worth a follow-up: - The payment shows in Activity as a Lightning SENT row for the gross amount; the claim tx paying the recipient is one ldk-node never sees. - BoltzSwap carries no intent field, so a send swap and a savings swap are indistinguishable in history. - The claim fee rate is the global one the updates stream was started with, so the send has no per-payment speed choice and the fee cell is not editable. Tests cover the inverse fee math (round-trip, ceil boundary, min/max limits, spendable balance, degenerate fee schedules), the savings-versus-swap resolution including keeping bounds on revert, and the swaps-disabled fallback. --- Bitkit/Services/SendSwapService.swift | 396 ++++++++++++++++++ Bitkit/ViewModels/AppViewModel.swift | 166 +++++++- .../Views/Wallets/Send/SendAmountView.swift | 89 +++- .../Wallets/Send/SendConfirmationView.swift | 237 +++++++++-- Bitkit/Views/Wallets/Send/SendSheet.swift | 30 +- Bitkit/Views/Wallets/Send/SendSuccess.swift | 4 +- BitkitTests/SendSwapTests.swift | 225 ++++++++++ 7 files changed, 1083 insertions(+), 64 deletions(-) create mode 100644 Bitkit/Services/SendSwapService.swift create mode 100644 BitkitTests/SendSwapTests.swift diff --git a/Bitkit/Services/SendSwapService.swift b/Bitkit/Services/SendSwapService.swift new file mode 100644 index 000000000..7e1ba1c71 --- /dev/null +++ b/Bitkit/Services/SendSwapService.swift @@ -0,0 +1,396 @@ +import BitkitCore +import Foundation +import LDKNode + +/// Cost of delivering `deliverSat` on-chain out of the spending balance. +struct SendSwapQuote: Equatable { + /// What the recipient receives on-chain. + let deliverSat: UInt64 + /// What the Lightning invoice pays, i.e. what leaves the spending balance. + let invoiceSat: UInt64 + /// Everything the swap costs on top of `deliverSat`: Boltz's cut plus miner fees. + let serviceFeeSat: UInt64 + /// Boltz's lockup and claim miner fee estimate, already included in `serviceFeeSat`. + let networkFeeSat: UInt64 + + /// Boltz's percentage fee expressed as a fraction, clamped so the inversion below always + /// converges even if the pair ever reports a nonsensical rate. A non-finite percentage would + /// otherwise poison every UInt64 conversion downstream, so it is coerced to zero. + private static func rate(_ limits: BoltzPairInfo) -> Double { + guard limits.feePercentage.isFinite else { return 0 } + return min(max(limits.feePercentage / 100, 0), 0.99) + } + + /// What a reverse swap of `invoiceSat` leaves on-chain. Mirrors the forward pricing Boltz + /// applies: it charges `ceil(percentage% * invoice)` and takes the miner fees on top. The + /// savings quote rounds this fee to nearest instead, which is a fraction of a sat out and + /// harmless there because the user keeps the remainder; here it would short the recipient. + static func delivered(invoiceSat: UInt64, limits: BoltzPairInfo) -> UInt64 { + let serviceFee = UInt64((rate(limits) * Double(invoiceSat)).rounded(.up)) + let cost = serviceFee + limits.minerFeesSat + return invoiceSat > cost ? invoiceSat - cost : 0 + } + + /// Solve for the Lightning invoice amount that leaves exactly `deliverSat` on-chain. Seeded + /// from the closed form, then corrected against the integer forward formula so rounding can + /// never leave the recipient a satoshi short of what the review screen promised. + static func build(deliverSat: UInt64, limits: BoltzPairInfo) -> SendSwapQuote { + let seed = (Double(deliverSat) + Double(limits.minerFeesSat)) / (1 - rate(limits)) + var invoiceSat = max(UInt64(seed.rounded(.up)), deliverSat) + + while delivered(invoiceSat: invoiceSat, limits: limits) < deliverSat { + invoiceSat += 1 + } + while invoiceSat > 0, delivered(invoiceSat: invoiceSat - 1, limits: limits) >= deliverSat { + invoiceSat -= 1 + } + + return SendSwapQuote( + deliverSat: deliverSat, + invoiceSat: invoiceSat, + serviceFeeSat: invoiceSat > deliverSat ? invoiceSat - deliverSat : 0, + networkFeeSat: limits.minerFeesSat + ) + } + + /// Largest amount a swap can deliver on-chain given what the spending balance can pay. + static func maxDeliverable(sendableSat: UInt64, limits: BoltzPairInfo) -> UInt64 { + delivered(invoiceSat: min(limits.maximalSat, sendableSat), limits: limits) + } + + /// Smallest amount worth offering: the swap minimum applies to the invoice, so the delivered + /// equivalent is walked up until its quote clears that minimum. Rounding puts this within a + /// couple of satoshis, and each step raises the invoice by at least one, so it converges. + static func minDeliverable(limits: BoltzPairInfo) -> UInt64 { + var deliverSat = delivered(invoiceSat: limits.minimalSat, limits: limits) + while build(deliverSat: deliverSat, limits: limits).invoiceSat < limits.minimalSat { + deliverSat += 1 + } + return deliverSat + } +} + +/// The bounds the amount screen enforces for a swap send, in delivered satoshis. +struct SendSwapBounds: Equatable { + let minDeliverSat: UInt64 + let maxDeliverSat: UInt64 + + var isUsable: Bool { maxDeliverSat > 0 && maxDeliverSat >= minDeliverSat } +} + +/// What a completed swap send leaves behind. The Lightning leg is settled either way. +struct SendSwapReceipt: Equatable { + /// Hash of the hold invoice payment, i.e. the activity the send produces. + let paymentHash: String + /// Transaction paying the recipient, nil while the updates stream is still broadcasting it. + let claimTxId: String? +} + +/// Pays an on-chain address out of the spending balance with a Boltz reverse swap, so a send can +/// go through when savings are short but spending is not. +/// +/// A reverse swap claims to any address, so the recipient's address is used as the claim address +/// and Boltz's payout lands directly on them. Boltz prices a reverse swap from the Lightning +/// invoice amount while a send starts from the amount the recipient must receive, so the quote +/// here inverts the forward pricing `SavingsSwapQuote` does for the transfer to savings. +@MainActor +class SendSwapService { + static let shared = SendSwapService() + + private let boltzService: BoltzService + private let lightningService: LightningService + + private var cachedLimits: BoltzPairInfo? + private var cachedLimitsAt: Date? + + /// Upper bound for fetching swap limits before the send flow gives up on a quote. + private let limitsTimeout: TimeInterval = 15 + /// How long cached pair terms are reused before Boltz is asked again. The send flow re-prices + /// as the amount changes and the terms only move with Boltz's fee schedule. + private let limitsTtl: TimeInterval = 60 + /// How long the send waits for the on-chain claim before backgrounding it. + private let claimTimeout: TimeInterval = 30 + /// Minimum sats held back from a swap to cover Lightning routing fees. + private static let minLnRoutingFeeReserveSats: UInt64 = 10 + + init(boltzService: BoltzService = .shared, lightningService: LightningService = .shared) { + self.boltzService = boltzService + self.lightningService = lightningService + } + + // MARK: - Quoting + + /// Bounds a swap can deliver right now, so the amount screen can cap its input without + /// pricing every keystroke against Boltz. Nil whenever no swap is on offer. + func bounds() async -> SendSwapBounds? { + guard let limits = await limits() else { return nil } + + let bounds = SendSwapBounds( + minDeliverSat: SendSwapQuote.minDeliverable(limits: limits), + maxDeliverSat: SendSwapQuote.maxDeliverable(sendableSat: sendableLightningSats(), limits: limits) + ) + return bounds.isUsable ? bounds : nil + } + + /// Price a swap that delivers exactly `deliverSat` on-chain. Nil when swaps are off, Boltz is + /// unreachable, or the amount falls outside the swap limits or the spendable balance, so + /// callers can simply not offer the swap. + func quote(deliverSat: UInt64) async -> SendSwapQuote? { + guard deliverSat > 0 else { return nil } + guard let limits = await limits() else { return nil } + + // A swap can never deliver more than it locks, and it locks at most `maximalSat`, so an + // amount above that is unquotable. Bailing here also keeps the quote math off absurd + // inputs (e.g. a crafted BIP21 amount near UInt64.max) that would otherwise overflow. + guard deliverSat <= limits.maximalSat else { + Logger.debug("Swap send of \(deliverSat) sat is above the swap maximum", context: "SendSwapService") + return nil + } + + let quote = SendSwapQuote.build(deliverSat: deliverSat, limits: limits) + + guard quote.invoiceSat >= limits.minimalSat, quote.invoiceSat <= limits.maximalSat else { + Logger.debug("Swap send of \(quote.invoiceSat) sat is outside the swap limits", context: "SendSwapService") + return nil + } + guard quote.invoiceSat <= sendableLightningSats() else { + Logger.debug("Swap send of \(quote.invoiceSat) sat is over the spendable balance", context: "SendSwapService") + return nil + } + guard lightningService.canSend(amountSats: quote.invoiceSat) else { + Logger.debug("Swap send of \(quote.invoiceSat) sat cannot be routed", context: "SendSwapService") + return nil + } + + return quote + } + + // MARK: - Paying + + /// Create the swap, pay its hold invoice over Lightning and wait for the on-chain claim. + /// + /// Boltz reports the amount it will lock before anything is paid, so a swap that would short + /// the recipient is abandoned while it is still free to abandon. Once the invoice is paid the + /// claim is broadcast by the updates stream, so a timeout waiting for it leaves + /// `SendSwapReceipt.claimTxId` nil rather than failing the send. + /// + /// `onInvoicePrepared` runs with the hold invoice's payment hash just before it is paid, so + /// the caller can register the activity metadata the send will produce. `onEvent`/`removeEvent` + /// register a node event listener so an unroutable payment can be observed. + func payToAddress( + address: String, + deliverSat: UInt64, + onInvoicePrepared: (String) async -> Void, + onEvent: @escaping (String, @escaping (Event) -> Void) -> Void, + removeEvent: @escaping (String) -> Void + ) async throws -> SendSwapReceipt { + guard let quote = await quote(deliverSat: deliverSat) else { + throw AppError(message: t("other__try_again"), debugMessage: "No swap quote covers \(deliverSat) sat") + } + + // Boltz never verifies the claim address, and a lockup to a wrong-network address is + // unrecoverable, so re-check it here rather than trusting the decode that scanned it. + try validateClaimAddress(address) + + // Subscribe before creating the swap so a claim settling faster than the payment call + // returns is not missed; events buffer from the moment the stream is created. + let events = boltzService.events() + + let swap = try await boltzService.createReverseSwap(amountSat: quote.invoiceSat, claimAddress: address) + Logger.info("Created send swap \(swap.id) delivering \(deliverSat) sat", context: "SendSwapService") + + // Boltz reports what it will lock before anything is paid. The quote already holds back + // Boltz's own claim fee estimate, so a lockup below the delivered amount means Boltz + // priced on different terms than we quoted and the send is abandoned for free. + guard swap.onchainAmountSat >= deliverSat else { + throw AppError( + message: t("other__try_again"), + debugMessage: "Boltz locks \(swap.onchainAmountSat) sat, short of \(deliverSat) sat" + ) + } + + // The invoice is what actually leaves the spending balance, and it is Boltz-supplied, so + // confirm it encodes the amount we quoted before paying it blind. A mismatch means Boltz + // priced on different terms; abandon while it is still free to abandon. + let holdInvoice = try await decodeLightning(swap.invoice) + guard holdInvoice.amountSatoshis == quote.invoiceSat else { + throw AppError( + message: t("other__try_again"), + debugMessage: "Hold invoice is \(holdInvoice.amountSatoshis) sat, quoted \(quote.invoiceSat) sat" + ) + } + + let paymentHash = holdInvoice.paymentHash.hex + await onInvoicePrepared(paymentHash) + + // Pay the hold invoice. `send` returns a payment id as soon as the HTLC is dispatched; a + // hold invoice never settles until Boltz claims on-chain, so this does not block on the + // claim. A failure to dispatch the payment throws and is surfaced immediately. + let paymentId = try await lightningService.send(bolt11: swap.invoice) + + let claimTxId = try await awaitClaim( + swapId: swap.id, + paymentId: paymentId, + events: events, + onEvent: onEvent, + removeEvent: removeEvent + ) + + return SendSwapReceipt(paymentHash: paymentHash, claimTxId: claimTxId) + } + + /// Wait for whichever swap outcome resolves first. `send` returning does not mean the payment + /// routed, and a paid hold invoice never settles until the on-chain claim, so an unroutable + /// payment would otherwise idle into the claim timeout and read as a completed send. Watching + /// the node's payment-failed event alongside the claim surfaces it as a failure instead. A + /// timeout is not a failure: the updates stream broadcasts the claim in the background. + private func awaitClaim( + swapId: String, + paymentId: String, + events: AsyncStream, + onEvent: @escaping (String, @escaping (Event) -> Void) -> Void, + removeEvent: @escaping (String) -> Void + ) async throws -> String? { + let eventId = "send-swap-\(swapId)" + let paymentFailure = SwapPaymentFailureCapture() + + // LDK events are dispatched on the main actor, so this handler runs there. The returned + // payment id may land in either the event's payment id or its payment hash field. + onEvent(eventId) { event in + guard case let .paymentFailed(eventPaymentId, eventPaymentHash, _) = event else { return } + guard [eventPaymentId, eventPaymentHash].compactMap({ $0 }).contains(paymentId) else { return } + Task { await paymentFailure.markFailed() } + } + + // Capture main-actor state locally so the detached group tasks stay Sendable. + let timeout = claimTimeout + let paymentFailedMessage = t("wallet__toast_payment_failed_description") + + let outcome = await withTaskGroup(of: SendSwapClaimOutcome?.self) { group in + // On-chain claim or a Boltz-side error. + group.addTask { + for await event in events { + switch event { + case let .claimed(swapId: id, txid: txid) where id == swapId: + return .claimed(txid: txid) + case let .error(swapId: id, message: message) where id == swapId: + return .failed(message: message) + default: + continue + } + } + return nil + } + // Lightning routing failure on the paid hold invoice. + group.addTask { + while !Task.isCancelled { + if await paymentFailure.hasFailed { + return .failed(message: paymentFailedMessage) + } + try? await Task.sleep(nanoseconds: 200_000_000) + } + return nil + } + // Bounded wait: a timeout is not a failure, the claim settles in the background. + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + return .settling + } + + var result: SendSwapClaimOutcome = .settling + for await value in group { + if let value { + result = value + break + } + } + group.cancelAll() + return result + } + + removeEvent(eventId) + + switch outcome { + case let .claimed(txid): + return txid + case .settling: + return nil + case let .failed(message): + throw AppError(message: message, debugMessage: "Send swap \(swapId) failed") + } + } + + // MARK: - Helpers + + /// Pair terms, cached briefly. Nil whenever swaps are unavailable, which is the only signal + /// callers need: without terms there is no swap to offer. + private func limits() async -> BoltzPairInfo? { + guard boltzService.isSwapEnabled else { return nil } + + if let cachedLimits, let cachedLimitsAt, Date().timeIntervalSince(cachedLimitsAt) < limitsTtl { + return cachedLimits + } + + guard let fresh = await fetchReverseLimits() else { return nil } + cachedLimits = fresh + cachedLimitsAt = Date() + return fresh + } + + /// Bounded so a hanging Boltz request cannot leave the send flow stuck loading. + private func fetchReverseLimits() async -> BoltzPairInfo? { + let timeout = limitsTimeout + let service = boltzService + + return await withTaskGroup(of: BoltzPairInfo?.self) { group in + group.addTask { + do { + return try await service.reverseLimits() + } catch { + Logger.error("Failed to load reverse swap limits", context: error.localizedDescription) + return nil + } + } + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + return nil + } + defer { group.cancelAll() } + return await group.next() ?? nil + } + } + + /// Lightning outbound minus a routing reserve. Paying an invoice for 100% of outbound capacity + /// leaves nothing for fees and fails to route. + private func sendableLightningSats() -> UInt64 { + let spendable = (lightningService.channels ?? []) + .filter(\.isUsable) + .map(\.nextOutboundHtlcLimitMsat) + .reduce(0, +) / 1000 + let reserve = max(spendable / 100, Self.minLnRoutingFeeReserveSats) + return spendable > reserve ? spendable - reserve : 0 + } + + private func validateClaimAddress(_ address: String) throws { + let validation = try? validateBitcoinAddress(address: address) + let addressNetwork = validation.map { NetworkValidationHelper.convertNetworkType($0.network) } + guard !NetworkValidationHelper.isNetworkMismatch(addressNetwork: addressNetwork, currentNetwork: Env.network) else { + throw AppError(message: t("other__scan_err_decoding"), debugMessage: "Claim address is not on \(Env.network)") + } + } + + /// Decode the Boltz hold invoice so its amount and payment hash can be inspected before paying. + private func decodeLightning(_ bolt11: String) async throws -> LightningInvoice { + guard case let .lightning(invoice) = try await decode(invoice: bolt11) else { + throw AppError(message: t("other__try_again"), debugMessage: "Boltz returned an invoice that is not a bolt11") + } + return invoice + } +} + +/// How a paid swap resolved while the send was still on screen. +private enum SendSwapClaimOutcome { + case claimed(txid: String) + case settling + case failed(message: String) +} diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 5598341ce..b1bd61db7 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -34,6 +34,14 @@ class AppViewModel: ObservableObject { @Published var manualEntryValidationResult: ManualEntryValidationResult = .empty @Published var contactPaymentContext: ContactPaymentContext? + // Swap send: paying an on-chain address out of the spending balance via a Boltz reverse swap. + // `selectedWalletToPayFrom` stays `.onchain` because the recipient is still an address and the + // whole send flow keys off `scannedOnchainInvoice`; only the funding source changes. + @Published var isSwapSend = false + @Published var sendSwapQuote: SendSwapQuote? + @Published var sendSwapBounds: SendSwapBounds? + private var swapSendUpdateSequence: UInt64 = 0 + // LNURL @Published var lnurlPayData: LnurlPayData? @Published var lnurlWithdrawData: LnurlWithdrawData? @@ -86,6 +94,7 @@ class AppViewModel: ObservableObject { private let lightningService: LightningService private let coreService: CoreService + private let swapService: SendSwapService private let sheetViewModel: SheetViewModel private let navigationViewModel: NavigationViewModel private var manualEntryValidationSequence: UInt64 = 0 @@ -97,11 +106,13 @@ class AppViewModel: ObservableObject { init( lightningService: LightningService = .shared, coreService: CoreService = .shared, + swapService: SendSwapService = .shared, sheetViewModel: SheetViewModel, navigationViewModel: NavigationViewModel ) { self.lightningService = lightningService self.coreService = coreService + self.swapService = swapService self.sheetViewModel = sheetViewModel self.navigationViewModel = navigationViewModel @@ -145,6 +156,12 @@ class AppViewModel: ObservableObject { ) } + /// Whether savings alone can cover the send. A zero-amount invoice only needs some balance, + /// since the amount screen validates what the user then enters. + func hasSufficientOnchainBalance(invoiceAmount: UInt64, onchainBalance: UInt64) -> Bool { + invoiceAmount > 0 ? onchainBalance >= invoiceAmount : onchainBalance > 0 + } + /// Validates onchain balance and shows toast if insufficient. Returns true if sufficient. private func validateOnchainBalance(invoiceAmount: UInt64, onchainBalance: UInt64) -> Bool { if invoiceAmount > 0 { @@ -447,7 +464,7 @@ extension AppViewModel { // usable channels without capacity). // Fall back to onchain and validate onchain balance immediately. let onchainBalance = lightningService.balances?.spendableOnchainBalanceSats ?? 0 - guard validateOnchainBalance(invoiceAmount: invoice.amountSatoshis, onchainBalance: onchainBalance) else { + guard await canCoverOnchainSend(invoice: invoice, onchainBalance: onchainBalance) else { return } @@ -471,7 +488,7 @@ extension AppViewModel { // If node is running, validate balance immediately if lightningService.status?.isRunning == true { let onchainBalance = lightningService.balances?.spendableOnchainBalanceSats ?? 0 - guard validateOnchainBalance(invoiceAmount: invoice.amountSatoshis, onchainBalance: onchainBalance) else { + guard await canCoverOnchainSend(invoice: invoice, onchainBalance: onchainBalance) else { return } } @@ -594,6 +611,20 @@ extension AppViewModel { } } + /// Whether the send can go ahead, toasting only once neither rail can pay it. Savings falling + /// short is not a dead end while a swap can pay the address out of spending instead. + private func canCoverOnchainSend(invoice: OnChainInvoice, onchainBalance: UInt64) async -> Bool { + if hasSufficientOnchainBalance(invoiceAmount: invoice.amountSatoshis, onchainBalance: onchainBalance) { + return true + } + + if await trySwitchToSwapSend(amountSats: invoice.amountSatoshis) { + return true + } + + return validateOnchainBalance(invoiceAmount: invoice.amountSatoshis, onchainBalance: onchainBalance) + } + private func handleScannedOnchainInvoice(_ invoice: OnChainInvoice) { selectedWalletToPayFrom = .onchain scannedOnchainInvoice = invoice @@ -706,6 +737,124 @@ extension AppViewModel { lnurlPayData = nil lnurlWithdrawData = nil contactPaymentContext = nil + clearSwapSend() + } +} + +// MARK: Swap send + +extension AppViewModel { + /// Fund the send from the spending balance instead, paying the on-chain address through a + /// Boltz reverse swap. Returns false when no swap can deliver `amountSats`, leaving the send + /// untouched so the caller keeps the pre-swap behaviour. An amount of zero is not priceable + /// yet, so only the bounds are established and the amount screen prices the rest. + @discardableResult + func trySwitchToSwapSend(amountSats: UInt64) async -> Bool { + let token = beginSwapSendUpdate() + guard let priced = await priceSwapSend(amountSats: amountSats) else { return false } + guard isCurrentSwapSendUpdate(token) else { return false } + + Logger.info("Offering swap send for \(amountSats) sat, max \(priced.bounds.maxDeliverSat) sat", context: "AppViewModel") + isSwapSend = true + sendSwapBounds = priced.bounds + sendSwapQuote = priced.quote + return true + } + + /// Whether a swap could deliver `amountSats`, without committing the send to one. Used while + /// an address is still being typed, where the send state must not move under the user. + func canSwapCoverSend(amountSats: UInt64) async -> Bool { + await priceSwapSend(amountSats: amountSats) != nil + } + + /// Establish the swap bounds for a plain on-chain send without switching rails, so the amount + /// screen can let the user type past their savings ceiling toward what a swap could deliver. + /// The send stays on savings until the amount actually crosses that ceiling. + func primeSwapSendBounds() async { + guard scannedOnchainInvoice != nil, scannedLightningInvoice == nil else { return } + guard lnurlPayData == nil, lnurlWithdrawData == nil else { return } + guard !isSwapSend, sendSwapBounds == nil else { return } + + let token = beginSwapSendUpdate() + let bounds = await swapService.bounds() + guard isCurrentSwapSendUpdate(token) else { return } + sendSwapBounds = bounds + } + + private func priceSwapSend(amountSats: UInt64) async -> (bounds: SendSwapBounds, quote: SendSwapQuote?)? { + guard let bounds = await swapService.bounds() else { return nil } + guard amountSats > 0 else { return (bounds, nil) } + guard let quote = await swapService.quote(deliverSat: amountSats) else { return nil } + return (bounds, quote) + } + + /// Keep a plain on-chain send on the rail that can pay it: savings while the amount fits + /// there, spending via a swap once it does not. Only runs while the amount is being edited, + /// so a send that is already committed to a rail is never revisited behind the user's back. + func resolveSwapSendMethod(amountSats: UInt64, onchainBalance: UInt64) async { + guard scannedOnchainInvoice != nil, scannedLightningInvoice == nil else { return } + guard lnurlPayData == nil, lnurlWithdrawData == nil else { return } + + if !isSwapSend { + if amountSats > onchainBalance { + await trySwitchToSwapSend(amountSats: amountSats) + } + return + } + + if amountSats > 0, amountSats <= onchainBalance { + // Fall back to savings, but keep the bounds so the amount can be raised past savings + // again to re-arm the swap without a fresh scan (mirrors Android keeping swapMaxSendSats). + revertSwapSendToSavings() + return + } + + await refreshSwapQuote(amountSats: amountSats) + } + + /// Re-price the swap for `amountSats`. Keeps the last good quote on a transient failure so the + /// review screen is not wedged behind a nil quote; the payment re-quotes before it commits. + func refreshSwapQuote(amountSats: UInt64) async { + guard isSwapSend else { return } + let token = beginSwapSendUpdate() + let quote = await swapService.quote(deliverSat: amountSats) + guard isCurrentSwapSendUpdate(token), isSwapSend else { return } + if let quote { + sendSwapQuote = quote + } + } + + /// Whether a swap can still be priced for `amountSats` right now. Used by the review screen to + /// tell "still pricing" from "no longer available" so it does not spin forever. + func canStillQuoteSwapSend(amountSats: UInt64) async -> Bool { + await swapService.quote(deliverSat: amountSats) != nil + } + + private func revertSwapSendToSavings() { + beginSwapSendUpdate() + isSwapSend = false + sendSwapQuote = nil + } + + func clearSwapSend() { + beginSwapSendUpdate() + isSwapSend = false + sendSwapQuote = nil + sendSwapBounds = nil + } + + /// A send edit changes the amount faster than Boltz can be re-priced, and the pricing calls run + /// on unstructured tasks. Bumping this token on every state change lets a slow call that resumes + /// late drop its stale result instead of overwriting the current one (mirrors the manual-entry + /// validation sequence). + @discardableResult + private func beginSwapSendUpdate() -> UInt64 { + swapSendUpdateSequence &+= 1 + return swapSendUpdateSequence + } + + private func isCurrentSwapSendUpdate(_ token: UInt64) -> Bool { + token == swapSendUpdateSequence } } @@ -837,12 +986,13 @@ extension AppViewModel { } if !canPayLightning { - // On-chain: check savings balance - if invoice.amountSatoshis > 0 && savingsBalanceSats < Int(invoice.amountSatoshis) { - result = .insufficientSavings - } else if invoice.amountSatoshis == 0 && savingsBalanceSats == 0 { - // Zero-amount invoice: user must have some balance to proceed - result = .insufficientSavings + // On-chain: check savings balance, falling back to a swap out of spending when + // savings cannot cover it so the Continue button is not dead-ended. + let savings = UInt64(max(0, savingsBalanceSats)) + if !hasSufficientOnchainBalance(invoiceAmount: invoice.amountSatoshis, onchainBalance: savings) { + let canSwap = await canSwapCoverSend(amountSats: invoice.amountSatoshis) + guard currentSequence == manualEntryValidationSequence else { return } + result = canSwap ? .valid : .insufficientSavings } } diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index 01311dedf..17a819efb 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -20,18 +20,31 @@ struct SendAmountView: View { app.scannedOnchainInvoice != nil && app.scannedLightningInvoice != nil } + /// Paying an on-chain address out of spending through a swap: the recipient is still an + /// address, only the funding source and its limits differ. + private var isSwapSend: Bool { + app.isSwapSend && app.selectedWalletToPayFrom == .onchain + } + + private var payingFromSpending: Bool { + app.selectedWalletToPayFrom == .lightning || isSwapSend + } + private var assetButtonTestIdentifier: String { if canSwitchWallet { return "switch" } - return app.selectedWalletToPayFrom == .lightning ? "spending" : "savings" + return payingFromSpending ? "spending" : "savings" } /// The amount to display in the available balance section /// For onchain transactions, this shows the max sendable amount (balance minus fees) /// For lightning transactions, this shows the max sendable lightning amount minus routing fees + /// For swap sends, this shows what a swap can actually deliver on-chain after its fees var availableAmount: UInt64 { - if app.selectedWalletToPayFrom == .lightning { + if isSwapSend { + return app.sendSwapBounds?.maxDeliverSat ?? 0 + } else if app.selectedWalletToPayFrom == .lightning { let maxSendLightning = UInt64(wallet.maxSendLightningSats) return maxSendLightning >= routingFee ? maxSendLightning - routingFee : 0 } else { @@ -40,15 +53,30 @@ struct SendAmountView: View { } } + private var minimumAmount: UInt64 { + let dustLimit = UInt64(Env.dustLimit) + if isSwapSend { + // Boltz's reverse minimum applies to the invoice, so the delivered floor is higher + // than the dust limit and an amount below it can never be quoted. + return max(dustLimit, app.sendSwapBounds?.minDeliverSat ?? dustLimit) + } + return app.selectedWalletToPayFrom == .lightning ? 1 : dustLimit + } + private var isValidAmount: Bool { - let minAmount = app.selectedWalletToPayFrom == .lightning ? 1 : Env.dustLimit + amountSats >= minimumAmount && amountSats <= availableAmount + } - return amountSats >= minAmount && amountSats <= availableAmount + /// Highest amount the number pad accepts. On a plain on-chain send this is normally the savings + /// max, but when a swap is on offer it is raised to what the swap can deliver so the user can + /// type past their savings ceiling; crossing it flips the send onto the swap rail. + private var inputCap: UInt64 { + max(availableAmount, app.sendSwapBounds?.maxDeliverSat ?? 0) } /// Determines if the current amount is a max amount send var isMaxAmountSend: Bool { - guard app.selectedWalletToPayFrom == .onchain else { return false } + guard app.selectedWalletToPayFrom == .onchain, !isSwapSend else { return false } return amountSats == availableAmount && amountSats > 0 } @@ -91,11 +119,11 @@ struct SendAmountView: View { // No specific invoice, show toggle button based on selected wallet type NumberPadActionButton( - text: app.selectedWalletToPayFrom == .lightning + text: payingFromSpending ? t("wallet__spending__title") : t("wallet__savings__title"), imageName: canSwitchWallet ? "arrow-up-down" : nil, - color: app.selectedWalletToPayFrom == .lightning ? .purpleAccent : .brandAccent, + color: payingFromSpending ? .purpleAccent : .brandAccent, variant: canSwitchWallet ? .primary : .secondary, disabled: !canSwitchWallet ) { @@ -164,6 +192,23 @@ struct SendAmountView: View { } } } + .task { + // Establish the swap ceiling up front so the pad lets the user type past their savings + // balance toward what a swap could deliver. No-op unless swaps are enabled and this is + // a plain on-chain send. + await app.primeSwapSendBounds() + } + .task(id: amountSats) { + // Keyed on the amount so a slow re-price is cancelled when the amount changes again. + await resolveSwapSendMethod() + } + .onChange(of: app.isSwapSend) { _, _ in + // Falling back to savings needs the fee-aware on-chain max, which is skipped while in + // swap mode; recompute it so Continue is not enabled at an amount that leaves no fee. + if !isSwapSend { + Task { await calculateMaxSendableAmount() } + } + } .onChange(of: app.selectedWalletToPayFrom) { _, newValue in // Recalculate max sendable amount when switching wallet types if newValue == .onchain { @@ -186,7 +231,7 @@ struct SendAmountView: View { } } } - .onChange(of: availableAmount, initial: true) { updateInputCap() } + .onChange(of: inputCap, initial: true) { updateInputCap() } .onChange(of: amountViewModel.maxExceededCount) { showMaxExceededToast() } } @@ -195,6 +240,21 @@ struct SendAmountView: View { wallet.sendAmountSats = amountSats wallet.isMaxAmountSend = isMaxAmountSend + // Swap send: the recipient is an on-chain address but the funds leave spending, so + // there are no UTXOs to select and nothing to coin-select over. + if isSwapSend { + await app.refreshSwapQuote(amountSats: amountSats) + // Bounds are cached; a nil quote here means the swap became unavailable (Boltz + // unreachable, or spending capacity dropped below the amount). Surface it rather + // than pushing the user onto a review screen that can never be swiped. + guard app.sendSwapQuote != nil else { + app.toast(type: .error, title: t("other__try_again")) + return + } + navigationPath.append(.confirm) + return + } + // Lightning payment if app.selectedWalletToPayFrom == .lightning { if UInt64(wallet.maxSendLightningSats) < amountSats { @@ -256,7 +316,7 @@ struct SendAmountView: View { private func updateInputCap() { // Don't cap when nothing is sendable, so the pad stays usable (Continue stays disabled instead). - amountViewModel.maxAmountOverride = availableAmount > 0 ? availableAmount : nil + amountViewModel.maxAmountOverride = inputCap > 0 ? inputCap : nil } private func showMaxExceededToast() { @@ -269,9 +329,18 @@ struct SendAmountView: View { ) } + /// Keep the send on the rail that can pay it as the amount is edited: savings while it fits + /// there, spending through a swap once it does not. + private func resolveSwapSendMethod() async { + await app.resolveSwapSendMethod( + amountSats: amountSats, + onchainBalance: maxSendableAmount ?? UInt64(max(0, wallet.spendableOnchainBalanceSats)) + ) + } + private func calculateMaxSendableAmount() async { // Make sure we have everything we need to calculate the max sendable amount - guard app.selectedWalletToPayFrom == .onchain else { return } + guard app.selectedWalletToPayFrom == .onchain, !isSwapSend else { return } guard let address = app.scannedOnchainInvoice?.address else { return } guard let feeRate = wallet.selectedFeeRateSatsPerVByte else { return } diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 9d1aae658..530bc41fd 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -24,8 +24,14 @@ struct SendConfirmationView: View { @State private var warningContinuation: CheckedContinuation? @State private var swipeProgress: CGFloat = 0 + /// Paying an on-chain address out of spending through a Boltz reverse swap. The recipient and + /// the review layout stay on-chain; the funds and the fees are the swap's. + private var isSwapSend: Bool { + app.isSwapSend && app.selectedWalletToPayFrom == .onchain + } + var accentColor: Color { - app.selectedWalletToPayFrom == .lightning ? .purpleAccent : .brandAccent + app.selectedWalletToPayFrom == .lightning || isSwapSend ? .purpleAccent : .brandAccent } var canSwitchWallet: Bool { @@ -125,7 +131,9 @@ struct SendConfirmationView: View { .padding(.bottom, 44) if showDetails { - if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice { + if isSwapSend, let invoice = app.scannedOnchainInvoice { + swapView(invoice) + } else if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice { onchainView(invoice) } else if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { lightningView(invoice) @@ -146,7 +154,7 @@ struct SendConfirmationView: View { CustomButton( title: showDetails ? t("common__hide_details") : t("common__show_details"), size: .small, - icon: Image(showDetails ? "eye-slash" : app.selectedWalletToPayFrom == .lightning ? "bolt-hollow" : "speed-normal") + icon: Image(showDetails ? "eye-slash" : accentColor == .purpleAccent ? "bolt-hollow" : "speed-normal") .resizable() .frame(width: 16, height: 16) .foregroundColor(accentColor), @@ -159,7 +167,14 @@ struct SendConfirmationView: View { .accessibilityIdentifier("SendConfirmToggleDetails") } - SwipeButton(title: t("wallet__send_swipe"), accentColor: accentColor, swipeProgress: $swipeProgress) { + SwipeButton( + title: t("wallet__send_swipe"), + accentColor: accentColor, + // A swap cannot be paid before Boltz has priced it, so hold the swipe until the + // review screen is showing the real cost. + isLoading: isSwapSend && app.sendSwapQuote == nil, + swipeProgress: $swipeProgress + ) { // Validate payment and show warnings if needed let warnings = await validatePayment() if !warnings.isEmpty { @@ -200,6 +215,17 @@ struct SendConfirmationView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .task { ensureSendAmountFromScannedInvoicesIfNeeded() + // Price the swap before the user can swipe so the review never shows a half-priced send. + if isSwapSend { + await app.refreshSwapQuote(amountSats: wallet.sendAmountSats ?? 0) + // No cached quote means the swap is no longer available; go back rather than leave + // the swipe button spinning behind a nil quote with no way forward. + if app.sendSwapQuote == nil { + app.toast(type: .error, title: t("other__try_again")) + navigateToAmount() + return + } + } await calculateTransactionFee() await calculateRoutingFee() } @@ -266,25 +292,7 @@ struct SendConfirmationView: View { .accessibilityIdentifier("SendConfirmAssetButton") } - if let contact = contactPaymentContact { - SendSectionView(t("wallet__send_to")) { - contactRecipient(contact) - } - } else { - Button { - navigateToManual(with: invoice.address) - } label: { - SendSectionView(t("wallet__send_to")) { - BodySSBText(invoice.address.ellipsis(maxLength: 18)) - .lineLimit(1) - .truncationMode(.middle) - .frame(height: 28) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .buttonStyle(.plain) - .accessibilityIdentifier("ReviewUri") - } + recipientSection(address: invoice.address) } .frame(maxWidth: .infinity, alignment: .leading) @@ -337,19 +345,103 @@ struct SendConfirmationView: View { } .frame(maxWidth: .infinity, alignment: .leading) - SendSectionView(t("wallet__tags")) { - TagsListView( - tags: tagManager.selectedTagsArray, - icon: .close, - onAddTag: { - navigationPath.append(.tag) - }, - onTagDelete: { tag in - tagManager.removeTagFromSelection(tag) - }, - addButtonTestId: "TagsAddSend" - ) + tagsSection + } + } + + /// Review rows for a send that pays an on-chain address out of the spending balance through a + /// swap. Mirrors `onchainView`, except the fee is Boltz's rather than ours, so it is not + /// editable, and the confirmation estimate gives way to the swap's service fee. + func swapView(_ invoice: OnChainInvoice) -> some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top, spacing: 16) { + SendSectionView(t("wallet__send_from")) { + NumberPadActionButton( + text: t("wallet__spending__title"), + color: .purpleAccent, + variant: .secondary, + disabled: true + ) {} + .accessibilityIdentifier("SendConfirmAssetButton") + } + + recipientSection(address: invoice.address) } + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(alignment: .top, spacing: 16) { + SendSectionView(t("wallet__send_fee_and_speed")) { + HStack(spacing: 0) { + Image("speed-normal") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.purpleAccent) + .frame(width: 16, height: 16) + .padding(.trailing, 4) + + swapFeeText(sats: app.sendSwapQuote?.networkFeeSat) + } + } + + SendSectionView(t("lightning__savings_confirm__service_fee")) { + HStack(spacing: 0) { + swapFeeText(sats: app.sendSwapQuote?.serviceFeeSat) + } + .accessibilityIdentifier("SendConfirmServiceFee") + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + tagsSection + } + } + + @ViewBuilder + private func swapFeeText(sats: UInt64?) -> some View { + if let sats { + MoneyText(sats: Int(sats), size: .bodySSB, symbol: true, symbolColor: .textPrimary) + } else { + ActivityIndicator(size: 16) + .frame(height: 28) + } + } + + @ViewBuilder + private func recipientSection(address: String) -> some View { + if let contact = contactPaymentContact { + SendSectionView(t("wallet__send_to")) { + contactRecipient(contact) + } + } else { + Button { + navigateToManual(with: address) + } label: { + SendSectionView(t("wallet__send_to")) { + BodySSBText(address.ellipsis(maxLength: 18)) + .lineLimit(1) + .truncationMode(.middle) + .frame(height: 28) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .buttonStyle(.plain) + .accessibilityIdentifier("ReviewUri") + } + } + + private var tagsSection: some View { + SendSectionView(t("wallet__tags")) { + TagsListView( + tags: tagManager.selectedTagsArray, + icon: .close, + onAddTag: { + navigationPath.append(.tag) + }, + onTagDelete: { tag in + tagManager.removeTagFromSelection(tag) + }, + addButtonTestId: "TagsAddSend" + ) } } @@ -481,10 +573,76 @@ struct SendConfirmationView: View { .accessibilityIdentifier("ReviewContactRecipient") } + /// Pay an on-chain address out of the spending balance through a Boltz reverse swap. + /// + /// The claim that pays the recipient is broadcast by the swap updates stream, which + /// `WalletViewModel` keeps running, so a claim we stop waiting for still lands. That makes a + /// timed-out (settling) outcome a success here: the invoice is paid and the payout is on its + /// way. A failure only toasts and shows the failure screen; it never deletes the metadata, + /// because by then the payment may already be committed. + private func performSwapPayment(invoice: OnChainInvoice, contactPublicKey: String?) async { + let amount = wallet.sendAmountSats ?? invoice.amountSatoshis + // Show what the recipient receives, not the larger amount the swap debits. + wallet.sendAmountSats = amount + + // Make sure the updates stream is running so the swap is tracked and its claim broadcast, + // even if the launch-time start had not yet succeeded. + wallet.ensureSwapUpdatesRunning() + + do { + let receipt = try await SendSwapService.shared.payToAddress( + address: invoice.address, + deliverSat: amount, + onInvoicePrepared: { paymentHash in + // The hold invoice is what the wallet records, so its hash is the id the + // activity, its tags and the recipient address hang off. + await createPreActivityMetadata( + paymentId: paymentHash, + paymentHash: paymentHash, + address: invoice.address + ) + }, + onEvent: { id, handler in wallet.addOnEvent(id: id, handler: handler) }, + removeEvent: { id in wallet.removeOnEvent(id: id) } + ) + + if let contactPublicKey { + await PrivatePaykitService.shared.discardRemoteOnchainEndpoints( + publicKey: contactPublicKey, + addresses: [invoice.address] + ) + } + await syncContactForActivity(paymentId: receipt.paymentHash, contactPublicKey: contactPublicKey) + + // Reflect the spent balance before the next send prices against it (the swap bypasses + // WalletViewModel.send, which is where a normal Lightning send would sync). + await wallet.syncStateAsync() + + Logger.info("Swap send claim txid: \(receipt.claimTxId ?? "pending")") + + navigationPath.append(.success(paymentId: receipt.paymentHash)) + } catch { + Logger.error("Swap send failed: \(error)") + app.toast(error) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + navigationPath.append(.failure) + } + } + } + private func performPayment() async throws { var createdMetadataPaymentId: String? = nil let contactPublicKey = app.contactPaymentContext?.publicKey + // The swap send owns its error handling: once the hold invoice is paid the funds are in + // flight, so a later failure must not delete the activity metadata or claim the send is + // lost. It is handled here rather than in the shared catch below (which is safe only for + // sends that never dispatched). + if isSwapSend, let invoice = app.scannedOnchainInvoice { + await performSwapPayment(invoice: invoice, contactPublicKey: contactPublicKey) + return + } + do { if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { let amount = wallet.sendAmountSats ?? invoice.amountSatoshis @@ -616,7 +774,7 @@ struct SendConfirmationView: View { } // Check if amount > 50% of balance - if app.selectedWalletToPayFrom == .lightning { + if app.selectedWalletToPayFrom == .lightning || isSwapSend { let lightningBalance = wallet.totalLightningSats if amount > lightningBalance / 2 { warnings.append(.balance) @@ -637,8 +795,8 @@ struct SendConfirmationView: View { } } - // Check if fee > $10 (only for onchain) - if app.selectedWalletToPayFrom == .onchain { + // Check if fee > $10 (only for onchain, the swap's fee is Boltz's and shown separately) + if app.selectedWalletToPayFrom == .onchain, !isSwapSend { if let feeUsd = currency.convert(sats: UInt64(transactionFee), to: "USD") { if feeUsd.value > 10.0 { warnings.append(.fee) @@ -812,7 +970,8 @@ struct SendConfirmationView: View { } private func calculateTransactionFee() async { - guard app.selectedWalletToPayFrom == .onchain else { + // A swap send spends no utxos of ours, so there is no transaction of ours to price. + guard app.selectedWalletToPayFrom == .onchain, !isSwapSend else { return } diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index feb4d56f0..829d0ef20 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -68,9 +68,12 @@ struct SendSheet: View { guard wallet.nodeLifecycleState == .running else { return true } // For lightning payments, also need usable channels (peer connected) + // A swap send pays a bolt11 too, so it needs usable channels even though its recipient is + // an on-chain address. let isLightningPayment = app.scannedLightningInvoice != nil || app.lnurlPayData != nil || app.selectedWalletToPayFrom == .lightning + || app.isSwapSend if isLightningPayment { // If there are no channels at all, don't show the sync overlay – @@ -223,7 +226,7 @@ struct SendSheet: View { ), accessibilityIdentifier: "InsufficientSavingsToast" ) - sheets.hideSheet() + sheets.hideSheetIfActive(.send) return false } } else { @@ -235,7 +238,7 @@ struct SendSheet: View { description: t("other__pay_insufficient_savings_description"), accessibilityIdentifier: "InsufficientSavingsToast" ) - sheets.hideSheet() + sheets.hideSheetIfActive(.send) return false } } @@ -317,11 +320,26 @@ struct SendSheet: View { // Validate onchain payment balance (for pure onchain invoices) if let onchainInvoice = app.scannedOnchainInvoice { let onchainBalance = LightningService.shared.balances?.spendableOnchainBalanceSats ?? 0 - guard validateOnchainBalanceAndDismissIfInsufficient( - invoiceAmount: onchainInvoice.amountSatoshis, - onchainBalance: onchainBalance - ) else { + + // Savings falling short is not a dead end while a swap can pay the address out of + // spending. The node was not running at scan time in this path, so this is the first + // chance to ask Boltz. + guard app.hasSufficientOnchainBalance(invoiceAmount: onchainInvoice.amountSatoshis, onchainBalance: onchainBalance) else { hasValidatedAfterSync = true + Task { + let switched = await app.trySwitchToSwapSend(amountSats: onchainInvoice.amountSatoshis) + // The Boltz round trip can outlast the sheet; do nothing if the user has since + // moved on to a different send or closed the sheet. + guard app.scannedOnchainInvoice?.address == onchainInvoice.address else { return } + guard switched else { + _ = validateOnchainBalanceAndDismissIfInsufficient( + invoiceAmount: onchainInvoice.amountSatoshis, + onchainBalance: onchainBalance + ) + return + } + navigationPath = [.amount] + } return } } diff --git a/Bitkit/Views/Wallets/Send/SendSuccess.swift b/Bitkit/Views/Wallets/Send/SendSuccess.swift index c2170f0f6..c40144589 100644 --- a/Bitkit/Views/Wallets/Send/SendSuccess.swift +++ b/Bitkit/Views/Wallets/Send/SendSuccess.swift @@ -31,7 +31,9 @@ struct SendSuccess: View { /// Load the confetti animation private var confettiAnimation: LottieAnimation? { - let isOnchain = app.selectedWalletToPayFrom == .onchain + // A swap send keeps `selectedWalletToPayFrom == .onchain` (the recipient is an address) but + // spends from Lightning, so it uses the spending-purple confetti like the rest of its flow. + let isOnchain = app.selectedWalletToPayFrom == .onchain && !app.isSwapSend let animationName = isOnchain ? "confetti-orange" : "confetti-purple" guard let filepathURL = Bundle.main.url(forResource: animationName, withExtension: "json") else { diff --git a/BitkitTests/SendSwapTests.swift b/BitkitTests/SendSwapTests.swift new file mode 100644 index 000000000..f10abcdab --- /dev/null +++ b/BitkitTests/SendSwapTests.swift @@ -0,0 +1,225 @@ +import BitkitCore +import XCTest + +@testable import Bitkit + +final class SendSwapTests: XCTestCase { + // MARK: - Inverse quote math + + func testQuoteGrossesFeesUpSoTheRecipientReceivesTheRequestedAmount() { + let quote = SendSwapQuote.build(deliverSat: 100_000, limits: limits()) + + XCTAssertEqual(quote.deliverSat, 100_000) + XCTAssertEqual(quote.networkFeeSat, 320) + XCTAssertEqual(quote.serviceFeeSat, quote.invoiceSat - 100_000) + XCTAssertGreaterThan(quote.invoiceSat, 100_000) + XCTAssertEqual(SendSwapQuote.delivered(invoiceSat: quote.invoiceSat, limits: limits()), 100_000) + } + + func testQuoteIsTheSmallestInvoiceThatStillCoversTheRecipient() { + let quote = SendSwapQuote.build(deliverSat: 100_000, limits: limits()) + + // Boltz rounds its percentage fee up, so one sat less must fall short. + XCTAssertLessThan(SendSwapQuote.delivered(invoiceSat: quote.invoiceSat - 1, limits: limits()), 100_000) + } + + func testQuoteCoversTheRecipientAcrossARangeOfAmounts() { + for deliverSat: UInt64 in [1000, 49999, 60000, 60001, 99999, 123_457, 1_000_000] { + let quote = SendSwapQuote.build(deliverSat: deliverSat, limits: limits()) + XCTAssertGreaterThanOrEqual( + SendSwapQuote.delivered(invoiceSat: quote.invoiceSat, limits: limits()), + deliverSat, + "recipient \(deliverSat) shorted by invoice \(quote.invoiceSat)" + ) + } + } + + func testQuoteCoversTheRecipientAcrossARangeOfFeeSchedules() { + for feePercentage in [0.0, 0.1, 0.25, 0.5, 1.0, 5.0] { + let schedule = limits(feePercentage: feePercentage) + let quote = SendSwapQuote.build(deliverSat: 250_000, limits: schedule) + XCTAssertGreaterThanOrEqual( + SendSwapQuote.delivered(invoiceSat: quote.invoiceSat, limits: schedule), + 250_000, + "recipient shorted at \(feePercentage)%" + ) + } + } + + func testQuoteChargesOnlyMinerFeesWithoutAPercentageFee() { + let quote = SendSwapQuote.build(deliverSat: 100_000, limits: limits(feePercentage: 0)) + + XCTAssertEqual(quote.invoiceSat, 100_320) + XCTAssertEqual(quote.serviceFeeSat, 320) + } + + func testQuoteInvertsTheForwardPricingTheSavingsSwapApplies() { + // The transfer to savings prices an invoice down to an on-chain amount; a send starts + // from the amount the recipient must receive, so the two must round-trip. + let deliverSat = SendSwapQuote.delivered(invoiceSat: 500_000, limits: limits()) + let quote = SendSwapQuote.build(deliverSat: deliverSat, limits: limits()) + + XCTAssertEqual(quote.invoiceSat, 500_000) + } + + // MARK: - Bounds + + func testMaxDeliverableIsWhatTheSpendableBalanceDeliversAfterFees() { + let sendableSat: UInt64 = 200_000 + + let maxDeliverSat = SendSwapQuote.maxDeliverable(sendableSat: sendableSat, limits: limits()) + + XCTAssertEqual(maxDeliverSat, SendSwapQuote.delivered(invoiceSat: sendableSat, limits: limits())) + XCTAssertLessThan(maxDeliverSat, sendableSat) + } + + func testMaxDeliverableIsCappedByTheSwapMaximum() { + let maxDeliverSat = SendSwapQuote.maxDeliverable(sendableSat: 10_000_000, limits: limits()) + + XCTAssertEqual(maxDeliverSat, SendSwapQuote.delivered(invoiceSat: 1_000_000, limits: limits())) + } + + func testMaxDeliverableIsZeroWhenFeesExceedWhatCanBePaid() { + XCTAssertEqual(SendSwapQuote.maxDeliverable(sendableSat: 100, limits: limits(minerFeesSat: 500)), 0) + } + + func testQuoteMathToleratesADegenerateFeeSchedule() { + // A non-finite percentage must not poison the UInt64 conversions downstream: it is coerced + // to a zero rate, so only miner fees are charged and the recipient is still covered. + let schedule = limits(feePercentage: .nan) + let quote = SendSwapQuote.build(deliverSat: 100_000, limits: schedule) + + XCTAssertEqual(quote.invoiceSat, 100_320) + XCTAssertGreaterThanOrEqual(SendSwapQuote.delivered(invoiceSat: quote.invoiceSat, limits: schedule), 100_000) + } + + func testMinDeliverableQuotesToAtLeastTheSwapMinimum() { + for schedule in [limits(), limits(feePercentage: 0.25, minerFeesSat: 320), limits(minimalSat: 50000)] { + let minDeliverSat = SendSwapQuote.minDeliverable(limits: schedule) + + XCTAssertGreaterThanOrEqual( + SendSwapQuote.build(deliverSat: minDeliverSat, limits: schedule).invoiceSat, + schedule.minimalSat + ) + XCTAssertLessThan( + SendSwapQuote.build(deliverSat: minDeliverSat - 1, limits: schedule).invoiceSat, + schedule.minimalSat + ) + } + } + + func testBoundsAreUnusableWhenTheBalanceCannotReachTheSwapMinimum() { + let bounds = SendSwapBounds( + minDeliverSat: SendSwapQuote.minDeliverable(limits: limits()), + maxDeliverSat: SendSwapQuote.maxDeliverable(sendableSat: 10000, limits: limits()) + ) + + XCTAssertFalse(bounds.isUsable) + } + + func testBoundsAreUsableOnceTheBalanceClearsTheSwapMinimum() { + let bounds = SendSwapBounds( + minDeliverSat: SendSwapQuote.minDeliverable(limits: limits()), + maxDeliverSat: SendSwapQuote.maxDeliverable(sendableSat: 500_000, limits: limits()) + ) + + XCTAssertTrue(bounds.isUsable) + } + + // MARK: - Availability + + @MainActor + func testNoSwapIsOfferedWhileSwapsAreDisabled() async { + // Unit tests run on regtest, where Boltz resolves to a local backend no build can reach, + // so the send must fall back to the pre-swap insufficient-savings behaviour. + XCTAssertFalse(BoltzService.shared.isSwapEnabled) + + let service = SendSwapService() + + let bounds = await service.bounds() + let quote = await service.quote(deliverSat: 100_000) + + XCTAssertNil(bounds) + XCTAssertNil(quote) + } + + @MainActor + func testZeroAmountIsNeverQuoted() async { + let quote = await SendSwapService().quote(deliverSat: 0) + + XCTAssertNil(quote) + } + + @MainActor + func testSendStateResetClearsTheSwapSend() { + let app = AppViewModel() + app.isSwapSend = true + app.sendSwapBounds = SendSwapBounds(minDeliverSat: 25000, maxDeliverSat: 100_000) + app.sendSwapQuote = SendSwapQuote.build(deliverSat: 100_000, limits: limits()) + + app.resetSendState() + + XCTAssertFalse(app.isSwapSend) + XCTAssertNil(app.sendSwapBounds) + XCTAssertNil(app.sendSwapQuote) + } + + @MainActor + func testFallingBackToSavingsKeepsBoundsSoTheSwapCanBeReArmed() async { + // A swap send whose amount is lowered into savings range reverts to on-chain, but the + // bounds must survive so raising the amount past savings again re-offers the swap without + // a fresh scan (mirrors Android keeping swapMaxSendSats). + let app = makeSwapSendApp() + app.isSwapSend = true + app.sendSwapBounds = SendSwapBounds(minDeliverSat: 25000, maxDeliverSat: 400_000) + app.sendSwapQuote = SendSwapQuote.build(deliverSat: 100_000, limits: limits()) + + await app.resolveSwapSendMethod(amountSats: 5000, onchainBalance: 10000) + + XCTAssertFalse(app.isSwapSend) + XCTAssertNil(app.sendSwapQuote) + XCTAssertEqual(app.sendSwapBounds?.maxDeliverSat, 400_000) + } + + @MainActor + func testSavingsCoversTheSendOnlyWhenItHoldsTheFullAmount() { + let app = AppViewModel() + + XCTAssertTrue(app.hasSufficientOnchainBalance(invoiceAmount: 10000, onchainBalance: 10000)) + XCTAssertFalse(app.hasSufficientOnchainBalance(invoiceAmount: 10001, onchainBalance: 10000)) + // A zero-amount invoice only needs some balance; the amount screen validates the rest. + XCTAssertTrue(app.hasSufficientOnchainBalance(invoiceAmount: 0, onchainBalance: 1)) + XCTAssertFalse(app.hasSufficientOnchainBalance(invoiceAmount: 0, onchainBalance: 0)) + } + + // MARK: - Fixtures + + @MainActor + private func makeSwapSendApp() -> AppViewModel { + let app = AppViewModel() + app.scannedOnchainInvoice = OnChainInvoice( + address: "bcrt1qswaprecipient", + amountSatoshis: 0, + label: nil, + message: nil, + params: nil + ) + return app + } + + private func limits( + minimalSat: UInt64 = 25000, + maximalSat: UInt64 = 1_000_000, + feePercentage: Double = 0.5, + minerFeesSat: UInt64 = 320 + ) -> BoltzPairInfo { + BoltzPairInfo( + hash: "hash", + rate: 1.0, + minimalSat: minimalSat, + maximalSat: maximalSat, + feePercentage: feePercentage, + minerFeesSat: minerFeesSat + ) + } +}