BLOG |
How-tos
Recovering 100k sats with seed and state backups.
I just force-exited a non-custodial Blink wallet (using Spark) on Bitcoin mainnet. Seed only — no operator cooperation: recovery bundle, fee funding, CPFP signing and sweep all derived from the wallet seed.
The honest numbers for a 100k sat wallet:
Non-custodial means you can always leave. But the fire escape is narrow. The full case study with costs, failures and lessons follows below.
This is a write-up of an actual unilateral exit performed with this repo on 2026-07-08, recovering a real mainnet Spark wallet without relying on Spark operators for the exit itself. Everything below — transaction IDs, amounts, failures — happened on Bitcoin mainnet and is publicly verifiable on chain. It doubles as an honest account of what unilateral exit costs today, because the numbers surprised us.

A mobile Spark wallet holding 100,000 sats spread across 22 leaves.
Quick context for readers new to Spark: a Spark wallet's funds live in a shared on-chain tree managed by a set of operators, and each leaf of that tree is a discrete chunk of the wallet's balance with its own pre-signed path back to Bitcoin. Unilateral exit means broadcasting that path — the leaf's exit chain of on-chain transactions, confirming level by level down the tree — to force the leaf onto Bitcoin without operator cooperation.
Spark wallets accumulate leaves through normal use (payments split and re-split the tree). For this wallet, 22 leaves meant 253 transaction packages across the 22 chains — for 100k sats.
Seed-only recovery is not possible once Spark operators are offline: current leaves cannot be discovered from the seed alone. The exit needs a recovery bundle — a JSON snapshot of the wallet's leaves and their ancestor transactions — refreshed while operators are still online:
make refresh-recovery-bundle SEED_FILE=../.spark-seed.txt BUNDLE=../recovery-bundle.json
First real-world lesson: our first bundle was silently incomplete. The operators' bulk query_nodes(include_parents=true) API omits the tree-root node for legacy mainnet trees, so every one of the 22 exit chains had a gap at the top, and offline package construction failed with Exit chain is incomplete. Re-fetching missing ancestors by node ID (which bypasses the root skip) fixed it — the exporter now does this automatically and refuses to write a bundle with open chains. Validate your bundle's ancestor chains before you need them; an incomplete bundle discovered during an outage is unrecoverable.
Exit transactions are pre-signed with zero fee and pay fees through CPFP ephemeral anchors, so the exit needs an independent L1 UTXO to fund fee bumps. The CLI derives a dedicated funding address from the wallet seed itself (path m/8797556'/<account>/0, only the purpose hardened so a watch-only wallet can monitor it):
make cpfp-address SEED_FILE=../.spark-seed.txt BUNDLE=../recovery-bundle.json FEE_RATE=1
At 1 sat/vB this asked for 78,573 sats to exit all 22 leaves — nearly 79% of the wallet balance in fees. We funded the full amount (3ab20a4c…7262) before doing the arithmetic per leaf — an 8× overfund, as it turned out. Only ~9,388 sats of it were ever needed; the rest sits untouched as spendable change at the funding address. More on the per-leaf arithmetic below.
The first attempt packaged, signed, and submitted all 253 packages back-to-back through Esplora's POST /txs/package. The first package confirmed (parent 16895bc8…9619, CPFP child 384cdc6d…3b37). The other 252 were rejected:
"error": "TRUC-violation, tx 16895bc8… would exceed descendant count limit"
"error": "bad-txns-inputs-missingorspent"
Spark exit transactions are v3 (TRUC). Mempool policy caps a v3 cluster at one unconfirmed parent plus one child — that is what makes the pre-signed zero-fee exit transactions safely fee-bumpable, and it also means each level of an exit chain must confirm before the next level can enter the mempool. A 15-package chain takes at least 15 blocks, no matter how it is submitted. Any tooling that fires packages back-to-back will strand everything after the first package per chain.
Before automating the loop, we priced each leaf: CPFP fees for its chain plus the final ~111 vB sweep, against the leaf's value. The result for this real wallet at 1 sat/vB:
Four leaves (32,768 + 32,768 + 16,384 + 8,192 sats) held 90% of the balance. The other 18 — dust from routine wallet activity, some as small as 1 sat — would each have cost 2,100–4,600 sats to exit: exiting everything would have spent ~77.5k sats in fees to recover 100k. The tooling now computes this per leaf and skips uneconomical leaves by default (INCLUDE_UNECONOMICAL=1 overrides; the economics math is regression-tested against this very bundle, adapted to regtest).
Following every sat of the 100,000-sat wallet through the economical exit at 1 sat/vB. Two pots of money are in play: the wallet balance itself, and the 9,388 sats of fee funding the economical exit actually consumed from the funding address.
The wallet's 100,000 sats:
Spark exit and refund transactions are pre-signed with zero fee (fees ride the CPFP anchors), so each refund output carries the full leaf value; the only deduction from the wallet balance itself is the final sweep fee.
The 9,388 sats of fee funding drawn on:
Bottom line: 89,668 of 100,000 sats (~90%) reach the destination. The all-in cost of the exit is ~18,700 sats — 9,888 abandoned as dust, 8,388 in CPFP fees, 444 in sweep fees — plus the on-chain fee for the transaction that funded the CPFP address in the first place. At higher fee rates every term scales up and more leaves fall below the economic threshold; at 10 sat/vB this wallet would abandon two more leaves and pay ten times the fees on the rest.
The rewritten flow is a single command:
make recover SEED_FILE=../.spark-seed.txt BUNDLE=../recovery-bundle.json NETWORK=mainnet FEE_RATE=1
Each round it rebuilds packages from live chain state, signs the CPFP bump with the seed, submits one package per leaf chain, waits for confirmation, and repeats. Already-confirmed transactions are skipped on reconstruction, so the loop is stateless: rate limits, crashes, and reboots cost nothing — re-run and it resumes. Esplora hiccups are retried with exponential backoff instead of aborting a wait that spans hours.
Two structural details matter:
We ran this recovery sequentially (the default). With FAN_OUT=1 the loop would first have broadcast one extra transaction with 4 outputs — the 9,388 sats of funding split into one UTXO per economical leaf, each sized to that leaf's remaining CPFP fees plus a 1,000-sat buffer, the last output absorbing the remainder. A 1-input/4-output P2WPKH transaction is ~203 vB, so ~203 sats at 1 sat/vB.
From then on 4 tracks run in parallel — one per leaf. Each leaf chain is its own TRUC cluster, so all four packages of a round are independent and can confirm in the same block. Each of this wallet's four leaves had a ~6-package chain before its refund:
Roughly a 3.5× wall-clock gain for ~203 sats, and the same shape repeats after refund maturity: the four refund packages broadcast in one block with per-leaf UTXOs instead of four consecutive blocks. The gain scales with leaf count (sum of chain depths versus deepest single chain), so a wallet with dozens of economical leaves should always fan out; for a handful of leaves, sequential is simpler and was fast enough here.
After the loop finishes, each economical leaf's refund waits out its CSV timelock. Re-running the same make recover after maturity broadcasts the refunds; once they confirm, make sweep builds and signs one-input Taproot key-path spends from the refund outputs to any destination address, and broadcast-sweep pushes them as ordinary transactions. At the time of writing, this wallet's four economical exits are through their chains and waiting on refund maturity.
Full case study, repo and tooling: github.com/blinkbitcoin/spark-unilateral-exit

Start receiving and sending bitcoin now