Compare commits

...
Author SHA1 Message Date
ssmithxandClaude Sonnet 5 2773532769 fix(ecash): resolve short v2 keyset ids before verifying a cashuB payment
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 <noreply@anthropic.com>
2026-09-18 14:47:49 +00:00
ssmithxandClaude Sonnet 5 c86a2436e5 fix(ecash): stop swallowing the mint's real reason for a 422
Two independent bugs were hiding the actual cause of a failed Cashu
swap/verify behind "mint returned 422 Unprocessable Entity with no
further detail":

- describe_mint_error_body() only read `detail` as a plain string, but
  FastAPI (which most mint implementations, including Nutshell, are
  built on) reports validation errors as an array of {loc, msg, type}
  objects. That shape fell through to the generic fallback even when
  the mint sent a specific reason.
- The warn!() logging a failed swap in ecash.rs used `{}` (top-level
  message only) instead of `{:#}`, discarding the raw mint body that
  mint_error() already attaches to the error's cause chain for exactly
  this purpose.

Confirmed live against mint.minibits.cash (2026-09-18): a real 422
during a peer-to-peer ecash payment logged nothing actionable on
either end because of this pair of bugs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 04:25:58 +00:00
3 changed files with 131 additions and 11 deletions
+39
View File
@@ -489,6 +489,45 @@ pub fn amount_to_denominations(mut amount: u64) -> Vec<u64> {
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 {
+17 -2
View File
@@ -1363,14 +1363,29 @@ 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);
received_total += amount;
}
Err(e) => {
warn!("Payment verification failed at mint {}: {}", entry.mint, e);
// {:#} walks the full anyhow context chain, including the raw
// mint response body `mint_error()` attaches as the cause —
// {} prints only the friendly top-level message and silently
// discards the one thing that would explain a bare 422.
warn!("Payment verification failed at mint {}: {:#}", entry.mint, e);
}
}
}
+75 -9
View File
@@ -114,27 +114,68 @@ fn describe_mint_error_code(code: i64) -> Option<&'static str> {
})
}
/// Render a FastAPI-style validation error list — `detail` as an array of
/// `{"loc": [...], "msg": "...", "type": "..."}` objects — into one line per
/// entry. This is the shape FastAPI (and therefore most Cashu mint
/// implementations, including Nutshell) actually sends for a 422, not the
/// plain string the rest of this file otherwise expects; without this a
/// mint's real reason (e.g. `body -> inputs -> 0 -> id: NUT02: ID length
/// invalid`) was silently replaced with "no further detail".
fn describe_validation_errors(detail: &serde_json::Value) -> Option<String> {
let items = detail.as_array()?;
if items.is_empty() {
return None;
}
let lines: Vec<String> = items
.iter()
.filter_map(|item| {
let msg = item.get("msg").and_then(|m| m.as_str())?;
let loc = item
.get("loc")
.and_then(|l| l.as_array())
.map(|parts| {
parts
.iter()
.map(|p| p.as_str().map(str::to_string).unwrap_or_else(|| p.to_string()))
.collect::<Vec<_>>()
.join(" -> ")
})
.unwrap_or_default();
Some(if loc.is_empty() {
msg.to_string()
} else {
format!("{loc}: {msg}")
})
})
.collect();
(!lines.is_empty()).then(|| lines.join("; "))
}
/// Parse a mint's error body (`{"code": N, "detail": "..."}`) and pick the
/// best user-facing message: the plain-language translation when we know the
/// code, otherwise the mint's own `detail` text, otherwise the raw body.
/// code, otherwise the mint's own `detail` text (a plain string, or a
/// FastAPI-style validation-error array), otherwise the raw body.
fn describe_mint_error_body(status: reqwest::StatusCode, body: &str) -> String {
let parsed: Option<serde_json::Value> = serde_json::from_str(body).ok();
let code = parsed
.as_ref()
.and_then(|v| v.get("code"))
.and_then(|c| c.as_i64());
let detail = parsed
.as_ref()
.and_then(|v| v.get("detail"))
.and_then(|d| d.as_str());
let detail = parsed.as_ref().and_then(|v| v.get("detail"));
if let Some(friendly) = code.and_then(describe_mint_error_code) {
return friendly.to_string();
}
match detail {
Some(d) if !d.is_empty() => d.to_string(),
_ => format!("mint returned {} with no further detail", status),
if let Some(d) = detail {
if let Some(s) = d.as_str() {
if !s.is_empty() {
return s.to_string();
}
} else if let Some(rendered) = describe_validation_errors(d) {
return rendered;
}
}
format!("mint returned {} with no further detail", status)
}
/// Build the error for a failed mint HTTP call: `op` + status + raw body as
@@ -742,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<Proof> {
pub(crate) async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Vec<Proof> {
let needs_repair = proofs.iter().any(|p| is_truncated_v2_keyset_id(&p.id));
if !needs_repair {
return proofs.to_vec();
@@ -850,6 +891,31 @@ mod tests {
);
}
#[test]
fn a_fastapi_validation_error_array_is_rendered_not_swallowed() {
// FastAPI's actual 422 shape — `detail` is a list of
// {loc, msg, type}, not the plain string the rest of this file
// otherwise expects. Confirmed live against mint.minibits.cash
// (2026-09-18): this used to collapse to "mint returned 422
// Unprocessable Entity with no further detail", discarding the one
// piece of text that actually explains the failure.
let body = serde_json::json!({
"detail": [
{"loc": ["body", "inputs", 0, "id"], "msg": "NUT02: ID length invalid", "type": "value_error"}
]
})
.to_string();
let msg = super::describe_mint_error_body(reqwest::StatusCode::UNPROCESSABLE_ENTITY, &body);
assert_eq!(msg, "body -> inputs -> 0 -> id: NUT02: ID length invalid");
}
#[test]
fn an_empty_validation_error_array_falls_back_to_the_generic_message() {
let body = serde_json::json!({"detail": []}).to_string();
let msg = super::describe_mint_error_body(reqwest::StatusCode::UNPROCESSABLE_ENTITY, &body);
assert_eq!(msg, "mint returned 422 Unprocessable Entity with no further detail");
}
use super::*;
#[test]