feat(wallet): withdraw the whole balance on-chain - #653
Open
dmnyc wants to merge 1 commit into
Open
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, aCpfpSigner.signPsbtimplementation, and a resumable broadcaster.Hence the naming: this is Withdraw, and "Exit" stays free for that.
Why
FeePolicy.FEES_INCLUDEDThe 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_INCLUDEDmakes 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
prepareWithdrawOnchainreturns plain values and keeps the SDK's prepared request private toSparkRepository.executeWithdrawOnchainrefuses 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
optimizeLeavesretrySpark 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
sendPaymentreturns a FAILED payment without throwing — the same trappayInvoicedocuments — sopayment.statusis 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 encodebitcoin: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
onWithdrawOnchain), so the row can't render for an NWC wallet that has no on-chain send command behind it.SparkRepositoryand stays previewable.Honest about its limits
conversionOptionsriding on a payment.WithdrawOnchainRemainderexists to report what's left behind.Explorer link
Success links Sparkscan, not mempool.space:
payment.idis 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.