feat(openwrt): make TollGate payout Lightning address configurable

Archipelago never touched /etc/tollgate/identities.json — the "owner"
payout identity was whatever the router's TollGate install happened to
default to. Confirmed live against archy-x250-pa3: an unmodified upstream
placeholder (tollgate@minibits.cash), meaning 79% of every customer payment
would auto-payout to an address the operator never chose and doesn't
control.

Adds TollGateConfig.payout_address (opt-in — None leaves the router
untouched), config::apply_payout_identity() to merge it into the "owner"
entry of identities.json without disturbing the merchant keypair or the
other profit-share identities, an RPC param on openwrt.provision-tollgate,
and a status field + reconfigure-form input in the OpenWrt Gateway panel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdMfVJChCwCCYF1ZTRQLc
This commit is contained in:
2026-09-07 20:23:19 +00:00
co-authored by Claude Sonnet 5
parent 2b2a14c569
commit 5f83fd1fee
4 changed files with 128 additions and 14 deletions
+88 -13
View File
@@ -23,6 +23,14 @@ pub struct TollGateConfig {
pub min_steps: u32,
/// Whether the TollGate service should be running and enabled at boot.
pub enabled: bool,
/// Operator's own Lightning address for the daemon's built-in payout
/// (the "owner" entry in `/etc/tollgate/identities.json`, `profit_share`
/// weight 0.79 in the upstream default). `None` leaves whatever is
/// already on the router untouched — which, on a router whose TollGate
/// wasn't provisioned through this project, is an unmodified upstream
/// placeholder nobody actually controls (confirmed live against
/// archy-x250-pa3 2026-09-07: shipped as `tollgate@minibits.cash`).
pub payout_address: Option<String>,
}
impl Default for TollGateConfig {
@@ -34,6 +42,7 @@ impl Default for TollGateConfig {
step_size_ms: 60_000,
min_steps: 1,
enabled: true,
payout_address: None,
}
}
}
@@ -46,19 +55,27 @@ impl Default for TollGateConfig {
/// 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",
&[
("tollgate.main", "tollgate"),
("tollgate.main.enabled", if cfg.enabled { "1" } else { "0" }),
("tollgate.main.metric", "milliseconds"),
("tollgate.main.step_size", &cfg.step_size_ms.to_string()),
("tollgate.main.min_steps", &cfg.min_steps.to_string()),
("tollgate.main.price_per_step", &cfg.price_sats.to_string()),
("tollgate.main.currency", "sat"),
("tollgate.main.mint_url", &cfg.mint_url),
],
)?;
let step_size = cfg.step_size_ms.to_string();
let min_steps = cfg.min_steps.to_string();
let price_sats = cfg.price_sats.to_string();
let mut pairs = vec![
("tollgate.main", "tollgate"),
("tollgate.main.enabled", if cfg.enabled { "1" } else { "0" }),
("tollgate.main.metric", "milliseconds"),
("tollgate.main.step_size", step_size.as_str()),
("tollgate.main.min_steps", min_steps.as_str()),
("tollgate.main.price_per_step", price_sats.as_str()),
("tollgate.main.currency", "sat"),
("tollgate.main.mint_url", &cfg.mint_url),
];
// Status-display only (see doc comment above) — only written when the
// caller actually supplied one, so a reconfigure that doesn't touch
// payout leaves whatever's already there alone.
if let Some(addr) = &cfg.payout_address {
pairs.push(("tollgate.main.payout_address", addr));
}
router.uci_apply("tollgate", &pairs)?;
Ok(())
}
@@ -97,3 +114,61 @@ pub fn apply_daemon_config(router: &Router, cfg: &TollGateConfig) -> Result<()>
.context("upload /etc/tollgate/config.json")?;
Ok(())
}
/// Set the operator's own payout Lightning address in
/// `/etc/tollgate/identities.json` — the "owner" entry under
/// `public_identities` (`profit_share` weight 0.79 in the upstream default;
/// the other entries there are revenue-share addresses for the upstream
/// project's own maintainers and must never be touched by this function).
///
/// No-op when `payout_address` is `None` — the UI only sends one when the
/// operator has actually filled the field in, so a reconfigure of price/mint
/// alone never overwrites this. Merges into whatever identities.json already
/// exists (same reasoning as `apply_daemon_config`: `owned_identities` holds
/// the merchant's own private key and must survive untouched); creates an
/// "owner" entry if none exists yet rather than erroring, since a router
/// whose TollGate wasn't provisioned through this project may have any
/// upstream-default shape here.
///
/// Must run before the daemon restart in `restart_services` — like
/// `config.json`, `tollgate-wrt` only reads `identities.json` at startup.
pub fn apply_payout_identity(router: &Router, payout_address: Option<&str>) -> Result<()> {
let Some(address) = payout_address else {
return Ok(());
};
let existing = router.run_ok("cat /etc/tollgate/identities.json 2>/dev/null || echo '{}'")?;
let mut doc: serde_json::Value =
serde_json::from_str(existing.trim()).unwrap_or_else(|_| serde_json::json!({}));
let identities = doc
.as_object_mut()
.context("identities.json root is not a JSON object")?
.entry("public_identities")
.or_insert_with(|| serde_json::json!([]));
let identities = identities
.as_array_mut()
.context("identities.json public_identities is not an array")?;
match identities
.iter_mut()
.find(|i| i.get("name").and_then(|n| n.as_str()) == Some("owner"))
{
Some(owner) => {
owner["lightning_address"] = serde_json::json!(address);
}
None => {
identities.push(serde_json::json!({
"name": "owner",
"pubkey": "not currently used",
"lightning_address": address,
}));
}
}
let json_str = serde_json::to_string_pretty(&doc).context("serialize identities.json")?;
router
.upload_file("/etc/tollgate/identities.json", json_str.as_bytes())
.context("upload /etc/tollgate/identities.json")?;
Ok(())
}
+2
View File
@@ -68,6 +68,8 @@ 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")?;
config::apply_payout_identity(router, config.payout_address.as_deref())
.context("write /etc/tollgate/identities.json owner payout address")?;
// Also must come after provision_ssid: points gatewayinterface at
// br-tollgate, which provision_ssid is what creates.
nodogsplash::configure(router, config)