diff --git a/core/archipelago/src/wallet/ecash.rs b/core/archipelago/src/wallet/ecash.rs index c374ee3f..30fa5871 100644 --- a/core/archipelago/src/wallet/ecash.rs +++ b/core/archipelago/src/wallet/ecash.rs @@ -1040,6 +1040,12 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result { 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 = 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 { 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( diff --git a/core/archipelago/src/wallet/mint_client.rs b/core/archipelago/src/wallet/mint_client.rs index 3347137d..e92a6374 100644 --- a/core/archipelago/src/wallet/mint_client.rs +++ b/core/archipelago/src/wallet/mint_client.rs @@ -59,6 +59,72 @@ pub struct MintResult { pub proofs: Vec, } +/// 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::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")?;