From 973356df1634d9e72d430b008621b4a7b46056f3 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 8 Sep 2026 21:16:51 -0400 Subject: [PATCH] fix(ecash): harden Minibits claim persistence --- core/archipelago/src/wallet/minibits.rs | 479 +++++++++++++++--- .../src/components/ReceiveBitcoinModal.vue | 17 +- .../ReceiveBitcoinModal.i18n.test.ts | 4 + .../__tests__/ReceiveBitcoinModal.test.ts | 38 ++ neode-ui/src/locales/en.json | 2 +- neode-ui/src/locales/es.json | 2 +- 6 files changed, 462 insertions(+), 80 deletions(-) diff --git a/core/archipelago/src/wallet/minibits.rs b/core/archipelago/src/wallet/minibits.rs index 7471e10f..362c4c0c 100644 --- a/core/archipelago/src/wallet/minibits.rs +++ b/core/archipelago/src/wallet/minibits.rs @@ -67,6 +67,13 @@ use std::path::Path; use tokio::fs; use tracing::{debug, info, warn}; +// Address registration, claim fetching and the state/wallet updates they +// trigger are one transaction from this module's point of view. Multiple +// dashboard tabs can call the RPC concurrently, while a relay fetch normally +// lasts longer than the UI's poll interval; serialise them so two polls cannot +// consume the same claim and race each other's state file writes. +static MINIBITS_STATE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + /// 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"; @@ -108,10 +115,10 @@ const ADJECTIVES: &[&str] = &[ ]; 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", + "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. @@ -145,7 +152,7 @@ pub struct MinibitsState { /// 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 @@ -153,6 +160,41 @@ pub struct MinibitsState { /// already-spent token) but wasteful and noisy. #[serde(default)] pub last_dm_seen_at: u64, + /// Event ids already queued from the relay. `created_at` has only + /// one-second resolution, so a strict `since = last + 1` watermark can + /// permanently miss a second payment published later in the same second. + /// We query the boundary second inclusively and deduplicate by event id. + #[serde(default)] + pub seen_dm_ids: Vec, +} + +/// A retryable encrypted token and the server key that encrypted it. The +/// legacy string form is accepted for state written by the original PR; new +/// entries retain their author so a server-key rotation does not make an older +/// pending claim undecryptable. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum PendingClaim { + Attributed { + content: String, + sender_pubkey: String, + }, + Legacy(String), +} + +impl PendingClaim { + fn content(&self) -> &str { + match self { + Self::Attributed { content, .. } | Self::Legacy(content) => content, + } + } + + fn sender_pubkey<'a>(&'a self, fallback: &'a str) -> &'a str { + match self { + Self::Attributed { sender_pubkey, .. } => sender_pubkey, + Self::Legacy(_) => fallback, + } + } } /// A fresh Nostr keypair + seedHash derived from the node's ecash phrase. @@ -171,6 +213,11 @@ fn derive_identity(phrase: &str, seed: &[u8; 64]) -> Result { Ok(MinibitsIdentity { keys, seed_hash }) } +fn state_matches_identity(state: &MinibitsState, identity: &MinibitsIdentity) -> bool { + state.seed_hash == identity.seed_hash + && state.nostr_pubkey == identity.keys.public_key().to_hex() +} + /// A Minibits profile record — the fields we read off every profile response. #[derive(Debug, Deserialize)] struct ProfileRecord { @@ -180,8 +227,6 @@ struct ProfileRecord { nip05: String, #[serde(default)] lud16: Option, - #[serde(default)] - pubkey: String, } /// Turn a non-2xx Minibits response into a readable error, surfacing the @@ -194,16 +239,42 @@ fn minibits_error(status: reqwest::StatusCode, body: &str) -> anyhow::Error { return anyhow!("Minibits API error {status}: {name} {msg}"); } } - anyhow!( - "Minibits API error {status}: {}", - &body[..body.len().min(180)] - ) + let excerpt: String = body.chars().take(180).collect(); + anyhow!("Minibits API error {status}: {excerpt}") } fn state_path(data_dir: &Path) -> std::path::PathBuf { data_dir.join(STATE_FILE) } +async fn archive_state(data_dir: &Path, reason: &str) -> Result> { + let path = state_path(data_dir); + if !path.exists() { + return Ok(None); + } + let stamp = chrono::Utc::now().timestamp_millis(); + let archived = path.with_file_name(format!("minibits.recovery-{stamp}.json")); + fs::rename(&path, &archived).await.with_context(|| { + format!( + "Failed to preserve {} as {}", + path.display(), + archived.display() + ) + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&archived, std::fs::Permissions::from_mode(0o600)) + .await + .with_context(|| format!("Failed to protect {}", archived.display()))?; + } + warn!( + "Minibits: preserved prior state at {} before recovery ({reason})", + archived.display() + ); + Ok(Some(archived)) +} + async fn load_state(data_dir: &Path) -> Result> { let path = state_path(data_dir); match fs::read_to_string(&path).await { @@ -224,6 +295,7 @@ async fn load_state(data_dir: &Path) -> Result> { "Minibits: {} is corrupt/unreadable ({e}); treating as no profile yet and re-registering", path.display() ); + archive_state(data_dir, "state file was not valid JSON").await?; Ok(None) } }, @@ -241,11 +313,34 @@ async fn save_state(data_dir: &Path, state: &MinibitsState) -> Result<()> { .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) + let content = + serde_json::to_string_pretty(state).context("Failed to serialize the Minibits profile")?; + + // This file contains both a bearer token and already-consumed claims. + // Truncating it in place can lose retryable value on a crash or full disk. + // Write and fsync a 0600 sibling, then atomically rename it over the state. + let tmp = path.with_extension("json.tmp"); + let mut options = fs::OpenOptions::new(); + options.create(true).truncate(true).write(true); + #[cfg(unix)] + { + options.mode(0o600); + } + let mut file = options + .open(&tmp) .await - .with_context(|| format!("Failed to write {}", path.display()))?; + .with_context(|| format!("Failed to create {}", tmp.display()))?; + use tokio::io::AsyncWriteExt; + file.write_all(content.as_bytes()) + .await + .with_context(|| format!("Failed to write {}", tmp.display()))?; + file.sync_all() + .await + .with_context(|| format!("Failed to flush {}", tmp.display()))?; + drop(file); + fs::rename(&tmp, &path) + .await + .with_context(|| format!("Failed to replace {}", path.display()))?; #[cfg(unix)] { @@ -271,10 +366,7 @@ fn jwt_expiry(token: &str) -> Option { /// 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)> { +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() })) @@ -409,10 +501,7 @@ async fn register_profile( /// 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 { +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}'"))?; @@ -448,6 +537,7 @@ async fn ecash_phrase(data_dir: &Path) -> Result<(String, [u8; 64])> { /// 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 _state_guard = MINIBITS_STATE_LOCK.lock().await; let network = ecash::load_network(data_dir).await; if network == EcashNetwork::Testnet { return Err(anyhow!( @@ -464,27 +554,15 @@ pub async fn lnaddress(data_dir: &Path) -> Result { let identity = derive_identity(&phrase, &seed)?; let mut state = match load_state(data_dir).await? { - Some(st) => st, + Some(st) if state_matches_identity(&st, &identity) => st, + Some(_) => { + warn!("Minibits: cached profile belongs to a different ecash seed; registering the restored wallet identity"); + archive_state(data_dir, "profile belonged to a different ecash seed").await?; + register_new_state(&client, &identity).await? + } 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_nostr_pubkey: String::new(), - created_at: chrono::Utc::now().to_rfc3339(), - pending_claims: Vec::new(), - last_dm_seen_at: 0, - } + register_new_state(&client, &identity).await? } }; @@ -500,6 +578,42 @@ pub async fn lnaddress(data_dir: &Path) -> Result { })) } +async fn register_new_state( + client: &reqwest::Client, + identity: &MinibitsIdentity, +) -> Result { + let (access, expires) = authenticate(client, &identity.keys).await?; + let rec = register_profile(client, &access, &identity.seed_hash).await?; + let lud16 = rec + .lud16 + .clone() + .unwrap_or_else(|| format!("{}@minibits.cash", rec.wallet_id)); + let (name, domain) = lud16 + .split_once('@') + .ok_or_else(|| anyhow!("Minibits returned malformed Lightning address '{lud16}'"))?; + if name.is_empty() || !domain.eq_ignore_ascii_case("minibits.cash") { + return Err(anyhow!( + "Minibits returned an unexpected Lightning-address domain '{domain}'" + )); + } + Ok(MinibitsState { + wallet_id: rec.wallet_id, + lud16, + nip05: rec.nip05, + // Bind local state to the key we actually derived and authenticated, + // rather than trusting an optional echo in the remote response. + nostr_pubkey: identity.keys.public_key().to_hex(), + seed_hash: identity.seed_hash.clone(), + access_token: access, + access_expires: expires, + server_nostr_pubkey: String::new(), + created_at: chrono::Utc::now().to_rfc3339(), + pending_claims: Vec::new(), + last_dm_seen_at: 0, + seen_dm_ids: Vec::new(), + }) +} + /// Outcome of a claim poll. #[derive(Debug, Serialize)] pub struct ClaimOutcome { @@ -541,8 +655,9 @@ const NO_CLAIMS: ClaimOutcome = ClaimOutcome { /// earlier fetches worth retrying. async fn fetch_relay_dms( our_pubkey: nostr_sdk::PublicKey, + server_pubkey: nostr_sdk::PublicKey, since: u64, -) -> Vec<(String, u64, String)> { +) -> Vec<(String, u64, String, String)> { let client = Client::default(); for url in CLAIM_RELAY_URLS { if let Err(e) = client.add_relay(*url).await { @@ -554,14 +669,14 @@ async fn fetch_relay_dms( // 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. + // Nostr timestamps have one-second resolution. Query the boundary second + // inclusively: a later-published payment may legitimately share that + // timestamp. `seen_dm_ids` performs the exact deduplication locally. let filter = Filter::new() + .author(server_pubkey) .pubkey(our_pubkey) .kind(Kind::from(4u16)) - .since(Timestamp::from(since.saturating_add(1))) + .since(Timestamp::from(since)) .limit(200); let result = match client @@ -569,11 +684,18 @@ async fn fetch_relay_dms( .await { Ok(events) => { - let mut out: Vec<(String, u64, String)> = events + let mut out: Vec<(String, u64, String, String)> = events .into_iter() - .map(|e| (e.content, e.created_at.as_u64(), e.pubkey.to_hex())) + .map(|e| { + ( + e.content, + e.created_at.as_secs(), + e.pubkey.to_hex(), + e.id.to_hex(), + ) + }) .collect(); - out.sort_by_key(|(_, created_at, _)| *created_at); + out.sort_by_key(|(_, created_at, _, _)| *created_at); out } Err(e) => { @@ -586,6 +708,31 @@ async fn fetch_relay_dms( result } +fn queue_relay_dm( + state: &mut MinibitsState, + content: String, + created_at: u64, + event_id: String, + sender_pubkey: String, +) -> bool { + if state.seen_dm_ids.iter().any(|seen| seen == &event_id) { + return false; + } + state.pending_claims.push(PendingClaim::Attributed { + content, + sender_pubkey, + }); + state.last_dm_seen_at = state.last_dm_seen_at.max(created_at); + state.seen_dm_ids.push(event_id); + // This is only a boundary-second dedupe window, not transaction history. + // Keep it bounded while retaining ample overlap for delayed relay delivery. + if state.seen_dm_ids.len() > 512 { + let excess = state.seen_dm_ids.len() - 512; + state.seen_dm_ids.drain(..excess); + } + true +} + 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) { @@ -609,6 +756,7 @@ async fn ensure_mint_accepted(data_dir: &Path, mint_url: &str) -> Result<()> { /// 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 _state_guard = MINIBITS_STATE_LOCK.lock().await; let network = ecash::load_network(data_dir).await; if network == EcashNetwork::Testnet { return Ok(NO_CLAIMS); @@ -624,22 +772,36 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result { let identity = derive_identity(&phrase, &seed)?; let mut state = match load_state(data_dir).await? { - Some(st) => st, + Some(st) if state_matches_identity(&st, &identity) => st, + Some(_) => { + return Err(anyhow!( + "The cached Minibits profile belongs to a different ecash seed; open Receive → Ecash to register the restored wallet first" + )); + } // 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(NO_CLAIMS), }; ensure_token(&client, &mut state, &identity.keys).await?; - // Discover (and cache) the service key that wraps claimed tokens. - if state.server_nostr_pubkey.is_empty() { - match discover_server_nostr_pubkey(&client, &state.lud16).await { - Ok(pk) => state.server_nostr_pubkey = pk, - Err(e) => { - warn!("Minibits: could not read service Nostr pubkey ({e}); using fallback"); - state.server_nostr_pubkey = FALLBACK_SERVER_NOSTR_PUBKEY.to_string(); - } + // Refresh the service key that authors and encrypts claim DMs. Keeping the + // first discovered key forever would make a legitimate Minibits rotation + // invisible: the relay filter would exclude every event from the new key. + // A metadata outage retains the last known valid key; only a fresh profile + // with no cached key needs the compiled fallback. + match discover_server_nostr_pubkey(&client, &state.lud16).await { + Ok(candidate) if nostr_sdk::PublicKey::from_hex(&candidate).is_ok() => { + state.server_nostr_pubkey = candidate; } + Ok(candidate) => { + warn!("Minibits: LUD-16 metadata returned invalid Nostr pubkey {candidate:?}; retaining the last valid key"); + } + Err(e) => { + warn!("Minibits: could not refresh service Nostr pubkey ({e}); retaining the last valid key"); + } + } + if state.server_nostr_pubkey.is_empty() { + state.server_nostr_pubkey = FALLBACK_SERVER_NOSTR_PUBKEY.to_string(); } let server_pk = nostr_sdk::PublicKey::from_hex(&state.server_nostr_pubkey) @@ -663,7 +825,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::Attributed { + content: t.to_string(), + sender_pubkey: state.server_nostr_pubkey.clone(), + }), None => warn!("Minibits claim had no 'token' field; skipping"), } } @@ -674,21 +839,22 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result { warn!("{}", minibits_error(status, &body)); } } - Err(e) => warn!("Minibits claim request failed ({e}); retrying only previously-pending claims"), + 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 { + let dms = fetch_relay_dms(identity.keys.public_key(), server_pk, state.last_dm_seen_at).await; + for (content, created_at, author, event_id) in dms { 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); + queue_relay_dm(&mut state, content, created_at, event_id, author); } // Persist immediately: everything in `pending_claims` right now has @@ -705,12 +871,27 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result { let mut redeemed = 0usize; let mut sats = 0u64; let mut still_pending = Vec::new(); - for enc in &to_process { - let decoded = match nip04::decrypt(identity.keys.secret_key(), &server_pk, enc) { + for claim in &to_process { + let claim_server_pk = + match nostr_sdk::PublicKey::from_hex(claim.sender_pubkey(&state.server_nostr_pubkey)) { + Ok(pubkey) => pubkey, + Err(e) => { + warn!( + "Minibits claim has an invalid sender pubkey ({e}); will retry next poll" + ); + still_pending.push(claim.clone()); + continue; + } + }; + let decoded = match nip04::decrypt( + identity.keys.secret_key(), + &claim_server_pk, + claim.content(), + ) { Ok(d) => d, Err(e) => { warn!("Minibits claim could not be decrypted ({e}); will retry next poll"); - still_pending.push(enc.clone()); + still_pending.push(claim.clone()); continue; } }; @@ -722,7 +903,7 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result { } Err(e) => { warn!("Minibits claim decrypted but failed to redeem ({e}); will retry next poll"); - still_pending.push(enc.clone()); + still_pending.push(claim.clone()); } } } @@ -731,7 +912,11 @@ 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, + }) } #[cfg(test)] @@ -744,7 +929,8 @@ mod tests { // 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 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()), @@ -756,7 +942,8 @@ mod tests { 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 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)); @@ -764,14 +951,37 @@ mod tests { assert_eq!(id.seed_hash, want); } + #[test] + fn cached_profile_is_bound_to_the_current_ecash_identity() { + let phrase = + "leader monkey parrot ring guide accident before fence cannon height naive bean"; + let mnemonic: bip39::Mnemonic = phrase.parse().unwrap(); + let seed = mnemonic.to_seed(""); + let identity = derive_identity(phrase, &seed).unwrap(); + let mut state = MinibitsState { + seed_hash: identity.seed_hash.clone(), + nostr_pubkey: identity.keys.public_key().to_hex(), + ..Default::default() + }; + assert!(state_matches_identity(&state, &identity)); + state.seed_hash = "restored-different-seed".into(); + assert!(!state_matches_identity(&state, &identity)); + } + #[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())); + 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)); + assert!(n + .chars() + .last() + .map(|c| c.is_ascii_digit()) + .unwrap_or(false)); } } @@ -791,6 +1001,13 @@ mod tests { })); } + #[test] + fn api_error_excerpt_is_utf8_safe() { + let body = "é".repeat(181); + let error = minibits_error(reqwest::StatusCode::BAD_GATEWAY, &body); + assert!(error.to_string().contains("Minibits API error 502")); + } + #[tokio::test] async fn ensure_mint_accepted_heals_a_dropped_default_mint() { // Regression guard: `ecash::receive_token` checks the raw accepted-mints @@ -813,7 +1030,10 @@ mod tests { 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")); + assert!(accepted + .mints + .iter() + .any(|m| m == "https://mint.example.com")); } #[tokio::test] @@ -841,6 +1061,64 @@ mod tests { assert_eq!(watermark, 110); } + #[test] + fn relay_dedupe_keeps_distinct_payments_from_the_same_second() { + let mut state = MinibitsState::default(); + assert!(queue_relay_dm( + &mut state, + "first".into(), + 100, + "id-1".into(), + "server-1".into(), + )); + assert!(queue_relay_dm( + &mut state, + "second".into(), + 100, + "id-2".into(), + "server-2".into(), + )); + assert!(!queue_relay_dm( + &mut state, + "duplicate".into(), + 100, + "id-1".into(), + "server-1".into(), + )); + assert_eq!(state.pending_claims.len(), 2); + assert_eq!(state.pending_claims[0].content(), "first"); + assert_eq!( + state.pending_claims[0].sender_pubkey("fallback"), + "server-1" + ); + assert_eq!(state.pending_claims[1].content(), "second"); + assert_eq!( + state.pending_claims[1].sender_pubkey("fallback"), + "server-2" + ); + assert_eq!(state.last_dm_seen_at, 100); + } + + #[test] + fn legacy_pending_claims_remain_readable() { + let state: MinibitsState = serde_json::from_value(serde_json::json!({ + "wallet_id": "legacy-wallet", + "lud16": "legacy-wallet@minibits.cash", + "nip05": "legacy-wallet@minibits.cash", + "nostr_pubkey": "node-key", + "seed_hash": "seed-hash", + "pending_claims": ["legacy-encrypted-token"] + })) + .unwrap(); + + assert_eq!(state.pending_claims.len(), 1); + assert_eq!(state.pending_claims[0].content(), "legacy-encrypted-token"); + assert_eq!( + state.pending_claims[0].sender_pubkey("cached-server-key"), + "cached-server-key" + ); + } + #[tokio::test] async fn load_state_treats_empty_file_as_no_profile() { // Reproduces archy-x250-pa3, 2026-09-08: a disk-full write truncated @@ -869,6 +1147,51 @@ mod tests { let st = load_state(tmp.path()).await.unwrap(); assert!(st.is_none()); + assert!(!path.exists()); + let backups = std::fs::read_dir(path.parent().unwrap()) + .unwrap() + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with("minibits.recovery-") + }) + .count(); + assert_eq!(backups, 1); + } + + #[tokio::test] + async fn save_state_is_atomic_private_and_round_trips() { + let tmp = tempfile::TempDir::new().unwrap(); + let state = MinibitsState { + wallet_id: "quietisland7".into(), + lud16: "quietisland7@minibits.cash".into(), + nostr_pubkey: "abc".into(), + seed_hash: "def".into(), + pending_claims: vec![PendingClaim::Attributed { + content: "encrypted-value".into(), + sender_pubkey: "server-key".into(), + }], + ..Default::default() + }; + save_state(tmp.path(), &state).await.unwrap(); + let loaded = load_state(tmp.path()).await.unwrap().unwrap(); + assert_eq!(loaded.pending_claims, state.pending_claims); + assert!(!state_path(tmp.path()).with_extension("json.tmp").exists()); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(state_path(tmp.path())) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600, + ); + } } /// Live end-to-end against the production Minibits API: register a throwaway @@ -893,7 +1216,9 @@ mod tests { 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"); + let out = claim_and_redeem(&dir) + .await + .expect("live claim poll failed"); println!("claim poll: {out:?}"); assert_eq!(out.claimed_count, 0); diff --git a/neode-ui/src/components/ReceiveBitcoinModal.vue b/neode-ui/src/components/ReceiveBitcoinModal.vue index 550c27e2..65f6a2ba 100644 --- a/neode-ui/src/components/ReceiveBitcoinModal.vue +++ b/neode-ui/src/components/ReceiveBitcoinModal.vue @@ -242,13 +242,17 @@ 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 +let lnClaimInFlight = false 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' }) + const res = await rpcClient.call<{ address?: string }>({ + method: 'wallet.ecash-lnaddress', + timeout: 60_000, + }) lnAddress.value = res?.address || '' if (lnAddress.value) { await nextTick() @@ -277,14 +281,23 @@ function startLnClaimPoll() { } async function pollLnClaims() { + if (lnClaimInFlight) return if (!props.show || !lnAddress.value) { stopLnClaimPoll() return } + lnClaimInFlight = true try { const res = await rpcClient.call<{ received_sats?: number; failed_count?: number }>({ method: 'wallet.ecash-lnaddress-claim', + // Relay collection alone has a ten-second window and redemption may + // then contact the mint. Keep the browser request alive long enough for + // the backend's bounded work instead of timing out and queuing another. + timeout: 90_000, }) + // The user may have closed the modal while the relay fetch was in flight. + // Do not resurrect its status or emit a stale received event afterward. + if (!props.show || !lnAddress.value) return if (res?.received_sats && res.received_sats > 0) { lnClaimedSats.value += res.received_sats emit('received') @@ -292,6 +305,8 @@ async function pollLnClaims() { lnPendingClaims.value = res?.failed_count || 0 } catch { // Transient poll failure (offline, mint busy) — keep polling. + } finally { + lnClaimInFlight = false } } diff --git a/neode-ui/src/components/__tests__/ReceiveBitcoinModal.i18n.test.ts b/neode-ui/src/components/__tests__/ReceiveBitcoinModal.i18n.test.ts index a5d036d9..d112426c 100644 --- a/neode-ui/src/components/__tests__/ReceiveBitcoinModal.i18n.test.ts +++ b/neode-ui/src/components/__tests__/ReceiveBitcoinModal.i18n.test.ts @@ -21,6 +21,10 @@ vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() }, })) +vi.mock('qrcode', () => ({ + toCanvas: vi.fn().mockResolvedValue(undefined), +})) + vi.mock('@/composables/useLightningRequired', () => ({ useLightningRequired: () => ({ requireLightningReady: vi.fn().mockResolvedValue(true), diff --git a/neode-ui/src/components/__tests__/ReceiveBitcoinModal.test.ts b/neode-ui/src/components/__tests__/ReceiveBitcoinModal.test.ts index 3a9b4986..26a9e4b1 100644 --- a/neode-ui/src/components/__tests__/ReceiveBitcoinModal.test.ts +++ b/neode-ui/src/components/__tests__/ReceiveBitcoinModal.test.ts @@ -16,6 +16,10 @@ vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() }, })) +vi.mock('qrcode', () => ({ + toCanvas: vi.fn().mockResolvedValue(undefined), +})) + vi.mock('@/composables/useLightningRequired', () => ({ useLightningRequired: () => ({ requireLightningReady: vi.fn().mockResolvedValue(true), @@ -70,4 +74,38 @@ describe('ReceiveBitcoinModal — ecash tab click', () => { expect(document.body.querySelector('[role="dialog"]')).toBeTruthy() wrapper.unmount() }) + + it('never overlaps slow Lightning-address claim polls', async () => { + vi.useFakeTimers() + let finishClaim!: (value: unknown) => void + const slowClaim = new Promise((resolve) => { finishClaim = resolve }) + vi.mocked(rpcClient.call).mockImplementation(async ({ method }: { method: string }) => { + if (method === 'wallet.ecash-lnaddress') { + return { address: 'someone@minibits.cash' } as never + } + if (method === 'wallet.ecash-lnaddress-claim') return slowClaim as never + return {} as never + }) + + const wrapper = mount(ReceiveBitcoinModal, { + props: { show: true }, + attachTo: document.body, + }) + const ecashTab = Array.from(document.body.querySelectorAll('button')).find((b) => + b.textContent?.toLowerCase().includes('ecash'), + ) + ecashTab!.dispatchEvent(new Event('click', { bubbles: true })) + await flushPromises() + + await vi.advanceTimersByTimeAsync(24_000) + const claimCalls = vi.mocked(rpcClient.call).mock.calls.filter( + ([request]) => request.method === 'wallet.ecash-lnaddress-claim', + ) + expect(claimCalls).toHaveLength(1) + + finishClaim({ claimed_count: 0, received_sats: 0, failed_count: 0 }) + await flushPromises() + wrapper.unmount() + vi.useRealTimers() + }) }) diff --git a/neode-ui/src/locales/en.json b/neode-ui/src/locales/en.json index b7e17f8a..7cfa1925 100644 --- a/neode-ui/src/locales/en.json +++ b/neode-ui/src/locales/en.json @@ -776,7 +776,7 @@ "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.", + "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. Minibits is a third-party beta service; keep balances small.", "lnAddressLabel": "Your {'@'}minibits.cash address:", "lnAddressLoading": "Setting up your Lightning address…", "lnAddressUnavailable": "Lightning address unavailable — you can still paste a token below.", diff --git a/neode-ui/src/locales/es.json b/neode-ui/src/locales/es.json index 5f9fa831..950e2e6e 100644 --- a/neode-ui/src/locales/es.json +++ b/neode-ui/src/locales/es.json @@ -757,7 +757,7 @@ "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.", + "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. Minibits es un servicio beta de terceros; mant\u00e9n saldos peque\u00f1os.", "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.",