feat(wallet): send and receive bitcoin on-chain - #454
Conversation
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.
barrydeen
left a comment
There was a problem hiding this comment.
Review — Request changes
I reviewed this against the full PR branch plus the pinned SDK (breez-sdk-spark-swift 0.23.1), not just the diff. One blocking funds-loss defect, one blocking functional defect. Details and patches below.
Blocking 1 (funds loss): double-tap sends twice on-chain
WalletView.swift:1063-1080 — the Pay button's only re-entrancy guard is .disabled(!canProceed || inFlight), which takes effect after a re-render. inFlight is set inside Task { await pay() }, so a second tap landing in the same frame (double-tap, tremor, Switch Control, ~16 ms window) spawns a second pay() that runs to completion — pay() (WalletView.swift:1273) never checks inFlight itself.
That alone wouldn't matter if the model consumed the quote — but SparkWallet.swift:358-397 holds preparedOnchainSend until after await sdk.sendPayment returns. Two concurrent executeSendOnchain calls both pass held.quote == quote (neither has cleared it yet) and both broadcast. And idempotencyKey: nil (SparkWallet.swift:380) discards the exact SDK primitive built for this ("providing the same idempotency key for multiple requests will ensure that only one payment is made").
Why this is on-chain-specific: the pre-existing Lightning path has the same UI shape, but a double-paid invoice fails closed (the SDK rejects an already-paid invoice — see friendlyPayError's "already paid" branch). An on-chain address+amount has no natural idempotency: both sends succeed and the user pays 2×. Patch (the model fix is the airtight one — SparkWallet is @MainActor, so guard+consume runs atomically before the first suspension; the view guard is belt-and-braces):
// SparkWallet.swift:271
private var preparedOnchainSend: (quote: OnchainSendQuote, prepared: PrepareSendPaymentResponse, idempotencyKey: String)?// prepareSendOnchain, replacing the store on success:
let idempotencyKey = UUID().uuidString
preparedOnchainSend = (quote, prepared, idempotencyKey)
return .success(quote)// executeSendOnchain, replacing the guard + send block:
guard let held = preparedOnchainSend, held.quote == quote else {
return .failure(.other("This quote expired. Check the amount and try again."))
}
// Consume BEFORE the first suspension: a second concurrent call (second tap
// in the same frame) must find nothing to send. The idempotency key is the
// second layer — the SSP executes a key exactly once.
preparedOnchainSend = nil
// ... then sendPayment with:
request: SendPaymentRequest(
prepareResponse: held.prepared,
options: .bitcoinAddress(confirmationSpeed: sdkSpeed),
idempotencyKey: held.idempotencyKey
)
// (delete the old `preparedOnchainSend = nil` after the response)// WalletView.swift pay():
private func pay() async {
// The button's `.disabled(inFlight)` only applies after a re-render.
guard !inFlight else { return }
inFlight = true; defer { inFlight = false }No behavior regression: on throw, the held quote is now consumed instead of orphaned — but the view already clears onchainQuote on any failure, forcing a re-quote either way.
Blocking 2 (documented behavior doesn't work): BIP-21 + Lightning invoice is dropped
The PR body promises "A BIP-21 that also offers a Lightning invoice takes the invoice." It can't: SparkWallet.swift:591-594 returns .bolt11(amountSats:) carrying only the amount — the invoice string (inv.invoice.bolt11, which exists on Bolt11InvoiceDetails in 0.23.1) is discarded. pay() (WalletView.swift:1285-1286) then feeds the raw BIP-21 URI into payInvoice, which fails to decode. Every BIP-21-with-lightning paste errors. Fail-closed (no loss), but 100% broken. Patch — thread the invoice through (all other constructors pass nil, behavior unchanged):
// LnurlResolver.swift
case bolt11(amountSats: Int64?, invoice: String?)
// update the 5 `case .bolt11(let amt)` patterns (WalletView x3, LnurlResolver x2) to `case .bolt11(let amt, _)`// SparkWallet.swift bip21 branch:
return .bolt11(amountSats: inv.amountMsat.map { Int64($0 / 1000) }, invoice: inv.invoice.bolt11)
// direct-bolt11 branch + WalletStore.swift NWC fallback: add `, invoice: nil`// WalletView.swift pay():
case .bolt11:
if case .bolt11(_, let inv?) = inputType {
result = await store.payInvoice(inv)
} else {
result = await store.payInvoice(normalizeInvoice(input))
}Non-blocking (verified correct, plus minor hardening)
- Claim-error mapping correct.
DepositClaimError.maxDepositClaimFeeExceeded(tx, vout, maxFee, requiredFeeSats, requiredFeeRateSatPerVbyte)— the 4th-position binding (SparkWallet.swift:440) is the right field, and passing it asMaxFee.fixedfails closed if fees rise further. Decline-reason order (maxBps, quotedBps, quotedSats,SparkWallet.swift:460) also matches 0.23.1. (Note: SDKmainhas since reshapedInstantClaimStatus— this code is correct against the pinned 0.23.1, don't "fix" it towardmain.) - Fee policy math correct per SDK docs:
feesExcluded= recipient gets amount,feesIncluded= drain.deliveredSats,totalSats, dust guard all consistent. - Address rotation safe: SDK documents "Previous ones remain valid."
- Manual-claim cap parity true: auto-claimer and nil-retry both use
.networkRecommended(leewaySatPerVbyte: 5). - NWC can't reach on-chain UI: classification requires the Spark SDK's
parse, and the NWC fallback never produces.bitcoinAddress— so unusedsupportsOnchainSend/isOnchainis dead code, not a hole (execution also guards fail-closed). Suggest gating the section on it or deleting the helper. - Minor hardening worth considering:
listOnchainDepositscollapses load errors to empty (SparkWallet.swift:430), hiding pending deposits on transient failure — keep the previous value instead. No quote TTL — an hours-old quote executes at a stale fee (overpay or stuck tx); consider a 5–10 min expiry.awaitingConfirmationsis test-only dead code. Alert "Claim" uses.destructivefor a non-destructive action. The view's 15s poll duplicates the store's 30s poll.feeSats.map(UInt64.init)traps on negative (unreachable from SDK data, but a guard is free). Stray comment inOnchainReceiveTests.swift:89-92.
OnchainSendSpeed/WithdrawOnchainSpeed duplication is acknowledged in the PR body — fine, consolidate after #452 lands.
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.
|
Both blocking defects confirmed and fixed in 96eafea. Thanks — the double-send in particular is a real funds-loss path and I'd missed that the guard only bites after a re-render. Blocking 1 — double-sendVerified all three legs: Took the patch as written: quote consumed before the first suspension ( Your framing of why this is on-chain-specific is the part I should have reasoned about myself: Lightning fails closed because the SDK rejects an already-paid invoice, while an address plus an amount has no natural idempotency, so both sends land. Blocking 2 — BIP-21 with a Lightning invoiceConfirmed: Non-blocking — taken
Noted, not changedKept the claim-error mapping and decline-reason order as-is against the pinned 0.23.1, per your note that SDK
Builds clean, tests pass, running on the simulator. |
#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.
barrydeen
left a comment
There was a problem hiding this comment.
Re-reviewed the post-review changes (96eafea, 76e0b1d). Both blocking defects are correctly fixed:
- Double-send: quote consumed before the first suspension (
@MainActor-atomic), idempotency key generated at quote time and passed tosendPayment,pay()guards on entry, and the view clears the quote on failure so a spent quote can't be retried. - BIP-21 invoice: the invoice is carried through and preferred in
pay(); all.bolt11patterns updated branch-wide, regression tests added.
All non-blocking items taken as noted (TTL, deposit snapshot retention, supportsOnchainSend gate, poll dedupe, .destructive removal, UInt64 clamp, dead-code removal). OnchainSpeed rename is complete with zero leftover references.
Merging. Follow-up needed: executeWithdrawOnchain (from #452) still has the same triple-defect shape this PR just fixed — preparedWithdrawal never consumed, no entry guard on send(), idempotencyKey: nil. Worth its own PR before anyone taps that dialog twice.
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 somewhere else, and a deposit that arrived was invisible until it settled into the balance on its own.
Receive
A Lightning / Bitcoin selector on the receive sheet. The Bitcoin tab shows the deposit address as a QR and as text, with copy, share, and rotation. Older addresses stay valid for future deposits, so rotating costs nothing.
Pending deposits
Deposits are tracked from
WalletStore, not from 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. Tracking it from a view would have made "my money arrived" a fact you only learn by sitting on the right tab — which is exactly the anxious moment not to hide it. The store watches while a Spark wallet is connected, so any surface can show it.
The wallet dashboard gets a banner with the amount and current status, opening the receive sheet on the Bitcoin tab.
Each pending deposit offers its transaction id and a link to a block explorer. That's deliberate:
DepositInforeports only a matured / not-matured flag, with no confirmation count, so the wallet genuinely cannot say "2 of 3" — the explorer is the authoritative answer, and tapping through is the user choosing to ask it rather than the app phoning an explorer unprompted.Claim failures
Claiming stays automatic and is unchanged. What's new is visibility when it fails.
The SDK's
claimErrorwas never read, so a deposit whose claim fee exceeded the automatic cap sat unclaimed with nothing said. Those now surface with the reason, and a retry that names the required fee and asks first — paying more than the automatic cap should be a deliberate choice, not a silent one.Send
The existing field now accepts a Bitcoin address or BIP-21 URI alongside what it already took. A BIP-21 that also offers a Lightning invoice takes the invoice — cheaper and instant.
On-chain sends quote before they send. An on-chain fee is added on top of the amount rather than taken out of it, and doesn't scale with the amount, so a small send can lose a large share of itself to fees. 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 — a send of the whole balance can't pay a fee on top.
Available balance
The send screen now shows the spendable balance.
On-chain this isn't a convenience. What 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.
Stranded tokens
Draining warns when it would leave a token balance behind.
balanceSatsis bitcoin only, so emptying a wallet imported from an app that holds stablecoins strands those — in a wallet Wisp deliberately won't convert with.This is disclosure, not a fix. The wallet would otherwise look empty while it wasn't. Moving those balances still needs the conversion work tracked separately.
Safety
Every quote is held and re-checked before sending. A screen that has drifted from what was agreed re-quotes rather than sending a different amount or destination, and each field participates in quote equality so nothing can be swapped between confirming and sending.
On instant (0-conf) claims
The SDK exposes them, and I built and then removed the feature. It can't work through this SDK version, and the reason is worth recording:
listUnclaimedDepositsdoesn't report a deposit until it already has a confirmation. Verified directly: the SDK'sunclaimed_depositstable stayed empty and the txid appeared nowhere in its storage for the entire time a real test deposit sat in the mempool.No instant (0-conf) claim plan available.A "Claim now" that we can't predict and that usually fails teaches people the wallet is broken, so it's out. The
InstantClaimstatus is still parsed — if the SDK ever claims a deposit that way itself, the UI needs to know not to touch it mid-settle.Testing
Unit tests cover the SDK-free models: deposit identity and claimability, failure classification and retry-worthiness, summary aggregation, fee totals under both policies, the disproportionate-fee threshold, the zero-amount divide guard, and that every quote field participates in equality.
Exercised end to end on the simulator against a real wallet: an on-chain deposit of 38,475 sats and a second of 19,890 sats were received at the generated address, appeared as pending deposits, and were claimed automatically once matured.
Not exercised against a live SSP: the claim-failure retry path, which needs a deposit whose fee genuinely exceeds the cap. It's modeled and unit-tested but has not been seen to fire.
Notes for review
Done — #452 merged, so the two are now oneOnchainSendSpeedduplicatesWithdrawOnchainSpeedfrom #452.OnchainSpeed. Renamed rather than keeping either name: it serves both the whole-balance withdraw and a send to a chosen address, and the fee tiers are the same question either way."Send all funds" here overlaps #452's whole-balance withdraw in wallet settings — the same underlying operation reached two ways. I'd keep both. They serve different intents: emptying a wallet to close it out or move off the device is a settings action someone goes looking for deliberately, while sending everything to an address is just the far end of a normal send. Removing either one sends some users hunting through the wrong screen.
What's worth consolidating is the code behind them, not the entry points. The speed type is done; one shared drain path is still worth doing later.
Review changes (96eafea, 76e0b1d)
Both blocking defects from the review are fixed, and main is merged in.
Double-send. The quote is now consumed before the first suspension —
SparkWalletis@MainActor, so guard-and-consume is atomic — and the send carries an idempotency key, which the SSP executes exactly once.pay()guards on entry too, since.disabled(inFlight)only applies after a re-render. Lightning survived the same shape only because the SDK rejects an already-paid invoice; an address plus an amount has no such protection, so both sends landed and the user paid twice.BIP-21 with a Lightning invoice.
Bolt11InvoiceDetails.invoice.bolt11exists in 0.23.1 and was being dropped, sopayInvoicereceived the raw URI and every such paste failed. The case now carries the invoice and the pay path uses it, with regression tests.Also taken from the review: a 10-minute quote TTL so a stale fee can't execute;
listOnchainDepositskeeping its last snapshot on a transient failure instead of blanking pending money out of the banner; the send section gated onsupportsOnchainSend; the receive sheet's duplicate 15s poll dropped in favor of the store's 30s one; the claim alert no longer.destructive; theUInt64conversion clamped; andawaitingConfirmationsplus an orphaned doc comment removed.Left as-is per the review note: the claim-error mapping and decline-reason order stay correct against the pinned 0.23.1, not SDK
main.