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-08 21:06:36 -04:00
committed by archipelago
co-authored by Claude Sonnet 5
parent 87a5025341
commit f9af30b08a
4 changed files with 128 additions and 14 deletions
+19 -1
View File
@@ -150,6 +150,7 @@ impl RpcHandler {
"min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(1), "min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(1),
"currency": router.uci_get("tollgate.main.currency").unwrap_or_default(), "currency": router.uci_get("tollgate.main.currency").unwrap_or_default(),
"mint_url": router.uci_get("tollgate.main.mint_url").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 { } else {
serde_json::json!({ "installed": false }) serde_json::json!({ "installed": false })
@@ -199,10 +200,15 @@ impl RpcHandler {
/// ///
/// Params: `{ "host": "192.168.1.1", "ssh_user": "root", "ssh_password": "", /// Params: `{ "host": "192.168.1.1", "ssh_user": "root", "ssh_password": "",
/// "price_sats": 10, "step_size_ms": 60000, "min_steps": 1, /// "price_sats": 10, "step_size_ms": 60000, "min_steps": 1,
/// "mint_url": "<optional override>" }` /// "mint_url": "<optional override>",
/// "payout_address": "<optional Lightning address>" }`
/// ///
/// `mint_url` defaults to `http://<this node's IP>:3338` — the local Cashu /// `mint_url` defaults to `http://<this node's IP>:3338` — the local Cashu
/// mint that must be running as an Archy app before calling this endpoint. /// 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( pub(super) async fn handle_openwrt_provision_tollgate(
&self, &self,
params: Option<serde_json::Value>, params: Option<serde_json::Value>,
@@ -254,6 +260,17 @@ impl RpcHandler {
.trim_end_matches('/') .trim_end_matches('/')
.to_string(); .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 { let config = TollGateConfig {
ssid: "archipelago".to_string(), ssid: "archipelago".to_string(),
mint_url, mint_url,
@@ -264,6 +281,7 @@ impl RpcHandler {
.unwrap_or(60_000), .unwrap_or(60_000),
min_steps: p.get("min_steps").and_then(|v| v.as_u64()).unwrap_or(1) as u32, 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), 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 — // Blocking SSH session, and provision runs `opkg install` over it —
+88 -13
View File
@@ -23,6 +23,14 @@ pub struct TollGateConfig {
pub min_steps: u32, pub min_steps: u32,
/// Whether the TollGate service should be running and enabled at boot. /// Whether the TollGate service should be running and enabled at boot.
pub enabled: bool, 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 { impl Default for TollGateConfig {
@@ -34,6 +42,7 @@ impl Default for TollGateConfig {
step_size_ms: 60_000, step_size_ms: 60_000,
min_steps: 1, min_steps: 1,
enabled: true, 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 /// tollgate.main.enabled` etc.); changing pricing or the mint here has no
/// effect on what the daemon advertises or accepts. /// effect on what the daemon advertises or accepts.
pub fn apply(router: &Router, cfg: &TollGateConfig) -> Result<()> { pub fn apply(router: &Router, cfg: &TollGateConfig) -> Result<()> {
router.uci_apply( let step_size = cfg.step_size_ms.to_string();
"tollgate", let min_steps = cfg.min_steps.to_string();
&[ let price_sats = cfg.price_sats.to_string();
("tollgate.main", "tollgate"),
("tollgate.main.enabled", if cfg.enabled { "1" } else { "0" }), let mut pairs = vec![
("tollgate.main.metric", "milliseconds"), ("tollgate.main", "tollgate"),
("tollgate.main.step_size", &cfg.step_size_ms.to_string()), ("tollgate.main.enabled", if cfg.enabled { "1" } else { "0" }),
("tollgate.main.min_steps", &cfg.min_steps.to_string()), ("tollgate.main.metric", "milliseconds"),
("tollgate.main.price_per_step", &cfg.price_sats.to_string()), ("tollgate.main.step_size", step_size.as_str()),
("tollgate.main.currency", "sat"), ("tollgate.main.min_steps", min_steps.as_str()),
("tollgate.main.mint_url", &cfg.mint_url), ("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(()) Ok(())
} }
@@ -97,3 +114,61 @@ pub fn apply_daemon_config(router: &Router, cfg: &TollGateConfig) -> Result<()>
.context("upload /etc/tollgate/config.json")?; .context("upload /etc/tollgate/config.json")?;
Ok(()) 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. // the daemon restart below — config.json is only read at startup.
config::apply_daemon_config(router, config) config::apply_daemon_config(router, config)
.context("write /etc/tollgate/config.json — tollgate-wrt reads this, not UCI")?; .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 // Also must come after provision_ssid: points gatewayinterface at
// br-tollgate, which provision_ssid is what creates. // br-tollgate, which provision_ssid is what creates.
nodogsplash::configure(router, config) nodogsplash::configure(router, config)
@@ -36,6 +36,7 @@ interface TollGateStatus {
min_steps?: number min_steps?: number
currency?: string currency?: string
mint_url?: string mint_url?: string
payout_address?: string
} }
interface WanStatus { interface WanStatus {
@@ -149,6 +150,7 @@ const editPriceSats = ref(10)
const editStepSizeMin = ref(1) const editStepSizeMin = ref(1)
const editMinSteps = ref(1) const editMinSteps = ref(1)
const editMintUrl = ref('') const editMintUrl = ref('')
const editPayoutAddress = ref('')
const editEnabled = ref(true) const editEnabled = ref(true)
// WAN setup flow // WAN setup flow
@@ -306,6 +308,7 @@ function startEditTollgate() {
editStepSizeMin.value = Math.max(1, Math.round((tg?.step_size_ms ?? 60000) / 60000)) editStepSizeMin.value = Math.max(1, Math.round((tg?.step_size_ms ?? 60000) / 60000))
editMinSteps.value = tg?.min_steps ?? 1 editMinSteps.value = tg?.min_steps ?? 1
editMintUrl.value = tg?.mint_url ?? '' editMintUrl.value = tg?.mint_url ?? ''
editPayoutAddress.value = tg?.payout_address ?? ''
editEnabled.value = tg?.enabled ?? true editEnabled.value = tg?.enabled ?? true
updateTollgateError.value = '' updateTollgateError.value = ''
editingTollgate.value = true editingTollgate.value = true
@@ -321,6 +324,7 @@ async function saveTollgateConfig() {
step_size_ms: editStepSizeMin.value * 60_000, step_size_ms: editStepSizeMin.value * 60_000,
min_steps: editMinSteps.value, min_steps: editMinSteps.value,
mint_url: editMintUrl.value, mint_url: editMintUrl.value,
payout_address: editPayoutAddress.value,
enabled: editEnabled.value, enabled: editEnabled.value,
} }
await rpcClient.call({ method: 'openwrt.provision-tollgate', params, timeout: 300000 }) 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" 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"
/> />
</div> </div>
<div class="mb-4">
<label class="block text-xs text-white/40 mb-2">Payout Lightning address</label>
<input
v-model="editPayoutAddress"
type="text"
placeholder="you@yourwallet.example"
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"
/>
<p class="mt-2 text-xs text-white/40">
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.
</p>
</div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
:disabled="updatingTollgate" :disabled="updatingTollgate"