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
+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();