Skip to content

feat(wallet): withdraw the whole balance on-chain - #653

Open
dmnyc wants to merge 1 commit into
barrydeen:mainfrom
dmnyc:feat/withdraw-onchain
Open

feat(wallet): withdraw the whole balance on-chain#653
dmnyc wants to merge 1 commit into
barrydeen:mainfrom
dmnyc:feat/withdraw-onchain

Conversation

@dmnyc

@dmnyc dmnyc commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Ports barrydeen/wisp-ios#452.

Adds Withdraw on-chain — wallet settings, danger section, Spark only — which drains the entire spendable balance to a Bitcoin address.

Built for the user who believes their wallet is broken. It's the escape hatch that moves their money somewhere they already trust, so it leads with what will happen, quotes a real fee from the SDK before anything is signed, and never claims more than it can deliver.

Cooperative withdrawal, not a unilateral exit

Everything here is ordinary send machinery aimed at a Bitcoin address — getInfo, prepareSendPayment, sendPayment, optimizeLeaves. No direct Spark RPC, no custom signing, no broadcasting by us.

Which means it needs the Spark operator to cooperate. It covers the common cases (wallet feels stuck, user wants out, moving to another app) but not operator failure — and "my wallet is broken" sometimes is operator failure. The SDK exposes the trustless path (prepareUnilateralExit / unilateralExit), but that needs external UTXOs to fund CPFP fees, a CpfpSigner.signPsbt implementation, and a resumable broadcaster.

Hence the naming: this is Withdraw, and "Exit" stays free for that.

Why FeePolicy.FEES_INCLUDED

The SDK default adds fees on top of the amount, so a send of the full balance can never succeed — nothing is left to pay them with. FEES_INCLUDED makes the wallet spend exactly the amount with the fee taken out of it, which the SDK docs name as the way to drain a balance.

The quote shows three honest numbers: balance, fee deducted, what actually lands. Balance is read with ensureSynced = true — quoting against the cached figure prices a fee for an amount that may no longer exist.

Quote / execute split is the safety property

prepareWithdrawOnchain returns plain values and keeps the SDK's prepared request private to SparkRepository. executeWithdrawOnchain refuses unless the quote it's handed still equals the held one — so what gets broadcast is exactly what the user approved, and a drifted screen re-quotes rather than sending different terms.

The optimizeLeaves retry

Spark spends from individual leaves, so a nominally sufficient balance can still fail selection with Tree service error: insufficient funds — most often right after a conversion credits many small leaves. On that error only: consolidate, re-quote, retry once.

Without it a full drain fails on arithmetic that looks correct. Learned from zapcooking's spark-drain, which hit this against a live wallet.

Failure handling

sendPayment returns a FAILED payment without throwing — the same trap payInvoice documents — so payment.status is inspected rather than trusted, and a failed withdrawal says the funds were not sent.

Scanning

Addresses can be scanned as well as pasted, reusing the existing QrScanner. Both paths normalize BIP-21: bitcoin QRs almost always encode bitcoin:bc1q…?amount=…, which the SDK parser rejects outright.

A requested amount= is deliberately discarded — this screen always sends everything, so honoring it would contradict the button. The scheme match is anchored and case-insensitive, so an address containing "bitcoin:" isn't mangled.

Two shape notes

  • The settings row is opt-in via a nullable callback (onWithdrawOnchain), so the row can't render for an NWC wallet that has no on-chain send command behind it.
  • The sheet takes suspend lambdas rather than a repository, so the composable has no dependency on SparkRepository and stays previewable.

Honest about its limits

  • A fee larger than the balance disables the button and explains why.
  • A fee above 10% of the balance is flagged before confirming.
  • USDB is not included yet — tokens need a conversion leg and the SDK has no standalone convert, only conversionOptions riding on a payment. WithdrawOnchainRemainder exists to report what's left behind.
  • Tokens under ~$0.50 can't be converted at any price.

Explorer link

Success links Sparkscan, not mempool.space: payment.id is Spark's internal identifier, not a Bitcoin txid, so a mempool lookup would 404.

Testing

18 unit tests on the pure parts: quote arithmetic (net, clamping at zero, uneconomical, fee share, and that quote equality is what gates execution) and BIP-21 normalization.

Compiled and unit tested only — the Compose sheet has not been exercised on a device. The iOS equivalent was verified on the simulator against a live Spark wallet; the send path here is the same shape. Flagging rather than implying coverage it doesn't have.

Adds Withdraw on-chain — wallet settings, danger section, Spark only —
which drains the entire spendable balance to a Bitcoin address.

Built for the user who believes their wallet is broken. It is the escape
hatch that moves their money somewhere they already trust, so it leads
with what will happen, quotes a real fee from the SDK before anything is
signed, and never claims more than it can deliver.

Cooperative, not a unilateral exit. Everything here is ordinary send
machinery aimed at a Bitcoin address — getInfo, prepareSendPayment,
sendPayment, optimizeLeaves — so it needs the Spark operator to
cooperate. It covers the common cases (wallet feels stuck, moving to
another app) but not operator failure. The SDK does expose
prepareUnilateralExit / unilateralExit for that, and it is a much larger
job: fees come from external UTXOs the app does not have, the caller
must implement CpfpSigner.signPsbt, and the caller must broadcast the
transaction set resumably. Hence the naming — this is Withdraw, and
"Exit" stays free for the trustless one.

Uses FeePolicy.FEES_INCLUDED. The SDK default adds fees on top of the
amount, so a send of the full balance can never succeed — there is
nothing left to pay them with. FEES_INCLUDED makes the wallet spend
exactly the amount with the fee taken out of it, which the SDK docs name
as the way to drain a balance. The quote therefore shows three honest
numbers: balance, fee deducted, and what actually lands.

The balance is read with ensureSynced = true — quoting against the
cached figure prices a fee for an amount that may no longer exist.

Quote and execute are split, and that split is the safety property.
prepareWithdrawOnchain returns plain values and keeps the SDK prepared
request private to SparkRepository; executeWithdrawOnchain refuses
unless the quote it is handed still equals the held one. What gets
broadcast is exactly what the user approved, and a drifted screen
re-quotes instead of sending different terms.

Retries once through optimizeLeaves on insufficient funds. Spark spends
from individual leaves, so a nominally sufficient balance can still fail
selection — most often right after a conversion credits many small
leaves. Without this a *full* drain fails on arithmetic that looks
correct. Learned from zapcooking spark-drain, which hit it against a
live wallet.

sendPayment returns a FAILED payment WITHOUT throwing — the same trap
payInvoice documents — so payment.status is inspected rather than
trusted, and a failed withdrawal says the funds were not sent.

Addresses can be scanned as well as pasted, reusing the existing
QrScanner. Both paths normalize BIP-21: bitcoin QRs almost always encode
"bitcoin:bc1q...?amount=...", which the SDK parser rejects. A requested
amount= is deliberately discarded — this screen always sends everything,
so honoring it would contradict the button. The scheme match is anchored
and case-insensitive, so an address containing "bitcoin:" is not
mangled.

Two shape notes. The settings row is opt-in through a nullable callback
(onWithdrawOnchain), so the row cannot render for an NWC wallet that has
no on-chain send command behind it. And the sheet takes suspend lambdas
rather than a repository, so the composable has no dependency on
SparkRepository and stays previewable.

Honest about its limits: a fee larger than the balance disables the
button and explains why, a fee above 10% of the balance is flagged
before confirming, and WithdrawOnchainRemainder exists to report what
gets left behind. USDB is not included yet — tokens need a conversion
leg and the SDK has no standalone convert, only conversionOptions riding
on a payment. Tokens under ~$0.50 cannot be converted at any price.

Success links Sparkscan, not mempool.space: payment.id is Spark
internal, not a Bitcoin txid, so a mempool lookup would 404.

Ported from wisp-ios#452. 18 tests on the pure parts: quote arithmetic
(net, clamping, uneconomical, fee share, and that quote equality gates
execution) and BIP-21 normalization. Compiled and unit tested; the
Compose sheet has not been exercised on a device.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant