Skip to content

About

Tools and guide for Spark unilateral-exit recovery

Resources

Stars

7 stars

Watchers

0 watching

Forks

Repository files navigation

spark-unilateral-exit

Spark unilateral-exit recovery research, tooling, and tests.

This repo uses a bundle-first recovery model:

  • A Spark wallet or app keeps an encrypted Spark recovery bundle fresh while Spark operators are online.
  • The CLI consumes that bundle plus CPFP fee inputs and a destination Bitcoin address.
  • Seed-only recovery is not sufficient once Spark operators are offline, because current leaves cannot be discovered from the seed alone.

See docs/withdraw-guide.md for the recovery guide and docs/recovery-runbook.md for the operator runbook. docs/mainnet-exit-case-study.md documents a real mainnet unilateral exit performed with this tooling — costs, failures, and lessons. For wallet-app integration, see docs/mobile-integration-plan.md (integration boundary) and docs/mobile-ux-flow.md (screen flow for Blink mobile).

Prerequisites

Everything runs on Node 22+; there is no other toolchain. The repo ships a Nix flake that pins Node (and jq for the runbook snippets), loaded automatically by direnv:

  1. Install Nix — the Determinate Systems installer is recommended (flakes enabled out of the box, clean uninstall, survives macOS upgrades):

    curl -fsSL https://install.determinate.systems/nix | sh -s -- install
  2. Install direnv and hook it into your shell.

  3. In the repo root, run direnv allow once; the .envrc (use flake) then loads the pinned tools on every cd into the repo.

  4. Run npm install to fetch the JS dependencies.

Without direnv, prefix commands with nix develop --command, or simply bring your own Node 22+.

Current CLI

The CLI (node src/cli.ts <command>, run help for full flags) exposes:

Command Purpose
refresh-bundle Query live Spark leaves from a seed and write a bundle
consolidate Swap small leaves with the SSP into the fewest denominations (make consolidate)
plan Validate a saved bundle and print a recovery plan
cpfp-address Derive a CPFP funding address from the seed and estimate the sats to send it
watch-cpfp Watch the funding address for an incoming UTXO and emit it as --cpfp-utxo
package Construct unilateral-exit packages via the upstream Spark SDK
sign-packages Validate, summarize, confirm, and sign the CPFP fee-bump PSBTs (key from seed, key-file, or hex)
broadcast Submit signed packages via Esplora
tx-status Check confirmation status of a transaction via Esplora
sweep Spend confirmed refund outputs to a destination address
broadcast-sweep Broadcast signed sweep transactions via Esplora

watch-cpfp, broadcast, broadcast-sweep, and tx-status use Esplora and support only MAINNET/TESTNET/SIGNET by default; on REGTEST/LOCAL pass --esplora-url or use bitcoin-cli.

Recovery Flow

flowchart TD
    seed[("Spark seed")]

    subgraph prep["Preparation — Spark operators online"]
        consolidate["consolidate<br/>(optional: fewest-leaves denominations,<br/>cheapest to exit)"]
        refresh["refresh-bundle"]
        bundle[("recovery-bundle.json<br/>store encrypted, keep fresh")]
        consolidate -.-> refresh
        refresh --> bundle
    end

    subgraph funding["CPFP fee funding"]
        addr["cpfp-address<br/>prints cpfpAddress + requiredSats"]
        send["send ≥ requiredSats to cpfpAddress<br/>(single on-chain UTXO)"]
        watch["watch-cpfp<br/>emits cpfpUtxo"]
        addr --> send --> watch
    end

    subgraph exit["Unilateral exit — operators offline"]
        plan["plan<br/>optional dry run"]
        pkg["package<br/>unilateral-exit tx packages"]
        sign["sign-packages<br/>sign CPFP fee-bump PSBTs"]
        bcast["broadcast<br/>submit packages via Esplora"]
        status["tx-status<br/>wait for refund confirmation + timelock"]
        sweep["sweep<br/>spend refund outputs to destination"]
        bsweep["broadcast-sweep"]
        done[("BTC at destination address")]
        plan --> pkg --> sign --> bcast --> status --> sweep --> bsweep --> done
    end

    seed --> refresh
    bundle --> addr
    bundle --> pkg
    watch -- "--cpfp-utxo" --> plan
    seed -.-> addr
    seed -.-> sign
    seed -.-> sweep
Loading

Solid arrows carry data between steps; dashed arrows mark the commands that re-derive keys from the seed (use the same --account-number everywhere) and optional steps. The bundle must be refreshed while operators are online — everything below the first subgraph works fully offline against Bitcoin only. The one-shot make recover flow runs the preparation subgraph automatically on a best-effort basis: it consolidates leaves and refreshes the bundle when operators are reachable, and falls back to the saved bundle with a note when they are not.

Files required to resume a recovery

Three artifacts are the resume state of a recovery in progress; losing them can strand funds behind timelocks:

File Why it matters
Spark seed (SEED_FILE / SPARK_SEED) Derives the CPFP funding key and the refund/sweep keys
recovery-bundle.json + its *.backup.json copies Only source of the exit and refund transactions per leaf set; a bundle from before a swap is the only way to rebuild packages for leaves that were exiting at that time
recovery-packages.json (the --out of auto-exit) The refund transactions of the in-flight recovery; sweep reads it

The CLI never overwrites any of these silently: every write that would replace different content first copies the old file to a timestamped *.backup.json next to it, and auto-exit warns explicitly when a new run would drop leaves that the existing packages file still tracks. The packages file itself carries a purpose label and its creation context so it is recognizable months later. When testing or running a second recovery while one is in flight, point the run at separate paths (BUNDLE=... PACKAGES=...); but even without that, the previous state survives in a backup.

Install dependencies:

nix develop
npm install

Refresh the recovery bundle while Spark operators are online:

node src/cli.ts refresh-bundle \
  --seed-file /path/to/spark-seed.txt \
  --network MAINNET \
  --out recovery-bundle.json \
  --operator-set spark-mainnet \
  --app-version example-app

The seed file may contain a BIP-39 mnemonic or a 64-byte hex seed. When --seed-file, --seed, and SPARK_SEED are omitted, the CLI prompts for the seed with terminal echo disabled so typed or pasted wallet material is not displayed. Prefer --seed-file or the hidden prompt over --seed so shell history does not capture wallet material. --operator-set and --app-version are optional provenance metadata stored in the bundle. Store the resulting JSON encrypted in the mobile backup target; it contains the current Spark leaves needed for later offline recovery.

The exporter does not open a Spark SDK wallet. It derives the wallet identity key from the seed (m/8797555'/{account}'/0'), authenticates to the Spark coordinator directly, and queries query_nodes with include_parents=true over gRPC-web (src/operator/). It writes both leaf nodes and all their ancestor nodes into the bundle, re-fetching any ancestors the bulk query omits (legacy mainnet trees) and refusing to write a bundle with an open exit chain. The package builder uses those bundled ancestors as an offline query_nodes source when constructing unilateral-exit packages. --account-number, --operator-set, and --app-version are optional; use --account-number when the wallet used a non-default Spark account, and --coordinator to target a non-default coordinator (for example a local stack). The same client implementation ships inside the Blink mobile app, so app-saved bundles and CLI-exported bundles are interchangeable.

The same flows are available as Make targets:

make refresh-recovery-bundle \
  SEED_FILE=../.spark-seed.txt \
  BUNDLE=../recovery-bundle.json \
  NETWORK=mainnet \
  ACCOUNT_NUMBER=1

make plan \
  BUNDLE=../recovery-bundle.json \
  DESTINATION=bc1qexampledestination000000000000000000000000 \
  FEE_RATE=1 \
  CPFP_UTXO=txid:0:50000:0014example:02examplepubkey

make package \
  BUNDLE=../recovery-bundle.json \
  DESTINATION=bc1qexampledestination000000000000000000000000 \
  FEE_RATE=1 \
  CPFP_UTXO=txid:0:50000:0014example:02examplepubkey

make sweep \
  PACKAGES=recovery-packages.json \
  SEED_FILE=../.spark-seed.txt \
  NETWORK=mainnet \
  DESTINATION=bc1qexampledestination000000000000000000000000 \
  FEE_RATE=1 \
  ACCOUNT_NUMBER=1 \
  SWEEPS=sweep-transactions.json

make broadcast-sweep \
  SWEEPS=sweep-transactions.json \
  NETWORK=mainnet

Derive a CPFP funding address from the seed and estimate the sats to send it, then watch for the funds and capture the ready-to-use --cpfp-utxo value:

node src/cli.ts cpfp-address \
  --bundle recovery-bundle.json \
  --seed-file /path/to/spark-seed.txt \
  --network MAINNET \
  --fee-rate 10

# send at least the printed requiredSats to the printed cpfpAddress, then:

node src/cli.ts watch-cpfp \
  --bundle recovery-bundle.json \
  --seed-file /path/to/spark-seed.txt \
  --network MAINNET \
  --fee-rate 10

cpfp-address derives a dedicated P2WPKH funding key from the seed (path m/8797556'/<account>/0, purpose one above the Spark wallet purpose; only the purpose level is hardened so watch-only wallets can track the address from the m/8797556' xpub) and prints cpfpAddress, script, publicKey, and requiredSats (summed fee-bump fees plus --buffer-sats, default 1000). watch-cpfp polls until a UTXO of at least requiredSats (from --bundle + --fee-rate, or --min-sats) reaches that address and emits the cpfpUtxo string for package --cpfp-utxo. It requires --min-sats or --bundle so it never accepts an underfunded UTXO. This seed-derived path replaces manually exporting a fee UTXO from Bitcoin Core/Electrum/Sparrow (still documented in the withdraw guide), and the same seed signs the CPFP PSBTs via sign-packages --seed-file. If you pass --account-number, use the same value for cpfp-address, watch-cpfp, and sign-packages (it defaults to 0): each account derives a different key, so a mismatched watch-cpfp polls an address that never receives the funds.

Create a dry-run recovery plan from a saved bundle:

node src/cli.ts plan \
  --bundle examples/recovery-bundle.example.json \
  --destination bc1qexampledestination000000000000000000000000 \
  --fee-rate 10 \
  --cpfp-utxo txid:0:50000:0014example:02examplepubkey

Generate transaction packages with the upstream Spark SDK:

node src/cli.ts package \
  --bundle examples/recovery-bundle.example.json \
  --destination bc1qexampledestination000000000000000000000000 \
  --fee-rate 10 \
  --cpfp-utxo txid:0:50000:0014example:02examplepubkey

The package command creates unilateral-exit transaction packages from the saved TreeNode protobuf hex strings. After each refund transaction is broadcast and spendable, use sweep to spend the refund output to --destination.

The CPFP UTXO is an external on-chain Bitcoin output used to pay fees for the unilateral-exit package. It must be unspent, on the same Bitcoin network as the bundle, and controlled by a key you can use to sign the fee-bump transaction. The format is txid:vout:value:script:publicKey, where value is sats, script is the output scriptPubKey hex, and publicKey is the compressed or x-only public key for the spending key. See docs/withdraw-guide.md#cpfp-utxo-input for Bitcoin Core, Electrum, and Sparrow export steps.

Sweep construction derives the Spark refund key from the seed, verifies it matches the refund transaction output, and emits signed Bitcoin transaction hex. Broadcast the sweep transaction only after the refund transaction confirms and any required timelock has elapsed.

Minimum Practical Balance

The CLI does not currently enforce a minimum recoverable balance. As conservative operator guidance at 1 sat/vbyte, treat about 10,000 sats for one Bitcoin leaf or about 20,000 sats total for two Bitcoin leaves as the practical floor. This is not a protocol limit; it is a planning threshold for package fees, CPFP fees, final sweep fees, dust, and margin.

These floors are roughly an order of magnitude above Spark's published cooperative "Exit to L1" fee of 250 × sats_per_vbyte + 750 (about 1,000 sats at 1 sat/vbyte). That cooperative fee is flat and applies only while Spark operators are online; unilateral exit is the offline fallback and costs more because the user broadcasts the full exit tree plus CPFP bumps and a final sweep rather than one operator-assisted transaction. See docs/withdraw-guide.md for details.

Leaf Consolidation

Every leaf pays its own CPFP and sweep fees on unilateral exit, so a balance fragmented across many small leaves strands more value in uneconomical leaves than the same balance held as a few large ones. While the Spark operators are online, the leaf set can be consolidated cooperatively:

make consolidate SEED_FILE=../spark-seed.txt NETWORK=mainnet DRY_RUN=1  # inspect the plan
make consolidate SEED_FILE=../spark-seed.txt NETWORK=mainnet           # execute the swaps

This drives the Spark SDK's optimizeLeaves swap with the SSP toward the greedy power-of-two decomposition of the balance - the fewest leaves Spark can represent it with. Leaf values are power-of-two denominations (matched from the SSP's inventory), so a single leaf holding the whole balance is generally not possible: 9,888 sats consolidates to 8192 + 1024 + 512 + 128 + 32, five leaves, not one. Value stranded in still-uneconomical leaves after consolidation is bounded by roughly twice the per-leaf exit cost, no matter how fragmented the wallet was before.

Consolidation is a cooperative operation (not an exit) and spends the current leaves, so refresh the recovery bundle afterwards; the command reminds you. make recover performs both steps automatically before exiting (see below).

The consolidation trade-off

There is no leaf set that is optimal for both exits and payments; the denomination shape trades one against the other:

Exit-optimal (MULTIPLICITY=0) Transfer-optimal (MULTIPLICITY=1)
Leaf set for 9,888 sats 32,128,512,1024,8192 (5 leaves) 1,1,2,4,8,...,2048,4096 (18 leaves)
Unilateral exit cost Lowest possible; least value stranded Every small leaf pays its own exit fees; most strand below break-even
Sending payments Most amounts cannot be composed exactly, so sends trigger an SSP swap first (extra latency, SSP must be online, more metadata shared, burns refund-timelock hops) Any amount up to the balance composes like a binary number, so sends almost never need a pre-swap

The Spark SDK's own default is transfer-optimal (it even re-optimizes in the background toward multiplicity 1; this tool disables that on the wallets it opens). So treat exit-optimal as a recovery posture, not a resting state:

  • Before a unilateral exit: consolidate to MULTIPLICITY=0 (or let make recover do it) so the maximum value is economical to exit.
  • If the recovery turns out unnecessary (operators recovered, false alarm) and the wallet goes back to day-to-day use: swap back with make consolidate MULTIPLICITY=1 so ordinary payments stop depending on an SSP swap, and refresh the recovery bundle again afterwards.

Test

npm test

The per-leaf economics calculations can additionally be verified against a real exported recovery bundle (adapted to regtest in-memory; the bundle itself is never committed because it reveals wallet graph metadata):

SPARK_REAL_BUNDLE=../recovery-bundle.json npx vitest run test/e2e/real-bundle-economics.test.ts
SPARK_REAL_BUNDLE=../recovery-bundle.json npx vitest run test/e2e/real-consolidate.test.ts

The second test measures what consolidate would do to the real bundle's leaf set: how much value is stranded in uneconomical leaves now versus after consolidating to the greedy power-of-two denominations. Both tests are skipped automatically when no bundle is present at SPARK_REAL_BUNDLE or ../recovery-bundle.json.

GitHub Actions

This repo has two workflows:

  • CI: runs on push/PR, installs dependencies, runs npm audit, unit coverage, and a CLI smoke test.
  • Spark Local E2E: runs nightly and via workflow_dispatch. It checks out upstream buildonspark/spark, starts the local docker-compose Spark/bitcoind stack, then runs npm run test:e2e. The E2E proves live leaf export into the bundle schema against the local Spark stack, constructs and submits the unilateral-exit package, then signs, broadcasts, and confirms the destination sweep.

The local E2E can also be run manually when Docker and the Spark local stack are available:

RUN_SPARK_E2E=1 npm run test:e2e

About

Tools and guide for Spark unilateral-exit recovery

Resources

Stars

7 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages