Village Starlink share: pay-per-MB backhaul, zero-rated messaging · qalhotspot/README.md
qalhotspot — the metered-access gateway ("village Starlink share")
Status: PAPER v0 (2026-09-13) — the accounting + messaging integration is
the deliverable. No real traffic shaping exists yet: metering is
accounting-only (a client script reports bytes; in production this hooks
iptables/nftables counters). The money math is real paper-loopd math:
escrowed holds, integer-cent settlement, exact refunds, chain-anchored
receipts. Port :8844 · zero-dependency stdlib · qalhotspot.py.
bash qalhotspot/test_qalhotspot.sh # full suite, all green
bash qalhotspot/client.sh guesta villageop 500 8 # guest-side demo
The village model — the network pays for its own backhaul
Founder intent (verbatim): "people on a network can on-sell WiFi connection, or have metered WiFi, if one person in a village is supplying Starlink. It would also enable communications."
One payer buys the expensive uplink (Starlink, LTE, long-range PtP). Everyone
else prepays small amounts of QALS into their own loopd agents. The
gateway escrows a hold on the guest's agent at connect (115% pre-auth, the
petrol-card model), meters bytes, and at disconnect settles the hold exactly:
actual usage pays the operator (loopd settle to_agent_id = beneficiary), the
remainder refunds to the guest, and the receipt is anchored via qalpipe. The
mesh (qalchat) carries every message. Nobody needs a bank account, a contract,
or even working internet at the far end — prepaid escrow + local mesh is enough.
THE VILLAGE CIRCULAR ECONOMY
============================
AU$ (Starlink bill) QALS (prepaid credit)
▲ │
│ payer buys │ guests prepay
│ backhaul ▼
┌─────────────────┐ sells bytes ┌─────────────────┐
│ OPERATOR │◄──────────────────────│ GUESTS │
│ "village │ 2c/MB (their price) │ own loopd agents│
│ Starlink" │──────────────────────►│ escrowed holds │
└────────┬────────┘ metered internet └────────┬────────┘
│ earns QALS │ qalchat DMs
│ │ (welcome/warn/receipt)
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ qalchat MESH — ALWAYS FREE (zero-rated) │
│ encrypted envelopes via the local relay :8830 │
│ the village can ALWAYS talk — even at 0 quota │
└─────────────────────────────────────────────────────────┘
│ conversation Merkle roots
▼
qalpipe anchor → the chain remembers (receipts, proofs)
The loop closes: guest prepay funds the operator; the operator's Starlink revenue keeps the mesh's backhaul alive; the mesh keeps the village on. The network funds its own backhaul from the demand on it.
Pricing freedom — operators set the rate, the network takes ZERO
Per content/ground_truth.md (F16): chain fees are zero — the network never
charges. A hotspot is the same doctrine at the access layer:
- The operator registers their own price (
price_cents_per_mb; default 2c/MB = AU$20/GB, but AU$5/GB in a competitive street or AU$1/MB at a festival are equally valid — it's their uplink, their risk, their price). - The settlement is peer-to-peer escrow: guest hold → operator agent. QALS
(the network) appears nowhere in the money path — there is no platform cut,
no spread, nothing to capture.
spread = modelonly when an operator chooses to run one. - This is why the model works in a village: the price can be whatever the local market bears, and every cent of it stays in the loop above.
The zero-rated messaging principle
The expensive thing is the backhaul (Starlink quota). The cheap thing is local wireless. So the gateway splits traffic into two classes at the accounting layer (v0) and the firewall layer (production):
| class | what | priced |
|---|---|---|
internet |
general traffic → paid uplink | operator's c/MB rate |
mesh |
qalchat envelopes to/from the local relay | always 0c — allowlisted |
The gateway's own welcome/warning/receipt DMs are mesh-class by definition:
the gateway is a qalchat client (subprocess to qalchat.py, the gmux-bridge
pattern — loopd and qalchat files untouched, HTTP/CLI only). Even a guest at
0 balance keeps encrypted messaging, group chat, and payment messages —
a village that can't pay for YouTube can still call the doctor. That is the
"it would also enable communications" clause, taken literally and made
non-negotiable: GET /mesh-status → {"messaging": "always on"}.
The money path (exact, tested)
- connect — guest posts prepay + payment pre-auth (
agent_id,api_secret, the paper stand-in for card/NFC pre-auth). Gateway places a loopd hold on the guest's agent: 500c prepay → 575c escrowed (115%), debited at placement. - meter — integer math only:
usage_cents = bytes × rate // 1_000_000. Warning DM atwarn_pct(default 80) of prepay. At 0 remaining the session auto-cuts and settles clamped at the prepay — the guest never owes more than they committed. - disconnect — loopd settle at actual usage,
round_to_cents: 0(exact, no doof rounding on wifi),to_agent_idpays the operator. Receipt JSON → qalpipe anchor (purpose: wifi:{op}:{guest}:{mb}MB) → receipt DM.
Proven line from test_qalhotspot.sh:
guest paid 300c (150MB × 2c) · guest 425→700c · operator 0→300c
refund itemised: 200c unused + 75c margin = 275c · network fee: 0c
Honest edges (documented, paper phase): guest pre-auth secrets live in gateway memory only, never at rest; loopd holds expire after 5 min (production adds keepalive/re-auth); the per-user daily cap is enforced per-session in v0.
API
| route | what |
|---|---|
POST /register |
operator: {handle, name?, price_cents_per_mb?=2, warn_pct?=80, daily_cap_mb?=500} — handle must own a qalchat identity (beneficiary agent auto-looked-up) |
POST /connect |
{operator, guest_handle, prepay_cents, agent_id, api_secret} → session token + welcome DM — or {mode:"autopay", auth_token, operator, ts?} (the auto-pay key IS the connection) |
POST /vouchers/create |
operator-auth: {handle, mb_limit, rate_note?, agent_id, api_secret} → VCH-XXXX-XXXX-XXXX + operator-funded escrow |
POST /vouchers/redeem |
{code, guest_handle} → session on the voucher's escrow; single-use (replay 409, unknown 404) |
POST /autopay/authorize |
guest: {guest_handle, agent_id, secret, max_cents_per_day, operator} → ap_… key (secret memory-only) |
GET /voucher/{code}/qr · GET /autopay/{token}/qr |
inline-SVG QR (zero-dep encoder qrsvg.py) for the physical card / phone |
GET /autopay/{token} |
spend status {spent_today_cents, remaining_today_cents, active_hold_cents} |
POST /meter/{token} |
{bytes, class?: internet\|mesh} → usage, warning, auto-cut |
POST /disconnect/{token} |
settle exact → refund → anchor → receipt DM |
GET /session/{token} GET /mesh-status GET /report[/{op}] GET /health |
observability |
The voucher key system — two ways in, one escrow engine
Founder ask: "someone pays in cash and the owner gives them a key which they
use to put in the Qals app … or they get paid automatically as it goes in
Qals." Both modes shipped (2026-09-13), proven end-to-end with exact math in
test_vouchers.sh (102 assertions, green).
MODE A — prepaid vouchers (cash → key → data)
walk-in guest operator (shopop) the ledger (loopd)
───────────── ───────────────── ───────────────────
pays AU$2 cash ────► POST /vouchers/create
at the door {mb_limit:100} ─────────────► hold 229c on the
◄─ VCH-K4M2-8XQZ-1P7D OPERATOR's agent
GET /voucher/CODE/qr (int(200×1.15))
◄─ SVG QR → PRINT ON A CARD
types code into
the Qals app ──────► POST /vouchers/redeem
{code, guest_handle} ───────► session on the
◄─ wh_… token, 100MB voucher's escrow
@ 2c/MB (guest needs NO
browses 80MB ──────► POST /meter … → 160c used loopd balance)
disconnect ────────► POST /disconnect
◄─ charged 160c, refund 69c → receipt anchored
(qalpipe PASS) → receipt DM
The card is a funded promise: the operator escrows the face value upfront (cash already changed hands), the code is single-use, and settlement unwinds to the operator's own agent — net-zero Qals movement, pure accounting. The guest touches money zero times.
Exact-math line from the suite:
cash AU$2 in hand · 80MB → 160c of the 200c card burned
operator escrow 771→1000c net-zero (69c released + 160c self-settled)
walkin loopd balance: 0 before, 0 after · network fee: 0c
MODE B — auto-pay keys (pay-as-you-go, billed in Qals)
subscriber gateway the ledger (loopd)
───────────── ───────────────── ───────────────────
POST /autopay/
authorize ─────────► ap_<30-hex> key ◄── shows QR at the door
(cap AU$5/day) GET /autopay/{tok}/qr
POST /connect ─────► hold = remaining daily budget
{mode:"autopay"} (500c → 575c escrowed)
session 1: 100MB ──► settle 200c ────────────────► operator +200c
session 2: 50MB ──► settle 100c (day total 300 < 500)
session 3: 250MB ──► AUTO-CUT at remaining 200c — cap enforced MID-SESSION
4th connect ──► 402 daily cap reached
next day (ts) ──► fresh 500c budget (resets at midnight; ts-mockable)
Exact-math line from the suite:
day total 200c + 100c + 200c = 500c = AU$5.00 cap — enforced mid-session
subguest 2000→1500c · operator +500c across 3 sessions · network fee: 0c
The key IS the connection: authorize once, connect forever (within the cap), billed automatically in Qals. Secrets stay memory-only — never at rest.
The cash-card model (physical ↔ digital)
A voucher card is physical money with a digital twin: the QR on the card is
the escrow key. Mint it, sell it for cash, scratch it (redeem), done — no
accounts, no onboarding, no smartphone required beyond the Qals app to type
the code. The qrsvg.py encoder is zero-dep (byte mode, versions 1-5, ECC-L,
penalty-optimal masking) and the suite decodes its SVG output with an
independent zxing reader — byte-exact.
Office pattern (same keys, different meter)
Swap "MB" for "GPU-minutes" and the whole system ports to compute: a
visitor buys a cash card at the office kiosk → redeems it against the qmarket
pool for N GPU-minutes (voucher mode); a resident agent holds an auto-pay key
capped at AU$/day → metered agent spend with the same mid-session cap
enforcement (autopay mode). Same escrow engine, same exact settlement, same
anchored receipts — 07_compute_marketplace is the natural next stop.
Production path (v1+)
- Real metering — nftables per-station counters (
nft add rule … counterper MAC/SSID client), gateway reads deltas; the/meteraccounting interface stays identical, so v0 clients keep working. - Captive portal — TLS on :8844, the welcome DM is the portal (guest needs only qalchat); web fallback page for bootstrap.
- Voucher cards at scale — ✅ paper phase DONE (vouchers, auto-pay keys, QRs, daily caps); production adds batch card printing (the SVG QR endpoint feeds a print sheet), voucher expiry + transfer, and a card-revocation list for lost cards.
- Hold keepalive — re-auth before loopd's 5-min TTL for long sessions; ✅ cross-session daily caps now enforced for auto-pay keys.
- Roaming — operators cross-accept each other's guests (settle
operator-to-operator via
to_agent_idchains); the pricing-freedom rule makes bilateral rates trivial.
Next step: the carrier plugin (from content/mesh-pipelines.md)
The hotspot proves the HTTP relay carrier end-to-end with money. The same
gateway pattern generalises: a carrier is just put(envelope)/poll(handle),
and the mesh-pipelines doc sketches lora (a 32-byte Merkle anchor crosses a
mesh in one LoRa frame — receipts and conversation proofs ride for free),
dtn, ipfs, ble. A village whose only link is one Starlink dish plus a
LoRa ring is exactly the deployment this is built for: metered backhaul for
the internet class, free mesh for the messaging class, anchors whenever any
node touches the chain. LoRa carrier = the next build.
Runbook
python3 qalhotspot/qalhotspot.py # gateway on :8844 (LAN-facing)
# needs: loopd :8823, qalchat relay :8830 (and qalnet :9000 for anchoring)
# env: QALHOTSPOT_LOOPD, QALCHAT_RELAY, QALCHAT_DIR (defaults match the stack)
#
# suites (serialised via /tmp lock; each archives data first — never deletes):
bash qalhotspot/test_qalhotspot.sh # classic prepaid sessions (11 sections)
bash qalhotspot/test_vouchers.sh # voucher keys: MODE A + MODE B (102 asserts)
systemd unit pattern (user unit — the stack runs as the operator's user; do not install without review):
# ~/.config/systemd/user/qalhotspot.service (generated pattern — paper phase)
[Unit]
Description=qalhotspot — metered-access gateway (village Starlink share)
After=network-online.target
Wants=network-online.target
[Service]
WorkingDirectory=%h/projects/GLM_projects/crypto_currency/qalhotspot
ExecStart=/usr/bin/python3 %h/projects/GLM_projects/crypto_currency/qalhotspot/qalhotspot.py
Environment=QALCHAT_RELAY=http://127.0.0.1:8830
Environment=QALHOTSPOT_LOOPD=http://127.0.0.1:8823
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target
Registration elsewhere in the repo: DIRECTORY.md (dir + port 8844),
scripts/build_docs.py PAGES (wiki: Metered WiFi), website/ui-codex/hub.html
(System card). Data lives in qalhotspot/data/ — archived on test runs,
never deleted.