feat(ecash): adopt the reference NUT-02 resolver + real/test network switch
Demo images / Build & push demo images (push) Failing after 2m26s
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:
co-authored by
Claude Fable 5
parent
03cf74696d
commit
be2cfb8293
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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).
|
||||
///
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user