Compare commits

..
Author SHA1 Message Date
ssmithxandClaude Sonnet 5 dc7b598558 fix(tor): un-alias bitcoin-core's hidden-service name; add regression tests
read_tor_address("bitcoin-core") was resolving through tor_service_name to
the shared "bitcoin" alias, but enrollment (install.rs auto-enroll and the
tor.create-service RPC) always names HiddenServiceDir/tor-hostnames entries
using the raw package_id verbatim — never canonicalized. On a real node
that's hidden_service_bitcoin-core, which the aliased lookup never found,
so the per-app UI Tor badge stayed empty even after the previous commit
made bitcoin-core auto-enrollable.

Give bitcoin-core its own identity-mapped arm instead of folding it into
the legacy bitcoin/bitcoin-knots/bitcoind alias, and pin all three lookup
tables (known_service_port, is_protocol_service, tor_service_name) with
regression tests so this alias-drift class of bug can't recur silently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxfWiFfnBkdSxwKUuV2tNy
2026-09-10 16:26:59 +00:00
ssmithxandClaude Sonnet 5 69f3a355c7 fix(tor): recognize bitcoin-core in Tor auto-enrollment tables
apps/bitcoin-core/manifest.yml uses id "bitcoin-core", but
known_service_port/is_protocol_service (tor/mod.rs) and
tor_service_name (docker_packages.rs) only matched "bitcoin" and
"bitcoin-knots", so the app silently never got auto-enrolled for a
P2P (8333) hidden service at install time, and the UI's Tor address
lookup for it always returned None.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxfWiFfnBkdSxwKUuV2tNy
2026-09-10 15:28:17 +00:00
16 changed files with 56 additions and 1611 deletions
+2 -3
View File
@@ -90,9 +90,8 @@ 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).
# nip06: NIP-06 key derivation for the Minibits @minibits.cash profile flow.
nostr-sdk = { version = "0.44", features = ["nip04", "nip06", "nip44"] }
# Nostr (node discovery + NIP-44 encrypted peer handshake)
nostr-sdk = { version = "0.44", features = ["nip04", "nip44"] }
# Backup encryption (DID identity export) + TOTP 2FA encryption
argon2 = "0.5.3"
@@ -269,8 +269,6 @@ 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,
+19 -2
View File
@@ -377,6 +377,23 @@ async fn write_staged_torrc(content: &str, staging: &str) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod known_service_tests {
use super::{is_protocol_service, known_service_port};
#[test]
fn bitcoin_core_is_a_protocol_service_on_the_p2p_port() {
// Regression: apps/bitcoin-core/manifest.yml uses id "bitcoin-core",
// distinct from the legacy "bitcoin"/"bitcoin-knots" ids. Missing
// here means auto-enrollment silently skips it (known_service_port
// returns 0) and, separately, regenerate_torrc falls back to the
// web-app HiddenServicePort-80 default instead of forwarding 8333
// straight through.
assert_eq!(known_service_port("bitcoin-core"), 8333);
assert!(is_protocol_service("bitcoin-core"));
}
}
#[cfg(test)]
mod torrc_tests {
use super::app_hidden_service_port_line;
@@ -594,7 +611,7 @@ fn is_valid_v3_onion(s: &str) -> bool {
pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
match name {
"archipelago" => 80,
"bitcoin" | "bitcoin-knots" => 8333,
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => 8333,
"electrs" | "electrumx" => 50001,
"lnd" => 8080,
"btcpay" | "btcpay-server" | "btcpayserver" => 23000,
@@ -619,7 +636,7 @@ pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
pub(in crate::api::rpc) fn is_protocol_service(name: &str) -> bool {
matches!(
name,
"bitcoin" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd"
"bitcoin" | "bitcoin-core" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd"
)
}
-25
View File
@@ -421,31 +421,6 @@ impl RpcHandler {
}))
}
/// `wallet.ecash-lnaddress` — the node's Minibits Lightning address
/// (`<name>@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<serde_json::Value> {
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<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,
"dropped_count": outcome.dropped_count,
}))
}
pub(super) async fn handle_wallet_networking_profits(&self) -> Result<serde_json::Value> {
let summary = profits::get_networking_profits(&self.config.data_dir).await?;
Ok(serde_json::json!({
@@ -657,9 +657,19 @@ fn apply_dynamic_metadata(app_id: &str, meta: &mut AppMetadata) {
/// Map app_id to Tor hidden service directory name.
/// "archipelago" is the main web UI (nginx port 80).
/// Supports container names from deploy (archy-*, btcpay-server, etc.).
///
/// This must match what enrollment actually names the hidden service dir
/// with — both the install-time auto-enroll (`install.rs`) and the manual
/// `tor.create-service` RPC write `HiddenServiceDir` using the raw
/// `package_id`/`name` verbatim, with no canonicalization. So `bitcoin-core`
/// gets its own identity arm rather than folding into the "bitcoin" alias:
/// aliasing it here without also canonicalizing the write side would point
/// this lookup at `hidden_service_bitcoin`, which never gets created — the
/// on-disk dir is always `hidden_service_bitcoin-core` for this app id.
fn tor_service_name(app_id: &str) -> Option<&'static str> {
match app_id {
"archipelago" => Some("archipelago"),
"bitcoin-core" => Some("bitcoin-core"),
"bitcoin" | "bitcoin-knots" | "bitcoind" => Some("bitcoin"),
"electrumx" | "electrs" | "electrum" => Some("electrumx"),
"lnd" | "lnd-ui" => Some("lnd"),
@@ -906,6 +916,28 @@ mod launch_url_port_tests {
}
}
#[cfg(test)]
mod tor_service_name_tests {
use super::tor_service_name;
#[test]
fn bitcoin_core_resolves_to_its_own_hidden_service_dir() {
// Regression: enrollment (install.rs, tor.create-service) writes
// HiddenServiceDir/tor-hostnames entries using the raw package_id
// verbatim, never canonicalized. Aliasing "bitcoin-core" to the
// shared "bitcoin" name here would point reads at a directory
// enrollment never creates.
assert_eq!(tor_service_name("bitcoin-core"), Some("bitcoin-core"));
}
#[test]
fn legacy_bitcoin_ids_share_the_bitcoin_alias() {
assert_eq!(tor_service_name("bitcoin"), Some("bitcoin"));
assert_eq!(tor_service_name("bitcoin-knots"), Some("bitcoin"));
assert_eq!(tor_service_name("bitcoind"), Some("bitcoin"));
}
}
#[cfg(test)]
mod extract_lan_address_tests {
use super::extract_lan_address;
-47
View File
@@ -207,15 +207,7 @@ impl CashuToken {
}
/// Decode a cashuA (V3 JSON) or cashuB (V4 CBOR) token string.
///
/// Trims surrounding whitespace first: a token can arrive with stray
/// leading/trailing whitespace from a clipboard paste, or (confirmed
/// live, 2026-09-08) from Minibits' own NIP-04 claim-DM content, which
/// has a trailing space after the base64 — none of the base64 alphabets
/// in `decode_token_base64` tolerate that, so an otherwise-valid token
/// would hard-fail with "Invalid base64" instead of parsing.
pub fn deserialize(token_str: &str) -> Result<Self> {
let token_str = token_str.trim();
if let Some(payload) = token_str.strip_prefix(CASHU_B_PREFIX) {
return Self::deserialize_v4(payload);
}
@@ -516,45 +508,6 @@ 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 {
File diff suppressed because it is too large Load Diff
+1 -7
View File
@@ -71,16 +71,10 @@ 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 => ALREADY_REDEEMED_MSG,
11001 => "This ecash has already been redeemed — it can't be claimed twice.",
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.",
-1
View File
@@ -6,7 +6,6 @@ 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;
-12
View File
@@ -137,18 +137,6 @@ 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
}
@@ -106,30 +106,6 @@
<!-- Ecash -->
<div v-if="receiveMethod === 'ecash'">
<!-- Shareable @minibits.cash Lightning address (LUD-16): any Lightning
wallet can pay this node by address, and the sats land as ecash.
Fetched on tab open; claimed payments are polled in while open. -->
<div v-if="lnAddress" class="mb-4 p-3 bg-white/5 rounded-lg text-center">
<p class="text-white/60 text-sm mb-2">{{ t('receiveBitcoin.lnAddressTitle') }}</p>
<canvas ref="lnAddressQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-1">{{ t('receiveBitcoin.lnAddressLabel') }}</p>
<p class="text-base font-mono text-white/95 break-all mb-2">{{ lnAddress }}</p>
<CopyButton :value="lnAddress" :label="t('common.copy')" />
<p class="text-white/40 text-xs mt-3 leading-relaxed">{{ t('receiveBitcoin.lnAddressHint') }}</p>
<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') }}
</div>
<div v-else-if="lnAddressError" class="mb-3 text-xs text-white/40">
{{ t('receiveBitcoin.lnAddressUnavailable') }}
</div>
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.pasteEcashToken') }}</label>
<textarea v-model="ecashToken" rows="3" placeholder="cashuB… (Cashu) or Fedimint notes" class="w-full input-glass font-mono"></textarea>
@@ -199,12 +175,6 @@ 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') {
@@ -223,93 +193,9 @@ const ecashResult = ref('')
const onchainQrCanvas = ref<HTMLCanvasElement | null>(null)
const lightningQrCanvas = ref<HTMLCanvasElement | null>(null)
const arkQrCanvas = ref<HTMLCanvasElement | null>(null)
const lnAddressQrCanvas = ref<HTMLCanvasElement | null>(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<typeof setInterval> | 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.
@@ -423,16 +309,12 @@ 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')
}
@@ -1,66 +0,0 @@
// 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()
})
})
@@ -1,129 +0,0 @@
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()
})
})
// 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()
})
})
@@ -1,48 +0,0 @@
// 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([])
})
})
+1 -8
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",
@@ -775,13 +775,6 @@
"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",
+1 -8
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",
@@ -756,13 +756,6 @@
"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",