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-history" => self.handle_wallet_ecash_history().await,
|
||||||
"wallet.ecash-network" => self.handle_wallet_ecash_network().await,
|
"wallet.ecash-network" => self.handle_wallet_ecash_network().await,
|
||||||
"wallet.ecash-set-network" => self.handle_wallet_ecash_set_network(params).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,
|
"wallet.networking-profits" => self.handle_wallet_networking_profits().await,
|
||||||
// Fedimint ecash (via fedimint-clientd sidecar)
|
// Fedimint ecash (via fedimint-clientd sidecar)
|
||||||
"wallet.fedimint-list" => self.handle_wallet_fedimint_list().await,
|
"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()
|
.parse()
|
||||||
.context("Invalid mnemonic in memory")?;
|
.context("Invalid mnemonic in memory")?;
|
||||||
crate::seed::save_seed_encrypted(data_dir, &mnemonic, passphrase).await?;
|
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;
|
*state = None;
|
||||||
Ok(true)
|
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> {
|
pub(super) async fn handle_wallet_networking_profits(&self) -> Result<serde_json::Value> {
|
||||||
let summary = profits::get_networking_profits(&self.config.data_dir).await?;
|
let summary = profits::get_networking_profits(&self.config.data_dir).await?;
|
||||||
Ok(serde_json::json!({
|
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 FIPS_KEY_INFO: &[u8] = b"archipelago/fips/secp256k1/v1";
|
||||||
const LND_ENTROPY_INFO: &[u8] = b"archipelago/lnd/entropy/v1";
|
const LND_ENTROPY_INFO: &[u8] = b"archipelago/lnd/entropy/v1";
|
||||||
const RELEASE_ROOT_ED25519_INFO: &[u8] = b"archipelago/release/root/ed25519/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 ─────────────────────────────────────────────────────────
|
// ─── MasterSeed ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -300,6 +301,30 @@ pub fn derive_lnd_entropy(seed: &MasterSeed) -> Result<[u8; 16]> {
|
|||||||
Ok(entropy)
|
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 ─────────────────────────────────────────────
|
// ─── Encrypted Seed Storage ─────────────────────────────────────────────
|
||||||
|
|
||||||
/// Encrypt `plaintext` with Argon2(passphrase) + ChaCha20-Poly1305.
|
/// Encrypt `plaintext` with Argon2(passphrase) + ChaCha20-Poly1305.
|
||||||
@@ -657,6 +682,39 @@ mod tests {
|
|||||||
assert_eq!(e1.len(), 16);
|
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]
|
#[test]
|
||||||
fn test_generate_produces_24_words() {
|
fn test_generate_produces_24_words() {
|
||||||
let (mnemonic, _seed) = MasterSeed::generate().unwrap();
|
let (mnemonic, _seed) = MasterSeed::generate().unwrap();
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
//! Cashu token format (NUT-00) — serialization and deserialization.
|
//! Cashu token format (NUT-00) — serialization and deserialization.
|
||||||
//!
|
//!
|
||||||
//! Emits the cashuA (V3) token format:
|
//! Reads and writes both wire versions:
|
||||||
//! cashuA<base64url_encoded_json>
|
|
||||||
//!
|
//!
|
||||||
//! Token JSON structure:
|
//! - **cashuA (V3)** — `cashuA<base64url_encoded_json>`, whose JSON is the
|
||||||
//! {
|
//! structs below verbatim:
|
||||||
//! "token": [{ "mint": "<url>", "proofs": [{ "amount": u64, "id": "<keyset>", "secret": "<str>", "C": "<hex>" }] }],
|
//! ```text
|
||||||
//! "memo": "<optional>"
|
//! { "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
|
//! `serialize_v4` is what we emit — most wallets default to cashuB now —
|
||||||
//! by default now:
|
//! with `serialize` (cashuA) kept for older receivers and as the fallback
|
||||||
//! cashuB<base64url_encoded_cbor>
|
//! for the one token shape V4 cannot express (multi-mint).
|
||||||
//! 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.
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use bitcoin::secp256k1::PublicKey;
|
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
|
// 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
|
// 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).
|
// 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::{
|
use cashu::nuts::nut02::{
|
||||||
Id as CdkId, KeySetInfo as CdkKeySetInfo, ShortKeysetId as CdkShortKeysetId,
|
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 serde::{Deserialize, Serialize};
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
/// Prefix for V3 (JSON) tokens.
|
/// Prefix for V3 (JSON) tokens.
|
||||||
const CASHU_A_PREFIX: &str = "cashuA";
|
const CASHU_A_PREFIX: &str = "cashuA";
|
||||||
@@ -148,6 +154,58 @@ impl CashuToken {
|
|||||||
Ok(format!("{}{}", CASHU_A_PREFIX, encoded))
|
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.
|
/// Decode a cashuA (V3 JSON) or cashuB (V4 CBOR) token string.
|
||||||
pub fn deserialize(token_str: &str) -> Result<Self> {
|
pub fn deserialize(token_str: &str) -> Result<Self> {
|
||||||
if let Some(payload) = token_str.strip_prefix(CASHU_B_PREFIX) {
|
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()));
|
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]
|
#[test]
|
||||||
fn test_amount_to_denominations() {
|
fn test_amount_to_denominations() {
|
||||||
assert_eq!(amount_to_denominations(0), Vec::<u64>::new());
|
assert_eq!(amount_to_denominations(0), Vec::<u64>::new());
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use super::cashu::{amount_to_denominations, CashuToken, Proof};
|
use super::cashu::{amount_to_denominations, CashuToken, Proof};
|
||||||
use super::mint_client::MintClient;
|
use super::mint_client::MintClient;
|
||||||
|
use super::nut13::RecoverySource;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -416,13 +417,26 @@ pub async fn save_accepted_mints(data_dir: &Path, mints: &AcceptedMints) -> Resu
|
|||||||
Ok(())
|
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.
|
/// Request a mint quote — returns a Lightning invoice to pay.
|
||||||
pub async fn mint_quote(
|
pub async fn mint_quote(
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
amount_sats: u64,
|
amount_sats: u64,
|
||||||
) -> Result<super::mint_client::MintQuote> {
|
) -> Result<super::mint_client::MintQuote> {
|
||||||
let wallet = load_wallet(data_dir).await?;
|
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
|
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> {
|
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 mut wallet = load_wallet(data_dir).await?;
|
||||||
let mint_url = wallet.mint_url.clone();
|
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 result = client.mint_tokens(quote_id, amount_sats).await?;
|
||||||
let minted: u64 = result.proofs.iter().map(|p| p.amount).sum();
|
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.
|
/// 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> {
|
pub async fn melt_quote(data_dir: &Path, bolt11: &str) -> Result<super::mint_client::MeltQuote> {
|
||||||
let wallet = load_wallet(data_dir).await?;
|
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
|
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> {
|
pub async fn melt_tokens(data_dir: &Path, quote_id: &str, bolt11: &str) -> Result<u64> {
|
||||||
let mut wallet = load_wallet(data_dir).await?;
|
let mut wallet = load_wallet(data_dir).await?;
|
||||||
let mint_url = wallet.mint_url.clone();
|
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
|
// Get the melt quote to know the amount needed
|
||||||
let quote = client.melt_quote(bolt11).await?;
|
let quote = client.melt_quote(bolt11).await?;
|
||||||
@@ -583,8 +597,8 @@ pub async fn swap_between_mints(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let from = MintClient::new(from_mint)?;
|
let from = mint_client(data_dir, from_mint).await?;
|
||||||
let to = MintClient::new(to_mint)?;
|
let to = mint_client(data_dir, to_mint).await?;
|
||||||
|
|
||||||
// 1. Mint quote on the target → invoice to pay.
|
// 1. Mint quote on the target → invoice to pay.
|
||||||
let mint_quote = to
|
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> {
|
pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result<String> {
|
||||||
let mint_url = load_wallet(data_dir).await?.mint_url;
|
let mint_url = load_wallet(data_dir).await?.mint_url;
|
||||||
send_token_at(data_dir, &mint_url, amount_sats).await
|
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
|
/// 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
|
/// 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
|
// If there's overpayment, swap to get exact change
|
||||||
let send_proofs = if overpayment > 0 {
|
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 send_denoms = amount_to_denominations(amount_sats);
|
||||||
let change_denoms = amount_to_denominations(overpayment);
|
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
|
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 = 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(
|
wallet.record_tx(
|
||||||
TransactionType::Send,
|
TransactionType::Send,
|
||||||
@@ -898,7 +923,7 @@ fn plan_payment(
|
|||||||
PaymentPlan::Insufficient
|
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
|
/// 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.
|
/// 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 pending = load_pending_swaps(data_dir).await?;
|
||||||
let mut reclaimed = 0u64;
|
let mut reclaimed = 0u64;
|
||||||
for swap in pending {
|
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,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(
|
warn!(
|
||||||
@@ -1151,7 +1176,7 @@ fn target_liquidity_score(liq: &SwapLiquidity, to_mint: &str) -> i64 {
|
|||||||
.sum()
|
.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> {
|
pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||||
// Handle legacy format for backwards compatibility
|
// Handle legacy format for backwards compatibility
|
||||||
if token_str.starts_with("cashuSend_") {
|
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
|
// Swap proofs at each mint
|
||||||
for entry in &token.token {
|
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 {
|
match client.receive_token(&token).await {
|
||||||
Ok(new_proofs) => {
|
Ok(new_proofs) => {
|
||||||
let amount: u64 = new_proofs.iter().map(|p| p.amount).sum();
|
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);
|
return Ok(received);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse and validate cashuA token
|
// Parse and validate the token (cashuA or cashuB)
|
||||||
let token = CashuToken::deserialize(token_str)?;
|
let token = CashuToken::deserialize(token_str)?;
|
||||||
let total = token.total_amount();
|
let total = token.total_amount();
|
||||||
|
|
||||||
@@ -1325,7 +1350,7 @@ pub async fn verify_and_receive_payment(
|
|||||||
let mut received_total = 0u64;
|
let mut received_total = 0u64;
|
||||||
|
|
||||||
for entry in &token.token {
|
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 entry_total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
||||||
let target_amounts = amount_to_denominations(entry_total);
|
let target_amounts = amount_to_denominations(entry_total);
|
||||||
|
|
||||||
@@ -1361,6 +1386,232 @@ pub async fn verify_and_receive_payment(
|
|||||||
Ok(received_total)
|
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.
|
/// Check the wallet balance.
|
||||||
pub async fn get_balance(data_dir: &Path) -> Result<u64> {
|
pub async fn get_balance(data_dir: &Path) -> Result<u64> {
|
||||||
let wallet = load_wallet(data_dir).await?;
|
let wallet = load_wallet(data_dir).await?;
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ use super::cashu::{
|
|||||||
amount_to_denominations, is_truncated_v2_keyset_id, BlindSignature, BlindedMessageRequest,
|
amount_to_denominations, is_truncated_v2_keyset_id, BlindSignature, BlindedMessageRequest,
|
||||||
CashuToken, KeysetInfo, MintKeyset, Proof,
|
CashuToken, KeysetInfo, MintKeyset, Proof,
|
||||||
};
|
};
|
||||||
|
use super::nut13::RecoverySource;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use bitcoin::secp256k1;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::debug;
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
/// Default timeout for mint API calls.
|
/// Default timeout for mint API calls.
|
||||||
const MINT_TIMEOUT_SECS: u64 = 10;
|
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 {
|
pub struct MintClient {
|
||||||
url: String,
|
url: String,
|
||||||
client: reqwest::Client,
|
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 {
|
impl MintClient {
|
||||||
/// Create a new mint client for the given mint URL.
|
/// 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> {
|
pub fn new(mint_url: &str) -> Result<Self> {
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(MINT_TIMEOUT_SECS))
|
.timeout(std::time::Duration::from_secs(MINT_TIMEOUT_SECS))
|
||||||
@@ -143,6 +154,7 @@ impl MintClient {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
url: mint_url.trim_end_matches('/').to_string(),
|
url: mint_url.trim_end_matches('/').to_string(),
|
||||||
client,
|
client,
|
||||||
|
recovery: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,13 +163,67 @@ impl MintClient {
|
|||||||
Self {
|
Self {
|
||||||
url: mint_url.trim_end_matches('/').to_string(),
|
url: mint_url.trim_end_matches('/').to_string(),
|
||||||
client,
|
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 {
|
pub fn url(&self) -> &str {
|
||||||
&self.url
|
&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) ──
|
// ── Keyset discovery (NUT-01, NUT-02) ──
|
||||||
|
|
||||||
/// Fetch the active keyset from the mint.
|
/// Fetch the active keyset from the mint.
|
||||||
@@ -210,6 +276,36 @@ impl MintClient {
|
|||||||
Ok(keysets)
|
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.
|
/// Get the active keyset for the "sat" unit.
|
||||||
pub async fn get_active_sat_keyset(&self) -> Result<MintKeyset> {
|
pub async fn get_active_sat_keyset(&self) -> Result<MintKeyset> {
|
||||||
let keysets = self.get_keys().await?;
|
let keysets = self.get_keys().await?;
|
||||||
@@ -276,21 +372,8 @@ impl MintClient {
|
|||||||
let keyset = self.get_active_sat_keyset().await?;
|
let keyset = self.get_active_sat_keyset().await?;
|
||||||
let denominations = amount_to_denominations(amount);
|
let denominations = amount_to_denominations(amount);
|
||||||
|
|
||||||
let mut blinded_messages = Vec::new();
|
let (blinded_messages, blinding_data) =
|
||||||
let mut blinding_data = Vec::new(); // (secret, blinding_factor, amount)
|
self.blinded_outputs(&keyset.id, &denominations).await?;
|
||||||
|
|
||||||
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 url = format!("{}/v1/mint/bolt11", self.url);
|
let url = format!("{}/v1/mint/bolt11", self.url);
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
@@ -434,21 +517,8 @@ impl MintClient {
|
|||||||
target_amounts
|
target_amounts
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut blinded_messages = Vec::new();
|
let (blinded_messages, blinding_data) =
|
||||||
let mut blinding_data = Vec::new();
|
self.blinded_outputs(&keyset.id, target_amounts).await?;
|
||||||
|
|
||||||
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 url = format!("{}/v1/swap", self.url);
|
let url = format!("{}/v1/swap", self.url);
|
||||||
let res = self
|
let res = self
|
||||||
@@ -543,6 +613,78 @@ impl MintClient {
|
|||||||
Ok(states)
|
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.
|
/// Receive a CashuToken by swapping its proofs for fresh ones.
|
||||||
/// This prevents double-spend and ensures only we can spend the new proofs.
|
/// 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.
|
/// Repair proofs whose keyset id is a truncated NUT-02 **v2** id.
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ pub mod cashu;
|
|||||||
pub mod ecash;
|
pub mod ecash;
|
||||||
pub mod fedimint_client;
|
pub mod fedimint_client;
|
||||||
pub mod mint_client;
|
pub mod mint_client;
|
||||||
|
pub mod nut13;
|
||||||
pub mod profits;
|
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">
|
<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 ============ -->
|
<!-- ============ SUCCESS PANE — the payment's moment, not a footnote ============ -->
|
||||||
<template v-if="successInfo">
|
<template v-if="successInfo">
|
||||||
<div class="text-center py-4">
|
<PaymentSuccessPane
|
||||||
<div class="send-success-badge mx-auto mb-6">
|
:amount="successInfo.amount"
|
||||||
<ScreensaverRing size="badge" />
|
verb="SENT"
|
||||||
<div class="send-success-burst">
|
:method-label="successInfo.methodLabel"
|
||||||
<div class="burst-core">
|
:rows="successRows"
|
||||||
<svg class="w-14 h-14 text-green-400 burst-check" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
|
:note="successInfo.note"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
again-label="Send another"
|
||||||
</svg>
|
@again="sendAnother"
|
||||||
</div>
|
@done="close"
|
||||||
</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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ============ CONFIRM PANE (second step, mirrors the scan flow) ============ -->
|
<!-- ============ CONFIRM PANE (second step, mirrors the scan flow) ============ -->
|
||||||
@@ -255,7 +224,7 @@ import { rpcClient } from '@/api/rpc-client'
|
|||||||
import { useLightningRequired } from '@/composables/useLightningRequired'
|
import { useLightningRequired } from '@/composables/useLightningRequired'
|
||||||
import BaseModal from '@/components/BaseModal.vue'
|
import BaseModal from '@/components/BaseModal.vue'
|
||||||
import CopyButton from '@/components/CopyButton.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 { t } = useI18n()
|
||||||
const lightning = useLightningRequired()
|
const lightning = useLightningRequired()
|
||||||
@@ -320,6 +289,18 @@ const successInfo = ref<{
|
|||||||
} | null>(null)
|
} | null>(null)
|
||||||
const ecashToken = ref('')
|
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)
|
// "Send all funds" — sweeps the whole on-chain balance (explicit on-chain tab only)
|
||||||
const sendAll = ref(false)
|
const sendAll = ref(false)
|
||||||
const onchainBalance = ref<number | null>(null)
|
const onchainBalance = ref<number | null>(null)
|
||||||
@@ -711,57 +692,3 @@ async function send() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</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 { rpcClient } from '@/api/rpc-client'
|
||||||
import { appConfirm } from '@/composables/useAppConfirm'
|
import { appConfirm } from '@/composables/useAppConfirm'
|
||||||
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
|
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
|
||||||
|
import EcashSeedBackup from '@/components/EcashSeedBackup.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -317,6 +318,11 @@ defineExpose({ loadBackups })
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Reveal recovery phrase modal -->
|
||||||
<Teleport to="body">
|
<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 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