BLOG |

How-tos

Case study: a real Spark unilateral exit on Bitcoin mainnet

Recovering 100k sats with seed and state backups.

Case study: a real Spark unilateral exit on Bitcoin mainnet
July 13, 2026
openoms

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:

  • 22 leaves, 253 transaction packages to exit fully
  • Only 4 leaves (90% of the value) were worth exiting. Fees would have eaten the other 18.
  • ~9.4k sats in fees to recover 90.1k, one package per block per chain (a v3/TRUC mempool rule, explained below), then a timelock wait of about 10 days

Non-custodial means you can always leave. But the fire escape is narrow. Here is the full story, with costs, failures, and lessons.

Why we did this

"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.

The wallet

A mobile Spark wallet holding 100,000 sats spread across 22 leaves.

(illustrative)

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.

Step 1: keep a recovery bundle fresh

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.

Step 2: fee funding from the same seed

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.

Step 3: the naive broadcast, and why it failed

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.

Step 4: exit only what is worth exiting

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:

leavesvalueexit cost
Economical490,112 sats8,388 sats
Uneconomical (dust)189,888 sats~69,000 sats

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).

So what lands at the destination?

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:

sats
4 economical leaves, exit chains + refunds confirmed90,112
− sweep fees (4 × ~111 vB × 1 sat/vB)−444
arrives at the destination address89,668
dust in 18 uneconomical leaves, left behind in Spark9,888

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:

sats
CPFP fee bumps across the 4 exit chains8,388
safety buffer, returns as change at the funding address~1,000

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.

Step 5: the automated confirm-and-continue loop

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:

  1. Refund transactions are deferred, not broadcast. The last transaction of each chain (the refund that actually hands the leaf to the user's key) carries a CSV timelock: about 2,000 blocks (roughly two weeks) for fresh leaves; this wallet's renewed leaves carried 1,400 blocks (about 10 days). The loop decodes each refund's lock, reports its maturity height, and stops when only timelocked refunds remain. Because refunds are never broadcast early, the fee-funding change is never captured by a timelocked transaction, and leaves drain sequentially over a single funding UTXO without deadlock.
  2. Parallelism is optional. A fan-out mode first splits the funding into one UTXO per leaf, after which every leaf chain advances each block instead of taking turns. For this wallet that would have cut the broadcast phase from about 4 hours to about 70 minutes, for roughly 200 sats extra. The gain scales with leaf count; for a handful of leaves, sequential was fast enough.

What fan-out would have changed here

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:

blocks until timelock waitat ~10 min/block
Sequential (default)4 leaves × ~6 packages ≈ 24 blocks~4 hours
FAN_OUT=11 (fan-out) + ~6 (deepest chain) ≈ 7 blocks~70 minutes

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.

Step 6: timelocks, then sweep

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.

The trust model, stated plainly

Worth being precise, because this is where the marketing words usually get ahead of the facts:

  • Operators can never spend your funds. Every leaf is locked to an aggregate of your key and theirs; your signature is required for every movement. Theft would require every single operator to collude. One honest operator makes it impossible, even an offline one: theft needs their active signature, not their availability. Operators going offline is a different failure entirely. Nothing is taken, the cooperative path freezes, and the exit below is the answer to it.
  • The exit needs data, once per state change. The recovery bundle must be fetched while operators are online, and it needs no more permission than transacting does: any time you can spend, you can refresh it. Everything covered by your last refresh stays exitable forever, and withholding is immediately detectable (your refresh fails while your payments succeed).
  • The exit itself is permissionless. With a saved bundle, packaging, signing, broadcasting, and sweeping need nothing from the operators. That is the half this recovery proved on mainnet.

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.

Takeaways

  • A unilateral exit is a fire escape, not a door. 253 packages, one block per package per chain, a timelock measured in weeks, and fees that would have consumed 79% of the balance if applied indiscriminately, on a 100k-sat wallet. With economic triage, ~90% of the balance reached the destination. Self-custody on Spark is real, but the exit path is expensive and slow by construction; size expectations accordingly.
  • Dust leaves are a liability.18 of 22 leaves were not worth exiting at even 1 sat/vB. Since this recovery, the tooling consolidates leaves into exit-optimal denominations (via cooperative swaps, while operators are reachable) before exiting, so future recoveries abandon far less.
  • Bundle freshness and completeness are the whole game. Without a complete, recent recovery bundle there is nothing to exit. The tooling refreshes it opportunistically on every run today; building automatic refresh into the Blink app is the next step.
  • Respect TRUC. One unconfirmed parent+child pair per chain. Exit tooling must confirm-and-continue, tolerate TRUC-violation and missing-inputs as normal sequencing signals, and never treat a package batch as fire-and-forget.
  • Everything derives from the seed. Bundle refresh, fee funding, CPFP signing, and the final sweep all used the wallet's existing seed — no separate keys to back up, and the funding address is watch-only monitorable from an xpub.

Outlook: the exit, built into the app

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.

Did you find this valuable? Tip the author!

Did you find this valuable? Tip the author!

Social Share Component

Download Blink

Start receiving and sending bitcoin now

Community