From 3f557404e01d5465d1b4ad83a58c85899ff870c9 Mon Sep 17 00:00:00 2001 From: dmnyc Date: Fri, 4 Sep 2026 00:23:15 -0400 Subject: [PATCH 1/3] feat(wallet): send and receive bitcoin on-chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spark wallets hold a static Bitcoin deposit address and can pay to one, but neither was reachable from the app. Receiving on-chain meant finding the address elsewhere, and a deposit that arrived was invisible until it settled into the balance on its own. Receive gains a Lightning / Bitcoin selector. The Bitcoin tab shows the deposit address as a QR and text, with copy, share, and rotation — previous addresses stay valid, so rotating costs nothing. Deposits are tracked from WalletStore rather than the receive screen. A deposit arrives with no user action and takes three confirmations to mature, so nothing on screen prompts a refresh at the moment one lands; watching from a view would have made "money arrived" a fact you only learn by sitting on the right tab. The wallet dashboard shows a banner with the amount and status that opens the receive sheet on the Bitcoin tab, and each pending deposit offers its transaction id and a link to a block explorer, which is the authoritative answer to how far along a confirmation is — the SDK reports only a matured / not-matured flag, so the wallet genuinely cannot count confirmations itself. Claiming stays automatic. What's new is visibility when it fails: the SDK's claim error was never read, so a deposit whose claim fee exceeded the automatic cap sat unclaimed and unexplained. Those now surface with the reason, and a retry that names the required fee and asks first — paying more than the automatic cap is a deliberate choice. Send accepts a Bitcoin address or BIP-21 URI in the field it already had. A BIP-21 that also offers a Lightning invoice takes the invoice: cheaper and instant. On-chain sends quote before they send, because an on-chain fee is added on top of the amount rather than taken out of it, and can be a large share of a small send — a fee over a tenth of the amount says so and points at Lightning. "Send all funds" quotes with the fee coming out of the balance instead, since a send of the whole balance can't pay a fee on top. The send screen now shows the available balance. On-chain that isn't a convenience: the amount that clears is the amount plus the fee, so a send that looks affordable can fail on a total the user was never shown. A quote beyond the balance is refused with the total spelled out. Draining also warns when it would strand tokens. `balanceSats` is bitcoin only, so emptying a wallet imported from an app that holds stablecoins leaves those behind, in a wallet Wisp won't convert with. That is a disclosure rather than a fix — the wallet would otherwise look empty while it wasn't. Every quote is held and re-checked before sending, so a screen that has drifted re-quotes rather than sending an amount or destination the user didn't agree to. Instant (0-conf) claims are read but never requested: the SSP sells that risk at broadcast, and the SDK doesn't report a deposit until it already has a confirmation, so the request is always declined. Reading the status still matters, so a deposit the SDK claims that way isn't touched mid-settle. --- LnurlResolver.swift | 12 + SparkWallet.swift | 260 ++++++++++++ WalletStore.swift | 90 ++++ WalletView.swift | 616 +++++++++++++++++++++++++++- wisp/OnchainReceive.swift | 112 +++++ wisp/OnchainSend.swift | 68 +++ wispTests/OnchainReceiveTests.swift | 126 ++++++ wispTests/OnchainSendTests.swift | 102 +++++ 8 files changed, 1379 insertions(+), 7 deletions(-) create mode 100644 wisp/OnchainReceive.swift create mode 100644 wisp/OnchainSend.swift create mode 100644 wispTests/OnchainReceiveTests.swift create mode 100644 wispTests/OnchainSendTests.swift diff --git a/LnurlResolver.swift b/LnurlResolver.swift index e8189478..69565ce3 100644 --- a/LnurlResolver.swift +++ b/LnurlResolver.swift @@ -18,15 +18,27 @@ enum WalletInputType { 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 .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 diff --git a/SparkWallet.swift b/SparkWallet.swift index c3ccb663..8b3269d5 100644 --- a/SparkWallet.swift +++ b/SparkWallet.swift @@ -264,6 +264,246 @@ final class SparkWallet: Wallet { } } + // MARK: - On-chain send + + /// 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)? + + /// 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: OnchainSendSpeed, + 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) + 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.")) + } + + 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), + idempotencyKey: nil + ) + ) + preparedOnchainSend = nil + // 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 OnchainDepositSummary(deposits: []) } + do { + let response = try await sdk.listUnclaimedDeposits(request: ListUnclaimedDepositsRequest()) + return OnchainDepositSummary(deposits: response.deposits.map(Self.onchainDeposit)) + } catch { + emit("Failed to list deposits: \(error.localizedDescription)") + return 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? { @@ -342,6 +582,26 @@ 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 { + return .bolt11(amountSats: inv.amountMsat.map { Int64($0 / 1000) }) + } + } + 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 } diff --git a/WalletStore.swift b/WalletStore.swift index e23ba16b..bf0ac2ca 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 @@ -136,6 +141,7 @@ final class WalletStore { lightningAddress = nil nwcNodeAlias = nil nwcMethods = [] + onchainDeposits = nil relayBackupSearchState = .idle relayBackupPublishState = .idle } @@ -379,12 +385,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 @@ -409,6 +417,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) @@ -475,6 +497,74 @@ 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: OnchainSendSpeed, + 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, + feeSats: feeSats.map(UInt64.init) + ) + 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 51e3819b..56dc207b 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 { @@ -691,10 +766,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: OnchainSendSpeed = .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 @@ -705,6 +802,11 @@ struct SendInvoiceSheet: View { switch inputType { 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 } } @@ -713,6 +815,7 @@ struct SendInvoiceSheet: View { switch inputType { case .sparkLnurl, .lightningAddressNeedsResolve: return true case .bolt11(let amt): return amt == nil + case .bitcoinAddress(_, let amt): return amt == nil && !sendMax default: return false } } @@ -721,6 +824,8 @@ struct SendInvoiceSheet: View { switch inputType { 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" } } @@ -758,12 +863,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) @@ -821,7 +941,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) } @@ -921,6 +1045,11 @@ struct SendInvoiceSheet: View { .frame(maxWidth: .infinity, alignment: .leading) } + // On-chain: destination, speed, and the quoted fee + if case .bitcoinAddress(let address, _) = inputType { + onchainSendSection(address: address) + } + // Status if let status { HStack(spacing: 8) { @@ -957,6 +1086,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(OnchainSendSpeed.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 @@ -978,6 +1277,11 @@ struct SendInvoiceSheet: View { let result: Result switch inputType { + 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: result = await store.payInvoice(normalizeInvoice(input)) case .sparkLnurl, .lightningAddressNeedsResolve: @@ -1034,6 +1338,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? @@ -1044,6 +1351,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" @@ -1063,10 +1390,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) @@ -1079,6 +1420,50 @@ 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) + } + while !Task.isCancelled { + await store.refreshOnchainDeposits() + try? await Task.sleep(for: .seconds(15)) + } + } + .alert( + "Claim deposit?", + isPresented: Binding( + get: { depositPendingFeeConfirmation != nil }, + set: { if !$0 { depositPendingFeeConfirmation = nil } } + ) + ) { + Button("Claim", role: .destructive) { + 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 { @@ -1176,6 +1561,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..74354844 --- /dev/null +++ b/wisp/OnchainReceive.swift @@ -0,0 +1,112 @@ +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) } + + /// Confirmed but not yet claimable, so the screen can say "waiting for + /// confirmations" rather than showing a dead button. + var awaitingConfirmations: [OnchainDeposit] { deposits.filter { !$0.isMature } } +} + diff --git a/wisp/OnchainSend.swift b/wisp/OnchainSend.swift new file mode 100644 index 00000000..3e6fdfd4 --- /dev/null +++ b/wisp/OnchainSend.swift @@ -0,0 +1,68 @@ +import Foundation + +/// 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. +/// +/// Deliberately separate from `WithdrawOnchainSpeed`: draining the wallet and +/// sending a chosen amount quote against opposite fee policies, and the two +/// features are in flight on different branches. Worth collapsing into one +/// type once both have landed. +enum OnchainSendSpeed: String, CaseIterable, Sendable { + case slow + case medium + case fast + + var label: String { + switch self { + case .slow: return "Economy" + case .medium: return "Standard" + case .fast: return "Priority" + } + } + + var detail: String { + switch self { + case .slow: return "Cheapest. May take hours to confirm." + case .medium: return "Balanced fee and confirmation time." + case .fast: return "Highest fee. Confirms soonest." + } + } +} + +/// 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: OnchainSendSpeed + /// 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/wispTests/OnchainReceiveTests.swift b/wispTests/OnchainReceiveTests.swift new file mode 100644 index 00000000..29c04fc6 --- /dev/null +++ b/wispTests/OnchainReceiveTests.swift @@ -0,0 +1,126 @@ +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) + } + + /// The ceiling we pay without asking stays well under a percent — above + /// it the user is asked rather than charged. + // 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]) + #expect(summary.awaitingConfirmations.map(\.vout) == [1]) + } + + @Test func emptySummary() { + let summary = OnchainDepositSummary(deposits: []) + #expect(summary.isEmpty) + #expect(summary.pendingSats == 0) + #expect(summary.claimable.isEmpty) + #expect(summary.awaitingConfirmations.isEmpty) + } +} diff --git a/wispTests/OnchainSendTests.swift b/wispTests/OnchainSendTests.swift new file mode 100644 index 00000000..b60e39c7 --- /dev/null +++ b/wispTests/OnchainSendTests.swift @@ -0,0 +1,102 @@ +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: OnchainSendSpeed = .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: - Speed tiers + + @Test func everySpeedIsLabeled() { + for speed in OnchainSendSpeed.allCases { + #expect(!speed.label.isEmpty) + #expect(!speed.detail.isEmpty) + } + #expect(OnchainSendSpeed.allCases.count == 3) + } +} From 96eafea51a50687b200d7d22ac8a073116eeaa62 Mon Sep 17 00:00:00 2001 From: dmnyc Date: Sun, 6 Sep 2026 12:29:12 -0400 Subject: [PATCH 2/3] fix(wallet): stop a double tap sending on-chain twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found two blocking defects. Both confirmed against the pinned SDK. **Double-send.** The Pay button's only re-entrancy guard was `.disabled(inFlight)`, which applies after a re-render, and `pay()` didn't check `inFlight` itself — so a second tap in the same frame ran a second send. That would have been survivable if the model consumed the quote, but `executeSendOnchain` cleared `preparedOnchainSend` only after `sendPayment` returned, so both calls passed the equality guard and both broadcast. `idempotencyKey: nil` threw away the SDK primitive built for exactly this. Lightning survives the same shape because the SDK rejects an already-paid invoice. An address plus an amount has no natural idempotency: both sends succeed and the user pays twice. The quote is now consumed before the first suspension — `SparkWallet` is `@MainActor`, so guard-and-consume runs atomically — and the send carries an idempotency key, which the SSP executes exactly once. `pay()` guards on entry as well. **BIP-21 with a Lightning invoice never worked.** Detection returned `.bolt11(amountSats:)` and dropped the invoice string, so paying fed the raw BIP-21 URI to `payInvoice`, which can't decode it. The case now carries the invoice and the pay path uses it. Also from the review: - A quote older than ten minutes is refused. On-chain rates move, and a quote left open on screen would otherwise execute at a stale fee — overpaying, or stranding a transaction below the going rate. - A failed deposit list keeps the last known deposits instead of returning empty, which made pending money vanish from the banner on any transient hiccup. - The on-chain send section is gated on `supportsOnchainSend` rather than leaving the capability unused. - The receive sheet no longer runs its own 15s poll on top of the store's 30s one. - Claiming a deposit is not a destructive action, so the alert button no longer renders as one. - `UInt64.init` on a fee could trap on a negative; clamped. - Removed `awaitingConfirmations`, which only tests referenced, and a doc comment orphaned by an earlier deletion. --- LnurlResolver.swift | 9 +++-- SparkWallet.swift | 55 ++++++++++++++++++++++++----- WalletStore.swift | 5 ++- WalletView.swift | 26 ++++++++------ wisp/OnchainReceive.swift | 3 -- wispTests/OnchainReceiveTests.swift | 4 --- wispTests/OnchainSendTests.swift | 32 +++++++++++++++++ 7 files changed, 104 insertions(+), 30 deletions(-) diff --git a/LnurlResolver.swift b/LnurlResolver.swift index 69565ce3..e4c6cb5c 100644 --- a/LnurlResolver.swift +++ b/LnurlResolver.swift @@ -12,7 +12,10 @@ 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) @@ -25,7 +28,7 @@ enum WalletInputType { 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 @@ -41,7 +44,7 @@ enum WalletInputType { 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 8b3269d5..7cb815ad 100644 --- a/SparkWallet.swift +++ b/SparkWallet.swift @@ -266,9 +266,18 @@ 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)? + 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 @@ -345,7 +354,7 @@ final class SparkWallet: Wallet { speed: speed, leavesTokensBehind: strandsTokens ) - preparedOnchainSend = (quote, prepared) + preparedOnchainSend = (quote, prepared, UUID().uuidString, Date()) return .success(quote) } catch { let friendly = Self.friendlyPayError(error) @@ -363,6 +372,21 @@ final class SparkWallet: Wallet { 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 { @@ -377,10 +401,11 @@ final class SparkWallet: Wallet { request: SendPaymentRequest( prepareResponse: held.prepared, options: .bitcoinAddress(confirmationSpeed: sdkSpeed), - idempotencyKey: nil + // 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 ) ) - preparedOnchainSend = nil // 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 { @@ -421,16 +446,25 @@ final class SparkWallet: Wallet { /// 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 OnchainDepositSummary(deposits: []) } + guard let sdk else { return lastKnownDeposits } do { let response = try await sdk.listUnclaimedDeposits(request: ListUnclaimedDepositsRequest()) - return OnchainDepositSummary(deposits: response.deposits.map(Self.onchainDeposit)) + 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 OnchainDepositSummary(deposits: []) + 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 { @@ -590,7 +624,12 @@ final class SparkWallet: Wallet { // costs less — and fall back to the on-chain address. for method in d.paymentMethods { if case .bolt11Invoice(let inv) = method { - return .bolt11(amountSats: inv.amountMsat.map { Int64($0 / 1000) }) + // 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 { diff --git a/WalletStore.swift b/WalletStore.swift index bf0ac2ca..3cea4ef9 100644 --- a/WalletStore.swift +++ b/WalletStore.swift @@ -558,7 +558,10 @@ final class WalletStore { let result = await spark.claimOnchainDeposit( txid: deposit.txid, vout: deposit.vout, - feeSats: feeSats.map(UInt64.init) + // `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() diff --git a/WalletView.swift b/WalletView.swift index 56dc207b..3fd74256 100644 --- a/WalletView.swift +++ b/WalletView.swift @@ -800,7 +800,7 @@ 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, @@ -814,7 +814,7 @@ 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 } @@ -822,7 +822,7 @@ struct SendInvoiceSheet: View { 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" @@ -1046,7 +1046,7 @@ struct SendInvoiceSheet: View { } // On-chain: destination, speed, and the quoted fee - if case .bitcoinAddress(let address, _) = inputType { + if case .bitcoinAddress(let address, _) = inputType, store.supportsOnchainSend { onchainSendSection(address: address) } @@ -1271,6 +1271,9 @@ 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 @@ -1282,8 +1285,10 @@ struct SendInvoiceSheet: View { guard sendMax || sats != nil else { return } await sendOnchain(address: address, sats: sats ?? 0) return - case .bolt11: - result = await store.payInvoice(normalizeInvoice(input)) + 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) @@ -1438,10 +1443,9 @@ struct ReceiveInvoiceSheet: View { if onchainAddress == nil { await loadOnchainAddress(newAddress: false) } - while !Task.isCancelled { - await store.refreshOnchainDeposits() - try? await Task.sleep(for: .seconds(15)) - } + // 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?", @@ -1450,7 +1454,7 @@ struct ReceiveInvoiceSheet: View { set: { if !$0 { depositPendingFeeConfirmation = nil } } ) ) { - Button("Claim", role: .destructive) { + Button("Claim") { if let deposit = depositPendingFeeConfirmation, case .feeExceeded(let sats) = deposit.failure { Task { await claim(deposit, feeSats: sats) } diff --git a/wisp/OnchainReceive.swift b/wisp/OnchainReceive.swift index 74354844..5b5d011a 100644 --- a/wisp/OnchainReceive.swift +++ b/wisp/OnchainReceive.swift @@ -105,8 +105,5 @@ struct OnchainDepositSummary: Equatable, Sendable { /// Deposits that can be claimed right now. var claimable: [OnchainDeposit] { deposits.filter(\.isClaimable) } - /// Confirmed but not yet claimable, so the screen can say "waiting for - /// confirmations" rather than showing a dead button. - var awaitingConfirmations: [OnchainDeposit] { deposits.filter { !$0.isMature } } } diff --git a/wispTests/OnchainReceiveTests.swift b/wispTests/OnchainReceiveTests.swift index 29c04fc6..aa32255b 100644 --- a/wispTests/OnchainReceiveTests.swift +++ b/wispTests/OnchainReceiveTests.swift @@ -80,8 +80,6 @@ struct OnchainReceiveTests { #expect(!deposit().isClaimInFlight) } - /// The ceiling we pay without asking stays well under a percent — above - /// it the user is asked rather than charged. // MARK: - Failure messages @Test func feeExceededMessageShowsRequiredFee() { @@ -113,7 +111,6 @@ struct OnchainReceiveTests { #expect(!summary.isEmpty) #expect(summary.pendingSats == 150_000) #expect(summary.claimable.map(\.vout) == [0, 2]) - #expect(summary.awaitingConfirmations.map(\.vout) == [1]) } @Test func emptySummary() { @@ -121,6 +118,5 @@ struct OnchainReceiveTests { #expect(summary.isEmpty) #expect(summary.pendingSats == 0) #expect(summary.claimable.isEmpty) - #expect(summary.awaitingConfirmations.isEmpty) } } diff --git a/wispTests/OnchainSendTests.swift b/wispTests/OnchainSendTests.swift index b60e39c7..11151027 100644 --- a/wispTests/OnchainSendTests.swift +++ b/wispTests/OnchainSendTests.swift @@ -90,6 +90,38 @@ struct OnchainSendTests { #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() { From 76e0b1d9d4d2ec9e7cd56f70c16732ed9886ad9c Mon Sep 17 00:00:00 2001 From: dmnyc Date: Mon, 7 Sep 2026 22:11:50 -0400 Subject: [PATCH 3/3] refactor(wallet): one confirmation-speed type for both on-chain paths #452 has landed, so the duplication this PR flagged can go. The two enums were byte-identical apart from the name. Renamed to `OnchainSpeed` rather than keeping either name: it now serves the whole-balance withdraw and a send to a chosen address, and the fee tiers are the same question either way. --- SparkWallet.swift | 4 ++-- WalletStore.swift | 4 ++-- WalletView.swift | 4 ++-- wisp/OnchainSend.swift | 31 +--------------------------- wisp/WithdrawOnchain.swift | 9 +++++--- wisp/WithdrawOnchainSheet.swift | 4 ++-- wispTests/OnchainSendTests.swift | 6 +++--- wispTests/WithdrawOnchainTests.swift | 4 ++-- 8 files changed, 20 insertions(+), 46 deletions(-) diff --git a/SparkWallet.swift b/SparkWallet.swift index 2089f194..c3a4a18c 100644 --- a/SparkWallet.swift +++ b/SparkWallet.swift @@ -302,7 +302,7 @@ final class SparkWallet: Wallet { func prepareSendOnchain( address: String, amountSats: Int64, - speed: OnchainSendSpeed, + speed: OnchainSpeed, drainAll: Bool = false ) async -> Result { guard let sdk else { return .failure(.notConnected) } @@ -820,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 db47cc0a..1ccfb284 100644 --- a/WalletStore.swift +++ b/WalletStore.swift @@ -113,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) @@ -535,7 +535,7 @@ final class WalletStore { func prepareSendOnchain( address: String, amountSats: Int64, - speed: OnchainSendSpeed, + speed: OnchainSpeed, drainAll: Bool = false ) async -> Result { guard let spark = wallet as? SparkWallet else { diff --git a/WalletView.swift b/WalletView.swift index 3b8b4b14..3d7a3c4a 100644 --- a/WalletView.swift +++ b/WalletView.swift @@ -791,7 +791,7 @@ struct SendInvoiceSheet: View { @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: OnchainSendSpeed = .medium + @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 @@ -1153,7 +1153,7 @@ struct SendInvoiceSheet: View { .textCase(.uppercase) .tracking(0.5) HStack(spacing: 8) { - ForEach(OnchainSendSpeed.allCases, id: \.rawValue) { speed in + ForEach(OnchainSpeed.allCases, id: \.rawValue) { speed in Button { guard onchainSpeed != speed else { return } onchainSpeed = speed diff --git a/wisp/OnchainSend.swift b/wisp/OnchainSend.swift index 3e6fdfd4..681dd6dd 100644 --- a/wisp/OnchainSend.swift +++ b/wisp/OnchainSend.swift @@ -1,34 +1,5 @@ import Foundation -/// 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. -/// -/// Deliberately separate from `WithdrawOnchainSpeed`: draining the wallet and -/// sending a chosen amount quote against opposite fee policies, and the two -/// features are in flight on different branches. Worth collapsing into one -/// type once both have landed. -enum OnchainSendSpeed: String, CaseIterable, Sendable { - case slow - case medium - case fast - - var label: String { - switch self { - case .slow: return "Economy" - case .medium: return "Standard" - case .fast: return "Priority" - } - } - - var detail: String { - switch self { - case .slow: return "Cheapest. May take hours to confirm." - case .medium: return "Balanced fee and confirmation time." - case .fast: return "Highest fee. Confirms soonest." - } - } -} - /// 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 @@ -41,7 +12,7 @@ struct OnchainSendQuote: Equatable, Sendable { let amountSats: Int64 /// Service fee plus the L1 broadcast fee, both real cost to the user. let feeSats: Int64 - let speed: OnchainSendSpeed + 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 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/OnchainSendTests.swift b/wispTests/OnchainSendTests.swift index 11151027..01a19faf 100644 --- a/wispTests/OnchainSendTests.swift +++ b/wispTests/OnchainSendTests.swift @@ -12,7 +12,7 @@ struct OnchainSendTests { private func quote( amountSats: Int64 = 100_000, feeSats: Int64 = 500, - speed: OnchainSendSpeed = .medium, + speed: OnchainSpeed = .medium, leavesTokensBehind: Bool = false ) -> OnchainSendQuote { OnchainSendQuote( @@ -125,10 +125,10 @@ struct OnchainSendTests { // MARK: - Speed tiers @Test func everySpeedIsLabeled() { - for speed in OnchainSendSpeed.allCases { + for speed in OnchainSpeed.allCases { #expect(!speed.label.isEmpty) #expect(!speed.detail.isEmpty) } - #expect(OnchainSendSpeed.allCases.count == 3) + #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) }