From e5a0d95459e890b8a0d43ac1ed290b0f41d104b6 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Tue, 8 Sep 2026 22:23:56 +0000 Subject: [PATCH] fix(ecash): fetch Minibits claims from Nostr relays, not the dead /claim REST poll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed live 2026-09-08 against three real Lightning payments to a registered @minibits.cash address: POST /claim (the only claim source claim_and_redeem checked) always returned an empty array, no matter how long or how often it was polled. Independently queried wss://relay.minibits.cash and found all three payments sitting there as NIP-04-encrypted kind-4 DMs, #p-tagged to the wallet's own Nostr pubkey and authored by the Minibits service key — that is the actual delivery channel for a payment made to the address, and this module never looked at it. fetch_relay_dms queries CLAIM_RELAY_URLS (the service's own relay plus two public fallbacks) for kind-4 events tagged to our pubkey, feeding matching content into the existing pending_claims retry pipeline unchanged. A new last_dm_seen_at watermark stops the same (immutable, never-expiring) relay event from being re-fetched and re-attempted on every poll. The REST /claim call stays in place alongside it in case it serves some other payment path — this only adds the missing one. fix(ecash): trim stray whitespace before parsing a cashuA/cashuB token Once the relay fix above surfaced the three real payments, all three failed to redeem with "Invalid base64 in cashuB token" — the decrypted NIP-04 content had a trailing space after the base64 payload (Minibits' own encoding), which every base64 alphabet in decode_token_base64 rejects outright. CashuToken::deserialize now trims the whole token string before touching the "cashuA"/"cashuB" prefix or payload. This is a general robustness fix, not just a Minibits workaround — the same stray-whitespace failure could hit a hand-pasted token from a clipboard copy just as easily. Both fixes verified end-to-end against production: all three stuck payments (20 + 5 + 20 = 45 sats) redeemed cleanly on the first poll after deploying this build to archy-x250-pa3. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a --- core/archipelago/src/wallet/cashu.rs | 47 +++++++++++ core/archipelago/src/wallet/minibits.rs | 108 +++++++++++++++++++++++- 2 files changed, 152 insertions(+), 3 deletions(-) diff --git a/core/archipelago/src/wallet/cashu.rs b/core/archipelago/src/wallet/cashu.rs index ebc7fbc1..1b541e7a 100644 --- a/core/archipelago/src/wallet/cashu.rs +++ b/core/archipelago/src/wallet/cashu.rs @@ -207,7 +207,15 @@ impl CashuToken { } /// Decode a cashuA (V3 JSON) or cashuB (V4 CBOR) token string. + /// + /// Trims surrounding whitespace first: a token can arrive with stray + /// leading/trailing whitespace from a clipboard paste, or (confirmed + /// live, 2026-09-08) from Minibits' own NIP-04 claim-DM content, which + /// has a trailing space after the base64 — none of the base64 alphabets + /// in `decode_token_base64` tolerate that, so an otherwise-valid token + /// would hard-fail with "Invalid base64" instead of parsing. pub fn deserialize(token_str: &str) -> Result { + let token_str = token_str.trim(); if let Some(payload) = token_str.strip_prefix(CASHU_B_PREFIX) { return Self::deserialize_v4(payload); } @@ -508,6 +516,45 @@ mod tests { assert_eq!(decoded.memo, Some("test token".to_string())); } + /// Regression guard (2026-09-08): a real Minibits claim DM decrypted to + /// a cashuB token with a trailing space after the base64 payload, which + /// made every base64 alphabet in `decode_token_base64` reject it as + /// invalid — three real payments got stuck retrying forever with + /// "Invalid base64 in cashuB token" until `deserialize` started + /// trimming the whole string first. Whitespace can show up around a + /// token from more than one source (clipboard paste included), so this + /// covers cashuA too, and leading as well as trailing. + #[test] + fn deserialize_trims_stray_whitespace() { + let token = CashuToken { + token: vec![TokenEntry { + mint: "http://127.0.0.1:8175".to_string(), + proofs: vec![Proof { + amount: 8, + id: "009a1f293253e41e".to_string(), + secret: "abcdef1234567890".to_string(), + c: "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24" + .to_string(), + }], + }], + memo: None, + unit: Some("sat".to_string()), + }; + let encoded = token.serialize().unwrap(); + assert!(encoded.starts_with("cashuA")); + + for wrapped in [ + format!("{encoded} "), + format!(" {encoded}"), + format!(" {encoded}\n"), + format!("{encoded}\t"), + ] { + let decoded = CashuToken::deserialize(&wrapped) + .unwrap_or_else(|e| panic!("failed on {wrapped:?}: {e}")); + assert_eq!(decoded.total_amount(), 8); + } + } + #[test] fn test_total_amount_multi_proof() { let token = CashuToken { diff --git a/core/archipelago/src/wallet/minibits.rs b/core/archipelago/src/wallet/minibits.rs index 713784d2..7471e10f 100644 --- a/core/archipelago/src/wallet/minibits.rs +++ b/core/archipelago/src/wallet/minibits.rs @@ -59,7 +59,7 @@ use super::nut13; use anyhow::{anyhow, Context, Result}; use base64::Engine; use nostr_sdk::nips::{nip04, nip06::FromMnemonic}; -use nostr_sdk::{EventBuilder, Kind, RelayUrl, Tag, TagKind, Timestamp, ToBech32}; +use nostr_sdk::{Client, EventBuilder, Filter, Kind, RelayUrl, Tag, TagKind, Timestamp, ToBech32}; use rand::seq::SliceRandom; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -73,6 +73,15 @@ const API_BASE: &str = "https://api.minibits.cash/v3"; /// The relay named in the NIP-42 auth event. Matches the value the Minibits app /// sends and the relay the service publishes in its NIP-05 record. const RELAY_URL: &str = "wss://relay.minibits.cash"; +/// Relays to check for incoming claim DMs (see `fetch_relay_dms`). Confirmed +/// live 2026-09-08: real Lightning payments to a `@minibits.cash` address are +/// delivered as a NIP-04 DM (kind 4, `#p`-tagged to the wallet's pubkey) +/// published to relays — *not* surfaced by `POST /claim`, which was the only +/// source this module fetched from until that gap stranded three real +/// payments. `RELAY_URL` first (it's the service's own relay and the one +/// most likely to have them), plus two large public relays as a fallback in +/// case that relay is ever unreachable or Minibits adds others. +const CLAIM_RELAY_URLS: &[&str] = &[RELAY_URL, "wss://relay.damus.io", "wss://nos.lol"]; /// NIP-42 client authentication event kind. const AUTH_KIND: u16 = 22242; /// The Minibits service Nostr pubkey that NIP-04-encrypts claimed tokens. Used @@ -137,6 +146,13 @@ pub struct MinibitsState { /// coins outright. #[serde(default)] pub pending_claims: Vec, + /// Unix timestamp of the newest Nostr DM we've already pulled into + /// `pending_claims` (see `fetch_relay_dms`). Nostr events never expire + /// from relays, so without this watermark every poll would re-fetch and + /// re-attempt every claim ever sent — harmless (the mint rejects an + /// already-spent token) but wasteful and noisy. + #[serde(default)] + pub last_dm_seen_at: u64, } /// A fresh Nostr keypair + seedHash derived from the node's ecash phrase. @@ -467,6 +483,7 @@ pub async fn lnaddress(data_dir: &Path) -> Result { server_nostr_pubkey: String::new(), created_at: chrono::Utc::now().to_rfc3339(), pending_claims: Vec::new(), + last_dm_seen_at: 0, } } }; @@ -514,6 +531,61 @@ const NO_CLAIMS: ClaimOutcome = ClaimOutcome { /// backed by this one mint — registering it already implies trusting the /// mint — so self-heal the allow-list here rather than let that combination /// silently strand funds. +/// Fetch NIP-04 DM (kind 4) events addressed to `our_pubkey` newer than +/// `since`, from `CLAIM_RELAY_URLS`. Returns each event's raw (still +/// encrypted) content plus its `created_at`, newest last. This — not +/// `POST /claim` — is how Minibits actually delivers a Lightning payment +/// made to a `@minibits.cash` address: confirmed live 2026-09-08 against +/// 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. +async fn fetch_relay_dms( + our_pubkey: nostr_sdk::PublicKey, + since: u64, +) -> Vec<(String, u64, String)> { + 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}"); + } + } + 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(800)).await; + + // `since` is inclusive in NIP-01, and `since` here is the `created_at` of + // the newest event we've already queued — so filter strictly after it, + // or the same event gets re-fetched (and its already-spent token + // re-attempted) every poll forever. + let filter = Filter::new() + .pubkey(our_pubkey) + .kind(Kind::from(4u16)) + .since(Timestamp::from(since.saturating_add(1))) + .limit(200); + + let result = match client + .fetch_events(filter, std::time::Duration::from_secs(10)) + .await + { + Ok(events) => { + let mut out: Vec<(String, u64, String)> = events + .into_iter() + .map(|e| (e.content, e.created_at.as_u64(), e.pubkey.to_hex())) + .collect(); + out.sort_by_key(|(_, created_at, _)| *created_at); + out + } + Err(e) => { + warn!("Minibits: relay fetch for claim DMs failed: {e}"); + Vec::new() + } + }; + + client.shutdown().await; + result +} + async fn ensure_mint_accepted(data_dir: &Path, mint_url: &str) -> Result<()> { let mut accepted = ecash::load_accepted_mints(data_dir).await?; if !accepted.mints.iter().any(|m| m == mint_url) { @@ -605,9 +677,24 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result { Err(e) => warn!("Minibits claim request failed ({e}); retrying only previously-pending claims"), } + // The actual delivery channel: real Lightning payments arrive as a + // 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(), state.last_dm_seen_at).await; + for (content, created_at, author) in dms { + if author != state.server_nostr_pubkey { + warn!("Minibits: ignoring claim DM from unexpected pubkey {author}"); + continue; + } + state.pending_claims.push(content); + state.last_dm_seen_at = state.last_dm_seen_at.max(created_at); + } + // Persist immediately: everything in `pending_claims` right now has - // already been consumed server-side, whether it came from this fetch or - // survived from an earlier failed attempt. + // already been consumed server-side (or, for relay DMs, is public and + // can't be un-sent), whether it came from this fetch or survived from an + // earlier failed attempt. save_state(data_dir, &state).await?; if state.pending_claims.is_empty() { @@ -739,6 +826,21 @@ mod tests { assert_eq!(accepted.mints.iter().filter(|m| *m == mint).count(), 1); } + #[test] + fn dm_watermark_advances_but_never_rewinds() { + // `claim_and_redeem` does `state.last_dm_seen_at.max(created_at)` per + // event. Events from `fetch_relay_dms` are sorted ascending, but the + // watermark must still be safe against an out-of-order relay + // response (or a future refactor) — it must never move backward, or + // an already-queued DM gets re-fetched and its now-spent token + // re-attempted forever. + let mut watermark = 100u64; + for created_at in [105, 103, 110, 108] { + watermark = watermark.max(created_at); + } + assert_eq!(watermark, 110); + } + #[tokio::test] async fn load_state_treats_empty_file_as_no_profile() { // Reproduces archy-x250-pa3, 2026-09-08: a disk-full write truncated