feat(ecash): Minibits @minibits.cash Lightning address on Cashu receive #156
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -421,6 +421,31 @@ 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!({
|
||||
|
||||
@@ -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<Self> {
|
||||
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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -106,6 +106,30 @@
|
||||
|
||||
<!-- 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>
|
||||
@@ -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<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.
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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,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<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()
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user