diff --git a/core/archipelago/src/api/rpc/openwrt.rs b/core/archipelago/src/api/rpc/openwrt.rs index 72b271c7..19a4e49d 100644 --- a/core/archipelago/src/api/rpc/openwrt.rs +++ b/core/archipelago/src/api/rpc/openwrt.rs @@ -150,6 +150,7 @@ impl RpcHandler { "min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::().ok()).unwrap_or(1), "currency": router.uci_get("tollgate.main.currency").unwrap_or_default(), "mint_url": router.uci_get("tollgate.main.mint_url").unwrap_or_default(), + "payout_address":router.uci_get("tollgate.main.payout_address").unwrap_or_default(), }) } else { serde_json::json!({ "installed": false }) @@ -199,10 +200,15 @@ impl RpcHandler { /// /// Params: `{ "host": "192.168.1.1", "ssh_user": "root", "ssh_password": "", /// "price_sats": 10, "step_size_ms": 60000, "min_steps": 1, - /// "mint_url": "" }` + /// "mint_url": "", + /// "payout_address": "" }` /// /// `mint_url` defaults to `http://:3338` — the local Cashu /// mint that must be running as an Archy app before calling this endpoint. + /// + /// `payout_address` sets the "owner" identity's Lightning address for + /// TollGate's own built-in payout (see `config::apply_payout_identity`). + /// Omitted or blank leaves whatever's already on the router untouched. pub(super) async fn handle_openwrt_provision_tollgate( &self, params: Option, @@ -254,6 +260,17 @@ impl RpcHandler { .trim_end_matches('/') .to_string(); + // `None` (not sent, or sent blank) leaves whatever's already on the + // router untouched — see apply_payout_identity's doc comment for why + // that matters (an upstream-default placeholder otherwise survives + // forever, since nothing else ever writes this field). + let payout_address = p + .get("payout_address") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + let config = TollGateConfig { ssid: "archipelago".to_string(), mint_url, @@ -264,6 +281,7 @@ impl RpcHandler { .unwrap_or(60_000), min_steps: p.get("min_steps").and_then(|v| v.as_u64()).unwrap_or(1) as u32, enabled: p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true), + payout_address, }; // Blocking SSH session, and provision runs `opkg install` over it — diff --git a/core/openwrt/src/tollgate/config.rs b/core/openwrt/src/tollgate/config.rs index d6761d54..050771ef 100644 --- a/core/openwrt/src/tollgate/config.rs +++ b/core/openwrt/src/tollgate/config.rs @@ -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, } 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(()) +} diff --git a/core/openwrt/src/tollgate/mod.rs b/core/openwrt/src/tollgate/mod.rs index 71439e0b..ecd28f23 100644 --- a/core/openwrt/src/tollgate/mod.rs +++ b/core/openwrt/src/tollgate/mod.rs @@ -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) diff --git a/neode-ui/src/views/server/OpenWrtGateway.vue b/neode-ui/src/views/server/OpenWrtGateway.vue index 1905b02e..da6261b4 100644 --- a/neode-ui/src/views/server/OpenWrtGateway.vue +++ b/neode-ui/src/views/server/OpenWrtGateway.vue @@ -36,6 +36,7 @@ interface TollGateStatus { min_steps?: number currency?: string mint_url?: string + payout_address?: string } interface WanStatus { @@ -149,6 +150,7 @@ const editPriceSats = ref(10) const editStepSizeMin = ref(1) const editMinSteps = ref(1) const editMintUrl = ref('') +const editPayoutAddress = ref('') const editEnabled = ref(true) // WAN setup flow @@ -306,6 +308,7 @@ function startEditTollgate() { editStepSizeMin.value = Math.max(1, Math.round((tg?.step_size_ms ?? 60000) / 60000)) editMinSteps.value = tg?.min_steps ?? 1 editMintUrl.value = tg?.mint_url ?? '' + editPayoutAddress.value = tg?.payout_address ?? '' editEnabled.value = tg?.enabled ?? true updateTollgateError.value = '' editingTollgate.value = true @@ -321,6 +324,7 @@ async function saveTollgateConfig() { step_size_ms: editStepSizeMin.value * 60_000, min_steps: editMinSteps.value, mint_url: editMintUrl.value, + payout_address: editPayoutAddress.value, enabled: editEnabled.value, } await rpcClient.call({ method: 'openwrt.provision-tollgate', params, timeout: 300000 }) @@ -935,6 +939,21 @@ onMounted(() => { class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/30 focus:outline-none focus:border-white/40 transition-colors font-mono text-xs" /> +
+ + +

+ Where TollGate's built-in auto-payout sends your share once the on-router balance + crosses its threshold. Leave blank to keep whatever's already set on the router — + on a router not originally provisioned here, that may be an upstream default you + don't control. +

+