Files
archy/core/archipelago/src/wallet/nut13.rs
T
ssmithxandarchipelago ce9fca1c38 feat(ecash): Minibits @minibits.cash Lightning address on Cashu receive
The wallet used Minibits only as a Cashu mint, so the node could hold and
swap ecash there but had no addressable name at it. This derives a LUD-16
Lightning address (name@minibits.cash) from the node's own ecash wallet and
surfaces it in the ecash Receive tab above the existing paste-token box.

Identity reuses the NUT-13 ecash phrase, so there is no second secret:
  - seedHash = sha256(mnemonic.to_seed("")) — the exact hash the Minibits app
    stores, so restoring the same phrase recovers the same address both ways;
  - Nostr keys via NIP-06 at m/44'/1237'/0'/0/0 (nostr-sdk Keys::from_mnemonic,
    pinned by a unit test against the NIP-06 vector so a bump cannot silently
    move the derivation and orphan the profile).

Backend (wallet/minibits.rs) implements the verified live /v3 flow: NIP-42
challenge/verify -> JWT, idempotent /profile registration with collision
retry, and /claim polling that NIP-04-decrypts each token (service pubkey read
from the address's own LUD-16 metadata, constant fallback) and redeems it
through ecash::receive_token. Mainnet-only; state cached 0600 in
wallet/minibits.json.

New RPC: wallet.ecash-lnaddress (register-or-read, idempotent) and
wallet.ecash-lnaddress-claim (sweep Lightning payments into ecash). The modal
fetches the address on tab open, renders QR + copy, and sweeps claims while
open; a registration failure is non-fatal so paste-token still works.

Verified end-to-end against production: registered a disposable
@minibits.cash address, confirmed it resolves via /.well-known/lnurlp, and the
claim poll returns cleanly.
2026-09-08 21:16:57 -04:00

798 lines
33 KiB
Rust

//! 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,
/// Supplied by the operator, from another NUT-13 wallet. Same caveat as
/// `Independent` — the node's recovery phrase does not cover it — but it
/// is worth telling apart, because these words exist somewhere else too
/// and the operator already knows where.
Imported,
}
impl SeedSource {
/// Does restoring the *node* from its recovery phrase bring this wallet
/// back? Only a derived phrase can promise that.
pub fn covered_by_node_seed(&self) -> bool {
matches!(self, Self::NodeSeed)
}
}
/// 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()
}
/// The phrase as a single string — the input to NUT-13 *and* to the NIP-06
/// Nostr derivation the Minibits profile flow needs (`crate::wallet::minibits`).
pub fn phrase(&self) -> String {
self.mnemonic.to_string()
}
/// The 64-byte BIP-39 seed. Same bytes Minibits hashes with SHA-256 to get
/// its `seedHash`, so the two wallets agree on wallet identity.
pub fn seed_bytes(&self) -> [u8; 64] {
self.seed
}
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))
}
/// Establish a wallet seed that is **not** derived from the node's master
/// seed, for a node that has no encrypted master seed to derive from.
///
/// Plenty of nodes are in that position: `identity/master_seed.enc` is written
/// during onboarding, and any node onboarded before that step existed simply
/// does not have one. The choice there is not "derived phrase or independent
/// phrase" — it is "independent phrase or **no backup at all**", and a wallet
/// whose coins can be restored from words the operator holds is strictly
/// better than one whose coins die with a single file.
///
/// The cost is stated plainly rather than hidden: the phrase is recorded as
/// [`SeedSource::Independent`], and every surface that shows it says that
/// restoring the node will *not* bring this wallet back — only these words
/// will. That is a real obligation on the operator, so it must never be the
/// silent default when derivation was possible; [`establish_from_master`] is
/// what a node with a master seed gets.
pub async fn establish_independent(data_dir: &Path) -> Result<EcashSeed> {
if let Some(existing) = load_seed(data_dir).await? {
return Ok(existing);
}
// Same guarded generation path as the node's own seed: a named CSPRNG and
// the degenerate-entropy check, not a dependency's default (KEY-05).
let (mnemonic, _seed) = crate::seed::MasterSeed::generate()?;
write_seed(data_dir, &mnemonic, SeedSource::Independent).await?;
warn!(
"Established an INDEPENDENT ecash backup phrase: this node has no encrypted \
master seed to derive one from, so restoring the node will not restore this \
ecash wallet — only the phrase itself will."
);
Ok(EcashSeed::from_mnemonic(mnemonic, SeedSource::Independent))
}
/// Adopt a phrase the operator supplies, from another NUT-13 wallet.
///
/// This is the "bring your own" path: it points the wallet at someone else's
/// derivation, which is what makes coins held in Minibits, Nutstash or
/// `cdk-cli` restorable here.
///
/// Replacing a phrase is the one genuinely lossy thing this module can do.
/// Coins already in `wallet/ecash.json` stay spendable — they are proofs, not
/// derivations, and nothing here touches them — but they were minted under
/// the *old* phrase, so a future restore will no longer find them. The old
/// phrase is therefore archived rather than overwritten, and replacing an
/// established one needs `confirm`. An operator who imports by mistake must
/// not lose the only copy of the words their balance was minted under.
///
/// Counters are deliberately left alone. They are per-keyset and seed-
/// relative, so under a new seed they merely start high — which costs nothing,
/// since a restore scans from zero regardless. Resetting them would be the
/// dangerous choice if the imported phrase turned out to be the one already
/// in use.
pub async fn import_mnemonic(data_dir: &Path, words: &str, confirm: bool) -> Result<EcashSeed> {
let mnemonic: bip39::Mnemonic = words
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.parse()
.map_err(|e| {
anyhow::anyhow!(
"That is not a valid BIP-39 recovery phrase: {e}. Check for typos — \
every word must come from the BIP-39 word list, and the phrase as \
a whole carries a checksum."
)
})?;
if let Some(existing) = load_seed(data_dir).await? {
if existing.mnemonic == mnemonic {
// Importing the phrase already in use: nothing to do, and
// certainly nothing to archive.
return Ok(existing);
}
if !confirm {
anyhow::bail!(
"This wallet already has a backup phrase. Importing a different one \
means coins minted under the current phrase will no longer be \
restorable from words — they stay spendable, but a restore will \
not find them. Reveal and write down the current phrase first, \
then confirm to replace it."
);
}
archive_seed(data_dir).await?;
}
write_seed(data_dir, &mnemonic, SeedSource::Imported).await?;
warn!("Ecash backup phrase REPLACED by an imported one (the previous phrase, if any, was archived)");
Ok(EcashSeed::from_mnemonic(mnemonic, SeedSource::Imported))
}
/// Move the current seed file aside, timestamped, before it is replaced.
///
/// Never deleted and never overwritten: this file may be the last copy of the
/// words a balance was minted under, and the whole point of the module is that
/// such a thing is not casually destroyed.
async fn archive_seed(data_dir: &Path) -> Result<()> {
let from = seed_path(data_dir);
if !from.exists() {
return Ok(());
}
let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
let to = data_dir.join(format!("wallet/cashu_seed.replaced-{stamp}.json"));
fs::rename(&from, &to).await.with_context(|| {
format!(
"Could not archive the previous ecash phrase to {}",
to.display()
)
})?;
warn!("Previous ecash phrase archived to {}", to.display());
Ok(())
}
/// 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"
);
}
/// A phrase from another wallet must derive that wallet's secrets — that
/// is the entire point of importing one.
#[tokio::test]
async fn an_imported_phrase_derives_the_other_wallets_secrets() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
// Stand in for the other wallet: a known phrase and what it derives.
let theirs: bip39::Mnemonic = TEST_MNEMONIC.parse().unwrap();
let expected = EcashSeed::from_mnemonic(theirs.clone(), SeedSource::Imported)
.derive_output(V1_KEYSET, 3)
.unwrap();
let imported = import_mnemonic(d, TEST_MNEMONIC, false).await.unwrap();
assert_eq!(imported.source(), SeedSource::Imported);
assert!(!imported.source().covered_by_node_seed());
assert_eq!(imported.derive_output(V1_KEYSET, 3).unwrap().0, expected.0);
// And it is what the wallet uses from now on.
let reloaded = load_seed(d).await.unwrap().expect("persisted");
assert_eq!(reloaded.words(), imported.words());
}
#[tokio::test]
async fn importing_over_an_established_phrase_needs_confirmation() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
let original = establish_from_master(d, &master).await.unwrap();
let original_words = original.words();
// Refused without confirmation — replacing a phrase silently would
// orphan every coin minted under it.
let (other, _) = MasterSeed::generate().unwrap();
let err = import_mnemonic(d, &other.to_string(), false)
.await
.expect_err("must not replace without confirmation");
assert!(
err.to_string().contains("already has a backup phrase"),
"{err}"
);
assert_eq!(
load_seed(d).await.unwrap().unwrap().words(),
original_words,
"a refused import must change nothing"
);
// Confirmed: replaced, and the old phrase archived rather than lost.
import_mnemonic(d, &other.to_string(), true).await.unwrap();
assert_eq!(
load_seed(d).await.unwrap().unwrap().words(),
other.words().map(|w| w.to_string()).collect::<Vec<_>>()
);
let archived: Vec<_> = std::fs::read_dir(d.join("wallet"))
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("cashu_seed.replaced-")
})
.collect();
assert_eq!(archived.len(), 1, "the replaced phrase must be kept");
}
/// Re-importing the phrase already in use is a no-op, not a replacement —
/// it must not archive anything or churn the file.
#[tokio::test]
async fn importing_the_current_phrase_changes_nothing() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
let first = import_mnemonic(d, TEST_MNEMONIC, false).await.unwrap();
let again = import_mnemonic(d, TEST_MNEMONIC, false).await.unwrap();
assert_eq!(first.words(), again.words());
let archived = std::fs::read_dir(d.join("wallet"))
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("cashu_seed.replaced-")
})
.count();
assert_eq!(archived, 0);
}
#[tokio::test]
async fn a_malformed_phrase_is_refused_with_something_actionable() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
// Right shape, wrong checksum — the commonest real mistake.
let bad = TEST_MNEMONIC.replace(" art", " abandon");
let err = import_mnemonic(d, &bad, false).await.expect_err("checksum");
assert!(err.to_string().contains("BIP-39"), "{err}");
assert!(!seed_exists(d), "a rejected phrase must not be written");
assert!(import_mnemonic(d, "not a phrase", false).await.is_err());
assert!(import_mnemonic(d, "", false).await.is_err());
}
/// Whitespace and casing vary wildly in what people paste out of other
/// wallets; the words are what matter.
#[tokio::test]
async fn a_pasted_phrase_survives_untidy_whitespace() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
let messy = format!(" {} ", TEST_MNEMONIC.replace(' ', "\n "));
let imported = import_mnemonic(d, &messy, false).await.unwrap();
assert_eq!(imported.words().len(), 24);
assert_eq!(imported.words().join(" "), TEST_MNEMONIC);
}
#[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);
}
}
}