BLOG |
How-tos
Recovering 100k sats with seed and state backups.
A real unilateral exit from the Spark network: the numbers, the failures, and the lessons. Everything below happened on Bitcoin mainnet and is publicly verifiable on chain.
I force-exited a non-custodial Blink wallet (using Spark) on Bitcoin mainnet. Everything the exit needed was derived from the wallet seed: the fee funding, the transaction signing, and the final sweep. The one extra ingredient is a recovery bundle, a snapshot of the wallet's exit data saved while the Spark network was still reachable. With that bundle in hand, the exit itself required no cooperation from anyone.
The honest numbers for a 100,000-sat wallet:
Non-custodial means you can always leave. But the fire escape is narrow. Here is the full story, with costs, failures, and lessons.
"Non-custodial" is an easy word to put in marketing copy. We wanted it to be something you can verify, not something you are asked to believe. So we took a real Blink wallet, pretended the Spark network had vanished, and forced every recoverable sat back onto the Bitcoin blockchain using nothing but the seed, a saved recovery bundle, and open-source tooling anyone can run. This post is the receipt.
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.
A mobile Spark wallet holding 100,000 sats spread across 22 leaves. Spark wallets accumulate leaves through normal use (payments split and re-split the tree), and each leaf carries its own exit chain: the on-chain transactions that must confirm, level by level, to force the leaf onto Bitcoin without operator cooperation. For this wallet that 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 Bitcoin UTXO to fund fee bumps. The tooling derives a dedicated funding address from the wallet seed itself (path m/8797556'/<account>/0: only the purpose index is hardened, the account and address indexes are not, so a watch-only wallet can derive and monitor the funding address from an xpub without any private key):
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 it 3ab20a4c…7262 before doing the arithmetic per leaf. More on that 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, also called TRUC (Topologically Restricted Until Confirmation, BIP 431). 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 broadcasts them as ordinary transactions. At the time of writing, this wallet's four economical exits are through their chains and waiting on refund maturity.
Worth being precise, because this is where the marketing words usually get ahead of the facts:
Not trustless, and we do not claim it is. On the spectrum: weaker than a Lightning node that holds its own channel state by construction, much stronger than a custodian who holds both your keys and your data and whose withholding looks like normal service until the withdrawal freeze.
TRUC-violation and missing-inputs as normal sequencing signals, and never treat a package batch as fire-and-forget.Everything above ran by hand with open-source tooling. That is the right place to prove it, but not where most users live. The direction we are building toward is an exit path a user never has to prepare for.
Concretely: the Blink app will refresh and persist the recovery bundle automatically after every state change - every transaction - saving it to local device storage and, where the user configures it, to their own cloud storage, encrypted. The bundle carries no spending authority; it is exit data, not keys, so backing it up automatically widens the exit window without widening the attack surface. The seed stays the single secret to protect, exactly as today.
The point is that "you can always leave" should need no preparation. Whatever the last transaction was, the data required to force those funds back onto Bitcoin is already saved. If Spark is unreachable, the exit will run from inside the Blink app or from the open-source tool against the same bundle - no scramble to fetch state from operators who may already be gone.
That closes the gap this case study exposed: the tooling can exit, but only against a complete, recent bundle. Automating the refresh so it is always fresh and always backed up is what turns a fire escape you have to build first into one that is simply there.
All the tooling used here is open source, and the technical case study with every command and transaction ID lives next to the code:
Try it against your own wallet's bundle before you need it. That is the point.

Start receiving and sending bitcoin now