diff --git a/core/archipelago/src/wallet/cashu.rs b/core/archipelago/src/wallet/cashu.rs index 936e0155..762e14de 100644 --- a/core/archipelago/src/wallet/cashu.rs +++ b/core/archipelago/src/wallet/cashu.rs @@ -215,12 +215,56 @@ impl CashuToken { if proof.c.is_empty() { anyhow::bail!("Proof has empty C"); } + validate_keyset_id(&proof.id)?; } } Ok(()) } } +/// NUT-02 keyset ID: hex for either 8 bytes (v1, the `00…` short form) or +/// 33 bytes (v2, version-byte + hash). +/// +/// Checked when a token is decoded rather than left to the mint. Forwarding +/// an out-of-spec id produced a swap the mint rejected with a bare +/// `422 Unprocessable Entity`, which reached the operator as "check server +/// logs" with nothing actionable in the UI (framework-pt, 2026-08-17, +/// mint.minibits.cash: `inputs[0].id: NUT02: ID length invalid`). A local +/// check can say which keyset format the token uses and that this wallet +/// cannot spend it, before any network call. +fn validate_keyset_id(id: &str) -> Result<()> { + let bytes = hex::decode(id).map_err(|_| { + anyhow::anyhow!( + "Token uses a keyset id that is not hex ({id:?}) — this wallet supports \ + NUT-02 v1 (8-byte) and v2 (33-byte) hex keyset ids" + ) + })?; + match bytes.len() { + // 8 bytes is v1's whole id, and also what a wallet that predates v2 + // leaves behind when it truncates one. Both are accepted here; the + // truncated case is repaired against the mint's keyset list at swap + // time (see MintClient::resolve_truncated_keyset_ids). + 8 | 33 => Ok(()), + n => anyhow::bail!( + "Token uses an unsupported keyset id format: {n}-byte id {id:?}. NUT-02 \ + defines 8-byte (v1) and 33-byte (v2) ids; the mint will reject a swap \ + carrying this one" + ), + } +} + +/// Is this the first 8 bytes of a NUT-02 **v2** keyset id rather than a +/// complete v1 one? +/// +/// A v1 id is 8 bytes beginning with the version byte `0x00`; a v2 id is 33 +/// bytes beginning with `0x01`. So an 8-byte id that starts with `0x01` is a +/// v2 id some wallet cut to the old length — it cannot be spent as-is, but +/// the full id can be recovered from the mint because the short form is a +/// prefix of it. +pub fn is_truncated_v2_keyset_id(id: &str) -> bool { + id.len() == 16 && id.starts_with("01") && hex::decode(id).is_ok() +} + /// Decode a token's base64 payload, trying URL-safe-no-pad first (the spec /// default) and falling back to other alphabets some implementations use. fn decode_token_base64(payload: &str) -> Result, base64::DecodeError> { @@ -473,4 +517,66 @@ mod tests { }; assert!(proof.c_as_pubkey().is_ok()); } + + #[test] + fn keyset_ids_of_both_nut02_versions_are_accepted() { + // v1: 8 bytes / 16 hex chars. + assert!(validate_keyset_id("009a1f293253e41e").is_ok()); + // v2: 33 bytes / 66 hex chars (version byte + 32-byte hash). + let v2 = format!("01{}", "ab".repeat(32)); + assert!(validate_keyset_id(&v2).is_ok()); + } + + #[test] + fn a_truncated_v2_keyset_id_is_recognised_as_repairable() { + // The real case: a Minibits token carried the first 8 bytes of the + // mint's 33-byte v2 keyset id (2026-08-17). + let short = "01fc0ec0e59cd6fa"; + let full = "01fc0ec0e59cd6fa01b7a88f8cd77fce81fd1e64bca67d752e984992b7a3c3a821"; + assert!(is_truncated_v2_keyset_id(short)); + assert!(full.starts_with(short), "short form must prefix the full id"); + // It must survive token validation so the swap path can repair it, + // rather than being rejected as malformed. + assert!(validate_keyset_id(short).is_ok()); + + // A genuine v1 id (version byte 00) is not "truncated". + assert!(!is_truncated_v2_keyset_id("009a1f293253e41e")); + // Neither is a complete v2 id. + assert!(!is_truncated_v2_keyset_id(full)); + } + + #[test] + fn an_out_of_spec_keyset_id_is_rejected_locally_with_its_length() { + // 9 bytes — what a legacy base64 keyset id decodes to, and neither + // NUT-02 length. The mint answers this with a bare 422, so the + // message has to come from here. + let err = validate_keyset_id("00112233445566778899") + .expect_err("9-byte keyset id must be rejected"); + let msg = err.to_string(); + assert!(msg.contains("10-byte") || msg.contains("unsupported keyset id"), "{msg}"); + + // Non-hex ids (the original base64 keyset format) are named as such + // rather than reported as a length problem. + let err = validate_keyset_id("I2yN+iRYfkzT").expect_err("base64 id must be rejected"); + assert!(err.to_string().contains("not hex"), "{err}"); + } + + #[test] + fn a_token_carrying_an_unsupported_keyset_id_fails_to_decode() { + let token = CashuToken { + token: vec![TokenEntry { + mint: "https://mint.example.com".to_string(), + proofs: vec![Proof { + amount: 1, + id: "I2yN+iRYfkzT".to_string(), + secret: "s".to_string(), + c: "02".to_string(), + }], + }], + memo: None, + unit: None, + }; + // The whole point: this must fail here, not at the mint. + assert!(token.validate().is_err()); + } } diff --git a/core/archipelago/src/wallet/mint_client.rs b/core/archipelago/src/wallet/mint_client.rs index e92a6374..e5df450f 100644 --- a/core/archipelago/src/wallet/mint_client.rs +++ b/core/archipelago/src/wallet/mint_client.rs @@ -9,7 +9,8 @@ use super::bdhke; use super::cashu::{ - amount_to_denominations, BlindSignature, BlindedMessageRequest, CashuToken, MintKeyset, Proof, + amount_to_denominations, is_truncated_v2_keyset_id, BlindSignature, BlindedMessageRequest, + CashuToken, KeysetInfo, MintKeyset, Proof, }; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; @@ -184,6 +185,31 @@ impl MintClient { Ok(keysets) } + /// List the mint's keysets (NUT-02 `GET /v1/keysets`) — ids and status + /// only, no public keys. Unlike `/v1/keys` this includes *inactive* + /// keysets, which a received token may well reference: coins from a + /// retired keyset stay spendable. + pub async fn get_keysets(&self) -> Result> { + 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")?; + let keysets: Vec = serde_json::from_value( + body.get("keysets") + .cloned() + .unwrap_or(serde_json::json!([])), + ) + .context("Failed to parse keyset list")?; + Ok(keysets) + } + /// Get the active keyset for the "sat" unit. pub async fn get_active_sat_keyset(&self) -> Result { let keysets = self.get_keys().await?; @@ -483,6 +509,61 @@ impl MintClient { /// Receive a CashuToken by swapping its proofs for fresh ones. /// This prevents double-spend and ensures only we can spend the new proofs. + /// Repair proofs whose keyset id is a truncated NUT-02 **v2** id. + /// + /// A v2 keyset id is 33 bytes (version byte `0x01` + 32-byte hash), but + /// wallets written against the original 8-byte format truncate it when + /// they build a token. The mint then reads the `0x01` version, expects 33 + /// bytes, and rejects the swap — reported as + /// `inputs[0].id: NUT02: ID length invalid` behind a bare 422 (seen with + /// a Minibits-issued token, 2026-08-17). + /// + /// The id only names which keyset signed the proof, so restoring the full + /// id the mint advertises is exactly what the sender meant. It is also + /// safe to attempt: an id that names the wrong keyset fails signature + /// 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 { + let needs_repair = proofs.iter().any(|p| is_truncated_v2_keyset_id(&p.id)); + if !needs_repair { + return proofs.to_vec(); + } + + let known = match self.get_keysets().await { + Ok(k) => k, + Err(e) => { + debug!("Could not list keysets to repair truncated keyset ids: {e:#}"); + return proofs.to_vec(); + } + }; + + proofs + .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::>(); + 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(); + } + p + }) + .collect() + } + pub async fn receive_token(&self, token: &CashuToken) -> Result> { let mut all_new_proofs = Vec::new(); @@ -498,7 +579,8 @@ impl MintClient { let total: u64 = entry.proofs.iter().map(|p| p.amount).sum(); let target_amounts = amount_to_denominations(total); - let result = self.swap(&entry.proofs, &target_amounts).await?; + let proofs = self.resolve_truncated_keyset_ids(&entry.proofs).await; + let result = self.swap(&proofs, &target_amounts).await?; all_new_proofs.extend(result.new_proofs); }