fix(ecash): fetch Minibits claims from Nostr relays, not the dead /claim REST poll
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
This commit is contained in:
@@ -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<Self> {
|
||||
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 {
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<serde_json::Value> {
|
||||
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<ClaimOutcome> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user