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
archipelago 3b9b74dae5 chore: publish release v1.8.17-alpha
Demo images / Build & push demo images (push) Failing after 36s
2026-09-15 12:56:18 -04:00
archipelago 4021c1f496 chore: prepare release v1.8.17-alpha 2026-09-15 12:53:06 -04:00
archipelago 5f8de584bc docs: add v1.8.17-alpha release notes
Demo images / Build & push demo images (push) Failing after 42s
2026-09-15 12:33:24 -04:00
chaum 38de1b3310 Merge pull request 'fix(ecash): stop replayed Minibits claims retrying forever, reduce relay churn' (#160) from fix/minibits-already-redeemed into main 2026-09-15 16:32:53 +00:00
11 changed files with 190 additions and 50 deletions
+7
View File
@@ -2,6 +2,13 @@
## Unreleased
## v1.8.17-alpha (2026-09-15)
- Minibits claims that every mint reports as already spent leave the retry queue, clearing repeated failure notices. Network errors and mixed mint failures remain queued for another attempt.
- Minibits polls its primary relay first and connects to public fallback relays only when the primary is unreachable, reducing unnecessary connections.
- Large payment backlogs are fetched from newest to oldest with a saved cursor, so polling can resume after interruptions or page limits. Payments sharing the same timestamp remain reachable.
- Added regression coverage for spent-claim classification, wrapped and mixed mint errors, same-second payments, and interrupted or multi-poll backlogs.
## v1.8.16-alpha (2026-09-15)
- App updates refresh and verify the signed catalog before changing containers. A failed refresh or manifest reload cancels the update, and automatic updates wait for a successful refresh.
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.8.16-alpha"
version = "1.8.17-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.8.16-alpha"
version = "1.8.17-alpha"
edition = "2021"
license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend"
+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]
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.8.16-alpha",
"version": "1.8.17-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.8.16-alpha",
"version": "1.8.17-alpha",
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.8.16-alpha",
"version": "1.8.17-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
@@ -362,6 +362,19 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.8.17-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.17-alpha</span>
<span class="text-xs text-white/40">September 15, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Minibits claims that every mint reports as already spent leave the retry queue, clearing repeated failure notices. Network errors and mixed mint failures remain queued for another attempt.</p>
<p>Minibits polls its primary relay first and connects to public fallback relays only when the primary is unreachable, reducing unnecessary connections.</p>
<p>Large payment backlogs are fetched from newest to oldest with a saved cursor, so polling can resume after interruptions or page limits. Payments sharing the same timestamp remain reachable.</p>
<p>Added regression coverage for spent-claim classification, wrapped and mixed mint errors, same-second payments, and interrupted or multi-poll backlogs.</p>
</div>
</div>
<!-- v1.8.16-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
+17 -17
View File
@@ -1,30 +1,30 @@
{
"changelog": [
"App updates refresh and verify the signed catalog before changing containers. A failed refresh or manifest reload cancels the update, and automatic updates wait for a successful refresh.",
"Fixed repeated Mempool update offers: downstream `-archyN` patches now sort above their upstream release, and moving a published image between registry namespaces does not hide a genuine upgrade.",
"Updates inspect installed component versions, refuse known downgrades, skip containers already at the target versions, and verify the resulting versions before reporting success.",
"Added regression coverage for stale catalogs, matching versions, publisher namespace changes, stack component updates, and keeping running containers untouched when no upgrade is needed."
"Minibits claims that every mint reports as already spent leave the retry queue, clearing repeated failure notices. Network errors and mixed mint failures remain queued for another attempt.",
"Minibits polls its primary relay first and connects to public fallback relays only when the primary is unreachable, reducing unnecessary connections.",
"Large payment backlogs are fetched from newest to oldest with a saved cursor, so polling can resume after interruptions or page limits. Payments sharing the same timestamp remain reachable.",
"Added regression coverage for spent-claim classification, wrapped and mixed mint errors, same-second payments, and interrupted or multi-poll backlogs."
],
"components": [
{
"current_version": "1.8.16-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.16-alpha/archipelago",
"current_version": "1.8.17-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.16-alpha",
"sha256": "1800f57678a0b994ab2e43a830ef06d1c96fd3cc7be47ce4e6e46b7df8a5420f",
"size_bytes": 64851944
"new_version": "1.8.17-alpha",
"sha256": "32a7b009eb58f8c9f256e6597711a77ded11e15d5865a3fe16901603264e1f70",
"size_bytes": 64953344
},
{
"current_version": "1.8.16-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.16-alpha/archipelago-frontend-1.8.16-alpha.tar.gz",
"name": "archipelago-frontend-1.8.16-alpha.tar.gz",
"new_version": "1.8.16-alpha",
"sha256": "7dd73c50a54bc530385d9e450a18cbff9c3f4ffaf289a2a7b21e5d3803116722",
"size_bytes": 98799570
"current_version": "1.8.17-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago-frontend-1.8.17-alpha.tar.gz",
"name": "archipelago-frontend-1.8.17-alpha.tar.gz",
"new_version": "1.8.17-alpha",
"sha256": "faf692e9a0e16268357bcac2bf86b62950ae49663e3c95982e54a132bb761980",
"size_bytes": 98801608
}
],
"release_date": "2026-09-15",
"signature": "083b131a6b895e1ff8fb9e9a52b1ead260e2140081a0295ae6756cbbc4f8f2c30e8a8bc72822c905702e21ac90f7cb85d5cca9b5f8c10fc87f32a365da202c0d",
"signature": "c8196fe278a5747b3c3ba3bf70998874f1e3e6eedbdab33b9e33c3339a3769ab4431f41d99924ec4cdd15a5ffed299a5af786c7ab5e9d084cdc11beabbee9103",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.16-alpha"
"version": "1.8.17-alpha"
}
+17 -17
View File
@@ -1,30 +1,30 @@
{
"changelog": [
"App updates refresh and verify the signed catalog before changing containers. A failed refresh or manifest reload cancels the update, and automatic updates wait for a successful refresh.",
"Fixed repeated Mempool update offers: downstream `-archyN` patches now sort above their upstream release, and moving a published image between registry namespaces does not hide a genuine upgrade.",
"Updates inspect installed component versions, refuse known downgrades, skip containers already at the target versions, and verify the resulting versions before reporting success.",
"Added regression coverage for stale catalogs, matching versions, publisher namespace changes, stack component updates, and keeping running containers untouched when no upgrade is needed."
"Minibits claims that every mint reports as already spent leave the retry queue, clearing repeated failure notices. Network errors and mixed mint failures remain queued for another attempt.",
"Minibits polls its primary relay first and connects to public fallback relays only when the primary is unreachable, reducing unnecessary connections.",
"Large payment backlogs are fetched from newest to oldest with a saved cursor, so polling can resume after interruptions or page limits. Payments sharing the same timestamp remain reachable.",
"Added regression coverage for spent-claim classification, wrapped and mixed mint errors, same-second payments, and interrupted or multi-poll backlogs."
],
"components": [
{
"current_version": "1.8.16-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.16-alpha/archipelago",
"current_version": "1.8.17-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.16-alpha",
"sha256": "1800f57678a0b994ab2e43a830ef06d1c96fd3cc7be47ce4e6e46b7df8a5420f",
"size_bytes": 64851944
"new_version": "1.8.17-alpha",
"sha256": "32a7b009eb58f8c9f256e6597711a77ded11e15d5865a3fe16901603264e1f70",
"size_bytes": 64953344
},
{
"current_version": "1.8.16-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.16-alpha/archipelago-frontend-1.8.16-alpha.tar.gz",
"name": "archipelago-frontend-1.8.16-alpha.tar.gz",
"new_version": "1.8.16-alpha",
"sha256": "7dd73c50a54bc530385d9e450a18cbff9c3f4ffaf289a2a7b21e5d3803116722",
"size_bytes": 98799570
"current_version": "1.8.17-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago-frontend-1.8.17-alpha.tar.gz",
"name": "archipelago-frontend-1.8.17-alpha.tar.gz",
"new_version": "1.8.17-alpha",
"sha256": "faf692e9a0e16268357bcac2bf86b62950ae49663e3c95982e54a132bb761980",
"size_bytes": 98801608
}
],
"release_date": "2026-09-15",
"signature": "083b131a6b895e1ff8fb9e9a52b1ead260e2140081a0295ae6756cbbc4f8f2c30e8a8bc72822c905702e21ac90f7cb85d5cca9b5f8c10fc87f32a365da202c0d",
"signature": "c8196fe278a5747b3c3ba3bf70998874f1e3e6eedbdab33b9e33c3339a3769ab4431f41d99924ec4cdd15a5ffed299a5af786c7ab5e9d084cdc11beabbee9103",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.16-alpha"
"version": "1.8.17-alpha"
}