Compare commits
5
Commits
6effc6b574
...
3768395e59
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3768395e59 | ||
|
|
6041eb6306 | ||
|
|
3be6f45fe8 | ||
|
|
3f52e4cd78 | ||
|
|
76d565fb18 |
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user