Compare commits
2
Commits
f4a1c47429
...
59fffc809f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59fffc809f | ||
|
|
579287ba48 |
@@ -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!({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -7,4 +7,5 @@ pub mod cashu;
|
||||
pub mod ecash;
|
||||
pub mod fedimint_client;
|
||||
pub mod mint_client;
|
||||
pub mod nut13;
|
||||
pub mod profits;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user