From c86a2436e5adf31ba8afc88d7003304cd81ae557 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Fri, 18 Sep 2026 04:25:58 +0000 Subject: [PATCH] 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 --- core/archipelago/src/wallet/ecash.rs | 6 +- core/archipelago/src/wallet/mint_client.rs | 82 +++++++++++++++++++--- 2 files changed, 79 insertions(+), 9 deletions(-) diff --git a/core/archipelago/src/wallet/ecash.rs b/core/archipelago/src/wallet/ecash.rs index e77647a8..8e74b085 100644 --- a/core/archipelago/src/wallet/ecash.rs +++ b/core/archipelago/src/wallet/ecash.rs @@ -1370,7 +1370,11 @@ pub async fn verify_and_receive_payment( 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); } } } diff --git a/core/archipelago/src/wallet/mint_client.rs b/core/archipelago/src/wallet/mint_client.rs index 83950898..48739cea 100644 --- a/core/archipelago/src/wallet/mint_client.rs +++ b/core/archipelago/src/wallet/mint_client.rs @@ -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 { + let items = detail.as_array()?; + if items.is_empty() { + return None; + } + let lines: Vec = 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::>() + .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::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 @@ -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]