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 + ) + } +}