feat(ecash): the wallet can now be restored from a phrase (NUT-13)
Demo images / Build & push demo images (push) Failing after 2m15s

Until now every Cashu proof this node held was backed by a secret drawn
from OsRng and written to exactly one file. Losing wallet/ecash.json
lost the coins outright — no phrase to write down, and nothing the mint
could do about it. Ecash is a bearer instrument, so "one file, no
backup" was the sharpest edge in the wallet.

NUT-13 derives each proof's secret and blinding factor from (seed,
keyset id, counter) instead. The wallet becomes a phrase, and the coins
can be re-derived and re-claimed — here or in any other NUT-13 wallet.

The phrase is its own 24 words, derived from the node master seed over a
fixed HKDF path. Both halves matter: it is still covered by the node's
recovery phrase, so there is nothing extra to write down; but it is
portable, so restoring ecash into Minibits or cdk-cli does not mean
handing over the key to the entire node.

It sits on disk unencrypted, deliberately. The master seed needs the
operator's password to open, which no background mint or swap can ask
for; and this file lives beside wallet/ecash.json, which already holds
spendable bearer secrets in plaintext. It regenerates exactly those
secrets, so it is the same sensitivity class as the file next to it.
0600, like identity/nostr_secret, which is derived and persisted the
same way.

Counters are reserved *before* the mint call and never rolled back. A
gap costs a restore scan a few extra probes; a reused counter costs a
coin, because two proofs with the same secret can only be spent once.

Restore is the half that cannot be done offline: a re-derived secret is
not money until the mint's signature over it exists. /v1/restore returns
those signatures; unblinding reconstitutes the proofs. It is additive
and idempotent — coins already held are skipped by secret, spent ones
are counted but not added — so it is safe to press on a working wallet,
which is when someone is most likely to reach for it.

Existing nodes activate on the first visit to Settings → Ecash backup
phrase: that password prompt is the only moment the master seed can
legitimately be opened. New nodes get it at onboarding. Until then the
behaviour is exactly as before — valid proofs, no backup — and the card
says so rather than implying a backup already exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-17 07:56:34 -04:00
co-authored by Claude Opus 5
parent 579287ba48
commit 59fffc809f
9 changed files with 1197 additions and 31 deletions
@@ -268,6 +268,9 @@ impl RpcHandler {
"wallet.ecash-history" => self.handle_wallet_ecash_history().await,
"wallet.ecash-network" => self.handle_wallet_ecash_network().await,
"wallet.ecash-set-network" => self.handle_wallet_ecash_set_network(params).await,
"wallet.ecash-seed-status" => self.handle_wallet_ecash_seed_status().await,
"wallet.ecash-seed-reveal" => self.handle_wallet_ecash_seed_reveal(params).await,
"wallet.ecash-restore" => self.handle_wallet_ecash_restore(params).await,
"wallet.networking-profits" => self.handle_wallet_networking_profits().await,
// Fedimint ecash (via fedimint-clientd sidecar)
"wallet.fedimint-list" => self.handle_wallet_fedimint_list().await,
+12
View File
@@ -52,6 +52,18 @@ pub(in crate::api::rpc) async fn save_pending_seed_encrypted(
.parse()
.context("Invalid mnemonic in memory")?;
crate::seed::save_seed_encrypted(data_dir, &mnemonic, passphrase).await?;
// Establish the ecash wallet's NUT-13 phrase here too — this is the last
// moment the master seed exists in plaintext during onboarding, and the
// ecash wallet needs its own phrase on disk to mint restorable proofs
// without a password prompt on every background swap. Best-effort: a node
// that fails here still onboards, mints valid coins, and can establish the
// phrase later from Settings → Back up ecash.
let master = crate::seed::MasterSeed::from_mnemonic(&mnemonic);
if let Err(e) = crate::wallet::nut13::establish_from_master(data_dir, &master).await {
tracing::warn!("Could not establish the ecash wallet phrase at onboarding: {e:#}");
}
*state = None;
Ok(true)
}
+125
View File
@@ -246,6 +246,131 @@ impl RpcHandler {
}))
}
/// `wallet.ecash-seed-status` — whether this wallet has a NUT-13 phrase
/// yet, and therefore whether its coins can be restored at all.
///
/// Deliberately says nothing secret. `active: false` is the honest answer
/// for a node that predates NUT-13: its existing proofs live in exactly one
/// file and nothing can bring them back, which the UI needs to be able to
/// say plainly rather than implying a backup exists.
pub(super) async fn handle_wallet_ecash_seed_status(&self) -> Result<serde_json::Value> {
let data_dir = &self.config.data_dir;
let active = crate::wallet::nut13::seed_exists(data_dir);
let source = match crate::wallet::nut13::load_seed(data_dir).await {
Ok(Some(seed)) => Some(seed.source()),
_ => None,
};
// Whether the node has an encrypted master seed decides whether the
// "set up" path can derive from it, which is what the operator is
// promised: your node's 24 words already cover your ecash.
Ok(serde_json::json!({
"active": active,
"source": source,
"can_activate": crate::seed::seed_exists(data_dir),
}))
}
/// `wallet.ecash-seed-reveal` — show the ecash wallet's 24 words, and
/// establish them from the node's master seed if this is the first time.
///
/// Gated exactly like `seed.reveal` and `lnd.seed-reveal`: authenticated
/// session, password re-verification, TOTP when enabled. The words are
/// returned to the caller only and never logged.
///
/// Reveal doubles as activation because the master seed is encrypted at
/// rest: this password prompt is the only moment the node can legitimately
/// open it, so it is also the only moment the ecash phrase can be derived
/// from it. A node that has never been here mints valid but unrecoverable
/// proofs; one visit fixes that for every proof minted afterwards.
pub(super) async fn handle_wallet_ecash_seed_reveal(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
use zeroize::Zeroize;
let params = params.unwrap_or_default();
let data_dir = &self.config.data_dir;
let mut password = self.verify_reveal_auth(&params, "the ecash seed").await?;
// Already established: just open it. No master seed needed, so this
// still works on a node whose backup passphrase has been forgotten.
if let Some(seed) = crate::wallet::nut13::load_seed(data_dir).await? {
password.zeroize();
let words = seed.words();
return Ok(serde_json::json!({
"words": words,
"word_count": words.len(),
"source": seed.source(),
"newly_activated": false,
}));
}
if !crate::seed::seed_exists(data_dir) {
password.zeroize();
anyhow::bail!(
"This node has no encrypted seed backup, so an ecash recovery \
phrase cannot be derived from it."
);
}
// The backup passphrase may differ from the login password — same
// fallback `seed.reveal` uses.
let passphrase = params
.get("passphrase")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| password.clone());
let master = crate::seed::load_seed_encrypted(data_dir, &passphrase).await;
password.zeroize();
let mnemonic = master.map_err(|_| {
anyhow::anyhow!(
"Could not decrypt the saved seed. If you set a separate backup \
passphrase during setup, enter that passphrase."
)
})?;
let master = crate::seed::MasterSeed::from_mnemonic(&mnemonic);
let seed = crate::wallet::nut13::establish_from_master(data_dir, &master).await?;
let words = seed.words();
Ok(serde_json::json!({
"words": words,
"word_count": words.len(),
"source": seed.source(),
"newly_activated": true,
}))
}
/// `wallet.ecash-restore` — rebuild the wallet's coins from its NUT-13
/// phrase by asking a mint which re-derived secrets it has signed.
///
/// Defaults to the wallet's own mint; `mint_url` targets another one, for
/// a wallet whose coins were spread across mints.
pub(super) async fn handle_wallet_ecash_restore(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let mint_url = match params.get("mint_url").and_then(|v| v.as_str()) {
Some(url) if !url.trim().is_empty() => url.trim().to_string(),
_ => {
crate::wallet::ecash::load_wallet(&self.config.data_dir)
.await?
.mint_url
}
};
let outcome =
crate::wallet::ecash::restore_from_seed(&self.config.data_dir, &mint_url).await?;
Ok(serde_json::json!({
"mint_url": mint_url,
"recovered_sats": outcome.recovered_sats,
"recovered_proofs": outcome.recovered_proofs,
"already_spent": outcome.already_spent,
"keysets_scanned": outcome.keysets_scanned,
}))
}
pub(super) async fn handle_wallet_networking_profits(&self) -> Result<serde_json::Value> {
let summary = profits::get_networking_profits(&self.config.data_dir).await?;
Ok(serde_json::json!({