From 6effc6b574d5493ab4cdc7dd0eaefac103426fef Mon Sep 17 00:00:00 2001 From: ssmithx Date: Tue, 8 Sep 2026 02:49:02 +0000 Subject: [PATCH 1/9] feat(ecash): Minibits @minibits.cash Lightning address on Cashu receive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wallet used Minibits only as a Cashu mint, so the node could hold and swap ecash there but had no addressable name at it. This derives a LUD-16 Lightning address (name@minibits.cash) from the node's own ecash wallet and surfaces it in the ecash Receive tab above the existing paste-token box. Identity reuses the NUT-13 ecash phrase, so there is no second secret: - seedHash = sha256(mnemonic.to_seed("")) — the exact hash the Minibits app stores, so restoring the same phrase recovers the same address both ways; - Nostr keys via NIP-06 at m/44'/1237'/0'/0/0 (nostr-sdk Keys::from_mnemonic, pinned by a unit test against the NIP-06 vector so a bump cannot silently move the derivation and orphan the profile). Backend (wallet/minibits.rs) implements the verified live /v3 flow: NIP-42 challenge/verify -> JWT, idempotent /profile registration with collision retry, and /claim polling that NIP-04-decrypts each token (service pubkey read from the address's own LUD-16 metadata, constant fallback) and redeems it through ecash::receive_token. Mainnet-only; state cached 0600 in wallet/minibits.json. New RPC: wallet.ecash-lnaddress (register-or-read, idempotent) and wallet.ecash-lnaddress-claim (sweep Lightning payments into ecash). The modal fetches the address on tab open, renders QR + copy, and sweeps claims while open; a registration failure is non-fatal so paste-token still works. Verified end-to-end against production: registered a disposable @minibits.cash address, confirmed it resolves via /.well-known/lnurlp, and the claim poll returns cleanly. --- core/archipelago/Cargo.toml | 5 +- core/archipelago/src/api/rpc/dispatcher.rs | 2 + core/archipelago/src/api/rpc/wallet.rs | 19 + core/archipelago/src/wallet/minibits.rs | 641 ++++++++++++++++++ core/archipelago/src/wallet/mod.rs | 1 + core/archipelago/src/wallet/nut13.rs | 12 + .../src/components/ReceiveBitcoinModal.vue | 100 +++ neode-ui/src/locales/en.json | 6 + neode-ui/src/locales/es.json | 6 + 9 files changed, 790 insertions(+), 2 deletions(-) create mode 100644 core/archipelago/src/wallet/minibits.rs diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index ac7a0c77..5f40e8da 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -90,8 +90,9 @@ rustls-pemfile = "1.0" webpki = { package = "rustls-webpki", version = "0.101" } reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls", "stream"] } -# Nostr (node discovery + NIP-44 encrypted peer handshake) -nostr-sdk = { version = "0.44", features = ["nip04", "nip44"] } +# Nostr (node discovery + NIP-44 encrypted peer handshake). +# nip06: NIP-06 key derivation for the Minibits @minibits.cash profile flow. +nostr-sdk = { version = "0.44", features = ["nip04", "nip06", "nip44"] } # Backup encryption (DID identity export) + TOTP 2FA encryption argon2 = "0.5.3" diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 2337ce68..d7fbf067 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -269,6 +269,8 @@ impl RpcHandler { "wallet.ecash-network" => self.handle_wallet_ecash_network().await, "wallet.ecash-set-network" => self.handle_wallet_ecash_set_network(params).await, "wallet.ecash-seed-status" => self.handle_wallet_ecash_seed_status().await, + "wallet.ecash-lnaddress" => self.handle_wallet_ecash_lnaddress().await, + "wallet.ecash-lnaddress-claim" => self.handle_wallet_ecash_lnaddress_claim().await, "wallet.ecash-seed-reveal" => self.handle_wallet_ecash_seed_reveal(params).await, "wallet.ecash-restore" => self.handle_wallet_ecash_restore(params).await, "wallet.ecash-seed-import" => self.handle_wallet_ecash_seed_import(params).await, diff --git a/core/archipelago/src/api/rpc/wallet.rs b/core/archipelago/src/api/rpc/wallet.rs index 8dba743b..88a1e468 100644 --- a/core/archipelago/src/api/rpc/wallet.rs +++ b/core/archipelago/src/api/rpc/wallet.rs @@ -421,6 +421,25 @@ impl RpcHandler { })) } + /// `wallet.ecash-lnaddress` — the node's Minibits Lightning address + /// (`@minibits.cash`, LUD-16), derived from and authenticated by the + /// ecash wallet's own seed. Registers the profile on first use; safe to call + /// on every open of the Cashu receive screen (it is idempotent). + pub(super) async fn handle_wallet_ecash_lnaddress(&self) -> Result { + crate::wallet::minibits::lnaddress(&self.config.data_dir).await + } + + /// `wallet.ecash-lnaddress-claim` — redeem any Lightning payments that + /// arrived on the node's Minibits address as ecash. Returns the sats swept in + /// (0 when nothing was waiting), so the UI can refresh its balance. + pub(super) async fn handle_wallet_ecash_lnaddress_claim(&self) -> Result { + let outcome = crate::wallet::minibits::claim_and_redeem(&self.config.data_dir).await?; + Ok(serde_json::json!({ + "claimed_count": outcome.claimed_count, + "received_sats": outcome.received_sats, + })) + } + pub(super) async fn handle_wallet_networking_profits(&self) -> Result { let summary = profits::get_networking_profits(&self.config.data_dir).await?; Ok(serde_json::json!({ diff --git a/core/archipelago/src/wallet/minibits.rs b/core/archipelago/src/wallet/minibits.rs new file mode 100644 index 00000000..0b87e3b6 --- /dev/null +++ b/core/archipelago/src/wallet/minibits.rs @@ -0,0 +1,641 @@ +//! The Minibits `@minibits.cash` Lightning address (LUD-16) for the node's +//! ecash wallet. +//! +//! ## Why this exists next to the mint client +//! +//! The ecash wallet already talks to `mint.minibits.cash` as a plain Cashu +//! mint (`wallet::mint_client`): mint/melt quotes, swap, receive. That gives the +//! node ecash *from* Minibits, but not a *name* at Minibits. A human-readable +//! Lightning address like `braveharbor42@minibits.cash` is a separate service — +//! the Minibits profile API at `api.minibits.cash/v3` — and it is what lets any +//! Lightning wallet pay this node by typing an address, with the payment landing +//! as ecash. +//! +//! ## Identity: the ecash wallet *is* the Minibits wallet +//! +//! Minibits ties an address to a wallet by `seedHash`, and authenticates the +//! wallet with a NIP-06 Nostr keypair. Both come from the *existing* NUT-13 +//! ecash phrase (`wallet::nut13`), so there is no second secret to back up: +//! +//! - `seedHash = sha256(mnemonic.to_seed(""))` — the exact bytes the Minibits +//! app hashes, so restoring the same phrase in the Minibits app recovers the +//! same address (and vice-versa). +//! - Nostr keys via NIP-06 at `m/44'/1237'/0'/0/0`. `nostr_sdk::Keys::from_mnemonic` +//! uses that path with an empty BIP-39 passphrase — byte-for-byte the derivation +//! the Minibits app (nostr-tools `accountFromSeedWords`) does, verified against +//! the crate's own NIP-06 test vector. +//! +//! ## Flow (all verified against the live v3 API) +//! +//! 1. `POST /auth/challenge {pubkey}` → `{challenge, createdAt}`. +//! 2. Sign a NIP-42 kind-22242 event (`relay` + `challenge` tags, server's +//! `createdAt`) with the Nostr key. +//! 3. `POST /auth/verify {pubkey, challenge, signature}` → JWT access token. +//! 4. `POST /profile {walletId, seedHash}` → the assigned `lud16`/`nip05`. +//! Idempotent per pubkey: re-registering returns the existing address. +//! 5. `POST /claim {seedHash}` → NIP-04-encrypted Cashu tokens for Lightning +//! payments sent to the address; decrypt with the Nostr key + the server's +//! Nostr pubkey, then redeem through `ecash::receive_token`. +//! +//! Only runs on the mainnet ecash network — Minibits is a mainnet service, and a +//! testnet node must not register a profile or hit the production API. + +use super::ecash::{self, EcashNetwork}; +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 rand::seq::SliceRandom; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::path::Path; +use tokio::fs; +use tracing::{debug, info, warn}; + +/// Minibits profile/LNURL API. Confirmed live: `/v3/auth/challenge`, +/// `/v3/profile`, `/v3/claim` (the older `/v2` host no longer serves profiles). +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"; +/// NIP-42 client authentication event kind. +const AUTH_KIND: u16 = 22242; +/// The Minibits service Nostr pubkey that NIP-04-encrypts claimed tokens. Used +/// only as a fallback: the authoritative value is read from the address's own +/// LUD-16 metadata (`nostrPubkey`) at claim time, so a Minibits key rotation +/// does not strand claims. +const FALLBACK_SERVER_NOSTR_PUBKEY: &str = + "beeb48407a6f087ea8f76dc384a5d88c67ced9bd9fb0cdba90930210df3d92e7"; +/// Re-authenticate this long before the JWT actually expires, so a claim poll +/// never races the expiry boundary. +const TOKEN_EXPIRY_SKEP_SECS: i64 = 120; + +const STATE_FILE: &str = "wallet/minibits.json"; + +/// Small word lists for the generated address name. Uniqueness comes from the +/// numeric suffix plus the retry-on-collision below — the Minibits server rejects +/// a name already taken by another wallet and we simply draw another, so these do +/// not need to be exhaustive (the Minibits app ships lists hundreds long). +const ADJECTIVES: &[&str] = &[ + "calm", "brave", "quiet", "solar", "rapid", "noble", "lunar", "vivid", "amber", "crisp", + "eager", "fancy", "gentle", "happy", "jolly", "keen", "lucky", "mellow", "nimble", "proud", + "quick", "rusty", "sunny", "tidy", "urban", "vital", "warm", "zesty", "bold", "clever", + "daring", "epic", "fiery", "grand", "humble", "iron", "merry", "polar", "sleek", "wild", +]; +const NOUNS: &[&str] = &[ + "harbor", "meadow", "canyon", "summit", "river", "forest", "island", "comet", "nebula", + "orbit", "quartz", "maple", "willow", "falcon", "otter", "badger", "salmon", "crane", + "ridge", "creek", "glade", "grove", "prairie", "delta", "cobalt", "onyx", "topaz", "ember", + "anchor", "lantern", "beacon", "cabin", "drift", "signal", "thunder", "zephyr", "marble", + "pebble", "sequoia", "tundra", +]; + +/// Persistent state for the node's Minibits address. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct MinibitsState { + /// The chosen wallet name (the `name` in `name@minibits.cash`). + pub wallet_id: String, + /// The full LUD-16 Lightning address, e.g. `braveharbor42@minibits.cash`. + pub lud16: String, + /// NIP-05 address (Minibits sets this equal to `lud16`). + pub nip05: String, + /// This node's NIP-06 Nostr pubkey (hex) the profile is bound to. + pub nostr_pubkey: String, + /// `sha256(seed)` — the wallet identifier Minibits keys claims on. + pub seed_hash: String, + /// Cached JWT access token. + #[serde(default)] + pub access_token: String, + /// Access-token expiry (unix seconds); 0 when unknown/expired. + #[serde(default)] + pub access_expires: i64, + /// Server Nostr pubkey used to decrypt claims, discovered from LUD-16. + #[serde(default)] + pub server_nostur_pubkey: String, + #[serde(default)] + pub created_at: String, +} + +/// A fresh Nostr keypair + seedHash derived from the node's ecash phrase. +struct MinibitsIdentity { + keys: nostr_sdk::Keys, + seed_hash: String, +} + +/// Derive the Minibits identity (NIP-06 Nostr keys + seedHash) from the node's +/// ecash mnemonic. Both are deterministic, so the address and claims are +/// recoverable from the same 24 words the ecash already lives on. +fn derive_identity(phrase: &str, seed: &[u8; 64]) -> Result { + let keys = nostr_sdk::Keys::from_mnemonic(phrase, None::<&str>) + .map_err(|e| anyhow!("NIP-06 derivation failed: {e}"))?; + let seed_hash = hex::encode(Sha256::digest(seed)); + Ok(MinibitsIdentity { keys, seed_hash }) +} + +/// A Minibits profile record — the fields we read off every profile response. +#[derive(Debug, Deserialize)] +struct ProfileRecord { + #[serde(rename = "walletId")] + wallet_id: String, + #[serde(default)] + nip05: String, + #[serde(default)] + lud16: Option, + #[serde(default)] + pubkey: String, +} + +/// Turn a non-2xx Minibits response into a readable error, surfacing the +/// server's `error.name`/`error.message` when present. +fn minibits_error(status: reqwest::StatusCode, body: &str) -> anyhow::Error { + if let Ok(v) = serde_json::from_str::(body) { + if let Some(err) = v.get("error") { + let name = err.get("name").and_then(|n| n.as_str()).unwrap_or("ERROR"); + let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or(""); + return anyhow!("Minibits API error {status}: {name} {msg}"); + } + } + anyhow!( + "Minibits API error {status}: {}", + &body[..body.len().min(180)] + ) +} + +fn state_path(data_dir: &Path) -> std::path::PathBuf { + data_dir.join(STATE_FILE) +} + +async fn load_state(data_dir: &Path) -> Result> { + let path = state_path(data_dir); + match fs::read_to_string(&path).await { + Ok(s) => { + let st: MinibitsState = serde_json::from_str(&s) + .with_context(|| format!("Failed to parse {}", path.display()))?; + if st.wallet_id.is_empty() { + Ok(None) + } else { + Ok(Some(st)) + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e).with_context(|| format!("Failed to read {}", path.display())), + } +} + +/// Write the state file 0600 — it holds a bearer JWT. Same sensitivity class as +/// the ecash files it sits beside, so it gets the same owner-only mode. +async fn save_state(data_dir: &Path, state: &MinibitsState) -> Result<()> { + let path = state_path(data_dir); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .await + .context("Failed to create the wallet directory")?; + } + let content = serde_json::to_string_pretty(state) + .context("Failed to serialize the Minibits profile")?; + fs::write(&path, content) + .await + .with_context(|| format!("Failed to write {}", path.display()))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .await + .with_context(|| format!("Failed to chmod 0600 {}", path.display()))?; + } + Ok(()) +} + +/// Read the `exp` claim (unix seconds) from a JWT without verifying it — the +/// token comes straight from Minibits over TLS; we only use the expiry to decide +/// when to refresh. +fn jwt_expiry(token: &str) -> Option { + let payload = token.split('.').nth(1)?; + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .ok()?; + let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?; + v.get("exp")?.as_i64() +} + +/// Run the NIP-42 challenge/verify dance and return the access token plus its +/// expiry. Idempotent and cheap enough to redo whenever the cached token lapses. +async fn authenticate( + client: &reqwest::Client, + keys: &nostr_sdk::Keys, +) -> Result<(String, i64)> { + let ch: serde_json::Value = client + .post(format!("{API_BASE}/auth/challenge")) + .json(&serde_json::json!({ "pubkey": keys.public_key().to_hex() })) + .send() + .await + .context("Minibits auth challenge request failed")? + .error_for_status() + .context("Minibits auth challenge rejected")? + .json() + .await + .context("Minibits auth challenge was not JSON")?; + + let challenge = ch["challenge"] + .as_str() + .ok_or_else(|| anyhow!("Minibits challenge response missing 'challenge'"))? + .to_string(); + let created_at = ch["createdAt"] + .as_u64() + .ok_or_else(|| anyhow!("Minibits challenge response missing 'createdAt'"))?; + + // Sign a NIP-42 auth event, stamping the server's own createdAt so the + // signature lines up with the challenge it was issued for. + let unsigned = EventBuilder::new(Kind::from(AUTH_KIND), "") + .tag(Tag::relay( + RelayUrl::parse(RELAY_URL).context("Invalid Minibits relay URL")?, + )) + .tag(Tag::custom( + TagKind::custom("challenge"), + vec![challenge.clone()], + )) + .custom_created_at(Timestamp::from(created_at)) + .build(keys.public_key()); + let signed = unsigned + .sign_with_keys(keys) + .map_err(|e| anyhow!("Failed to sign the Minibits auth challenge: {e}"))?; + + let tok: serde_json::Value = client + .post(format!("{API_BASE}/auth/verify")) + .json(&serde_json::json!({ + "pubkey": keys.public_key().to_hex(), + "challenge": challenge, + "signature": hex::encode(signed.sig.serialize()), + })) + .send() + .await + .context("Minibits auth verify request failed")? + .error_for_status() + .context("Minibits auth verify rejected (bad challenge signature)")? + .json() + .await + .context("Minibits auth verify was not JSON")?; + + let access = tok["accessToken"] + .as_str() + .ok_or_else(|| anyhow!("Minibits verify response missing 'accessToken'"))? + .to_string(); + let expires = jwt_expiry(&access).unwrap_or_else(|| { + chrono::Utc::now().timestamp() + 3600 // conservative fallback + }); + Ok((access, expires)) +} + +/// True when the cached access token is missing or about to lapse. +fn token_is_stale(state: &MinibitsState) -> bool { + let now = chrono::Utc::now().timestamp(); + state.access_token.is_empty() || now + TOKEN_EXPIRY_SKEP_SECS >= state.access_expires +} + +/// Ensure we hold a valid access token, re-authenticating as needed and folding +/// the fresh token back into `state` (which the caller persists). +async fn ensure_token( + client: &reqwest::Client, + state: &mut MinibitsState, + keys: &nostr_sdk::Keys, +) -> Result<()> { + if token_is_stale(state) { + let (access, expires) = authenticate(client, keys).await?; + state.access_token = access; + state.access_expires = expires; + debug!("Minibits: authenticated (token valid to {})", expires); + } + Ok(()) +} + +/// Draw a fresh readable wallet name, Minibits-style: adjective + noun + number. +fn generate_wallet_id() -> String { + let mut rng = rand::thread_rng(); + let adj = ADJECTIVES.choose(&mut rng).copied().unwrap_or("quiet"); + let noun = NOUNS.choose(&mut rng).copied().unwrap_or("harbor"); + let num = rand::Rng::gen_range(&mut rng, 1..=999); + format!("{adj}{noun}{num}") +} + +/// Register the profile, returning the assigned address. Retries with a new name +/// a handful of times if the generated name is already taken by another wallet. +async fn register_profile( + client: &reqwest::Client, + access: &str, + seed_hash: &str, +) -> Result { + let mut last_err = None; + for attempt in 0..6 { + let wallet_id = generate_wallet_id(); + let resp = client + .post(format!("{API_BASE}/profile")) + .bearer_auth(access) + .json(&serde_json::json!({ "walletId": wallet_id, "seedHash": seed_hash })) + .send() + .await + .context("Minibits profile registration request failed")?; + let status = resp.status(); + let body = resp + .text() + .await + .context("Minibits profile response body read failed")?; + if status.is_success() { + let rec: ProfileRecord = serde_json::from_str(&body) + .context("Minibits profile response was not the expected shape")?; + return Ok(rec); + } + // Name collision → draw another. Anything else is fatal. + let is_taken = body.contains("ALREADY_EXISTS") || body.contains("already"); + if is_taken { + warn!("Minibits name '{wallet_id}' taken, retrying (attempt {attempt})"); + last_err = Some(minibits_error(status, &body)); + continue; + } + return Err(minibits_error(status, &body)); + } + Err(last_err.unwrap_or_else(|| anyhow!("Could not register a free Minibits name"))) +} + +/// Fetch the LUD-16 metadata for our own address and read the service's +/// `nostrPubkey` — the key that NIP-04-encrypts claimed tokens. +async fn discover_server_nostr_pubkey( + client: &reqwest::Client, + lud16: &str, +) -> Result { + let (name, domain) = lud16 + .split_once('@') + .ok_or_else(|| anyhow!("Malformed Minibits address '{lud16}'"))?; + let url = format!("https://{domain}/.well-known/lnurlp/{name}"); + let md: serde_json::Value = client + .get(&url) + .send() + .await + .context("Minibits LUD-16 metadata request failed")? + .json() + .await + .context("Minibits LUD-16 metadata was not JSON")?; + md.get("nostrPubkey") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| anyhow!("Minibits LUD-16 metadata missing 'nostrPubkey'")) +} + +/// Load the ecash phrase, or establish it from the node master seed when this +/// node has not materialised one yet — but never fail an address request just +/// because a phrase is not on disk; report that clearly instead. +async fn ecash_phrase(data_dir: &Path) -> Result<(String, [u8; 64])> { + let seed = nut13::load_seed(data_dir) + .await? + .ok_or_else(|| anyhow!("The ecash wallet has no seed yet — restore or reveal it first"))?; + Ok((seed.phrase(), seed.seed_bytes())) +} + +/// Get (registering on first use) the node's Minibits Lightning address. +/// +/// On mainnet this registers a profile with the Minibits server the first time +/// and caches it in `wallet/minibits.json`; later calls return the cached address +/// and refresh the access token as needed. Registration is idempotent per pubkey, +/// so a node that restores the same ecash phrase recovers the same address. +pub async fn lnaddress(data_dir: &Path) -> Result { + let network = ecash::load_network(data_dir).await; + if network == EcashNetwork::Testnet { + return Err(anyhow!( + "Minibits Lightning addresses are mainnet-only — switch the ecash network to mainnet to set one up" + )); + } + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(20)) + .build() + .context("Failed to build the Minibits HTTP client")?; + + let (phrase, seed) = ecash_phrase(data_dir).await?; + let identity = derive_identity(&phrase, &seed)?; + + let mut state = match load_state(data_dir).await? { + Some(st) => st, + None => { + info!("Minibits: no profile yet, registering a new @minibits.cash address"); + let (access, expires) = authenticate(&client, &identity.keys).await?; + let rec = register_profile(&client, &access, &identity.seed_hash).await?; + MinibitsState { + wallet_id: rec.wallet_id.clone(), + lud16: rec + .lud16 + .clone() + .unwrap_or_else(|| format!("{}@minibits.cash", rec.wallet_id)), + nip05: rec.nip05.clone(), + nostr_pubkey: rec.pubkey.clone(), + seed_hash: identity.seed_hash.clone(), + access_token: access, + access_expires: expires, + server_nostur_pubkey: String::new(), + created_at: chrono::Utc::now().to_rfc3339(), + } + } + }; + + ensure_token(&client, &mut state, &identity.keys).await?; + save_state(data_dir, &state).await?; + + Ok(serde_json::json!({ + "address": state.lud16, + "nip05": state.nip05, + "wallet_id": state.wallet_id, + "nostr_pubkey": state.nostr_pubkey, + "npub": identity.keys.public_key().to_bech32().unwrap_or_default(), + })) +} + +/// Outcome of a claim poll. +#[derive(Debug, Serialize)] +pub struct ClaimOutcome { + pub claimed_count: usize, + pub received_sats: u64, +} + +/// Poll Minibits for Lightning payments sent to the node's address and redeem +/// each into the ecash wallet. +/// +/// Each claim is a NUT-00 token NIP-04-encrypted by the Minibits service to this +/// wallet's Nostr key; decrypting it needs the service pubkey (discovered from +/// our LUD-16 metadata, falling back to the known constant). A token that fails +/// to decrypt or redeem is logged and skipped rather than aborting the batch — +/// but note a claim is consumed server-side the moment it is fetched, so any +/// failure here is surfaced loudly since those coins cannot be re-fetched. +pub async fn claim_and_redeem(data_dir: &Path) -> Result { + let network = ecash::load_network(data_dir).await; + if network == EcashNetwork::Testnet { + return Ok(ClaimOutcome { claimed_count: 0, received_sats: 0 }); + } + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .context("Failed to build the Minibits HTTP client")?; + + let (phrase, seed) = ecash_phrase(data_dir).await?; + let identity = derive_identity(&phrase, &seed)?; + + let mut state = match load_state(data_dir).await? { + Some(st) => st, + // Nothing is addressable until a profile exists; registering lazily here + // means a payment could not have arrived, so claiming is a no-op. + None => return Ok(ClaimOutcome { claimed_count: 0, received_sats: 0 }), + }; + ensure_token(&client, &mut state, &identity.keys).await?; + + // Discover (and cache) the service key that wraps claimed tokens. + if state.server_nostur_pubkey.is_empty() { + match discover_server_nostr_pubkey(&client, &state.lud16).await { + Ok(pk) => state.server_nostur_pubkey = pk, + Err(e) => { + warn!("Minibits: could not read service Nostr pubkey ({e}); using fallback"); + state.server_nostur_pubkey = FALLBACK_SERVER_NOSTR_PUBKEY.to_string(); + } + } + } + save_state(data_dir, &state).await?; + + let server_pk = nostr_sdk::PublicKey::from_hex(&state.server_nostur_pubkey) + .context("Service Nostr pubkey was not valid hex")?; + + let resp = client + .post(format!("{API_BASE}/claim")) + .bearer_auth(&state.access_token) + .json(&serde_json::json!({ "seedHash": state.seed_hash })) + .send() + .await + .context("Minibits claim request failed")?; + let status = resp.status(); + let body = resp.text().await.context("Minibits claim body read failed")?; + if !status.is_success() { + return Err(minibits_error(status, &body)); + } + let claims: Vec = + serde_json::from_str(&body).context("Minibits claim response was not a JSON array")?; + if claims.is_empty() { + return Ok(ClaimOutcome { claimed_count: 0, received_sats: 0 }); + } + + let mut redeemed = 0usize; + let mut sats = 0u64; + for claim in &claims { + let enc = match claim.get("token").and_then(|t| t.as_str()) { + Some(t) => t, + None => { + warn!("Minibits claim had no 'token' field; skipping"); + continue; + } + }; + let decoded = match nip04::decrypt(identity.keys.secret_key(), &server_pk, enc) { + Ok(d) => d, + Err(e) => { + // Claim already consumed server-side — this is a real loss. + warn!("Minibits claim could not be decrypted ({e}); coins may be unrecoverable"); + continue; + } + }; + match ecash::receive_token(data_dir, &decoded).await { + Ok(got) => { + redeemed += 1; + sats += got; + info!("Minibits: redeemed a claimed payment ({got} sats)"); + } + Err(e) => { + warn!("Minibits claim decrypted but failed to redeem ({e}); coins may be unrecoverable") + } + } + } + + Ok(ClaimOutcome { claimed_count: redeemed, received_sats: sats }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derived_nostr_key_matches_the_nip06_vector() { + // The Minibits app derives its Nostr key at m/44'/1237'/0'/0/0 with an + // empty BIP-39 passphrase (nostr-tools accountFromSeedWords). Lock to the + // crate's own NIP-06 secret-key vector so a nostr-sdk bump cannot silently + // move our derivation and orphan the registered address. + let phrase = "leader monkey parrot ring guide accident before fence cannon height naive bean"; + let keys = nostr_sdk::Keys::from_mnemonic(phrase, None::<&str>).unwrap(); + assert_eq!( + hex::encode(keys.secret_key().as_secret_bytes()), + "7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a" + ); + } + + #[test] + fn seed_hash_is_sha256_of_the_bip39_seed() { + // Minibits hashes the *seed*, not the phrase — a regression here would + // make the node register a profile that the Minibits app cannot recover. + let phrase = "leader monkey parrot ring guide accident before fence cannon height naive bean"; + let m: bip39::Mnemonic = phrase.parse().unwrap(); + let seed = m.to_seed(""); + let want = hex::encode(Sha256::digest(seed)); + let id = derive_identity(phrase, &seed).unwrap(); + assert_eq!(id.seed_hash, want); + } + + #[test] + fn generated_names_are_readable_and_bounded() { + for _ in 0..200 { + let n = generate_wallet_id(); + assert!(!n.is_empty()); + assert!(n.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())); + // ends in at least one digit (the 1..=999 suffix) + assert!(n.chars().last().map(|c| c.is_ascii_digit()).unwrap_or(false)); + } + } + + #[test] + fn stale_token_when_missing_or_near_expiry() { + let now = chrono::Utc::now().timestamp(); + assert!(token_is_stale(&MinibitsState::default())); + assert!(token_is_stale(&MinibitsState { + access_token: "x".into(), + access_expires: now + 10, // inside the skew window + ..Default::default() + })); + assert!(!token_is_stale(&MinibitsState { + access_token: "x".into(), + access_expires: now + 3600, + ..Default::default() + })); + } + + /// Live end-to-end against the production Minibits API: register a throwaway + /// profile with a random ecash phrase and claim (nothing pending → 0). Run + /// with `cargo test -- --ignored --nocapture`. It creates one disposable + /// profile on the public service and holds no funds. + #[tokio::test] + #[ignore] + async fn registers_and_claims_against_live_minibits() { + let dir = std::env::temp_dir().join(format!("mbtest-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + // A node has an ecash phrase before it has a Minibits profile; stand up + // a fresh random one so registration derives a real identity. + nut13::establish_independent(&dir).await.unwrap(); + + let info = lnaddress(&dir).await.expect("live registration failed"); + let addr = info["address"].as_str().unwrap().to_string(); + assert!(addr.ends_with("@minibits.cash"), "bad address {addr}"); + println!("registered live address: {addr}"); + + // A second call must return the same cached address, not register again. + let again = lnaddress(&dir).await.unwrap(); + assert_eq!(again["address"].as_str().unwrap(), addr.as_str()); + + let out = claim_and_redeem(&dir).await.expect("live claim poll failed"); + println!("claim poll: {out:?}"); + assert_eq!(out.claimed_count, 0); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/core/archipelago/src/wallet/mod.rs b/core/archipelago/src/wallet/mod.rs index 8e1a4d26..96cff2b9 100644 --- a/core/archipelago/src/wallet/mod.rs +++ b/core/archipelago/src/wallet/mod.rs @@ -6,6 +6,7 @@ pub mod bdhke; pub mod cashu; pub mod ecash; pub mod fedimint_client; +pub mod minibits; pub mod mint_client; pub mod nut13; pub mod profits; diff --git a/core/archipelago/src/wallet/nut13.rs b/core/archipelago/src/wallet/nut13.rs index 75df8604..ff13918f 100644 --- a/core/archipelago/src/wallet/nut13.rs +++ b/core/archipelago/src/wallet/nut13.rs @@ -137,6 +137,18 @@ impl EcashSeed { self.mnemonic.words().map(|w| w.to_string()).collect() } + /// The phrase as a single string — the input to NUT-13 *and* to the NIP-06 + /// Nostr derivation the Minibits profile flow needs (`crate::wallet::minibits`). + pub fn phrase(&self) -> String { + self.mnemonic.to_string() + } + + /// The 64-byte BIP-39 seed. Same bytes Minibits hashes with SHA-256 to get + /// its `seedHash`, so the two wallets agree on wallet identity. + pub fn seed_bytes(&self) -> [u8; 64] { + self.seed + } + pub fn source(&self) -> SeedSource { self.source } diff --git a/neode-ui/src/components/ReceiveBitcoinModal.vue b/neode-ui/src/components/ReceiveBitcoinModal.vue index 12728321..e3ea7c36 100644 --- a/neode-ui/src/components/ReceiveBitcoinModal.vue +++ b/neode-ui/src/components/ReceiveBitcoinModal.vue @@ -106,6 +106,27 @@
+ +
+

{{ t('receiveBitcoin.lnAddressTitle') }}

+ +

{{ t('receiveBitcoin.lnAddressLabel') }}

+

{{ lnAddress }}

+ +

{{ t('receiveBitcoin.lnAddressHint') }}

+

+ {{ t('receiveBitcoin.lnAddressReceived', { amount: lnClaimedSats.toLocaleString() }) }} +

+
+
+ {{ t('receiveBitcoin.lnAddressLoading') }} +
+
+ {{ t('receiveBitcoin.lnAddressUnavailable') }} +
+
@@ -175,6 +196,11 @@ watch(() => props.show, (open) => { arkAddress.value = '' ecashToken.value = '' ecashResult.value = '' + stopLnClaimPoll() + lnAddress.value = '' + lnAddressLoading.value = false + lnAddressError.value = false + lnClaimedSats.value = 0 error.value = '' processing.value = false if (props.autoGenerate && receiveMethod.value === 'onchain') { @@ -193,9 +219,80 @@ const ecashResult = ref('') const onchainQrCanvas = ref(null) const lightningQrCanvas = ref(null) const arkQrCanvas = ref(null) +const lnAddressQrCanvas = ref(null) const processing = ref(false) const error = ref('') +// ── Minibits Lightning address (ecash receive) ────────────────────────────── +// The ecash tab doubles as "receive onto my @minibits.cash address": the node +// derives/registers it from its own ecash seed (wallet.ecash-lnaddress) and +// sweeps any Lightning payments that land there back into ecash while the tab is +// open (wallet.ecash-lnaddress-claim). A registration failure is never fatal — +// the paste-token path below always works. +const lnAddress = ref('') +const lnAddressLoading = ref(false) +const lnAddressError = ref(false) +const lnClaimedSats = ref(0) +let lnClaimTimer: ReturnType | null = null + +async function loadLnAddress() { + if (lnAddress.value || lnAddressLoading.value) return + lnAddressLoading.value = true + lnAddressError.value = false + try { + const res = await rpcClient.call<{ address?: string }>({ method: 'wallet.ecash-lnaddress' }) + lnAddress.value = res?.address || '' + if (lnAddress.value) { + await nextTick() + renderQr(lnAddress.value, lnAddressQrCanvas.value) + startLnClaimPoll() + } else { + lnAddressError.value = true + } + } catch { + lnAddressError.value = true + } finally { + lnAddressLoading.value = false + } +} + +function stopLnClaimPoll() { + if (lnClaimTimer) { + clearInterval(lnClaimTimer) + lnClaimTimer = null + } +} + +function startLnClaimPoll() { + stopLnClaimPoll() + lnClaimTimer = setInterval(() => void pollLnClaims(), 8000) +} + +async function pollLnClaims() { + if (!props.show || !lnAddress.value) { + stopLnClaimPoll() + return + } + try { + const res = await rpcClient.call<{ received_sats?: number }>({ + method: 'wallet.ecash-lnaddress-claim', + }) + if (res?.received_sats && res.received_sats > 0) { + lnClaimedSats.value += res.received_sats + emit('received') + } + } catch { + // Transient poll failure (offline, mint busy) — keep polling. + } +} + +onUnmounted(stopLnClaimPoll) + +// Fetch the address the first time the operator opens the ecash tab. +watch(receiveMethod, (m) => { + if (m === 'ecash' && props.show) void loadLnAddress() +}) + // ── On-chain payment detection ──────────────────────────────────────────── // The generated address is FRESH (lnd.newaddress), so any incoming wallet // transaction paying it is this receive — no baseline bookkeeping needed. @@ -309,12 +406,15 @@ async function renderQr(data: string, canvas: HTMLCanvasElement | null, prefix = function close() { stopWatchingPayment() + stopLnClaimPoll() paymentSeen.value = null invoiceResult.value = '' onchainAddress.value = '' arkAddress.value = '' ecashToken.value = '' ecashResult.value = '' + lnAddress.value = '' + lnClaimedSats.value = 0 error.value = '' emit('close') } diff --git a/neode-ui/src/locales/en.json b/neode-ui/src/locales/en.json index 2769e2af..4fd3fcb3 100644 --- a/neode-ui/src/locales/en.json +++ b/neode-ui/src/locales/en.json @@ -775,6 +775,12 @@ "paymentConfirmed": "Payment confirmed", "transactionId": "Transaction ID", "pasteEcashToken": "Paste ecash token", + "lnAddressTitle": "Or share your Minibits Lightning address", + "lnAddressHint": "Anyone can pay you sats with any Lightning wallet by sending to this address — the sats arrive as ecash. Keep this screen open to receive them.", + "lnAddressLabel": "Your @minibits.cash address:", + "lnAddressLoading": "Setting up your Lightning address…", + "lnAddressUnavailable": "Lightning address unavailable — you can still paste a token below.", + "lnAddressReceived": "Received {amount} sats to your Lightning address!", "processing": "Processing...", "generateAddress": "Generate Address", "createInvoice": "Create Invoice", diff --git a/neode-ui/src/locales/es.json b/neode-ui/src/locales/es.json index 62b980d2..07a6c5ce 100644 --- a/neode-ui/src/locales/es.json +++ b/neode-ui/src/locales/es.json @@ -756,6 +756,12 @@ "paymentConfirmed": "Pago confirmado", "transactionId": "ID de transacci\u00f3n", "pasteEcashToken": "Pegar token Ecash", + "lnAddressTitle": "O comparte tu direcci\u00f3n Lightning de Minibits", + "lnAddressHint": "Cualquier persona puede pagarte sats con cualquier billetera Lightning enviando a esta direcci\u00f3n \u2014 los sats llegan como ecash. Mant\u00e9n esta pantalla abierta para recibirlos.", + "lnAddressLabel": "Su direcci\u00f3n @minibits.cash:", + "lnAddressLoading": "Configurando su direcci\u00f3n Lightning\u2026", + "lnAddressUnavailable": "Direcci\u00f3n Lightning no disponible \u2014 a\u00fan puede pegar un token abajo.", + "lnAddressReceived": "\u00a1Recibi\u00f3 {amount} sats en su direcci\u00f3n Lightning!", "processing": "Procesando...", "generateAddress": "Generar direcci\u00f3n", "createInvoice": "Crear factura", -- 2.54.0 From 76d565fb181ef58d69e82e28adef5c6a83b975a1 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Tue, 8 Sep 2026 13:24:11 +0000 Subject: [PATCH 2/9] fix(ecash): stop Minibits LN-address claims from being silently lost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Minibits /claim response consumes the payment server-side the instant it's returned — it can never be re-fetched. claim_and_redeem previously decrypted/redeemed each claim inline and just warn!-logged any failure, so a mint-unreachable blip, a stale cached server key, or an operator who'd edited their accepted-mints list to drop the default mint (via streaming.configure-mints) could make a real payment vanish with nothing but a log line to show for it — claimed_count/received_sats still came back as a clean 0, identical to "nothing arrived." Now: every fetched claim is persisted to MinibitsState.pending_claims before decrypt/redeem is attempted, survives failures across polls instead of being dropped, and claim_and_redeem no longer bails out on a fetch error without first retrying whatever was already pending. ensure_mint_accepted self-heals the accepted-mints allow-list so the Minibits mint (the address is inherently backed by it) can't be excluded out from under a claim. ClaimOutcome gains failed_count, threaded through wallet.ecash-lnaddress-claim and shown in ReceiveBitcoinModal so a stuck claim is visible instead of silent. Also fixes the server_nostur_pubkey field-name typo (no live state to migrate — this feature hasn't shipped yet). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a --- core/archipelago/src/api/rpc/wallet.rs | 5 + core/archipelago/src/wallet/minibits.rs | 194 ++++++++++++++---- .../src/components/ReceiveBitcoinModal.vue | 12 +- neode-ui/src/locales/en.json | 1 + neode-ui/src/locales/es.json | 1 + 5 files changed, 174 insertions(+), 39 deletions(-) diff --git a/core/archipelago/src/api/rpc/wallet.rs b/core/archipelago/src/api/rpc/wallet.rs index 88a1e468..a4ca5600 100644 --- a/core/archipelago/src/api/rpc/wallet.rs +++ b/core/archipelago/src/api/rpc/wallet.rs @@ -432,11 +432,16 @@ impl RpcHandler { /// `wallet.ecash-lnaddress-claim` — redeem any Lightning payments that /// arrived on the node's Minibits address as ecash. Returns the sats swept in /// (0 when nothing was waiting), so the UI can refresh its balance. + /// `failed_count` is non-zero when a payment was fetched (and so already + /// consumed server-side) but couldn't be redeemed yet — it stays queued + /// and is retried automatically, but the UI should tell the operator + /// rather than let it be a silent, unbounded wait. pub(super) async fn handle_wallet_ecash_lnaddress_claim(&self) -> Result { let outcome = crate::wallet::minibits::claim_and_redeem(&self.config.data_dir).await?; Ok(serde_json::json!({ "claimed_count": outcome.claimed_count, "received_sats": outcome.received_sats, + "failed_count": outcome.failed_count, })) } diff --git a/core/archipelago/src/wallet/minibits.rs b/core/archipelago/src/wallet/minibits.rs index 0b87e3b6..dac7815e 100644 --- a/core/archipelago/src/wallet/minibits.rs +++ b/core/archipelago/src/wallet/minibits.rs @@ -39,6 +39,20 @@ //! //! Only runs on the mainnet ecash network — Minibits is a mainnet service, and a //! testnet node must not register a profile or hit the production API. +//! +//! ## A claim can't be re-fetched — so nothing gets dropped +//! +//! `/claim` consumes a payment server-side the instant it's returned. A local +//! failure after that point (mint briefly unreachable, a stale cached server +//! 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 +//! 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 +//! mint, so an operator-edited allow-list must never be able to cause this +//! same kind of loss via `receive_token`'s mint check. use super::ecash::{self, EcashNetwork}; use super::nut13; @@ -112,9 +126,17 @@ pub struct MinibitsState { pub access_expires: i64, /// Server Nostr pubkey used to decrypt claims, discovered from LUD-16. #[serde(default)] - pub server_nostur_pubkey: String, + pub server_nostr_pubkey: String, #[serde(default)] pub created_at: String, + /// Raw NIP-04-encrypted claim tokens fetched from `/claim` but not yet + /// successfully redeemed. A claim is consumed server-side the instant + /// `/claim` returns it, so it is stashed here *before* decrypt/redeem is + /// attempted — a local failure (mint briefly down, bad cached server key, + /// process crash mid-loop) then retries next poll instead of losing the + /// coins outright. + #[serde(default)] + pub pending_claims: Vec, } /// A fresh Nostr keypair + seedHash derived from the node's ecash phrase. @@ -431,8 +453,9 @@ pub async fn lnaddress(data_dir: &Path) -> Result { seed_hash: identity.seed_hash.clone(), access_token: access, access_expires: expires, - server_nostur_pubkey: String::new(), + server_nostr_pubkey: String::new(), created_at: chrono::Utc::now().to_rfc3339(), + pending_claims: Vec::new(), } } }; @@ -454,22 +477,60 @@ pub async fn lnaddress(data_dir: &Path) -> Result { pub struct ClaimOutcome { pub claimed_count: usize, pub received_sats: u64, + /// Claims that were fetched (and so already consumed server-side) but + /// still haven't been redeemed after this poll — decrypt/redeem failed + /// and they are queued in `pending_claims` for the next poll rather than + /// dropped. Non-zero here means real, unswept value the operator should + /// know about. + pub failed_count: usize, +} + +const NO_CLAIMS: ClaimOutcome = ClaimOutcome { + claimed_count: 0, + received_sats: 0, + failed_count: 0, +}; + +/// Make sure the Minibits mint is on the accepted-mints allow-list. +/// +/// `ecash::receive_token` checks the raw accepted-mints file directly (not +/// the more lenient `ecash::is_mint_trusted`, which always trusts the default +/// mint) — so an operator who edited their accepted-mints list (e.g. via the +/// `streaming.configure-mints` RPC) and dropped the default mint would +/// otherwise cause every Minibits claim to fail *after* the claim was already +/// consumed server-side, permanently losing those coins with nothing but a +/// log line to show for it. The Minibits Lightning address is inherently +/// 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. +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) { + accepted.mints.push(mint_url.to_string()); + ecash::save_accepted_mints(data_dir, &accepted).await?; + info!("Minibits: added {mint_url} to accepted mints (needed to redeem LN-address claims)"); + } + Ok(()) } /// Poll Minibits for Lightning payments sent to the node's address and redeem /// each into the ecash wallet. /// -/// Each claim is a NUT-00 token NIP-04-encrypted by the Minibits service to this -/// wallet's Nostr key; decrypting it needs the service pubkey (discovered from -/// our LUD-16 metadata, falling back to the known constant). A token that fails -/// to decrypt or redeem is logged and skipped rather than aborting the batch — -/// but note a claim is consumed server-side the moment it is fetched, so any -/// failure here is surfaced loudly since those coins cannot be re-fetched. +/// Each claim is a NUT-00 token NIP-04-encrypted by the Minibits service to +/// this wallet's Nostr key; decrypting it needs the service pubkey +/// (discovered from our LUD-16 metadata, falling back to the known +/// constant). A claim is consumed server-side the instant `/claim` returns +/// it, so newly-fetched tokens are persisted to `state.pending_claims` +/// *before* decrypt/redeem is attempted; a token that fails to decrypt or +/// redeem stays in `pending_claims` and is retried on the next poll instead +/// of being dropped, and `failed_count` tells the caller when that happened +/// so it isn't purely a log-line event. pub async fn claim_and_redeem(data_dir: &Path) -> Result { let network = ecash::load_network(data_dir).await; if network == EcashNetwork::Testnet { - return Ok(ClaimOutcome { claimed_count: 0, received_sats: 0 }); + return Ok(NO_CLAIMS); } + ensure_mint_accepted(data_dir, &network.default_mint()).await?; let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) @@ -483,58 +544,75 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result { Some(st) => st, // Nothing is addressable until a profile exists; registering lazily here // means a payment could not have arrived, so claiming is a no-op. - None => return Ok(ClaimOutcome { claimed_count: 0, received_sats: 0 }), + None => return Ok(NO_CLAIMS), }; ensure_token(&client, &mut state, &identity.keys).await?; // Discover (and cache) the service key that wraps claimed tokens. - if state.server_nostur_pubkey.is_empty() { + if state.server_nostr_pubkey.is_empty() { match discover_server_nostr_pubkey(&client, &state.lud16).await { - Ok(pk) => state.server_nostur_pubkey = pk, + Ok(pk) => state.server_nostr_pubkey = pk, Err(e) => { warn!("Minibits: could not read service Nostr pubkey ({e}); using fallback"); - state.server_nostur_pubkey = FALLBACK_SERVER_NOSTR_PUBKEY.to_string(); + state.server_nostr_pubkey = FALLBACK_SERVER_NOSTR_PUBKEY.to_string(); } } } - save_state(data_dir, &state).await?; - let server_pk = nostr_sdk::PublicKey::from_hex(&state.server_nostur_pubkey) + let server_pk = nostr_sdk::PublicKey::from_hex(&state.server_nostr_pubkey) .context("Service Nostr pubkey was not valid hex")?; + // Fetch anything new. A failure here is *not* fatal to the poll — the + // operator may still have earlier claims sitting in `pending_claims` that + // are worth retrying — so log and fall through instead of bailing out. let resp = client .post(format!("{API_BASE}/claim")) .bearer_auth(&state.access_token) .json(&serde_json::json!({ "seedHash": state.seed_hash })) .send() - .await - .context("Minibits claim request failed")?; - let status = resp.status(); - let body = resp.text().await.context("Minibits claim body read failed")?; - if !status.is_success() { - return Err(minibits_error(status, &body)); - } - let claims: Vec = - serde_json::from_str(&body).context("Minibits claim response was not a JSON array")?; - if claims.is_empty() { - return Ok(ClaimOutcome { claimed_count: 0, received_sats: 0 }); + .await; + match resp { + Ok(resp) => { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + if status.is_success() { + match serde_json::from_str::>(&body) { + Ok(claims) => { + for claim in &claims { + match claim.get("token").and_then(|t| t.as_str()) { + Some(t) => state.pending_claims.push(t.to_string()), + None => warn!("Minibits claim had no 'token' field; skipping"), + } + } + } + Err(e) => warn!("Minibits claim response was not the expected shape: {e}"), + } + } else { + warn!("{}", minibits_error(status, &body)); + } + } + Err(e) => warn!("Minibits claim request failed ({e}); retrying only previously-pending claims"), } + // 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. + save_state(data_dir, &state).await?; + + if state.pending_claims.is_empty() { + return Ok(NO_CLAIMS); + } + + let to_process = std::mem::take(&mut state.pending_claims); let mut redeemed = 0usize; let mut sats = 0u64; - for claim in &claims { - let enc = match claim.get("token").and_then(|t| t.as_str()) { - Some(t) => t, - None => { - warn!("Minibits claim had no 'token' field; skipping"); - continue; - } - }; + let mut still_pending = Vec::new(); + for enc in &to_process { let decoded = match nip04::decrypt(identity.keys.secret_key(), &server_pk, enc) { Ok(d) => d, Err(e) => { - // Claim already consumed server-side — this is a real loss. - warn!("Minibits claim could not be decrypted ({e}); coins may be unrecoverable"); + warn!("Minibits claim could not be decrypted ({e}); will retry next poll"); + still_pending.push(enc.clone()); continue; } }; @@ -545,12 +623,17 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result { info!("Minibits: redeemed a claimed payment ({got} sats)"); } Err(e) => { - warn!("Minibits claim decrypted but failed to redeem ({e}); coins may be unrecoverable") + warn!("Minibits claim decrypted but failed to redeem ({e}); will retry next poll"); + still_pending.push(enc.clone()); } } } - Ok(ClaimOutcome { claimed_count: redeemed, received_sats: sats }) + let failed_count = still_pending.len(); + state.pending_claims = still_pending; + save_state(data_dir, &state).await?; + + Ok(ClaimOutcome { claimed_count: redeemed, received_sats: sats, failed_count }) } #[cfg(test)] @@ -610,6 +693,41 @@ mod tests { })); } + #[tokio::test] + async fn ensure_mint_accepted_heals_a_dropped_default_mint() { + // Regression guard: `ecash::receive_token` checks the raw accepted-mints + // file, not the more lenient `is_mint_trusted` — so an operator-edited + // allow-list that dropped the default mint must not be able to make + // Minibits claims (already consumed server-side by the time redeem + // runs) fail permanently and silently. + let tmp = tempfile::TempDir::new().unwrap(); + let mint = "https://mint.minibits.cash/Bitcoin"; + ecash::save_accepted_mints( + tmp.path(), + &ecash::AcceptedMints { + mints: vec!["https://mint.example.com".to_string()], + }, + ) + .await + .unwrap(); + + ensure_mint_accepted(tmp.path(), mint).await.unwrap(); + + let accepted = ecash::load_accepted_mints(tmp.path()).await.unwrap(); + assert!(accepted.mints.iter().any(|m| m == mint)); + assert!(accepted.mints.iter().any(|m| m == "https://mint.example.com")); + } + + #[tokio::test] + async fn ensure_mint_accepted_does_not_duplicate() { + let tmp = tempfile::TempDir::new().unwrap(); + let mint = "https://mint.minibits.cash/Bitcoin"; + ensure_mint_accepted(tmp.path(), mint).await.unwrap(); + ensure_mint_accepted(tmp.path(), mint).await.unwrap(); + let accepted = ecash::load_accepted_mints(tmp.path()).await.unwrap(); + assert_eq!(accepted.mints.iter().filter(|m| *m == mint).count(), 1); + } + /// Live end-to-end against the production Minibits API: register a throwaway /// profile with a random ecash phrase and claim (nothing pending → 0). Run /// with `cargo test -- --ignored --nocapture`. It creates one disposable diff --git a/neode-ui/src/components/ReceiveBitcoinModal.vue b/neode-ui/src/components/ReceiveBitcoinModal.vue index e3ea7c36..550c27e2 100644 --- a/neode-ui/src/components/ReceiveBitcoinModal.vue +++ b/neode-ui/src/components/ReceiveBitcoinModal.vue @@ -119,6 +119,9 @@

{{ t('receiveBitcoin.lnAddressReceived', { amount: lnClaimedSats.toLocaleString() }) }}

+

+ {{ t('receiveBitcoin.lnAddressPendingRetry', { count: lnPendingClaims }) }} +

{{ t('receiveBitcoin.lnAddressLoading') }} @@ -201,6 +204,7 @@ watch(() => props.show, (open) => { lnAddressLoading.value = false lnAddressError.value = false lnClaimedSats.value = 0 + lnPendingClaims.value = 0 error.value = '' processing.value = false if (props.autoGenerate && receiveMethod.value === 'onchain') { @@ -233,6 +237,10 @@ const lnAddress = ref('') const lnAddressLoading = ref(false) const lnAddressError = ref(false) const lnClaimedSats = ref(0) +// A payment the backend fetched (and so already consumed at Minibits) but +// couldn't redeem yet — it's queued for automatic retry, not lost, but the +// operator should see it rather than have it be a silent, unbounded wait. +const lnPendingClaims = ref(0) let lnClaimTimer: ReturnType | null = null async function loadLnAddress() { @@ -274,13 +282,14 @@ async function pollLnClaims() { return } try { - const res = await rpcClient.call<{ received_sats?: number }>({ + const res = await rpcClient.call<{ received_sats?: number; failed_count?: number }>({ method: 'wallet.ecash-lnaddress-claim', }) if (res?.received_sats && res.received_sats > 0) { lnClaimedSats.value += res.received_sats emit('received') } + lnPendingClaims.value = res?.failed_count || 0 } catch { // Transient poll failure (offline, mint busy) — keep polling. } @@ -415,6 +424,7 @@ function close() { ecashResult.value = '' lnAddress.value = '' lnClaimedSats.value = 0 + lnPendingClaims.value = 0 error.value = '' emit('close') } diff --git a/neode-ui/src/locales/en.json b/neode-ui/src/locales/en.json index 4fd3fcb3..c852de09 100644 --- a/neode-ui/src/locales/en.json +++ b/neode-ui/src/locales/en.json @@ -781,6 +781,7 @@ "lnAddressLoading": "Setting up your Lightning address…", "lnAddressUnavailable": "Lightning address unavailable — you can still paste a token below.", "lnAddressReceived": "Received {amount} sats to your Lightning address!", + "lnAddressPendingRetry": "A payment arrived but couldn't be redeemed yet ({count}) — retrying automatically, keep this screen open.", "processing": "Processing...", "generateAddress": "Generate Address", "createInvoice": "Create Invoice", diff --git a/neode-ui/src/locales/es.json b/neode-ui/src/locales/es.json index 07a6c5ce..ae1d7fdc 100644 --- a/neode-ui/src/locales/es.json +++ b/neode-ui/src/locales/es.json @@ -762,6 +762,7 @@ "lnAddressLoading": "Configurando su direcci\u00f3n Lightning\u2026", "lnAddressUnavailable": "Direcci\u00f3n Lightning no disponible \u2014 a\u00fan puede pegar un token abajo.", "lnAddressReceived": "\u00a1Recibi\u00f3 {amount} sats en su direcci\u00f3n Lightning!", + "lnAddressPendingRetry": "Lleg\u00f3 un pago pero a\u00fan no se pudo canjear ({count}) \u2014 reintentando autom\u00e1ticamente, mantenga esta pantalla abierta.", "processing": "Procesando...", "generateAddress": "Generar direcci\u00f3n", "createInvoice": "Crear factura", -- 2.54.0 From 3f52e4cd789909ba27fb1329410670b042d0c5d4 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Tue, 8 Sep 2026 15:34:28 +0000 Subject: [PATCH 3/9] fix(ecash): recover from a truncated/corrupt Minibits state file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit archy-x250-pa3's data volume filled to 100% (cuprate at 125G, since removed) while a client had the ecash receive tab open. save_state's write landed mid-truncate, leaving wallet/minibits.json at 0 bytes. load_state then hard-failed every wallet.ecash-lnaddress call with "EOF while parsing a value", surfaced in the UI as "Lightning address unavailable" — permanently, since nothing ever cleared the bad file. Registration is idempotent per pubkey (re-registering returns the same lud16 Minibits already assigned), so there's no reason a corrupt local mirror of that state should be fatal. load_state now treats an empty or unparseable state file the same as a missing one — re-register and recover the same address — instead of erroring. Manually cleared the stuck file on archy-x250-pa3 as an immediate fix; this closes the gap so it self-heals next time. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a --- core/archipelago/src/wallet/minibits.rs | 55 +++++++++++++++++++++---- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/core/archipelago/src/wallet/minibits.rs b/core/archipelago/src/wallet/minibits.rs index dac7815e..713784d2 100644 --- a/core/archipelago/src/wallet/minibits.rs +++ b/core/archipelago/src/wallet/minibits.rs @@ -191,15 +191,26 @@ fn state_path(data_dir: &Path) -> std::path::PathBuf { async fn load_state(data_dir: &Path) -> Result> { let path = state_path(data_dir); match fs::read_to_string(&path).await { - Ok(s) => { - let st: MinibitsState = serde_json::from_str(&s) - .with_context(|| format!("Failed to parse {}", path.display()))?; - if st.wallet_id.is_empty() { + Ok(s) if s.trim().is_empty() => Ok(None), + Ok(s) => match serde_json::from_str::(&s) { + Ok(st) if st.wallet_id.is_empty() => Ok(None), + Ok(st) => Ok(Some(st)), + // Unlike the accepted-mints file, nothing here is a user-editable + // security setting — it's a pure mirror of state Minibits already + // holds server-side, and registration is idempotent per pubkey + // (§ module docs), so re-registering after a corrupt/truncated + // read always recovers the *same* address. A node whose disk + // filled up mid-write (observed on archy-x250-pa3, 2026-09-08: + // this file truncated to 0 bytes) must self-heal on the next open + // rather than permanently show "Lightning address unavailable". + Err(e) => { + warn!( + "Minibits: {} is corrupt/unreadable ({e}); treating as no profile yet and re-registering", + path.display() + ); Ok(None) - } else { - Ok(Some(st)) } - } + }, Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) => Err(e).with_context(|| format!("Failed to read {}", path.display())), } @@ -728,6 +739,36 @@ mod tests { assert_eq!(accepted.mints.iter().filter(|m| *m == mint).count(), 1); } + #[tokio::test] + async fn load_state_treats_empty_file_as_no_profile() { + // Reproduces archy-x250-pa3, 2026-09-08: a disk-full write truncated + // wallet/minibits.json to 0 bytes, which then made every + // wallet.ecash-lnaddress call fail with "EOF while parsing a value" + // instead of just re-registering (idempotent per pubkey, so safe). + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join(STATE_FILE); + tokio::fs::create_dir_all(path.parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(&path, b"").await.unwrap(); + + let st = load_state(tmp.path()).await.unwrap(); + assert!(st.is_none()); + } + + #[tokio::test] + async fn load_state_treats_corrupt_json_as_no_profile() { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join(STATE_FILE); + tokio::fs::create_dir_all(path.parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(&path, b"{ not valid json").await.unwrap(); + + let st = load_state(tmp.path()).await.unwrap(); + assert!(st.is_none()); + } + /// Live end-to-end against the production Minibits API: register a throwaway /// profile with a random ecash phrase and claim (nothing pending → 0). Run /// with `cargo test -- --ignored --nocapture`. It creates one disposable -- 2.54.0 From 3be6f45fe860622ff129ff9ba368f3f159f8dcb6 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Tue, 8 Sep 2026 15:46:12 +0000 Subject: [PATCH 4/9] test(ui): guard the ecash-tab-click path in ReceiveBitcoinModal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator report (2026-09-08): clicking the Ecash tab appeared to close the whole Receive modal. Added a regression test simulating the exact click, both for wallet.ecash-lnaddress succeeding and failing — the tab switch alone never emits `close` or unmounts the dialog in either case, so this isn't reproduced by a plain component-level click; the investigation continues with the reporter for a browser-console repro. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a --- .../__tests__/ReceiveBitcoinModal.test.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 neode-ui/src/components/__tests__/ReceiveBitcoinModal.test.ts diff --git a/neode-ui/src/components/__tests__/ReceiveBitcoinModal.test.ts b/neode-ui/src/components/__tests__/ReceiveBitcoinModal.test.ts new file mode 100644 index 00000000..3a9b4986 --- /dev/null +++ b/neode-ui/src/components/__tests__/ReceiveBitcoinModal.test.ts @@ -0,0 +1,73 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import ReceiveBitcoinModal from '../ReceiveBitcoinModal.vue' +import { rpcClient } from '@/api/rpc-client' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ fullPath: '/dashboard' }), + useRouter: () => ({ push: vi.fn() }), +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: (key: string, params?: Record) => (params ? `${key}:${JSON.stringify(params)}` : key) }), +})) + +vi.mock('@/api/rpc-client', () => ({ + rpcClient: { call: vi.fn() }, +})) + +vi.mock('@/composables/useLightningRequired', () => ({ + useLightningRequired: () => ({ + requireLightningReady: vi.fn().mockResolvedValue(true), + handleLightningFailure: vi.fn().mockReturnValue(false), + }), +})) + +// Guards an operator report (2026-09-08): clicking the Ecash tab appeared to +// close the whole Receive modal. Not reproduced here — the tab switch alone +// (success or failure of wallet.ecash-lnaddress) never emits `close` or +// unmounts the dialog — but the RPC-eager tab switch is exactly the kind of +// path a future change could regress, so it's worth pinning down. +describe('ReceiveBitcoinModal — ecash tab click', () => { + it('does not close/emit when the ecash tab is clicked and the RPC succeeds', async () => { + vi.mocked(rpcClient.call).mockResolvedValue({ address: 'someone@minibits.cash' } as never) + + const wrapper = mount(ReceiveBitcoinModal, { + props: { show: true }, + attachTo: document.body, + }) + await flushPromises() + + const tabs = Array.from(document.body.querySelectorAll('button')) + const ecashTab = tabs.find((b) => b.textContent?.toLowerCase().includes('ecash')) + expect(ecashTab).toBeTruthy() + + ecashTab!.dispatchEvent(new Event('click', { bubbles: true })) + await flushPromises() + + expect(wrapper.emitted('close')).toBeFalsy() + expect(document.body.querySelector('[role="dialog"]')).toBeTruthy() + wrapper.unmount() + }) + + it('does not close/emit when the ecash tab is clicked and the RPC fails', async () => { + vi.mocked(rpcClient.call).mockRejectedValue(new Error('boom')) + + const wrapper = mount(ReceiveBitcoinModal, { + props: { show: true }, + attachTo: document.body, + }) + await flushPromises() + + const tabs = Array.from(document.body.querySelectorAll('button')) + const ecashTab = tabs.find((b) => b.textContent?.toLowerCase().includes('ecash')) + expect(ecashTab).toBeTruthy() + + ecashTab!.dispatchEvent(new Event('click', { bubbles: true })) + await flushPromises() + + expect(wrapper.emitted('close')).toBeFalsy() + expect(document.body.querySelector('[role="dialog"]')).toBeTruthy() + wrapper.unmount() + }) +}) -- 2.54.0 From 6041eb63062ff0eae6a25350197edc28f46220a6 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Tue, 8 Sep 2026 16:19:52 +0000 Subject: [PATCH 5/9] fix(ui): escape the literal @ in the Minibits address label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of "click Receive, click Ecash, the modal disappears" (in both the browser and the Android companion's WebView, since both host the same neode-ui bundle): vue-i18n treats a bare @ as the start of "linked message" syntax. receiveBitcoin.lnAddressLabel ("Your @minibits.cash address:") isn't valid linked-message syntax, so *compiling* that message throws a SyntaxError the instant it's first rendered — i.e. the moment wallet.ecash-lnaddress resolves and the address section becomes visible. The uncaught render-function error blanks the whole teleported modal, which is indistinguishable from it just closing. Confirmed with a real (non-mocked) Vue app + real vue-i18n compiler in a headless Chromium — a Vitest run with `t` mocked to a no-op, which is how the existing component test suite covers this file, cannot catch a bad message string at all. Fixed by escaping the @ as {'@'} — the same pattern the codebase already uses for settings.domainNamePlaceholder ("user{'@'}example.com"). Added a regression test using the real vue-i18n instance instead of the mocked one; verified it fails on the old string and passes on the fix. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a --- .../ReceiveBitcoinModal.i18n.test.ts | 66 +++++++++++++++++++ neode-ui/src/locales/en.json | 2 +- neode-ui/src/locales/es.json | 2 +- 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 neode-ui/src/components/__tests__/ReceiveBitcoinModal.i18n.test.ts diff --git a/neode-ui/src/components/__tests__/ReceiveBitcoinModal.i18n.test.ts b/neode-ui/src/components/__tests__/ReceiveBitcoinModal.i18n.test.ts new file mode 100644 index 00000000..a5d036d9 --- /dev/null +++ b/neode-ui/src/components/__tests__/ReceiveBitcoinModal.i18n.test.ts @@ -0,0 +1,66 @@ +// Real vue-i18n instance (unlike ReceiveBitcoinModal.test.ts, which mocks +// `t` to a no-op and so cannot catch a bad message string). Operator report +// (2026-09-08): clicking the Ecash tab closed the whole Receive modal, in +// both the browser and the Android companion's WebView. Root cause: vue-i18n +// treats a bare `@` as the start of "linked message" syntax — `en.json`'s +// `receiveBitcoin.lnAddressLabel` ("Your @minibits.cash address:") isn't +// valid linked-message syntax, so *compiling* that message throws a +// SyntaxError the instant it's first rendered (i.e. the moment the address +// loads), and the uncaught render-function error blanks the whole teleported +// modal. Fixed by escaping it as `{'@'}` (the same pattern already used for +// `settings.domainNamePlaceholder`). This test uses the real compiler so a +// future bad interpolation string in this component fails fast in `npm test` +// instead of only in a live browser. +import { flushPromises, mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import ReceiveBitcoinModal from '../ReceiveBitcoinModal.vue' +import { rpcClient } from '@/api/rpc-client' +import i18n from '@/i18n' + +vi.mock('@/api/rpc-client', () => ({ + rpcClient: { call: vi.fn() }, +})) + +vi.mock('@/composables/useLightningRequired', () => ({ + useLightningRequired: () => ({ + requireLightningReady: vi.fn().mockResolvedValue(true), + handleLightningFailure: vi.fn().mockReturnValue(false), + }), +})) + +describe('ReceiveBitcoinModal — ecash tab with the real vue-i18n compiler', () => { + it('renders the Minibits address label without an uncaught render error', async () => { + vi.mocked(rpcClient.call).mockImplementation(async ({ method }: { method: string }) => { + if (method === 'wallet.ecash-lnaddress') { + return { address: 'someone@minibits.cash' } as never + } + return { claimed_count: 0, received_sats: 0, failed_count: 0 } as never + }) + + const wrapper = mount(ReceiveBitcoinModal, { + props: { show: true }, + attachTo: document.body, + global: { plugins: [i18n] }, + }) + let captured: unknown = null + wrapper.vm.$.appContext.app.config.errorHandler = (err) => { captured = err } + await flushPromises() + + const ecashTab = Array.from(document.body.querySelectorAll('button')).find((b) => + b.textContent?.toLowerCase().includes('ecash'), + ) + expect(ecashTab).toBeTruthy() + ecashTab!.dispatchEvent(new Event('click', { bubbles: true })) + await flushPromises() + await flushPromises() + + expect(captured).toBeNull() + expect(wrapper.emitted('close')).toBeFalsy() + const dialog = document.body.querySelector('[role="dialog"]') + expect(dialog).toBeTruthy() + expect(dialog?.textContent).toContain('minibits.cash') + expect(dialog?.textContent).toContain('someone@minibits.cash') + + wrapper.unmount() + }) +}) diff --git a/neode-ui/src/locales/en.json b/neode-ui/src/locales/en.json index c852de09..751bc17b 100644 --- a/neode-ui/src/locales/en.json +++ b/neode-ui/src/locales/en.json @@ -777,7 +777,7 @@ "pasteEcashToken": "Paste ecash token", "lnAddressTitle": "Or share your Minibits Lightning address", "lnAddressHint": "Anyone can pay you sats with any Lightning wallet by sending to this address — the sats arrive as ecash. Keep this screen open to receive them.", - "lnAddressLabel": "Your @minibits.cash address:", + "lnAddressLabel": "Your {'@'}minibits.cash address:", "lnAddressLoading": "Setting up your Lightning address…", "lnAddressUnavailable": "Lightning address unavailable — you can still paste a token below.", "lnAddressReceived": "Received {amount} sats to your Lightning address!", diff --git a/neode-ui/src/locales/es.json b/neode-ui/src/locales/es.json index ae1d7fdc..d1ceb5cd 100644 --- a/neode-ui/src/locales/es.json +++ b/neode-ui/src/locales/es.json @@ -758,7 +758,7 @@ "pasteEcashToken": "Pegar token Ecash", "lnAddressTitle": "O comparte tu direcci\u00f3n Lightning de Minibits", "lnAddressHint": "Cualquier persona puede pagarte sats con cualquier billetera Lightning enviando a esta direcci\u00f3n \u2014 los sats llegan como ecash. Mant\u00e9n esta pantalla abierta para recibirlos.", - "lnAddressLabel": "Su direcci\u00f3n @minibits.cash:", + "lnAddressLabel": "Su direcci\u00f3n {'@'}minibits.cash:", "lnAddressLoading": "Configurando su direcci\u00f3n Lightning\u2026", "lnAddressUnavailable": "Direcci\u00f3n Lightning no disponible \u2014 a\u00fan puede pegar un token abajo.", "lnAddressReceived": "\u00a1Recibi\u00f3 {amount} sats en su direcci\u00f3n Lightning!", -- 2.54.0 From 3768395e5920e65ba06de6af7e785e897c6f84d1 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Tue, 8 Sep 2026 16:25:37 +0000 Subject: [PATCH 6/9] fix(ui): escape a second live vue-i18n message-compile crash + add a full-sweep test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same class of bug as the Minibits address label (settings.passwordNeedSpecial: "...(!@#$%^&* etc.)" — a bare @ vue-i18n parses as linked-message syntax). This one is live in ChangePasswordSection.vue's password-strength validator: typing a new password with no special character throws this exact SyntaxError the moment the message is rendered. Fixed the same way ({'@'} escaping). Added locales/__tests__/i18nMessagesCompile.test.ts, which walks every string in every locale file and asks the real vue-i18n compiler to parse it — confirmed it fails on both bad strings before their fixes and passes clean now, with no other landmines left in either locale file. This closes the whole bug class rather than just these two instances; a future bad interpolation string fails `npm test` instead of only a live crash report. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a --- .../__tests__/i18nMessagesCompile.test.ts | 48 +++++++++++++++++++ neode-ui/src/locales/en.json | 2 +- neode-ui/src/locales/es.json | 2 +- 3 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 neode-ui/src/locales/__tests__/i18nMessagesCompile.test.ts diff --git a/neode-ui/src/locales/__tests__/i18nMessagesCompile.test.ts b/neode-ui/src/locales/__tests__/i18nMessagesCompile.test.ts new file mode 100644 index 00000000..2cd34f7f --- /dev/null +++ b/neode-ui/src/locales/__tests__/i18nMessagesCompile.test.ts @@ -0,0 +1,48 @@ +// Every message string must survive vue-i18n's message compiler. Found the +// hard way (2026-09-08): a bare `@` in a message is parsed as the start of +// "linked message" syntax (`@:key`), so a literal `@` (an email/handle-style +// placeholder, e.g. "user@example.com") throws a SyntaxError the first time +// it's *rendered*, not at build time — see [[vue-i18n-bare-at-sign-crash]] +// in project memory for the full incident (it blanked a whole modal in both +// the browser and the Android companion's WebView). A literal `@`, `{`, `}` +// or other message-syntax character must be escaped as e.g. `{'@'}`. +// +// This walks every string in every locale file and asks the real compiler +// to parse it — no rendering, no component needed, so it's fast and catches +// the whole class of bug regardless of which component ever ends up using +// the string. +import { describe, it, expect } from 'vitest' +import i18n from '@/i18n' +import en from '../en.json' +import es from '../es.json' + +function collectStrings(obj: unknown, path: string, out: Array<[string, string]>) { + if (typeof obj === 'string') { + out.push([path, obj]) + } else if (obj && typeof obj === 'object') { + for (const [k, v] of Object.entries(obj as Record)) { + collectStrings(v, path ? `${path}.${k}` : k, out) + } + } +} + +describe('locale messages compile', () => { + it.each([ + ['en', en], + ['es', es], + ])('every %s message string compiles under the real vue-i18n compiler', (_locale, messages) => { + const strings: Array<[string, string]> = [] + collectStrings(messages, '', strings) + expect(strings.length).toBeGreaterThan(100) + + const failures: string[] = [] + for (const [path, msg] of strings) { + try { + i18n.global.t(path) + } catch (e) { + failures.push(`${path}: ${(e as Error).message.split('\n')[0]} (source: ${JSON.stringify(msg)})`) + } + } + expect(failures).toEqual([]) + }) +}) diff --git a/neode-ui/src/locales/en.json b/neode-ui/src/locales/en.json index 751bc17b..b7e17f8a 100644 --- a/neode-ui/src/locales/en.json +++ b/neode-ui/src/locales/en.json @@ -315,7 +315,7 @@ "passwordNeedUppercase": "Password must contain at least one uppercase letter", "passwordNeedLowercase": "Password must contain at least one lowercase letter", "passwordNeedDigit": "Password must contain at least one digit", - "passwordNeedSpecial": "Password must contain at least one special character (!@#$%^&* etc.)", + "passwordNeedSpecial": "Password must contain at least one special character (!{'@'}#$%^&* etc.)", "setupFailed": "Setup failed", "verificationFailed": "Verification failed", "disableFailed": "Failed to disable 2FA", diff --git a/neode-ui/src/locales/es.json b/neode-ui/src/locales/es.json index d1ceb5cd..5f9fa831 100644 --- a/neode-ui/src/locales/es.json +++ b/neode-ui/src/locales/es.json @@ -315,7 +315,7 @@ "passwordNeedUppercase": "La contrase\u00f1a debe contener al menos una letra may\u00fascula", "passwordNeedLowercase": "La contrase\u00f1a debe contener al menos una letra min\u00fascula", "passwordNeedDigit": "La contrase\u00f1a debe contener al menos un d\u00edgito", - "passwordNeedSpecial": "La contrase\u00f1a debe contener al menos un car\u00e1cter especial (!@#$%^&* etc.)", + "passwordNeedSpecial": "La contrase\u00f1a debe contener al menos un car\u00e1cter especial (!{'@'}#$%^&* etc.)", "setupFailed": "La configuraci\u00f3n fall\u00f3", "verificationFailed": "La verificaci\u00f3n fall\u00f3", "disableFailed": "Error al deshabilitar 2FA", -- 2.54.0 From fc5b51ab2f88b9fe112930529b3dfb5dc3d91121 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Tue, 8 Sep 2026 22:23:56 +0000 Subject: [PATCH 7/9] 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 -- 2.54.0 From 4e410d7c9883f8e8b0471a8fa4f628d218ce9d37 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Wed, 9 Sep 2026 03:50:34 +0000 Subject: [PATCH 8/9] fix(ecash): guard Minibits claim polls against races and stop replayed claims retrying forever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI polls wallet.ecash-lnaddress-claim every 8s, but a single poll (auth + /claim + relay fetch + redeem loop) can outlast that interval. Two overlapping claim_and_redeem runs then loaded the same last_dm_seen_at, fetched/redeemed the same claims, and last-writer-wins on save — rewinding the watermark and/or double-redeeming. A double-redeemed or state-loss-replayed claim then failed forever as "already spent" with no way to leave pending_claims, leaving a permanent orange retry banner. - STATE_LOCK (backend) + an in-flight guard (UI) serialize claim polls and the lnaddress registration/token-refresh path, so two callers can't race on minibits.json. - pending_claims now tracks per-claim attempts (PendingClaim, migrating transparently from the old plain-string shape); a claim that fails MAX_CLAIM_ATTEMPTS times is dropped instead of retried forever. - A redeem failure recognized as mint error 11001 (already redeemed) is treated as terminal and dropped immediately — the value was already swept, so retrying it is pointless. ClaimOutcome gains dropped_count so the two drop reasons (harmless vs. real loss) are visible to the caller. - save_state now writes via temp-file + rename instead of truncating minibits.json in place — the exact disk-full failure mode that corrupted this file on archy-x250-pa3, 2026-09-08, could otherwise destroy pending_claims tokens that /claim had already consumed server-side (unrecoverable, unlike relay DMs). - minibits_error no longer panics on a multi-byte UTF-8 boundary when truncating a server error body (was byte-slicing, not char-safe). - register_profile's name-collision check now matches the structured error.name == ALREADY_EXISTS instead of a raw "already" substring, so an unrelated error message doesn't burn a retry attempt. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EZnFgeUBKY5UAfyJFsYccS --- core/archipelago/src/api/rpc/wallet.rs | 1 + core/archipelago/src/wallet/minibits.rs | 256 +++++++++++++++--- core/archipelago/src/wallet/mint_client.rs | 8 +- .../src/components/ReceiveBitcoinModal.vue | 8 + .../__tests__/ReceiveBitcoinModal.test.ts | 56 ++++ 5 files changed, 290 insertions(+), 39 deletions(-) diff --git a/core/archipelago/src/api/rpc/wallet.rs b/core/archipelago/src/api/rpc/wallet.rs index a4ca5600..02a938ea 100644 --- a/core/archipelago/src/api/rpc/wallet.rs +++ b/core/archipelago/src/api/rpc/wallet.rs @@ -442,6 +442,7 @@ impl RpcHandler { "claimed_count": outcome.claimed_count, "received_sats": outcome.received_sats, "failed_count": outcome.failed_count, + "dropped_count": outcome.dropped_count, })) } diff --git a/core/archipelago/src/wallet/minibits.rs b/core/archipelago/src/wallet/minibits.rs index 7471e10f..8f6f2030 100644 --- a/core/archipelago/src/wallet/minibits.rs +++ b/core/archipelago/src/wallet/minibits.rs @@ -138,14 +138,14 @@ pub struct MinibitsState { pub server_nostr_pubkey: String, #[serde(default)] pub created_at: String, - /// Raw NIP-04-encrypted claim tokens fetched from `/claim` but not yet - /// successfully redeemed. A claim is consumed server-side the instant - /// `/claim` returns it, so it is stashed here *before* decrypt/redeem is + /// Raw NIP-04-encrypted claim tokens fetched from `/claim` (or a relay DM) + /// but not yet successfully redeemed. A claim is consumed server-side the + /// instant it's fetched, so it is stashed here *before* decrypt/redeem is /// attempted — a local failure (mint briefly down, bad cached server key, /// process crash mid-loop) then retries next poll instead of losing the /// coins outright. #[serde(default)] - pub pending_claims: Vec, + 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 @@ -155,6 +155,64 @@ pub struct MinibitsState { pub last_dm_seen_at: u64, } +/// A claim token queued for redeem, plus how many times redeem has already +/// been tried. Deserializes from either shape: the pre-existing plain string +/// (a node's `minibits.json` written before this field existed) becomes +/// `attempts: 0`, so upgrading never drops or resets an operator's queued +/// claims (see the module's data-preservation invariant). +#[derive(Debug, Clone, Serialize)] +pub struct PendingClaim { + pub token: String, + #[serde(default)] + pub attempts: u32, +} + +impl<'de> Deserialize<'de> for PendingClaim { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum Repr { + Legacy(String), + Full { + token: String, + #[serde(default)] + attempts: u32, + }, + } + Ok(match Repr::deserialize(deserializer)? { + Repr::Legacy(token) => PendingClaim { token, attempts: 0 }, + Repr::Full { token, attempts } => PendingClaim { token, attempts }, + }) + } +} + +/// A claim is dropped (not retried again) after this many failed decrypt/redeem +/// attempts — a backstop against a token that fails for a permanent reason +/// `is_already_redeemed` doesn't catch (e.g. a corrupt payload), which would +/// otherwise retry forever and keep `failed_count` stuck non-zero. +const MAX_CLAIM_ATTEMPTS: u32 = 20; + +/// 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 after a state-file +/// watermark rewind replays an already-swept relay DM, or (pre-`STATE_LOCK`) +/// after a race let two polls redeem the same claim. +fn is_already_redeemed(err: &anyhow::Error) -> bool { + err.to_string().contains(super::mint_client::ALREADY_REDEEMED_MSG) +} + +/// Serializes every in-flight Minibits state read-modify-write +/// (`lnaddress`'s registration/token-refresh and `claim_and_redeem`'s fetch/ +/// redeem cycle) so two callers can never race on `minibits.json`. Without +/// this, two overlapping claim polls can each load the same +/// `last_dm_seen_at`, fetch the same DMs, and last-writer-wins on save — +/// rewinding the watermark and/or duplicating a pending token into a double +/// redeem. +static STATE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + /// A fresh Nostr keypair + seedHash derived from the node's ecash phrase. struct MinibitsIdentity { keys: nostr_sdk::Keys, @@ -184,19 +242,28 @@ struct ProfileRecord { pubkey: String, } +/// Parse a Minibits `{"error": {"name": ..., "message": ...}}` response body, +/// when present. +fn parse_minibits_error(body: &str) -> Option<(String, String)> { + let v: serde_json::Value = serde_json::from_str(body).ok()?; + let err = v.get("error")?; + let name = err.get("name").and_then(|n| n.as_str()).unwrap_or("ERROR"); + let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or(""); + Some((name.to_string(), msg.to_string())) +} + /// Turn a non-2xx Minibits response into a readable error, surfacing the /// server's `error.name`/`error.message` when present. fn minibits_error(status: reqwest::StatusCode, body: &str) -> anyhow::Error { - if let Ok(v) = serde_json::from_str::(body) { - if let Some(err) = v.get("error") { - let name = err.get("name").and_then(|n| n.as_str()).unwrap_or("ERROR"); - let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or(""); - return anyhow!("Minibits API error {status}: {name} {msg}"); - } + if let Some((name, msg)) = parse_minibits_error(body) { + return anyhow!("Minibits API error {status}: {name} {msg}"); } + // Truncate on a char boundary, not a byte index — `body` is + // server-controlled and a multi-byte UTF-8 character straddling byte 180 + // would otherwise panic inside this RPC handler's own error path. anyhow!( "Minibits API error {status}: {}", - &body[..body.len().min(180)] + body.chars().take(180).collect::() ) } @@ -234,6 +301,15 @@ async fn load_state(data_dir: &Path) -> Result> { /// Write the state file 0600 — it holds a bearer JWT. Same sensitivity class as /// the ecash files it sits beside, so it gets the same owner-only mode. +/// +/// Writes via a temp file + rename rather than truncating `minibits.json` in +/// place: a disk-full write hitting the truncate-then-write path destroyed +/// this exact file on archy-x250-pa3, 2026-09-08. `pending_claims` entries +/// sourced from `/claim` are unrecoverable once consumed server-side (unlike +/// relay DMs, which stay on the relay), so losing that field to a partial +/// write is not something the existing corrupt-file self-heal can undo — this +/// makes the write itself atomic instead. `STATE_LOCK` (held by every caller) +/// makes a fixed temp filename safe: only one writer runs at a time. async fn save_state(data_dir: &Path, state: &MinibitsState) -> Result<()> { let path = state_path(data_dir); if let Some(parent) = path.parent() { @@ -243,17 +319,22 @@ async fn save_state(data_dir: &Path, state: &MinibitsState) -> Result<()> { } let content = serde_json::to_string_pretty(state) .context("Failed to serialize the Minibits profile")?; - fs::write(&path, content) + let tmp_path = path.with_extension("json.tmp"); + fs::write(&tmp_path, content) .await - .with_context(|| format!("Failed to write {}", path.display()))?; + .with_context(|| format!("Failed to write {}", tmp_path.display()))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o600)) .await - .with_context(|| format!("Failed to chmod 0600 {}", path.display()))?; + .with_context(|| format!("Failed to chmod 0600 {}", tmp_path.display()))?; } + + fs::rename(&tmp_path, &path) + .await + .with_context(|| format!("Failed to move {} into place", tmp_path.display()))?; Ok(()) } @@ -395,8 +476,14 @@ async fn register_profile( .context("Minibits profile response was not the expected shape")?; return Ok(rec); } - // Name collision → draw another. Anything else is fatal. - let is_taken = body.contains("ALREADY_EXISTS") || body.contains("already"); + // Name collision → draw another. Anything else is fatal. Match on the + // structured error name, not a raw substring: an unrelated failure + // whose free-text message happens to contain the word "already" + // (e.g. a rate-limit or session message) must not burn one of the + // 6 retry attempts here. + let is_taken = parse_minibits_error(&body) + .map(|(name, _)| name.eq_ignore_ascii_case("ALREADY_EXISTS")) + .unwrap_or(false); if is_taken { warn!("Minibits name '{wallet_id}' taken, retrying (attempt {attempt})"); last_err = Some(minibits_error(status, &body)); @@ -463,6 +550,11 @@ pub async fn lnaddress(data_dir: &Path) -> Result { let (phrase, seed) = ecash_phrase(data_dir).await?; let identity = derive_identity(&phrase, &seed)?; + // Hold the same lock a claim poll uses: both read-modify-write + // `minibits.json`, and this call registers the profile the first time — + // it must not race a poll that's mid-save. + let _guard = STATE_LOCK.lock().await; + let mut state = match load_state(data_dir).await? { Some(st) => st, None => { @@ -511,26 +603,20 @@ pub struct ClaimOutcome { /// dropped. Non-zero here means real, unswept value the operator should /// know about. pub failed_count: usize, + /// Claims permanently given up on this poll: either recognized as + /// already redeemed elsewhere (harmless — the value was already swept), + /// or a decrypt/redeem failure that hit `MAX_CLAIM_ATTEMPTS`. The latter + /// case means real value that was lost; the former does not. + pub dropped_count: usize, } const NO_CLAIMS: ClaimOutcome = ClaimOutcome { claimed_count: 0, received_sats: 0, failed_count: 0, + dropped_count: 0, }; -/// Make sure the Minibits mint is on the accepted-mints allow-list. -/// -/// `ecash::receive_token` checks the raw accepted-mints file directly (not -/// the more lenient `ecash::is_mint_trusted`, which always trusts the default -/// mint) — so an operator who edited their accepted-mints list (e.g. via the -/// `streaming.configure-mints` RPC) and dropped the default mint would -/// otherwise cause every Minibits claim to fail *after* the claim was already -/// consumed server-side, permanently losing those coins with nothing but a -/// log line to show for it. The Minibits Lightning address is inherently -/// 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 @@ -613,6 +699,19 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result { if network == EcashNetwork::Testnet { return Ok(NO_CLAIMS); } + + // The UI polls this every 8s, and a single poll (auth + `/claim` + relay + // fetch + redeem loop) can run well past that — so a second poll starting + // before the first finishes is expected, not exceptional. Skip it rather + // than queue: both copies would otherwise load the same + // `last_dm_seen_at`, fetch/redeem the same claims, and last-writer-wins on + // save (rewinding the watermark and/or double-redeeming). Nothing is lost + // by skipping — relay DMs persist and get picked up next tick. + let Ok(_guard) = STATE_LOCK.try_lock() else { + debug!("Minibits: a claim poll is already in flight, skipping this tick"); + return Ok(NO_CLAIMS); + }; + ensure_mint_accepted(data_dir, &network.default_mint()).await?; let client = reqwest::Client::builder() @@ -663,7 +762,10 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result { Ok(claims) => { for claim in &claims { match claim.get("token").and_then(|t| t.as_str()) { - Some(t) => state.pending_claims.push(t.to_string()), + Some(t) => state.pending_claims.push(PendingClaim { + token: t.to_string(), + attempts: 0, + }), None => warn!("Minibits claim had no 'token' field; skipping"), } } @@ -687,7 +789,10 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result { warn!("Minibits: ignoring claim DM from unexpected pubkey {author}"); continue; } - state.pending_claims.push(content); + state.pending_claims.push(PendingClaim { + token: content, + attempts: 0, + }); state.last_dm_seen_at = state.last_dm_seen_at.max(created_at); } @@ -704,13 +809,23 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result { let to_process = std::mem::take(&mut state.pending_claims); let mut redeemed = 0usize; let mut sats = 0u64; + let mut dropped = 0usize; let mut still_pending = Vec::new(); - for enc in &to_process { - let decoded = match nip04::decrypt(identity.keys.secret_key(), &server_pk, enc) { + for mut claim in to_process { + let decoded = match nip04::decrypt(identity.keys.secret_key(), &server_pk, &claim.token) { Ok(d) => d, Err(e) => { - warn!("Minibits claim could not be decrypted ({e}); will retry next poll"); - still_pending.push(enc.clone()); + claim.attempts += 1; + if claim.attempts >= MAX_CLAIM_ATTEMPTS { + warn!( + "Minibits: dropping a claim after {} failed decrypt attempts ({e})", + claim.attempts + ); + dropped += 1; + } else { + warn!("Minibits claim could not be decrypted ({e}); will retry next poll"); + still_pending.push(claim); + } continue; } }; @@ -720,9 +835,26 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result { sats += got; info!("Minibits: redeemed a claimed payment ({got} sats)"); } + // Terminal: the token was already swept (a watermark-rewind + // replay of an old DM, or — pre-`STATE_LOCK` — a race's double + // redeem). Retrying changes nothing, so drop it instead of + // leaving `failed_count` stuck non-zero forever. + Err(e) if is_already_redeemed(&e) => { + info!("Minibits: a claimed token was already redeemed elsewhere; dropping it ({e})"); + dropped += 1; + } Err(e) => { - warn!("Minibits claim decrypted but failed to redeem ({e}); will retry next poll"); - still_pending.push(enc.clone()); + claim.attempts += 1; + if claim.attempts >= MAX_CLAIM_ATTEMPTS { + warn!( + "Minibits: dropping a claim after {} failed redeem attempts ({e})", + claim.attempts + ); + dropped += 1; + } else { + warn!("Minibits claim decrypted but failed to redeem ({e}); will retry next poll"); + still_pending.push(claim); + } } } } @@ -731,13 +863,61 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result { state.pending_claims = still_pending; save_state(data_dir, &state).await?; - Ok(ClaimOutcome { claimed_count: redeemed, received_sats: sats, failed_count }) + Ok(ClaimOutcome { + claimed_count: redeemed, + received_sats: sats, + failed_count, + dropped_count: dropped, + }) } #[cfg(test)] mod tests { use super::*; + #[tokio::test] + async fn state_lock_rejects_a_second_concurrent_holder() { + // Regression guard for the overlapping-claim race: `claim_and_redeem` + // must skip rather than run when another call already holds + // `STATE_LOCK`, or two polls can race on `minibits.json`. + let _guard = STATE_LOCK.lock().await; + assert!(STATE_LOCK.try_lock().is_err()); + } + + #[test] + fn pending_claim_migrates_from_the_legacy_plain_string_shape() { + // A node's existing `minibits.json` (written before `attempts` + // existed) has `pending_claims` as a plain string array — it must + // load as `attempts: 0`, not fail or drop the queued claim. + let legacy: PendingClaim = serde_json::from_str("\"encrypted-token\"").unwrap(); + assert_eq!(legacy.token, "encrypted-token"); + assert_eq!(legacy.attempts, 0); + + let current: PendingClaim = + serde_json::from_str(r#"{"token":"encrypted-token","attempts":3}"#).unwrap(); + assert_eq!(current.token, "encrypted-token"); + assert_eq!(current.attempts, 3); + } + + #[test] + fn minibits_error_does_not_panic_on_a_multibyte_boundary() { + // Reproduces the byte-slice panic: a multi-byte UTF-8 character + // straddling byte 180 of a server-controlled error body must not + // crash the RPC handler's own error path. + let body = format!("{}{}", "x".repeat(179), "€".repeat(10)); + let err = minibits_error(reqwest::StatusCode::BAD_REQUEST, &body); + assert!(err.to_string().contains("Minibits API error")); + } + + #[test] + fn is_already_redeemed_matches_only_the_specific_mint_error() { + let redeemed = anyhow!("Could not receive this ecash: {}", super::super::mint_client::ALREADY_REDEEMED_MSG); + assert!(is_already_redeemed(&redeemed)); + + let unrelated = anyhow!("Could not receive this ecash: mint unreachable"); + assert!(!is_already_redeemed(&unrelated)); + } + #[test] fn derived_nostr_key_matches_the_nip06_vector() { // The Minibits app derives its Nostr key at m/44'/1237'/0'/0/0 with an diff --git a/core/archipelago/src/wallet/mint_client.rs b/core/archipelago/src/wallet/mint_client.rs index 2da75edd..a591874a 100644 --- a/core/archipelago/src/wallet/mint_client.rs +++ b/core/archipelago/src/wallet/mint_client.rs @@ -71,10 +71,16 @@ 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. Minibits +/// claim replay) 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."; + 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.", diff --git a/neode-ui/src/components/ReceiveBitcoinModal.vue b/neode-ui/src/components/ReceiveBitcoinModal.vue index 550c27e2..e64fd42f 100644 --- a/neode-ui/src/components/ReceiveBitcoinModal.vue +++ b/neode-ui/src/components/ReceiveBitcoinModal.vue @@ -242,6 +242,10 @@ const lnClaimedSats = ref(0) // operator should see it rather than have it be a silent, unbounded wait. const lnPendingClaims = ref(0) let lnClaimTimer: ReturnType | null = null +// A poll can outlast the 8s interval (backend auth + relay fetch + redeem +// loop) — without this, the next tick fires on top of it and both calls hit +// the backend's `minibits.json` at once. +let lnPollInFlight = false async function loadLnAddress() { if (lnAddress.value || lnAddressLoading.value) return @@ -281,6 +285,8 @@ async function pollLnClaims() { stopLnClaimPoll() return } + if (lnPollInFlight) return + lnPollInFlight = true try { const res = await rpcClient.call<{ received_sats?: number; failed_count?: number }>({ method: 'wallet.ecash-lnaddress-claim', @@ -292,6 +298,8 @@ async function pollLnClaims() { lnPendingClaims.value = res?.failed_count || 0 } catch { // Transient poll failure (offline, mint busy) — keep polling. + } finally { + lnPollInFlight = false } } diff --git a/neode-ui/src/components/__tests__/ReceiveBitcoinModal.test.ts b/neode-ui/src/components/__tests__/ReceiveBitcoinModal.test.ts index 3a9b4986..5443a8ce 100644 --- a/neode-ui/src/components/__tests__/ReceiveBitcoinModal.test.ts +++ b/neode-ui/src/components/__tests__/ReceiveBitcoinModal.test.ts @@ -71,3 +71,59 @@ describe('ReceiveBitcoinModal — ecash tab click', () => { wrapper.unmount() }) }) + +// Regression guard for the overlapping-claim race: a single +// wallet.ecash-lnaddress-claim call can outlast the 8s poll interval (backend +// auth + relay fetch + redeem loop), and a second call firing on top of it +// raced on the backend's minibits.json (see minibits.rs STATE_LOCK). +describe('ReceiveBitcoinModal — ecash claim poll', () => { + it('does not start a second claim poll while one is still in flight', async () => { + vi.useFakeTimers() + let resolveClaim: (v: unknown) => void = () => {} + vi.mocked(rpcClient.call).mockImplementation((args: unknown) => { + const method = (args as { method?: string })?.method + if (method === 'wallet.ecash-lnaddress') { + return Promise.resolve({ address: 'someone@minibits.cash' } as never) + } + if (method === 'wallet.ecash-lnaddress-claim') { + return new Promise((resolve) => { + resolveClaim = resolve + }) as never + } + return Promise.resolve({} as never) + }) + + const wrapper = mount(ReceiveBitcoinModal, { + props: { show: true }, + attachTo: document.body, + }) + await flushPromises() + + const tabs = Array.from(document.body.querySelectorAll('button')) + const ecashTab = tabs.find((b) => b.textContent?.toLowerCase().includes('ecash')) + ecashTab!.dispatchEvent(new Event('click', { bubbles: true })) + await flushPromises() + + const claimCalls = () => + vi + .mocked(rpcClient.call) + .mock.calls.filter(([a]) => (a as { method?: string })?.method === 'wallet.ecash-lnaddress-claim').length + + await vi.advanceTimersByTimeAsync(8000) + expect(claimCalls()).toBe(1) + + // Second tick fires while the first claim call is still unresolved. + await vi.advanceTimersByTimeAsync(8000) + expect(claimCalls()).toBe(1) + + resolveClaim({ received_sats: 0, failed_count: 0 }) + await flushPromises() + + // Once the in-flight call finishes, the next tick is free to poll again. + await vi.advanceTimersByTimeAsync(8000) + expect(claimCalls()).toBe(2) + + wrapper.unmount() + vi.useRealTimers() + }) +}) -- 2.54.0 From 489995ced0de20ae2ff7aa68473bc7efaa973ee9 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Wed, 9 Sep 2026 03:57:49 +0000 Subject: [PATCH 9/9] fix(ecash): reduce Minibits relay churn/privacy leak and page past a 200-DM claim backlog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetch_relay_dms connected to all three CLAIM_RELAY_URLS (the Minibits relay plus the two public fallbacks, relay.damus.io and nos.lol) on every 8s poll, even though the module's own docs already described RELAY_URL as the primary with the public relays meant only as a fallback. In practice this meant 3 fresh WebSocket connections every poll and broadcasting the wallet's derived Nostr pubkey's DM activity to two public relays it didn't need to touch. - Query RELAY_URL alone first; only add and query the public fallbacks when it's unreachable (via try_connect_relay). Happy path is now one connection per poll instead of three, and the public relays only see this pubkey's traffic when the primary is actually down. - Page through the DM filter instead of a single limit(200) fetch: a relay returns the newest `limit` events for a filter, so a backlog of more than 200 DMs since the last poll (e.g. a long-offline node) silently skipped the older ones forever, since `since` never advanced past them. Capped at 5 pages so a relay that never stops returning full pages can't hang the poll. - Moved the ensure_mint_accepted doc comment back above its own function — it had been glued onto fetch_relay_dms by an earlier edit. - Timestamp::as_u64() -> as_secs() to clear the deprecation warning. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EZnFgeUBKY5UAfyJFsYccS --- core/archipelago/src/wallet/minibits.rs | 111 +++++++++++++++++------- 1 file changed, 82 insertions(+), 29 deletions(-) diff --git a/core/archipelago/src/wallet/minibits.rs b/core/archipelago/src/wallet/minibits.rs index 8f6f2030..a3956c7b 100644 --- a/core/archipelago/src/wallet/minibits.rs +++ b/core/archipelago/src/wallet/minibits.rs @@ -625,53 +625,106 @@ const NO_CLAIMS: ClaimOutcome = ClaimOutcome { /// 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 8s 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`. 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}"); + 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(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); + // Page through results instead of a single `limit(200)` fetch: relays + // return the *newest* `limit` events for a filter, so a backlog of more + // than 200 DMs since the last poll (a node offline a long time) would + // otherwise silently skip the older ones forever — `since` never moves + // past them because they're never fetched. Capped at `MAX_PAGES` so a + // relay that never stops returning full pages can't hang the poll. + const PAGE_LIMIT: usize = 200; + const MAX_PAGES: usize = 5; + let mut watermark = since; + let mut out: Vec<(String, u64, String)> = Vec::new(); + for page in 0..MAX_PAGES { + // `since` is inclusive in NIP-01, and `watermark` 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(watermark.saturating_add(1))) + .limit(PAGE_LIMIT); - 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 + let events = match client + .fetch_events(filter, std::time::Duration::from_secs(10)) + .await + { + Ok(events) => events, + Err(e) => { + warn!("Minibits: relay fetch for claim DMs failed: {e}"); + break; + } + }; + let got = events.len(); + let mut page_events: Vec<(String, u64, String)> = events + .into_iter() + .map(|e| (e.content, e.created_at.as_secs(), e.pubkey.to_hex())) + .collect(); + page_events.sort_by_key(|(_, created_at, _)| *created_at); + if let Some((_, newest, _)) = page_events.last() { + watermark = watermark.max(*newest); } - Err(e) => { - warn!("Minibits: relay fetch for claim DMs failed: {e}"); - Vec::new() + out.extend(page_events); + + if got < PAGE_LIMIT { + break; } - }; + if page == MAX_PAGES - 1 { + warn!( + "Minibits: hit the {MAX_PAGES}-page claim DM pagination cap; \ + some older DMs may remain unfetched until the next poll" + ); + } + } client.shutdown().await; - result + out } +/// Make sure the Minibits mint is on the accepted-mints allow-list. +/// +/// `ecash::receive_token` checks the raw accepted-mints file directly (not +/// the more lenient `ecash::is_mint_trusted`, which always trusts the default +/// mint) — so an operator who edited their accepted-mints list (e.g. via the +/// `streaming.configure-mints` RPC) and dropped the default mint would +/// otherwise cause every Minibits claim to fail *after* the claim was already +/// consumed server-side, permanently losing those coins with nothing but a +/// log line to show for it. The Minibits Lightning address is inherently +/// 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. 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) { -- 2.54.0