Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
489995ced0 | ||
|
|
4e410d7c98 | ||
|
|
fc5b51ab2f | ||
|
|
3768395e59 | ||
|
|
6041eb6306 | ||
|
|
3be6f45fe8 | ||
|
|
3f52e4cd78 | ||
|
|
76d565fb18 | ||
|
|
6effc6b574 | ||
|
|
db52c06a72 | ||
|
|
4b14b62e74 | ||
|
|
5da91e4099 | ||
|
|
62731cc729 | ||
|
|
5e17ace690 | ||
|
|
b010471a4a | ||
|
|
c4ede96517 | ||
|
|
be06e1a502 | ||
|
|
f9a1ef031c | ||
|
|
cf240df4b6 |
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.8.11-alpha (2026-09-07)
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
## v1.8.10-alpha (2026-09-02)
|
## v1.8.10-alpha (2026-09-02)
|
||||||
|
|
||||||
- **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.
|
- **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.
|
||||||
|
|||||||
+35
-14
@@ -45,7 +45,12 @@ app:
|
|||||||
|
|
||||||
resources:
|
resources:
|
||||||
cpu_limit: 0
|
cpu_limit: 0
|
||||||
memory_limit: 4Gi
|
# Raised from 4Gi alongside target_max_memory below (see files[] comment)
|
||||||
|
# — 2026-09-03 incident: a 4Gi/3GB-cache config starved
|
||||||
|
# cuprated's DB cache into constant eviction/flush, driving 45% sustained
|
||||||
|
# CPU and ~595GB/24h of block I/O on a fully-synced node. 10Gi leaves
|
||||||
|
# headroom above the 8GiB cache for the process itself.
|
||||||
|
memory_limit: 10Gi
|
||||||
disk_limit: 300Gi
|
disk_limit: 300Gi
|
||||||
|
|
||||||
security:
|
security:
|
||||||
@@ -82,17 +87,21 @@ app:
|
|||||||
# bind without an explicit i_know_what_im_doing override.
|
# bind without an explicit i_know_what_im_doing override.
|
||||||
# Restricted RPC: Monero's own purpose-built safe-for-public subset —
|
# Restricted RPC: Monero's own purpose-built safe-for-public subset —
|
||||||
# what wallets use when connecting to a "remote node". Disabled by
|
# what wallets use when connecting to a "remote node". Disabled by
|
||||||
# cuprated's own default; enabled via files[] below. A dashboard login
|
# cuprated's own default; enabled via files[] below. `open`, not `gated`:
|
||||||
# would break wallet clients connecting programmatically, same
|
# the gate still takes the port over (loopback pin, external binds,
|
||||||
# reasoning as electrumx's port. The daemon still uses its canonical
|
# fronts the Tor onion) but skips the dashboard login challenge, same
|
||||||
# container port 18089, but Penpot already owns host port 18089, so this
|
# reasoning as electrumx's port — wallet clients (Feather,
|
||||||
# maps the public host port to the free 18090 instead.
|
# monero-wallet-rpc, GUI) speak plain HTTP JSON-RPC programmatically and
|
||||||
|
# cannot complete a browser login or hold a session cookie. The daemon
|
||||||
|
# still uses its canonical container port 18089, but Penpot already owns
|
||||||
|
# host port 18089, so this maps the public host port to the free 18090
|
||||||
|
# instead.
|
||||||
- host: 18090
|
- host: 18090
|
||||||
container: 18089
|
container: 18089
|
||||||
protocol: tcp
|
protocol: tcp
|
||||||
auth: none
|
auth: open
|
||||||
auth_rationale: >-
|
auth_rationale: >-
|
||||||
Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot hold a dashboard session cookie.
|
Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot complete a browser login or hold a dashboard session cookie.
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- type: bind
|
- type: bind
|
||||||
@@ -103,11 +112,23 @@ app:
|
|||||||
# Settings that need to differ from cuprated's own documented defaults
|
# Settings that need to differ from cuprated's own documented defaults
|
||||||
# (verified against `cuprated --generate-config` and `--dry-run` locally,
|
# (verified against `cuprated --generate-config` and `--dry-run` locally,
|
||||||
# 2026-08-21):
|
# 2026-08-21):
|
||||||
|
# - fast_sync: cuprated's own default is false, which performs full
|
||||||
|
# cryptographic verification (ring signatures + RandomX PoW) on every
|
||||||
|
# incoming block instead of trusting checkpointed history. Root-caused
|
||||||
|
# 2026-09-03 as the dominant cause of a sustained 45% CPU node,
|
||||||
|
# vs. 2.8% on a reference node with fast_sync = true — same chain height, same
|
||||||
|
# block rate. Set explicitly rather than relying on the binary
|
||||||
|
# default so fresh deploys don't silently regress into full-verify.
|
||||||
# - target_max_memory: cuprated's own default auto-detects total *host*
|
# - target_max_memory: cuprated's own default auto-detects total *host*
|
||||||
# RAM via sysinfo, which inside a memory-limited container would let
|
# RAM via sysinfo, which inside a memory-limited container would let
|
||||||
# it size caches far past what resources.memory_limit above actually
|
# it size caches far past what resources.memory_limit above actually
|
||||||
# grants — same class of problem bitcoin-knots' -dbcache sizing
|
# grants — same class of problem bitcoin-knots' -dbcache sizing
|
||||||
# comment addresses. Set explicitly, comfortably under the 4Gi limit.
|
# comment addresses. Set explicitly, comfortably under the 10Gi limit.
|
||||||
|
# Previously 3000000000 (~2.8GiB); that starved the DB cache and
|
||||||
|
# forced constant eviction/flush (595GB/24h block I/O on a node just
|
||||||
|
# appending ~2MB blocks every 2 minutes) — raised to 8GiB, matching
|
||||||
|
# the healthy reference node, and
|
||||||
|
# resources.memory_limit above raised in step to keep headroom above it.
|
||||||
# - rpc.restricted.enable: cuprated ships this off by default; flip on
|
# - rpc.restricted.enable: cuprated ships this off by default; flip on
|
||||||
# so the auth:none host port above actually serves something instead
|
# so the auth:none host port above actually serves something instead
|
||||||
# of refusing every connection. port stays at its documented default
|
# of refusing every connection. port stays at its documented default
|
||||||
@@ -128,21 +149,21 @@ app:
|
|||||||
# - tracing.stdout.level / tracing.file.{level,max_log_files}: an
|
# - tracing.stdout.level / tracing.file.{level,max_log_files}: an
|
||||||
# operator reading Cuprated.toml on disk should be able to see and
|
# operator reading Cuprated.toml on disk should be able to see and
|
||||||
# tune the log level directly instead of the file silently omitting
|
# tune the log level directly instead of the file silently omitting
|
||||||
# the whole [tracing] table (verified live on amishparadise
|
# the whole [tracing] table (verified live on the affected node
|
||||||
# 2026-09-01: the deployed file had no [tracing] section at all, and
|
# 2026-09-01: the deployed file had no [tracing] section at all, and
|
||||||
# the level was only discoverable by running `cuprated
|
# the level was only discoverable by running `cuprated
|
||||||
# --generate-config` and diffing). file.level is set to "info", NOT
|
# --generate-config` and diffing). file.level is set to "info", NOT
|
||||||
# cuprated's own raw default of "debug" — matches the reference dev
|
# cuprated's own raw default of "debug" — matches the reference dev
|
||||||
# config this app was built and tested against
|
# config this app was built and tested against (verified 2026-09-01),
|
||||||
# (ssmithx@archy-dev-pa:/home/ssmithx/cuprate/Cuprated.toml,
|
# which deliberately runs file logging quieter
|
||||||
# verified 2026-09-01), which deliberately runs file logging quieter
|
|
||||||
# than the binary default. max_log_files similarly follows that
|
# than the binary default. max_log_files similarly follows that
|
||||||
# reference (14, not the binary default of 7).
|
# reference (14, not the binary default of 7).
|
||||||
files:
|
files:
|
||||||
- path: /var/lib/archipelago/cuprate/Cuprated.toml
|
- path: /var/lib/archipelago/cuprate/Cuprated.toml
|
||||||
content: |
|
content: |
|
||||||
network = "Mainnet"
|
network = "Mainnet"
|
||||||
target_max_memory = 3000000000
|
fast_sync = true
|
||||||
|
target_max_memory = 8589934592
|
||||||
|
|
||||||
[rpc.restricted]
|
[rpc.restricted]
|
||||||
enable = true
|
enable = true
|
||||||
|
|||||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.8.10-alpha"
|
version = "1.8.11-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"archipelago-container",
|
"archipelago-container",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.8.10-alpha"
|
version = "1.8.11-alpha"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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!({
|
||||||
|
|||||||
@@ -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
@@ -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.",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ const TOLLGATE_VERSION: &str = "v0.5.0";
|
|||||||
/// Source: https://github.com/OpenTollGate/tollgate-module-basic-go/releases/tag/v0.5.0
|
/// Source: https://github.com/OpenTollGate/tollgate-module-basic-go/releases/tag/v0.5.0
|
||||||
fn ipk_url(arch: &str) -> Option<String> {
|
fn ipk_url(arch: &str) -> Option<String> {
|
||||||
let name = match arch {
|
let name = match arch {
|
||||||
"mips_24kc" => "mips_24kc",
|
"mips_24kc" => "mips_24kc",
|
||||||
"mipsel_24kc" => "mipsel_24kc",
|
"mipsel_24kc" => "mipsel_24kc",
|
||||||
"aarch64_cortex-a53" => "aarch64_cortex-a53",
|
"aarch64_cortex-a53" => "aarch64_cortex-a53",
|
||||||
"aarch64_cortex-a72" => "aarch64_cortex-a72",
|
"aarch64_cortex-a72" => "aarch64_cortex-a72",
|
||||||
"arm_cortex-a7" => "arm_cortex-a7",
|
"arm_cortex-a7" => "arm_cortex-a7",
|
||||||
"x86_64" => "x86_64",
|
"x86_64" => "x86_64",
|
||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
Some(format!(
|
Some(format!(
|
||||||
@@ -69,8 +69,9 @@ pub fn install_tollgate(router: &Router) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Package not in any feed — download the .ipk directly.
|
// Package not in any feed — download the .ipk directly.
|
||||||
let arch = router
|
let arch = router.run_ok(
|
||||||
.run_ok("opkg print-architecture | grep -v all | grep -v noarch | tail -1 | awk '{print $2}'")?;
|
"opkg print-architecture | grep -v all | grep -v noarch | tail -1 | awk '{print $2}'",
|
||||||
|
)?;
|
||||||
let arch = arch.trim();
|
let arch = arch.trim();
|
||||||
|
|
||||||
let url = ipk_url(arch).ok_or_else(|| {
|
let url = ipk_url(arch).ok_or_else(|| {
|
||||||
@@ -162,8 +163,7 @@ pub fn install_tollgate_apk_native(router: &Router) -> Result<()> {
|
|||||||
size
|
size
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let (add_out, add_code) =
|
let (add_out, add_code) = router.run("apk add --allow-untrusted /tmp/tollgate.apk 2>&1")?;
|
||||||
router.run("apk add --allow-untrusted /tmp/tollgate.apk 2>&1")?;
|
|
||||||
router.run_ok("rm -f /tmp/tollgate.apk")?;
|
router.run_ok("rm -f /tmp/tollgate.apk")?;
|
||||||
if add_code != 0 {
|
if add_code != 0 {
|
||||||
anyhow::bail!("TollGate .apk install failed: {}", add_out.trim());
|
anyhow::bail!("TollGate .apk install failed: {}", add_out.trim());
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"version": "1.8.10-alpha",
|
"version": "1.8.11-alpha",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"version": "1.8.10-alpha",
|
"version": "1.8.11-alpha",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@scure/bip39": "^2.2.0",
|
"@scure/bip39": "^2.2.0",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.8.10-alpha",
|
"version": "1.8.11-alpha",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "./start-dev.sh",
|
"start": "./start-dev.sh",
|
||||||
|
|||||||
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -64,10 +64,10 @@ describe('appSessionConfig', () => {
|
|||||||
configurable: true,
|
configurable: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// did-wallet's manifest publishes host port 8088 (apps/did-wallet/
|
// searxng's manifest publishes host port 8888 (apps/searxng/
|
||||||
// manifest.yml) — assert against the manifest-generated value, which is
|
// manifest.yml) — assert against the manifest-generated value, which is
|
||||||
// exactly what this test exists to protect.
|
// exactly what this test exists to protect.
|
||||||
expect(resolveAppUrl('did-wallet')).toBe('http://192.0.2.10:8088')
|
expect(resolveAppUrl('searxng')).toBe('http://192.0.2.10:8888')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not treat service-only tcp ports as web launch surfaces', () => {
|
it('does not treat service-only tcp ports as web launch surfaces', () => {
|
||||||
|
|||||||
@@ -362,6 +362,18 @@ init()
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||||
|
<!-- v1.8.11-alpha -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.11-alpha</span>
|
||||||
|
<span class="text-xs text-white/40">September 7, 2026</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||||
|
<p><strong>Cuprate now syncs without burning a core for days.</strong> 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.</p>
|
||||||
|
<p><strong>OpenWrt Gateway setup is documented from a real install, and two setup bugs are fixed.</strong> 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.</p>
|
||||||
|
<p><strong>Release publishing now checks the public Gitea download links before a manifest goes live.</strong> 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.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- v1.8.10-alpha -->
|
<!-- v1.8.10-alpha -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
@@ -369,9 +381,9 @@ init()
|
|||||||
<span class="text-xs text-white/40">September 2, 2026</span>
|
<span class="text-xs text-white/40">September 2, 2026</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||||
<p><strong>Lightning sends work again.</strong> v1.8.9's move to LND 0.21's supported payment route shipped without a fee budget, and the API treats a missing one as zero allowed fees — so every wallet send failed "No route to the recipient" all day, on perfectly healthy channels. Payments now carry a proper fee budget and a test keeps it from ever regressing.</p>
|
<p><strong>Lightning sends work again — v1.8.9's payment switch lost the fee budget.</strong> 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 <strong>zero allowed fees</strong>: 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.</p>
|
||||||
<p><strong>A channel that drops its peer link now heals itself — on every node.</strong> 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". The daemon now watches the channel graph as desired state — every open channel should have a live peer — and reconnects any that don't. Nodes without LND are untouched; an unreachable peer is retried gently.</p>
|
<p><strong>A channel that drops its peer link now heals itself — on every node.</strong> 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.</p>
|
||||||
<p><strong>The Lightning wallet says what's actually wrong, instead of "you have no channel".</strong> Trying to send while a channel you just opened was still confirming — or when all its balance sits on the far side — produced a modal claiming you had no channel at all, and payment routing failures even showed the receiving copy. The gate now reads your real channel list: 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", and only a genuinely channel-less node is sent to open one.</p>
|
<p><strong>The Lightning wallet states the node's real funding state instead of "you have no channel."</strong> 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 <em>receiving</em> 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.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- v1.8.9-alpha -->
|
<!-- v1.8.9-alpha -->
|
||||||
@@ -381,12 +393,13 @@ init()
|
|||||||
<span class="text-xs text-white/40">September 1, 2026</span>
|
<span class="text-xs text-white/40">September 1, 2026</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||||
<p><strong>Lightning sends work again after the LND 0.21.2 update.</strong> LND 0.21 removed the payment route the node's backend used — every send answered "Not Found". Payments now go through LND's supported v2 router route, slow multi-hop payments are still tracked to completion (never falsely declared failed), failures explain themselves in plain language, and a new test speaks the payment route directly at release-gate time so an image/backend mismatch like this can never ship silently again.</p>
|
<p><strong>Lightning sends work again after the LND 0.21.2 update.</strong> LND 0.21 removed the old synchronous payment route the node's backend paid through (/v1/channels/transactions) — every Lightning send answered the literal "Not Found" and the wallet showed "Payment failed: Not Found". The backend now pays through the supported Router.SendPaymentV2 route, keeps the same settle-then-report behaviour (a slow multi-hop payment is still tracked to completion, never falsely declared failed), and translates LND's failure reasons into plain advice. A new gate test speaks the payment route directly against the running LND, so an image/backend skew like this can never ship silently again.</p>
|
||||||
<p><strong>HTTP and HTTPS both work, and no longer break each other.</strong> The HTTPS listener used to pin a year-long browser policy (HSTS); once your browser had visited HTTPS, it silently rewrote the HTTP dashboard's calls to HTTPS — cross-origin, so everything showed "Failed to fetch"/CORS errors while the node was healthy. The pin is gone, the HTTPS listener now actively clears the stale policy browsers already cached (visit HTTPS once after this update to clear yours), and plain-HTTP access — which is deliberate on nodes whose self-signed certificate you haven't installed — keeps working exactly as before.</p>
|
<p><strong>The node no longer pins HSTS — HTTP access is a supported mode, and it stays working.</strong> The HTTPS listener used to send Strict-Transport-Security: max-age=31536000; includeSubDomains; browsers that visited HTTPS once cached that and then silently upgraded the still-open HTTP dashboard's calls to HTTPS, which is a scheme change — cross-origin — so every request died as "CORS blocked / Failed to fetch" while the node was perfectly healthy. The HTTPS listener now actively clears the cached policy (max-age=0) and port 80 sends no HSTS at all, which is deliberate: the node's certificate is optional and self-signed, and devices that haven't installed the CA must keep plain-HTTP access (that's what Settings → Node certificate is for). If your browser already cached the old policy, visiting the dashboard over HTTPS once after this update clears it; a gate test now refuses any config that reintroduces the pin.</p>
|
||||||
<p><strong>Apps open over HTTPS again, including Mempool, Bitcoin and IndeeHub.</strong> The launcher looked each app's port policy up in the signed catalog under the name you click, but the catalog lists that port under the app that owns it — so Mempool "did not connect", Bitcoin opened a plain-http tab, and Nostr sign-in on IndeeHub silently did nothing over HTTPS. Launches now follow the alias to the owning manifest, the catalog is loaded before the first app you open (not just in the App Store), and the Nostr bridge replies to the app frame's real origin instead of a stale recorded address.</p>
|
<p><strong>App frames open over HTTPS again — including the ones that "did not connect."</strong> The launcher asked the signed catalog for each app's port policy under the name you click ("Mempool Web", "Bitcoin Knots"), but the catalog declares those ports under the manifest that owns them (the Mempool web container, Bitcoin UI). The lookup missed, the launcher handed the iframe an http:// address, and the browser blocked it as mixed content — the app tile went blank or spun forever. Port resolution now follows launch aliases (mempool-web, bitcoin-knots/bitcoin-core, lnd, electrs and friends), falls back to a port-wide catalog scan when the id is unknown, and the catalog is warmed as soon as the dashboard loads rather than only in the App Store, so the very first app you open already knows which ports serve TLS.</p>
|
||||||
<p><strong>Nginx Proxy Manager starts again.</strong> Its manifest was missing two things its image requires — the LetsEncrypt folder mount and the permission to bind low ports — leaving it in an endless restart loop on nodes that had it installed. Both are declared now; your existing certificates are untouched, and the fix arrives via the signed catalog without waiting for this release.</p>
|
<p><strong>Signing in to IndeeHub with Nostr works over HTTPS.</strong> The NIP-07 bridge compared the app frame's origin for exact equality with the recorded http:// app URL — a frame the browser upgraded to HTTPS (or any scheme change) was silently ignored, and replies addressed to the stale origin were refused outright, so Nostr sign-in quietly did nothing. The bridge now matches host and port (scheme intentionally ignored) and always replies to the frame's real origin.</p>
|
||||||
<p><strong>Portainer's first-run token is on the app page, not buried in "server logs".</strong> New Portainer versions hand the first admin a one-time setup token that was only printed in the container logs — on this box, that token now appears with your app's other credentials, with a copy button, and disappears once setup is done.</p>
|
<p><strong>Nginx Proxy Manager starts again.</strong> Converting it to a platform manifest dropped two things its image needs: the /etc/letsencrypt mount its boot script hard-requires, and the NET_BIND_SERVICE capability its internal nginx needs to bind ports 80/443/81 under the orchestrator's --cap-drop=ALL. The result was an endless start/die loop (a node watched it restart 3,176 times). Both are declared in its manifest now, its certs live on unchanged under the same persistent app directory, and the signed catalog carries the fix so installed nodes heal on the next update.</p>
|
||||||
<p><strong>The Lightning wallet says what's actually wrong, instead of "you have no channel".</strong> Trying to send while a channel you just opened was still confirming — or when all its balance sits on the far side — produced a modal claiming you had no channel at all. The gate now looks at your real channel list: a confirming channel gets "it unlocks automatically once confirmed, nothing needed from you", a far-side balance gets "you can receive but there's nothing to send right now", and only a genuinely channel-less node is sent to open one.</p>
|
<p><strong>Portainer's first-run token is in the app page, not buried in "server logs."</strong> New Portainer versions mint a one-time setup token on a fresh install and print it only to the container logs — on an appliance that meant telling the user to go read a server log to get into their own app. The token now appears in the same launch interstitial as app login credentials (with a copy button), only while first-run setup is actually pending; once the admin account exists the card disappears on its own.</p>
|
||||||
|
<p><strong>The Lightning wallet states the node's real funding state instead of "you have no channel."</strong> 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). 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 pointing at channel setup, and only a genuinely channel-less node is sent to open one.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- v1.8.8-alpha -->
|
<!-- v1.8.8-alpha -->
|
||||||
|
|||||||
+17
-17
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1319,7 +1319,7 @@
|
|||||||
"description": "Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.",
|
"description": "Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"content": "network = \"Mainnet\"\ntarget_max_memory = 3000000000\n\n[rpc.restricted]\nenable = true\n\n[tracing.stdout]\nlevel = \"info\"\n\n[tracing.file]\nlevel = \"info\"\nmax_log_files = 14\n",
|
"content": "network = \"Mainnet\"\nfast_sync = true\ntarget_max_memory = 8589934592\n\n[rpc.restricted]\nenable = true\n\n[tracing.stdout]\nlevel = \"info\"\n\n[tracing.file]\nlevel = \"info\"\nmax_log_files = 14\n",
|
||||||
"overwrite": false,
|
"overwrite": false,
|
||||||
"path": "/var/lib/archipelago/cuprate/Cuprated.toml"
|
"path": "/var/lib/archipelago/cuprate/Cuprated.toml"
|
||||||
}
|
}
|
||||||
@@ -1350,8 +1350,8 @@
|
|||||||
"protocol": "tcp"
|
"protocol": "tcp"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"auth": "none",
|
"auth": "open",
|
||||||
"auth_rationale": "Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot hold a dashboard session cookie.",
|
"auth_rationale": "Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot complete a browser login or hold a dashboard session cookie.",
|
||||||
"container": 18089,
|
"container": 18089,
|
||||||
"host": 18090,
|
"host": 18090,
|
||||||
"protocol": "tcp"
|
"protocol": "tcp"
|
||||||
@@ -1360,7 +1360,7 @@
|
|||||||
"resources": {
|
"resources": {
|
||||||
"cpu_limit": 0,
|
"cpu_limit": 0,
|
||||||
"disk_limit": "300Gi",
|
"disk_limit": "300Gi",
|
||||||
"memory_limit": "4Gi"
|
"memory_limit": "10Gi"
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"capabilities": [],
|
"capabilities": [],
|
||||||
@@ -5429,7 +5429,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"schema": 1,
|
"schema": 1,
|
||||||
"signature": "f982faeb9823062d9d39f6e4b38a171b4442cad0f35e74792ea161b5d77246ab9128044acbdc390ec23f921363af2d13bbba66c558b188d14d06a3f9a7f42406",
|
"signature": "3e87496a7197177ea295eba416cd1ed9a2c41ddca3328a160b1db2c65d39ce813c2b1e63df1313680a8e48e0113e2bbe6118df01df4a777bd33b189e7ef69206",
|
||||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||||
"updated": "2026-09-01"
|
"updated": "2026-09-03"
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-17
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# check-gitea-release-download-links.sh - verify Gitea's public release page
|
||||||
|
# points users at the canonical HTTPS download URLs, not an internal ROOT_URL.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/check-gitea-release-download-links.sh VERSION ASSET_NAME...
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="${1:-}"
|
||||||
|
if [ -z "$VERSION" ] || [ "$#" -lt 2 ]; then
|
||||||
|
echo "usage: $0 VERSION ASSET_NAME..." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
shift
|
||||||
|
|
||||||
|
PUBLIC_BASE="${ARCHY_RELEASE_PUBLIC_BASE:-https://source.archipelago-foundation.org/lfg2025/archy}"
|
||||||
|
page_url="$PUBLIC_BASE/releases/tag/v$VERSION"
|
||||||
|
|
||||||
|
command -v curl >/dev/null 2>&1 || { echo "ERROR: curl required" >&2; exit 2; }
|
||||||
|
command -v python3 >/dev/null 2>&1 || { echo "ERROR: python3 required" >&2; exit 2; }
|
||||||
|
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
trap 'rm -f "$tmp"' EXIT
|
||||||
|
curl -fsSL "$page_url" -o "$tmp"
|
||||||
|
|
||||||
|
python3 - "$tmp" "$PUBLIC_BASE" "$VERSION" "$page_url" "$@" <<'PY'
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from urllib.parse import quote
|
||||||
|
import sys
|
||||||
|
|
||||||
|
html_path, public_base, version, page_url, *assets = sys.argv[1:]
|
||||||
|
with open(html_path, encoding="utf-8") as f:
|
||||||
|
html = f.read()
|
||||||
|
|
||||||
|
class LinkParser(HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.hrefs = []
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
if tag.lower() != "a":
|
||||||
|
return
|
||||||
|
attrs = dict(attrs)
|
||||||
|
href = attrs.get("href")
|
||||||
|
if href:
|
||||||
|
self.hrefs.append(href)
|
||||||
|
|
||||||
|
parser = LinkParser()
|
||||||
|
parser.feed(html)
|
||||||
|
hrefs = set(parser.hrefs)
|
||||||
|
|
||||||
|
bad_internal = sorted(
|
||||||
|
h for h in hrefs
|
||||||
|
if "/releases/download/" in h and h.startswith(("http://", "https://"))
|
||||||
|
and not h.startswith(public_base + "/releases/download/")
|
||||||
|
)
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
for asset in assets:
|
||||||
|
expected = f"{public_base}/releases/download/v{quote(version)}/{quote(asset)}"
|
||||||
|
if expected not in hrefs:
|
||||||
|
matches = sorted(h for h in hrefs if h.endswith("/" + quote(asset)))
|
||||||
|
if matches:
|
||||||
|
failures.append(f"{asset}: expected {expected}, found {matches[0]}")
|
||||||
|
else:
|
||||||
|
failures.append(f"{asset}: expected {expected}, but no matching release-page link was found")
|
||||||
|
|
||||||
|
if bad_internal:
|
||||||
|
failures.append("release page contains non-canonical download href(s):")
|
||||||
|
failures.extend(f" {h}" for h in bad_internal[:10])
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
print(f"FAIL: public release page has broken download links: {page_url}", file=sys.stderr)
|
||||||
|
for failure in failures:
|
||||||
|
print(f" {failure}", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
"Fix the Gitea public URL/proxy configuration so release links are generated "
|
||||||
|
"from the canonical HTTPS origin, then re-run the publish check.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"OK: public release page download links use {public_base}")
|
||||||
|
PY
|
||||||
@@ -145,6 +145,11 @@ echo "Verifying public download URLs (full GET + size + sha256)..."
|
|||||||
"$PROJECT_ROOT/scripts/check-release-assets.sh" "$MANIFEST" \
|
"$PROJECT_ROOT/scripts/check-release-assets.sh" "$MANIFEST" \
|
||||||
|| fail "asset verification failed — NOT pushing main. The manifest stays off the branch nodes read, so no node sees a version it cannot fetch. Repair the assets and re-run."
|
|| fail "asset verification failed — NOT pushing main. The manifest stays off the branch nodes read, so no node sees a version it cannot fetch. Repair the assets and re-run."
|
||||||
|
|
||||||
|
"$PROJECT_ROOT/scripts/check-gitea-release-download-links.sh" "$VERSION" \
|
||||||
|
"archipelago" \
|
||||||
|
"archipelago-frontend-${VERSION}.tar.gz" \
|
||||||
|
|| fail "release page download links are not public HTTPS URLs — fix Gitea ROOT_URL/proxy configuration before publishing."
|
||||||
|
|
||||||
# Assets are proven fetchable — only now may the manifest become live. First
|
# Assets are proven fetchable — only now may the manifest become live. First
|
||||||
# incorporate concurrent work, then promote in a dedicated commit. Until the
|
# incorporate concurrent work, then promote in a dedicated commit. Until the
|
||||||
# final push succeeds the remote still serves the previous manifest.
|
# final push succeeds the remote still serves the previous manifest.
|
||||||
@@ -261,4 +266,10 @@ for b in bad:
|
|||||||
sys.exit(1 if bad else 0)
|
sys.exit(1 if bad else 0)
|
||||||
PY
|
PY
|
||||||
|
|
||||||
|
"$PROJECT_ROOT/scripts/check-gitea-release-download-links.sh" "$VERSION" \
|
||||||
|
"$ISO_NAME" \
|
||||||
|
"$ISO_NAME.sha256" \
|
||||||
|
"$ISO_NAME.sha256.json" \
|
||||||
|
|| fail "ISO is uploaded but the release page links are not public HTTPS URLs — fix Gitea ROOT_URL/proxy configuration."
|
||||||
|
|
||||||
echo "ISO for v${VERSION} published and verified on $REMOTE."
|
echo "ISO for v${VERSION} published and verified on $REMOTE."
|
||||||
|
|||||||
Reference in New Issue
Block a user