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..02a938ea 100644 --- a/core/archipelago/src/api/rpc/wallet.rs +++ b/core/archipelago/src/api/rpc/wallet.rs @@ -421,6 +421,31 @@ 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. + /// `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, + "dropped_count": outcome.dropped_count, + })) + } + 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/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 new file mode 100644 index 00000000..a3956c7b --- /dev/null +++ b/core/archipelago/src/wallet/minibits.rs @@ -0,0 +1,1135 @@ +//! 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. +//! +//! ## 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; +use anyhow::{anyhow, Context, Result}; +use base64::Engine; +use nostr_sdk::nips::{nip04, nip06::FromMnemonic}; +use nostr_sdk::{Client, EventBuilder, Filter, 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"; +/// 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 +/// 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_nostr_pubkey: String, + #[serde(default)] + pub created_at: String, + /// 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, + /// 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 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, + 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, +} + +/// 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 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.chars().take(180).collect::() + ) +} + +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) 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) + } + }, + 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. +/// +/// 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() { + 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")?; + let tmp_path = path.with_extension("json.tmp"); + fs::write(&tmp_path, content) + .await + .with_context(|| format!("Failed to write {}", tmp_path.display()))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o600)) + .await + .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(()) +} + +/// 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. 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)); + 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)?; + + // 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 => { + 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, + } + } + }; + + 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, + /// 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, + /// 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, +}; + +/// 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. +/// +/// 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(); + 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; + } + // 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; + + // 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 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); + } + 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; + 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) { + 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 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(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() + .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(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(); + } + } + } + + 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; + 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(PendingClaim { + token: t.to_string(), + attempts: 0, + }), + 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"), + } + + // 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(PendingClaim { + token: content, + attempts: 0, + }); + 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 (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() { + return Ok(NO_CLAIMS); + } + + 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 mut claim in to_process { + let decoded = match nip04::decrypt(identity.keys.secret_key(), &server_pk, &claim.token) { + Ok(d) => d, + Err(e) => { + 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; + } + }; + match ecash::receive_token(data_dir, &decoded).await { + Ok(got) => { + redeemed += 1; + 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) => { + 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); + } + } + } + } + + 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, + 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 + // 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() + })); + } + + #[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); + } + + #[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 + // 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 + /// 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/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/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..e64fd42f 100644 --- a/neode-ui/src/components/ReceiveBitcoinModal.vue +++ b/neode-ui/src/components/ReceiveBitcoinModal.vue @@ -106,6 +106,30 @@
+ +
+

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

+ +

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

+

{{ lnAddress }}

+ +

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

+

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

+

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

+
+
+ {{ t('receiveBitcoin.lnAddressLoading') }} +
+
+ {{ t('receiveBitcoin.lnAddressUnavailable') }} +
+
@@ -175,6 +199,12 @@ watch(() => props.show, (open) => { arkAddress.value = '' ecashToken.value = '' ecashResult.value = '' + stopLnClaimPoll() + lnAddress.value = '' + lnAddressLoading.value = false + lnAddressError.value = false + lnClaimedSats.value = 0 + lnPendingClaims.value = 0 error.value = '' processing.value = false if (props.autoGenerate && receiveMethod.value === 'onchain') { @@ -193,9 +223,93 @@ 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) +// 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 +// 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 + 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 + } + if (lnPollInFlight) return + lnPollInFlight = true + try { + 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. + } finally { + lnPollInFlight = false + } +} + +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 +423,16 @@ 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 + lnPendingClaims.value = 0 error.value = '' emit('close') } 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/components/__tests__/ReceiveBitcoinModal.test.ts b/neode-ui/src/components/__tests__/ReceiveBitcoinModal.test.ts new file mode 100644 index 00000000..5443a8ce --- /dev/null +++ b/neode-ui/src/components/__tests__/ReceiveBitcoinModal.test.ts @@ -0,0 +1,129 @@ +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() + }) +}) + +// 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() + }) +}) 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 2769e2af..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", @@ -775,6 +775,13 @@ "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!", + "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 62b980d2..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", @@ -756,6 +756,13 @@ "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!", + "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",