diff --git a/core/archipelago/src/wallet/ecash.rs b/core/archipelago/src/wallet/ecash.rs index 2015bc19..e77647a8 100644 --- a/core/archipelago/src/wallet/ecash.rs +++ b/core/archipelago/src/wallet/ecash.rs @@ -1205,6 +1205,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result { // for the log. Remember the last one so a total failure can tell the user // *why* instead of just "nothing was received". let mut last_reason: Option = None; + 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 { } Err(e) => { warn!("Failed to swap proofs from mint {}: {:#}", entry.mint, e); + all_already_redeemed &= e.is::(); 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 { } 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 { Ok(received_total) } +fn receive_failure(last_reason: Option, 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 { @@ -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::()); + assert!(!super::receive_failure(Some(reason), false) + .is::()); + assert!( + !super::receive_failure(None, true).is::() + ); + } + use super::*; use tempfile::TempDir; diff --git a/core/archipelago/src/wallet/minibits.rs b/core/archipelago/src/wallet/minibits.rs index 16aaed32..087178b9 100644 --- a/core/archipelago/src/wallet/minibits.rs +++ b/core/archipelago/src/wallet/minibits.rs @@ -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, /// 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::() +} + 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, +) -> 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, + limit: usize, +} + +struct RelayBatch { + dms: Vec, + resume: Option, +} + +/// 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( + since: u64, + resume: Option, + mut fetch: F, +) -> RelayBatch +where + F: FnMut(RelayScan) -> Fut, + Fut: std::future::Future>>, +{ + 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 { // 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 { 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 { #[cfg(test)] mod tests { + fn simulated_relay_page(events: &[RelayDm], scan: RelayScan) -> Vec { + 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 { + (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] diff --git a/core/archipelago/src/wallet/mint_client.rs b/core/archipelago/src/wallet/mint_client.rs index 2da75edd..83950898 100644 --- a/core/archipelago/src/wallet/mint_client.rs +++ b/core/archipelago/src/wallet/mint_client.rs @@ -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::(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::()); + 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::() + ); + let body = serde_json::json!({"detail":super::ALREADY_REDEEMED_MSG}).to_string(); + assert!( + !super::mint_error("Swap", reqwest::StatusCode::BAD_GATEWAY, &body) + .is::() + ); + } + use super::*; #[test] diff --git a/docs/incident-2026-09-15-minibits-already-redeemed.md b/docs/incident-2026-09-15-minibits-already-redeemed.md new file mode 100644 index 00000000..f367023e --- /dev/null +++ b/docs/incident-2026-09-15-minibits-already-redeemed.md @@ -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 ..main` / `main..`) 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.