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>
This commit is contained in:
@@ -1370,7 +1370,11 @@ pub async fn verify_and_receive_payment(
|
|||||||
received_total += amount;
|
received_total += amount;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
/// Parse a mint's error body (`{"code": N, "detail": "..."}`) and pick the
|
||||||
/// best user-facing message: the plain-language translation when we know 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 {
|
fn describe_mint_error_body(status: reqwest::StatusCode, body: &str) -> String {
|
||||||
let parsed: Option<serde_json::Value> = serde_json::from_str(body).ok();
|
let parsed: Option<serde_json::Value> = serde_json::from_str(body).ok();
|
||||||
let code = parsed
|
let code = parsed
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|v| v.get("code"))
|
.and_then(|v| v.get("code"))
|
||||||
.and_then(|c| c.as_i64());
|
.and_then(|c| c.as_i64());
|
||||||
let detail = parsed
|
let detail = parsed.as_ref().and_then(|v| v.get("detail"));
|
||||||
.as_ref()
|
|
||||||
.and_then(|v| v.get("detail"))
|
|
||||||
.and_then(|d| d.as_str());
|
|
||||||
|
|
||||||
if let Some(friendly) = code.and_then(describe_mint_error_code) {
|
if let Some(friendly) = code.and_then(describe_mint_error_code) {
|
||||||
return friendly.to_string();
|
return friendly.to_string();
|
||||||
}
|
}
|
||||||
match detail {
|
if let Some(d) = detail {
|
||||||
Some(d) if !d.is_empty() => d.to_string(),
|
if let Some(s) = d.as_str() {
|
||||||
_ => format!("mint returned {} with no further detail", status),
|
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
|
/// 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::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user