Several OpenWrt routers, all broadcasting !TollGate with a captive portal. Paid credit follows the phone from router to router, only we can issue byte tokens, and everyone reaches the internet through one VPS.
Every router broadcasts the same open SSID, !TollGate, and runs its own captive portal (proxyd).
A user's balance is a purse of tokens held at the shared mint. Any router can draw from it, so the routers never talk to each other.
One mint, on the VPS. Its issuing endpoint is on loopback, used by us and by the market next to it. Free vouchers are off.
All traffic leaves through the VPS, so every phone has the same public address on every router. Identical LAN settings let a phone usually keep its local IP too.
10.99.0.1/24, one peer per router, listening only on fips0. NAT out to the internet. A DNS resolver on 10.99.0.1.!TollGate.dhcp-authoritative, so a roaming phone usually keeps its IP.tollgate-ip): gates, shapes and meters each phone on br-lan, as today. Settles channels at the VPS mint.Issuing. mintd runs with auto_accept: false, so the portal shows no "Get free tokens" option and nobody can mint from the public listener. We issue byte tokens over an SSH tunnel to the private listener and hand them out as text or QR codes. Users paste or scan them on the portal's Pay screen.
Selling. If a router's portal offers Lightning, proxyd pays the invoice at a sat mint (market.lightning_mint), buys byte tokens from the VPS market, and puts them into the user's purse. Sats tokens from accepted mints follow the same route. Either way, the sats end up in merchantd's wallet on the VPS.
Routers do not need merchantd. tollgated uses merchantd only to buy from an upstream (fund) and to keep tokens from other mints (deposit). The routers have no upstream to pay, and channel funds in the mint's own tokens are always burned at settlement.
Router float. When a phone leaves, its router refunds unused credit into the purse, including credit tollgated already holds as budget. That part comes from proxyd's wallet. We top each router's float up by minting byte tokens, which costs us nothing because they are our own unit.
The mint's URL. Every token names its mint by URL, so changing it later strands tokens already handed out.
https://mint.example.net), behind TLS on the VPS. Routers reach it through the tunnel, and users can keep tokens you give them in any Cashu wallet. Recommended.http://<vps-npub>.fips:3338. Reachable only over the mesh, so tokens can't sit in an ordinary wallet.A Cashu token can't be redeemed twice, so a user can't paste the same token again at the next router. Instead, a payment becomes a purse: tokens whose secrets and blinding factors are derived from a seed S (NUT-13). Anyone holding S can ask the mint to restore those tokens (NUT-09) and see which are still unspent (NUT-07). The unspent ones are the user's balance. tollgate-net already uses NUT-09 to recover a Spilman channel's change.
S = HMAC(fleet_secret, MAC), so every router can derive a phone's purse as soon as the phone associates. The portal also shows S as a receipt (QR or phrase) so the user can recover it if the phone changes its MAC.
S, rather than into the router's wallet.S, and forget the session key.HMAC(S, router_npub), so two routers never create the same output. Restore scans every router's path; the list of routers is fixed.On each router, both ends of every channel are ours: proxyd pays and tollgated receives. Channels only ever lock tokens that router already pulled from the purse. What the user is owed is always credit − what tollgated drew, wherever the tokens currently sit.
Deleting the session key means nobody can reclaim the budget tollgated holds for that phone. It expires within the hour, and its tokens are burned on settlement. Unsigned channel remainders return to proxyd's wallet when the channel closes or its refund time passes.
| Event | What a user can lose |
|---|---|
| Normal roam (old router sees the disconnect and refunds) | Nothing |
| Old router crashes or loses power before refunding | At most one chunk plus roughly two channels' worth, plus the budget tollgated held. Smaller chunks and a shorter channel_secs shrink this. |
| Same purse used at two routers at once | Nothing for the user. The mint lets only one pull win; double use is limited to what was already pulled. |
| Phone changes its MAC | Nothing, if the user pastes or scans the receipt. Without it, the purse waits at the mint. |
The phone joins the open !TollGate SSID. It is gated: DNS and the portal work, nothing else. The OS's captive-portal check opens the portal page.
The user pastes or scans a token we gave them, or pays by Lightning. proxyd swaps the value into the phone's purse S and shows the receipt. The user picks a speed or "Pay for what you use".
proxyd pulls a chunk from the purse and funds a channel to its own tollgated. tollgated carries the phone's traffic from br-lan to wg0, over FIPS to the VPS, and out through NAT.
S, and forgets the key.S from its MAC, restores the purse, checks which tokens are unspent, pulls a chunk, and puts the phone online with no portal.The purse is empty, so the phone is gated and kicked off the Wi-Fi, and the portal comes back. Each router only kicks phones on its own Wi-Fi, which the existing kick-on-expiry code already does.
We open an SSH tunnel to the VPS's private listener, mint byte tokens, and hand them out. Only processes on the VPS can reach that listener.
| State | Where | Lost if |
|---|---|---|
| A user's balance | Purse S at the mint | Not lost while the mint survives: the mint is the authority |
| Pulled chunk, open channels, held budget | The router serving the phone | That router crashes before refunding (see the table above) |
| Router float | Each router's proxyd wallet | The router is lost. Keep the float small. |
| Sat revenue | merchantd's wallet on the VPS | The VPS and its backups are lost |
| Mint keys and spent-token record | VPS: mint.seed, mint.sqlite | Everything. Without the seed, every outstanding token is void; without the record, spent tokens redeem again. Back both up off-site. |
fleet_secret | Every router | A compromised router can drain any purse (it cannot mint) |
Each router runs WireGuard to the VPS, with the VPS's FIPS fd address as the endpoint. Paid traffic stays on the kernel's forward path (br-lan to wg0), so tollgated's ip enforcer gates, shapes and meters it exactly as it does today. UDP works, so QUIC, calls and games are fine. exitd's SOCKS5 proxy would not do here: it carries TCP only.
Tunnel addresses are 10.99.0.0/24: the VPS is .1, router N is .N. Every router can keep 192.168.1.0/24 on its LAN because each one masquerades into wg0.
# /etc/config/network
config interface 'wg0'
option proto 'wireguard'
option private_key '<router privkey>'
list addresses '10.99.0.N/24'
option mtu '<fips0 mtu - 80>'
config wireguard_wg0
option public_key '<vps pubkey>'
option endpoint_host '<vps fd address>'
option endpoint_port '51820'
list allowed_ips '0.0.0.0/0'
option route_allowed_ips '0'
option persistent_keepalive '25'
config route
option interface 'wg0'
option target '0.0.0.0/0'
option table '200'
config route # kill switch
option type 'unreachable'
option target '0.0.0.0/0'
option table '200'
option metric '1000'
config rule # forwarded LAN traffic only
option in 'lan'
option lookup '200'
option priority '30000'
# /etc/config/firewall: zone 'tun' on wg0
# masq 1, mtu_fix 1, forwarding lan -> tun
# remove the stock lan -> wan forwarding
# dnsmasq: server=10.99.0.1, no-resolv
# /etc/wireguard/wg0.conf
[Interface]
Address = 10.99.0.1/24
ListenPort = 51820
PrivateKey = <vps privkey>
MTU = <fips0 mtu - 80>
[Peer] # one per router
PublicKey = <router N pubkey>
AllowedIPs = 10.99.0.N/32
# sysctl: net.ipv4.ip_forward=1
# nft: oifname "eth0" ip saddr 10.99.0.0/24 masquerade
# plus an MSS clamp on forwarded traffic
# firewall: udp/51820 accepted only on fips0
# resolver listening on 10.99.0.1
The rule matches in 'lan', not the source address. proxyd sends packets from the router with a phone's IP as the source, and tollgated's replies to them use fwmark 0x1 lookup 100. Both start on the router itself, so they never match a rule for traffic arriving from the LAN.
wg-exit-setup script could match fips-exit-setup)mint.url, mint.local and market.url pointed at the VPSauto_accept: falsepay, budget): redeem into S, pull chunks, restore and check statefleet_secret in the configuration; receipt on the portal| Risk | Effect | Mitigation |
|---|---|---|
| VPS down | No internet anywhere. The portal still loads but nobody can pay, roam or browse. | Monitoring; a standby VPS with the same WireGuard keys and a mint restored from backup |
| Mint down, exit up | Phones browse until their current chunk runs out; no payments or roaming | Size chunks to cover a short outage |
| MAC spoofing | An attacker who copies a phone's MAC can use its purse | Same trust level as today's IP-and-MAC identity; receipts let a user move a purse if needed |
| Compromised router | Can drain purses using fleet_secret, and its float | Cannot mint: the private listener stays on the VPS. Rotate fleet_secret fleet-wide. |
| Shared public IP | Abuse complaints, CAPTCHAs and rate limits land on one address; the VPS can't tell users apart | Keep proxyd's per-device logs on routers for tracing |
tollgated runs cleanly with mint.url and mint.local on the VPS and no merchantd on the router, and settles channels over FIPS without stalling.
mintd answers NUT-07 state checks and NUT-09 restores for the purse; tollgate-net already relies on NUT-09 for channel change. Input fee set to 0, since small tokens multiply fees. How often its keyset rotates (restore scans each keyset).
fips0 MTU, and the WireGuard MTU set 80 below it. Test a large download and a TLS handshake.
A router reaches the VPS's fd address on UDP 51820, both directly and across mesh hops.
Kill switch: with wg0 down, a paid phone loses internet rather than falling back to the WAN.
What Android and iOS do when roaming between routers with the same SSID and subnet: whether they keep the IP, and whether they run the captive-portal check again.