feat(ecash): adopt the reference NUT-02 resolver + real/test network switch
Demo images / Build & push demo images (push) Failing after 2m26s

Executes steps 1-3 of docs/cashu-cdk-migration-plan.md, plus the test-coin
switch needed to exercise these routes without spending real sats.

Protocol layer: depend on `cashu` 0.17.5 (MIT, the crate CDK is built on,
default-features off, `wallet` only). Keyset ids now go through upstream's
`Id::from_short_keyset_id` / `ShortKeysetId` instead of the prefix match
hand-rolled in 2277fc46 — same repair, but implemented by the reference
code that defines the rule, so the next spec turn is a version bump rather
than another incident. `MintClient` feeds it the mint's `/v1/keysets` in
upstream's own `KeySetInfo` shape, parsing entries individually so one
keyset in an unmodelled unit can't block resolving the id we need.

Adding the crate required relaxing `bip39 = "=2.1.0"` to `"2.1"` (resolves
2.2.2): the exact pin held `unicode-normalization` at 0.1.22 and no
resolution existed otherwise. The pin carried no recorded rationale; seed
tests cover the bump.

Network switch: `wallet.ecash-network` / `wallet.ecash-set-network`, with a
Test mode toggle in Wallet Settings → Cashu. Cashu has no testnet, so this
points the wallet at the public `testnut` mint — but crucially each network
gets its OWN wallet and accepted-mints file, because test and real proofs
in one purse would be spendable interchangeably and the balance would be a
lie. Mainnet keeps the original filenames, so existing funds files are
untouched and switching is reversible: tests assert a real balance survives
a round trip through test mode.

Headless coverage: scripts/test-ecash-routes.sh drives every ecash RPC over
the real HTTP path (network get/set, balance, history, mint quote + claim,
send, receive, double-redeem refusal, garbage input, melt quote), restores
the node's original network on exit, and exits non-zero with the failure
count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-17 05:04:25 -04:00
co-authored by Claude Fable 5
parent 03cf74696d
commit be2cfb8293
9 changed files with 917 additions and 83 deletions
+2 -1
View File
@@ -72,7 +72,7 @@ bs58 = "0.5"
chrono = "0.4"
# BIP-39 mnemonic seed generation + BIP-32 HD key derivation
bip39 = { version = "=2.1.0", features = ["rand"] }
bip39 = { version = "2.1", features = ["rand"] }
bitcoin = { version = "=0.32.5", features = ["rand-std"] }
# Configuration
@@ -143,6 +143,7 @@ async-trait = "0.1"
iroh = { version = "1", optional = true }
iroh-blobs = { version = "0.103", optional = true }
lofty = "0.24.0"
cashu = { version = "0.17.5", default-features = false, features = ["wallet"] }
[dev-dependencies]
tokio-test = "0.4"
@@ -266,6 +266,8 @@ impl RpcHandler {
"wallet.ecash-send" => self.handle_wallet_ecash_send(params).await,
"wallet.ecash-receive" => self.handle_wallet_ecash_receive(params).await,
"wallet.ecash-history" => self.handle_wallet_ecash_history().await,
"wallet.ecash-network" => self.handle_wallet_ecash_network().await,
"wallet.ecash-set-network" => self.handle_wallet_ecash_set_network(params).await,
"wallet.networking-profits" => self.handle_wallet_networking_profits().await,
// Fedimint ecash (via fedimint-clientd sidecar)
"wallet.fedimint-list" => self.handle_wallet_fedimint_list().await,
+45
View File
@@ -36,6 +36,51 @@ impl RpcHandler {
}))
}
/// `wallet.ecash-network` — which ecash network this node is on, and the
/// balance sitting in the *other* one so the UI can say what switching
/// would reveal rather than appearing to lose money.
pub(super) async fn handle_wallet_ecash_network(&self) -> Result<serde_json::Value> {
let current = ecash::load_network(&self.config.data_dir).await;
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
Ok(serde_json::json!({
"network": current,
"is_test": current.is_test(),
"mint_url": wallet.mint_url,
"balance_sats": wallet.balance(),
}))
}
/// `wallet.ecash-set-network` — switch between real and test ecash.
///
/// Each network keeps its own wallet file, so this never moves, merges or
/// deletes coins: switching away parks the current balance and switching
/// back finds it exactly as it was.
pub(super) async fn handle_wallet_ecash_set_network(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let requested = params
.get("network")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing network ('mainnet' or 'testnet')"))?;
let network = match requested {
"mainnet" => ecash::EcashNetwork::Mainnet,
"testnet" => ecash::EcashNetwork::Testnet,
other => anyhow::bail!("Unknown ecash network '{other}' — use 'mainnet' or 'testnet'"),
};
ecash::save_network(&self.config.data_dir, network).await?;
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
tracing::info!(?network, "ecash network switched by operator");
Ok(serde_json::json!({
"network": network,
"is_test": network.is_test(),
"mint_url": wallet.mint_url,
"balance_sats": wallet.balance(),
}))
}
pub(super) async fn handle_wallet_ecash_mint(
&self,
params: Option<serde_json::Value>,
+27
View File
@@ -20,6 +20,13 @@
use anyhow::{Context, Result};
use bitcoin::secp256k1::PublicKey;
// Protocol types from the reference implementation (`cashu`, the crate CDK
// itself is built on). Used for the parts of NUT-00/02 that move with the
// spec — token parsing and keyset ids — while the structs below stay ours
// because they are also the on-disk format (see docs/cashu-cdk-migration-plan.md).
use cashu::nuts::nut02::{
Id as CdkId, KeySetInfo as CdkKeySetInfo, ShortKeysetId as CdkShortKeysetId,
};
use serde::{Deserialize, Serialize};
/// Prefix for V3 (JSON) tokens.
@@ -222,6 +229,26 @@ impl CashuToken {
}
}
/// Resolve a token's (possibly short) keyset id against the mint's keyset
/// list, using the reference implementation's NUT-02 rules.
///
/// A v1 id is complete at 8 bytes; a v2 id is 33 bytes and may legitimately
/// travel in a token as a shorter prefix, which only the mint's keyset list
/// can expand. Upstream `Id::from_short_keyset_id` implements exactly that,
/// including the "8 bytes but `0x01`-versioned" case that a wallet written
/// against the old format produces (framework-pt, 2026-08-17).
///
/// Returns the full hex id to send to the mint, or `None` when the id is
/// already complete or cannot be resolved — the caller passes those through
/// untouched so the mint's own error still reaches the operator.
pub fn resolve_keyset_id(id_hex: &str, mint_keysets: &[CdkKeySetInfo]) -> Option<String> {
let bytes = hex::decode(id_hex).ok()?;
let short = CdkShortKeysetId::from_bytes(&bytes).ok()?;
let full = CdkId::from_short_keyset_id(&short, mint_keysets).ok()?;
let full_hex = hex::encode(full.to_bytes());
(full_hex != id_hex).then_some(full_hex)
}
/// NUT-02 keyset ID: hex for either 8 bytes (v1, the `00…` short form) or
/// 33 bytes (v2, version-byte + hash).
///
+163 -8
View File
@@ -14,6 +14,7 @@ use tracing::{debug, info, warn};
const WALLET_FILE: &str = "wallet/ecash.json";
const MINTS_FILE: &str = "wallet/accepted_mints.json";
const NETWORK_FILE: &str = "wallet/network.json";
/// Transaction type for history tracking.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -213,12 +214,96 @@ impl WalletState {
}
}
/// Which ecash network this node's wallet is operating on.
///
/// Cashu itself has no notion of a testnet — a "test" wallet is simply one
/// pointed at a mint that issues valueless coins (the public `testnut` mint).
/// Modelling it as a network setting rather than "just add a mint" matters
/// because the two must never share a purse: test proofs and real proofs in
/// one file would be spendable interchangeably, and a balance would be a lie.
///
/// So each network gets its own wallet and its own accepted-mints list.
/// **Mainnet keeps the original filenames**, so an existing node's funds file
/// is untouched by this feature and by switching back and forth.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EcashNetwork {
#[default]
Mainnet,
Testnet,
}
impl EcashNetwork {
fn wallet_file(&self) -> &'static str {
match self {
Self::Mainnet => WALLET_FILE,
Self::Testnet => "wallet/ecash.testnet.json",
}
}
fn mints_file(&self) -> &'static str {
match self {
Self::Mainnet => MINTS_FILE,
Self::Testnet => "wallet/accepted_mints.testnet.json",
}
}
/// The mint a fresh wallet on this network starts out trusting.
pub fn default_mint(&self) -> String {
match self {
Self::Mainnet => default_mint_url(),
// Public test mint: issues coins with no monetary value, and hands
// them out freely, so every route (mint/melt/send/receive/swap)
// can be exercised end to end without risking real sats.
Self::Testnet => "https://testnut.cashu.space".to_string(),
}
}
pub fn is_test(&self) -> bool {
matches!(self, Self::Testnet)
}
}
/// Read the node's ecash network. Absent file = mainnet, so nodes that never
/// touch this setting behave exactly as before.
pub async fn load_network(data_dir: &Path) -> EcashNetwork {
let path = data_dir.join(NETWORK_FILE);
let Ok(content) = fs::read_to_string(&path).await else {
return EcashNetwork::Mainnet;
};
serde_json::from_str::<NetworkConfig>(&content)
.map(|c| c.network)
.unwrap_or_default()
}
/// Switch the node's ecash network. The other network's wallet is left on
/// disk untouched, so switching is reversible and loses nothing.
pub async fn save_network(data_dir: &Path, network: EcashNetwork) -> Result<()> {
let dir = data_dir.join("wallet");
fs::create_dir_all(&dir)
.await
.context("Failed to create wallet dir")?;
let content = serde_json::to_string_pretty(&NetworkConfig { network })
.context("Failed to serialize ecash network")?;
fs::write(data_dir.join(NETWORK_FILE), content)
.await
.context("Failed to write ecash network")?;
Ok(())
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
struct NetworkConfig {
#[serde(default)]
network: EcashNetwork,
}
/// Load wallet state from disk.
pub async fn load_wallet(data_dir: &Path) -> Result<WalletState> {
let path = data_dir.join(WALLET_FILE);
let network = load_network(data_dir).await;
let path = data_dir.join(network.wallet_file());
if !path.exists() {
return Ok(WalletState {
mint_url: default_mint_url(),
mint_url: network.default_mint(),
..Default::default()
});
}
@@ -229,7 +314,7 @@ pub async fn load_wallet(data_dir: &Path) -> Result<WalletState> {
// Set default mint URL if empty
if wallet.mint_url.is_empty() {
wallet.mint_url = default_mint_url();
wallet.mint_url = network.default_mint();
}
Ok(wallet)
@@ -241,7 +326,7 @@ pub async fn save_wallet(data_dir: &Path, wallet: &WalletState) -> Result<()> {
fs::create_dir_all(&dir)
.await
.context("Failed to create wallet dir")?;
let path = data_dir.join(WALLET_FILE);
let path = data_dir.join(load_network(data_dir).await.wallet_file());
let content = serde_json::to_string_pretty(wallet).context("Failed to serialize wallet")?;
fs::write(&path, content)
.await
@@ -251,17 +336,18 @@ pub async fn save_wallet(data_dir: &Path, wallet: &WalletState) -> Result<()> {
/// Load accepted mints list.
pub async fn load_accepted_mints(data_dir: &Path) -> Result<AcceptedMints> {
let path = data_dir.join(MINTS_FILE);
let network = load_network(data_dir).await;
let path = data_dir.join(network.mints_file());
if !path.exists() {
return Ok(AcceptedMints {
mints: vec![default_mint_url()],
mints: vec![network.default_mint()],
});
}
let content = fs::read_to_string(&path)
.await
.context("Failed to read accepted mints")?;
let mints: AcceptedMints = serde_json::from_str(&content).unwrap_or(AcceptedMints {
mints: vec![default_mint_url()],
mints: vec![network.default_mint()],
});
Ok(mints)
}
@@ -272,7 +358,7 @@ pub async fn save_accepted_mints(data_dir: &Path, mints: &AcceptedMints) -> Resu
fs::create_dir_all(&dir)
.await
.context("Failed to create wallet dir")?;
let path = data_dir.join(MINTS_FILE);
let path = data_dir.join(load_network(data_dir).await.mints_file());
let content =
serde_json::to_string_pretty(mints).context("Failed to serialize accepted mints")?;
fs::write(&path, content)
@@ -1885,4 +1971,73 @@ mod tests {
other => panic!("expected swap into liquid target, got {:?}", other),
}
}
#[tokio::test]
async fn ecash_network_defaults_to_mainnet_and_leaves_files_alone() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
assert_eq!(load_network(dir).await, EcashNetwork::Mainnet);
// A node that never touches this setting has no new file.
assert!(!dir.join(NETWORK_FILE).exists());
assert_eq!(
load_wallet(dir).await.unwrap().mint_url,
default_mint_url(),
"mainnet must keep the original default mint"
);
}
#[tokio::test]
async fn switching_to_testnet_uses_a_separate_purse_and_test_mint() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
// Put real coins in the mainnet wallet.
let mut real = load_wallet(dir).await.unwrap();
real.proofs.push(StoredProof {
proof: Proof {
amount: 1000,
id: "009a1f293253e41e".into(),
secret: "real".into(),
c: "02".into(),
},
mint_url: default_mint_url(),
spent: false,
reserved: false,
created_at: "2026-01-01T00:00:00Z".into(),
});
save_wallet(dir, &real).await.unwrap();
assert_eq!(load_wallet(dir).await.unwrap().balance(), 1000);
// Switching to testnet must show an EMPTY purse pointed at the test
// mint — never the real coins.
save_network(dir, EcashNetwork::Testnet).await.unwrap();
let test_wallet = load_wallet(dir).await.unwrap();
assert_eq!(test_wallet.balance(), 0, "test wallet must not see real coins");
assert!(test_wallet.mint_url.contains("testnut"));
assert!(load_accepted_mints(dir).await.unwrap().mints[0].contains("testnut"));
// Test coins are written to their own file...
let mut t = test_wallet;
t.proofs.push(StoredProof {
proof: Proof {
amount: 7,
id: "009a1f293253e41e".into(),
secret: "test".into(),
c: "02".into(),
},
mint_url: EcashNetwork::Testnet.default_mint(),
spent: false,
reserved: false,
created_at: "2026-01-01T00:00:00Z".into(),
});
save_wallet(dir, &t).await.unwrap();
assert!(dir.join("wallet/ecash.testnet.json").exists());
// ...and switching back finds the real balance exactly as it was.
save_network(dir, EcashNetwork::Mainnet).await.unwrap();
let back = load_wallet(dir).await.unwrap();
assert_eq!(back.balance(), 1000, "real funds must survive a round trip");
assert_eq!(back.proofs.len(), 1);
assert_eq!(back.proofs[0].proof.secret, "real");
}
}
+33 -17
View File
@@ -530,7 +530,9 @@ impl MintClient {
return proofs.to_vec();
}
let known = match self.get_keysets().await {
// The mint's own keyset list, in the reference implementation's shape
// so its NUT-02 resolver can consume it directly.
let known = match self.get_cdk_keysets().await {
Ok(k) => k,
Err(e) => {
debug!("Could not list keysets to repair truncated keyset ids: {e:#}");
@@ -542,28 +544,42 @@ impl MintClient {
.iter()
.cloned()
.map(|mut p| {
if !is_truncated_v2_keyset_id(&p.id) {
return p;
}
// Prefer an active keyset when a prefix somehow matches more
// than one; ambiguity beyond that is left to the mint.
let mut matches = known
.iter()
.filter(|k| k.id.len() == 66 && k.id.starts_with(&p.id))
.collect::<Vec<_>>();
matches.sort_by_key(|k| !k.active);
if let Some(full) = matches.first() {
debug!(
"Expanded truncated v2 keyset id {} to {} for swap",
p.id, full.id
);
p.id = full.id.clone();
if let Some(full) = super::cashu::resolve_keyset_id(&p.id, &known) {
debug!("Expanded short keyset id {} to {} for swap", p.id, full);
p.id = full;
}
p
})
.collect()
}
/// The mint's keysets as upstream `KeySetInfo`, for NUT-02 id resolution.
async fn get_cdk_keysets(&self) -> Result<Vec<cashu::nuts::nut02::KeySetInfo>> {
let url = format!("{}/v1/keysets", self.url);
let res = self
.client
.get(&url)
.send()
.await
.context("Failed to fetch mint keysets")?;
if !res.status().is_success() {
anyhow::bail!("Mint keysets request failed: {}", res.status());
}
let body: serde_json::Value = res.json().await.context("Failed to parse mint keysets")?;
// Deserialize per-entry and keep what parses: a mint may advertise a
// keyset in a unit or format this build doesn't model, and one such
// entry must not block resolving the id we actually need.
let list = body
.get("keysets")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
Ok(list
.into_iter()
.filter_map(|v| serde_json::from_value::<cashu::nuts::nut02::KeySetInfo>(v).ok())
.collect())
}
pub async fn receive_token(&self, token: &CashuToken) -> Result<Vec<Proof>> {
let mut all_new_proofs = Vec::new();