Compare commits

...
5 Commits
Author SHA1 Message Date
ssmithxandClaude Sonnet 5 3768395e59 fix(ui): escape a second live vue-i18n message-compile crash + add a full-sweep test
Same class of bug as the Minibits address label
(settings.passwordNeedSpecial: "...(!@#$%^&* etc.)" — a bare @ vue-i18n
parses as linked-message syntax). This one is live in
ChangePasswordSection.vue's password-strength validator: typing a new
password with no special character throws this exact
SyntaxError the moment the message is rendered. Fixed the same way
({'@'} escaping).

Added locales/__tests__/i18nMessagesCompile.test.ts, which walks every
string in every locale file and asks the real vue-i18n compiler to
parse it — confirmed it fails on both bad strings before their fixes
and passes clean now, with no other landmines left in either locale
file. This closes the whole bug class rather than just these two
instances; a future bad interpolation string fails `npm test` instead
of only a live crash report.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 16:25:37 +00:00
ssmithxandClaude Sonnet 5 6041eb6306 fix(ui): escape the literal @ in the Minibits address label
Root cause of "click Receive, click Ecash, the modal disappears" (in
both the browser and the Android companion's WebView, since both host
the same neode-ui bundle): vue-i18n treats a bare @ as the start of
"linked message" syntax. receiveBitcoin.lnAddressLabel ("Your
@minibits.cash address:") isn't valid linked-message syntax, so
*compiling* that message throws a SyntaxError the instant it's first
rendered — i.e. the moment wallet.ecash-lnaddress resolves and the
address section becomes visible. The uncaught render-function error
blanks the whole teleported modal, which is indistinguishable from it
just closing.

Confirmed with a real (non-mocked) Vue app + real vue-i18n compiler in
a headless Chromium — a Vitest run with `t` mocked to a no-op, which is
how the existing component test suite covers this file, cannot catch a
bad message string at all. Fixed by escaping the @ as {'@'} — the same
pattern the codebase already uses for settings.domainNamePlaceholder
("user{'@'}example.com"). Added a regression test using the real
vue-i18n instance instead of the mocked one; verified it fails on the
old string and passes on the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 16:19:52 +00:00
ssmithxandClaude Sonnet 5 3be6f45fe8 test(ui): guard the ecash-tab-click path in ReceiveBitcoinModal
Operator report (2026-09-08): clicking the Ecash tab appeared to close
the whole Receive modal. Added a regression test simulating the exact
click, both for wallet.ecash-lnaddress succeeding and failing — the
tab switch alone never emits `close` or unmounts the dialog in either
case, so this isn't reproduced by a plain component-level click; the
investigation continues with the reporter for a browser-console repro.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 15:46:12 +00:00
ssmithxandClaude Sonnet 5 3f52e4cd78 fix(ecash): recover from a truncated/corrupt Minibits state file
archy-x250-pa3's data volume filled to 100% (cuprate at 125G, since
removed) while a client had the ecash receive tab open. save_state's
write landed mid-truncate, leaving wallet/minibits.json at 0 bytes.
load_state then hard-failed every wallet.ecash-lnaddress call with
"EOF while parsing a value", surfaced in the UI as "Lightning address
unavailable" — permanently, since nothing ever cleared the bad file.

Registration is idempotent per pubkey (re-registering returns the same
lud16 Minibits already assigned), so there's no reason a corrupt local
mirror of that state should be fatal. load_state now treats an empty
or unparseable state file the same as a missing one — re-register and
recover the same address — instead of erroring. Manually cleared the
stuck file on archy-x250-pa3 as an immediate fix; this closes the gap
so it self-heals next time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 15:34:28 +00:00
ssmithxandClaude Sonnet 5 76d565fb18 fix(ecash): stop Minibits LN-address claims from being silently lost
A Minibits /claim response consumes the payment server-side the instant
it's returned — it can never be re-fetched. claim_and_redeem previously
decrypted/redeemed each claim inline and just warn!-logged any failure,
so a mint-unreachable blip, a stale cached server key, or an operator
who'd edited their accepted-mints list to drop the default mint (via
streaming.configure-mints) could make a real payment vanish with
nothing but a log line to show for it — claimed_count/received_sats
still came back as a clean 0, identical to "nothing arrived."

Now: every fetched claim is persisted to MinibitsState.pending_claims
before decrypt/redeem is attempted, survives failures across polls
instead of being dropped, and claim_and_redeem no longer bails out on a
fetch error without first retrying whatever was already pending.
ensure_mint_accepted self-heals the accepted-mints allow-list so the
Minibits mint (the address is inherently backed by it) can't be
excluded out from under a claim. ClaimOutcome gains failed_count,
threaded through wallet.ecash-lnaddress-claim and shown in
ReceiveBitcoinModal so a stuck claim is visible instead of silent.

Also fixes the server_nostur_pubkey field-name typo (no live state to
migrate — this feature hasn't shipped yet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 13:24:11 +00:00
8 changed files with 413 additions and 50 deletions
+5
View File
@@ -432,11 +432,16 @@ impl RpcHandler {
/// `wallet.ecash-lnaddress-claim` — redeem any Lightning payments that
/// arrived on the node's Minibits address as ecash. Returns the sats swept in
/// (0 when nothing was waiting), so the UI can refresh its balance.
/// `failed_count` is non-zero when a payment was fetched (and so already
/// consumed server-side) but couldn't be redeemed yet — it stays queued
/// and is retried automatically, but the UI should tell the operator
/// rather than let it be a silent, unbounded wait.
pub(super) async fn handle_wallet_ecash_lnaddress_claim(&self) -> Result<serde_json::Value> {
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,
}))
}
+204 -45
View File
@@ -39,6 +39,20 @@
//!
//! Only runs on the mainnet ecash network — Minibits is a mainnet service, and a
//! testnet node must not register a profile or hit the production API.
//!
//! ## A claim can't be re-fetched — so nothing gets dropped
//!
//! `/claim` consumes a payment server-side the instant it's returned. A local
//! failure after that point (mint briefly unreachable, a stale cached server
//! key, a crash mid-loop) must not silently lose the coins, so every fetched
//! token is persisted to `MinibitsState::pending_claims` *before* decrypt/
//! redeem is attempted, and stays there — retried on every later poll — until
//! it succeeds. `ClaimOutcome::failed_count` reports how many are still
//! stuck so the caller can surface it instead of it being a log-only event.
//! Separately, `ensure_mint_accepted` keeps the Minibits mint on the node's
//! accepted-mints allow-list: the address is inherently backed by that one
//! mint, so an operator-edited allow-list must never be able to cause this
//! same kind of loss via `receive_token`'s mint check.
use super::ecash::{self, EcashNetwork};
use super::nut13;
@@ -112,9 +126,17 @@ pub struct MinibitsState {
pub access_expires: i64,
/// Server Nostr pubkey used to decrypt claims, discovered from LUD-16.
#[serde(default)]
pub server_nostur_pubkey: String,
pub server_nostr_pubkey: String,
#[serde(default)]
pub created_at: String,
/// Raw NIP-04-encrypted claim tokens fetched from `/claim` but not yet
/// successfully redeemed. A claim is consumed server-side the instant
/// `/claim` returns it, so it is stashed here *before* decrypt/redeem is
/// attempted — a local failure (mint briefly down, bad cached server key,
/// process crash mid-loop) then retries next poll instead of losing the
/// coins outright.
#[serde(default)]
pub pending_claims: Vec<String>,
}
/// A fresh Nostr keypair + seedHash derived from the node's ecash phrase.
@@ -169,15 +191,26 @@ fn state_path(data_dir: &Path) -> std::path::PathBuf {
async fn load_state(data_dir: &Path) -> Result<Option<MinibitsState>> {
let path = state_path(data_dir);
match fs::read_to_string(&path).await {
Ok(s) => {
let st: MinibitsState = serde_json::from_str(&s)
.with_context(|| format!("Failed to parse {}", path.display()))?;
if st.wallet_id.is_empty() {
Ok(s) if s.trim().is_empty() => Ok(None),
Ok(s) => match serde_json::from_str::<MinibitsState>(&s) {
Ok(st) if st.wallet_id.is_empty() => Ok(None),
Ok(st) => Ok(Some(st)),
// Unlike the accepted-mints file, nothing here is a user-editable
// security setting — it's a pure mirror of state Minibits already
// holds server-side, and registration is idempotent per pubkey
// (§ module docs), so re-registering after a corrupt/truncated
// read always recovers the *same* address. A node whose disk
// filled up mid-write (observed on archy-x250-pa3, 2026-09-08:
// this file truncated to 0 bytes) must self-heal on the next open
// rather than permanently show "Lightning address unavailable".
Err(e) => {
warn!(
"Minibits: {} is corrupt/unreadable ({e}); treating as no profile yet and re-registering",
path.display()
);
Ok(None)
} else {
Ok(Some(st))
}
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("Failed to read {}", path.display())),
}
@@ -431,8 +464,9 @@ pub async fn lnaddress(data_dir: &Path) -> Result<serde_json::Value> {
seed_hash: identity.seed_hash.clone(),
access_token: access,
access_expires: expires,
server_nostur_pubkey: String::new(),
server_nostr_pubkey: String::new(),
created_at: chrono::Utc::now().to_rfc3339(),
pending_claims: Vec::new(),
}
}
};
@@ -454,22 +488,60 @@ pub async fn lnaddress(data_dir: &Path) -> Result<serde_json::Value> {
pub struct ClaimOutcome {
pub claimed_count: usize,
pub received_sats: u64,
/// Claims that were fetched (and so already consumed server-side) but
/// still haven't been redeemed after this poll — decrypt/redeem failed
/// and they are queued in `pending_claims` for the next poll rather than
/// dropped. Non-zero here means real, unswept value the operator should
/// know about.
pub failed_count: usize,
}
const NO_CLAIMS: ClaimOutcome = ClaimOutcome {
claimed_count: 0,
received_sats: 0,
failed_count: 0,
};
/// Make sure the Minibits mint is on the accepted-mints allow-list.
///
/// `ecash::receive_token` checks the raw accepted-mints file directly (not
/// the more lenient `ecash::is_mint_trusted`, which always trusts the default
/// mint) — so an operator who edited their accepted-mints list (e.g. via the
/// `streaming.configure-mints` RPC) and dropped the default mint would
/// otherwise cause every Minibits claim to fail *after* the claim was already
/// consumed server-side, permanently losing those coins with nothing but a
/// log line to show for it. The Minibits Lightning address is inherently
/// backed by this one mint — registering it already implies trusting the
/// mint — so self-heal the allow-list here rather than let that combination
/// silently strand funds.
async fn ensure_mint_accepted(data_dir: &Path, mint_url: &str) -> Result<()> {
let mut accepted = ecash::load_accepted_mints(data_dir).await?;
if !accepted.mints.iter().any(|m| m == mint_url) {
accepted.mints.push(mint_url.to_string());
ecash::save_accepted_mints(data_dir, &accepted).await?;
info!("Minibits: added {mint_url} to accepted mints (needed to redeem LN-address claims)");
}
Ok(())
}
/// Poll Minibits for Lightning payments sent to the node's address and redeem
/// each into the ecash wallet.
///
/// Each claim is a NUT-00 token NIP-04-encrypted by the Minibits service to this
/// wallet's Nostr key; decrypting it needs the service pubkey (discovered from
/// our LUD-16 metadata, falling back to the known constant). A token that fails
/// to decrypt or redeem is logged and skipped rather than aborting the batch —
/// but note a claim is consumed server-side the moment it is fetched, so any
/// failure here is surfaced loudly since those coins cannot be re-fetched.
/// Each claim is a NUT-00 token NIP-04-encrypted by the Minibits service to
/// this wallet's Nostr key; decrypting it needs the service pubkey
/// (discovered from our LUD-16 metadata, falling back to the known
/// constant). A claim is consumed server-side the instant `/claim` returns
/// it, so newly-fetched tokens are persisted to `state.pending_claims`
/// *before* decrypt/redeem is attempted; a token that fails to decrypt or
/// redeem stays in `pending_claims` and is retried on the next poll instead
/// of being dropped, and `failed_count` tells the caller when that happened
/// so it isn't purely a log-line event.
pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
let network = ecash::load_network(data_dir).await;
if network == EcashNetwork::Testnet {
return Ok(ClaimOutcome { claimed_count: 0, received_sats: 0 });
return Ok(NO_CLAIMS);
}
ensure_mint_accepted(data_dir, &network.default_mint()).await?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
@@ -483,58 +555,75 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
Some(st) => st,
// Nothing is addressable until a profile exists; registering lazily here
// means a payment could not have arrived, so claiming is a no-op.
None => return Ok(ClaimOutcome { claimed_count: 0, received_sats: 0 }),
None => return Ok(NO_CLAIMS),
};
ensure_token(&client, &mut state, &identity.keys).await?;
// Discover (and cache) the service key that wraps claimed tokens.
if state.server_nostur_pubkey.is_empty() {
if state.server_nostr_pubkey.is_empty() {
match discover_server_nostr_pubkey(&client, &state.lud16).await {
Ok(pk) => state.server_nostur_pubkey = pk,
Ok(pk) => state.server_nostr_pubkey = pk,
Err(e) => {
warn!("Minibits: could not read service Nostr pubkey ({e}); using fallback");
state.server_nostur_pubkey = FALLBACK_SERVER_NOSTR_PUBKEY.to_string();
state.server_nostr_pubkey = FALLBACK_SERVER_NOSTR_PUBKEY.to_string();
}
}
}
save_state(data_dir, &state).await?;
let server_pk = nostr_sdk::PublicKey::from_hex(&state.server_nostur_pubkey)
let server_pk = nostr_sdk::PublicKey::from_hex(&state.server_nostr_pubkey)
.context("Service Nostr pubkey was not valid hex")?;
// Fetch anything new. A failure here is *not* fatal to the poll — the
// operator may still have earlier claims sitting in `pending_claims` that
// are worth retrying — so log and fall through instead of bailing out.
let resp = client
.post(format!("{API_BASE}/claim"))
.bearer_auth(&state.access_token)
.json(&serde_json::json!({ "seedHash": state.seed_hash }))
.send()
.await
.context("Minibits claim request failed")?;
let status = resp.status();
let body = resp.text().await.context("Minibits claim body read failed")?;
if !status.is_success() {
return Err(minibits_error(status, &body));
}
let claims: Vec<serde_json::Value> =
serde_json::from_str(&body).context("Minibits claim response was not a JSON array")?;
if claims.is_empty() {
return Ok(ClaimOutcome { claimed_count: 0, received_sats: 0 });
.await;
match resp {
Ok(resp) => {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
if status.is_success() {
match serde_json::from_str::<Vec<serde_json::Value>>(&body) {
Ok(claims) => {
for claim in &claims {
match claim.get("token").and_then(|t| t.as_str()) {
Some(t) => state.pending_claims.push(t.to_string()),
None => warn!("Minibits claim had no 'token' field; skipping"),
}
}
}
Err(e) => warn!("Minibits claim response was not the expected shape: {e}"),
}
} else {
warn!("{}", minibits_error(status, &body));
}
}
Err(e) => warn!("Minibits claim request failed ({e}); retrying only previously-pending claims"),
}
// Persist immediately: everything in `pending_claims` right now has
// already been consumed server-side, whether it came from this fetch or
// survived from an earlier failed attempt.
save_state(data_dir, &state).await?;
if state.pending_claims.is_empty() {
return Ok(NO_CLAIMS);
}
let to_process = std::mem::take(&mut state.pending_claims);
let mut redeemed = 0usize;
let mut sats = 0u64;
for claim in &claims {
let enc = match claim.get("token").and_then(|t| t.as_str()) {
Some(t) => t,
None => {
warn!("Minibits claim had no 'token' field; skipping");
continue;
}
};
let mut still_pending = Vec::new();
for enc in &to_process {
let decoded = match nip04::decrypt(identity.keys.secret_key(), &server_pk, enc) {
Ok(d) => d,
Err(e) => {
// Claim already consumed server-side — this is a real loss.
warn!("Minibits claim could not be decrypted ({e}); coins may be unrecoverable");
warn!("Minibits claim could not be decrypted ({e}); will retry next poll");
still_pending.push(enc.clone());
continue;
}
};
@@ -545,12 +634,17 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
info!("Minibits: redeemed a claimed payment ({got} sats)");
}
Err(e) => {
warn!("Minibits claim decrypted but failed to redeem ({e}); coins may be unrecoverable")
warn!("Minibits claim decrypted but failed to redeem ({e}); will retry next poll");
still_pending.push(enc.clone());
}
}
}
Ok(ClaimOutcome { claimed_count: redeemed, received_sats: sats })
let failed_count = still_pending.len();
state.pending_claims = still_pending;
save_state(data_dir, &state).await?;
Ok(ClaimOutcome { claimed_count: redeemed, received_sats: sats, failed_count })
}
#[cfg(test)]
@@ -610,6 +704,71 @@ mod tests {
}));
}
#[tokio::test]
async fn ensure_mint_accepted_heals_a_dropped_default_mint() {
// Regression guard: `ecash::receive_token` checks the raw accepted-mints
// file, not the more lenient `is_mint_trusted` — so an operator-edited
// allow-list that dropped the default mint must not be able to make
// Minibits claims (already consumed server-side by the time redeem
// runs) fail permanently and silently.
let tmp = tempfile::TempDir::new().unwrap();
let mint = "https://mint.minibits.cash/Bitcoin";
ecash::save_accepted_mints(
tmp.path(),
&ecash::AcceptedMints {
mints: vec!["https://mint.example.com".to_string()],
},
)
.await
.unwrap();
ensure_mint_accepted(tmp.path(), mint).await.unwrap();
let accepted = ecash::load_accepted_mints(tmp.path()).await.unwrap();
assert!(accepted.mints.iter().any(|m| m == mint));
assert!(accepted.mints.iter().any(|m| m == "https://mint.example.com"));
}
#[tokio::test]
async fn ensure_mint_accepted_does_not_duplicate() {
let tmp = tempfile::TempDir::new().unwrap();
let mint = "https://mint.minibits.cash/Bitcoin";
ensure_mint_accepted(tmp.path(), mint).await.unwrap();
ensure_mint_accepted(tmp.path(), mint).await.unwrap();
let accepted = ecash::load_accepted_mints(tmp.path()).await.unwrap();
assert_eq!(accepted.mints.iter().filter(|m| *m == mint).count(), 1);
}
#[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
@@ -119,6 +119,9 @@
<p v-if="lnClaimedSats > 0" class="text-green-400 text-sm mt-2">
{{ t('receiveBitcoin.lnAddressReceived', { amount: lnClaimedSats.toLocaleString() }) }}
</p>
<p v-if="lnPendingClaims > 0" class="text-orange-400 text-sm mt-2">
{{ t('receiveBitcoin.lnAddressPendingRetry', { count: lnPendingClaims }) }}
</p>
</div>
<div v-else-if="lnAddressLoading" class="mb-4 text-center text-white/50 text-sm py-4">
{{ t('receiveBitcoin.lnAddressLoading') }}
@@ -201,6 +204,7 @@ watch(() => props.show, (open) => {
lnAddressLoading.value = false
lnAddressError.value = false
lnClaimedSats.value = 0
lnPendingClaims.value = 0
error.value = ''
processing.value = false
if (props.autoGenerate && receiveMethod.value === 'onchain') {
@@ -233,6 +237,10 @@ const lnAddress = ref('')
const lnAddressLoading = ref(false)
const lnAddressError = ref(false)
const lnClaimedSats = ref(0)
// A payment the backend fetched (and so already consumed at Minibits) but
// couldn't redeem yet — it's queued for automatic retry, not lost, but the
// operator should see it rather than have it be a silent, unbounded wait.
const lnPendingClaims = ref(0)
let lnClaimTimer: ReturnType<typeof setInterval> | null = null
async function loadLnAddress() {
@@ -274,13 +282,14 @@ async function pollLnClaims() {
return
}
try {
const res = await rpcClient.call<{ received_sats?: number }>({
const res = await rpcClient.call<{ received_sats?: number; failed_count?: number }>({
method: 'wallet.ecash-lnaddress-claim',
})
if (res?.received_sats && res.received_sats > 0) {
lnClaimedSats.value += res.received_sats
emit('received')
}
lnPendingClaims.value = res?.failed_count || 0
} catch {
// Transient poll failure (offline, mint busy) — keep polling.
}
@@ -415,6 +424,7 @@ function close() {
ecashResult.value = ''
lnAddress.value = ''
lnClaimedSats.value = 0
lnPendingClaims.value = 0
error.value = ''
emit('close')
}
@@ -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()
})
})
@@ -0,0 +1,73 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import ReceiveBitcoinModal from '../ReceiveBitcoinModal.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-router', () => ({
useRoute: () => ({ fullPath: '/dashboard' }),
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string, params?: Record<string, unknown>) => (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()
})
})
@@ -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<string, unknown>)) {
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([])
})
})
+3 -2
View File
@@ -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",
@@ -777,10 +777,11 @@
"pasteEcashToken": "Paste ecash token",
"lnAddressTitle": "Or share your Minibits Lightning address",
"lnAddressHint": "Anyone can pay you sats with any Lightning wallet by sending to this address — the sats arrive as ecash. Keep this screen open to receive them.",
"lnAddressLabel": "Your @minibits.cash address:",
"lnAddressLabel": "Your {'@'}minibits.cash address:",
"lnAddressLoading": "Setting up your Lightning address…",
"lnAddressUnavailable": "Lightning address unavailable — you can still paste a token below.",
"lnAddressReceived": "Received {amount} sats to your Lightning address!",
"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",
+3 -2
View File
@@ -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",
@@ -758,10 +758,11 @@
"pasteEcashToken": "Pegar token Ecash",
"lnAddressTitle": "O comparte tu direcci\u00f3n Lightning de Minibits",
"lnAddressHint": "Cualquier persona puede pagarte sats con cualquier billetera Lightning enviando a esta direcci\u00f3n \u2014 los sats llegan como ecash. Mant\u00e9n esta pantalla abierta para recibirlos.",
"lnAddressLabel": "Su direcci\u00f3n @minibits.cash:",
"lnAddressLabel": "Su direcci\u00f3n {'@'}minibits.cash:",
"lnAddressLoading": "Configurando su direcci\u00f3n Lightning\u2026",
"lnAddressUnavailable": "Direcci\u00f3n Lightning no disponible \u2014 a\u00fan puede pegar un token abajo.",
"lnAddressReceived": "\u00a1Recibi\u00f3 {amount} sats en su direcci\u00f3n Lightning!",
"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",