From a252eb12fd366ca870c9c86c31a536587afefb21 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Thu, 2 Jul 2026 17:23:12 +0000 Subject: [PATCH 1/8] fix(tollgate): install and configure NoDogSplash for real client gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tollgate-wrt has no firewall/netfilter code of its own (confirmed via strings on the binary and the upstream Go source) — it delegates all MAC authorization and gate open/close to NoDogSplash via ndsctl. Upstream's package declares +nodogsplash as a hard dependency, but neither of our install paths pull it in: the opkg fast path only resolves deps against a real feed, and the raw .ipk-extraction fallback (used whenever the package isn't in a feed, and always on ApkNative) skips dependency resolution entirely. Result: tollgate-wrt ran, accepted payments, and tracked balances, but never actually blocked unpaid clients — the static firewall zone just forwarded everyone to WAN unconditionally. Also fixes the config.json mismatch: tollgate-wrt reads /etc/tollgate/config.json exclusively, never the tollgate.main.* UCI keys we were writing — so mint/price changes through this project's UI silently had no effect on what the daemon actually advertised. - tollgate/nodogsplash.rs: install nodogsplash, configure it to gate the dedicated br-tollgate bridge, open the pre-auth walled-garden ports (2121 payment, 2050 portal). - tollgate/wifi.rs: bind network.tollgate to a stable br-tollgate bridge device (NoDogSplash needs a known interface name — the driver-assigned name of a bare wifi vif isn't guaranteed); disable IPv6 RA/DHCPv6 on it (NoDogSplash only manages IPv4 iptables, so IPv6 would let clients bypass the portal entirely). - tollgate/config.rs: apply_daemon_config() merges pricing/mint into the real config.json instead of (only) UCI. - opkg.rs: generic install_package() for standard feed packages under either PkgManager mode. - router.rs: upload_file() (SCP) for non-UCI config files. --- core/openwrt/src/opkg.rs | 27 +++++++++++++ core/openwrt/src/router.rs | 20 +++++++++- core/openwrt/src/tollgate/config.rs | 44 ++++++++++++++++++++- core/openwrt/src/tollgate/mod.rs | 28 +++++++++++-- core/openwrt/src/tollgate/nodogsplash.rs | 50 ++++++++++++++++++++++++ core/openwrt/src/tollgate/wifi.rs | 24 +++++++++++- 6 files changed, 184 insertions(+), 9 deletions(-) create mode 100644 core/openwrt/src/tollgate/nodogsplash.rs diff --git a/core/openwrt/src/opkg.rs b/core/openwrt/src/opkg.rs index 84d889e0..296715ba 100644 --- a/core/openwrt/src/opkg.rs +++ b/core/openwrt/src/opkg.rs @@ -87,4 +87,31 @@ impl Router { self.run_ok(&format!("/usr/bin/opkg remove {}", package))?; Ok(()) } + + /// Install a standard OpenWrt package via whichever manager is active. + /// + /// Unlike `tollgate-module-basic-go` itself (which falls back to manual + /// `.ipk` extraction and therefore skips dependency resolution — see + /// `tollgate::install`), packages like `nodogsplash` are in every + /// upstream OpenWrt feed, so a plain `apk add` / `opkg install` works + /// even in `ApkNative` mode. + pub fn install_package(&self, pkg_mgr: PkgManager, package: &str) -> Result<()> { + match pkg_mgr { + PkgManager::Opkg => self.opkg_install(package), + PkgManager::ApkNative => self.apk_install(package), + } + } + + /// `apk add `, skipping if already installed. + pub fn apk_install(&self, package: &str) -> Result<()> { + let (_, code) = self.run(&format!("apk info -e {} >/dev/null 2>&1", package))?; + if code == 0 { + info!("[{}] {} already installed", self.host, package); + return Ok(()); + } + + info!("[{}] apk add {}", self.host, package); + self.run_ok(&format!("/usr/bin/apk add {}", package))?; + Ok(()) + } } diff --git a/core/openwrt/src/router.rs b/core/openwrt/src/router.rs index 088ec9c5..97b1a84c 100644 --- a/core/openwrt/src/router.rs +++ b/core/openwrt/src/router.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result}; use ssh2::Session; -use std::io::Read; +use std::io::{Read, Write}; use std::net::TcpStream; use std::path::Path; use tracing::debug; @@ -84,4 +84,22 @@ impl Router { .context("read /etc/openwrt_release — is this an OpenWrt device?")?; Ok(release) } + + /// Upload file contents to the router over SCP, overwriting any existing + /// file at `remote_path`. Used for config files that aren't UCI-backed + /// (e.g. `/etc/tollgate/config.json`), where `uci_*` helpers don't apply. + pub fn upload_file(&self, remote_path: &str, contents: &[u8]) -> Result<()> { + let mut channel = self + .session + .scp_send(Path::new(remote_path), 0o644, contents.len() as u64, None) + .with_context(|| format!("scp_send to {}", remote_path))?; + channel + .write_all(contents) + .with_context(|| format!("write contents to {}", remote_path))?; + channel.send_eof().context("scp send_eof")?; + channel.wait_eof().context("scp wait_eof")?; + channel.close().context("scp close")?; + channel.wait_close().context("scp wait_close")?; + Ok(()) + } } diff --git a/core/openwrt/src/tollgate/config.rs b/core/openwrt/src/tollgate/config.rs index 005e2cae..d6761d54 100644 --- a/core/openwrt/src/tollgate/config.rs +++ b/core/openwrt/src/tollgate/config.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use crate::Router; @@ -40,7 +40,11 @@ impl Default for TollGateConfig { /// Write TollGate UCI configuration and commit. /// -/// Maps TIP-01 / TIP-02 fields onto UCI keys used by tollgate-module-basic-go. +/// `tollgate-wrt` never reads UCI — see `apply_daemon_config` below for the +/// config it actually consumes. These `tollgate.main.*` keys exist only for +/// this project's own status display / detection probes (`uci get +/// tollgate.main.enabled` etc.); changing pricing or the mint here has no +/// effect on what the daemon advertises or accepts. pub fn apply(router: &Router, cfg: &TollGateConfig) -> Result<()> { router.uci_apply( "tollgate", @@ -57,3 +61,39 @@ pub fn apply(router: &Router, cfg: &TollGateConfig) -> Result<()> { )?; Ok(()) } + +/// Write the config `tollgate-wrt` actually reads: `/etc/tollgate/config.json` +/// (schema `v0.0.6`/`v0.0.7`, see `config_manager` in the upstream Go source). +/// +/// Merges into whatever config.json already exists (the daemon writes a +/// default on first boot) rather than overwriting it wholesale — fields this +/// project doesn't manage (`profit_share`, `upstream_detector`, +/// `upstream_session_manager`/`chandler`, `relays`, ...) must survive +/// re-provisioning. +/// +/// Must run before the daemon is (re)started — it only reads this file at +/// startup, it does not hot-reload. +pub fn apply_daemon_config(router: &Router, cfg: &TollGateConfig) -> Result<()> { + let existing = router.run_ok("cat /etc/tollgate/config.json 2>/dev/null || echo '{}'")?; + let mut doc: serde_json::Value = + serde_json::from_str(existing.trim()).unwrap_or_else(|_| serde_json::json!({})); + + doc["metric"] = serde_json::json!("milliseconds"); + doc["step_size"] = serde_json::json!(cfg.step_size_ms); + doc["accepted_mints"] = serde_json::json!([{ + "url": cfg.mint_url, + "min_balance": 64, + "balance_tolerance_percent": 10, + "payout_interval_seconds": 60, + "min_payout_amount": 128, + "price_per_step": cfg.price_sats, + "price_unit": "sats", + "purchase_min_steps": cfg.min_steps, + }]); + + let json_str = serde_json::to_string_pretty(&doc).context("serialize config.json")?; + router + .upload_file("/etc/tollgate/config.json", json_str.as_bytes()) + .context("upload /etc/tollgate/config.json")?; + Ok(()) +} diff --git a/core/openwrt/src/tollgate/mod.rs b/core/openwrt/src/tollgate/mod.rs index f694e97d..88d8db0a 100644 --- a/core/openwrt/src/tollgate/mod.rs +++ b/core/openwrt/src/tollgate/mod.rs @@ -1,21 +1,25 @@ pub mod config; pub mod install; +pub mod nodogsplash; pub mod wifi; pub use config::TollGateConfig; pub use install::install_tollgate; pub use wifi::provision_ssid; -use anyhow::Result; +use anyhow::{Context, Result}; use tracing::info; use crate::{opkg::PkgManager, Router}; /// Full TollGate provisioning sequence: /// 1. Install tollgate-module-basic-go -/// 2. Write TollGate UCI config (pricing, mint URL) -/// 3. Create the pay-as-you-go WiFi SSID -/// 4. Restart affected services +/// 2. Install and configure NoDogSplash (client gating — tollgate-wrt has no +/// firewall/enforcement code of its own; see `nodogsplash::provision`) +/// 3. Write TollGate config: UCI (status/detection only) + the JSON file the +/// daemon actually reads +/// 4. Create the pay-as-you-go WiFi SSID and its dedicated bridge/network +/// 5. Restart affected services pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> { info!("[{}] Starting TollGate provisioning", router.host); @@ -29,9 +33,25 @@ pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> { install::install_tollgate_apk_native(router)?; } } + + // NoDogSplash is a hard runtime dependency of tollgate-wrt (upstream's + // package declares `+nodogsplash`), but neither install path above pulls + // it in: the opkg fast path only resolves deps against a real feed, and + // the raw .ipk-extraction fallback (used whenever the package isn't in a + // feed, and always on ApkNative) skips dependency resolution entirely. + // Without it, tollgate-wrt runs and accepts payments but never actually + // blocks unpaid clients. + nodogsplash::provision(router, pkg_mgr, config) + .context("provision nodogsplash — tollgate-wrt cannot gate clients without it")?; + config::apply(router, config)?; wifi::provision_ssid(router, config)?; + // Must come after provision_ssid (which creates br-tollgate) and before + // the daemon restart below — config.json is only read at startup. + config::apply_daemon_config(router, config) + .context("write /etc/tollgate/config.json — tollgate-wrt reads this, not UCI")?; restart_services(router, config.enabled)?; + nodogsplash::restart(router)?; info!("[{}] TollGate provisioning complete", router.host); Ok(()) diff --git a/core/openwrt/src/tollgate/nodogsplash.rs b/core/openwrt/src/tollgate/nodogsplash.rs new file mode 100644 index 00000000..5742cee2 --- /dev/null +++ b/core/openwrt/src/tollgate/nodogsplash.rs @@ -0,0 +1,50 @@ +use anyhow::{Context, Result}; + +use crate::opkg::PkgManager; +use crate::tollgate::TollGateConfig; +use crate::Router; + +/// Install and configure NoDogSplash — the captive-portal engine that +/// `tollgate-wrt` delegates all MAC authorization and gate open/close to via +/// `ndsctl`. `tollgate-wrt` contains no firewall/netfilter code of its own +/// (confirmed: its binary has no `nft`/`ipset`/`iptables` calls at all); the +/// upstream package therefore hard-depends on `+nodogsplash` and its postinst +/// restarts it. Without NoDogSplash actually running, nothing blocks +/// unauthenticated clients from reaching the internet, regardless of what +/// TollGate itself is configured to charge. +/// +/// Gates the dedicated `br-tollgate` bridge (see `wifi::provision_network`), +/// not `br-lan` — the paid SSID here lives on its own isolated network/subnet +/// rather than the canonical upstream layout where it's bridged into `lan`. +pub fn provision(router: &Router, pkg_mgr: PkgManager, cfg: &TollGateConfig) -> Result<()> { + router + .install_package(pkg_mgr, "nodogsplash") + .context("install nodogsplash — required by tollgate-wrt for client gating")?; + + router.run_ok("touch /etc/config/nodogsplash")?; + router.uci_set("nodogsplash.main", "nodogsplash")?; + router.uci_set("nodogsplash.main.enabled", "1")?; + router.uci_set("nodogsplash.main.gatewayinterface", "br-tollgate")?; + router.uci_set("nodogsplash.main.gatewayname", &format!("{} Portal", cfg.ssid))?; + router.uci_set("nodogsplash.main.gatewaydomainname", "TollGate.lan")?; + router.uci_set("nodogsplash.main.gatewayport", "2050")?; + + // Pre-auth "walled garden": unauthenticated clients must still be able to + // reach the TollGate payment endpoint (2121) and the splash portal itself + // (2050) — everything else is blocked by NoDogSplash until ndsctl + // authorizes their MAC. Rebuild the list each run rather than trying to + // dedupe, so repeated provisioning stays idempotent. + let _ = router.uci_delete("nodogsplash.main.users_to_router"); + router.uci_add_list("nodogsplash.main.users_to_router", "allow tcp port 2121")?; + router.uci_add_list("nodogsplash.main.users_to_router", "allow tcp port 2050")?; + + router.uci_commit(Some("nodogsplash"))?; + Ok(()) +} + +/// (Re)start the nodogsplash service so config changes and gate state take effect. +pub fn restart(router: &Router) -> Result<()> { + router.run_ok("/etc/init.d/nodogsplash enable")?; + router.run_ok("/etc/init.d/nodogsplash restart || /etc/init.d/nodogsplash start")?; + Ok(()) +} diff --git a/core/openwrt/src/tollgate/wifi.rs b/core/openwrt/src/tollgate/wifi.rs index 03baea1d..5d3428bd 100644 --- a/core/openwrt/src/tollgate/wifi.rs +++ b/core/openwrt/src/tollgate/wifi.rs @@ -38,14 +38,29 @@ pub fn provision_ssid(router: &Router, cfg: &TollGateConfig) -> Result<()> { } /// Add a `tollgate` network interface (isolated LAN for TollGate clients). +/// +/// Binds to a named bridge device (`br-tollgate`) rather than leaving the +/// wifi-iface as the network's raw device — NoDogSplash's `gatewayinterface` +/// needs a stable, known interface name to gate (see `nodogsplash::provision`), +/// and the driver-assigned name of a bare wifi vif (e.g. `phy0-ap0`) isn't +/// guaranteed across hardware. fn provision_network(router: &Router) -> Result<()> { router.uci_apply( "network", &[ + ("network.tollgate_bridge", "device"), + ("network.tollgate_bridge.type", "bridge"), + ("network.tollgate_bridge.name", "br-tollgate"), ("network.tollgate", "interface"), + ("network.tollgate.device", "br-tollgate"), ("network.tollgate.proto", "static"), ("network.tollgate.ipaddr", "192.168.99.1"), ("network.tollgate.netmask", "255.255.255.0"), + // NoDogSplash only manages IPv4 iptables rules. If IPv6 RA/DHCPv6 + // stays enabled, clients get routable IPv6 addresses and their OS + // validates connectivity (and browses freely) over IPv6, bypassing + // the portal entirely. See OpenTollGate/tollgate-module-basic-go#148. + ("network.tollgate.ip6assign", "0"), ], )?; @@ -58,6 +73,8 @@ fn provision_network(router: &Router) -> Result<()> { ("dhcp.tollgate.start", "100"), ("dhcp.tollgate.limit", "150"), ("dhcp.tollgate.leasetime", "5m"), + ("dhcp.tollgate.ra", "disabled"), + ("dhcp.tollgate.dhcpv6", "disabled"), ], )?; @@ -66,8 +83,11 @@ fn provision_network(router: &Router) -> Result<()> { /// Add firewall zone for the tollgate interface. /// -/// TollGate itself gates forwarding via iptables; the firewall zone isolates -/// tollgate clients from other LAN segments. +/// This zone only isolates tollgate clients from other LAN segments and +/// opens the payment port to the router. Per-client forwarding to WAN is +/// actually gated by NoDogSplash's own iptables rules (via `ndsctl`), not by +/// anything in this static firewall config — `tollgate-wrt` has no netfilter +/// code of its own. See `nodogsplash::provision`. fn provision_firewall(router: &Router) -> Result<()> { // Zone router.uci_apply( From 7439a1251c67b93c661861ed2b8dbab4e906d7a0 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Thu, 2 Jul 2026 19:28:48 +0000 Subject: [PATCH 2/8] fix(tollgate): fix nodogsplash provisioning order (found live: gated br-lan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploying the previous commit's fix live exposed a real bug: nodogsplash's OpenWrt package postinst auto-enables and starts the service immediately on install, using its stock default config — gatewayinterface=br-lan. Since NoDogSplash only touches IPv4 iptables, this silently cut IPv4 (not IPv6) connectivity for anything on br-lan, including the admin management box plugged into this router's LAN port, for the window between install and our own configure step. Split nodogsplash provisioning into install_and_stop() (runs first, closes that window immediately) and configure() (runs after wifi::provision_ssid has created br-tollgate, so gatewayinterface is pointed at the isolated tollgate bridge instead of br-lan). --- core/openwrt/src/tollgate/mod.rs | 21 ++++++++---- core/openwrt/src/tollgate/nodogsplash.rs | 41 +++++++++++++++++------- 2 files changed, 44 insertions(+), 18 deletions(-) diff --git a/core/openwrt/src/tollgate/mod.rs b/core/openwrt/src/tollgate/mod.rs index 88d8db0a..9f9e3ab0 100644 --- a/core/openwrt/src/tollgate/mod.rs +++ b/core/openwrt/src/tollgate/mod.rs @@ -14,12 +14,14 @@ use crate::{opkg::PkgManager, Router}; /// Full TollGate provisioning sequence: /// 1. Install tollgate-module-basic-go -/// 2. Install and configure NoDogSplash (client gating — tollgate-wrt has no -/// firewall/enforcement code of its own; see `nodogsplash::provision`) +/// 2. Install NoDogSplash and immediately stop it (its postinst auto-starts +/// it against `br-lan` by default — see `nodogsplash::install_and_stop`) /// 3. Write TollGate config: UCI (status/detection only) + the JSON file the /// daemon actually reads /// 4. Create the pay-as-you-go WiFi SSID and its dedicated bridge/network -/// 5. Restart affected services +/// 5. Configure NoDogSplash to gate that bridge (now that it exists) — +/// client gating; tollgate-wrt has no enforcement code of its own +/// 6. Restart affected services pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> { info!("[{}] Starting TollGate provisioning", router.host); @@ -40,9 +42,11 @@ pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> { // the raw .ipk-extraction fallback (used whenever the package isn't in a // feed, and always on ApkNative) skips dependency resolution entirely. // Without it, tollgate-wrt runs and accepts payments but never actually - // blocks unpaid clients. - nodogsplash::provision(router, pkg_mgr, config) - .context("provision nodogsplash — tollgate-wrt cannot gate clients without it")?; + // blocks unpaid clients. Install + stop happens before anything else so + // its auto-started default config (gating br-lan) is live for as little + // time as possible. + nodogsplash::install_and_stop(router, pkg_mgr) + .context("install nodogsplash — tollgate-wrt cannot gate clients without it")?; config::apply(router, config)?; wifi::provision_ssid(router, config)?; @@ -50,6 +54,11 @@ pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> { // the daemon restart below — config.json is only read at startup. config::apply_daemon_config(router, config) .context("write /etc/tollgate/config.json — tollgate-wrt reads this, not UCI")?; + // Also must come after provision_ssid: points gatewayinterface at + // br-tollgate, which provision_ssid is what creates. + nodogsplash::configure(router, config) + .context("configure nodogsplash — tollgate-wrt cannot gate clients without it")?; + restart_services(router, config.enabled)?; nodogsplash::restart(router)?; diff --git a/core/openwrt/src/tollgate/nodogsplash.rs b/core/openwrt/src/tollgate/nodogsplash.rs index 5742cee2..06025bf4 100644 --- a/core/openwrt/src/tollgate/nodogsplash.rs +++ b/core/openwrt/src/tollgate/nodogsplash.rs @@ -4,23 +4,40 @@ use crate::opkg::PkgManager; use crate::tollgate::TollGateConfig; use crate::Router; -/// Install and configure NoDogSplash — the captive-portal engine that -/// `tollgate-wrt` delegates all MAC authorization and gate open/close to via -/// `ndsctl`. `tollgate-wrt` contains no firewall/netfilter code of its own -/// (confirmed: its binary has no `nft`/`ipset`/`iptables` calls at all); the -/// upstream package therefore hard-depends on `+nodogsplash` and its postinst -/// restarts it. Without NoDogSplash actually running, nothing blocks -/// unauthenticated clients from reaching the internet, regardless of what -/// TollGate itself is configured to charge. +/// Install NoDogSplash and immediately stop it, before configuring anything. /// -/// Gates the dedicated `br-tollgate` bridge (see `wifi::provision_network`), -/// not `br-lan` — the paid SSID here lives on its own isolated network/subnet -/// rather than the canonical upstream layout where it's bridged into `lan`. -pub fn provision(router: &Router, pkg_mgr: PkgManager, cfg: &TollGateConfig) -> Result<()> { +/// The OpenWrt package's postinst auto-enables and starts nodogsplash on +/// install using its stock default config — critically, `gatewayinterface` +/// defaults to `br-lan`. On a fresh install that window is real: NoDogSplash +/// only manages IPv4 iptables, so anything plugged into `br-lan` (e.g. an +/// admin's own management box) silently loses IPv4 connectivity (DHCP still +/// listens, but the gate blocks the client until ndsctl authorizes its MAC) +/// until we get a chance to repoint it — a full network re-scan can take +/// long enough for that to matter. Stopping it right after install, before +/// `configure()` ever runs, closes that window as early as possible. +/// +/// `tollgate-wrt` delegates all MAC authorization and gate open/close to +/// NoDogSplash via `ndsctl` — it has no firewall/netfilter code of its own +/// (confirmed: its binary has no `nft`/`ipset`/`iptables` calls at all). +/// Upstream's package therefore hard-depends on `+nodogsplash`, but neither +/// of our install paths (see `tollgate::install`) pull it in automatically. +pub fn install_and_stop(router: &Router, pkg_mgr: PkgManager) -> Result<()> { router .install_package(pkg_mgr, "nodogsplash") .context("install nodogsplash — required by tollgate-wrt for client gating")?; + router.run_ok("/etc/init.d/nodogsplash stop || true")?; + Ok(()) +} +/// Configure NoDogSplash to gate the dedicated `br-tollgate` bridge (see +/// `wifi::provision_network`), not `br-lan` — the paid SSID here lives on its +/// own isolated network/subnet rather than the canonical upstream layout +/// where it's bridged into `lan`. +/// +/// Must run after `wifi::provision_ssid` has created `br-tollgate` — pointing +/// `gatewayinterface` at a bridge that doesn't exist yet is at best a no-op +/// and at worst leaves NoDogSplash in a confused state. +pub fn configure(router: &Router, cfg: &TollGateConfig) -> Result<()> { router.run_ok("touch /etc/config/nodogsplash")?; router.uci_set("nodogsplash.main", "nodogsplash")?; router.uci_set("nodogsplash.main.enabled", "1")?; From abe03a57028543edc24bbd91ba230615d4803773 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Thu, 2 Jul 2026 20:21:32 +0000 Subject: [PATCH 3/8] fix(tollgate): remove stray nodogsplash section, fix netifd claim race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more issues found deploying the previous two commits live: 1. The nodogsplash package's own uci-defaults populate an anonymous @nodogsplash[0] section pointed at br-lan (see install_and_stop's doc comment). NoDogSplash runs one gateway instance per config section, so this ran alongside our own nodogsplash.main instead of being superseded by it — silently re-gating br-lan. configure() now deletes it. 2. After `network restart` + `wifi down/up`, netifd intermittently loses the race to claim br-tollgate as the wifi vif attaches (reports up:false, DEVICE_CLAIM_FAILED) even though the bridge device and member interface both exist correctly. NoDogSplash refuses to start against an interface netifd hasn't brought up. restart_services() now explicitly cycles just the tollgate interface (ifdown/ifup) after the wifi restart. --- core/openwrt/src/tollgate/mod.rs | 7 +++++++ core/openwrt/src/tollgate/nodogsplash.rs | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/core/openwrt/src/tollgate/mod.rs b/core/openwrt/src/tollgate/mod.rs index 9f9e3ab0..c05d96a4 100644 --- a/core/openwrt/src/tollgate/mod.rs +++ b/core/openwrt/src/tollgate/mod.rs @@ -86,5 +86,12 @@ fn restart_services(router: &Router, enabled: bool) -> Result<()> { // Reload wireless so wireless.tollgate.disabled takes effect on the radio — // `network restart` alone doesn't reliably reconfigure wifi interfaces. router.run_ok("wifi down 2>&1; wifi up 2>&1")?; + // Observed live: netifd can lose the race to claim br-tollgate as the + // wifi vif attaches to it during the restart above, leaving the + // interface reported "up: false, DEVICE_CLAIM_FAILED" even though the + // bridge device and its member both exist. An explicit down/up of just + // this logical interface resolves it — needed before NoDogSplash starts, + // since it refuses to start against an interface netifd hasn't brought up. + router.run_ok("sleep 2; ifdown tollgate 2>&1; sleep 1; ifup tollgate 2>&1; sleep 2")?; Ok(()) } diff --git a/core/openwrt/src/tollgate/nodogsplash.rs b/core/openwrt/src/tollgate/nodogsplash.rs index 06025bf4..de040e32 100644 --- a/core/openwrt/src/tollgate/nodogsplash.rs +++ b/core/openwrt/src/tollgate/nodogsplash.rs @@ -39,6 +39,16 @@ pub fn install_and_stop(router: &Router, pkg_mgr: PkgManager) -> Result<()> { /// and at worst leaves NoDogSplash in a confused state. pub fn configure(router: &Router, cfg: &TollGateConfig) -> Result<()> { router.run_ok("touch /etc/config/nodogsplash")?; + + // The nodogsplash package's own uci-defaults populate an anonymous + // `@nodogsplash[0]` section on first install, pointed at `br-lan` (its + // stock default — see `install_and_stop`). NoDogSplash supports multiple + // simultaneous gateway instances, one per config section, so leaving this + // in place alongside our own `nodogsplash.main` doesn't get overridden by + // it — it starts a *second* instance gating br-lan for real. Delete it; + // `main` is the only instance this project manages. + let _ = router.uci_delete("nodogsplash.@nodogsplash[0]"); + router.uci_set("nodogsplash.main", "nodogsplash")?; router.uci_set("nodogsplash.main.enabled", "1")?; router.uci_set("nodogsplash.main.gatewayinterface", "br-tollgate")?; From c1f191128fab1d65a935ba0c2039bd8241831bf8 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Thu, 2 Jul 2026 20:51:27 +0000 Subject: [PATCH 4/8] fix(tollgate): restore DHCP/DNS in nodogsplash's pre-auth walled garden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found live: new clients on the archipelago SSID couldn't get an IP address at all. configure()'s users_to_router rebuild replaced the nodogsplash package's stock default list (DNS, DHCP, SSH/Telnet to the router) with only our two TollGate-specific ports (2121, 2050) — dropping `allow udp port 67`, so DHCP DISCOVER from an unauthenticated client hit ndsRTR's default REJECT before ever reaching dnsmasq. Carries over DNS (53) and DHCP (67/udp) from the stock default — without them a client can't get an IP or resolve anything before authenticating. Deliberately does not carry over SSH/Telnet (22/23): the stock default exposes router shell access to every unauthenticated device on the network, which isn't an appropriate default for a public pay-as-you-go hotspot. --- core/openwrt/src/tollgate/nodogsplash.rs | 25 +++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/core/openwrt/src/tollgate/nodogsplash.rs b/core/openwrt/src/tollgate/nodogsplash.rs index de040e32..887cb144 100644 --- a/core/openwrt/src/tollgate/nodogsplash.rs +++ b/core/openwrt/src/tollgate/nodogsplash.rs @@ -56,12 +56,27 @@ pub fn configure(router: &Router, cfg: &TollGateConfig) -> Result<()> { router.uci_set("nodogsplash.main.gatewaydomainname", "TollGate.lan")?; router.uci_set("nodogsplash.main.gatewayport", "2050")?; - // Pre-auth "walled garden": unauthenticated clients must still be able to - // reach the TollGate payment endpoint (2121) and the splash portal itself - // (2050) — everything else is blocked by NoDogSplash until ndsctl - // authorizes their MAC. Rebuild the list each run rather than trying to - // dedupe, so repeated provisioning stays idempotent. + // Pre-auth "walled garden": traffic an unauthenticated client must still + // reach before ndsctl authorizes their MAC. `uci_delete` + rebuild (rather + // than only adding our own entries) is deliberate — the stock package + // config ships a `users_to_router` default of its own (DNS, DHCP, plus + // SSH/Telnet to the router), and `uci_set`/`add_list` on an existing + // *named* section does not clear an inherited default list, so without + // an explicit delete first, re-provisioning would silently keep + // whatever was there before. + // + // DNS (53) and DHCP (67, udp) are carried over from that stock default — + // without them a client can't even get an IP or resolve the portal + // domain before authenticating (confirmed live: omitting udp/67 here + // broke DHCP entirely for new clients on the archipelago SSID). 2121 + // (TollGate payment) and 2050 (NDS's own splash portal) are ours. + // SSH/Telnet (22/23) are deliberately *not* carried over — the stock + // default exposes router shell access to every unauthenticated device + // on a public pay-as-you-go network, which is a bad default here. let _ = router.uci_delete("nodogsplash.main.users_to_router"); + router.uci_add_list("nodogsplash.main.users_to_router", "allow udp port 53")?; + router.uci_add_list("nodogsplash.main.users_to_router", "allow tcp port 53")?; + router.uci_add_list("nodogsplash.main.users_to_router", "allow udp port 67")?; router.uci_add_list("nodogsplash.main.users_to_router", "allow tcp port 2121")?; router.uci_add_list("nodogsplash.main.users_to_router", "allow tcp port 2050")?; From 6060ad23b24020a55ec85ab89d27cbbe357e4166 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Thu, 2 Jul 2026 21:54:43 +0000 Subject: [PATCH 5/8] fix(tollgate): verify br-tollgate's kernel-level IP after restart, retry if missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found live again, in a different shape: after a later round of service restarts (dnsmasq restart while debugging), br-tollgate desynced a second time — but this time netifd's own status reported the interface up with 192.168.99.1 assigned, while `ip -4 addr show br-tollgate` was genuinely empty at the kernel level. dnsmasq logged "DHCP packet received on br-tollgate which has no address" and silently dropped every DISCOVER — clients associated to the archipelago SSID fine but never got an IP. A single blind ifdown/ifup (the previous fix) isn't trustworthy against this netifd race — replace it with a loop that checks the actual kernel address after each cycle and retries up to 5 times, failing loudly (rather than silently leaving DHCP broken) if it never converges. --- core/openwrt/src/tollgate/mod.rs | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/core/openwrt/src/tollgate/mod.rs b/core/openwrt/src/tollgate/mod.rs index c05d96a4..8017f8f0 100644 --- a/core/openwrt/src/tollgate/mod.rs +++ b/core/openwrt/src/tollgate/mod.rs @@ -86,12 +86,27 @@ fn restart_services(router: &Router, enabled: bool) -> Result<()> { // Reload wireless so wireless.tollgate.disabled takes effect on the radio — // `network restart` alone doesn't reliably reconfigure wifi interfaces. router.run_ok("wifi down 2>&1; wifi up 2>&1")?; - // Observed live: netifd can lose the race to claim br-tollgate as the - // wifi vif attaches to it during the restart above, leaving the - // interface reported "up: false, DEVICE_CLAIM_FAILED" even though the - // bridge device and its member both exist. An explicit down/up of just - // this logical interface resolves it — needed before NoDogSplash starts, - // since it refuses to start against an interface netifd hasn't brought up. - router.run_ok("sleep 2; ifdown tollgate 2>&1; sleep 1; ifup tollgate 2>&1; sleep 2")?; + // Observed live, twice, in two different ways: netifd can lose the race + // to claim br-tollgate as the wifi vif attaches to it during the restart + // above. The first time it showed up as netifd reporting + // "up: false, DEVICE_CLAIM_FAILED"; the second time netifd reported the + // interface up with its address assigned while the kernel-level device + // genuinely had none (`ip -4 addr show br-tollgate` empty) — dnsmasq + // logged "DHCP packet received on br-tollgate which has no address" and + // silently dropped every DISCOVER. A single blind ifdown/ifup isn't + // trustworthy here — verify the address actually landed at the kernel + // level (not just what netifd claims) and retry the cycle if not, since + // NoDogSplash refuses to start against an interface that isn't really up + // and dnsmasq will silently refuse to answer DHCP without erroring loudly. + router.run_ok( + "sleep 2; \ + for i in 1 2 3 4 5; do \ + ifdown tollgate 2>&1; sleep 1; ifup tollgate 2>&1; sleep 2; \ + ip -4 addr show br-tollgate 2>/dev/null | grep -q 'inet ' && break; \ + echo \"br-tollgate has no kernel-level IPv4 address after cycle $i, retrying\"; \ + done; \ + ip -4 addr show br-tollgate 2>/dev/null | grep -q 'inet ' || \ + { echo 'br-tollgate never got a kernel-level IPv4 address after 5 cycles'; exit 1; }" + )?; Ok(()) } From 9902ffd31d3914bca2932cb58a4ad100ae2733cd Mon Sep 17 00:00:00 2001 From: ssmithx Date: Thu, 2 Jul 2026 22:56:53 +0000 Subject: [PATCH 6/8] fix(tollgate): wire up TollGate's real captive portal (was serving NDS's stock page) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found live: after fixing DHCP, the user could join the archipelago SSID, saw a splash page, clicked "Continue", and got straight to the internet — no payment step at all. NoDogSplash was serving its own bundled generic click-to-continue splash page, whose "Continue" button calls NDS's built-in auth handler directly and authorizes the client unconditionally. TollGate's actual payment UI — a React SPA with a Cashu/QR token entry flow — was already sitting on disk at /etc/tollgate/tollgate-captive-portal-site (staged by the .ipk's data payload during install), just never wired up as NoDogSplash's webroot. install_captive_portal_symlink() mirrors upstream's own 90-tollgate-captive-portal-symlink uci-defaults script exactly: swap /etc/nodogsplash/htdocs for a symlink to the real portal directory, backing up any existing real directory first. Confirmed live that setting `option webroot` directly instead (rather than the symlink swap) makes NoDogSplash 500 on every request for reasons not fully understood — the symlink approach is what's actually shipped/tested upstream, so that's what this uses. Also restores `authenticated_users 'allow all'` (the stock package default our from-scratch nodogsplash.main section never carried over) for correctness, even though this router's default-ACCEPT FORWARD policy happens to make an empty list behave the same. --- core/openwrt/src/tollgate/mod.rs | 9 +++++ core/openwrt/src/tollgate/nodogsplash.rs | 47 ++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/core/openwrt/src/tollgate/mod.rs b/core/openwrt/src/tollgate/mod.rs index 8017f8f0..d92b1c55 100644 --- a/core/openwrt/src/tollgate/mod.rs +++ b/core/openwrt/src/tollgate/mod.rs @@ -48,6 +48,15 @@ pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> { nodogsplash::install_and_stop(router, pkg_mgr) .context("install nodogsplash — tollgate-wrt cannot gate clients without it")?; + // Wire NoDogSplash's webroot to TollGate's actual payment portal instead + // of the generic stock splash page it ships with. Confirmed live: without + // this, "click continue" on the stock page authorizes the client via + // NoDogSplash's own built-in handler with zero payment involved. + nodogsplash::install_captive_portal_symlink(router).context( + "wire up TollGate's captive portal — without it NoDogSplash serves its own \ + generic splash page, which authorizes clients on click with no payment", + )?; + config::apply(router, config)?; wifi::provision_ssid(router, config)?; // Must come after provision_ssid (which creates br-tollgate) and before diff --git a/core/openwrt/src/tollgate/nodogsplash.rs b/core/openwrt/src/tollgate/nodogsplash.rs index 887cb144..82bafed4 100644 --- a/core/openwrt/src/tollgate/nodogsplash.rs +++ b/core/openwrt/src/tollgate/nodogsplash.rs @@ -29,6 +29,45 @@ pub fn install_and_stop(router: &Router, pkg_mgr: PkgManager) -> Result<()> { Ok(()) } +/// Point NoDogSplash's webroot at TollGate's own splash page instead of the +/// generic stock one NoDogSplash ships with. +/// +/// Confirmed live: without this, NoDogSplash serves its own bundled +/// click-to-continue splash page — clicking "Continue" calls NDS's built-in +/// auth handler directly and authorizes the client with zero payment +/// involved. TollGate's actual payment UI (a QR/Cashu-token entry SPA) lives +/// at `/etc/tollgate/tollgate-captive-portal-site` — the .ipk's data payload +/// stages it there (see `packaging/files/tollgate-captive-portal-site/` in +/// the upstream repo), it's just never wired up as NoDogSplash's webroot. +/// +/// Mirrors upstream's own `90-tollgate-captive-portal-symlink` uci-defaults +/// script exactly (symlink swap, not a `webroot` UCI override) — confirmed +/// live that setting `option webroot` directly instead causes NoDogSplash to +/// 500 on every request, for reasons not fully understood (worth filing +/// upstream, but the symlink approach is what's actually shipped/tested). +pub fn install_captive_portal_symlink(router: &Router) -> Result<()> { + let (_, exists) = router.run("test -d /etc/tollgate/tollgate-captive-portal-site")?; + if exists != 0 { + anyhow::bail!( + "/etc/tollgate/tollgate-captive-portal-site missing — expected to be staged \ + by the tollgate-wrt package install" + ); + } + + router.run_ok( + "if [ -L /etc/nodogsplash/htdocs ]; then \ + true; \ + else \ + if [ -d /etc/nodogsplash/htdocs ]; then \ + mv /etc/nodogsplash/htdocs /etc/nodogsplash/htdocs.backup; \ + fi; \ + rm -rf /etc/nodogsplash/htdocs; \ + ln -sf /etc/tollgate/tollgate-captive-portal-site /etc/nodogsplash/htdocs; \ + fi" + )?; + Ok(()) +} + /// Configure NoDogSplash to gate the dedicated `br-tollgate` bridge (see /// `wifi::provision_network`), not `br-lan` — the paid SSID here lives on its /// own isolated network/subnet rather than the canonical upstream layout @@ -80,6 +119,14 @@ pub fn configure(router: &Router, cfg: &TollGateConfig) -> Result<()> { router.uci_add_list("nodogsplash.main.users_to_router", "allow tcp port 2121")?; router.uci_add_list("nodogsplash.main.users_to_router", "allow tcp port 2050")?; + // Post-auth (paid) clients get full access — matches the stock package + // default (`list authenticated_users 'allow all'`), which our from-scratch + // named section never carried over. Under this router's default-ACCEPT + // FORWARD policy an empty list happens to behave the same, but that's an + // accident of this specific setup, not something to depend on. + let _ = router.uci_delete("nodogsplash.main.authenticated_users"); + router.uci_add_list("nodogsplash.main.authenticated_users", "allow all")?; + router.uci_commit(Some("nodogsplash"))?; Ok(()) } From 8f47d6608a4368abea3ddc308751c170091d6bd0 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Thu, 2 Jul 2026 23:55:15 +0000 Subject: [PATCH 7/8] feat(tollgate): periodically sweep TollGate's router wallet into the local wallet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tollgate-wrt keeps its own separate Cashu wallet on the router (/etc/tollgate/wallet.db) — customer payments land there, never in this node's own wallet. Its Lightning auto-payout is configured independently in /etc/tollgate/identities.json, which is easy to leave misconfigured or pointed at a placeholder address (found live: both "owner" and "developer" identities on this deployment pointed at the same unconfigured tollgate@minibits.cash default). Rather than depend on getting that Lightning payout config right, tollgate_sweep::sweep_once() periodically (every 5 min, via SSH) checks the router's `tollgate wallet balance`, and if nonzero, runs `tollgate wallet drain cashu` and receives the resulting token(s) straight into the local wallet via the same path the "Receive ecash" UI uses (wallet::ecash::receive_token) — bypassing Lightning payout entirely. A no-op (Ok(0)) if no router is configured or it doesn't have TollGate installed. --- core/archipelago/src/main.rs | 1 + core/archipelago/src/server.rs | 22 +++++++ core/archipelago/src/tollgate_sweep.rs | 81 ++++++++++++++++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 core/archipelago/src/tollgate_sweep.rs diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index 23805fb9..a6567e65 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -71,6 +71,7 @@ mod state; mod storage_crypto; mod streaming; mod swarm; +mod tollgate_sweep; mod totp; mod transport; mod trust; diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index 093557c4..445d4929 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -534,6 +534,28 @@ impl Server { }); } + // Periodic TollGate ecash sweep. tollgate-wrt keeps its own separate + // Cashu wallet on the router — customer payments never land in this + // node's wallet on their own, and its Lightning auto-payout config is + // independent and easy to leave misconfigured. This drains whatever + // TollGate has collected straight into the local wallet on a timer, + // sidestepping Lightning payout configuration entirely. + { + let data_dir = config.data_dir.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(30)).await; + let mut interval = tokio::time::interval(Duration::from_secs(300)); + loop { + interval.tick().await; + match crate::tollgate_sweep::sweep_once(&data_dir).await { + Ok(0) => {} + Ok(swept) => info!(sats = swept, "tollgate wallet sweep complete"), + Err(e) => debug!(error = %e, "tollgate wallet sweep (non-fatal)"), + } + } + }); + } + // Initialize container scanner — discovers installed apps from Podman/Docker { let scanner = create_docker_scanner(&config).await?; diff --git a/core/archipelago/src/tollgate_sweep.rs b/core/archipelago/src/tollgate_sweep.rs new file mode 100644 index 00000000..ed4aa878 --- /dev/null +++ b/core/archipelago/src/tollgate_sweep.rs @@ -0,0 +1,81 @@ +use std::path::Path; + +use anyhow::{Context, Result}; +use archipelago_openwrt::router::Router; +use tracing::{info, warn}; + +use crate::network::router as net_router; +use crate::wallet::ecash; + +/// Pull whatever TollGate has collected in its on-router Cashu wallet into +/// this node's own wallet. +/// +/// `tollgate-wrt` keeps a completely separate Cashu wallet on the router +/// (`/etc/tollgate/wallet.db`) — customer payments land there, never in this +/// node's wallet directly. Its own Lightning auto-payout is configured +/// independently in `/etc/tollgate/identities.json`, which is easy to leave +/// pointed at the wrong (or a placeholder) address and easy to lose track of. +/// This sweep sidesteps that entirely: periodically drain the router's +/// TollGate wallet to Cashu tokens (`tollgate wallet drain cashu`, over SSH) +/// and receive them straight into the local wallet via the same path the +/// "Receive ecash" UI uses. +/// +/// Returns the total sats swept in (0 if there was nothing to do, including +/// when no router is configured or it doesn't have TollGate installed). +pub async fn sweep_once(data_dir: &Path) -> Result { + let cfg = net_router::load_router_config(data_dir).await?; + if !cfg.configured { + return Ok(0); + } + let ssh_user = cfg.username.clone().unwrap_or_else(|| "root".to_string()); + let ssh_password = cfg.password.clone().unwrap_or_default(); + + let router = Router::connect_password(&cfg.address, 22, &ssh_user, &ssh_password) + .context("connect to router for tollgate wallet sweep")?; + + // `tollgate` CLI missing (no TollGate installed here) is a normal, + // expected case, not an error — just nothing to sweep. + let (balance_out, code) = router.run("tollgate wallet balance 2>/dev/null")?; + if code != 0 { + return Ok(0); + } + let balance_sats = balance_out + .lines() + .find_map(|l| l.trim().strip_prefix("balance_sats:")) + .and_then(|v| v.trim().parse::().ok()) + .unwrap_or(0); + if balance_sats == 0 { + return Ok(0); + } + + let (drain_out, drain_code) = router.run("tollgate wallet drain cashu 2>&1")?; + if drain_code != 0 { + anyhow::bail!("tollgate wallet drain cashu failed: {}", drain_out.trim()); + } + + // The CLI has no machine-readable output mode; pull tokens out of its + // " Token: cashuB..." lines. + let mut received_total = 0u64; + for line in drain_out.lines() { + let Some(token) = line.trim().strip_prefix("Token:") else { + continue; + }; + let token = token.trim(); + if token.is_empty() { + continue; + } + match ecash::receive_token(data_dir, token).await { + Ok(amount) => { + received_total += amount; + info!(amount_sats = amount, "swept TollGate ecash into local wallet"); + } + Err(e) => { + // The token is still in this log line if this happens — not + // silently lost, just needs manual `wallet.ecash-receive`. + warn!(error = %e, token, "failed to receive swept TollGate token"); + } + } + } + + Ok(received_total) +} From d99f4438d8e73749be152f6987c0d0b0db3cbdee Mon Sep 17 00:00:00 2001 From: ssmithx Date: Fri, 3 Jul 2026 02:46:48 +0000 Subject: [PATCH 8/8] fix(wallet): show Cashu/Fedimint receives in the Transactions modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found live: after receiving a TollGate Cashu payment into the wallet, the balance correctly showed the new sats (wallet.ecash-balance), but the Transactions modal said "no transactions yet." Home.vue's loadWeb5Status() only ever fetched lnd.gettransactions — ecash and Fedimint activity (wallet.ecash-history, which already unifies both) was never wired into walletTransactions at all, so no Cashu or Fedimint receive could ever show up there regardless of how long you waited. Maps EcashTransaction into the existing (LND-shaped) WalletTransaction interface rather than widening that type, and merges+sorts both lists by timestamp. --- neode-ui/src/views/Home.vue | 39 ++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/neode-ui/src/views/Home.vue b/neode-ui/src/views/Home.vue index d733404e..245988bb 100644 --- a/neode-ui/src/views/Home.vue +++ b/neode-ui/src/views/Home.vue @@ -519,6 +519,35 @@ const walletTransactions = ref([]) function openInMempool(txHash: string) { router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } }) } +// wallet.ecash-history's shape (see handle_wallet_ecash_history in +// api/rpc/wallet.rs) — distinct from the LND-shaped WalletTransaction used +// elsewhere in this file, so it's mapped into that shape below rather than +// widening WalletTransaction itself with a pile of ecash-only fields. +interface EcashTransaction { + id: string + tx_type: 'send' | 'receive' + amount_sats: number + timestamp: string + description: string + mint_url: string + peer: string + kind: 'cashu' | 'fedimint' +} + +function ecashToWalletTransaction(tx: EcashTransaction): WalletTransaction { + return { + tx_hash: tx.id, + amount_sats: tx.amount_sats, + direction: tx.tx_type === 'receive' ? 'incoming' : 'outgoing', + num_confirmations: 1, + time_stamp: Math.floor(new Date(tx.timestamp).getTime() / 1000), + total_fees: 0, + dest_addresses: [], + label: tx.description, + block_height: 0, + } +} + async function loadWeb5Status() { // A transient RPC timeout must NOT flash the balance to 0 ("wallet says 0 when // there is a balance"). On failure keep the last-known value — the refs start @@ -526,7 +555,15 @@ async function loadWeb5Status() { try { const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 }); walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true } catch { walletConnected.value = false } try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 }); walletEcash.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ } try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 }); walletFedimint.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ } - try { const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 }); walletTransactions.value = res.transactions || [] } catch { /* keep last-known transactions */ } + // Merge LND transactions with ecash/Fedimint history (wallet.ecash-history + // already unifies both) — previously only LND transactions were fetched + // here, so any Cashu or Fedimint receive (e.g. a TollGate payment) never + // appeared in the Transactions modal even though the balance included it. + let lndTxs: WalletTransaction[] = [] + try { const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 }); lndTxs = res.transactions || [] } catch { /* keep last-known transactions */ } + let ecashTxs: WalletTransaction[] = [] + try { const res = await rpcClient.call<{ transactions: EcashTransaction[] }>({ method: 'wallet.ecash-history', timeout: 5000 }); ecashTxs = (res.transactions || []).map(ecashToWalletTransaction) } catch { /* keep last-known transactions */ } + walletTransactions.value = [...lndTxs, ...ecashTxs].sort((a, b) => b.time_stamp - a.time_stamp) } // System stats