feat(ecash): the wallet can now be restored from a phrase (NUT-13)
Demo images / Build & push demo images (push) Failing after 2m15s
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:
co-authored by
Claude Opus 5
parent
579287ba48
commit
59fffc809f
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(¶ms, "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!({
|
||||
|
||||
Reference in New Issue
Block a user