diff --git a/LnurlResolver.swift b/LnurlResolver.swift index e8189478..e4c6cb5c 100644 --- a/LnurlResolver.swift +++ b/LnurlResolver.swift @@ -12,24 +12,39 @@ struct ResolvedLnurlInfo { enum WalletInputType { case unknown - case bolt11(amountSats: Int64?) + /// `invoice` carries the bolt11 string when it arrived inside something + /// else — a BIP-21 URI that offers both an address and an invoice. Paying + /// needs the invoice itself, not the URI it was wrapped in. + case bolt11(amountSats: Int64?, invoice: String? = nil) /// Spark SDK has already parsed this — payRequest is an opaque box that /// WalletStore unpacks when calling the SDK. case sparkLnurl(info: ResolvedLnurlInfo) /// NWC (or fallback) path: resolve LNURL manually from the address string. case lightningAddressNeedsResolve(String, info: ResolvedLnurlInfo?) + /// A Bitcoin address, or a BIP-21 URI carrying one. `amountSats` is set + /// only when the URI specified an amount; a bare address never does, so + /// the user is asked for one. + case bitcoinAddress(address: String, amountSats: Int64?) var needsAmountEntry: Bool { switch self { - case .bolt11(let amt): return amt == nil + case .bolt11(let amt, _): return amt == nil case .sparkLnurl, .lightningAddressNeedsResolve: return true + case .bitcoinAddress(_, let amt): return amt == nil case .unknown: return false } } + /// On-chain sends need a fee quote and a confirmation speed, which no + /// other payment type does. + var isOnchain: Bool { + if case .bitcoinAddress = self { return true } + return false + } + var isPayable: Bool { switch self { - case .bolt11(let amt): return amt != nil + case .bolt11(let amt, _): return amt != nil default: return false } } diff --git a/SparkWallet.swift b/SparkWallet.swift index 1dfde310..c3a4a18c 100644 --- a/SparkWallet.swift +++ b/SparkWallet.swift @@ -277,6 +277,280 @@ final class SparkWallet: Wallet { } } + // MARK: - On-chain send + + /// How long a quoted fee stays good for. On-chain fee rates move, and a + /// quote left open on screen shouldn't execute at yesterday's rate. + private static let quoteValiditySecs: TimeInterval = 600 + + /// Quote held between the user seeing a fee and confirming it, so the send + /// that goes out is the one that was signed off. + private var preparedOnchainSend: ( + quote: OnchainSendQuote, + prepared: PrepareSendPaymentResponse, + idempotencyKey: String, + quotedAt: Date + )? + + /// Quote sending `amountSats` to a Bitcoin address. Fees are added on top, + /// so the recipient receives exactly the amount and the wallet spends the + /// quote's total. + /// `drainAll` empties the wallet: the amount becomes the whole spendable + /// balance and the fee comes out of it rather than on top. Sending the + /// full balance any other way can never succeed, because there's nothing + /// left to pay the fee with. + func prepareSendOnchain( + address: String, + amountSats: Int64, + speed: OnchainSpeed, + drainAll: Bool = false + ) async -> Result { + guard let sdk else { return .failure(.notConnected) } + do { + let requestedSats: Int64 + var strandsTokens = false + if drainAll { + // Quote against a synced balance — a stale cached figure + // produces a fee for an amount that no longer exists. + let info = try await sdk.getInfo(request: GetInfoRequest(ensureSynced: true)) + requestedSats = Int64(info.balanceSats) + guard requestedSats > 0 else { + return .failure(.other("This wallet has no spendable balance.")) + } + // Draining moves bitcoin only. A wallet imported from an app + // that deals in stablecoins can hold a token balance this send + // won't carry, and Wisp has no way to convert it. + strandsTokens = info.tokenBalances.values.contains { $0.balance > 0 } + } else { + guard amountSats > 0 else { return .failure(.other("Enter an amount to send.")) } + requestedSats = amountSats + } + + let prepared = try await sdk.prepareSendPayment( + request: PrepareSendPaymentRequest( + paymentRequest: .input(input: address), + amount: BInt(requestedSats), + tokenIdentifier: nil, + conversionOptions: nil, + feePolicy: drainAll ? .feesIncluded : .feesExcluded + ) + ) + + guard case .bitcoinAddress(_, let feeQuote) = prepared.paymentMethod else { + // The input parsed as something else — a Lightning invoice or + // Spark address in the address field. Refuse rather than + // silently sending somewhere the user didn't intend. + return .failure(.other("That isn't a Bitcoin address.")) + } + + let tier: SendOnchainSpeedFeeQuote + switch speed { + case .slow: tier = feeQuote.speedSlow + case .medium: tier = feeQuote.speedMedium + case .fast: tier = feeQuote.speedFast + } + let feeSats = Int64(tier.userFeeSat) + Int64(tier.l1BroadcastFeeSat) + + // `amountSats` on the quote is always what lands at the + // destination. Draining spends the balance and the fee comes out + // of it, so what arrives is the balance minus the fee; otherwise + // the recipient gets exactly what was asked for. + let deliveredSats = drainAll ? max(0, requestedSats - feeSats) : requestedSats + guard deliveredSats > 0 else { + return .failure(.other("The fee is larger than the balance. Nothing would arrive.")) + } + + let quote = OnchainSendQuote( + address: address, + amountSats: deliveredSats, + feeSats: feeSats, + speed: speed, + leavesTokensBehind: strandsTokens + ) + preparedOnchainSend = (quote, prepared, UUID().uuidString, Date()) + return .success(quote) + } catch { + let friendly = Self.friendlyPayError(error) + emit("Quote failed: \(friendly)") + return .failure(.other(friendly)) + } + } + + /// Send the quote the user confirmed. Refuses anything else. + func executeSendOnchain(quote: OnchainSendQuote) async -> Result { + guard let sdk else { return .failure(.notConnected) } + // Only ever send the quote that was actually signed off. A mismatch + // means the screen drifted from what the user agreed to — re-quote + // rather than send a different amount or destination. + guard let held = preparedOnchainSend, held.quote == quote else { + return .failure(.other("This quote expired. Check the amount and try again.")) + } + // A fee quoted long enough ago is not the fee this send will pay — + // execute it and the user either overpays or watches a transaction + // sit unconfirmed at a rate the mempool has moved past. + guard Date().timeIntervalSince(held.quotedAt) < Self.quoteValiditySecs else { + preparedOnchainSend = nil + return .failure(.other("This quote is out of date. Check the fee and try again.")) + } + // Consume before the first suspension. Two taps landing in the same + // frame both reach here — the button's `.disabled` only applies after + // a re-render — and with the quote cleared only after `sendPayment` + // returned, both passed the equality check above and both broadcast. + // A Lightning double-send fails closed because the SDK rejects an + // already-paid invoice; an address plus an amount has no such + // protection, so both succeed and the user pays twice. + preparedOnchainSend = nil + + let sdkSpeed: OnchainConfirmationSpeed + switch quote.speed { + case .slow: sdkSpeed = .slow + case .medium: sdkSpeed = .medium + case .fast: sdkSpeed = .fast + } + + do { + emit("Sending on-chain…") + let response = try await sdk.sendPayment( + request: SendPaymentRequest( + prepareResponse: held.prepared, + options: .bitcoinAddress(confirmationSpeed: sdkSpeed), + // The SDK executes a given key exactly once, so even if a + // send is somehow retried the SSP won't broadcast twice. + idempotencyKey: held.idempotencyKey + ) + ) + // A failed payment comes back WITHOUT throwing, so the status has + // to be inspected rather than trusted — same shape as payInvoice. + if case .failed = response.payment.status { + emit("On-chain send failed") + return .failure(.other("The send failed — your sats were not sent.")) + } + await refreshBalance() + return .success(response.payment.id) + } catch { + let friendly = Self.friendlyPayError(error) + emit("On-chain send failed: \(friendly)") + return .failure(.other(friendly)) + } + } + + // MARK: - On-chain receive address + + /// Get the Bitcoin deposit address, or rotate to a fresh one. Funds sent + /// to it confirm on-chain and are then claimed into the spendable balance + /// by `claimDeposits` above. Rotation never invalidates the previous + /// address — the SDK keeps old ones working for future deposits. + func receiveOnchainAddress(newAddress: Bool = false) async -> Result { + guard let sdk else { return .failure(.notConnected) } + do { + let response = try await sdk.receivePayment( + request: ReceivePaymentRequest( + paymentMethod: .bitcoinAddress(newAddress: newAddress ? true : nil) + ) + ) + return .success(response.paymentRequest) + } catch { + return .failure(.other(error.localizedDescription)) + } + } + + /// Snapshot of deposits waiting to be claimed, for the receive screen. + /// The auto-claimer handles the routine cases; this surfaces the ones it + /// can't settle (mostly network fees above the claim cap) so a deposit + /// is never silently stuck. + func listOnchainDeposits() async -> OnchainDepositSummary { + guard let sdk else { return lastKnownDeposits } + do { + let response = try await sdk.listUnclaimedDeposits(request: ListUnclaimedDepositsRequest()) + let summary = OnchainDepositSummary(deposits: response.deposits.map(Self.onchainDeposit)) + lastKnownDeposits = summary + return summary + } catch { + // Returning empty here made a pending deposit vanish from the + // banner on any transient relay hiccup — the one moment a user is + // watching for it. Keep the last known state instead. + emit("Failed to list deposits: \(error.localizedDescription)") + return lastKnownDeposits + } + } + + /// Last successful deposit snapshot, so a failed refresh doesn't erase + /// money the user is waiting on. + private var lastKnownDeposits = OnchainDepositSummary(deposits: []) + + /// Maps the SDK's deposit type onto the UI model. Kept here so + /// `OnchainDeposit` stays SDK-free and unit-testable. + private static func onchainDeposit(_ deposit: DepositInfo) -> OnchainDeposit { + let failure: OnchainDeposit.Failure? + if let claimError = deposit.claimError { + switch claimError { + case .maxDepositClaimFeeExceeded(_, _, _, let requiredFeeSats, _): + failure = .feeExceeded(requiredSats: Int64(requiredFeeSats)) + case .missingUtxo: + failure = .missingUtxo + case .generic(let message): + failure = .other(message) + default: + failure = .other(String(describing: claimError)) + } + } else { + failure = nil + } + let instantClaim: OnchainDeposit.InstantClaim? + switch deposit.instantClaimStatus { + case .submitted: + instantClaim = .submitted + case .declined(let reason): + switch reason { + case .noPlan: + instantClaim = .declined(.noPlan) + case .feeExceeded(_, let quotedBps, let quotedSats): + instantClaim = .declined(.feeExceeded( + quotedSats: Int64(quotedSats), + quotedBps: Int(quotedBps) + )) + case .submissionFailed: + instantClaim = .declined(.submissionFailed) + } + case .none: + instantClaim = nil + } + return OnchainDeposit( + txid: deposit.txid, + vout: deposit.vout, + amountSats: Int64(deposit.amountSats), + isMature: deposit.isMature, + instantClaim: instantClaim, + failure: failure + ) + } + + /// Re-claim a deposit the automatic claimer couldn't settle. `feeSats` + /// caps the claim fee: pass the SDK-required fee surfaced from + /// `claimError` to accept a cost above the automatic limit — the UI + /// confirms that with the user first, so paying more is a deliberate + /// choice. `nil` retries at the same cap the automatic claimer uses. + func claimOnchainDeposit(txid: String, vout: UInt32, feeSats: UInt64?) async -> Result { + guard let sdk else { return .failure(.notConnected) } + let maxFee: MaxFee + if let feeSats { + maxFee = .fixed(amount: feeSats) + } else { + maxFee = .networkRecommended(leewaySatPerVbyte: 5) + } + do { + _ = try await sdk.claimDeposit( + request: ClaimDepositRequest(txid: txid, vout: vout, maxFee: maxFee) + ) + emit("Claimed on-chain deposit") + await refreshBalance() + return .success(()) + } catch { + emit("Failed to claim deposit: \(error.localizedDescription)") + return .failure(.other(error.localizedDescription)) + } + } + // MARK: - Lightning address func fetchLightningAddress() async -> String? { @@ -358,6 +632,31 @@ final class SparkWallet: Wallet { maxSats: Int64(pr.maxSendable / 1000), label: pr.address ?? pr.domain )) + case .bitcoinAddress(let d): + return .bitcoinAddress(address: d.address, amountSats: nil) + case .bip21(let d): + // A BIP-21 URI can carry several payment methods. Prefer a + // Lightning invoice when one is offered — it settles instantly and + // costs less — and fall back to the on-chain address. + for method in d.paymentMethods { + if case .bolt11Invoice(let inv) = method { + // Carry the invoice itself — the caller is holding a + // BIP-21 URI, which `payInvoice` can't decode. + return .bolt11( + amountSats: inv.amountMsat.map { Int64($0 / 1000) }, + invoice: inv.invoice.bolt11 + ) + } + } + for method in d.paymentMethods { + if case .bitcoinAddress(let addr) = method { + return .bitcoinAddress( + address: addr.address, + amountSats: d.amountSat.map(Int64.init) + ) + } + } + return .unknown default: return .unknown } @@ -521,7 +820,7 @@ final class SparkWallet: Wallet { /// screen can show a real fee from the SDK rather than an estimate. func prepareWithdrawOnchain( address: String, - speed: WithdrawOnchainSpeed + speed: OnchainSpeed ) async -> Result { guard let sdk else { return .failure(.notConnected) } do { diff --git a/WalletStore.swift b/WalletStore.swift index 9681c9b6..1ccfb284 100644 --- a/WalletStore.swift +++ b/WalletStore.swift @@ -16,6 +16,10 @@ final class WalletStore { private(set) var isConnected: Bool = false private(set) var lastStatus: String? private(set) var transactions: [WalletTransaction] = [] + /// On-chain deposits waiting to be claimed (Spark only). Surfaced on the + /// receive screen so a deposit the automatic claimer can't settle — + /// network fees above the claim cap, mostly — isn't silently stuck. + private(set) var onchainDeposits: OnchainDepositSummary? /// Backup search/publish progress for the Spark relay-backup flow. private(set) var relayBackupSearchState: BackupSearchState = .idle @@ -34,6 +38,7 @@ final class WalletStore { private var statusTask: Task? private var paymentTask: Task? private var balanceTask: Task? + private var depositTask: Task? enum BackupSearchState: Equatable { case idle @@ -108,7 +113,7 @@ final class WalletStore { /// `SparkWallet.prepareWithdrawOnchain`. func prepareWithdrawOnchain( address: String, - speed: WithdrawOnchainSpeed + speed: OnchainSpeed ) async -> Result { guard let spark = wallet as? SparkWallet else { return .failure(.notConnected) } return await spark.prepareWithdrawOnchain(address: address, speed: speed) @@ -165,6 +170,7 @@ final class WalletStore { lightningAddress = nil nwcNodeAlias = nil nwcMethods = [] + onchainDeposits = nil relayBackupSearchState = .idle relayBackupPublishState = .idle } @@ -408,12 +414,14 @@ final class WalletStore { lightningAddress = nil nwcNodeAlias = nil nwcMethods = [] + onchainDeposits = nil } func disconnect() { statusTask?.cancel(); statusTask = nil paymentTask?.cancel(); paymentTask = nil balanceTask?.cancel(); balanceTask = nil + depositTask?.cancel(); depositTask = nil wallet?.disconnect() wallet = nil isConnected = false @@ -438,6 +446,20 @@ final class WalletStore { WalletCache.saveBalance(msats, for: self.keypair.pubkey) } } + // On-chain deposits arrive with no user action and take confirmations + // to mature, so nothing on screen prompts a refresh at the moment one + // lands. Watching from the store rather than a view means the wallet + // knows about a pending deposit wherever the user happens to be — + // waiting on the receive screen must not be the price of finding out + // that money arrived. + if wallet is SparkWallet { + depositTask = Task { [weak self] in + while !Task.isCancelled { + await self?.refreshOnchainDeposits() + try? await Task.sleep(for: .seconds(30)) + } + } + } } // MARK: - Wallet ops (proxied) @@ -504,6 +526,77 @@ final class WalletStore { return await wallet.makeInvoice(amountMsats: amountSats * 1000, description: description, expirySecs: expirySecs) } + // MARK: - On-chain send (Spark only) + + /// Whether this wallet can send on-chain at all. NWC has no such method, + /// so the send screen shouldn't accept a Bitcoin address on one. + var supportsOnchainSend: Bool { wallet is SparkWallet } + + func prepareSendOnchain( + address: String, + amountSats: Int64, + speed: OnchainSpeed, + drainAll: Bool = false + ) async -> Result { + guard let spark = wallet as? SparkWallet else { + return .failure(.other("This wallet can't send Bitcoin on-chain.")) + } + return await spark.prepareSendOnchain( + address: address, + amountSats: amountSats, + speed: speed, + drainAll: drainAll + ) + } + + func executeSendOnchain(quote: OnchainSendQuote) async -> Result { + guard let spark = wallet as? SparkWallet else { + return .failure(.other("This wallet can't send Bitcoin on-chain.")) + } + let result = await spark.executeSendOnchain(quote: quote) + if case .success = result { + await refreshTransactions() + _ = await fetchBalance() + } + return result + } + + // MARK: - On-chain receive (Spark only) + + /// Current (or a fresh) Bitcoin deposit address. + func receiveOnchainAddress(newAddress: Bool = false) async -> Result { + guard let spark = wallet as? SparkWallet else { return .failure(.notConnected) } + return await spark.receiveOnchainAddress(newAddress: newAddress) + } + + /// Refresh the list of deposits waiting to be claimed. + func refreshOnchainDeposits() async { + guard let spark = wallet as? SparkWallet else { + onchainDeposits = nil + return + } + onchainDeposits = await spark.listOnchainDeposits() + } + + /// Claim a deposit the automatic claimer couldn't settle. `feeSats` nil + /// retries at the automatic cap; a value claims at exactly that cap after + /// the user confirmed the cost. Refreshes the deposit list either way — + /// success removes the row, failure updates its error — plus history. + func claimOnchainDeposit(_ deposit: OnchainDeposit, feeSats: Int64?) async -> Result { + guard let spark = wallet as? SparkWallet else { return .failure(.notConnected) } + let result = await spark.claimOnchainDeposit( + txid: deposit.txid, + vout: deposit.vout, + // `UInt64.init` traps on a negative. The SDK can't produce one, + // but the conversion is the only place a bad value would crash + // rather than fail. + feeSats: feeSats.map { UInt64(max($0, 0)) } + ) + await refreshOnchainDeposits() + await refreshTransactions() + return result + } + private(set) var hasMoreTransactions: Bool = false /// Last failure from `listTransactions`, surfaced in `TransactionHistoryView` so /// users (especially NWC) can see why the list is empty — `timeout` (wallet diff --git a/WalletView.swift b/WalletView.swift index e0548c47..3d7a3c4a 100644 --- a/WalletView.swift +++ b/WalletView.swift @@ -19,6 +19,9 @@ struct WalletView: View { @State private var setupMode: WalletMode? = nil @State private var showSend = false @State private var showReceive = false + /// Which tab the receive sheet opens on — the pending-deposit banner + /// sends the user straight to Bitcoin. + @State private var receiveInitialMethod: ReceiveInvoiceSheet.ReceiveMethod = .lightning @State private var showAllTransactions = false @AppStorage private var balanceDisplayRaw: String @AppStorage("walletBalanceUnit") private var balanceUnitRaw: String = WalletBalanceUnit.sats.rawValue @@ -83,9 +86,17 @@ struct WalletView: View { } .sheet(isPresented: $showReceive) { NavigationStack { - ReceiveInvoiceSheet(store: store, dismiss: { showReceive = false }) + ReceiveInvoiceSheet( + store: store, + dismiss: { showReceive = false }, + initialMethod: receiveInitialMethod + ) } } + .onChange(of: showReceive) { _, shown in + // Reset so the next plain tap on Receive opens on Lightning. + if !shown { receiveInitialMethod = .lightning } + } .sheet(isPresented: $showAllTransactions) { NavigationStack { TransactionHistoryView(store: store) @@ -130,6 +141,15 @@ struct WalletView: View { .padding(.bottom, 12) } + // Pending on-chain deposits — money that has arrived but + // isn't spendable yet. Sits directly under the seed banner + // so it's the first thing read after the balance. + if let summary = store.onchainDeposits, !summary.isEmpty { + pendingDepositBanner(summary) + .padding(.horizontal, 16) + .padding(.bottom, 12) + } + // Balance + actions — vertically centered in available space Spacer(minLength: 0) @@ -304,6 +324,61 @@ struct WalletView: View { .buttonStyle(.plain) } + // MARK: - Pending deposits + + /// Tapping opens the receive sheet already on the Bitcoin tab, where the + /// deposit can be inspected or claimed. + private func pendingDepositBanner(_ summary: OnchainDepositSummary) -> some View { + Button { + receiveInitialMethod = .onchain + showReceive = true + } label: { + HStack(spacing: 12) { + Image(systemName: "clock.arrow.circlepath") + .foregroundStyle(Color.wispZapColor) + .font(.system(size: 16)) + VStack(alignment: .leading, spacing: 2) { + Text(pendingDepositTitle(summary)) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + Text(pendingDepositSubtitle(summary)) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.wispZapColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 14)) + } + .buttonStyle(.plain) + } + + private func pendingDepositTitle(_ summary: OnchainDepositSummary) -> String { + let count = summary.deposits.count + let amount = CurrencyFormatter.formatNumber(summary.pendingSats) + return count == 1 + ? "\(amount) sats on the way" + : "\(amount) sats on the way · \(count) deposits" + } + + private func pendingDepositSubtitle(_ summary: OnchainDepositSummary) -> String { + if summary.deposits.contains(where: \.isClaimInFlight) { + return "Claiming now — settling" + } + if !summary.claimable.isEmpty { + return "Ready to claim" + } + if let failed = summary.deposits.first(where: { $0.failure != nil }) { + return failed.failure?.message ?? "Needs attention" + } + return "Waiting for confirmations" + } + // MARK: - Balance private var balanceCard: some View { @@ -714,10 +789,32 @@ struct SendInvoiceSheet: View { @State private var selectedPhoto: PhotosPickerItem? @State private var galleryError: String? @State private var detectTask: Task? + // On-chain send: fees are quoted before anything is signed, so the amount + // and speed feed a quote the user confirms rather than a blind send. + @State private var onchainSpeed: OnchainSpeed = .medium + @State private var onchainQuote: OnchainSendQuote? + @State private var isQuoting = false + /// Empty the wallet. Quoted with the fee coming out of the balance rather + /// than added to it, since a send of the full balance can't pay a fee on top. + @State private var sendMax = false private var decoded: Bolt11.DecodedInvoice? { Bolt11.decode(invoice) } private var trimmedInvoice: String { invoice.trimmingCharacters(in: .whitespacesAndNewlines) } + /// Spendable balance in sats. Shown on this screen because the amount a + /// user can send is the one fact they need here and would otherwise have + /// to leave and come back for — and on-chain it isn't even the amount + /// they typed, since the fee is added on top of it. + private var availableSats: Int64? { store.balanceMsats.map { $0 / 1000 } } + + /// A quoted on-chain send that costs more than the wallet holds. Draining + /// can't overspend by construction, so this only catches a typed amount + /// whose fee pushes the total past the balance. + private var quoteExceedsBalance: Bool { + guard let quote = onchainQuote, let available = availableSats, !sendMax else { return false } + return quote.totalSats > available + } + private var amountSats: Int64? { guard let v = Int64(amountText.filter { $0.isNumber }), v > 0 else { return nil } return v @@ -726,8 +823,13 @@ struct SendInvoiceSheet: View { private var canProceed: Bool { if trimmedInvoice.isEmpty { return false } switch inputType { - case .bolt11(let amt): return amt != nil + case .bolt11(let amt, _): return amt != nil case .sparkLnurl, .lightningAddressNeedsResolve: return amountSats != nil + case .bitcoinAddress(_, let uriAmount): + // Nothing is sent on-chain until a fee has been quoted and shown, + // and never a quote the balance can't cover. + if quoteExceedsBalance { return false } + return sendMax || (uriAmount ?? amountSats) != nil case .unknown: return false } } @@ -735,15 +837,18 @@ struct SendInvoiceSheet: View { private var needsAmountField: Bool { switch inputType { case .sparkLnurl, .lightningAddressNeedsResolve: return true - case .bolt11(let amt): return amt == nil + case .bolt11(let amt, _): return amt == nil + case .bitcoinAddress(_, let amt): return amt == nil && !sendMax default: return false } } private var buttonLabel: String { switch inputType { - case .bolt11(let amt): return amt != nil ? "Pay" : "Next" + case .bolt11(let amt, _): return amt != nil ? "Pay" : "Next" case .sparkLnurl, .lightningAddressNeedsResolve: return amountSats != nil ? "Pay" : "Next" + // On-chain is two steps: quote the fee, then send what was quoted. + case .bitcoinAddress: return onchainQuote == nil ? "Get fee quote" : "Send on-chain" case .unknown: return "Next" } } @@ -781,12 +886,27 @@ struct SendInvoiceSheet: View { private var inputView: some View { ScrollView { VStack(spacing: 20) { + if let available = availableSats { + HStack { + Text("Available") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Text("\(CurrencyFormatter.formatNumber(available)) sats") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Color.wispSurfaceVariant.opacity(0.3), in: RoundedRectangle(cornerRadius: 12)) + } + // Input card VStack(alignment: .leading, spacing: 0) { // TextEditor with placeholder overlay ZStack(alignment: .topLeading) { if invoice.isEmpty { - Text("Lightning address or invoice") + Text("Lightning or Bitcoin address, or invoice") .font(.system(.footnote, design: .monospaced)) .foregroundStyle(.tertiary) .padding(.horizontal, 18) @@ -844,7 +964,11 @@ struct SendInvoiceSheet: View { } } .background(Color.wispSurfaceVariant.opacity(0.4), in: RoundedRectangle(cornerRadius: 14)) - .onChange(of: invoice) { _, _ in scheduleDetect() } + .onChange(of: invoice) { _, _ in + onchainQuote = nil + scheduleDetect() + } + .onChange(of: amountText) { _, _ in onchainQuote = nil } .onChange(of: selectedPhoto) { _, item in guard let item else { return } Task { await decodeQRFromPhoto(item) } @@ -944,6 +1068,11 @@ struct SendInvoiceSheet: View { .frame(maxWidth: .infinity, alignment: .leading) } + // On-chain: destination, speed, and the quoted fee + if case .bitcoinAddress(let address, _) = inputType, store.supportsOnchainSend { + onchainSendSection(address: address) + } + // Status if let status { HStack(spacing: 8) { @@ -980,6 +1109,176 @@ struct SendInvoiceSheet: View { } } + @ViewBuilder + private func onchainSendSection(address: String) -> some View { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 8) { + Image(systemName: "bitcoinsign.circle.fill") + .foregroundStyle(Color.wispZapColor) + VStack(alignment: .leading, spacing: 2) { + Text("Bitcoin address") + .font(.caption) + .foregroundStyle(.secondary) + Text(address) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.primary) + .lineLimit(2) + .truncationMode(.middle) + } + Spacer(minLength: 0) + } + + Button { + sendMax.toggle() + // Draining and sending an amount quote against opposite fee + // policies, so the held quote can't survive the switch. + onchainQuote = nil + if sendMax { amountText = "" } + } label: { + HStack(spacing: 8) { + Image(systemName: sendMax ? "checkmark.circle.fill" : "circle") + .foregroundStyle(sendMax ? Color.wispZapColor : .secondary) + Text("Send all funds") + .font(.subheadline) + .foregroundStyle(.primary) + Spacer(minLength: 0) + } + } + .buttonStyle(.plain) + + VStack(alignment: .leading, spacing: 8) { + Text("Confirmation speed") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .tracking(0.5) + HStack(spacing: 8) { + ForEach(OnchainSpeed.allCases, id: \.rawValue) { speed in + Button { + guard onchainSpeed != speed else { return } + onchainSpeed = speed + // The fee is tier-specific, so a quote for the old + // tier would misstate what this send costs. + onchainQuote = nil + } label: { + Text(speed.label) + .font(.caption.weight(.medium)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + onchainSpeed == speed ? Color.wispZapColor : Color.wispSurfaceVariant.opacity(0.5), + in: Capsule() + ) + .foregroundStyle(onchainSpeed == speed ? .white : .primary) + } + .buttonStyle(.plain) + } + Spacer(minLength: 0) + } + Text(onchainSpeed.detail) + .font(.caption2) + .foregroundStyle(.tertiary) + } + + if isQuoting { + HStack(spacing: 8) { + ProgressView().scaleEffect(0.75) + Text("Quoting fee…").font(.caption).foregroundStyle(.secondary) + } + } else if let quote = onchainQuote { + VStack(spacing: 6) { + quoteRow("Amount", "\(CurrencyFormatter.formatNumber(quote.amountSats)) sats") + quoteRow("Network fee", "\(CurrencyFormatter.formatNumber(quote.feeSats)) sats") + Divider().opacity(0.25) + quoteRow("Total", "\(CurrencyFormatter.formatNumber(quote.totalSats)) sats", emphasized: true) + if quoteExceedsBalance { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "exclamationmark.circle.fill") + .foregroundStyle(.red) + .font(.caption) + Text("That's more than you have. The fee is added on top of the amount, so you need \(CurrencyFormatter.formatNumber(quote.totalSats)) sats in total.") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.top, 2) + } + if quote.leavesTokensBehind { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .font(.caption) + Text("This sends bitcoin only. Other token balances in this wallet stay behind — Wisp can't move them.") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.top, 2) + } + if quote.isFeeDisproportionate { + // On-chain fees don't scale with the amount, so a small + // send can cost a large share of itself. Say so before + // it's signed rather than after it's spent. + HStack(alignment: .top, spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .font(.caption) + Text("The fee is \(Int((quote.feeShare * 100).rounded()))% of what you're sending. A Lightning payment would cost far less.") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.top, 2) + } + } + .padding(12) + .background(Color.wispSurfaceVariant.opacity(0.3), in: RoundedRectangle(cornerRadius: 12)) + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.wispSurfaceVariant.opacity(0.25), in: RoundedRectangle(cornerRadius: 14)) + } + + private func quoteRow(_ label: String, _ value: String, emphasized: Bool = false) -> some View { + HStack { + Text(label) + .font(emphasized ? .subheadline.weight(.semibold) : .caption) + .foregroundStyle(emphasized ? .primary : .secondary) + Spacer() + Text(value) + .font(emphasized ? .subheadline.weight(.semibold) : .caption) + .foregroundStyle(.primary) + } + } + + /// Quote, then send. Splitting them means the fee is always seen before + /// it's paid — an on-chain fee can be a large share of a small send. + private func sendOnchain(address: String, sats: Int64) async { + if let quote = onchainQuote { + inFlight = true + defer { inFlight = false } + switch await store.executeSendOnchain(quote: quote) { + case .success: dismiss() + case .failure(let err): + status = err.localizedDescription + // The held quote is spent or stale either way; make the user + // re-quote rather than retry against a number that may have moved. + onchainQuote = nil + } + return + } + isQuoting = true + defer { isQuoting = false } + status = nil + switch await store.prepareSendOnchain( + address: address, + amountSats: sats, + speed: onchainSpeed, + drainAll: sendMax + ) { + case .success(let quote): onchainQuote = quote + case .failure(let err): status = err.localizedDescription + } + } + private func scheduleDetect() { detectTask?.cancel() let input = trimmedInvoice @@ -995,14 +1294,24 @@ struct SendInvoiceSheet: View { } private func pay() async { + // `.disabled(inFlight)` on the button only takes effect after a + // re-render, so a second tap in the same frame reaches here. + guard !inFlight else { return } inFlight = true; defer { inFlight = false } status = nil let input = trimmedInvoice let result: Result switch inputType { - case .bolt11: - result = await store.payInvoice(normalizeInvoice(input)) + case .bitcoinAddress(let address, let uriAmount): + let sats = uriAmount ?? amountSats + guard sendMax || sats != nil else { return } + await sendOnchain(address: address, sats: sats ?? 0) + return + case .bolt11(_, let carried): + // A BIP-21 that offered an invoice hands it over here; `input` is + // the URI, which `payInvoice` can't decode. + result = await store.payInvoice(carried ?? normalizeInvoice(input)) case .sparkLnurl, .lightningAddressNeedsResolve: guard let sats = amountSats else { return } result = await store.payLightningAddress(input, amountSats: sats) @@ -1057,6 +1366,9 @@ struct ReceiveInvoiceSheet: View { @Bindable var store: WalletStore @Environment(AppSettings.self) private var settings var dismiss: () -> Void + /// Tab to open on. The wallet dashboard's pending-deposit banner opens + /// straight to Bitcoin; everything else starts on Lightning. + var initialMethod: ReceiveMethod = .lightning @State private var amount: String = "" @State private var description: String = "" @State private var invoice: String? @@ -1067,6 +1379,26 @@ struct ReceiveInvoiceSheet: View { @State private var showCompose = false @State private var expiryPreset: ExpiryPreset = .oneHour @State private var customExpiryMinutes: String = "" + // On-chain (Bitcoin) tab + @State private var method: ReceiveMethod = .lightning + /// Guards the one-time adoption of `initialMethod` so a user who switches + /// tabs isn't yanked back on the next body evaluation. + @State private var didApplyInitialMethod = false + @State private var onchainAddress: String? + @State private var onchainStatus: String? + @State private var onchainInFlight = false + @State private var onchainCopied = false + /// Deposit held for the "claims at the required fee" confirmation. + @State private var depositPendingFeeConfirmation: OnchainDeposit? + /// Deposit currently being claimed, so only one claim runs at a time. + @State private var claimingDeposit: OnchainDeposit? + + /// Receive rails for Spark wallets: a Lightning invoice, or the static + /// Bitcoin deposit address for on-chain sends. + enum ReceiveMethod: String, CaseIterable { + case lightning = "Lightning" + case onchain = "Bitcoin" + } enum ExpiryPreset: String, CaseIterable { case oneHour = "1h" @@ -1086,10 +1418,24 @@ struct ReceiveInvoiceSheet: View { ScrollViewReader { proxy in ScrollView { VStack(spacing: 20) { - if let inv = invoice { - invoiceDisplay(inv) - } else { - lightningForm(proxy: proxy) + if store.mode == .spark { + Picker("Method", selection: $method) { + ForEach(ReceiveMethod.allCases, id: \.self) { m in + Text(m.rawValue).tag(m) + } + } + .pickerStyle(.segmented) + } + + switch method { + case .lightning: + if let inv = invoice { + invoiceDisplay(inv) + } else { + lightningForm(proxy: proxy) + } + case .onchain: + onchainForm } } .padding(.horizontal, 20) @@ -1102,6 +1448,49 @@ struct ReceiveInvoiceSheet: View { .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Close", action: dismiss) } } + .task { + if !didApplyInitialMethod { + didApplyInitialMethod = true + if method != initialMethod { method = initialMethod } + } + } + .task(id: method) { + // First switch to the Bitcoin tab: fetch the address, then keep + // the deposit list current. Deposits land without any user + // action — someone watching this screen for their transaction to + // confirm has nothing to tap to make it show up, so a one-shot + // load would read as "my bitcoin never arrived". SwiftUI cancels + // this task when the tab changes or the sheet closes, so the + // poll only runs while the section is actually on screen. + guard method == .onchain, store.mode == .spark else { return } + if onchainAddress == nil { + await loadOnchainAddress(newAddress: false) + } + // One refresh on open; `WalletStore` polls from here on, so a + // second loop in the view would just double the traffic. + await store.refreshOnchainDeposits() + } + .alert( + "Claim deposit?", + isPresented: Binding( + get: { depositPendingFeeConfirmation != nil }, + set: { if !$0 { depositPendingFeeConfirmation = nil } } + ) + ) { + Button("Claim") { + if let deposit = depositPendingFeeConfirmation, + case .feeExceeded(let sats) = deposit.failure { + Task { await claim(deposit, feeSats: sats) } + } + depositPendingFeeConfirmation = nil + } + Button("Cancel", role: .cancel) { depositPendingFeeConfirmation = nil } + } message: { + if let deposit = depositPendingFeeConfirmation, + case .feeExceeded(let sats) = deposit.failure { + Text("On-chain fees are high right now. Claiming your \(CurrencyFormatter.formatNumber(deposit.amountSats)) sats will pay about \(CurrencyFormatter.formatNumber(sats)) sats in fees.") + } + } .sheet(isPresented: $showCompose) { if let bolt11 = invoice { NavigationStack { @@ -1199,6 +1588,223 @@ struct ReceiveInvoiceSheet: View { } } + // MARK: On-chain (Bitcoin) tab + + private func loadOnchainAddress(newAddress: Bool) async { + onchainInFlight = true + onchainStatus = nil + defer { onchainInFlight = false } + switch await store.receiveOnchainAddress(newAddress: newAddress) { + case .success(let address): onchainAddress = address + case .failure(let err): onchainStatus = err.localizedDescription + } + } + + @ViewBuilder + private var onchainForm: some View { + if let address = onchainAddress { + VStack(spacing: 20) { + VStack(spacing: 10) { + QRCodeImage(payload: address, sideLength: 260) + .clipShape(RoundedRectangle(cornerRadius: 12)) + Text("Send Bitcoin on-chain to this address") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(20) + .frame(maxWidth: .infinity) + .background(Color.wispSurfaceVariant.opacity(0.35), in: RoundedRectangle(cornerRadius: 16)) + + VStack(spacing: 0) { + Text(address) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(3) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .padding(14) + + Divider().opacity(0.25) + + HStack(spacing: 0) { + Button { + UIPasteboard.general.string = address + withAnimation { onchainCopied = true } + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + withAnimation { onchainCopied = false } + } + } label: { + Label(onchainCopied ? "Copied ✓" : "Copy", systemImage: onchainCopied ? "checkmark" : "doc.on.doc") + .font(.subheadline.weight(.medium)) + .foregroundStyle(Color.wispZapColor) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.plain) + .animation(.easeInOut(duration: 0.15), value: onchainCopied) + + Divider().frame(height: 24) + + ShareLink(item: address) { + Label("Share", systemImage: "square.and.arrow.up") + .font(.subheadline.weight(.medium)) + .foregroundStyle(Color.wispZapColor) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + + Divider().frame(height: 24) + + Button { + Task { await loadOnchainAddress(newAddress: true) } + } label: { + Label("New", systemImage: "arrow.triangle.2.circlepath") + .font(.subheadline.weight(.medium)) + .foregroundStyle(Color.wispZapColor) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.plain) + } + } + .background(Color.wispSurfaceVariant.opacity(0.4), in: RoundedRectangle(cornerRadius: 14)) + + Text("Funds arrive after the transaction confirms. Older addresses keep working — they stay valid for future deposits.") + .font(.caption) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + } + } else if onchainInFlight { + ProgressView() + .padding(.vertical, 40) + } + + pendingDepositsSection + + if let onchainStatus { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.circle.fill").foregroundStyle(.red) + Text(onchainStatus).font(.subheadline).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + /// Deposits sent to the wallet's addresses that haven't been claimed into + /// the spendable balance yet. The automatic claimer handles the routine + /// cases — this surfaces the ones it can't, so a deposit is never + /// silently stuck. + @ViewBuilder + private var pendingDepositsSection: some View { + if let summary = store.onchainDeposits, !summary.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Pending deposits") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .tracking(0.5) + + VStack(spacing: 0) { + ForEach(Array(summary.deposits.enumerated()), id: \.element.id) { index, deposit in + depositRow(deposit) + if index < summary.deposits.count - 1 { + Divider().opacity(0.25).padding(.leading, 16) + } + } + } + .background(Color.wispSurfaceVariant.opacity(0.4), in: RoundedRectangle(cornerRadius: 14)) + } + } + } + + private func depositRow(_ deposit: OnchainDeposit) -> some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "link.circle") + .font(.system(size: 15)) + .foregroundStyle(Color.wispZapColor) + .padding(.top, 2) + VStack(alignment: .leading, spacing: 3) { + Text("\(CurrencyFormatter.formatNumber(deposit.amountSats)) sats") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + Text(depositStatus(deposit)) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + // The wallet can't say how far along a confirmation is, so + // give the user the transaction itself — the explorer is the + // authoritative answer to "where are my sats". + HStack(spacing: 14) { + Button { + UIPasteboard.general.string = deposit.txid + QuickFollowToast.shared.show("Transaction ID copied") + } label: { + Label("Copy ID", systemImage: "doc.on.doc") + .font(.caption2.weight(.medium)) + .foregroundStyle(Color.wispZapColor) + } + .buttonStyle(.plain) + + if let url = URL(string: "https://mempool.space/tx/\(deposit.txid)") { + Link(destination: url) { + Label("Explorer", systemImage: "arrow.up.right.square") + .font(.caption2.weight(.medium)) + .foregroundStyle(Color.wispZapColor) + } + } + } + .padding(.top, 2) + } + Spacer() + if let failure = deposit.failure, case .feeExceeded = failure, deposit.isClaimable { + Button("Claim…") { + depositPendingFeeConfirmation = deposit + } + .font(.footnote.weight(.medium)) + .foregroundStyle(Color.wispZapColor) + .buttonStyle(.plain) + } else if deposit.isClaimable { + Button { + Task { await claim(deposit, feeSats: nil) } + } label: { + if claimingDeposit == deposit { + ProgressView() + } else { + Text("Claim") + .font(.footnote.weight(.medium)) + .foregroundStyle(Color.wispZapColor) + } + } + .buttonStyle(.plain) + .disabled(claimingDeposit != nil) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + private func depositStatus(_ deposit: OnchainDeposit) -> String { + if deposit.isClaimInFlight { return "Claiming now — settling…" } + if let failure = deposit.failure { return failure.message } + // Same line whether or not it has matured. A confirmed deposit is + // claimed automatically and the row disappears when it lands, so + // announcing that stage tells the user about bookkeeping they can't + // act on. + return "Waiting for confirmations" + } + + private func claim(_ deposit: OnchainDeposit, feeSats: Int64?) async { + claimingDeposit = deposit + defer { claimingDeposit = nil } + switch await store.claimOnchainDeposit(deposit, feeSats: feeSats) { + case .success: + onchainStatus = nil + case .failure(let err): + onchainStatus = err.localizedDescription + } + } + // MARK: Invoice display (after creation) private func invoiceDisplay(_ inv: String) -> some View { diff --git a/wisp/OnchainReceive.swift b/wisp/OnchainReceive.swift new file mode 100644 index 00000000..5b5d011a --- /dev/null +++ b/wisp/OnchainReceive.swift @@ -0,0 +1,109 @@ +import Foundation + +/// A Bitcoin transaction sent to the wallet's Spark deposit address that has +/// not yet been pulled into the spendable balance. +/// +/// This type exists because on-chain receive has a second half people don't +/// expect: bitcoin sent to the deposit address confirms on-chain but does +/// **not** appear in the Spark balance until it is claimed. A wallet that only +/// showed the address would leave users watching a confirmed transaction that +/// their balance never reflects. +struct OnchainDeposit: Identifiable, Equatable, Sendable { + let txid: String + let vout: UInt32 + let amountSats: Int64 + /// Whether the deposit has enough confirmations to be claimable yet. + let isMature: Bool + /// Outcome of an instant (0-conf) claim, once one has been attempted. + /// Nil when none has been — the deposit is simply waiting for its three + /// confirmations. + let instantClaim: InstantClaim? + /// Set when a previous claim attempt failed. Claiming is retried by the + /// user, so the reason has to survive to be shown. + let failure: Failure? + + /// `txid:vout` — a transaction can pay the deposit address more than once. + var id: String { "\(txid):\(vout)" } + + enum Failure: Equatable, Sendable { + /// On-chain fees rose above the cap the claim was willing to pay. + /// Recoverable: retry when fees fall, or accept the higher fee. + case feeExceeded(requiredSats: Int64) + /// The output the SDK expected is no longer there — typically an + /// unconfirmed parent that got replaced. + case missingUtxo + case other(String) + + var message: String { + switch self { + case .feeExceeded(let sats): + return "On-chain fees rose above the limit. Claiming this now would cost about \(sats) sats." + case .missingUtxo: + return "The transaction this deposit came from is no longer on-chain." + case .other(let message): + return message + } + } + + /// Whether waiting is likely to help. A fee spike passes; a missing + /// output does not come back. + var isWorthRetrying: Bool { + switch self { + case .feeExceeded: return true + case .missingUtxo: return false + case .other: return true + } + } + } + + /// Status of an instant (0-conf) claim, in which the SSP fronts the + /// confirmation risk for a spread. + /// + /// Wisp never asks for one. The SSP sells that risk at broadcast time, + /// but the SDK doesn't report a deposit until it already has a + /// confirmation — by which point there is no risk left to sell, and the + /// request is declined with `noPlan`. Reading the status is still worth + /// it: if the SDK ever claims a deposit this way on its own, the UI needs + /// to know not to touch it while it settles. + enum InstantClaim: Equatable, Sendable { + /// Submitted and settling. The SDK requires that a deposit in this + /// state not be re-claimed, so the UI must not offer an action. + case submitted + case declined(Decline) + + enum Decline: Equatable, Sendable { + /// The SSP offered no 0-conf plan for this deposit. + case noPlan + /// The quoted spread was above the ceiling that was offered. + case feeExceeded(quotedSats: Int64, quotedBps: Int) + case submissionFailed + } + } + + /// An instant claim is submitted and settling. + var isClaimInFlight: Bool { instantClaim == .submitted } + + /// Claimable right now: confirmed enough, no claim already in flight, + /// and not blocked by a failure that retrying won't fix. + var isClaimable: Bool { + guard isMature, !isClaimInFlight else { return false } + guard let failure else { return true } + return failure.isWorthRetrying + } +} + +/// Everything the on-chain receive screen needs about pending deposits. +struct OnchainDepositSummary: Equatable, Sendable { + var deposits: [OnchainDeposit] = [] + + var isEmpty: Bool { deposits.isEmpty } + + /// Total still waiting to be claimed — the number a user compares against + /// what they sent. + var pendingSats: Int64 { deposits.reduce(0) { $0 + $1.amountSats } } + + /// Deposits that can be claimed right now. + var claimable: [OnchainDeposit] { deposits.filter(\.isClaimable) } + +} + diff --git a/wisp/OnchainSend.swift b/wisp/OnchainSend.swift new file mode 100644 index 00000000..681dd6dd --- /dev/null +++ b/wisp/OnchainSend.swift @@ -0,0 +1,39 @@ +import Foundation + +/// What an on-chain send would cost, quoted before anything is signed. +/// +/// Fees are added on top of the amount — the SDK's default `feesExcluded` — so +/// the recipient gets exactly `amountSats` and the wallet spends `totalSats`. +/// That's the opposite of draining, where the fee comes out of the amount, and +/// it's why the balance check is against the total rather than the amount. +struct OnchainSendQuote: Equatable, Sendable { + let address: String + /// What actually lands at the destination. + let amountSats: Int64 + /// Service fee plus the L1 broadcast fee, both real cost to the user. + let feeSats: Int64 + let speed: OnchainSpeed + /// Set when emptying the wallet would leave a token balance behind. + /// + /// `balanceSats` is bitcoin only — tokens sit in a separate balance the + /// send doesn't touch. Draining therefore empties the sats and strands + /// any stablecoin, and Wisp doesn't convert tokens, so the user would be + /// left with a wallet that looks empty and isn't. Worth saying before + /// they sign, not after. + var leavesTokensBehind: Bool = false + + /// What leaves the wallet. + var totalSats: Int64 { amountSats + feeSats } + + /// Fee as a share of the amount being sent. On-chain fees don't scale with + /// amount, so a small send can cost more in fees than it delivers — worth + /// saying out loud before the user signs. + var feeShare: Double { + guard amountSats > 0 else { return 0 } + return Double(feeSats) / Double(amountSats) + } + + /// True when the fee is a large enough share of the send to be worth a + /// warning rather than a line item. + var isFeeDisproportionate: Bool { feeShare >= 0.10 } +} diff --git a/wisp/WithdrawOnchain.swift b/wisp/WithdrawOnchain.swift index 62bf92bc..b9fc5eb6 100644 --- a/wisp/WithdrawOnchain.swift +++ b/wisp/WithdrawOnchain.swift @@ -1,8 +1,11 @@ import Foundation -/// Confirmation speed for an on-chain withdrawal, mapped to the SDK's three fee +/// Confirmation speed for an on-chain send, mapped to the SDK's three fee /// tiers. Kept SDK-free so the view layer and tests don't import the SDK. -enum WithdrawOnchainSpeed: String, CaseIterable, Sendable { +/// +/// Shared by the whole-balance withdraw and a send to a chosen address — +/// the fee tiers are the same question either way. +enum OnchainSpeed: String, CaseIterable, Sendable { case slow case medium case fast @@ -35,7 +38,7 @@ struct WithdrawOnchainQuote: Equatable, Sendable { let address: String let spendSats: Int64 let feeSats: Int64 - let speed: WithdrawOnchainSpeed + let speed: OnchainSpeed var netSats: Int64 { max(0, spendSats - feeSats) } diff --git a/wisp/WithdrawOnchainSheet.swift b/wisp/WithdrawOnchainSheet.swift index 9e2edae3..0f0c4098 100644 --- a/wisp/WithdrawOnchainSheet.swift +++ b/wisp/WithdrawOnchainSheet.swift @@ -11,7 +11,7 @@ struct WithdrawOnchainSheet: View { @Environment(\.dismiss) private var dismiss @State private var address = "" - @State private var speed: WithdrawOnchainSpeed = .medium + @State private var speed: OnchainSpeed = .medium @State private var quote: WithdrawOnchainQuote? @State private var isQuoting = false @State private var isSending = false @@ -143,7 +143,7 @@ struct WithdrawOnchainSheet: View { Text("Confirmation speed") .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) - ForEach(WithdrawOnchainSpeed.allCases, id: \.self) { option in + ForEach(OnchainSpeed.allCases, id: \.self) { option in Button { speed = option quote = nil diff --git a/wispTests/OnchainReceiveTests.swift b/wispTests/OnchainReceiveTests.swift new file mode 100644 index 00000000..aa32255b --- /dev/null +++ b/wispTests/OnchainReceiveTests.swift @@ -0,0 +1,122 @@ +import Foundation +import Testing +@testable import wisp + +/// The pure model behind the receive sheet's Bitcoin tab: deposit identity, +/// which deposits the user can act on, and how failures read. The SDK-facing +/// mapping lives in `SparkWallet` and needs a connected SDK, so it stays out +/// of these tests. +struct OnchainReceiveTests { + + private let txid = String(repeating: "a", count: 64) + + private func deposit( + txid: String? = nil, + vout: UInt32 = 0, + amountSats: Int64 = 50_000, + isMature: Bool = true, + instantClaim: OnchainDeposit.InstantClaim? = nil, + failure: OnchainDeposit.Failure? = nil + ) -> OnchainDeposit { + OnchainDeposit( + txid: txid ?? self.txid, + vout: vout, + amountSats: amountSats, + isMature: isMature, + instantClaim: instantClaim, + failure: failure + ) + } + + // MARK: - Identity + + @Test func idIsTxidAndVout() { + #expect(deposit().id == "\(txid):0") + #expect(deposit(vout: 2).id == "\(txid):2") + } + + /// A transaction can pay the deposit address more than once — each + /// output is its own row. + @Test func sameTxidDifferentVoutIsDifferentDeposit() { + #expect(deposit(vout: 0) != deposit(vout: 1)) + } + + // MARK: - Claimability + + @Test func healthyMatureDepositIsClaimable() { + #expect(deposit().isClaimable) + } + + @Test func immatureDepositIsNotClaimable() { + #expect(!deposit(isMature: false).isClaimable) + } + + @Test func feeExceededFailureIsRetriable() { + let d = deposit(failure: .feeExceeded(requiredSats: 900)) + #expect(d.isClaimable) + } + + @Test func missingUtxoIsNotRetriable() { + #expect(!deposit(failure: .missingUtxo).isClaimable) + } + + @Test func otherFailuresAreRetriable() { + #expect(deposit(failure: .other("something went wrong")).isClaimable) + } + + /// While an instant claim is settling, the SDK requires that the deposit + /// not be re-claimed — the UI must not offer a retry even though a + /// stale failure may still be attached. + @Test func claimInFlightIsNeverClaimable() { + #expect(!deposit(instantClaim: .submitted).isClaimable) + #expect(!deposit(instantClaim: .submitted, failure: .feeExceeded(requiredSats: 900)).isClaimable) + } + + // MARK: - Instant-claim status (reported by the SDK, never requested) + + @Test func inFlightTracksSubmittedOnly() { + #expect(deposit(instantClaim: .submitted).isClaimInFlight) + #expect(!deposit(instantClaim: .declined(.noPlan)).isClaimInFlight) + #expect(!deposit().isClaimInFlight) + } + + // MARK: - Failure messages + + @Test func feeExceededMessageShowsRequiredFee() { + let message = OnchainDeposit.Failure.feeExceeded(requiredSats: 900).message + #expect(message.contains("900")) + } + + @Test func otherFailurePassesMessageThrough() { + #expect(OnchainDeposit.Failure.other("nope").message == "nope") + } + + @Test func retryWorthiness() { + #expect(OnchainDeposit.Failure.feeExceeded(requiredSats: 1).isWorthRetrying) + #expect(OnchainDeposit.Failure.other("x").isWorthRetrying) + #expect(!OnchainDeposit.Failure.missingUtxo.isWorthRetrying) + } + + // MARK: - Summary aggregation + + @Test func summaryTotalsAndBuckets() { + let summary = OnchainDepositSummary(deposits: [ + deposit(vout: 0, amountSats: 10_000), + deposit(vout: 1, amountSats: 20_000, isMature: false), + deposit(vout: 2, amountSats: 30_000, failure: .feeExceeded(requiredSats: 900)), + deposit(vout: 3, amountSats: 40_000, failure: .missingUtxo), + deposit(vout: 4, amountSats: 50_000, instantClaim: .submitted), + ]) + + #expect(!summary.isEmpty) + #expect(summary.pendingSats == 150_000) + #expect(summary.claimable.map(\.vout) == [0, 2]) + } + + @Test func emptySummary() { + let summary = OnchainDepositSummary(deposits: []) + #expect(summary.isEmpty) + #expect(summary.pendingSats == 0) + #expect(summary.claimable.isEmpty) + } +} diff --git a/wispTests/OnchainSendTests.swift b/wispTests/OnchainSendTests.swift new file mode 100644 index 00000000..01a19faf --- /dev/null +++ b/wispTests/OnchainSendTests.swift @@ -0,0 +1,134 @@ +import Foundation +import Testing +@testable import wisp + +/// The pure model behind sending to a Bitcoin address: what leaves the wallet +/// versus what lands, and when a fee is large enough to warn about. The SDK +/// quote/execute path needs a connected wallet, so it stays out of these tests. +struct OnchainSendTests { + + private let address = "bc1p6m4waffms2qszpvg6fxvp406hw368t5huu80pwe8sdfjdkzp9zpqmz57y3" + + private func quote( + amountSats: Int64 = 100_000, + feeSats: Int64 = 500, + speed: OnchainSpeed = .medium, + leavesTokensBehind: Bool = false + ) -> OnchainSendQuote { + OnchainSendQuote( + address: address, + amountSats: amountSats, + feeSats: feeSats, + speed: speed, + leavesTokensBehind: leavesTokensBehind + ) + } + + // MARK: - Totals + + /// Fees are added on top, so the recipient gets the amount and the wallet + /// spends more than it. The inverse of draining, where the fee comes out. + @Test func feesAreAddedOnTop() { + let q = quote(amountSats: 100_000, feeSats: 500) + #expect(q.totalSats == 100_500) + #expect(q.amountSats == 100_000) + } + + @Test func zeroFeeStillTotalsTheAmount() { + #expect(quote(amountSats: 7_000, feeSats: 0).totalSats == 7_000) + } + + // MARK: - Fee proportion + + @Test func ordinaryFeeIsNotFlagged() { + #expect(!quote(amountSats: 100_000, feeSats: 500).isFeeDisproportionate) + } + + /// A fee that eats a large share of a small send is worth saying out loud + /// before it's signed, not after it's spent. + @Test func feeEatingSmallSendIsFlagged() { + let q = quote(amountSats: 2_000, feeSats: 800) + #expect(q.isFeeDisproportionate) + #expect(q.feeShare == 0.4) + } + + @Test func warningThresholdIsTenPercent() { + #expect(quote(amountSats: 10_000, feeSats: 1_000).isFeeDisproportionate) + #expect(!quote(amountSats: 10_000, feeSats: 999).isFeeDisproportionate) + } + + /// Guard the divide rather than trapping on a zero amount. + @Test func zeroAmountHasNoShare() { + #expect(quote(amountSats: 0, feeSats: 300).feeShare == 0) + } + + // MARK: - Quote identity + + /// Execution refuses anything but the exact quote that was confirmed, so + /// every field has to participate in equality. + @Test func quotesDifferOnEveryField() { + let base = quote() + #expect(base != quote(amountSats: 100_001)) + #expect(base != quote(feeSats: 501)) + #expect(base != quote(speed: .fast)) + #expect(base == quote()) + } + + // MARK: - Token balances + + /// Emptying the wallet moves bitcoin only. A wallet imported from an app + /// that holds stablecoins would look drained while still holding tokens, + /// so the quote has to carry that fact to the confirmation. + @Test func drainCanFlagStrandedTokens() { + #expect(quote(leavesTokensBehind: true).leavesTokensBehind) + #expect(!quote().leavesTokensBehind) + } + + /// The flag is part of what the user agreed to, so it can't be swapped + /// between quoting and sending. + @Test func tokenFlagParticipatesInEquality() { + #expect(quote(leavesTokensBehind: true) != quote(leavesTokensBehind: false)) + } + + // MARK: - Paying a BIP-21 that carries an invoice + + /// A BIP-21 URI can offer an address and a Lightning invoice. Taking the + /// invoice means paying *the invoice*, not the URI it arrived in — the + /// URI doesn't decode, so dropping the string broke every such paste. + @Test func bolt11CarriesTheInvoiceWhenItCameFromBip21() { + let type = WalletInputType.bolt11(amountSats: 1_000, invoice: "lnbc10n1pexample") + guard case .bolt11(let amount, let invoice) = type else { + Issue.record("expected bolt11, got \(type)") + return + } + #expect(amount == 1_000) + #expect(invoice == "lnbc10n1pexample") + } + + /// A directly pasted invoice has nothing to carry — the input itself is + /// the invoice. + @Test func directlyPastedInvoiceCarriesNothing() { + guard case .bolt11(_, let invoice) = WalletInputType.bolt11(amountSats: nil) else { + Issue.record("expected bolt11") + return + } + #expect(invoice == nil) + } + + /// An amountless invoice still needs an amount from the user, carried or + /// not — the gate is the amount, not where the invoice came from. + @Test func carriedInvoiceStillNeedsAnAmountWhenTheInvoiceOmitsOne() { + #expect(WalletInputType.bolt11(amountSats: nil, invoice: "lnbc1p").needsAmountEntry) + #expect(!WalletInputType.bolt11(amountSats: 500, invoice: "lnbc1p").needsAmountEntry) + } + + // MARK: - Speed tiers + + @Test func everySpeedIsLabeled() { + for speed in OnchainSpeed.allCases { + #expect(!speed.label.isEmpty) + #expect(!speed.detail.isEmpty) + } + #expect(OnchainSpeed.allCases.count == 3) + } +} diff --git a/wispTests/WithdrawOnchainTests.swift b/wispTests/WithdrawOnchainTests.swift index 10f37a51..06a9b026 100644 --- a/wispTests/WithdrawOnchainTests.swift +++ b/wispTests/WithdrawOnchainTests.swift @@ -7,7 +7,7 @@ import Testing /// emptying their wallet. struct WithdrawOnchainQuoteTests { - private func quote(spend: Int64, fee: Int64, speed: WithdrawOnchainSpeed = .medium) -> WithdrawOnchainQuote { + private func quote(spend: Int64, fee: Int64, speed: OnchainSpeed = .medium) -> WithdrawOnchainQuote { WithdrawOnchainQuote(address: "bc1qexample", spendSats: spend, feeSats: fee, speed: speed) } @@ -59,7 +59,7 @@ struct WithdrawOnchainQuoteTests { } @Test func everySpeedIsLabelledAndExplained() { - for speed in WithdrawOnchainSpeed.allCases { + for speed in OnchainSpeed.allCases { #expect(!speed.label.isEmpty) #expect(!speed.detail.isEmpty) }