Compare commits

..
Author SHA1 Message Date
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
archipelago 31d77f01ac chore: publish release v1.8.16-alpha
Demo images / Build & push demo images (push) Failing after 34s
2026-09-15 04:02:57 -04:00
7 changed files with 497 additions and 105 deletions
+26 -4
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
// *why* instead of just "nothing was received".
let mut last_reason: Option<String> = None;
let mut all_already_redeemed = true;
// Swap proofs at each mint
for entry in &token.token {
@@ -1217,6 +1218,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
}
Err(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());
// 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 {
match last_reason {
Some(reason) => anyhow::bail!("Could not receive this ecash: {}", reason),
None => anyhow::bail!("Failed to receive any proofs from token"),
}
return Err(receive_failure(last_reason, all_already_redeemed));
}
wallet.record_tx(
@@ -1243,6 +1242,17 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
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}).
/// For backwards compatibility during migration period.
async fn receive_legacy_token(data_dir: &Path, token_str: &str) -> Result<u64> {
@@ -1632,6 +1642,18 @@ fn default_mint_url() -> String {
#[cfg(test)]
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 tempfile::TempDir;
+266 -34
View File
@@ -47,7 +47,8 @@
//! key, a crash mid-loop) must not silently lose the coins, so every fetched
//! token is persisted to `MinibitsState::pending_claims` *before* decrypt/
//! 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.
//! Separately, `ensure_mint_accepted` keeps the Minibits mint on the node's
//! 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.
#[serde(default)]
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
/// one-second resolution, so a strict `since = last + 1` watermark can
/// 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(),
pending_claims: Vec::new(),
last_dm_seen_at: 0,
relay_scan: None,
seen_dm_ids: Vec::new(),
last_receipt_id: 0,
last_receipt_sats: 0,
@@ -652,6 +657,16 @@ pub struct ClaimOutcome {
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 {
claimed_count: 0,
received_sats: 0,
@@ -697,38 +712,58 @@ fn outcome_with_latest_receipt(
/// three real payments that `/claim` never surfaced. Best-effort: a relay
/// error here must not abort the poll, since `pending_claims` may still hold
/// 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(
our_pubkey: nostr_sdk::PublicKey,
server_pubkey: nostr_sdk::PublicKey,
since: u64,
) -> Vec<(String, u64, String, String)> {
resume: Option<RelayScan>,
) -> RelayBatch {
let client = Client::default();
for url in CLAIM_RELAY_URLS {
if let Err(e) = client.add_relay(*url).await {
warn!("Minibits: could not add relay {url}: {e}");
if let Err(e) = client.add_relay(RELAY_URL).await {
warn!("Minibits: could not add relay {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
// fetch's own timeout starts consuming that time.
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
// Nostr timestamps have one-second resolution. Query the boundary second
// inclusively: a later-published payment may legitimately share that
// timestamp. `seen_dm_ids` performs the exact deduplication locally.
let filter = Filter::new()
.author(server_pubkey)
.pubkey(our_pubkey)
.kind(Kind::from(4u16))
.since(Timestamp::from(since))
.limit(200);
let result = match client
.fetch_events(filter, std::time::Duration::from_secs(5))
.await
{
Ok(events) => {
let mut out: Vec<(String, u64, String, String)> = events
let batch = collect_relay_pages(since, resume, |scan| {
let client = &client;
async move {
let mut filter = Filter::new()
.author(server_pubkey)
.pubkey(our_pubkey)
.kind(Kind::from(4u16))
.since(Timestamp::from(scan.since))
.limit(scan.limit);
if let Some(until) = scan.until {
filter = filter.until(Timestamp::from(until));
}
let events = client
.fetch_events(filter, std::time::Duration::from_secs(5))
.await?;
Ok(events
.into_iter()
.map(|e| {
(
@@ -738,18 +773,83 @@ async fn fetch_relay_dms(
e.id.to_hex(),
)
})
.collect();
out.sort_by_key(|(_, created_at, _, _)| *created_at);
out
.collect())
}
Err(e) => {
warn!("Minibits: relay fetch for claim DMs failed: {e}");
Vec::new()
}
};
})
.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(
@@ -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
// 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).
let dms = fetch_relay_dms(identity.keys.public_key(), server_pk, state.last_dm_seen_at).await;
for (content, created_at, author, event_id) in dms {
let batch = fetch_relay_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 {
warn!("Minibits: ignoring claim DM from unexpected pubkey {author}");
continue;
@@ -964,6 +1071,14 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
sats += got;
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) => {
warn!("Minibits claim decrypted but failed to redeem ({e}); will retry next poll");
still_pending.push(claim.clone());
@@ -994,6 +1109,123 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
#[cfg(test)]
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::*;
#[test]
+50 -3
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
/// 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`.
///
/// 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> {
Some(match code {
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.",
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.",
@@ -124,8 +142,15 @@ fn describe_mint_error_body(status: reqwest::StatusCode, body: &str) -> String {
/// translation layered on top via `.context()` so `{}` — what reaches the
/// wallet user — shows something actionable instead of raw mint JSON.
fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error {
let friendly = describe_mint_error_body(status, body);
anyhow::anyhow!("{} failed ({}): {}", op, status, body).context(friendly)
let cause = anyhow::anyhow!("{} failed ({}): {}", op, status, body);
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.
@@ -803,6 +828,28 @@ impl MintClient {
#[cfg(test)]
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>()
);
}
use super::*;
#[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.
+18 -17
View File
@@ -1,29 +1,30 @@
{
"changelog": [
"Cuprate is presented as one user-facing app in My Apps, including its UI launch button; the generated dashboard companion is hidden as an implementation detail instead of appearing under Services.",
"Added regression coverage for Cuprate install and installed-state grouping.",
"Release validation was rerun on the corrected tree before OTA and ISO publication."
"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."
],
"components": [
{
"current_version": "1.8.15-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.15-alpha/archipelago",
"current_version": "1.8.16-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.16-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.15-alpha",
"sha256": "3eee71563337f20cb348529925c58b8227afec796783a69176e0b59b9e113c91",
"size_bytes": 64571544
"new_version": "1.8.16-alpha",
"sha256": "1800f57678a0b994ab2e43a830ef06d1c96fd3cc7be47ce4e6e46b7df8a5420f",
"size_bytes": 64851944
},
{
"current_version": "1.8.15-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.15-alpha/archipelago-frontend-1.8.15-alpha.tar.gz",
"name": "archipelago-frontend-1.8.15-alpha.tar.gz",
"new_version": "1.8.15-alpha",
"sha256": "86a32ef3334b03c197e47d9d28f4435c6f2fa7c4ccacc839a8fc4a0a749495dc",
"size_bytes": 98797600
"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
}
],
"release_date": "2026-09-13",
"signature": "5e13396e2f33571f136bb1a9ea0356486b3d42c6ba99ee5d33990cd565d9b1178f61eaf6a70a937a006e7de3fd388bcebd63f2daca4bb28accc1b810d8455d03",
"release_date": "2026-09-15",
"signature": "083b131a6b895e1ff8fb9e9a52b1ead260e2140081a0295ae6756cbbc4f8f2c30e8a8bc72822c905702e21ac90f7cb85d5cca9b5f8c10fc87f32a365da202c0d",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.15-alpha"
"version": "1.8.16-alpha"
}
+18 -17
View File
@@ -1,29 +1,30 @@
{
"changelog": [
"Cuprate is presented as one user-facing app in My Apps, including its UI launch button; the generated dashboard companion is hidden as an implementation detail instead of appearing under Services.",
"Added regression coverage for Cuprate install and installed-state grouping.",
"Release validation was rerun on the corrected tree before OTA and ISO publication."
"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."
],
"components": [
{
"current_version": "1.8.15-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.15-alpha/archipelago",
"current_version": "1.8.16-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.16-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.15-alpha",
"sha256": "3eee71563337f20cb348529925c58b8227afec796783a69176e0b59b9e113c91",
"size_bytes": 64571544
"new_version": "1.8.16-alpha",
"sha256": "1800f57678a0b994ab2e43a830ef06d1c96fd3cc7be47ce4e6e46b7df8a5420f",
"size_bytes": 64851944
},
{
"current_version": "1.8.15-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.15-alpha/archipelago-frontend-1.8.15-alpha.tar.gz",
"name": "archipelago-frontend-1.8.15-alpha.tar.gz",
"new_version": "1.8.15-alpha",
"sha256": "86a32ef3334b03c197e47d9d28f4435c6f2fa7c4ccacc839a8fc4a0a749495dc",
"size_bytes": 98797600
"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
}
],
"release_date": "2026-09-13",
"signature": "5e13396e2f33571f136bb1a9ea0356486b3d42c6ba99ee5d33990cd565d9b1178f61eaf6a70a937a006e7de3fd388bcebd63f2daca4bb28accc1b810d8455d03",
"release_date": "2026-09-15",
"signature": "083b131a6b895e1ff8fb9e9a52b1ead260e2140081a0295ae6756cbbc4f8f2c30e8a8bc72822c905702e21ac90f7cb85d5cca9b5f8c10fc87f32a365da202c0d",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.15-alpha"
"version": "1.8.16-alpha"
}
@@ -1,30 +0,0 @@
{
"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."
],
"components": [
{
"current_version": "1.8.16-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.16-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.16-alpha",
"sha256": "1800f57678a0b994ab2e43a830ef06d1c96fd3cc7be47ce4e6e46b7df8a5420f",
"size_bytes": 64851944
},
{
"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
}
],
"release_date": "2026-09-15",
"signature": "083b131a6b895e1ff8fb9e9a52b1ead260e2140081a0295ae6756cbbc4f8f2c30e8a8bc72822c905702e21ac90f7cb85d5cca9b5f8c10fc87f32a365da202c0d",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.16-alpha"
}