Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abfbccc906 | ||
|
|
9d4e74e094 | ||
|
|
db355b759c | ||
|
|
31d77f01ac |
@@ -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;
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user