Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2773532769 | ||
|
|
c86a2436e5 | ||
|
|
3b9b74dae5 | ||
|
|
4021c1f496 | ||
|
|
5f8de584bc | ||
|
|
38de1b3310 |
@@ -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.
|
||||
|
||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.8.16-alpha"
|
||||
version = "1.8.17-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Generated
+2
-2
@@ -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,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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user