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
archipelago abfbccc906 fix(ecash): preserve retryable claims and resume relay backlogs 2026-09-15 12:31:49 -04:00
ssmithxandClaude Sonnet 5 9d4e74e094 docs: redact node hostname from the Minibits incident writeup
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 16:19:23 +00:00
ssmithxandClaude Sonnet 5 db355b759c fix(ecash): stop replayed Minibits claims retrying forever, reduce relay churn
claim_and_redeem retried every redeem failure indefinitely, including a
terminal one: mint error 11001 "Token Already Spent" (a claim replayed by a
relay-watermark edge case, or already redeemed by an earlier run). On
archy-x250-pa3 this pinned pending_claims at 1 forever and hammered
mint.minibits.cash's swap endpoint every ~6s, with the UI permanently
showing "a payment arrived but couldn't be redeemed yet".

- mint_client: expose the NUT error-code-11001 message as
  ALREADY_REDEEMED_MSG so callers can recognize it without duplicating the
  string.
- minibits: drop (not retry) a redeem failure that matches
  is_already_redeemed — the value was already swept, so retrying can never
  succeed.
- fetch_relay_dms: query the primary relay.minibits.cash alone first,
  falling back to the public relay.damus.io/nos.lol only if it's
  unreachable, and page past a 200-DM backlog instead of silently
  stranding older DMs behind an un-advanced watermark.

This fix already existed on feat/minibits-lnurl-receive (4e410d7, 489995c,
2026-09-09) but that branch was never merged into main, which has its own
independently-diverged minibits.rs — so the bug shipped again in
1.8.16-alpha. Ported directly onto main's current implementation this time.

Immediate unblock on archy-x250-pa3: cleared the one poisoned
pending_claims entry from wallet/minibits.json by hand (already-redeemed,
zero value at risk) and restarted archipelago.service; confirmed via
journalctl that polling is quiet again.

See docs/incident-2026-09-15-minibits-already-redeemed.md for the full
writeup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 16:12:00 +00:00
13 changed files with 651 additions and 91 deletions
+7
View File
@@ -2,6 +2,13 @@
## Unreleased ## 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) ## 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. - 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]] [[package]]
name = "archipelago" name = "archipelago"
version = "1.8.16-alpha" version = "1.8.17-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"archipelago-container", "archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "archipelago" name = "archipelago"
version = "1.8.16-alpha" version = "1.8.17-alpha"
edition = "2021" edition = "2021"
license.workspace = true license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend" 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 { mod tests {
use super::*; 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] #[test]
fn test_serialize_deserialize_roundtrip() { fn test_serialize_deserialize_roundtrip() {
let token = CashuToken { let token = CashuToken {
+43 -6
View File
@@ -1205,6 +1205,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
// for the log. Remember the last one so a total failure can tell the user // for the log. Remember the last one so a total failure can tell the user
// *why* instead of just "nothing was received". // *why* instead of just "nothing was received".
let mut last_reason: Option<String> = None; let mut last_reason: Option<String> = None;
let mut all_already_redeemed = true;
// Swap proofs at each mint // Swap proofs at each mint
for entry in &token.token { for entry in &token.token {
@@ -1217,6 +1218,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
} }
Err(e) => { Err(e) => {
warn!("Failed to swap proofs from mint {}: {:#}", entry.mint, e); warn!("Failed to swap proofs from mint {}: {:#}", entry.mint, e);
all_already_redeemed &= e.is::<super::mint_client::AlreadyRedeemed>();
last_reason = Some(e.to_string()); last_reason = Some(e.to_string());
// Continue with other mints if any // Continue with other mints if any
} }
@@ -1224,10 +1226,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
} }
if received_total == 0 { if received_total == 0 {
match last_reason { return Err(receive_failure(last_reason, all_already_redeemed));
Some(reason) => anyhow::bail!("Could not receive this ecash: {}", reason),
None => anyhow::bail!("Failed to receive any proofs from token"),
}
} }
wallet.record_tx( wallet.record_tx(
@@ -1243,6 +1242,17 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
Ok(received_total) Ok(received_total)
} }
fn receive_failure(last_reason: Option<String>, all_already_redeemed: bool) -> anyhow::Error {
match last_reason {
Some(reason) if all_already_redeemed => {
anyhow::Error::new(super::mint_client::AlreadyRedeemed)
.context(format!("Could not receive this ecash: {reason}"))
}
Some(reason) => anyhow::anyhow!("Could not receive this ecash: {reason}"),
None => anyhow::anyhow!("Failed to receive any proofs from token"),
}
}
/// Receive a legacy format token (cashuSend_{amount}_{uuid}_{timestamp}). /// Receive a legacy format token (cashuSend_{amount}_{uuid}_{timestamp}).
/// For backwards compatibility during migration period. /// For backwards compatibility during migration period.
async fn receive_legacy_token(data_dir: &Path, token_str: &str) -> Result<u64> { async fn receive_legacy_token(data_dir: &Path, token_str: &str) -> Result<u64> {
@@ -1353,14 +1363,29 @@ pub async fn verify_and_receive_payment(
let entry_total: u64 = entry.proofs.iter().map(|p| p.amount).sum(); let entry_total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
let target_amounts = amount_to_denominations(entry_total); 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) => { Ok(result) => {
let amount: u64 = result.new_proofs.iter().map(|p| p.amount).sum(); let amount: u64 = result.new_proofs.iter().map(|p| p.amount).sum();
wallet.add_proofs(&entry.mint, result.new_proofs); wallet.add_proofs(&entry.mint, result.new_proofs);
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);
} }
} }
} }
@@ -1632,6 +1657,18 @@ fn default_mint_url() -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn mixed_mint_failures_do_not_discard_a_retryable_claim() {
let reason = super::super::mint_client::ALREADY_REDEEMED_MSG.to_string();
assert!(super::receive_failure(Some(reason.clone()), true)
.is::<super::super::mint_client::AlreadyRedeemed>());
assert!(!super::receive_failure(Some(reason), false)
.is::<super::super::mint_client::AlreadyRedeemed>());
assert!(
!super::receive_failure(None, true).is::<super::super::mint_client::AlreadyRedeemed>()
);
}
use super::*; use super::*;
use tempfile::TempDir; use tempfile::TempDir;
+266 -34
View File
@@ -47,7 +47,8 @@
//! key, a crash mid-loop) must not silently lose the coins, so every fetched //! key, a crash mid-loop) must not silently lose the coins, so every fetched
//! token is persisted to `MinibitsState::pending_claims` *before* decrypt/ //! token is persisted to `MinibitsState::pending_claims` *before* decrypt/
//! redeem is attempted, and stays there — retried on every later poll — until //! redeem is attempted, and stays there — retried on every later poll — until
//! it succeeds. `ClaimOutcome::failed_count` reports how many are still //! it succeeds or every mint reports that it was already spent.
//! `ClaimOutcome::failed_count` reports how many are still
//! stuck so the caller can surface it instead of it being a log-only event. //! stuck so the caller can surface it instead of it being a log-only event.
//! Separately, `ensure_mint_accepted` keeps the Minibits mint on the node's //! Separately, `ensure_mint_accepted` keeps the Minibits mint on the node's
//! accepted-mints allow-list: the address is inherently backed by that one //! accepted-mints allow-list: the address is inherently backed by that one
@@ -167,6 +168,9 @@ pub struct MinibitsState {
/// already-spent token) but wasteful and noisy. /// already-spent token) but wasteful and noisy.
#[serde(default)] #[serde(default)]
pub last_dm_seen_at: u64, pub last_dm_seen_at: u64,
/// Resume a bounded backward scan before advancing to newer relay events.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relay_scan: Option<RelayScan>,
/// Event ids already queued from the relay. `created_at` has only /// Event ids already queued from the relay. `created_at` has only
/// one-second resolution, so a strict `since = last + 1` watermark can /// one-second resolution, so a strict `since = last + 1` watermark can
/// permanently miss a second payment published later in the same second. /// permanently miss a second payment published later in the same second.
@@ -627,6 +631,7 @@ async fn register_new_state(
created_at: chrono::Utc::now().to_rfc3339(), created_at: chrono::Utc::now().to_rfc3339(),
pending_claims: Vec::new(), pending_claims: Vec::new(),
last_dm_seen_at: 0, last_dm_seen_at: 0,
relay_scan: None,
seen_dm_ids: Vec::new(), seen_dm_ids: Vec::new(),
last_receipt_id: 0, last_receipt_id: 0,
last_receipt_sats: 0, last_receipt_sats: 0,
@@ -652,6 +657,16 @@ pub struct ClaimOutcome {
pub receipt_at: u64, pub receipt_at: u64,
} }
/// True when `ecash::receive_token` failed because the token was already
/// redeemed (mint error 11001, see `mint_client::describe_mint_error_code`) —
/// a terminal condition, not a reason to retry. Seen on a deployed node,
/// 2026-09-15: a claim that had already been swept kept failing this way on
/// every poll forever, since nothing distinguished it from a transient
/// failure worth retrying.
fn is_already_redeemed(err: &anyhow::Error) -> bool {
err.is::<super::mint_client::AlreadyRedeemed>()
}
const NO_CLAIMS: ClaimOutcome = ClaimOutcome { const NO_CLAIMS: ClaimOutcome = ClaimOutcome {
claimed_count: 0, claimed_count: 0,
received_sats: 0, received_sats: 0,
@@ -697,38 +712,58 @@ fn outcome_with_latest_receipt(
/// three real payments that `/claim` never surfaced. Best-effort: a relay /// three real payments that `/claim` never surfaced. Best-effort: a relay
/// error here must not abort the poll, since `pending_claims` may still hold /// error here must not abort the poll, since `pending_claims` may still hold
/// earlier fetches worth retrying. /// earlier fetches worth retrying.
///
/// Queries `RELAY_URL` (the service's own relay) alone first — the happy
/// path for a poll is one WebSocket connection, not three, and the wallet's
/// derived Nostr pubkey isn't broadcast to the public fallback relays unless
/// it's actually needed. Only when that relay is unreachable does it fall
/// back to all of `CLAIM_RELAY_URLS`. Results are paged (capped at
/// `CLAIM_MAX_PAGES`) since a relay returns only the newest `limit` events for
/// a filter. A durable backward cursor keeps older pages reachable even after
/// newly queued claims advance the normal forward watermark.
async fn fetch_relay_dms( async fn fetch_relay_dms(
our_pubkey: nostr_sdk::PublicKey, our_pubkey: nostr_sdk::PublicKey,
server_pubkey: nostr_sdk::PublicKey, server_pubkey: nostr_sdk::PublicKey,
since: u64, since: u64,
) -> Vec<(String, u64, String, String)> { resume: Option<RelayScan>,
) -> RelayBatch {
let client = Client::default(); let client = Client::default();
for url in CLAIM_RELAY_URLS { if let Err(e) = client.add_relay(RELAY_URL).await {
if let Err(e) = client.add_relay(*url).await { warn!("Minibits: could not add relay {RELAY_URL}: {e}");
warn!("Minibits: could not add relay {url}: {e}"); }
let primary_reachable = client
.try_connect_relay(RELAY_URL, std::time::Duration::from_secs(3))
.await
.is_ok();
if !primary_reachable {
warn!("Minibits: primary relay {RELAY_URL} unreachable, falling back to public relays too");
for url in &CLAIM_RELAY_URLS[1..] {
if let Err(e) = client.add_relay(*url).await {
warn!("Minibits: could not add relay {url}: {e}");
}
} }
client.connect().await;
} }
client.connect().await;
// Give relays a moment to finish the WebSocket handshake before the // Give relays a moment to finish the WebSocket handshake before the
// fetch's own timeout starts consuming that time. // fetch's own timeout starts consuming that time.
tokio::time::sleep(std::time::Duration::from_millis(400)).await; tokio::time::sleep(std::time::Duration::from_millis(400)).await;
// Nostr timestamps have one-second resolution. Query the boundary second let batch = collect_relay_pages(since, resume, |scan| {
// inclusively: a later-published payment may legitimately share that let client = &client;
// timestamp. `seen_dm_ids` performs the exact deduplication locally. async move {
let filter = Filter::new() let mut filter = Filter::new()
.author(server_pubkey) .author(server_pubkey)
.pubkey(our_pubkey) .pubkey(our_pubkey)
.kind(Kind::from(4u16)) .kind(Kind::from(4u16))
.since(Timestamp::from(since)) .since(Timestamp::from(scan.since))
.limit(200); .limit(scan.limit);
if let Some(until) = scan.until {
let result = match client filter = filter.until(Timestamp::from(until));
.fetch_events(filter, std::time::Duration::from_secs(5)) }
.await let events = client
{ .fetch_events(filter, std::time::Duration::from_secs(5))
Ok(events) => { .await?;
let mut out: Vec<(String, u64, String, String)> = events Ok(events
.into_iter() .into_iter()
.map(|e| { .map(|e| {
( (
@@ -738,18 +773,83 @@ async fn fetch_relay_dms(
e.id.to_hex(), e.id.to_hex(),
) )
}) })
.collect(); .collect())
out.sort_by_key(|(_, created_at, _, _)| *created_at);
out
} }
Err(e) => { })
warn!("Minibits: relay fetch for claim DMs failed: {e}"); .await;
Vec::new()
}
};
client.shutdown().await; client.shutdown().await;
result batch
}
const CLAIM_PAGE_LIMIT: usize = 200;
const CLAIM_MAX_PAGES: usize = 5;
type RelayDm = (String, u64, String, String);
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct RelayScan {
since: u64,
until: Option<u64>,
limit: usize,
}
struct RelayBatch {
dms: Vec<RelayDm>,
resume: Option<RelayScan>,
}
/// NIP-01 returns newest events first. Walk backward with an inclusive `until`
/// boundary, deduplicating event ids. A full boundary second needs a larger
/// limit, not `until - 1`, which would skip payments sharing that timestamp.
/// Persist the cursor at the page cap or on failure so older claims cannot be
/// hidden by the newest timestamp already queued in `last_dm_seen_at`.
async fn collect_relay_pages<F, Fut>(
since: u64,
resume: Option<RelayScan>,
mut fetch: F,
) -> RelayBatch
where
F: FnMut(RelayScan) -> Fut,
Fut: std::future::Future<Output = Result<Vec<RelayDm>>>,
{
let mut scan = resume.unwrap_or(RelayScan {
since,
until: None,
limit: CLAIM_PAGE_LIMIT,
});
let mut out = Vec::new();
let mut ids = std::collections::HashSet::new();
let mut resume = Some(scan);
for _ in 0..CLAIM_MAX_PAGES {
let events = match fetch(scan).await {
Ok(events) => events,
Err(e) => {
warn!("Minibits: relay fetch failed; preserving scan cursor: {e}");
break;
}
};
let count = events.len();
let oldest = events.iter().map(|e| e.1).min();
for event in events {
if ids.insert(event.3.clone()) {
out.push(event);
}
}
if count < scan.limit {
resume = None;
break;
}
if let Some(oldest) = oldest {
if scan.until == Some(oldest) {
scan.limit = scan.limit.saturating_add(CLAIM_PAGE_LIMIT);
} else {
scan.until = Some(oldest);
scan.limit = CLAIM_PAGE_LIMIT;
}
}
resume = Some(scan);
}
out.sort_by(|a, b| (a.1, &a.3).cmp(&(b.1, &b.3)));
RelayBatch { dms: out, resume }
} }
fn queue_relay_dm( fn queue_relay_dm(
@@ -907,8 +1007,15 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
// NIP-04 DM on relays, not via `/claim` above. `since` is our own // NIP-04 DM on relays, not via `/claim` above. `since` is our own
// watermark (Nostr events never expire off a relay, so without it we'd // watermark (Nostr events never expire off a relay, so without it we'd
// re-fetch and re-attempt every claim ever sent on every poll). // re-fetch and re-attempt every claim ever sent on every poll).
let dms = fetch_relay_dms(identity.keys.public_key(), server_pk, state.last_dm_seen_at).await; let batch = fetch_relay_dms(
for (content, created_at, author, event_id) in dms { identity.keys.public_key(),
server_pk,
state.last_dm_seen_at,
state.relay_scan,
)
.await;
state.relay_scan = batch.resume;
for (content, created_at, author, event_id) in batch.dms {
if author != state.server_nostr_pubkey { if author != state.server_nostr_pubkey {
warn!("Minibits: ignoring claim DM from unexpected pubkey {author}"); warn!("Minibits: ignoring claim DM from unexpected pubkey {author}");
continue; continue;
@@ -964,6 +1071,14 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
sats += got; sats += got;
info!("Minibits: redeemed a claimed payment ({got} sats)"); info!("Minibits: redeemed a claimed payment ({got} sats)");
} }
Err(e) if is_already_redeemed(&e) => {
// Terminal: the value was already swept (a relay-watermark
// replay, or a claim redeemed by an earlier run before a
// crash lost track of it). Retrying can never succeed, so
// drop it instead of leaving `failed_count` stuck non-zero
// forever — see archy-x250-pa3, 2026-09-15.
info!("Minibits mint reports this claim was already redeemed; removing it from the retry queue");
}
Err(e) => { Err(e) => {
warn!("Minibits claim decrypted but failed to redeem ({e}); will retry next poll"); warn!("Minibits claim decrypted but failed to redeem ({e}); will retry next poll");
still_pending.push(claim.clone()); still_pending.push(claim.clone());
@@ -994,6 +1109,123 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
fn simulated_relay_page(events: &[RelayDm], scan: RelayScan) -> Vec<RelayDm> {
let mut page: Vec<_> = events
.iter()
.filter(|e| e.1 >= scan.since && scan.until.is_none_or(|until| e.1 <= until))
.cloned()
.collect();
page.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.3.cmp(&b.3)));
page.truncate(scan.limit);
page
}
fn relay_fixture(count: usize, same_second: bool) -> Vec<RelayDm> {
(1..=count)
.map(|n| {
(
format!("claim-{n}"),
if same_second { 100 } else { n as u64 },
"service".into(),
format!("id-{n:06}"),
)
})
.collect()
}
#[tokio::test]
async fn relay_paging_fetches_older_claims_in_newest_first_backlog() {
let events = relay_fixture(450, false);
let batch = collect_relay_pages(0, None, |scan| {
std::future::ready(Ok(simulated_relay_page(&events, scan)))
})
.await;
assert_eq!(batch.dms.len(), 450);
assert!(batch.resume.is_none());
assert_eq!(batch.dms.first().unwrap().1, 1);
assert_eq!(batch.dms.last().unwrap().1, 450);
}
#[tokio::test]
async fn relay_paging_preserves_payments_at_the_same_timestamp() {
let events = relay_fixture(250, true);
let batch = collect_relay_pages(100, None, |scan| {
std::future::ready(Ok(simulated_relay_page(&events, scan)))
})
.await;
assert_eq!(batch.dms.len(), 250);
assert!(batch.resume.is_none());
}
#[tokio::test]
async fn relay_page_cap_resumes_older_claims_after_watermark_advances() {
let events = relay_fixture(1300, false);
let mut state = MinibitsState::default();
let first = collect_relay_pages(0, None, |scan| {
std::future::ready(Ok(simulated_relay_page(&events, scan)))
})
.await;
assert!(first.resume.is_some());
state.relay_scan = first.resume;
let mut ids = std::collections::HashSet::new();
for (content, time, author, id) in first.dms {
ids.insert(id.clone());
queue_relay_dm(&mut state, content, time, id, author);
}
assert_eq!(state.last_dm_seen_at, 1300);
let state: MinibitsState =
serde_json::from_str(&serde_json::to_string(&state).unwrap()).unwrap();
let second = collect_relay_pages(state.last_dm_seen_at, state.relay_scan, |scan| {
std::future::ready(Ok(simulated_relay_page(&events, scan)))
})
.await;
assert!(second.resume.is_none());
ids.extend(second.dms.into_iter().map(|e| e.3));
assert_eq!(ids.len(), 1300);
}
#[tokio::test]
async fn relay_fetch_failure_keeps_the_unfinished_page_cursor() {
let events = relay_fixture(450, false);
let mut requests = 0;
let first = collect_relay_pages(0, None, |scan| {
requests += 1;
std::future::ready(if requests == 1 {
Ok(simulated_relay_page(&events, scan))
} else {
Err(anyhow!("relay timeout"))
})
})
.await;
assert_eq!(first.dms.len(), 200);
assert_eq!(first.resume.unwrap().until, Some(251));
let second = collect_relay_pages(450, first.resume, |scan| {
std::future::ready(Ok(simulated_relay_page(&events, scan)))
})
.await;
let ids: std::collections::HashSet<_> = first
.dms
.into_iter()
.chain(second.dms)
.map(|e| e.3)
.collect();
assert_eq!(ids.len(), 450);
}
#[test]
fn only_typed_spent_claims_are_terminal_even_with_wrapped_errors() {
let spent = anyhow::Error::new(super::super::mint_client::AlreadyRedeemed)
.context("receive token")
.context("claim failed");
assert!(is_already_redeemed(&spent));
assert!(!is_already_redeemed(&anyhow!(
super::super::mint_client::ALREADY_REDEEMED_MSG
)));
assert!(!is_already_redeemed(&anyhow!(
"mint temporarily unreachable"
)));
}
use super::*; use super::*;
#[test] #[test]
+125 -12
View File
@@ -71,10 +71,28 @@ pub struct MintResult {
/// keyset codes shared by NUT-02/03/04/05 — the codes a swap/melt/mint call /// 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 /// 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`. /// codes in the 20000s) so the caller falls back to the mint's own `detail`.
///
/// Text of the NUT error-code-11001 translation, exposed so callers that
/// received an `anyhow::Error` from a receive/redeem path (e.g. a replayed
/// Minibits claim) can recognize an already-spent token as terminal rather
/// than retrying it forever.
pub const ALREADY_REDEEMED_MSG: &str =
"This ecash has already been redeemed — it can't be claimed twice.";
/// Typed terminal condition: never infer spent proofs from a mint's free text.
#[derive(Debug)]
pub(super) struct AlreadyRedeemed;
impl std::fmt::Display for AlreadyRedeemed {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(ALREADY_REDEEMED_MSG)
}
}
impl std::error::Error for AlreadyRedeemed {}
fn describe_mint_error_code(code: i64) -> Option<&'static str> { fn describe_mint_error_code(code: i64) -> Option<&'static str> {
Some(match code { Some(match code {
10001 => "The mint rejected these coins as invalid.", 10001 => "The mint rejected these coins as invalid.",
11001 => "This ecash has already been redeemed — it can't be claimed twice.", 11001 => ALREADY_REDEEMED_MSG,
11002 => "This ecash is already being redeemed elsewhere — try again in a moment.", 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.", 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.", 11004 => "This request is still being processed by the mint — try again in a moment.",
@@ -96,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
@@ -124,8 +183,15 @@ fn describe_mint_error_body(status: reqwest::StatusCode, body: &str) -> String {
/// translation layered on top via `.context()` so `{}` — what reaches the /// translation layered on top via `.context()` so `{}` — what reaches the
/// wallet user — shows something actionable instead of raw mint JSON. /// wallet user — shows something actionable instead of raw mint JSON.
fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error { fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error {
let friendly = describe_mint_error_body(status, body); let cause = anyhow::anyhow!("{} failed ({}): {}", op, status, body);
anyhow::anyhow!("{} failed ({}): {}", op, status, body).context(friendly) if serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| v.get("code").and_then(|c| c.as_i64()))
== Some(11001)
{
return cause.context(AlreadyRedeemed);
}
cause.context(describe_mint_error_body(status, body))
} }
/// HTTP client for a single Cashu mint. /// HTTP client for a single Cashu mint.
@@ -717,7 +783,7 @@ impl MintClient {
/// verification at the mint and no coins move. Anything already valid, or /// verification at the mint and no coins move. Anything already valid, or
/// with no unambiguous match, is passed through untouched so the mint's /// with no unambiguous match, is passed through untouched so the mint's
/// own error is what the operator sees. /// 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)); let needs_repair = proofs.iter().any(|p| is_truncated_v2_keyset_id(&p.id));
if !needs_repair { if !needs_repair {
return proofs.to_vec(); return proofs.to_vec();
@@ -803,6 +869,53 @@ impl MintClient {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn spent_condition_comes_from_code_not_remote_text_and_survives_context() {
let spent = super::mint_error(
"Swap",
reqwest::StatusCode::BAD_REQUEST,
r#"{"code":11001,"detail":"Token Already Spent"}"#,
)
.context("Receive failed");
assert!(spent.is::<super::AlreadyRedeemed>());
let body =
serde_json::json!({"code":11002,"detail":super::ALREADY_REDEEMED_MSG}).to_string();
assert!(
!super::mint_error("Swap", reqwest::StatusCode::BAD_REQUEST, &body)
.is::<super::AlreadyRedeemed>()
);
let body = serde_json::json!({"detail":super::ALREADY_REDEEMED_MSG}).to_string();
assert!(
!super::mint_error("Swap", reqwest::StatusCode::BAD_GATEWAY, &body)
.is::<super::AlreadyRedeemed>()
);
}
#[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]
@@ -0,0 +1,119 @@
# Incident — 2026-09-15: Minibits Cashu claim stuck retrying an already-redeemed token
## Report
User: "The cashu server is unable to get it's tokens from nostr on
[affected node]" — clarified as the Cashu **client wallet**
(Minibits `@minibits.cash` Lightning-address receive flow), not a mint
server. UI showed: *"a payment arrived but couldn't be redeemed yet (1)"*.
## Root cause
`wallet::minibits::claim_and_redeem` (`core/archipelago/src/wallet/minibits.rs`)
polls Nostr relays for NIP-04-encrypted Cashu tokens sent to the node's
`@minibits.cash` address, decrypts them, and redeems them at the mint. A
token that fails to redeem is kept in `MinibitsState.pending_claims` and
retried on the next poll — by design, so a *transient* failure (mint briefly
down, decrypt hiccup) never drops real money.
But one queued claim had already been redeemed (mint error **11001 "Token
Already Spent"** — most likely double-delivered by the relay, or redeemed
by an earlier run before a crash lost track of it). That's a *terminal*
condition, not a transient one: the code didn't distinguish the two, so it
retried the same dead claim every ~6 seconds forever:
```
WARN archipelago::wallet::ecash: Failed to swap proofs from mint https://mint.minibits.cash/Bitcoin:
This ecash has already been redeemed — it can't be claimed twice.: {"code":11001,"detail":"Token Already Spent"}
WARN archipelago::wallet::minibits: Minibits claim decrypted but failed to redeem (...); will retry next poll
```
Confirmed via `sudo journalctl -u archipelago.service` on the affected node,
and via `/var/lib/archipelago/wallet/minibits.json`, which had exactly one
`pending_claims` entry. Each poll also unconditionally queried all three
`CLAIM_RELAY_URLS` (`relay.minibits.cash`, `relay.damus.io`, `nos.lol`)
instead of the primary relay only, adding needless churn and leaking the
wallet's Nostr pubkey to two relays it didn't need to touch — `relay.damus.io`
was additionally failing NIP-42 auth / 503ing on every poll.
**No funds were at risk** — an already-redeemed token has zero remaining
value. The only symptom was a permanently stuck "couldn't be redeemed yet"
banner and wasted relay connections.
### Why this had already been "fixed" once and came back
This exact bug (terminal-11001 handling + relay-query reduction) was fixed
on 2026-09-09 on branch `feat/minibits-lnurl-receive` (commits `4e410d7`,
`489995c`) and pushed to `origin`. **That branch was never merged into
`main`.** `main` carries its own, independently-diverged rewrite of
`minibits.rs` that never got those two hardening fixes. The affected node
OTA'd to `1.8.16-alpha` (built from `main`) earlier on 2026-09-15, so the bug
resurfaced on the first replayed/double-delivered claim after that update.
## Fix
Two parts:
### 1. Immediate unstick (affected node, operational, no code change)
- Backed up `/var/lib/archipelago/wallet/minibits.json`.
- Stopped `archipelago.service`, emptied `pending_claims` (`[]`) in the
state file, restarted the service.
- Verified via `journalctl` that polling resumed cleanly with no further
"already been redeemed" warnings.
### 2. Code fix, ported into `main`
- **`core/archipelago/src/wallet/mint_client.rs`**: exposed the existing
NUT error-code-11001 translation as a public constant,
`ALREADY_REDEEMED_MSG`, and a typed `AlreadyRedeemed` condition identified
only by the structured mint error code. Remote text cannot impersonate it.
- **`core/archipelago/src/wallet/minibits.rs`**:
- Added `is_already_redeemed(&anyhow::Error) -> bool`, checking the error
chain for the typed `AlreadyRedeemed` condition. The ecash receive path
preserves it only when all failed mint entries report already-spent proofs;
mixed terminal/transient failures remain retryable.
- In the claim redeem loop, a redeem failure matching
`is_already_redeemed` is now dropped (logged at `info!`, not retried)
instead of being pushed back onto `pending_claims`. Every other failure
still retries next poll, unchanged.
- `fetch_relay_dms` now connects to `RELAY_URL` (the Minibits relay)
alone first via `try_connect_relay`, and only adds the two public
fallback relays (`relay.damus.io`, `nos.lol`) if that primary relay is
unreachable. Also paginates the DM fetch (200/page, capped at 5 pages)
backward with an inclusive `until` boundary. The cursor persists across
polls when capped or interrupted, independently of the forward watermark.
A full same-second boundary is fetched with a larger limit rather than
skipped, so multiple payments sharing a timestamp remain reachable.
Deliberately **not** ported from the unmerged branch: its `STATE_LOCK`
skip-if-busy guard and per-claim attempt-count backstop. `main`'s existing
`MINIBITS_STATE_LOCK` already fully serializes claim polls (blocks rather
than skips — a different but equally valid way to close the same race), and
an attempt-count backstop would have required reshaping the `PendingClaim`
enum for marginal extra protection beyond what the 11001 fix already covers.
## Verification
- `cargo build -p archipelago` — clean, no new warnings.
- `cargo test -p archipelago --bin archipelago wallet::minibits` — existing
suite still green (see PR/commit for the run).
- Live on the affected node: claim poll loop confirmed quiet post-unstick
(only `relay.minibits.cash` connects logged, no redeem-failure warnings).
## Lesson (recorded in memory)
A fix that lives only on an unmerged feature branch is not a fix that's
actually deployed. Before trusting a memory or changelog claim that
something "shipped," check which branch the running/released build was
built from (`git log <branch>..main` / `main..<branch>`) rather than
assuming a pushed branch was merged.
## Pre-merge review regressions
- A 450-event newest-first backlog is completely fetched.
- 250 distinct payments sharing one timestamp are preserved.
- A 1,300-event backlog resumes after the five-page cap and a state reload.
- An interrupted relay fetch retains its unfinished cursor.
- Only structured error 11001 is terminal, including when errors are wrapped;
remote free text and mixed mint failures cannot discard a retryable claim.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"version": "1.8.16-alpha", "version": "1.8.17-alpha",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "neode-ui", "name": "neode-ui",
"version": "1.8.16-alpha", "version": "1.8.17-alpha",
"dependencies": { "dependencies": {
"@scure/bip39": "^2.2.0", "@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"private": true, "private": true,
"version": "1.8.16-alpha", "version": "1.8.17-alpha",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "./start-dev.sh", "start": "./start-dev.sh",
@@ -362,6 +362,19 @@ init()
</button> </button>
</div> </div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1"> <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 --> <!-- v1.8.16-alpha -->
<div> <div>
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">
+17 -17
View File
@@ -1,30 +1,30 @@
{ {
"changelog": [ "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.", "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.",
"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.", "Minibits polls its primary relay first and connects to public fallback relays only when the primary is unreachable, reducing unnecessary connections.",
"Updates inspect installed component versions, refuse known downgrades, skip containers already at the target versions, and verify the resulting versions before reporting success.", "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 stale catalogs, matching versions, publisher namespace changes, stack component updates, and keeping running containers untouched when no upgrade is needed." "Added regression coverage for spent-claim classification, wrapped and mixed mint errors, same-second payments, and interrupted or multi-poll backlogs."
], ],
"components": [ "components": [
{ {
"current_version": "1.8.16-alpha", "current_version": "1.8.17-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.16-alpha/archipelago", "download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago",
"name": "archipelago", "name": "archipelago",
"new_version": "1.8.16-alpha", "new_version": "1.8.17-alpha",
"sha256": "1800f57678a0b994ab2e43a830ef06d1c96fd3cc7be47ce4e6e46b7df8a5420f", "sha256": "32a7b009eb58f8c9f256e6597711a77ded11e15d5865a3fe16901603264e1f70",
"size_bytes": 64851944 "size_bytes": 64953344
}, },
{ {
"current_version": "1.8.16-alpha", "current_version": "1.8.17-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.16-alpha/archipelago-frontend-1.8.16-alpha.tar.gz", "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.16-alpha.tar.gz", "name": "archipelago-frontend-1.8.17-alpha.tar.gz",
"new_version": "1.8.16-alpha", "new_version": "1.8.17-alpha",
"sha256": "7dd73c50a54bc530385d9e450a18cbff9c3f4ffaf289a2a7b21e5d3803116722", "sha256": "faf692e9a0e16268357bcac2bf86b62950ae49663e3c95982e54a132bb761980",
"size_bytes": 98799570 "size_bytes": 98801608
} }
], ],
"release_date": "2026-09-15", "release_date": "2026-09-15",
"signature": "083b131a6b895e1ff8fb9e9a52b1ead260e2140081a0295ae6756cbbc4f8f2c30e8a8bc72822c905702e21ac90f7cb85d5cca9b5f8c10fc87f32a365da202c0d", "signature": "c8196fe278a5747b3c3ba3bf70998874f1e3e6eedbdab33b9e33c3339a3769ab4431f41d99924ec4cdd15a5ffed299a5af786c7ab5e9d084cdc11beabbee9103",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.16-alpha" "version": "1.8.17-alpha"
} }
+17 -17
View File
@@ -1,30 +1,30 @@
{ {
"changelog": [ "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.", "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.",
"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.", "Minibits polls its primary relay first and connects to public fallback relays only when the primary is unreachable, reducing unnecessary connections.",
"Updates inspect installed component versions, refuse known downgrades, skip containers already at the target versions, and verify the resulting versions before reporting success.", "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 stale catalogs, matching versions, publisher namespace changes, stack component updates, and keeping running containers untouched when no upgrade is needed." "Added regression coverage for spent-claim classification, wrapped and mixed mint errors, same-second payments, and interrupted or multi-poll backlogs."
], ],
"components": [ "components": [
{ {
"current_version": "1.8.16-alpha", "current_version": "1.8.17-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.16-alpha/archipelago", "download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago",
"name": "archipelago", "name": "archipelago",
"new_version": "1.8.16-alpha", "new_version": "1.8.17-alpha",
"sha256": "1800f57678a0b994ab2e43a830ef06d1c96fd3cc7be47ce4e6e46b7df8a5420f", "sha256": "32a7b009eb58f8c9f256e6597711a77ded11e15d5865a3fe16901603264e1f70",
"size_bytes": 64851944 "size_bytes": 64953344
}, },
{ {
"current_version": "1.8.16-alpha", "current_version": "1.8.17-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.16-alpha/archipelago-frontend-1.8.16-alpha.tar.gz", "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.16-alpha.tar.gz", "name": "archipelago-frontend-1.8.17-alpha.tar.gz",
"new_version": "1.8.16-alpha", "new_version": "1.8.17-alpha",
"sha256": "7dd73c50a54bc530385d9e450a18cbff9c3f4ffaf289a2a7b21e5d3803116722", "sha256": "faf692e9a0e16268357bcac2bf86b62950ae49663e3c95982e54a132bb761980",
"size_bytes": 98799570 "size_bytes": 98801608
} }
], ],
"release_date": "2026-09-15", "release_date": "2026-09-15",
"signature": "083b131a6b895e1ff8fb9e9a52b1ead260e2140081a0295ae6756cbbc4f8f2c30e8a8bc72822c905702e21ac90f7cb85d5cca9b5f8c10fc87f32a365da202c0d", "signature": "c8196fe278a5747b3c3ba3bf70998874f1e3e6eedbdab33b9e33c3339a3769ab4431f41d99924ec4cdd15a5ffed299a5af786c7ab5e9d084cdc11beabbee9103",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.16-alpha" "version": "1.8.17-alpha"
} }