From 27735327694d0ca3448c376790d929fb475fbcad Mon Sep 17 00:00:00 2001 From: ssmithx Date: Fri, 18 Sep 2026 14:47:49 +0000 Subject: [PATCH] fix(ecash): resolve short v2 keyset ids before verifying a cashuB payment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cashu crate's V4 (cashuB) encoder always writes a NUT-02 v2 keyset id in its short 8-byte form (serialize_v4_keyset_id narrows to ShortKeysetId unconditionally), which is spec-compliant: the receiver must expand it against the mint's keyset list before spending. The payment-receive loop in ecash.rs called MintClient::swap() directly with the short id still attached, so mint.minibits.cash (whose active keyset is v2) rejected every cashuB payment with `422 inputs[0].id: NUT02: ID length invalid` — hence "seller doesn't accept your Cashu mint" on any peer purchase. MintClient::receive_token() already resolves this via resolve_truncated_keyset_ids(); expose it pub(crate) and call it from the ecash.rs loop too. The only other swap() call sites either run after resolution or operate on our own full-id proofs. Adds a test documenting that the short form is what crosses the wire, so serialize_v4 is not "fixed" to defeat it. Co-Authored-By: Claude Sonnet 5 --- core/archipelago/src/wallet/cashu.rs | 39 ++++++++++++++++++++++ core/archipelago/src/wallet/ecash.rs | 13 +++++++- core/archipelago/src/wallet/mint_client.rs | 2 +- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/core/archipelago/src/wallet/cashu.rs b/core/archipelago/src/wallet/cashu.rs index 1b541e7a..9df4fe15 100644 --- a/core/archipelago/src/wallet/cashu.rs +++ b/core/archipelago/src/wallet/cashu.rs @@ -489,6 +489,45 @@ pub fn amount_to_denominations(mut amount: u64) -> Vec { mod tests { use super::*; + /// A v4 (cashuB) token always carries a v2 keyset id in its short + /// (8-byte) form — confirmed against the real cashu 0.17.5 crate + /// (`TokenV4Token`'s `serialize_v4_keyset_id` unconditionally narrows to + /// `ShortKeysetId`) and live against mint.minibits.cash (2026-09-18). + /// That is spec-compliant, not a bug here: a receiver MUST resolve the + /// short id against the mint's keyset list before spending it (see + /// `MintClient::resolve_truncated_keyset_ids`, and its missing call site + /// that this exact round trip caught in `ecash.rs`'s payment-receive + /// path). This test documents that the short form is what actually + /// crosses the wire, so nobody re-"fixes" serialize_v4 to defeat it. + #[test] + fn v4_round_trip_shortens_a_v2_keyset_id_by_design() { + let real_v2_id = "01fc0ec0e59cd6fa01b7a88f8cd77fce81fd1e64bca67d752e984992b7a3c3a821"; + assert_eq!(real_v2_id.len(), 66); + let token = CashuToken { + token: vec![TokenEntry { + mint: "https://mint.minibits.cash/Bitcoin".to_string(), + proofs: vec![Proof { + amount: 2, + id: real_v2_id.to_string(), + secret: "abcdef1234567890".to_string(), + // secp256k1 generator point G — a genuinely valid + // compressed pubkey (the other tests' placeholder C + // value is not, and serialize_v4 is the first path + // here that actually parses it). + c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + .to_string(), + }], + }], + memo: None, + unit: Some("sat".to_string()), + }; + let v4 = token.serialize_v4().expect("serialize_v4 should accept a real v2 id"); + let decoded = CashuToken::deserialize(&v4).unwrap(); + let got_id = &decoded.token[0].proofs[0].id; + assert_eq!(got_id, "01fc0ec0e59cd6fa", "expected the short (8-byte) v2 form on the wire"); + assert!(is_truncated_v2_keyset_id(got_id)); + } + #[test] fn test_serialize_deserialize_roundtrip() { let token = CashuToken { diff --git a/core/archipelago/src/wallet/ecash.rs b/core/archipelago/src/wallet/ecash.rs index 8e74b085..6d0e9a42 100644 --- a/core/archipelago/src/wallet/ecash.rs +++ b/core/archipelago/src/wallet/ecash.rs @@ -1363,7 +1363,18 @@ pub async fn verify_and_receive_payment( let entry_total: u64 = entry.proofs.iter().map(|p| p.amount).sum(); let target_amounts = amount_to_denominations(entry_total); - match client.swap(&entry.proofs, &target_amounts).await { + // The reference cashu crate's V4 (cashuB) encoder always writes a + // NUT-02 v2 keyset id in its short (8-byte) form — confirmed live + // against mint.minibits.cash (2026-09-18): every cashuB payment + // carrying that mint's active v2 keyset failed verification with a + // bare 422 "NUT02: ID length invalid" because this call skipped + // straight to swap() with the short id still attached. MintClient's + // own receive_token() already resolves this correctly; this is the + // same fix, just not routed through it (the loop here also tracks + // received_total/mint-scoped errors that receive_token() doesn't). + let proofs = client.resolve_truncated_keyset_ids(&entry.proofs).await; + + match client.swap(&proofs, &target_amounts).await { Ok(result) => { let amount: u64 = result.new_proofs.iter().map(|p| p.amount).sum(); wallet.add_proofs(&entry.mint, result.new_proofs); diff --git a/core/archipelago/src/wallet/mint_client.rs b/core/archipelago/src/wallet/mint_client.rs index 48739cea..a5d6de89 100644 --- a/core/archipelago/src/wallet/mint_client.rs +++ b/core/archipelago/src/wallet/mint_client.rs @@ -783,7 +783,7 @@ impl MintClient { /// verification at the mint and no coins move. Anything already valid, or /// with no unambiguous match, is passed through untouched so the mint's /// own error is what the operator sees. - async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Vec { + pub(crate) async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Vec { let needs_repair = proofs.iter().any(|p| is_truncated_v2_keyset_id(&p.id)); if !needs_repair { return proofs.to_vec();