Seed-only sweep tool for MoneyDevKit LDK clients. Given the MDK
mnemonic of a node, mdk-recovery enumerates every on-chain output
the seed can claim, queries an esplora endpoint for matching UTXOs,
builds a single signed sweep transaction, and optionally broadcasts it.
to_remoteoutputs from LSP force-closes of v2 static_remote_key channels. LDK 0.2 derives the counterparty payment key offm/8h/idx_hfrom the inner xprv built on top of the LDK seed; the 1000 enumerable indices cover every channel a client has ever held in either commitment-output flavour:- P2WPKH (non-anchor channels)
- P2WSH wrapping
<pubkey> OP_CHECKSIGVERIFY 1 OP_CSV(anchor channels)
- BIP-84 on-chain funds at
m/84h/{0,1}h/0h/{0,1}/iwith the default 20-address gap limit per chain.
That's it. Both paths are pure derivations from the seed — no prior wallet state, channel monitor, or VSS access required.
- Our-side force-close outputs (
to_localafterto_self_delay, HTLC outputs, justice transactions). These need the per-channel-deriveddelayed_payment_basepointand HTLC preimages from the encryptedChannelMonitorin VSS. A future VSS-backed recovery mode will cover them. - Channels opened by ldk-node ≤ 0.6.x. Their
to_remotesits at a per-channel-derived v1 script that is not enumerable from seed. Operators rotate these off cooperatively before relying on this tool. - In-flight HTLCs at the time of close. Same VSS dependency as our-side force-close.
If the seed is gone, nothing recovers anything.
npx @moneydevkit/recovery
Pulls the prebuilt binary for your platform from npm and runs it. Or build from source (ops, auditors):
cargo install --git https://github.com/moneydevkit/mdk-recovery
Every release is built and published by the project's CI, with a verifiable record of where it came from.
- Installed from npm:
npm audit signatures - Downloaded from GitHub releases:
gh attestation verify <binary> --repo moneydevkit/mdk-recovery
Every flag is optional. The bare invocation mdk-recovery runs the
full interactive flow against mainnet.
mdk-recovery [--network bitcoin|testnet|signet|regtest] default: bitcoin
[--to <address>] else prompted
[--feerate-sat-vb <n>] default: 5
[--scan-anchors] opt-in
[--mnemonic-stdin] force stdin read
[--yes] skip confirmation
[--verbose] per-input listing
[--json] scripted broadcast
One flat binary, no subcommands. On a TTY the run is:
- Prompt for the mnemonic with no echo (via
rpassword). - Scan the network's esplora for matching UTXOs.
- Print
Found N output(s) totalling X BTC. - Prompt
Where to? (onchain address):unless--towas given. - Print the To / Fee / Net summary block.
- Prompt
Broadcast? [y/N]unless--yeswas given. - Broadcast and print the mempool URL.
--scan-anchors extends the scan to the 1000 P2WSH anchor scripts.
Off by default since MDK does not open anchor channels; turning it
on roughly doubles the scan time.
--verbose appends a per-input listing (outpoint, source, value)
to the summary block. This is what an auditor reaches for when
they want to see exactly which UTXOs feed the sweep.
Off a TTY, or with --mnemonic-stdin on a TTY, the binary reads
the mnemonic from stdin to EOF instead of prompting. This is the
scripted / CI path.
--json emits one JSON object to stdout after broadcast
(txid, raw_hex, explorer_url). It is strictly
non-interactive: requires --to, --yes, and a mnemonic on stdin
(enforced at runtime so a TTY caller can't fall into the hidden
prompt by accident). All progress, prompts, and the human summary
go to stderr so the JSON stays pipeable.
The [y/N] after the summary block is the only gate. The
destination renders in plain context above it; the user reads
back what they pasted and types y. Anything other than y or
yes (case insensitive) aborts cleanly with no broadcast, which
also covers the dry-run case: an auditor who wants to inspect the
plan without broadcasting just declines.
Per-network esplora URLs are hard-coded in cli::endpoint_for:
| Network | Endpoint |
|---|---|
| Bitcoin | https://esplora.moneydevkit.com/api |
| Signet | https://mutinynet.com/api (the MDK target signet, not vanilla) |
| Testnet | https://blockstream.info/testnet/api |
| Regtest | $MDK_RECOVERY_ESPLORA_URL (test harness only) |
There is no CLI flag for arbitrary endpoint override — picking the wrong one is a footgun (silently redirecting mainnet traffic) and the per-network endpoints rarely move.
just check runs nix flake check which runs format/lint checks and tests.
The regtest integration tests spawn a real bitcoind via corepc-node
(binary path pinned through BITCOIND_EXE, fed by the nix flake's pinned bitcoind)
behind a small in-process esplora-shaped HTTP shim, fund the derived scripts
directly via bitcoind RPC, run the binary as a subprocess, and
assert the funds moved.
just release X.Y.Z # branch + bump + commit + push + open PR
# review the release/vX.Y.Z PR and merge it
Merging a release/* PR is the whole release. CI reads the version
from Cargo.toml, creates and pushes the matching vX.Y.Z tag, builds
every platform binary at that commit, attests them, cuts the GitHub
release, and publishes to npm. There is no hand-pushed tag, so the tag
and the published version can't drift apart.
Pre-releases use a semver hyphen (X.Y.Z-rc.1, X.Y.Z-beta.0) and
publish under the npm next dist-tag.
If a release published broken artifacts, tear it down before cutting a new one:
gh release delete vX.Y.Z --yes # GitHub release
git push --delete origin vX.Y.Z # remote tag
git tag -d vX.Y.Z # local tag
npm versions are immutable: you cannot republish X.Y.Z once it is
live, and unpublishing is heavily restricted. So a redo always means a
new version (bump the patch or the -rc.N), not the same one again.
If the failure was caught mid-run before npm published, you can keep
the version: delete the GitHub release and tag as above, fix the
problem, and re-merge the release/* PR (or re-point the tag at the
fixed commit).