Ecash gets its own BIP-39 mnemonic derived from the node master seed: still covered by the node's recovery phrase, but portable into any NUT-13 wallet without exposing the master seed — so 'back up my ecash' is not the same action as 'expose the key to everything'. Surfaced exactly like the Lightning seed: the shared SeedRevealPanel, on the app detail page and in Settings → Backup, behind the same verify_reveal_auth password re-entry. Standard BIP-39, so the SeedQR tab works (no aezeed flag). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7.6 KiB
Cashu: move to the reference implementation (cashu / CDK)
Status: planned (2026-08-17). Scoped from the framework-pt keyset incident.
Why
The node's Cashu support is hand-rolled: the NUT-00 token codec
(wallet/cashu.rs), the blind-DH crypto (wallet/bdhke.rs) and the mint HTTP
client (wallet/mint_client.rs) are ours. That was fine while the protocol
was small; it isn't any more.
The cost showed up on 2026-08-17: a Minibits token could not be redeemed
because it carried a NUT-02 v2 keyset id truncated to 8 bytes. The mint —
itself CDK-based — rejected the swap with NUT02: ID length invalid. The
reference implementation has carried the resolver for this exact case
(Id::from_short_keyset_id) since v0.11, and requires the mint's keyset list
to turn a token into proofs, so it cannot forward a truncated id. We shipped
an equivalent repair by hand (2277fc46); the general lesson is that we are
tracking a moving spec on our own, and we keep finding out where we lag from
production failures.
Other gaps we carry today:
- No DLEQ verification. V4 tokens'
d/wfields are parsed and discarded, so we cannot prove a mint signed with the keyset it claims. - No deterministic secrets (NUT-13).
bdhke::generate_secretis pure randomness, which means the ecash wallet cannot be restored from a seed — losingwallet/ecash.jsonloses the coins. - We emit only
cashuA(V3); most wallets now default tocashuB(V4).
What we adopt
The cashu crate alone (MIT, from cashubtc — the crate CDK itself is built
on), not the full cdk wallet with its WalletDatabase.
That gives us, from audited upstream code:
nut00::Token— encode and decode bothcashuAandcashuB, plusto_v3_string()for older walletsnut02::{Id, KeySetVersion, KeySetInfo}— correct v1 (8-byte) and v2 (33-byte) ids, andId::from_short_keyset_idfor the truncated casedhke::{hash_to_curve, blind_message, unblind_message}— the crypto, with upstream's test vectors- DLEQ verification
- NUT-13 deterministic secrets
It is a light dependency: ~7 transitive crates, no reqwest, no runtime
opinions. bitcoin ^0.32.2 matches our pinned =0.32.5, so secp256k1 stays a
single copy in the tree.
What we deliberately keep
wallet/ecash.rsand every on-disk file, unchanged.wallet/ecash.json,accepted_mints.json,pending_swaps.json,swap_liquidity.jsonkeep their exact schemas — includingStoredProof's flattened shape and the capital-Cfield. Balances, history and trusted mints survive the update untouched.EcashTransaction— Fedimint and Ark write the same struct; it is a cross-backend contract, not Cashu-private.MintClient's HTTP surface, its Tor-capablewith_clientseam, and the NUT error-code → plain-English table (which is better UX than upstream's raw errors).- Our multi-mint logic —
swap_between_mints,plan_payment, the liquidity cache and the crash-safe swap journal have no upstream equivalent.
What we do NOT adopt, and why
cdk::wallet::Wallet requires implementing WalletDatabase — ~50 methods.
Roughly 30 have no home in our format (keyset caches, NUT-13 counters, sagas,
uuid-keyed reservations, KV, P2PK), and the ~20 that do map are lossy in both
directions on exactly the records that hold real money (proof state, and a
transaction type shared with two other backends). A permanent compatibility
shim over live funds is the wrong trade. If we ever want the full wallet, it
should come with a one-way format migration, decided separately.
Seed backup, derived from the node seed
Today the ecash wallet has no seed and cannot be recovered. With NUT-13 it can, and it should not introduce a second thing for the operator to write down:
- Derive the Cashu wallet seed from the existing node master seed over a dedicated BIP32 path, alongside the node's other derived keys. The node's 24 words then already back up the ecash wallet — nothing new to record, and a restored node re-derives the same secrets.
- Persist the per-keyset NUT-13 counter (upstream's
increment_keyset_counter) in a new sidecar file. It is recovery metadata, not funds: a lost counter costs a restore scan, not coins. - Surface it in the UI the way the node seed already is: a "back up / restore ecash" path that states plainly that the node's recovery phrase covers it.
The words we show, and why they are their own mnemonic
Derive a dedicated BIP-39 mnemonic for the Cashu wallet from the node master seed (deterministic, fixed path), rather than showing the node's own 24 words. Both properties matter:
- it is still covered by the node's recovery phrase — a restored node re-derives the same ecash wallet, nothing extra to write down;
- but it is portable: the operator can restore their ecash in any NUT-13
wallet (Minibits, Nutstash,
cdk-cli) without handing over the node's master seed. Showing the node seed here would make "back up my ecash" and "expose the key to everything" the same action.
Where it appears — the existing seed-reveal pattern, unchanged
Mirror the Lightning seed backup exactly; do not invent a second pattern.
| Piece | Lightning (existing) | Ecash (to build) |
|---|---|---|
| Shared UI | components/SeedRevealPanel.vue (:words, Words/QR tabs, tap-to-reveal blur, SeedQR) |
same component, reused as-is |
| App detail page | views/appDetails/LndSeedBackup.vue, rendered from AppDetails.vue:22 when packageKey === 'lnd' && pkg.installed |
views/appDetails/EcashSeedBackup.vue, rendered the same way for the ecash-bearing app |
| Settings | views/settings/BackupSection.vue:352 |
same section, a panel beside it |
| Status RPC | lnd.seed-backup-status |
wallet.ecash-seed-status |
| Reveal RPC | lnd.seed-reveal |
wallet.ecash-seed-reveal |
| Auth gate | verify_reveal_auth(¶ms, "the Lightning seed") — password re-entry |
same helper, "the ecash seed" |
Pass SeedRevealPanel without the aezeed flag: unlike LND's aezeed, this
is standard BIP-39, so the SeedQR tab works and third-party wallets can consume
it.
Open question for the operator: whether to also allow importing an externally generated ecash mnemonic (bring-your-own, breaking the derived-from-node link). Default should be derived-from-node.
Options this unlocks (worth considering, not committed)
- Run our own mint. Upstream ships
cdk-mintd. Packaged as an app it would make a node its own Cashu mint — the same shape as thefedimint-clientdsidecar, and launchable from the dashboard if it has a UI. Independent of the wallet work here. - P2PK / locked tokens (NUT-11) — send ecash only a specific pubkey can redeem, which fits the mesh/federation identity we already have.
- Multi-unit support beyond sats.
Sequencing
Repair truncated v2 keyset ids so the failing case works now— done,2277fc46.- Swap
wallet/cashu.rs+wallet/bdhke.rsinternals for thecashucrate, keeping every public signature and the on-disk contract. Existing tests in both modules stay as the regression net; add upstream's DLEQ vectors. - Emit
cashuB(V4) by default, keepcashuAfor compatibility. - NUT-13 deterministic secrets + node-seed derivation, the reveal/restore surfaces above (app detail page and Settings), and a restore-from-words path so a wallet can be rebuilt from the mnemonic alone.
- Then reconsider
cdk-mintdand NUT-11 as separate features.
Verify each step against a real mint on a test node before the fleet, and keep
wallet/ecash.json from a pre-migration node to prove it still loads.