fix(wallet): translate Cashu NUT error codes into plain-language messages
Mint HTTP failures (swap/melt/mint-quote) were surfacing raw JSON bodies
like {"detail":"proofs already spent","code":11001} straight to the
user. Add a translator for the NUT-02/03/04/05 transaction-validation
error codes (10001-11017, 12001-12003; see
https://github.com/cashubtc/nuts/blob/main/error_codes.md) and layer it
onto the mint_client bail sites via anyhow context, so the top-level
message is actionable while the raw status/body stays available via
{:#} for logs. receive_token now surfaces the real reason (e.g. "This
ecash has already been redeemed") instead of a generic "Failed to
receive any proofs from token" when every mint in a token fails.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1040,6 +1040,12 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
|
||||
let mut wallet = load_wallet(data_dir).await?;
|
||||
let mut received_total = 0u64;
|
||||
// MintClient translates the mint's NUT error code into plain language and
|
||||
// puts it at the top of the error chain (see `mint_error` in
|
||||
// mint_client.rs); `{}` surfaces that, `{:#}` keeps the raw status/body
|
||||
// for the log. Remember the last one so a total failure can tell the user
|
||||
// *why* instead of just "nothing was received".
|
||||
let mut last_reason: Option<String> = None;
|
||||
|
||||
// Swap proofs at each mint
|
||||
for entry in &token.token {
|
||||
@@ -1051,14 +1057,18 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
received_total += amount;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to swap proofs from mint {}: {}", entry.mint, e);
|
||||
warn!("Failed to swap proofs from mint {}: {:#}", entry.mint, e);
|
||||
last_reason = Some(e.to_string());
|
||||
// Continue with other mints if any
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if received_total == 0 {
|
||||
anyhow::bail!("Failed to receive any proofs from token");
|
||||
match last_reason {
|
||||
Some(reason) => anyhow::bail!("Could not receive this ecash: {}", reason),
|
||||
None => anyhow::bail!("Failed to receive any proofs from token"),
|
||||
}
|
||||
}
|
||||
|
||||
wallet.record_tx(
|
||||
|
||||
@@ -59,6 +59,72 @@ pub struct MintResult {
|
||||
pub proofs: Vec<Proof>,
|
||||
}
|
||||
|
||||
/// Translate a Cashu NUT "transaction validation" error code into plain
|
||||
/// language a wallet user can act on. Mints respond to a rejected request
|
||||
/// with `{"code": N, "detail": "..."}`; `detail` is implementation-defined
|
||||
/// free text, but `code` is the stable identifier from the spec
|
||||
/// (https://github.com/cashubtc/nuts/blob/main/error_codes.md). Covers the
|
||||
/// 10001-11017 "proof/transaction validation" range plus the 12001-12003
|
||||
/// keyset codes shared by NUT-02/03/04/05 — the codes a swap/melt/mint call
|
||||
/// can actually hit. Returns `None` for anything else (e.g. Lightning/quote
|
||||
/// codes in the 20000s) so the caller falls back to the mint's own `detail`.
|
||||
fn describe_mint_error_code(code: i64) -> Option<&'static str> {
|
||||
Some(match code {
|
||||
10001 => "The mint rejected these coins as invalid.",
|
||||
11001 => "This ecash has already been redeemed — it can't be claimed twice.",
|
||||
11002 => "This ecash is already being redeemed elsewhere — try again in a moment.",
|
||||
11003 => "The mint already issued new coins for this exact request — there's nothing left to redeem.",
|
||||
11004 => "This request is still being processed by the mint — try again in a moment.",
|
||||
11005 => "The token's amounts don't add up (inputs don't match outputs) — it may be corrupt.",
|
||||
11006 => "That amount is outside the range this mint allows.",
|
||||
11007 => "This token contains duplicate coins — it may be corrupt or already used.",
|
||||
11008 => "The mint rejected this as a duplicate request.",
|
||||
11009 | 11010 => "This token mixes incompatible currency units — the mint rejected it.",
|
||||
11011 => "That Lightning invoice has no amount, which isn't supported here.",
|
||||
11012 => "The amount requested doesn't match the Lightning invoice.",
|
||||
11013 => "The mint doesn't support this currency unit.",
|
||||
11014 | 11015 => "This token has too many coins for the mint to process in one request.",
|
||||
11016 => "Duplicate quote IDs were sent in this request.",
|
||||
11017 => "Too many items were sent in a single request.",
|
||||
12001 => "The mint no longer recognizes the keyset that signed this token.",
|
||||
12002 => "The mint's signing key for this token is inactive.",
|
||||
12003 => "The mint's signing key for this token has expired.",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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());
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the error for a failed mint HTTP call: `op` + status + raw body as
|
||||
/// the technical cause (visible via `{:#}` in logs), with the plain-language
|
||||
/// translation layered on top via `.context()` so `{}` — what reaches the
|
||||
/// wallet user — shows something actionable instead of raw mint JSON.
|
||||
fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error {
|
||||
let friendly = describe_mint_error_body(status, body);
|
||||
anyhow::anyhow!("{} failed ({}): {}", op, status, body).context(friendly)
|
||||
}
|
||||
|
||||
/// HTTP client for a single Cashu mint.
|
||||
pub struct MintClient {
|
||||
url: String,
|
||||
@@ -146,7 +212,7 @@ impl MintClient {
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Mint quote failed ({}): {}", status, body);
|
||||
return Err(mint_error("Mint quote", status, &body));
|
||||
}
|
||||
|
||||
res.json().await.context("Failed to parse mint quote")
|
||||
@@ -212,7 +278,7 @@ impl MintClient {
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Mint tokens failed ({}): {}", status, body);
|
||||
return Err(mint_error("Minting tokens", status, &body));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = res.json().await.context("Failed to parse mint response")?;
|
||||
@@ -266,7 +332,7 @@ impl MintClient {
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Melt quote failed ({}): {}", status, body);
|
||||
return Err(mint_error("Melt quote", status, &body));
|
||||
}
|
||||
|
||||
res.json().await.context("Failed to parse melt quote")
|
||||
@@ -293,7 +359,7 @@ impl MintClient {
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Melt failed ({}): {}", status, body);
|
||||
return Err(mint_error("Melt", status, &body));
|
||||
}
|
||||
|
||||
res.json().await.context("Failed to parse melt response")
|
||||
@@ -337,7 +403,7 @@ impl MintClient {
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Swap failed ({}): {}", status, body);
|
||||
return Err(mint_error("Swap", status, &body));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = res.json().await.context("Failed to parse swap response")?;
|
||||
|
||||
Reference in New Issue
Block a user