feat(tollgate): periodically sweep TollGate's router wallet into the local wallet

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.
This commit is contained in:
ssmithx 2026-07-02 23:55:15 +00:00
parent 9902ffd31d
commit 8f47d6608a
3 changed files with 104 additions and 0 deletions

View File

@ -71,6 +71,7 @@ mod state;
mod storage_crypto;
mod streaming;
mod swarm;
mod tollgate_sweep;
mod totp;
mod transport;
mod trust;

View File

@ -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?;

View File

@ -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<u64> {
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::<u64>().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)
}