Scoped from the framework-pt keyset incident: adopt the `cashu` crate (MIT, the crate CDK is built on) for the token codec, keyset ids, crypto, DLEQ and NUT-13, while keeping ecash.rs's on-disk contract, MintClient's Tor seam and error table, and our multi-mint routing. Records why the full cdk WalletDatabase shim is the wrong trade over live funds, and how ecash backup can derive from the node's existing seed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5.7 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.
Open question for the operator: whether to also allow an independent wallet mnemonic, for someone who wants ecash separable from the node identity. 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 restore path.
- 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.