Compare commits

...
2 Commits
Author SHA1 Message Date
archipelagoandClaude Opus 5 59fffc809f 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>
2026-08-17 07:56:34 -04:00
archipelagoandClaude Opus 5 579287ba48 feat(ecash): emit cashuB tokens, and share one payment success screen
Most wallets — Minibits, Nutstash, cdk-cli — default to reading cashuB
(V4) now, so that is what we send. cashuA stays as the fallback rather
than the default: it is still valid everywhere, so a token this wallet
cannot express in V4 (a multi-mint one) is worth sending in V3 rather
than failing the send outright. That path warns, because by the time
`send_token_at` serializes, the proofs are already marked spent.

The V4 encoder is the reference implementation's, not ours. The envelope
puts the keyset id and signature on the wire as raw CBOR bytes under
single-letter keys, and a token subtly wrong there is money the receiver
cannot redeem — so upstream owns the encoding, the way it already owns
keyset-id resolution. Our own hand-written decoder reads what upstream
writes in the new test, which is agreement between two independent
implementations rather than a round trip through one codec.

Two refusals are deliberate and tested: a multi-mint token has no V4
form, and a truncated v2 keyset id must never be baked into a token we
emit (the framework-pt case) — it is only resolvable against the mint's
keyset list.

Also folds SendBitcoinModal onto the shared PaymentSuccessPane it had a
private copy of, so on-chain, Lightning and ecash all show the same
screen and the copyable-identifier row is defined once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 07:56:15 -04:00
12 changed files with 1662 additions and 159 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!({
+58
View File
@@ -39,6 +39,7 @@ const NODE_NOSTR_INFO: &[u8] = b"archipelago/nostr-node/secp256k1/v1";
const FIPS_KEY_INFO: &[u8] = b"archipelago/fips/secp256k1/v1";
const LND_ENTROPY_INFO: &[u8] = b"archipelago/lnd/entropy/v1";
const RELEASE_ROOT_ED25519_INFO: &[u8] = b"archipelago/release/root/ed25519/v1";
const CASHU_ENTROPY_INFO: &[u8] = b"archipelago/cashu/bip39-entropy/v1";
// ─── MasterSeed ─────────────────────────────────────────────────────────
@@ -300,6 +301,30 @@ pub fn derive_lnd_entropy(seed: &MasterSeed) -> Result<[u8; 16]> {
Ok(entropy)
}
/// Derive the ecash (Cashu, NUT-13) wallet's own 24-word BIP-39 mnemonic.
///
/// The ecash wallet gets a **separate mnemonic** rather than being handed the
/// node's own 24 words, and both halves of that matter:
///
/// - it is still covered by the node's recovery phrase, because it is derived
/// from the master seed over a fixed domain-separated path — restore the
/// node from its words and the same ecash wallet comes back, with nothing
/// extra for the operator to write down;
/// - but it is *portable*. NUT-13 is a standard, so these words restore the
/// ecash in Minibits, Nutstash or `cdk-cli`. Showing the node seed here
/// would have made "back up my ecash" and "hand over the key to the entire
/// node" the same action.
///
/// One-way by construction: HKDF cannot be run backwards, so a leaked ecash
/// mnemonic does not expose the master seed or any other derived key.
pub fn derive_cashu_mnemonic(seed: &MasterSeed) -> Result<bip39::Mnemonic> {
let mut entropy = hkdf_derive_32(seed.as_bytes(), CASHU_ENTROPY_INFO)?;
let mnemonic = bip39::Mnemonic::from_entropy(&entropy)
.map_err(|e| anyhow::anyhow!("Failed to derive the ecash mnemonic: {}", e));
entropy.zeroize();
mnemonic
}
// ─── Encrypted Seed Storage ─────────────────────────────────────────────
/// Encrypt `plaintext` with Argon2(passphrase) + ChaCha20-Poly1305.
@@ -657,6 +682,39 @@ mod tests {
assert_eq!(e1.len(), 16);
}
/// The ecash mnemonic must be reproducible from the node's words alone —
/// that reproducibility is the entire backup story ("your 24 words already
/// cover your ecash").
#[test]
fn cashu_mnemonic_is_reproducible_from_the_node_seed() {
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
let a = derive_cashu_mnemonic(&seed).unwrap();
let b = derive_cashu_mnemonic(&seed).unwrap();
assert_eq!(a.to_string(), b.to_string());
assert_eq!(a.word_count(), 24);
// A different node seed must yield a different ecash wallet, or two
// nodes would derive each other's coins.
let (other_words, _) = MasterSeed::generate().unwrap();
let (_, other_seed) = MasterSeed::from_mnemonic_words(&other_words.to_string()).unwrap();
assert_ne!(a.to_string(), derive_cashu_mnemonic(&other_seed).unwrap().to_string());
}
/// It must NOT be the node's own phrase. Restoring ecash into a
/// third-party wallet means handing these words over, and that must never
/// be the same as handing over the node.
#[test]
fn cashu_mnemonic_is_not_the_node_mnemonic() {
let (node_mnemonic, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
let cashu = derive_cashu_mnemonic(&seed).unwrap();
assert_ne!(cashu.to_string(), node_mnemonic.to_string());
// And knowing the ecash words must not re-derive the node seed: they
// are a one-way HKDF descendant, so the seeds they expand to differ.
let cashu_seed = MasterSeed::from_mnemonic(&cashu);
assert_ne!(cashu_seed.as_bytes(), seed.as_bytes());
}
#[test]
fn test_generate_produces_24_words() {
let (mnemonic, _seed) = MasterSeed::generate().unwrap();
+174 -15
View File
@@ -1,22 +1,22 @@
//! Cashu token format (NUT-00) — serialization and deserialization.
//!
//! Emits the cashuA (V3) token format:
//! cashuA<base64url_encoded_json>
//! Reads and writes both wire versions:
//!
//! Token JSON structure:
//! {
//! "token": [{ "mint": "<url>", "proofs": [{ "amount": u64, "id": "<keyset>", "secret": "<str>", "C": "<hex>" }] }],
//! "memo": "<optional>"
//! }
//! - **cashuA (V3)** — `cashuA<base64url_encoded_json>`, whose JSON is the
//! structs below verbatim:
//! ```text
//! { "token": [{ "mint": "<url>", "proofs": [{ "amount": u64, "id": "<keyset>",
//! "secret": "<str>", "C": "<hex>" }] }], "memo": "<optional>" }
//! ```
//! - **cashuB (V4)** — `cashuB<base64url_encoded_cbor>`, a CBOR map keyed by
//! the spec's single letters (t/i/p/a/s/c/m/u/d/w) rather than the JSON
//! names above, with the keyset id (`i`) and signature (`c`) as raw bytes.
//! Those are hex-encoded into `Proof` on the way in so the rest of the
//! wallet never has to know which version a token arrived in.
//!
//! Also accepts (decode-only) the cashuB (V4) CBOR format many wallets emit
//! by default now:
//! cashuB<base64url_encoded_cbor>
//! CBOR map keys are the spec's single-letter names (t/i/p/a/s/c/m/u/d/w),
//! not the JSON names above. `i` (keyset id) and `c` (signature) are raw
//! bytes on the wire; we hex-encode them into `Proof` to match the V3
//! convention so the rest of the wallet doesn't need to know which version
//! a token arrived in.
//! `serialize_v4` is what we emit — most wallets default to cashuB now —
//! with `serialize` (cashuA) kept for older receivers and as the fallback
//! for the one token shape V4 cannot express (multi-mint).
use anyhow::{Context, Result};
use bitcoin::secp256k1::PublicKey;
@@ -24,10 +24,16 @@ use bitcoin::secp256k1::PublicKey;
// itself is built on). Used for the parts of NUT-00/02 that move with the
// spec — token parsing and keyset ids — while the structs below stay ours
// because they are also the on-disk format (see docs/cashu-cdk-migration-plan.md).
use cashu::nuts::nut00::{Proof as CdkProof, Token as CdkToken};
use cashu::nuts::nut01::PublicKey as CdkPublicKey;
use cashu::nuts::nut02::{
Id as CdkId, KeySetInfo as CdkKeySetInfo, ShortKeysetId as CdkShortKeysetId,
};
use cashu::nuts::CurrencyUnit as CdkCurrencyUnit;
use cashu::secret::Secret as CdkSecret;
use cashu::{Amount as CdkAmount, MintUrl as CdkMintUrl};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
/// Prefix for V3 (JSON) tokens.
const CASHU_A_PREFIX: &str = "cashuA";
@@ -148,6 +154,58 @@ impl CashuToken {
Ok(format!("{}{}", CASHU_A_PREFIX, encoded))
}
/// Encode as a cashuB (V4, CBOR) token string — the format most wallets
/// default to today.
///
/// Built through the reference implementation rather than by hand. The V4
/// envelope puts the keyset id and the signature on the wire as raw CBOR
/// bytes under single-letter keys, and a token that is subtly wrong there
/// is money the receiver cannot redeem — so upstream owns the encoding,
/// the same way it owns keyset-id resolution.
///
/// V4 is single-mint by construction, so a multi-mint token — which only
/// our internal plumbing ever builds — has no V4 form and is refused
/// here; `send_token_at` falls back to cashuA for it.
pub fn serialize_v4(&self) -> Result<String> {
let entry = match self.token.as_slice() {
[only] => only,
[] => anyhow::bail!("Token has no entries"),
many => anyhow::bail!(
"cashuB carries one mint per token; this token spans {}",
many.len()
),
};
let mint_url = CdkMintUrl::from_str(&entry.mint)
.with_context(|| format!("Token has an unusable mint URL: {}", entry.mint))?;
// `unit` is optional on our struct and on V3; V4 requires one. Every
// proof this wallet holds is denominated in sats (the mint's SAT
// keyset is selected explicitly at signing time), so that is the
// right default rather than a guess.
let unit = CdkCurrencyUnit::from_str(self.unit.as_deref().unwrap_or("sat"))
.with_context(|| format!("Token has an unusable unit: {:?}", self.unit))?;
let proofs = entry
.proofs
.iter()
.map(|p| {
let keyset_id = CdkId::from_str(&p.id).with_context(|| {
format!("Proof carries a keyset id cashuB cannot encode: {}", p.id)
})?;
let c = CdkPublicKey::from_hex(&p.c)
.context("Proof carries an unparseable signature C")?;
Ok(CdkProof::new(
CdkAmount::from(p.amount),
keyset_id,
CdkSecret::new(p.secret.clone()),
c,
))
})
.collect::<Result<Vec<_>>>()?;
Ok(CdkToken::new(mint_url, proofs, self.memo.clone(), unit).to_string())
}
/// Decode a cashuA (V3 JSON) or cashuB (V4 CBOR) token string.
pub fn deserialize(token_str: &str) -> Result<Self> {
if let Some(payload) = token_str.strip_prefix(CASHU_B_PREFIX) {
@@ -553,6 +611,107 @@ mod tests {
assert_eq!(decoded.memo, Some("test token".to_string()));
}
#[test]
fn a_v4_token_we_emit_is_readable_by_our_own_v4_decoder() {
// Cross-implementation check: upstream's encoder writes the CBOR,
// our hand-written decoder reads it back. Agreement between two
// independent implementations is the evidence that matters here —
// a round trip through one codec would prove nothing about the wire.
let token = CashuToken {
token: vec![TokenEntry {
mint: "https://testnut.cashu.space".to_string(),
proofs: vec![
Proof {
amount: 8,
id: "009a1f293253e41e".to_string(),
secret: "abcdef1234567890".to_string(),
c: "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24"
.to_string(),
},
Proof {
amount: 2,
id: "009a1f293253e41e".to_string(),
secret: "fedcba0987654321".to_string(),
c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
.to_string(),
},
],
}],
memo: Some("ten sats".to_string()),
unit: Some("sat".to_string()),
};
let encoded = token.serialize_v4().expect("V4 encoding must succeed");
assert!(encoded.starts_with("cashuB"), "{encoded}");
let decoded = CashuToken::deserialize(&encoded).expect("our decoder must read it");
assert_eq!(decoded.total_amount(), 10);
assert_eq!(decoded.token[0].mint, "https://testnut.cashu.space");
assert_eq!(decoded.memo, Some("ten sats".to_string()));
// Every proof survives byte-for-byte, including the hex convention we
// impose on the raw-bytes CBOR fields.
let mut got: Vec<_> = decoded
.all_proofs()
.iter()
.map(|p| (p.amount, p.id.clone(), p.secret.clone(), p.c.clone()))
.collect();
got.sort();
let mut want: Vec<_> = token
.all_proofs()
.iter()
.map(|p| (p.amount, p.id.clone(), p.secret.clone(), p.c.clone()))
.collect();
want.sort();
assert_eq!(got, want);
}
#[test]
fn a_multi_mint_token_has_no_v4_form_and_says_so() {
// V4 is single-mint by construction. `send_token_at` relies on this
// failing (rather than silently dropping an entry) to fall back to
// cashuA — the proofs are already spent by the time it serializes.
let one = |mint: &str| TokenEntry {
mint: mint.to_string(),
proofs: vec![Proof {
amount: 1,
id: "009a1f293253e41e".to_string(),
secret: "s".to_string(),
c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".to_string(),
}],
};
let token = CashuToken {
token: vec![one("https://mint-a.example"), one("https://mint-b.example")],
memo: None,
unit: Some("sat".to_string()),
};
let err = token
.serialize_v4()
.expect_err("two mints cannot be one V4 token");
assert!(err.to_string().contains("one mint per token"), "{err}");
// …and cashuA, the fallback, still carries it.
assert!(token.serialize().unwrap().starts_with("cashuA"));
}
#[test]
fn a_truncated_keyset_id_is_refused_by_the_v4_encoder() {
// The framework-pt case. A short v2 id is only resolvable against the
// mint's keyset list, so it must never be baked into a token we emit.
let token = CashuToken::new(
"https://mint.minibits.cash/Bitcoin",
vec![Proof {
amount: 1,
id: "01fc0ec0e59cd6fa".to_string(),
secret: "s".to_string(),
c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".to_string(),
}],
);
let err = token.serialize_v4().expect_err("short id must not encode");
assert!(err.to_string().contains("keyset id"), "{err}");
}
#[test]
fn test_amount_to_denominations() {
assert_eq!(amount_to_denominations(0), Vec::<u64>::new());
+268 -17
View File
@@ -6,6 +6,7 @@
use super::cashu::{amount_to_denominations, CashuToken, Proof};
use super::mint_client::MintClient;
use super::nut13::RecoverySource;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
@@ -416,13 +417,26 @@ pub async fn save_accepted_mints(data_dir: &Path, mints: &AcceptedMints) -> Resu
Ok(())
}
/// Build a mint client whose proofs are **restorable from the wallet phrase**.
///
/// Every output such a client creates has its secret derived via NUT-13
/// (`wallet/nut13.rs`) rather than drawn from randomness, so the coins can be
/// re-derived and re-claimed if `wallet/ecash.json` is ever lost. That is the
/// only difference from `MintClient::new`, and it is the reason this wallet
/// has a backup story at all — so every mint/swap path in this module goes
/// through here. On a node with no phrase yet the source is absent and the
/// behaviour is exactly as it was before: valid proofs, no backup.
async fn mint_client(data_dir: &Path, mint_url: &str) -> Result<MintClient> {
Ok(MintClient::new(mint_url)?.with_recovery(RecoverySource::load(data_dir).await))
}
/// Request a mint quote — returns a Lightning invoice to pay.
pub async fn mint_quote(
data_dir: &Path,
amount_sats: u64,
) -> Result<super::mint_client::MintQuote> {
let wallet = load_wallet(data_dir).await?;
let client = MintClient::new(&wallet.mint_url)?;
let client = mint_client(data_dir, &wallet.mint_url).await?;
client.mint_quote(amount_sats).await
}
@@ -430,7 +444,7 @@ pub async fn mint_quote(
pub async fn mint_tokens(data_dir: &Path, quote_id: &str, amount_sats: u64) -> Result<u64> {
let mut wallet = load_wallet(data_dir).await?;
let mint_url = wallet.mint_url.clone();
let client = MintClient::new(&mint_url)?;
let client = mint_client(data_dir, &mint_url).await?;
let result = client.mint_tokens(quote_id, amount_sats).await?;
let minted: u64 = result.proofs.iter().map(|p| p.amount).sum();
@@ -452,7 +466,7 @@ pub async fn mint_tokens(data_dir: &Path, quote_id: &str, amount_sats: u64) -> R
/// Request a melt quote — how much to pay a Lightning invoice with ecash.
pub async fn melt_quote(data_dir: &Path, bolt11: &str) -> Result<super::mint_client::MeltQuote> {
let wallet = load_wallet(data_dir).await?;
let client = MintClient::new(&wallet.mint_url)?;
let client = mint_client(data_dir, &wallet.mint_url).await?;
client.melt_quote(bolt11).await
}
@@ -460,7 +474,7 @@ pub async fn melt_quote(data_dir: &Path, bolt11: &str) -> Result<super::mint_cli
pub async fn melt_tokens(data_dir: &Path, quote_id: &str, bolt11: &str) -> Result<u64> {
let mut wallet = load_wallet(data_dir).await?;
let mint_url = wallet.mint_url.clone();
let client = MintClient::new(&mint_url)?;
let client = mint_client(data_dir, &mint_url).await?;
// Get the melt quote to know the amount needed
let quote = client.melt_quote(bolt11).await?;
@@ -583,8 +597,8 @@ pub async fn swap_between_mints(
);
}
let from = MintClient::new(from_mint)?;
let to = MintClient::new(to_mint)?;
let from = mint_client(data_dir, from_mint).await?;
let to = mint_client(data_dir, to_mint).await?;
// 1. Mint quote on the target → invoice to pay.
let mint_quote = to
@@ -722,13 +736,13 @@ async fn wait_for_mint_quote_paid(client: &MintClient, quote_id: &str) -> Result
)
}
/// Create a cashuA token string to send to a peer, drawing from the home mint.
/// Create an ecash token string to send to a peer, drawing from the home mint.
pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result<String> {
let mint_url = load_wallet(data_dir).await?.mint_url;
send_token_at(data_dir, &mint_url, amount_sats).await
}
/// Create a cashuA token denominated in a specific mint's tokens.
/// Create an ecash token denominated in a specific mint's tokens.
///
/// Used by the payer-side cross-mint flow: after `swap_between_mints` lands value
/// on the seeder's accepted mint, we send a token from *that* mint so the seeder
@@ -755,7 +769,7 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) ->
// If there's overpayment, swap to get exact change
let send_proofs = if overpayment > 0 {
let client = MintClient::new(&mint_url)?;
let client = mint_client(data_dir, &mint_url).await?;
let send_denoms = amount_to_denominations(amount_sats);
let change_denoms = amount_to_denominations(overpayment);
@@ -804,9 +818,20 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) ->
selected_proofs
};
// Serialize as cashuA token
// Emit cashuB (V4) — what Minibits, Nutstash and cdk-cli read by default.
// cashuA stays the fallback rather than the default: it is still valid and
// every wallet accepts it, so a token this wallet cannot express in V4 is
// worth sending in V3 rather than failing the send outright. The warning
// exists so that never happens silently — at this point in `send_token_at`
// the proofs are already marked spent.
let token = CashuToken::new(&mint_url, send_proofs);
let token_str = token.serialize()?;
let token_str = match token.serialize_v4() {
Ok(v4) => v4,
Err(e) => {
warn!("Falling back to a cashuA token — cashuB encoding failed: {e:#}");
token.serialize()?
}
};
wallet.record_tx(
TransactionType::Send,
@@ -898,7 +923,7 @@ fn plan_payment(
PaymentPlan::Insufficient
}
/// Build a cashuA token to pay a seeder `amount_sats`, denominated in one of the
/// Build an ecash token to pay a seeder `amount_sats`, denominated in one of the
/// seeder's `accepted_mints`. Auto-swaps across mints (up to `max_fee_sats`) when
/// we don't already hold the right mint. Returns the token string ready to send.
///
@@ -1018,7 +1043,7 @@ pub async fn resume_pending_swaps(data_dir: &Path) -> Result<u64> {
let pending = load_pending_swaps(data_dir).await?;
let mut reclaimed = 0u64;
for swap in pending {
let to = match MintClient::new(&swap.to_mint) {
let to = match mint_client(data_dir, &swap.to_mint).await {
Ok(c) => c,
Err(e) => {
warn!(
@@ -1151,7 +1176,7 @@ fn target_liquidity_score(liq: &SwapLiquidity, to_mint: &str) -> i64 {
.sum()
}
/// Receive a cashuA token from a peer — swaps proofs at the mint for fresh ones.
/// Receive a Cashu token from a peer — swaps proofs at the mint for fresh ones.
pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
// Handle legacy format for backwards compatibility
if token_str.starts_with("cashuSend_") {
@@ -1184,7 +1209,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
// Swap proofs at each mint
for entry in &token.token {
let client = MintClient::new(&entry.mint)?;
let client = mint_client(data_dir, &entry.mint).await?;
match client.receive_token(&token).await {
Ok(new_proofs) => {
let amount: u64 = new_proofs.iter().map(|p| p.amount).sum();
@@ -1300,7 +1325,7 @@ pub async fn verify_and_receive_payment(
return Ok(received);
}
// Parse and validate cashuA token
// Parse and validate the token (cashuA or cashuB)
let token = CashuToken::deserialize(token_str)?;
let total = token.total_amount();
@@ -1325,7 +1350,7 @@ pub async fn verify_and_receive_payment(
let mut received_total = 0u64;
for entry in &token.token {
let client = MintClient::new(&entry.mint)?;
let client = mint_client(data_dir, &entry.mint).await?;
let entry_total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
let target_amounts = amount_to_denominations(entry_total);
@@ -1361,6 +1386,232 @@ pub async fn verify_and_receive_payment(
Ok(received_total)
}
// ── Restore from the NUT-13 phrase ─────────────────────────────────────────
/// How many counters to probe per `/v1/restore` call.
const RESTORE_BATCH: u32 = 100;
/// How many consecutive empty batches end a keyset's scan.
///
/// Counters are consumed in order but gaps happen: a reservation is persisted
/// before the mint call, so any failed mint or swap burns its counters. Three
/// empty batches is 300 unused counters in a row — far beyond any realistic
/// run of failures, while still terminating quickly on a fresh wallet.
const RESTORE_GAP_BATCHES: u32 = 3;
/// What a restore found.
#[derive(Debug, Default, Clone, serde::Serialize)]
pub struct RestoreOutcome {
/// Sats recovered and added to the wallet.
pub recovered_sats: u64,
/// Proofs added.
pub recovered_proofs: usize,
/// Proofs the mint had signed but which are already spent — the wallet's
/// history, not its balance. Reported because "found nothing" and "found
/// only coins you already spent" mean very different things to someone
/// staring at an empty balance.
pub already_spent: usize,
/// Keysets scanned at the mint.
pub keysets_scanned: usize,
}
/// Rebuild this wallet's proofs from its NUT-13 phrase by asking a mint which
/// of the re-derived secrets it has signed.
///
/// This is the half of the backup that cannot be done offline. The phrase
/// re-derives every secret the wallet ever used, but a secret alone is not
/// money — the mint's signature over it is. `/v1/restore` returns those
/// signatures, and unblinding them reconstitutes the proofs.
///
/// Additive and idempotent by design: proofs already in the wallet are skipped
/// by secret, and anything the mint reports as spent is counted but not added.
/// So a restore can be run against a *working* wallet without duplicating
/// coins or resurrecting spent ones, which matters because the most likely
/// time to press this button is when something already looks wrong.
pub async fn restore_from_seed(data_dir: &Path, mint_url: &str) -> Result<RestoreOutcome> {
let recovery = RecoverySource::load(data_dir).await.ok_or_else(|| {
anyhow::anyhow!(
"This wallet has no backup phrase yet, so there is nothing to restore from. \
Set one up in Settings → Ecash backup phrase."
)
})?;
let client = MintClient::new(mint_url)?;
// Every keyset, not just the active one: coins signed by a retired keyset
// are still spendable, and skipping it would leave them behind.
let keysets: Vec<_> = client
.get_keysets()
.await
.context("Could not list the mint's keysets")?
.into_iter()
.collect();
let mut wallet = load_wallet(data_dir).await?;
let known_secrets: std::collections::HashSet<String> = wallet
.proofs
.iter()
.map(|p| p.proof.secret.clone())
.collect();
let mut outcome = RestoreOutcome::default();
let mut found: Vec<Proof> = Vec::new();
for keyset in &keysets {
// The mint's public keys for this keyset — needed to unblind.
let keys = match client.get_keyset(&keyset.id).await {
Ok(k) => k,
Err(e) => {
warn!("Skipping keyset {} during restore: {e:#}", keyset.id);
continue;
}
};
if !keys.unit.eq_ignore_ascii_case("sat") {
continue;
}
outcome.keysets_scanned += 1;
let mut counter = 0u32;
let mut empty_batches = 0u32;
let mut highest_seen: Option<u32> = None;
while empty_batches < RESTORE_GAP_BATCHES {
// Re-derive this batch's outputs. The amount is deliberately 0:
// the mint matches a restore on the blinded message `B_` alone and
// returns the true amount in its signature — we do not know what
// denomination each counter was used for, and guessing would be
// wrong for most of them.
let mut derived = Vec::with_capacity(RESTORE_BATCH as usize);
let mut outputs = Vec::with_capacity(RESTORE_BATCH as usize);
for i in 0..RESTORE_BATCH {
let n = counter + i;
let (secret, r) = match recovery.derive_at(&keyset.id, n) {
Ok(pair) => pair,
// A keyset id NUT-13 cannot address — nothing was ever
// derived for it, so there is nothing to find.
Err(e) => {
debug!("Cannot derive for keyset {}: {e:#}", keyset.id);
break;
}
};
let blinded = super::bdhke::blind_message(&secret, &r)?;
outputs.push(super::cashu::BlindedMessageRequest {
amount: 0,
id: keyset.id.clone(),
b_prime: hex::encode(blinded.b_prime.serialize()),
});
derived.push((n, secret, r, hex::encode(blinded.b_prime.serialize())));
}
if outputs.is_empty() {
break;
}
let restored = client.restore(&outputs).await?;
if restored.is_empty() {
empty_batches += 1;
counter += RESTORE_BATCH;
continue;
}
empty_batches = 0;
for (b_prime, sig) in restored {
let Some((n, secret, r, _)) = derived.iter().find(|(_, _, _, b)| *b == b_prime)
else {
warn!("Mint restored an output we did not send — ignoring");
continue;
};
let mint_key = match keys.key_for_amount(sig.amount) {
Ok(k) => k,
Err(e) => {
warn!("Restored a {} sat output with no matching key: {e:#}", sig.amount);
continue;
}
};
let c_prime = sig.c_prime_as_pubkey()?;
let c = super::bdhke::unblind_signature(&c_prime, r, &mint_key)?;
highest_seen = Some(highest_seen.map_or(*n, |h: u32| h.max(*n)));
let secret = String::from_utf8_lossy(secret).to_string();
if known_secrets.contains(&secret) {
continue; // already in the wallet
}
found.push(Proof {
amount: sig.amount,
id: keyset.id.clone(),
secret,
c: hex::encode(c.serialize()),
});
}
counter += RESTORE_BATCH;
}
// Never hand out a counter this keyset has already used. The scan may
// have found coins beyond where the counter file thought we were —
// reusing those would mint proofs that collide with existing ones.
if let Some(highest) = highest_seen {
if let Err(e) =
super::nut13::advance_counter_to(data_dir, &keyset.id, highest + 1).await
{
warn!("Could not advance the NUT-13 counter after restore: {e:#}");
}
}
}
if found.is_empty() {
return Ok(outcome);
}
// Only unspent proofs are money. The mint signed every one of these at
// some point, including the ones already spent — adding those would
// inflate the balance with coins that fail on first use.
let states = client
.check_state(&found)
.await
.context("Could not check which restored coins are still unspent")?;
// NUT-07 answers in request order. Insist on that rather than assuming it:
// a mismatched length would pair a proof with someone else's verdict and
// credit spent coins as spendable.
if states.len() != found.len() {
anyhow::bail!(
"Mint returned {} proof states for {} restored coins — refusing to \
decide which are spendable",
states.len(),
found.len()
);
}
let mut keep = Vec::new();
for (proof, state) in found.iter().zip(states.iter()) {
if state.state.eq_ignore_ascii_case("UNSPENT") {
keep.push(proof.clone());
} else {
outcome.already_spent += 1;
}
}
outcome.recovered_sats = keep.iter().map(|p| p.amount).sum();
outcome.recovered_proofs = keep.len();
if !keep.is_empty() {
wallet.add_proofs(mint_url, keep);
wallet.record_tx(
TransactionType::Receive,
outcome.recovered_sats,
&format!(
"Restored {} sats from the backup phrase",
outcome.recovered_sats
),
mint_url,
"",
);
save_wallet(data_dir, &wallet).await?;
info!(
"Restored {} sats ({} proofs) from the ecash backup phrase",
outcome.recovered_sats, outcome.recovered_proofs
);
}
Ok(outcome)
}
/// Check the wallet balance.
pub async fn get_balance(data_dir: &Path) -> Result<u64> {
let wallet = load_wallet(data_dir).await?;
+173 -31
View File
@@ -12,9 +12,11 @@ use super::cashu::{
amount_to_denominations, is_truncated_v2_keyset_id, BlindSignature, BlindedMessageRequest,
CashuToken, KeysetInfo, MintKeyset, Proof,
};
use super::nut13::RecoverySource;
use anyhow::{Context, Result};
use bitcoin::secp256k1;
use serde::{Deserialize, Serialize};
use tracing::debug;
use tracing::{debug, warn};
/// Default timeout for mint API calls.
const MINT_TIMEOUT_SECS: u64 = 10;
@@ -130,10 +132,19 @@ fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Erro
pub struct MintClient {
url: String,
client: reqwest::Client,
/// NUT-13 output source. When set, every proof this client creates has a
/// secret derived from the wallet's phrase and is therefore restorable;
/// when absent, secrets are random and live only in `wallet/ecash.json`.
recovery: Option<RecoverySource>,
}
impl MintClient {
/// Create a new mint client for the given mint URL.
///
/// Proofs minted through a client built this way are **not** recoverable
/// from the wallet phrase. Prefer `ecash::mint_client`, which attaches the
/// NUT-13 source; this stays for callers with no data directory (probes,
/// keyset lookups, tests).
pub fn new(mint_url: &str) -> Result<Self> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(MINT_TIMEOUT_SECS))
@@ -143,6 +154,7 @@ impl MintClient {
Ok(Self {
url: mint_url.trim_end_matches('/').to_string(),
client,
recovery: None,
})
}
@@ -151,13 +163,67 @@ impl MintClient {
Self {
url: mint_url.trim_end_matches('/').to_string(),
client,
recovery: None,
}
}
/// Derive this client's blinded outputs from the wallet's NUT-13 phrase,
/// so the proofs it creates can be restored from those words.
pub fn with_recovery(mut self, recovery: Option<RecoverySource>) -> Self {
self.recovery = recovery;
self
}
pub fn url(&self) -> &str {
&self.url
}
/// Build the blinded messages for a batch of output amounts, together with
/// the `(secret, blinding factor, amount)` needed to unblind the mint's
/// signatures afterwards.
///
/// Prefers NUT-13 derivation so the resulting proofs are restorable. Falls
/// back to random secrets when this wallet has no phrase yet, or when the
/// keyset id is one NUT-13 cannot address — a random secret still mints a
/// perfectly valid, spendable proof, so refusing here would break the
/// wallet to protect a backup that does not exist.
async fn blinded_outputs(
&self,
keyset_id: &str,
amounts: &[u64],
) -> Result<(Vec<BlindedMessageRequest>, Vec<(Vec<u8>, secp256k1::SecretKey, u64)>)> {
let derived = match &self.recovery {
Some(source) => match source.next_outputs(keyset_id, amounts.len()).await {
Ok(pairs) => Some(pairs),
Err(e) => {
warn!("Minting unrecoverable proofs — NUT-13 derivation failed: {e:#}");
None
}
},
None => None,
};
let mut blinded_messages = Vec::with_capacity(amounts.len());
let mut blinding_data = Vec::with_capacity(amounts.len());
for (i, &amount) in amounts.iter().enumerate() {
let (secret, r) = match &derived {
Some(pairs) => pairs[i].clone(),
None => (bdhke::generate_secret(), bdhke::random_blinding_factor()),
};
let blinded = bdhke::blind_message(&secret, &r)?;
blinded_messages.push(BlindedMessageRequest {
amount,
id: keyset_id.to_string(),
b_prime: hex::encode(blinded.b_prime.serialize()),
});
blinding_data.push((secret, r, amount));
}
Ok((blinded_messages, blinding_data))
}
// ── Keyset discovery (NUT-01, NUT-02) ──
/// Fetch the active keyset from the mint.
@@ -210,6 +276,36 @@ impl MintClient {
Ok(keysets)
}
/// Fetch one keyset's public keys by id (NUT-01 `GET /v1/keys/{id}`).
///
/// `/v1/keys` returns only what the mint will still *sign* with, but a
/// restore has to unblind signatures made by keysets that have since been
/// retired — those coins are still spendable, and skipping their keysets
/// would quietly leave money behind.
pub async fn get_keyset(&self, keyset_id: &str) -> Result<MintKeyset> {
let url = format!("{}/v1/keys/{}", self.url, keyset_id);
let res = self
.client
.get(&url)
.send()
.await
.context("Failed to fetch a mint keyset")?;
if !res.status().is_success() {
anyhow::bail!("Mint keyset request failed: {}", res.status());
}
let body: serde_json::Value = res.json().await.context("Failed to parse mint keyset")?;
let keysets: Vec<MintKeyset> = serde_json::from_value(
body.get("keysets")
.cloned()
.unwrap_or(serde_json::json!([])),
)
.context("Failed to parse keyset")?;
keysets
.into_iter()
.find(|k| k.id == keyset_id)
.ok_or_else(|| anyhow::anyhow!("Mint did not return keyset {keyset_id}"))
}
/// Get the active keyset for the "sat" unit.
pub async fn get_active_sat_keyset(&self) -> Result<MintKeyset> {
let keysets = self.get_keys().await?;
@@ -276,21 +372,8 @@ impl MintClient {
let keyset = self.get_active_sat_keyset().await?;
let denominations = amount_to_denominations(amount);
let mut blinded_messages = Vec::new();
let mut blinding_data = Vec::new(); // (secret, blinding_factor, amount)
for &denom in &denominations {
let secret = bdhke::generate_secret();
let r = bdhke::random_blinding_factor();
let blinded = bdhke::blind_message(&secret, &r)?;
blinded_messages.push(BlindedMessageRequest {
amount: denom,
id: keyset.id.clone(),
b_prime: hex::encode(blinded.b_prime.serialize()),
});
blinding_data.push((secret, r, denom));
}
let (blinded_messages, blinding_data) =
self.blinded_outputs(&keyset.id, &denominations).await?;
let url = format!("{}/v1/mint/bolt11", self.url);
let client = reqwest::Client::builder()
@@ -434,21 +517,8 @@ impl MintClient {
target_amounts
};
let mut blinded_messages = Vec::new();
let mut blinding_data = Vec::new();
for &amount in target_amounts {
let secret = bdhke::generate_secret();
let r = bdhke::random_blinding_factor();
let blinded = bdhke::blind_message(&secret, &r)?;
blinded_messages.push(BlindedMessageRequest {
amount,
id: keyset.id.clone(),
b_prime: hex::encode(blinded.b_prime.serialize()),
});
blinding_data.push((secret, r, amount));
}
let (blinded_messages, blinding_data) =
self.blinded_outputs(&keyset.id, target_amounts).await?;
let url = format!("{}/v1/swap", self.url);
let res = self
@@ -543,6 +613,78 @@ impl MintClient {
Ok(states)
}
// ── Restore (NUT-09) ──
/// Ask the mint which of a batch of blinded messages it has signed before,
/// and hand back its signatures for those.
///
/// This is the half of the backup story the mint owns. A NUT-13 phrase can
/// re-derive every secret this wallet ever used, but not the mint's
/// signature over them — without that a re-derived secret is not yet money.
/// `/v1/restore` closes the gap: send the blinded messages again, get back
/// the signatures the mint already issued, unblind, and the proofs exist
/// again.
///
/// The response echoes the subset of `outputs` it recognised alongside the
/// matching `signatures`, so the caller matches on `B_` rather than
/// assuming positions line up — mints are free to return fewer, and
/// assuming otherwise would pair a signature with the wrong secret and
/// silently produce unspendable proofs.
pub async fn restore(
&self,
outputs: &[BlindedMessageRequest],
) -> Result<Vec<(String, BlindSignature)>> {
if outputs.is_empty() {
return Ok(Vec::new());
}
let url = format!("{}/v1/restore", self.url);
let res = self
.client
.post(&url)
.json(&serde_json::json!({ "outputs": outputs }))
.send()
.await
.context("Failed to ask the mint to restore outputs")?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
return Err(mint_error("Restore", status, &body));
}
let body: serde_json::Value = res
.json()
.await
.context("Failed to parse the mint's restore response")?;
let echoed: Vec<BlindedMessageRequest> = serde_json::from_value(
body.get("outputs")
.cloned()
.unwrap_or(serde_json::json!([])),
)
.context("Failed to parse restored outputs")?;
let signatures: Vec<BlindSignature> = serde_json::from_value(
body.get("signatures")
.cloned()
.unwrap_or(serde_json::json!([])),
)
.context("Failed to parse restored signatures")?;
if echoed.len() != signatures.len() {
anyhow::bail!(
"Mint restored {} outputs but {} signatures — refusing to pair them",
echoed.len(),
signatures.len()
);
}
Ok(echoed
.into_iter()
.map(|o| o.b_prime)
.zip(signatures)
.collect())
}
/// Receive a CashuToken by swapping its proofs for fresh ones.
/// This prevents double-spend and ensures only we can spend the new proofs.
/// Repair proofs whose keyset id is a truncated NUT-02 **v2** id.
+1
View File
@@ -7,4 +7,5 @@ pub mod cashu;
pub mod ecash;
pub mod fedimint_client;
pub mod mint_client;
pub mod nut13;
pub mod profits;
+552
View File
@@ -0,0 +1,552 @@
//! NUT-13 deterministic secrets — what makes the ecash wallet restorable.
//!
//! Until this module existed, 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: there was no phrase to write
//! down, and no amount of talking to the mint could reconstruct them. Ecash is
//! a bearer instrument, so "one file, no backup" was the sharpest edge in the
//! wallet.
//!
//! [NUT-13] fixes that by deriving each proof's secret and blinding factor
//! from `(wallet seed, keyset id, counter)` instead of from randomness. The
//! wallet is then a *phrase*, and the coins can be re-derived and re-claimed
//! from the mint — here, or in any other NUT-13 wallet.
//!
//! Three pieces live here:
//!
//! - **The wallet seed** (`wallet/cashu_seed.json`) — a 24-word BIP-39
//! mnemonic derived from the node's master seed, so the node's own recovery
//! phrase already covers the ecash. See [`crate::seed::derive_cashu_mnemonic`]
//! for why it is a *separate* phrase rather than the node's own.
//! - **The counters** (`wallet/cashu_counters.json`) — the next unused counter
//! per keyset. Recovery metadata, not funds: losing it costs a restore scan,
//! never coins.
//! - **The derivation itself** — delegated to the reference implementation, so
//! the secrets a third-party wallet re-derives from these words are the same
//! ones we did.
//!
//! ## Why the seed sits on disk in the clear
//!
//! The node's master seed is encrypted at rest and needs the operator's
//! password to open, which no background mint/swap can ask for. This file is
//! not encrypted, and that is deliberate: it lives in the same directory as
//! `wallet/ecash.json`, which already holds spendable bearer secrets in
//! plaintext. A NUT-13 seed regenerates exactly those same secrets, so it is
//! the same sensitivity class as the file beside it — encrypting one and not
//! the other would buy nothing. It is written 0600, matching
//! `identity/nostr_secret`, which is derived and persisted the same way.
//!
//! [NUT-13]: https://github.com/cashubtc/nuts/blob/main/13.md
use anyhow::{Context, Result};
use bitcoin::secp256k1::SecretKey;
use cashu::nuts::nut01::SecretKey as CdkSecretKey;
use cashu::nuts::nut02::Id as CdkId;
use cashu::secret::Secret as CdkSecret;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use tokio::fs;
use tracing::{debug, warn};
/// The wallet's BIP-39 phrase. One file for both networks: NUT-13 derivation
/// is keyed by keyset id, and a testnet mint's keysets never collide with a
/// real mint's, so the two purses cannot derive each other's secrets.
const SEED_FILE: &str = "wallet/cashu_seed.json";
/// Next-unused counter per keyset.
const COUNTER_FILE: &str = "wallet/cashu_counters.json";
/// Serialises counter reservation within this process. Reservation is a
/// read-modify-write of one small file, and two concurrent mints handing out
/// the same counter would mean two proofs with the same secret — the mint
/// signs both and only one is ever spendable.
static COUNTER_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
/// On-disk shape of `wallet/cashu_seed.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct StoredSeed {
/// The 24-word BIP-39 phrase.
mnemonic: String,
/// How this wallet got its phrase — see [`SeedSource`].
#[serde(default)]
source: SeedSource,
/// When it was first written, for the operator's benefit.
#[serde(default)]
created_at: String,
}
/// Where an ecash wallet's phrase came from, which decides what restoring the
/// *node* gets you back.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SeedSource {
/// Derived from the node's master seed. The node's 24 words restore this
/// ecash wallet too — nothing extra to write down.
#[default]
NodeSeed,
/// Generated independently of the node seed. Still a perfectly good
/// NUT-13 wallet, but restoring the node from its recovery phrase will
/// *not* bring it back — only these words will.
Independent,
}
/// A loaded ecash wallet seed, ready to derive secrets from.
#[derive(Clone)]
pub struct EcashSeed {
/// BIP-39 seed bytes — the NUT-13 input.
seed: [u8; 64],
mnemonic: bip39::Mnemonic,
source: SeedSource,
}
impl std::fmt::Debug for EcashSeed {
/// Never let the phrase or the seed bytes reach a log line.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EcashSeed")
.field("source", &self.source)
.finish_non_exhaustive()
}
}
impl EcashSeed {
fn from_mnemonic(mnemonic: bip39::Mnemonic, source: SeedSource) -> Self {
Self {
seed: mnemonic.to_seed(""),
mnemonic,
source,
}
}
/// The 24 words, for the backup screen. Everything else about this type
/// keeps them out of reach.
pub fn words(&self) -> Vec<String> {
self.mnemonic.words().map(|w| w.to_string()).collect()
}
pub fn source(&self) -> SeedSource {
self.source
}
/// Derive the NUT-13 secret and blinding factor for one output.
///
/// Delegated to the reference implementation rather than reimplemented:
/// NUT-13 uses BIP-32 for v1 keyset ids and an HMAC-SHA256 KDF for v2, and
/// getting either subtly wrong yields a wallet whose words restore
/// *nothing* — a failure that only shows up on the day it matters.
pub fn derive_output(&self, keyset_id: &str, counter: u32) -> Result<(Vec<u8>, SecretKey)> {
let id = CdkId::from_str(keyset_id)
.with_context(|| format!("Keyset id {keyset_id} is not one NUT-13 can derive for"))?;
let secret = CdkSecret::from_seed(&self.seed, id, counter)
.context("NUT-13 secret derivation failed")?;
let blinding = CdkSecretKey::from_seed(&self.seed, id, counter)
.context("NUT-13 blinding-factor derivation failed")?;
let blinding = SecretKey::from_slice(&blinding.to_secret_bytes())
.context("NUT-13 produced a blinding factor secp256k1 rejects")?;
Ok((secret.to_bytes(), blinding))
}
}
impl Drop for EcashSeed {
fn drop(&mut self) {
use zeroize::Zeroize;
self.seed.zeroize();
}
}
fn seed_path(data_dir: &Path) -> PathBuf {
data_dir.join(SEED_FILE)
}
/// Is this wallet backed by a phrase yet?
pub fn seed_exists(data_dir: &Path) -> bool {
seed_path(data_dir).exists()
}
/// Load the wallet seed, or `None` if this node has never established one.
///
/// A *damaged* seed file is an error, not a `None`: silently treating it as
/// "no seed" would send the wallet back to unrecoverable random secrets while
/// telling the operator their backup was fine.
pub async fn load_seed(data_dir: &Path) -> Result<Option<EcashSeed>> {
let path = seed_path(data_dir);
let Ok(content) = fs::read_to_string(&path).await else {
return Ok(None);
};
let stored: StoredSeed = serde_json::from_str(&content)
.with_context(|| format!("The ecash seed file is damaged: {}", path.display()))?;
let mnemonic: bip39::Mnemonic = stored
.mnemonic
.parse()
.map_err(|e| anyhow::anyhow!("The stored ecash phrase is not valid BIP-39: {e}"))?;
Ok(Some(EcashSeed::from_mnemonic(mnemonic, stored.source)))
}
/// Establish the wallet seed from the node's master seed, writing it if this
/// node does not have one yet.
///
/// Idempotent, and deliberately **never overwrites**: an existing phrase is
/// the only thing that can re-derive the proofs already minted under it, so a
/// re-derivation that disagreed (a different master seed after a restore from
/// different words, say) must not be allowed to replace it. The existing seed
/// is returned instead, and the mismatch is logged.
pub async fn establish_from_master(
data_dir: &Path,
master: &crate::seed::MasterSeed,
) -> Result<EcashSeed> {
let derived = crate::seed::derive_cashu_mnemonic(master)?;
if let Some(existing) = load_seed(data_dir).await? {
if existing.mnemonic != derived {
warn!(
"The ecash wallet's phrase does not match the one this node's master seed \
derives — keeping the existing phrase, because it is what the current \
proofs were minted under. Back it up from Settings; the node's own \
recovery phrase does not cover this wallet."
);
}
return Ok(existing);
}
write_seed(data_dir, &derived, SeedSource::NodeSeed).await?;
debug!("Established the ecash wallet seed from the node master seed");
Ok(EcashSeed::from_mnemonic(derived, SeedSource::NodeSeed))
}
/// Write the seed file at 0600, creating the wallet directory if needed.
async fn write_seed(
data_dir: &Path,
mnemonic: &bip39::Mnemonic,
source: SeedSource,
) -> Result<()> {
let path = seed_path(data_dir);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.await
.context("Failed to create the wallet directory")?;
}
let stored = StoredSeed {
mnemonic: mnemonic.to_string(),
source,
created_at: chrono::Utc::now().to_rfc3339(),
};
let content =
serde_json::to_string_pretty(&stored).context("Failed to serialize the ecash seed")?;
fs::write(&path, content)
.await
.context("Failed to write the ecash seed")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
.await
.context("Failed to restrict permissions on the ecash seed")?;
}
Ok(())
}
// ── Counters ───────────────────────────────────────────────────────────────
/// On-disk shape of `wallet/cashu_counters.json`.
#[derive(Debug, Default, Serialize, Deserialize)]
struct StoredCounters {
/// keyset id → next unused counter.
#[serde(default)]
counters: BTreeMap<String, u32>,
}
/// Reserve `count` consecutive counters for `keyset_id` and return the first.
///
/// Written to disk **before** the outputs are used, and never rolled back on
/// failure. A gap in the sequence costs a restore scan a few extra probes; a
/// *reused* counter costs a coin, because two proofs with the same secret can
/// only ever be spent once. So the asymmetry is resolved in favour of gaps.
pub async fn reserve_counters(data_dir: &Path, keyset_id: &str, count: usize) -> Result<u32> {
let _guard = COUNTER_LOCK.lock().await;
let path = data_dir.join(COUNTER_FILE);
let mut state: StoredCounters = match fs::read_to_string(&path).await {
Ok(content) if !content.trim().is_empty() => serde_json::from_str(&content)
.with_context(|| format!("The ecash counter file is damaged: {}", path.display()))?,
_ => StoredCounters::default(),
};
let start = *state.counters.get(keyset_id).unwrap_or(&0);
let next = start
.checked_add(u32::try_from(count).context("Absurd output count")?)
.context("NUT-13 counter space exhausted for this keyset")?;
state.counters.insert(keyset_id.to_string(), next);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.await
.context("Failed to create the wallet directory")?;
}
let content =
serde_json::to_string_pretty(&state).context("Failed to serialize ecash counters")?;
fs::write(&path, content)
.await
.context("Failed to persist ecash counters")?;
Ok(start)
}
/// Read the next-unused counter for a keyset without reserving anything.
pub async fn counter_for(data_dir: &Path, keyset_id: &str) -> u32 {
let path = data_dir.join(COUNTER_FILE);
let Ok(content) = fs::read_to_string(&path).await else {
return 0;
};
serde_json::from_str::<StoredCounters>(&content)
.ok()
.and_then(|s| s.counters.get(keyset_id).copied())
.unwrap_or(0)
}
/// Move a keyset's counter forward to at least `next`, so a restore that found
/// coins beyond the recorded point cannot hand the same counters out again.
pub async fn advance_counter_to(data_dir: &Path, keyset_id: &str, next: u32) -> Result<()> {
let current = counter_for(data_dir, keyset_id).await;
if next > current {
reserve_counters(data_dir, keyset_id, (next - current) as usize).await?;
}
Ok(())
}
// ── The source handed to the mint client ───────────────────────────────────
/// Supplies NUT-13 outputs to [`crate::wallet::mint_client::MintClient`].
///
/// Holds the data directory as well as the seed because reserving a counter is
/// a disk write that has to happen before the outputs are handed out.
#[derive(Clone, Debug)]
pub struct RecoverySource {
seed: EcashSeed,
data_dir: PathBuf,
}
impl RecoverySource {
/// Build a recovery source for this node, or `None` when the wallet has no
/// seed yet. Callers fall back to random secrets in that case, which is
/// exactly the pre-NUT-13 behaviour — correct, just not restorable.
pub async fn load(data_dir: &Path) -> Option<Self> {
match load_seed(data_dir).await {
Ok(Some(seed)) => Some(Self {
seed,
data_dir: data_dir.to_path_buf(),
}),
Ok(None) => None,
Err(e) => {
warn!("Ecash wallet seed unusable, minting unrecoverable proofs: {e:#}");
None
}
}
}
/// Reserve and derive `count` outputs for `keyset_id`.
pub async fn next_outputs(
&self,
keyset_id: &str,
count: usize,
) -> Result<Vec<(Vec<u8>, SecretKey)>> {
// Fail the derivation *before* burning counters if this keyset id is
// one NUT-13 cannot address.
let start = reserve_counters(&self.data_dir, keyset_id, count).await?;
(0..count)
.map(|i| self.seed.derive_output(keyset_id, start + i as u32))
.collect()
}
/// Derive one output at an explicit counter, without reserving — the
/// restore scan's probe, which must be able to re-derive the past.
pub fn derive_at(&self, keyset_id: &str, counter: u32) -> Result<(Vec<u8>, SecretKey)> {
self.seed.derive_output(keyset_id, counter)
}
pub fn data_dir(&self) -> &Path {
&self.data_dir
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::seed::MasterSeed;
const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art";
/// A real NUT-02 v1 keyset id (the one in the NUT test vectors).
const V1_KEYSET: &str = "009a1f293253e41e";
/// A NUT-02 v2 keyset id — 33 bytes, version byte 0x01. The two versions
/// take different derivation paths in the spec, so both need covering.
const V2_KEYSET: &str = "01fc0ec0e59cd6fa01b7a88f8cd77fce81fd1e64bca67d752e984992b7a3c3a821";
fn seed() -> EcashSeed {
let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
let mnemonic = crate::seed::derive_cashu_mnemonic(&master).unwrap();
EcashSeed::from_mnemonic(mnemonic, SeedSource::NodeSeed)
}
/// The whole promise of NUT-13: the same phrase and counter must give back
/// the same secret, or a restore finds nothing.
#[test]
fn the_same_phrase_and_counter_rederive_the_same_output() {
let a = seed();
let b = seed();
for keyset in [V1_KEYSET, V2_KEYSET] {
let (s1, r1) = a.derive_output(keyset, 7).unwrap();
let (s2, r2) = b.derive_output(keyset, 7).unwrap();
assert_eq!(s1, s2, "secret must be reproducible ({keyset})");
assert_eq!(
r1.secret_bytes(),
r2.secret_bytes(),
"blinding factor must be reproducible ({keyset})"
);
}
}
/// Different counters — and different keysets — must not collide, or two
/// proofs would share a secret and only one could ever be spent.
#[test]
fn different_counters_and_keysets_give_different_outputs() {
let s = seed();
let (a, _) = s.derive_output(V1_KEYSET, 0).unwrap();
let (b, _) = s.derive_output(V1_KEYSET, 1).unwrap();
let (c, _) = s.derive_output(V2_KEYSET, 0).unwrap();
assert_ne!(a, b, "counter must separate secrets");
assert_ne!(a, c, "keyset must separate secrets");
}
/// The secret must look like the one the rest of the wallet expects: a
/// 32-byte value, hex-encoded, carried as ASCII bytes — the same shape
/// `bdhke::generate_secret` produces.
#[test]
fn a_derived_secret_has_the_shape_the_wallet_already_uses() {
let (secret, _) = seed().derive_output(V1_KEYSET, 0).unwrap();
assert_eq!(secret.len(), 64, "32 bytes, hex-encoded");
let text = String::from_utf8(secret).expect("secret must be ASCII hex");
assert!(hex::decode(&text).is_ok(), "{text}");
}
/// A truncated v2 id cannot address a keyset, and must fail loudly rather
/// than deriving from a prefix that means nothing.
#[test]
fn an_unaddressable_keyset_id_is_refused() {
let err = seed()
.derive_output("01fc0ec0e59cd6fa", 0)
.expect_err("short v2 id must not derive");
assert!(err.to_string().contains("NUT-13"), "{err}");
}
#[tokio::test]
async fn counters_are_reserved_in_order_and_never_reused() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
assert_eq!(reserve_counters(d, V1_KEYSET, 3).await.unwrap(), 0);
assert_eq!(reserve_counters(d, V1_KEYSET, 2).await.unwrap(), 3);
assert_eq!(counter_for(d, V1_KEYSET).await, 5);
// A second keyset counts independently.
assert_eq!(reserve_counters(d, V2_KEYSET, 1).await.unwrap(), 0);
assert_eq!(counter_for(d, V1_KEYSET).await, 5);
}
/// Reservation must survive a process restart — the file is the state.
#[tokio::test]
async fn reserved_counters_persist_across_reloads() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
reserve_counters(d, V1_KEYSET, 4).await.unwrap();
// Nothing cached in memory: read it back cold.
assert_eq!(counter_for(d, V1_KEYSET).await, 4);
assert_eq!(reserve_counters(d, V1_KEYSET, 1).await.unwrap(), 4);
}
#[tokio::test]
async fn establishing_the_seed_is_idempotent_and_never_overwrites() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
assert!(!seed_exists(d));
let first = establish_from_master(d, &master).await.unwrap();
assert!(seed_exists(d));
assert_eq!(first.source(), SeedSource::NodeSeed);
let second = establish_from_master(d, &master).await.unwrap();
assert_eq!(first.words(), second.words());
// A *different* master seed must not replace the phrase the existing
// proofs were minted under.
let (other_words, _) = MasterSeed::generate().unwrap();
let (_, other_master) =
MasterSeed::from_mnemonic_words(&other_words.to_string()).unwrap();
let third = establish_from_master(d, &other_master).await.unwrap();
assert_eq!(
first.words(),
third.words(),
"an established ecash phrase must never be silently replaced"
);
}
#[tokio::test]
async fn the_seed_file_is_owner_only() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
establish_from_master(d, &master).await.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(seed_path(d)).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "the ecash phrase must be owner-only");
}
}
/// A damaged seed file must not read back as "this wallet has no backup" —
/// that would quietly return the wallet to unrecoverable random secrets.
#[tokio::test]
async fn a_damaged_seed_file_is_an_error_not_an_absence() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
fs::create_dir_all(d.join("wallet")).await.unwrap();
fs::write(seed_path(d), "{ truncated").await.unwrap();
assert!(load_seed(d).await.is_err());
assert!(
RecoverySource::load(d).await.is_none(),
"an unusable seed must not be presented as a working one"
);
}
#[tokio::test]
async fn the_recovery_source_hands_out_consecutive_outputs() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
establish_from_master(d, &master).await.unwrap();
let source = RecoverySource::load(d).await.expect("seed was established");
let first = source.next_outputs(V1_KEYSET, 2).await.unwrap();
let second = source.next_outputs(V1_KEYSET, 2).await.unwrap();
assert_eq!(first.len(), 2);
// Counters advanced, so no secret repeats across the two batches.
let secrets: std::collections::HashSet<_> = first
.iter()
.chain(second.iter())
.map(|(s, _)| s.clone())
.collect();
assert_eq!(secrets.len(), 4, "counters must not be handed out twice");
// And the batch is exactly what re-deriving counters 0..4 gives.
for (i, (secret, _)) in first.iter().chain(second.iter()).enumerate() {
let (expected, _) = source.derive_at(V1_KEYSET, i as u32).unwrap();
assert_eq!(secret, &expected);
}
}
}
+267
View File
@@ -0,0 +1,267 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
// Ecash (Cashu) wallet backup card — the same shape as the node recovery
// phrase and the Lightning seed cards, deliberately: a third reveal pattern
// would be a third thing to learn.
//
// Two things make this one different from those:
//
// 1. Revealing is also *activating*. The node's master seed is encrypted at
// rest, so this password prompt is the only moment the ecash phrase can be
// derived from it. Until an operator comes here once, a node that predates
// NUT-13 mints coins that no phrase can bring back — and the card says so
// rather than implying a backup already exists.
// 2. These are standard BIP-39 words for a NUT-13 wallet, so they restore in
// Minibits, Nutstash or cdk-cli. Hence `SeedRevealPanel` without `aezeed`:
// the SeedQR tab is genuinely useful here.
type SeedStatus = {
active: boolean
source: 'node-seed' | 'independent' | null
can_activate: boolean
}
const status = ref<SeedStatus | null>(null)
const statusLoaded = ref(false)
async function loadStatus() {
try {
status.value = await rpcClient.call<SeedStatus>({
method: 'wallet.ecash-seed-status',
timeout: 5000,
})
statusLoaded.value = true
} catch {
// A blip must not hide the card permanently — leave whatever we had.
}
}
onMounted(loadStatus)
const showRevealModal = ref(false)
const revealPassword = ref('')
const revealCode = ref('')
const revealPassphrase = ref('')
const revealing = ref(false)
const revealError = ref('')
const revealedWords = ref<string[]>([])
const revealedSource = ref<string | null>(null)
const wordsCopied = ref(false)
function openReveal() {
revealPassword.value = ''
revealCode.value = ''
revealPassphrase.value = ''
revealError.value = ''
revealedWords.value = []
showRevealModal.value = true
}
async function submitReveal() {
if (revealing.value || !revealPassword.value) return
revealing.value = true
revealError.value = ''
try {
const params: Record<string, string> = { password: revealPassword.value }
if (revealCode.value) params.code = revealCode.value
if (revealPassphrase.value) params.passphrase = revealPassphrase.value
const res = await rpcClient.call<{ words: string[]; source: string }>({
method: 'wallet.ecash-seed-reveal',
params,
})
revealedWords.value = res.words || []
revealedSource.value = res.source ?? null
// Activation may just have happened — refresh so the card stops offering
// to set up a backup that now exists.
void loadStatus()
} catch (e: unknown) {
revealError.value = e instanceof Error ? e.message : 'Failed to reveal the ecash phrase'
} finally {
revealing.value = false
}
}
function closeReveal() {
showRevealModal.value = false
revealedWords.value = []
revealPassword.value = ''
revealCode.value = ''
revealPassphrase.value = ''
}
async function copyRevealedWords() {
try {
await navigator.clipboard.writeText(revealedWords.value.join(' '))
wordsCopied.value = true
setTimeout(() => { wordsCopied.value = false }, 2000)
} catch { /* clipboard unavailable */ }
}
// ── Restore ────────────────────────────────────────────────────────────────
// The other half of the backup. Safe to run against a working wallet: the
// backend skips coins already held and never re-adds spent ones, so this is
// the button to reach for when the balance looks wrong, not just after a
// disaster.
const restoring = ref(false)
const restoreMsg = ref('')
const restoreError = ref('')
async function restoreFromPhrase() {
if (restoring.value) return
restoring.value = true
restoreMsg.value = ''
restoreError.value = ''
try {
const res = await rpcClient.call<{
recovered_sats: number
recovered_proofs: number
already_spent: number
keysets_scanned: number
}>({ method: 'wallet.ecash-restore', timeout: 180000 })
if (res.recovered_sats > 0) {
restoreMsg.value = `Recovered ${res.recovered_sats.toLocaleString()} sats (${res.recovered_proofs} coins).`
} else if (res.already_spent > 0) {
restoreMsg.value = `Nothing to recover — the ${res.already_spent} coin(s) found at this mint were already spent.`
} else {
restoreMsg.value = `Nothing to recover: no coins from this phrase at this mint (${res.keysets_scanned} keyset(s) checked).`
}
} catch (e: unknown) {
restoreError.value = e instanceof Error ? e.message : 'Restore failed'
} finally {
restoring.value = false
}
}
</script>
<template>
<div
v-if="statusLoaded"
class="glass-card px-6 py-6 mb-6"
:class="!status?.active ? 'border border-orange-400/40' : ''"
>
<div v-if="!status?.active" class="flex items-center gap-2 mb-3 text-orange-300 text-sm font-medium" role="alert">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86l-8.4 14.55A1.5 1.5 0 003.19 21h17.62a1.5 1.5 0 001.3-2.59l-8.4-14.55a1.5 1.5 0 00-2.62 0z" />
</svg>
Your ecash has no backup yet
</div>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<h2 class="text-xl font-semibold text-white/96 mb-1">Ecash backup phrase</h2>
<p v-if="status?.active" class="text-sm text-white/60">
Your ecash wallet has its own 24-word phrase, derived from this node's recovery
phrase — so the words you already wrote down cover your ecash too. Reveal it here
if you want to restore your ecash into another wallet (Minibits, Nutstash,
<span class="font-mono">cdk-cli</span>) without handing over the node's own seed.
</p>
<p v-else class="text-sm text-white/60">
Ecash is a bearer instrument: the coins live in a file on this node, and right now
nothing can bring them back if that file is lost. Setting up a backup phrase fixes
that for every coin minted from then on. It's derived from this node's recovery
phrase, so there's nothing new to write down.
</p>
<p v-if="status?.source === 'independent'" class="mt-2 text-xs text-orange-300/90">
This wallet's phrase was <strong>not</strong> derived from the node's recovery
phrase — restoring the node will not bring the ecash back. Write these words down
separately.
</p>
<p v-if="!status?.active && !status?.can_activate" class="mt-2 text-xs text-orange-300/90">
This node has no encrypted seed backup, so a phrase can't be derived from it.
</p>
</div>
<button
type="button"
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium"
:class="!status?.active ? 'bg-orange-500/20 border-orange-400/30' : ''"
:disabled="!status?.active && !status?.can_activate"
@click="openReveal"
>{{ status?.active ? 'Reveal' : 'Set up backup' }}</button>
</div>
<div v-if="status?.active" class="mt-4 pt-4 border-t border-white/10">
<div class="flex items-start justify-between gap-4">
<p class="text-sm text-white/60 min-w-0">
<span class="text-white/80 font-medium">Restore from this phrase.</span>
Asks your mint which coins it has signed for these words and puts back any that
are still unspent. Safe to run at any time — it never duplicates coins you already
hold.
</p>
<button
type="button"
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50"
:disabled="restoring"
@click="restoreFromPhrase"
>{{ restoring ? 'Scanning…' : 'Restore' }}</button>
</div>
<p v-if="restoreMsg" role="status" aria-live="polite" class="mt-3 text-xs alert-success px-3 py-2 rounded-lg">{{ restoreMsg }}</p>
<p v-if="restoreError" role="alert" class="mt-3 text-xs alert-error px-3 py-2 rounded-lg">{{ restoreError }}</p>
</div>
</div>
<Teleport to="body">
<div
v-if="showRevealModal"
class="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-md"
@click.self="closeReveal"
>
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="reveal-ecash-seed-title">
<h3 id="reveal-ecash-seed-title" class="text-lg font-semibold text-white mb-1">
{{ status?.active ? 'Reveal ecash phrase' : 'Set up ecash backup' }}
</h3>
<template v-if="revealedWords.length === 0">
<p class="text-sm text-white/60 mb-4">
Confirm your credentials to
{{ status?.active ? 'display the 24-word ecash phrase' : 'derive and display your ecash backup phrase' }}.
</p>
<form @submit.prevent="submitReveal" class="space-y-3">
<div>
<label class="block text-xs text-white/60 mb-1">Password</label>
<input v-model="revealPassword" type="password" autocomplete="current-password" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:border-white/30" placeholder="Your login password" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">2FA code <span class="text-white/30">(if enabled)</span></label>
<input v-model="revealCode" inputmode="numeric" autocomplete="one-time-code" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm font-mono tracking-widest focus:outline-none focus:border-white/30" placeholder="123456" />
</div>
<div v-if="!status?.active">
<label class="block text-xs text-white/60 mb-1">Backup passphrase <span class="text-white/30">(only if different from password)</span></label>
<input v-model="revealPassphrase" type="password" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:border-white/30" placeholder="Leave blank to use password" />
</div>
<p v-if="revealError" class="text-xs text-red-300 bg-red-500/10 border border-red-400/20 rounded-lg px-3 py-2">{{ revealError }}</p>
<div class="flex gap-2 pt-1">
<button type="button" @click="closeReveal" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">Cancel</button>
<button type="submit" :disabled="revealing || !revealPassword" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50">
{{ revealing ? 'Verifying…' : (status?.active ? 'Reveal' : 'Set up') }}
</button>
</div>
</form>
</template>
<template v-else>
<SeedRevealPanel :words="revealedWords" />
<p class="text-xs text-white/40 mt-3">
<template v-if="revealedSource === 'node-seed'">
Derived from this node's recovery phrase — restoring the node restores this
ecash wallet too. These words also restore it into any NUT-13 wallet.
</template>
<template v-else>
This phrase is independent of the node's recovery phrase. It is the
<strong>only</strong> way to restore this ecash wallet — write it down.
</template>
</p>
<div class="flex gap-2 pt-4">
<button type="button" @click="copyRevealedWords" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">{{ wordsCopied ? 'Copied!' : 'Copy' }}</button>
<button type="button" @click="closeReveal" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30">Done</button>
</div>
</template>
</div>
</div>
</Teleport>
</template>
+23 -96
View File
@@ -2,47 +2,16 @@
<BaseModal :show="show" :title="t('web5.sendBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
<!-- ============ SUCCESS PANE — the payment's moment, not a footnote ============ -->
<template v-if="successInfo">
<div class="text-center py-4">
<div class="send-success-badge mx-auto mb-6">
<ScreensaverRing size="badge" />
<div class="send-success-burst">
<div class="burst-core">
<svg class="w-14 h-14 text-green-400 burst-check" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
</div>
<div v-if="successInfo.amount > 0" class="text-5xl font-black text-green-400 mb-1">
{{ successInfo.amount.toLocaleString() }}<span class="text-2xl font-bold text-green-400/70"> sats</span>
</div>
<div class="text-2xl font-bold tracking-widest text-white mb-1">SENT</div>
<p class="text-sm text-white/50 mb-6">{{ successInfo.methodLabel }}</p>
<div v-if="successInfo.hash || successInfo.txid || successInfo.note" class="p-4 bg-white/5 rounded-xl text-left space-y-4 mb-6">
<div v-if="successInfo.hash">
<p class="text-xs text-white/50 mb-1">Payment hash</p>
<div class="flex items-center gap-2">
<p class="flex-1 text-xs font-mono text-white/80 break-all">{{ successInfo.hash }}</p>
<CopyButton class="shrink-0" :value="successInfo.hash" />
</div>
</div>
<div v-if="successInfo.txid">
<p class="text-xs text-white/50 mb-1">Transaction ID</p>
<div class="flex items-center gap-2">
<p class="flex-1 text-xs font-mono text-white/80 break-all">{{ successInfo.txid }}</p>
<CopyButton class="shrink-0" :value="successInfo.txid" />
</div>
</div>
<p v-if="successInfo.note" class="text-xs text-white/60">{{ successInfo.note }}</p>
</div>
<div class="flex gap-3">
<button @click="sendAnother" class="flex-1 glass-button px-4 py-3 rounded-xl text-sm font-medium">Send another</button>
<button @click="close" class="flex-1 glass-button glass-button-warning px-4 py-3 rounded-xl text-sm font-semibold">Done</button>
</div>
</div>
<PaymentSuccessPane
:amount="successInfo.amount"
verb="SENT"
:method-label="successInfo.methodLabel"
:rows="successRows"
:note="successInfo.note"
again-label="Send another"
@again="sendAnother"
@done="close"
/>
</template>
<!-- ============ CONFIRM PANE (second step, mirrors the scan flow) ============ -->
@@ -255,7 +224,7 @@ import { rpcClient } from '@/api/rpc-client'
import { useLightningRequired } from '@/composables/useLightningRequired'
import BaseModal from '@/components/BaseModal.vue'
import CopyButton from '@/components/CopyButton.vue'
import ScreensaverRing from '@/components/ScreensaverRing.vue'
import PaymentSuccessPane, { type SuccessRow } from '@/components/PaymentSuccessPane.vue'
const { t } = useI18n()
const lightning = useLightningRequired()
@@ -320,6 +289,18 @@ const successInfo = ref<{
} | null>(null)
const ecashToken = ref('')
// The identifiers worth keeping from a completed send, in the shape the
// shared success pane takes. Which ones exist depends on the rail: Lightning
// has a payment hash, on-chain has a txid.
const successRows = computed<SuccessRow[]>(() => {
const info = successInfo.value
if (!info) return []
const rows: SuccessRow[] = []
if (info.hash) rows.push({ label: 'Payment hash', value: info.hash })
if (info.txid) rows.push({ label: 'Transaction ID', value: info.txid })
return rows
})
// "Send all funds" — sweeps the whole on-chain balance (explicit on-chain tab only)
const sendAll = ref(false)
const onchainBalance = ref<number | null>(null)
@@ -711,57 +692,3 @@ async function send() {
}
}
</script>
<style scoped>
/* Success badge (FED-06) — the branded ScreensaverRing carries the motion,
with the emerald pop-in check centred over it. */
.send-success-badge {
position: relative;
width: 160px;
height: 160px;
display: flex;
align-items: center;
justify-content: center;
}
@media (min-width: 768px) {
.send-success-badge {
width: 192px;
height: 192px;
}
}
.send-success-burst {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 7rem;
height: 7rem;
}
.burst-core {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
background: rgba(16, 185, 129, 0.12);
box-shadow: 0 0 48px rgba(16, 185, 129, 0.3);
animation: burst-pop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.4) both;
}
.burst-check {
stroke-dasharray: 32;
stroke-dashoffset: 32;
animation: burst-draw 0.45s ease-out 0.25s forwards;
}
@keyframes burst-pop {
from { transform: scale(0.3); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes burst-draw {
to { stroke-dashoffset: 0; }
}
@media (prefers-reduced-motion: reduce) {
.burst-core, .burst-check { animation: none; }
.burst-check { stroke-dashoffset: 0; }
}
</style>
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { appConfirm } from '@/composables/useAppConfirm'
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
import EcashSeedBackup from '@/components/EcashSeedBackup.vue'
const { t } = useI18n()
@@ -317,6 +318,11 @@ defineExpose({ loadBackups })
</div>
</div>
<!-- Ecash backup phrase — sits beside the node phrase because it IS
derived from it; an operator asking "what do I need to write down?"
should find both answers in one place. -->
<EcashSeedBackup />
<!-- Reveal recovery phrase modal -->
<Teleport to="body">
<div v-if="showRevealModal" class="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="closeReveal">