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:
committed by
archipelago
co-authored by
Claude Sonnet 5
parent
87a5025341
commit
f9af30b08a
@@ -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),
|
||||
"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": "<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 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<serde_json::Value>,
|
||||
@@ -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 —
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</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">
|
||||
<button
|
||||
:disabled="updatingTollgate"
|
||||
|
||||
Reference in New Issue
Block a user