Compare commits

...
11 Commits
Author SHA1 Message Date
ssmithxandClaude Sonnet 5 489995ced0 fix(ecash): reduce Minibits relay churn/privacy leak and page past a 200-DM claim backlog
fetch_relay_dms connected to all three CLAIM_RELAY_URLS (the Minibits relay
plus the two public fallbacks, relay.damus.io and nos.lol) on every 8s poll,
even though the module's own docs already described RELAY_URL as the
primary with the public relays meant only as a fallback. In practice this
meant 3 fresh WebSocket connections every poll and broadcasting the wallet's
derived Nostr pubkey's DM activity to two public relays it didn't need to
touch.

- Query RELAY_URL alone first; only add and query the public fallbacks when
  it's unreachable (via try_connect_relay). Happy path is now one connection
  per poll instead of three, and the public relays only see this pubkey's
  traffic when the primary is actually down.
- Page through the DM filter instead of a single limit(200) fetch: a relay
  returns the newest `limit` events for a filter, so a backlog of more than
  200 DMs since the last poll (e.g. a long-offline node) silently skipped the
  older ones forever, since `since` never advanced past them. Capped at 5
  pages so a relay that never stops returning full pages can't hang the poll.
- Moved the ensure_mint_accepted doc comment back above its own function —
  it had been glued onto fetch_relay_dms by an earlier edit.
- Timestamp::as_u64() -> as_secs() to clear the deprecation warning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZnFgeUBKY5UAfyJFsYccS
2026-09-09 03:57:49 +00:00
ssmithxandClaude Sonnet 5 4e410d7c98 fix(ecash): guard Minibits claim polls against races and stop replayed claims retrying forever
The UI polls wallet.ecash-lnaddress-claim every 8s, but a single poll (auth +
/claim + relay fetch + redeem loop) can outlast that interval. Two overlapping
claim_and_redeem runs then loaded the same last_dm_seen_at, fetched/redeemed
the same claims, and last-writer-wins on save — rewinding the watermark and/or
double-redeeming. A double-redeemed or state-loss-replayed claim then failed
forever as "already spent" with no way to leave pending_claims, leaving a
permanent orange retry banner.

- STATE_LOCK (backend) + an in-flight guard (UI) serialize claim polls and
  the lnaddress registration/token-refresh path, so two callers can't race on
  minibits.json.
- pending_claims now tracks per-claim attempts (PendingClaim, migrating
  transparently from the old plain-string shape); a claim that fails
  MAX_CLAIM_ATTEMPTS times is dropped instead of retried forever.
- A redeem failure recognized as mint error 11001 (already redeemed) is
  treated as terminal and dropped immediately — the value was already swept,
  so retrying it is pointless. ClaimOutcome gains dropped_count so the two
  drop reasons (harmless vs. real loss) are visible to the caller.
- save_state now writes via temp-file + rename instead of truncating
  minibits.json in place — the exact disk-full failure mode that corrupted
  this file on archy-x250-pa3, 2026-09-08, could otherwise destroy
  pending_claims tokens that /claim had already consumed server-side
  (unrecoverable, unlike relay DMs).
- minibits_error no longer panics on a multi-byte UTF-8 boundary when
  truncating a server error body (was byte-slicing, not char-safe).
- register_profile's name-collision check now matches the structured
  error.name == ALREADY_EXISTS instead of a raw "already" substring, so an
  unrelated error message doesn't burn a retry attempt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZnFgeUBKY5UAfyJFsYccS
2026-09-09 03:50:34 +00:00
ssmithxandClaude Sonnet 5 fc5b51ab2f fix(ecash): fetch Minibits claims from Nostr relays, not the dead /claim REST poll
Confirmed live 2026-09-08 against three real Lightning payments to a
registered @minibits.cash address: POST /claim (the only claim source
claim_and_redeem checked) always returned an empty array, no matter
how long or how often it was polled. Independently queried
wss://relay.minibits.cash and found all three payments sitting there
as NIP-04-encrypted kind-4 DMs, #p-tagged to the wallet's own Nostr
pubkey and authored by the Minibits service key — that is the actual
delivery channel for a payment made to the address, and this module
never looked at it.

fetch_relay_dms queries CLAIM_RELAY_URLS (the service's own relay plus
two public fallbacks) for kind-4 events tagged to our pubkey, feeding
matching content into the existing pending_claims retry pipeline
unchanged. A new last_dm_seen_at watermark stops the same (immutable,
never-expiring) relay event from being re-fetched and re-attempted on
every poll. The REST /claim call stays in place alongside it in case
it serves some other payment path — this only adds the missing one.

fix(ecash): trim stray whitespace before parsing a cashuA/cashuB token

Once the relay fix above surfaced the three real payments, all three
failed to redeem with "Invalid base64 in cashuB token" — the decrypted
NIP-04 content had a trailing space after the base64 payload (Minibits'
own encoding), which every base64 alphabet in decode_token_base64
rejects outright. CashuToken::deserialize now trims the whole token
string before touching the "cashuA"/"cashuB" prefix or payload. This is
a general robustness fix, not just a Minibits workaround — the same
stray-whitespace failure could hit a hand-pasted token from a clipboard
copy just as easily.

Both fixes verified end-to-end against production: all three stuck
payments (20 + 5 + 20 = 45 sats) redeemed cleanly on the first poll
after deploying this build to archy-x250-pa3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 22:23:56 +00:00
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
ssmithx 6effc6b574 feat(ecash): Minibits @minibits.cash Lightning address on Cashu receive
The wallet used Minibits only as a Cashu mint, so the node could hold and
swap ecash there but had no addressable name at it. This derives a LUD-16
Lightning address (name@minibits.cash) from the node's own ecash wallet and
surfaces it in the ecash Receive tab above the existing paste-token box.

Identity reuses the NUT-13 ecash phrase, so there is no second secret:
  - seedHash = sha256(mnemonic.to_seed("")) — the exact hash the Minibits app
    stores, so restoring the same phrase recovers the same address both ways;
  - Nostr keys via NIP-06 at m/44'/1237'/0'/0/0 (nostr-sdk Keys::from_mnemonic,
    pinned by a unit test against the NIP-06 vector so a bump cannot silently
    move the derivation and orphan the profile).

Backend (wallet/minibits.rs) implements the verified live /v3 flow: NIP-42
challenge/verify -> JWT, idempotent /profile registration with collision
retry, and /claim polling that NIP-04-decrypts each token (service pubkey read
from the address's own LUD-16 metadata, constant fallback) and redeems it
through ecash::receive_token. Mainnet-only; state cached 0600 in
wallet/minibits.json.

New RPC: wallet.ecash-lnaddress (register-or-read, idempotent) and
wallet.ecash-lnaddress-claim (sweep Lightning payments into ecash). The modal
fetches the address on tab open, renders QR + copy, and sweeps claims while
open; a registration failure is non-fatal so paste-token still works.

Verified end-to-end against production: registered a disposable
@minibits.cash address, confirmed it resolves via /.well-known/lnurlp, and the
claim poll returns cleanly.
2026-09-08 02:49:02 +00:00
archipelago db52c06a72 chore(catalog): sign Cuprate registry update 2026-09-07 05:12:05 -04:00
archipelago 4b14b62e74 chore: publish release v1.8.11-alpha
Demo images / Build & push demo images (push) Successful in 3m40s
2026-09-07 04:35:48 -04:00
18 changed files with 4887 additions and 3310 deletions
+3 -2
View File
@@ -90,8 +90,9 @@ rustls-pemfile = "1.0"
webpki = { package = "rustls-webpki", version = "0.101" } webpki = { package = "rustls-webpki", version = "0.101" }
reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls", "stream"] } reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls", "stream"] }
# Nostr (node discovery + NIP-44 encrypted peer handshake) # Nostr (node discovery + NIP-44 encrypted peer handshake).
nostr-sdk = { version = "0.44", features = ["nip04", "nip44"] } # 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 # Backup encryption (DID identity export) + TOTP 2FA encryption
argon2 = "0.5.3" argon2 = "0.5.3"
@@ -269,6 +269,8 @@ impl RpcHandler {
"wallet.ecash-network" => self.handle_wallet_ecash_network().await, "wallet.ecash-network" => self.handle_wallet_ecash_network().await,
"wallet.ecash-set-network" => self.handle_wallet_ecash_set_network(params).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-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-seed-reveal" => self.handle_wallet_ecash_seed_reveal(params).await,
"wallet.ecash-restore" => self.handle_wallet_ecash_restore(params).await, "wallet.ecash-restore" => self.handle_wallet_ecash_restore(params).await,
"wallet.ecash-seed-import" => self.handle_wallet_ecash_seed_import(params).await, "wallet.ecash-seed-import" => self.handle_wallet_ecash_seed_import(params).await,
+25
View File
@@ -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> { pub(super) async fn handle_wallet_networking_profits(&self) -> Result<serde_json::Value> {
let summary = profits::get_networking_profits(&self.config.data_dir).await?; let summary = profits::get_networking_profits(&self.config.data_dir).await?;
Ok(serde_json::json!({ Ok(serde_json::json!({
+47
View File
@@ -207,7 +207,15 @@ impl CashuToken {
} }
/// Decode a cashuA (V3 JSON) or cashuB (V4 CBOR) token string. /// 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> { 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) { if let Some(payload) = token_str.strip_prefix(CASHU_B_PREFIX) {
return Self::deserialize_v4(payload); return Self::deserialize_v4(payload);
} }
@@ -508,6 +516,45 @@ mod tests {
assert_eq!(decoded.memo, Some("test token".to_string())); 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] #[test]
fn test_total_amount_multi_proof() { fn test_total_amount_multi_proof() {
let token = CashuToken { let token = CashuToken {
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -71,10 +71,16 @@ pub struct MintResult {
/// keyset codes shared by NUT-02/03/04/05 — the codes a swap/melt/mint call /// 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 /// 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`. /// 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> { fn describe_mint_error_code(code: i64) -> Option<&'static str> {
Some(match code { Some(match code {
10001 => "The mint rejected these coins as invalid.", 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.", 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.", 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.", 11004 => "This request is still being processed by the mint — try again in a moment.",
+1
View File
@@ -6,6 +6,7 @@ pub mod bdhke;
pub mod cashu; pub mod cashu;
pub mod ecash; pub mod ecash;
pub mod fedimint_client; pub mod fedimint_client;
pub mod minibits;
pub mod mint_client; pub mod mint_client;
pub mod nut13; pub mod nut13;
pub mod profits; pub mod profits;
+12
View File
@@ -137,6 +137,18 @@ impl EcashSeed {
self.mnemonic.words().map(|w| w.to_string()).collect() 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 { pub fn source(&self) -> SeedSource {
self.source self.source
} }
@@ -106,6 +106,30 @@
<!-- Ecash --> <!-- Ecash -->
<div v-if="receiveMethod === '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"> <div class="mb-3">
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.pasteEcashToken') }}</label> <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> <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 = '' arkAddress.value = ''
ecashToken.value = '' ecashToken.value = ''
ecashResult.value = '' ecashResult.value = ''
stopLnClaimPoll()
lnAddress.value = ''
lnAddressLoading.value = false
lnAddressError.value = false
lnClaimedSats.value = 0
lnPendingClaims.value = 0
error.value = '' error.value = ''
processing.value = false processing.value = false
if (props.autoGenerate && receiveMethod.value === 'onchain') { if (props.autoGenerate && receiveMethod.value === 'onchain') {
@@ -193,9 +223,93 @@ const ecashResult = ref('')
const onchainQrCanvas = ref<HTMLCanvasElement | null>(null) const onchainQrCanvas = ref<HTMLCanvasElement | null>(null)
const lightningQrCanvas = ref<HTMLCanvasElement | null>(null) const lightningQrCanvas = ref<HTMLCanvasElement | null>(null)
const arkQrCanvas = ref<HTMLCanvasElement | null>(null) const arkQrCanvas = ref<HTMLCanvasElement | null>(null)
const lnAddressQrCanvas = ref<HTMLCanvasElement | null>(null)
const processing = ref(false) const processing = ref(false)
const error = ref('') 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 ──────────────────────────────────────────── // ── On-chain payment detection ────────────────────────────────────────────
// The generated address is FRESH (lnd.newaddress), so any incoming wallet // The generated address is FRESH (lnd.newaddress), so any incoming wallet
// transaction paying it is this receive — no baseline bookkeeping needed. // 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() { function close() {
stopWatchingPayment() stopWatchingPayment()
stopLnClaimPoll()
paymentSeen.value = null paymentSeen.value = null
invoiceResult.value = '' invoiceResult.value = ''
onchainAddress.value = '' onchainAddress.value = ''
arkAddress.value = '' arkAddress.value = ''
ecashToken.value = '' ecashToken.value = ''
ecashResult.value = '' ecashResult.value = ''
lnAddress.value = ''
lnClaimedSats.value = 0
lnPendingClaims.value = 0
error.value = '' error.value = ''
emit('close') 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([])
})
})
+8 -1
View File
@@ -315,7 +315,7 @@
"passwordNeedUppercase": "Password must contain at least one uppercase letter", "passwordNeedUppercase": "Password must contain at least one uppercase letter",
"passwordNeedLowercase": "Password must contain at least one lowercase letter", "passwordNeedLowercase": "Password must contain at least one lowercase letter",
"passwordNeedDigit": "Password must contain at least one digit", "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", "setupFailed": "Setup failed",
"verificationFailed": "Verification failed", "verificationFailed": "Verification failed",
"disableFailed": "Failed to disable 2FA", "disableFailed": "Failed to disable 2FA",
@@ -775,6 +775,13 @@
"paymentConfirmed": "Payment confirmed", "paymentConfirmed": "Payment confirmed",
"transactionId": "Transaction ID", "transactionId": "Transaction ID",
"pasteEcashToken": "Paste ecash token", "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...", "processing": "Processing...",
"generateAddress": "Generate Address", "generateAddress": "Generate Address",
"createInvoice": "Create Invoice", "createInvoice": "Create Invoice",
+8 -1
View File
@@ -315,7 +315,7 @@
"passwordNeedUppercase": "La contrase\u00f1a debe contener al menos una letra may\u00fascula", "passwordNeedUppercase": "La contrase\u00f1a debe contener al menos una letra may\u00fascula",
"passwordNeedLowercase": "La contrase\u00f1a debe contener al menos una letra min\u00fascula", "passwordNeedLowercase": "La contrase\u00f1a debe contener al menos una letra min\u00fascula",
"passwordNeedDigit": "La contrase\u00f1a debe contener al menos un d\u00edgito", "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", "setupFailed": "La configuraci\u00f3n fall\u00f3",
"verificationFailed": "La verificaci\u00f3n fall\u00f3", "verificationFailed": "La verificaci\u00f3n fall\u00f3",
"disableFailed": "Error al deshabilitar 2FA", "disableFailed": "Error al deshabilitar 2FA",
@@ -756,6 +756,13 @@
"paymentConfirmed": "Pago confirmado", "paymentConfirmed": "Pago confirmado",
"transactionId": "ID de transacci\u00f3n", "transactionId": "ID de transacci\u00f3n",
"pasteEcashToken": "Pegar token Ecash", "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...", "processing": "Procesando...",
"generateAddress": "Generar direcci\u00f3n", "generateAddress": "Generar direcci\u00f3n",
"createInvoice": "Crear factura", "createInvoice": "Crear factura",
+17 -17
View File
@@ -1,29 +1,29 @@
{ {
"changelog": [ "changelog": [
"**Lightning sends work again — v1.8.9's payment switch lost the fee budget.** Moving payments to LND 0.21's supported route (Router.SendPaymentV2) shipped without a fee limit, and the v2 API treats an absent limit as **zero allowed fees**: every real route carries a routing fee, so the pathfinder rejected them all and the wallet answered \"No route to the recipient\" on every send — all day, on healthy channels with plenty of liquidity. The router debug log made it unambiguous (`fee_limit=0 mSAT` on every failing wallet payment; the same payment succeeded by hand the moment a fee limit was set). Payments now carry lncli's default budget (the payment amount), the wallet's amount handling for zero-value invoices is preserved, and a unit test pins the limit can never be zero again.", "**Cuprate now syncs without burning a core for days.** The app's shipped config now enables Cuprate's checkpoint-backed `fast_sync` path, raises the database cache to 8 GiB, and gives the container a 10 GiB memory limit so the cache has real headroom. A live comparison that motivated the change saw the affected node sit around 45% CPU while the corrected config held near low single digits at the same chain height and block rate. The restricted RPC remains fronted through the safe app gate/Tor path.",
"**A channel that drops its peer link now heals itself — on every node.** Restarting LND (an app update, a reboot, container churn) can leave a channel's peer connection down for hours while both endpoints keep the channel flagged disabled in the routing graph: the node looks perfectly healthy, the wallet shows balance, and every payment in either direction fails \"no route to the recipient\". Observed live: a node's only channel sat unroutable for ~17 hours after the LND 0.21.2 update, with no sign of it in any dashboard. The daemon now watches the channel graph as desired state — every open channel should have a live peer — and reconnects any that don't, using the peer's advertised addresses. Nodes without LND are untouched; an unreachable peer is retried gently, not hammered.", "**OpenWrt Gateway setup is documented from a real install, and two setup bugs are fixed.** The new guide walks a node operator through flashing a GL.iNet AX3000 to stock OpenWrt, pairing it with Archipelago, and installing TollGate pay-as-you-go WiFi. The installer now finds `opkg`/`apk` through the router's actual `PATH` instead of assuming `/usr/bin`, the UI no longer sends an empty password over a saved router connection, and the pinned TollGate package moves to `v0.5.0` with a native `.apk` install path where upstream provides one.",
"**The Lightning wallet states the node's real funding state instead of \"you have no channel.\"** Trying to send while a freshly opened channel was still waiting for on-chain confirmations — or when all its balance sits on the far side — raised a modal that claimed the node had NO channel at all (the outbound sum is legitimately zero in both states), pointed the user at opening a second channel, and — for payment routing failures — even showed the *receiving* copy. The funding gate now reads the channel list it already fetched: a confirming channel gets \"it unlocks automatically once confirmed, nothing is needed from you\", a far-side balance gets \"you can receive, but there's nothing to send right now\", a routing/liquidity payment failure says so instead of claiming channel problems, and only a genuinely channel-less node keeps the open-one guidance." "**Release publishing now checks the public Gitea download links before a manifest goes live.** The publisher already fetched every artifact back and verified its size and SHA-256; this release adds a second guard for the release page itself, so a bad Gitea `ROOT_URL` or proxy setting cannot publish working files behind broken public HTTPS download links."
], ],
"components": [ "components": [
{ {
"current_version": "1.8.10-alpha", "current_version": "1.8.11-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.10-alpha/archipelago", "download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.11-alpha/archipelago",
"name": "archipelago", "name": "archipelago",
"new_version": "1.8.10-alpha", "new_version": "1.8.11-alpha",
"sha256": "6c8bd41fed44cd999cb360c00e1b66a2d19d19812cc2b0c8a1677eec2a9579e6", "sha256": "ae569054edd6b2491beb101815f6809bc00c95a7dbe86bd084bcb9a7c36e1853",
"size_bytes": 64178056 "size_bytes": 64179264
}, },
{ {
"current_version": "1.8.10-alpha", "current_version": "1.8.11-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.10-alpha/archipelago-frontend-1.8.10-alpha.tar.gz", "download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.11-alpha/archipelago-frontend-1.8.11-alpha.tar.gz",
"name": "archipelago-frontend-1.8.10-alpha.tar.gz", "name": "archipelago-frontend-1.8.11-alpha.tar.gz",
"new_version": "1.8.10-alpha", "new_version": "1.8.11-alpha",
"sha256": "6b25de8a8e1a4f7fe51594f9bbbe21f5820f417af47a8b309c2dbf8f8723b719", "sha256": "192fd0470b6ccf66e78c80b4a4c3af5468882b85d81959362a3bd11f88b9d71d",
"size_bytes": 97736297 "size_bytes": 97741740
} }
], ],
"release_date": "2026-09-01", "release_date": "2026-09-07",
"signature": "b69926bcb1851ff7d6a5b24519cd4a8015aab4ed4b588ee989d8ce6e3beaeb2cc0eb38078f522ded0d389fe53b7dbcdbf3f40c534b4bfafa5cf4a2ab2c59e40f", "signature": "6449ce6ef35a4ef4fa6d0923bb58a2bff52ea5430d5532496e8f0af9ed52eaec293f19d7bec272dc9bc1af5fb2cdfa0e46068c827a9a4dd9cbef92d1c5845301",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.10-alpha" "version": "1.8.11-alpha"
} }
+3244 -3242
View File
File diff suppressed because one or more lines are too long
+17 -17
View File
@@ -1,29 +1,29 @@
{ {
"changelog": [ "changelog": [
"**Lightning sends work again — v1.8.9's payment switch lost the fee budget.** Moving payments to LND 0.21's supported route (Router.SendPaymentV2) shipped without a fee limit, and the v2 API treats an absent limit as **zero allowed fees**: every real route carries a routing fee, so the pathfinder rejected them all and the wallet answered \"No route to the recipient\" on every send — all day, on healthy channels with plenty of liquidity. The router debug log made it unambiguous (`fee_limit=0 mSAT` on every failing wallet payment; the same payment succeeded by hand the moment a fee limit was set). Payments now carry lncli's default budget (the payment amount), the wallet's amount handling for zero-value invoices is preserved, and a unit test pins the limit can never be zero again.", "**Cuprate now syncs without burning a core for days.** The app's shipped config now enables Cuprate's checkpoint-backed `fast_sync` path, raises the database cache to 8 GiB, and gives the container a 10 GiB memory limit so the cache has real headroom. A live comparison that motivated the change saw the affected node sit around 45% CPU while the corrected config held near low single digits at the same chain height and block rate. The restricted RPC remains fronted through the safe app gate/Tor path.",
"**A channel that drops its peer link now heals itself — on every node.** Restarting LND (an app update, a reboot, container churn) can leave a channel's peer connection down for hours while both endpoints keep the channel flagged disabled in the routing graph: the node looks perfectly healthy, the wallet shows balance, and every payment in either direction fails \"no route to the recipient\". Observed live: a node's only channel sat unroutable for ~17 hours after the LND 0.21.2 update, with no sign of it in any dashboard. The daemon now watches the channel graph as desired state — every open channel should have a live peer — and reconnects any that don't, using the peer's advertised addresses. Nodes without LND are untouched; an unreachable peer is retried gently, not hammered.", "**OpenWrt Gateway setup is documented from a real install, and two setup bugs are fixed.** The new guide walks a node operator through flashing a GL.iNet AX3000 to stock OpenWrt, pairing it with Archipelago, and installing TollGate pay-as-you-go WiFi. The installer now finds `opkg`/`apk` through the router's actual `PATH` instead of assuming `/usr/bin`, the UI no longer sends an empty password over a saved router connection, and the pinned TollGate package moves to `v0.5.0` with a native `.apk` install path where upstream provides one.",
"**The Lightning wallet states the node's real funding state instead of \"you have no channel.\"** Trying to send while a freshly opened channel was still waiting for on-chain confirmations — or when all its balance sits on the far side — raised a modal that claimed the node had NO channel at all (the outbound sum is legitimately zero in both states), pointed the user at opening a second channel, and — for payment routing failures — even showed the *receiving* copy. The funding gate now reads the channel list it already fetched: a confirming channel gets \"it unlocks automatically once confirmed, nothing is needed from you\", a far-side balance gets \"you can receive, but there's nothing to send right now\", a routing/liquidity payment failure says so instead of claiming channel problems, and only a genuinely channel-less node keeps the open-one guidance." "**Release publishing now checks the public Gitea download links before a manifest goes live.** The publisher already fetched every artifact back and verified its size and SHA-256; this release adds a second guard for the release page itself, so a bad Gitea `ROOT_URL` or proxy setting cannot publish working files behind broken public HTTPS download links."
], ],
"components": [ "components": [
{ {
"current_version": "1.8.10-alpha", "current_version": "1.8.11-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.10-alpha/archipelago", "download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.11-alpha/archipelago",
"name": "archipelago", "name": "archipelago",
"new_version": "1.8.10-alpha", "new_version": "1.8.11-alpha",
"sha256": "6c8bd41fed44cd999cb360c00e1b66a2d19d19812cc2b0c8a1677eec2a9579e6", "sha256": "ae569054edd6b2491beb101815f6809bc00c95a7dbe86bd084bcb9a7c36e1853",
"size_bytes": 64178056 "size_bytes": 64179264
}, },
{ {
"current_version": "1.8.10-alpha", "current_version": "1.8.11-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.10-alpha/archipelago-frontend-1.8.10-alpha.tar.gz", "download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.11-alpha/archipelago-frontend-1.8.11-alpha.tar.gz",
"name": "archipelago-frontend-1.8.10-alpha.tar.gz", "name": "archipelago-frontend-1.8.11-alpha.tar.gz",
"new_version": "1.8.10-alpha", "new_version": "1.8.11-alpha",
"sha256": "6b25de8a8e1a4f7fe51594f9bbbe21f5820f417af47a8b309c2dbf8f8723b719", "sha256": "192fd0470b6ccf66e78c80b4a4c3af5468882b85d81959362a3bd11f88b9d71d",
"size_bytes": 97736297 "size_bytes": 97741740
} }
], ],
"release_date": "2026-09-01", "release_date": "2026-09-07",
"signature": "b69926bcb1851ff7d6a5b24519cd4a8015aab4ed4b588ee989d8ce6e3beaeb2cc0eb38078f522ded0d389fe53b7dbcdbf3f40c534b4bfafa5cf4a2ab2c59e40f", "signature": "6449ce6ef35a4ef4fa6d0923bb58a2bff52ea5430d5532496e8f0af9ed52eaec293f19d7bec272dc9bc1af5fb2cdfa0e46068c827a9a4dd9cbef92d1c5845301",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.10-alpha" "version": "1.8.11-alpha"
} }
@@ -1,29 +0,0 @@
{
"changelog": [
"**Cuprate now syncs without burning a core for days.** The app's shipped config now enables Cuprate's checkpoint-backed `fast_sync` path, raises the database cache to 8 GiB, and gives the container a 10 GiB memory limit so the cache has real headroom. A live comparison that motivated the change saw the affected node sit around 45% CPU while the corrected config held near low single digits at the same chain height and block rate. The restricted RPC remains fronted through the safe app gate/Tor path.",
"**OpenWrt Gateway setup is documented from a real install, and two setup bugs are fixed.** The new guide walks a node operator through flashing a GL.iNet AX3000 to stock OpenWrt, pairing it with Archipelago, and installing TollGate pay-as-you-go WiFi. The installer now finds `opkg`/`apk` through the router's actual `PATH` instead of assuming `/usr/bin`, the UI no longer sends an empty password over a saved router connection, and the pinned TollGate package moves to `v0.5.0` with a native `.apk` install path where upstream provides one.",
"**Release publishing now checks the public Gitea download links before a manifest goes live.** The publisher already fetched every artifact back and verified its size and SHA-256; this release adds a second guard for the release page itself, so a bad Gitea `ROOT_URL` or proxy setting cannot publish working files behind broken public HTTPS download links."
],
"components": [
{
"current_version": "1.8.11-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.11-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.11-alpha",
"sha256": "ae569054edd6b2491beb101815f6809bc00c95a7dbe86bd084bcb9a7c36e1853",
"size_bytes": 64179264
},
{
"current_version": "1.8.11-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.11-alpha/archipelago-frontend-1.8.11-alpha.tar.gz",
"name": "archipelago-frontend-1.8.11-alpha.tar.gz",
"new_version": "1.8.11-alpha",
"sha256": "192fd0470b6ccf66e78c80b4a4c3af5468882b85d81959362a3bd11f88b9d71d",
"size_bytes": 97741740
}
],
"release_date": "2026-09-07",
"signature": "6449ce6ef35a4ef4fa6d0923bb58a2bff52ea5430d5532496e8f0af9ed52eaec293f19d7bec272dc9bc1af5fb2cdfa0e46068c827a9a4dd9cbef92d1c5845301",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.11-alpha"
}