upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHECKLIST.md148
-rw-r--r--PLAN.md293
-rw-r--r--main/CMakeLists.txt1
-rw-r--r--main/config.c22
-rw-r--r--main/config.h5
-rw-r--r--main/tollgate_client.c457
-rw-r--r--main/tollgate_client.h46
-rw-r--r--main/tollgate_main.c15
-rw-r--r--tests/unit/Makefile5
-rw-r--r--tests/unit/stubs/esp_err.h1
-rw-r--r--tests/unit/stubs/nucula_wallet.h17
-rw-r--r--tests/unit/test_tollgate_client.c186
12 files changed, 1112 insertions, 84 deletions
diff --git a/CHECKLIST.md b/CHECKLIST.md
index 9842390..dd21b0c 100644
--- a/CHECKLIST.md
+++ b/CHECKLIST.md
@@ -189,9 +189,151 @@
189- [ ] Update `package.json` npm scripts for new paths 189- [ ] Update `package.json` npm scripts for new paths
190- [ ] All `make test-*` targets work 190- [ ] All `make test-*` targets work
191 191
192## Phase 4: ESP32-to-OpenWRT TollGate Interop — NOT STARTED 192## Phase 4: ESP32 TollGate Client Detection + Auto-Payment — IN PROGRESS
193- [ ] ESP32 pays OpenWRT TollGate using Cashu tokens 193
194- [ ] Interoperability testing with existing OpenWRT TollGate on enx00e04c683d2d 194### tollgate_client.c/h (New)
195- [ ] Create `tollgate_client.h` — types: `tollgate_discovery_t`, `tollgate_client_state_t` enum (IDLE/DETECTING/NEEDS_PAY/PAYING/PAID/RENEWING)
196- [ ] Create `tollgate_client.c` — detection, payment, monitoring, state machine
197- [ ] `tollgate_client_detect(gw_ip)` — HTTP GET `http://{gw}:2121/`, parse kind=10021, extract price tags
198- [ ] `tollgate_client_pay(gw_ip, amount_sats)` — `nucula_wallet_send()` → POST to upstream → parse kind=1022/21023
199- [ ] `tollgate_client_on_sta_connected()` — extract gw from DHCP, detect, pay (blocking)
200- [ ] `tollgate_client_tick()` — GET `/usage`, renew at 20% remaining
201- [ ] `tollgate_client_on_sta_disconnected()` — reset state
202- [ ] `tollgate_client_get_usage(gw_ip)` — GET `/usage` → parse remaining/total
203
204### Config Changes
205- [ ] Add to `config.h`: `client_enabled`, `client_steps_to_buy`, `client_renewal_threshold_pct`, `client_retry_interval_ms`
206- [ ] Parse new fields in `config.c`
207
208### Integration (tollgate_main.c)
209- [ ] Make wallet init synchronous (call `nucula_wallet_init()` directly, not as task)
210- [ ] Add `tollgate_client_on_sta_connected()` in `ip_event_handler` (blocking, before `start_services()`)
211- [ ] Add `tollgate_client_on_sta_disconnected()` in `wifi_event_handler`
212- [ ] Add `tollgate_client_tick()` in main loop
213- [ ] Update `main/CMakeLists.txt` — add `tollgate_client.c`
214
215### Unit Tests
216- [ ] `tests/unit/test_tollgate_client.c` — discovery parsing, price extraction, state machine, renewal threshold
217- [ ] All unit tests passing
218
219### Integration Tests
220- [ ] ESP32→OpenWRT auto-payment (Scenario 4)
221- [ ] ESP32→ESP32 auto-payment (Scenario 5, needs Board B)
222
223### Test Cases 39-43
224- [ ] Test 39: Client detection (kind=10021 parse)
225- [ ] Test 40: Client payment flow (mock HTTP)
226- [ ] Test 41: Session renewal (20% threshold)
227- [ ] Test 42: ESP32→OpenWRT auto-pay
228- [ ] Test 43: ESP32→ESP32 auto-pay
229
230## Phase 5: Lightning Auto-Payout — NOT STARTED
231
232### lnurl_pay.c/h (New)
233- [ ] Create `lnurl_pay.h` — `lnurl_get_invoice(lightning_address, amount_sats, bolt11_out, out_size)`
234- [ ] Create `lnurl_pay.c` — GET `.well-known/lnurlp/{user}` → parse callback → GET callback with amount → extract BOLT11
235
236### lightning_payout.c/h (New)
237- [ ] Create `lightning_payout.h` — `payout_recipient_t`, config, init/tick API
238- [ ] Create `lightning_payout.c` — periodic balance check, threshold, multi-recipient split, melt with retry
239
240### nucula Bridge Extension
241- [ ] Add `nucula_wallet_melt(bolt11, max_fee_sats)` to `nucula_wallet.h/cpp`
242- [ ] Wraps `Wallet::request_melt_quote()` + `Wallet::melt_tokens()` (NUT-05)
243
244### Config Changes
245- [ ] Add payout config to `config.h`: `payout_enabled`, `min_payout_amount`, `min_balance`, `fee_tolerance_pct`, `check_interval_s`, `recipients[]`
246- [ ] Parse payout config in `config.c`
247
248### Integration (tollgate_main.c)
249- [ ] Add periodic payout timer (60s interval)
250- [ ] Update `main/CMakeLists.txt`
251
252### Unit Tests
253- [ ] `tests/unit/test_lnurl_pay.c` — LNURL-pay URL construction, response parsing
254- [ ] `tests/unit/test_lightning_payout.c` — threshold check, multi-recipient split, fee tolerance
255
256### Test Cases 44-48
257- [ ] Test 44: LNURL-pay flow
258- [ ] Test 45: Payout threshold
259- [ ] Test 46: Multi-recipient split
260- [ ] Test 47: Melt with fee tolerance
261- [ ] Test 48: Full payout cycle
262
263## Phase 6: Bytes-Based Billing — NOT STARTED
264
265### lwIP NAPT Stats Component (New)
266- [ ] Create `components/lwip_napt_stats/` — patched `ip4_napt.c` with byte counters
267- [ ] Add `uint64_t bytes_up/bytes_down` to `struct ip_napt_entry`
268- [ ] Increment in `ip_napt_forward()` and `ip_napt_recv()`
269- [ ] Add public API: `ip_napt_get_client_bytes(client_ip, &up, &down)`
270- [ ] Create component CMakeLists.txt
271
272### Session Changes
273- [ ] Add `allotment_bytes`, `bytes_consumed` to `session_t`
274- [ ] Dual-metric `session_is_expired()` dispatches on metric type
275- [ ] `session_add_bytes(client_ip, byte_count)` called from firewall counting
276
277### Config Changes
278- [ ] Add `metric` field ("milliseconds" or "bytes") to `config.h`
279- [ ] Add `step_size_bytes` to `config.h`
280- [ ] Parse in `config.c`
281
282### TollGate API Changes
283- [ ] Discovery endpoint advertises correct metric
284- [ ] `/usage` returns byte-based or time-based values
285- [ ] Allotment calculation dispatches on metric
286
287### Firewall Changes
288- [ ] `firewall_count_traffic()` — queries NAPT byte counters per active client
289- [ ] Called from `session_tick()` or main loop
290
291### Cashu Changes
292- [ ] Unify `cashu_calculate_allotment()` for both metrics
293
294### Unit Tests
295- [ ] `tests/unit/test_bytes_metric.c` — byte allotment calc, dual-metric session expiry
296
297### Test Cases 49-52
298- [ ] Test 49: Byte allotment calc
299- [ ] Test 50: Byte session expiry
300- [ ] Test 51: NAPT byte counting
301- [ ] Test 52: Bytes metric end-to-end
302
303## Phase 7: ContextVM Server (MCP over Nostr) — NOT STARTED
304
305### NIP-44 Encryption (New)
306- [ ] Create `nip44.h` — encrypt/decrypt API
307- [ ] Create `nip44.c` — XChaCha20-Poly1305 + secp256k1 ECDH + conversation key derivation
308
309### MCP Handler (New)
310- [ ] Create `mcp_handler.h` — tool registration, JSON-RPC parse/dispatch
311- [ ] Create `mcp_handler.c` — register tools, handle requests, build responses
312
313### CVM Server (New)
314- [ ] Create `cvm_server.h` — init/start/stop API
315- [ ] Create `cvm_server.c` — WebSocket listener, DM subscription, NIP-44 decrypt, MCP dispatch
316
317### MCP Tool Registration
318- [ ] `get_config`, `set_config`, `get_balance`, `get_sessions`, `get_usage`
319- [ ] `set_payout`, `set_metric`, `set_price`, `wallet_send`, `wallet_melt`
320
321### Auth
322- [ ] Only accept commands from owner npub
323
324### Integration (tollgate_main.c)
325- [ ] Start CVM server alongside wifistr
326- [ ] Update `main/CMakeLists.txt`
327
328### Unit Tests
329- [ ] `tests/unit/test_nip44.c` — encrypt/decrypt roundtrip
330- [ ] `tests/unit/test_mcp_handler.c` — JSON-RPC parse, tool dispatch
331
332### Test Cases 53-56
333- [ ] Test 53: NIP-44 encrypt/decrypt
334- [ ] Test 54: MCP JSON-RPC parse
335- [ ] Test 55: Config change via DM
336- [ ] Test 56: Balance query via CVM
195 337
196## Reminders 338## Reminders
197- Do NOT ask for instructions — proceed independently, skip blocked items, work on unblocked ones 339- Do NOT ask for instructions — proceed independently, skip blocked items, work on unblocked ones
diff --git a/PLAN.md b/PLAN.md
index 8ea827d..2a0ed2b 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -286,103 +286,243 @@ Publishes TollGate node to Nostr as kind 38787 (wifistr):
286| 37 | 5 consecutive payments | Loop | All authenticated | TODO | 286| 37 | 5 consecutive payments | Loop | All authenticated | TODO |
287| 38 | Stress: rapid pay/expire | Loop with short sessions | No crash/leak | TODO | 287| 38 | Stress: rapid pay/expire | Loop with short sessions | No crash/leak | TODO |
288 288
289### Phase 4: Mesh Service Discovery + ESP32-to-OpenWRT Interop — NOT STARTED 289### Phase 4: ESP32 TollGate Client Detection + Auto-Payment — IN PROGRESS
290 290
291**Goal:** Two capabilities: (1) Pre-association price discovery between mesh nodes using Wi-Fi Vendor IE beacons, (2) ESP32-to-OpenWRT TollGate interoperability with Cashu tokens. 291**Goal:** ESP32 detects upstream TollGate when connected as STA, automatically pays for internet access using on-device wallet. Enables ESP32→OpenWRT (Scenario 4) and ESP32→ESP32 (Scenario 5) auto-payment.
292 292
293#### 4A: Pre-Association Service Discovery via Vendor IE Beacons 293**New files:** `main/tollgate_client.c`, `main/tollgate_client.h`
294 294
295**Problem:** In a tollgate mesh network, a client router needs to know an upstream gateway's price before investing in Wi-Fi connection setup/teardown. Standard 802.11u ANQP is not supported by ESP-IDF. 295#### Architecture
296 296
297**Solution: Vendor-Specific Information Elements in Beacon/Probe Response frames** 297The ESP32 already runs `WIFI_MODE_APSTA` — STA connects to upstream WiFi. When STA gets an IP, the client module:
2981. Extracts gateway IP from DHCP info
2992. HTTP GET `http://{gw}:2121/` — check for TollGate (kind=10021)
3003. Parse price/mint/metric from advertisement tags
3014. Check wallet balance ≥ price
3025. `nucula_wallet_send(price_sats)` → cashuA V3 token
3036. POST token to `http://{gw}:2121/`
3047. Parse kind=1022 response — session granted
3058. Monitor: periodic GET `/usage`, auto-renew at 20% remaining
298 306
299ESP-IDF provides `esp_wifi_set_vendor_ie()` to inject custom data into 802.11 management frames. This allows passive price discovery during normal Wi-Fi scanning — no connection required. 307#### Client State Machine
300 308
301``` 309```
302┌─────────────────────────────────────────────────────────────┐ 310IDLE → [STA got IP] → DETECTING → [kind=10021 found] → NEEDS_PAY
303│ Layer 2 (Pre-Association) │ 311 ↓ [no TollGate] ↓ [wallet has funds]
304│ │ 312 NO_TOLLGATE PAYING → [kind=1022] → PAID
305│ Gateway AP broadcasts price in every Beacon (~100ms) │ 313 ↓ [expiry near]
306│ Client STA scans, reads price from beacon before connect │ 314 RENEWING → PAID
307│ │
308│ ┌─────────────┐ ┌─────────────┐ │
309│ │ Gateway AP │ Beacon ──────────► │ Client STA │ │
310│ │ │ (with price IE) │ │ │
311│ │ Vendor IE: │ │ Scan result │ │
312│ │ OUI:TG │ │ includes │ │
313│ │ price/sats │ │ price data │ │
314│ │ step_ms │ └──────┬──────┘ │
315│ │ mint_url │ │ │
316│ └─────────────┘ Decision: connect? │
317│ │ │
318└──────────────────────────────────────────────┼──────────────┘
319
320 ┌────────────────▼──────────────┐
321 │ Layer 3+ (Connected) │
322 │ POST / with Cashu token │
323 └───────────────────────────────┘
324``` 315```
325 316
326**Beacon IE Payload Format (Vendor-Specific, Element ID 0xDD):** 317#### Design Decisions
318- **Blocking**: upstream payment must succeed before local services start
319- **1 step per payment** (21 sats / 60s) — minimal, renew frequently
320- **No budget cap** — keep paying as long as wallet has balance
321- **Renew at 20% remaining** — re-pay when 80% of session consumed
322- **Wallet init synchronous** — must complete before client can create tokens
327 323
324#### Config Addition
325
326```json
327{
328 "client_enabled": true,
329 "client_steps_to_buy": 1,
330 "client_renewal_threshold_pct": 20,
331 "client_retry_interval_ms": 30000
332}
333```
334
335#### Integration with `tollgate_main.c`
336
337| Event | Action |
338|-------|--------|
339| `IP_EVENT_STA_GOT_IP` | Wallet init (sync) → `tollgate_client_on_sta_connected()` → start local services |
340| `WIFI_EVENT_STA_DISCONNECTED` | `tollgate_client_on_sta_disconnected()` — reset state |
341| Main loop (every 1s) | `tollgate_client_tick()` — check usage, renew if needed |
342
343#### Test Cases
344
345| # | Test | Method | Pass Criteria | Status |
346|---|------|--------|---------------|--------|
347| 39 | Client detection (kind=10021) | Unit test parse | Correct price/mint/metric extracted | TODO |
348| 40 | Client payment flow | Mock HTTP | Token POSTed, kind=1022 parsed | TODO |
349| 41 | Session renewal | Mock usage < 20% | Re-payment triggered | TODO |
350| 42 | ESP32→OpenWRT auto-pay | Integration | NAT works after payment | TODO |
351| 43 | ESP32→ESP32 auto-pay | Cross-board | Board B pays Board A | TODO |
352
353#### Vendor IE Beacon (Pre-Association Discovery) — DEFERRED
354
355Pre-association price discovery via Wi-Fi Vendor IE beacons (OUI `0x54:0x47`) is deferred to a future phase. The client currently uses HTTP-based discovery after connection.
356
357### Phase 5: Lightning Auto-Payout — NOT STARTED
358
359**Goal:** When wallet balance exceeds a configurable threshold, automatically pay out to Lightning addresses via LNURL-pay + Cashu NUT-05 melt.
360
361**New files:** `main/lnurl_pay.c`, `main/lnurl_pay.h`, `main/lightning_payout.c`, `main/lightning_payout.h`
362**Modified files:** `components/nucula_lib/nucula_wallet.h`, `components/nucula_lib/nucula_wallet.cpp`
363
364#### Architecture
365
366Mirrors the Go implementation in `tollgate-module-basic-go/src/merchant/` and `src/lightning/`:
367
368```
369Every 60s (per mint):
370 balance = nucula_wallet_balance()
371 balance >= min_payout_amount? No → skip
372 Yes:
373 payout_pool = balance - min_balance
374 For each recipient (factor):
375 share = payout_pool * factor
376 bolt11 = lnurl_get_invoice(lightning_address, share)
377 nucula_wallet_melt(bolt11, share + fee_tolerance%)
378```
379
380#### LNURL-pay Protocol (`lnurl_pay.c/h`)
381
382Pure HTTP implementation (2 GETs):
3831. `GET https://{domain}/.well-known/lnurlp/{username}` → parse callback URL, min/max amounts
3842. `GET {callback}?amount={millisats}` → extract BOLT11 invoice from response
385
386#### nucula Bridge Extension
387
388Add to `nucula_wallet.h`:
389```c
390esp_err_t nucula_wallet_melt(const char *bolt11_invoice, uint64_t max_fee_sats);
391```
392
393Wraps `Wallet::request_melt_quote()` + `Wallet::melt_tokens()` (NUT-05).
394
395#### Config Addition
396
397```json
398{
399 "payout": {
400 "enabled": true,
401 "min_payout_amount": 128,
402 "min_balance": 64,
403 "fee_tolerance_pct": 10,
404 "check_interval_s": 60,
405 "recipients": [
406 {"lightning_address": "user@domain.com", "factor": 0.79},
407 {"lightning_address": "dev@domain.com", "factor": 0.21}
408 ]
409 }
410}
411```
412
413#### Test Cases
414
415| # | Test | Method | Pass Criteria | Status |
416|---|------|--------|---------------|--------|
417| 44 | LNURL-pay flow | Unit test HTTP parse | Correct BOLT11 extracted | TODO |
418| 45 | Payout threshold | Unit test | Skip when below, trigger when above | TODO |
419| 46 | Multi-recipient split | Unit test | Factors sum to 1.0 | TODO |
420| 47 | Melt with fee tolerance | Integration | Invoice paid, change received | TODO |
421| 48 | Full payout cycle | E2E | Wallet drains to min_balance | TODO |
422
423### Phase 6: Bytes-Based Billing — NOT STARTED
424
425**Goal:** Support both time-based (milliseconds) and data-based (bytes) billing metrics. Mirrors the Go implementation's dual-metric system.
426
427#### lwIP NAPT Byte Counting (Managed Component)
428
429**New component:** `components/lwip_napt_stats/` — patched copy of ESP-IDF's `ip4_napt.c` with per-entry byte counters.
430
431Patch adds to `struct ip_napt_entry`:
432```c
433uint64_t bytes_up; // bytes uploaded (client → internet)
434uint64_t bytes_down; // bytes downloaded (internet → client)
328``` 435```
329┌──────────┬────────┬─────────────┬──────────────┬──────────────────┐ 436
330│ element_id│ length │ vendor_oui │ oui_type │ payload │ 437Increment in `ip_napt_forward()` (upload) and `ip_napt_recv()` (download).
331│ (0xDD) │ │ (3 bytes) │ (1 byte) │ (variable) │ 438
332├──────────┼────────┼─────────────┼──────────────┼──────────────────┤ 439New public API:
333│ 0xDD │ N │ "TG" │ 0x01 (price) │ See below │ 440```c
334│ │ │ 0x54:0x47 │ │ │ 441void ip_napt_get_client_bytes(uint32_t client_ip, uint64_t *bytes_up, uint64_t *bytes_down);
335└──────────┴────────┴─────────────┴──────────────┴──────────────────┘
336
337Price Payload (oui_type 0x01):
338┌─────────────┬─────────────┬──────────────┬───────────────┬────────────┐
339│ version (1B)│ price (2B) │ step_ms (2B) │ fee_ppk (2B) │ hop_count │
340│ = 0x01 │ sat/step │ ms/step │ or 0 │ (1B) │
341├─────────────┼─────────────┼──────────────┼───────────────┼────────────┤
342│ 0x01 │ uint16_le │ uint16_le │ uint16_le │ uint8 │
343└─────────────┴─────────────┴──────────────┴───────────────┴────────────┘
344Total payload: 9 bytes (fits easily in beacon, typical budget ~200 bytes)
345``` 442```
346 443
347**Implementation:** 444~30 line patch. Lives in the project repo as a managed component, survives ESP-IDF updates.
348 445
349**AP Side (Gateway — `beacon_price.c/h`):** 446#### Session Changes
350- `beacon_price_start()` — calls `esp_wifi_set_vendor_ie(true, WIFI_VND_IE_TYPE_BEACON, WIFI_VND_IE_ID_0, &ie_data)` and also for `WIFI_VND_IE_TYPE_PROBE_RESP`
351- `beacon_price_update(uint16_t price_sat, uint16_t step_ms, uint16_t fee_ppk, uint8_t hop_count)` — dynamically updates the IE in-place (no reconnect, no user kick; next beacon frame carries new price)
352- Price derived from `tollgate_config_t` fields (`price_per_step`, `step_size_ms`)
353- Can be called on-the-fly when market conditions change (e.g., upstream price changes)
354 447
355**STA Side (Client — `beacon_scan.c/h`):** 448`session_t` gains dual-metric support:
356- `beacon_scan_prices(wifi_ap_record_t *aps, int count, tollgate_price_t *prices, int *price_count)` — given scan results, extract price IEs 449```c
357- Uses `esp_wifi_set_vendor_ie_cb()` to register a callback that fires during scan 450uint64_t allotment_bytes;
358- Or parses `vendor_ie_data_t` from scan results if available in `wifi_ap_record_t` 451uint64_t bytes_consumed;
359- Returns array of `{bssid, ssid, price_sat, step_ms, fee_ppk, hop_count}` 452```
360- Client selects cheapest/upstream gateway from scan results before connecting
361 453
362**Integration with existing config:** 454`session_is_expired()` dispatches on metric type:
363- OUI: `0x54, 0x47` ("TG" in ASCII) — unique to TollGate 455- `"milliseconds"`: elapsed time ≥ allotment_ms
364- oui_type: `0x01` = price advertisement, `0x02` = mesh routing (future) 456- `"bytes"`: bytes_consumed ≥ allotment_bytes
365- `hop_count`: indicates network depth (0 = directly connected to internet, 1 = one hop away)
366- Price updates are rate-limited to once per 5 seconds to avoid beacon churn
367 457
368**GL-MT3000 (OpenWrt) Compatibility:** 458#### Config Addition
369- OpenWrt supports vendor IEs via `hostapd_cli -i wlan0 set vendor_elements <hex>` + `hostapd_cli -i wlan0 update_beacon`
370- Client scans via `iw dev wlan0 scan` show vendor elements
371- Requires stock OpenWrt 24 firmware (not GL.iNet default) for mac80211 driver access
372- Same OUI/payload format ensures ESP32 ↔ OpenWrt interop
373 459
374**Key Benefits:** 460```json
375- Zero connection overhead for price discovery 461{
376- Works during normal passive/active scanning (no extra frames) 462 "metric": "milliseconds",
377- Prices update live without disconnecting clients 463 "step_size_bytes": 22020096
378- Supports multi-hop mesh routing via `hop_count` 464}
379- Compatible with both ESP32 and Linux (OpenWrt) platforms 465```
380 466
381#### 4B: ESP32-to-OpenWRT TollGate Interop 467#### Test Cases
382 468
383**Goal:** ESP32 can pay OpenWRT TollGate using Cashu tokens. Full interoperability with existing OpenWRT-based TollGate infrastructure. 469| # | Test | Method | Pass Criteria | Status |
470|---|------|--------|---------------|--------|
471| 49 | Byte allotment calc | Unit test | Correct bytes per step | TODO |
472| 50 | Byte session expiry | Unit test | Expired when consumed ≥ allotment | TODO |
473| 51 | NAPT byte counting | Integration | Counters match actual traffic | TODO |
474| 52 | Bytes metric end-to-end | E2E | Client disconnected after data cap | TODO |
475
476### Phase 7: ContextVM Server (MCP over Nostr) — NOT STARTED
477
478**Goal:** Remote configuration of ESP32 TollGate via ContextVM — services communicate over Nostr using public keys as addresses. Exposes configuration as MCP tools accessible by both humans and AI agents.
479
480**New files:** `main/cvm_server.c`, `main/cvm_server.h`, `main/nip44.c`, `main/nip44.h`, `main/mcp_handler.c`, `main/mcp_handler.h`
481
482#### Architecture
483
484ContextVM uses MCP (JSON-RPC 2.0) over NIP-44 encrypted Nostr DMs:
4851. ESP32 subscribes to Nostr relays for DMs addressed to its npub
4862. Incoming DMs are NIP-44 decrypted, parsed as MCP JSON-RPC requests
4873. Dispatched to registered tool handlers
4884. Responses sent back via NIP-44 encrypted DM
489
490#### MCP Tools Exposed
491
492| Tool | Input | Output |
493|------|-------|--------|
494| `get_config` | — | Full config JSON |
495| `set_config` | `{key: value}` | Success/error |
496| `get_balance` | — | `{balance, proof_count}` |
497| `get_sessions` | — | Array of active sessions |
498| `get_usage` | — | Upstream usage if client active |
499| `set_payout` | `{recipients: [...]}` | Success/error |
500| `set_metric` | `{"bytes" or "milliseconds"}` | Success/error |
501| `set_price` | `{price_per_step: N}` | Success/error |
502| `wallet_send` | `{amount_sats: N}` | `{token: "cashuA..."}` |
503| `wallet_melt` | `{bolt11: "ln..."}` | `{preimage: "..."}` |
504
505#### Auth
506
507Only accept commands from owner npub (derived from nsec in config.json).
508
509#### Dependencies
510
511- XChaCha20-Poly1305 (from mbedtls or libsodium)
512- Base64url encoding (already in cashu code)
513- WebSocket listener (extends existing wifistr infrastructure)
514- NIP-44 v2 encryption/decryption
515
516#### Test Cases
517
518| # | Test | Method | Pass Criteria | Status |
519|---|------|--------|---------------|--------|
520| 53 | NIP-44 encrypt/decrypt | Unit test | Roundtrip matches | TODO |
521| 54 | MCP JSON-RPC parse | Unit test | Correct dispatch | TODO |
522| 55 | Config change via DM | Integration | ESP32 applies new config | TODO |
523| 56 | Balance query via CVM | Integration | Returns correct balance | TODO |
384 524
385## Total: 38 + 20 Tests across 4 phases 525## Total: 56 Tests across 7 phases
386 526
387## Testing Infrastructure 527## Testing Infrastructure
388 528
@@ -407,6 +547,7 @@ Host-compiled C tests that verify pure-logic functions with known input/output v
407| `test_nostr_event.c` | `nostr_event.c` | NIP-01 event ID (SHA-256 of canonical JSON), Schnorr signature generation + verification, JSON serialization | 547| `test_nostr_event.c` | `nostr_event.c` | NIP-01 event ID (SHA-256 of canonical JSON), Schnorr signature generation + verification, JSON serialization |
408| `test_cashu.c` | `cashu.c` | `cashu_decode_token()`, `cashu_calculate_allotment_ms()`, `cashu_is_mint_accepted()` | 548| `test_cashu.c` | `cashu.c` | `cashu_decode_token()`, `cashu_calculate_allotment_ms()`, `cashu_is_mint_accepted()` |
409| `test_session.c` | `session.c` | Session lifecycle: create/find/extend/expire/revoke, spent-secret dedup | 549| `test_session.c` | `session.c` | Session lifecycle: create/find/extend/expire/revoke, spent-secret dedup |
550| `test_tollgate_client.c` | `tollgate_client.c` | Discovery parsing, payment flow, renewal logic, state machine |
410 551
411**Run:** `make test-unit` 552**Run:** `make test-unit`
412 553
diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt
index df69283..be4d564 100644
--- a/main/CMakeLists.txt
+++ b/main/CMakeLists.txt
@@ -10,6 +10,7 @@ idf_component_register(SRCS "tollgate_main.c"
10 "nostr_event.c" 10 "nostr_event.c"
11 "geohash.c" 11 "geohash.c"
12 "wifistr.c" 12 "wifistr.c"
13 "tollgate_client.c"
13 INCLUDE_DIRS "." 14 INCLUDE_DIRS "."
14 REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server 15 REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server
15 lwip json esp_http_client mbedtls esp-tls log spiffs 16 lwip json esp_http_client mbedtls esp-tls log spiffs
diff --git a/main/config.c b/main/config.c
index 47d631f..c074410 100644
--- a/main/config.c
+++ b/main/config.c
@@ -22,6 +22,10 @@ esp_err_t tollgate_config_init(void)
22 g_config.step_size_ms = 60000; 22 g_config.step_size_ms = 60000;
23 g_config.persist_threshold_sats = 1; 23 g_config.persist_threshold_sats = 1;
24 g_config.nostr_publish_interval_s = 21600; 24 g_config.nostr_publish_interval_s = 21600;
25 g_config.client_enabled = false;
26 g_config.client_steps_to_buy = 1;
27 g_config.client_renewal_threshold_pct = 20;
28 g_config.client_retry_interval_ms = 30000;
25 29
26 esp_vfs_spiffs_conf_t conf = { 30 esp_vfs_spiffs_conf_t conf = {
27 .base_path = "/spiffs", 31 .base_path = "/spiffs",
@@ -49,7 +53,11 @@ esp_err_t tollgate_config_init(void)
49 "\"step_size_ms\":60000," 53 "\"step_size_ms\":60000,"
50 "\"nostr_geohash\":\"u281w0dfz\"," 54 "\"nostr_geohash\":\"u281w0dfz\","
51 "\"nostr_relays\":[\"wss://relay.damus.io\",\"wss://nos.lol\"]," 55 "\"nostr_relays\":[\"wss://relay.damus.io\",\"wss://nos.lol\"],"
52 "\"nostr_publish_interval_s\":21600" 56 "\"nostr_publish_interval_s\":21600,"
57 "\"client_enabled\":false,"
58 "\"client_steps_to_buy\":1,"
59 "\"client_renewal_threshold_pct\":20,"
60 "\"client_retry_interval_ms\":30000"
53 "}"; 61 "}";
54 f = fopen("/spiffs/config.json", "w"); 62 f = fopen("/spiffs/config.json", "w");
55 if (f) { 63 if (f) {
@@ -146,6 +154,18 @@ esp_err_t tollgate_config_init(void)
146 cJSON *pub_interval = cJSON_GetObjectItem(root, "nostr_publish_interval_s"); 154 cJSON *pub_interval = cJSON_GetObjectItem(root, "nostr_publish_interval_s");
147 if (pub_interval) g_config.nostr_publish_interval_s = pub_interval->valueint; 155 if (pub_interval) g_config.nostr_publish_interval_s = pub_interval->valueint;
148 156
157 cJSON *client_enabled = cJSON_GetObjectItem(root, "client_enabled");
158 if (client_enabled && cJSON_IsBool(client_enabled)) g_config.client_enabled = cJSON_IsTrue(client_enabled);
159
160 cJSON *client_steps = cJSON_GetObjectItem(root, "client_steps_to_buy");
161 if (client_steps) g_config.client_steps_to_buy = client_steps->valueint;
162
163 cJSON *client_renewal = cJSON_GetObjectItem(root, "client_renewal_threshold_pct");
164 if (client_renewal) g_config.client_renewal_threshold_pct = client_renewal->valueint;
165
166 cJSON *client_retry = cJSON_GetObjectItem(root, "client_retry_interval_ms");
167 if (client_retry) g_config.client_retry_interval_ms = client_retry->valueint;
168
149 cJSON_Delete(root); 169 cJSON_Delete(root);
150 170
151 if (g_config.nostr_relay_count == 0) { 171 if (g_config.nostr_relay_count == 0) {
diff --git a/main/config.h b/main/config.h
index 8254a62..4c6116e 100644
--- a/main/config.h
+++ b/main/config.h
@@ -49,6 +49,11 @@ typedef struct {
49 int nostr_publish_interval_s; 49 int nostr_publish_interval_s;
50 50
51 bool identity_initialized; 51 bool identity_initialized;
52
53 bool client_enabled;
54 int client_steps_to_buy;
55 int client_renewal_threshold_pct;
56 int client_retry_interval_ms;
52} tollgate_config_t; 57} tollgate_config_t;
53 58
54void tollgate_config_derive_unique(tollgate_config_t *cfg); 59void tollgate_config_derive_unique(tollgate_config_t *cfg);
diff --git a/main/tollgate_client.c b/main/tollgate_client.c
new file mode 100644
index 0000000..ac8dcfe
--- /dev/null
+++ b/main/tollgate_client.c
@@ -0,0 +1,457 @@
1#include "tollgate_client.h"
2#include "config.h"
3#include "nucula_wallet.h"
4#include "esp_log.h"
5#include "esp_http_client.h"
6#include "esp_crt_bundle.h"
7#include "cJSON.h"
8#include <string.h>
9#include <stdlib.h>
10
11static const char *TAG = "tg_client";
12
13static tollgate_client_state_t s_state = TG_CLIENT_IDLE;
14static tollgate_discovery_t s_discovery = {0};
15static char s_gw_ip[TG_CLIENT_MAX_GW_IP_LEN] = {0};
16static int64_t s_allotment_ms = 0;
17static int64_t s_remaining_ms = -1;
18static int64_t s_last_pay_time_ms = 0;
19static int s_retry_count = 0;
20
21static int64_t get_time_ms(void) {
22 return (int64_t)(xTaskGetTickCount() * portTICK_PERIOD_MS);
23}
24
25static esp_err_t http_get(const char *url, char *resp_buf, size_t resp_buf_size, int *status_out)
26{
27 esp_http_client_config_t config = {
28 .url = url,
29 .method = HTTP_METHOD_GET,
30 .timeout_ms = 10000,
31 .crt_bundle_attach = esp_crt_bundle_attach,
32 };
33 esp_http_client_handle_t client = esp_http_client_init(&config);
34 if (!client) return ESP_FAIL;
35
36 esp_err_t err = esp_http_client_open(client, 0);
37 if (err != ESP_OK) {
38 esp_http_client_cleanup(client);
39 return ESP_FAIL;
40 }
41
42 int content_length = esp_http_client_fetch_headers(client);
43 (void)content_length;
44 int status = esp_http_client_get_status_code(client);
45 if (status_out) *status_out = status;
46
47 int resp_len = esp_http_client_read(client, resp_buf, resp_buf_size - 1);
48 esp_http_client_cleanup(client);
49
50 if (resp_len < 0) return ESP_FAIL;
51 resp_buf[resp_len] = '\0';
52 return ESP_OK;
53}
54
55static esp_err_t http_post_text(const char *url, const char *body, char *resp_buf, size_t resp_buf_size, int *status_out)
56{
57 esp_http_client_config_t config = {
58 .url = url,
59 .method = HTTP_METHOD_POST,
60 .timeout_ms = 15000,
61 .crt_bundle_attach = esp_crt_bundle_attach,
62 };
63 esp_http_client_handle_t client = esp_http_client_init(&config);
64 if (!client) return ESP_FAIL;
65
66 esp_http_client_set_header(client, "Content-Type", "text/plain");
67 esp_err_t err = esp_http_client_open(client, strlen(body));
68 if (err != ESP_OK) {
69 esp_http_client_cleanup(client);
70 return ESP_FAIL;
71 }
72
73 esp_http_client_write(client, body, strlen(body));
74
75 int content_length = esp_http_client_fetch_headers(client);
76 (void)content_length;
77 int status = esp_http_client_get_status_code(client);
78 if (status_out) *status_out = status;
79
80 int resp_len = esp_http_client_read(client, resp_buf, resp_buf_size - 1);
81 esp_http_client_cleanup(client);
82
83 if (resp_len < 0) return ESP_FAIL;
84 resp_buf[resp_len] = '\0';
85 return ESP_OK;
86}
87
88static bool parse_discovery_response(const char *json_str, tollgate_discovery_t *out)
89{
90 cJSON *root = cJSON_Parse(json_str);
91 if (!root) return false;
92
93 cJSON *kind = cJSON_GetObjectItemCaseSensitive(root, "kind");
94 if (!kind || !cJSON_IsNumber(kind) || kind->valueint != 10021) {
95 cJSON_Delete(root);
96 return false;
97 }
98
99 memset(out, 0, sizeof(tollgate_discovery_t));
100 out->is_tollgate = true;
101
102 cJSON *tags = cJSON_GetObjectItemCaseSensitive(root, "tags");
103 if (!tags || !cJSON_IsArray(tags)) {
104 cJSON_Delete(root);
105 return true;
106 }
107
108 int tag_count = cJSON_GetArraySize(tags);
109 for (int i = 0; i < tag_count; i++) {
110 cJSON *tag = cJSON_GetArrayItem(tags, i);
111 if (!tag || !cJSON_IsArray(tag)) continue;
112
113 int tag_len = cJSON_GetArraySize(tag);
114 if (tag_len < 2) continue;
115
116 cJSON *tag_name = cJSON_GetArrayItem(tag, 0);
117 if (!tag_name || !cJSON_IsString(tag_name)) continue;
118
119 if (strcmp(tag_name->valuestring, "metric") == 0) {
120 cJSON *val = cJSON_GetArrayItem(tag, 1);
121 if (val && cJSON_IsString(val)) {
122 strncpy(out->metric, val->valuestring, sizeof(out->metric) - 1);
123 }
124 } else if (strcmp(tag_name->valuestring, "step_size") == 0) {
125 cJSON *val = cJSON_GetArrayItem(tag, 1);
126 if (val && cJSON_IsString(val)) {
127 out->step_size_ms = atoi(val->valuestring);
128 }
129 } else if (strcmp(tag_name->valuestring, "price_per_step") == 0 && tag_len >= 6) {
130 cJSON *amount = cJSON_GetArrayItem(tag, 2);
131 cJSON *mint = cJSON_GetArrayItem(tag, 4);
132
133 if (amount && cJSON_IsString(amount)) {
134 out->price_per_step = atoi(amount->valuestring);
135 }
136 if (mint && cJSON_IsString(mint)) {
137 strncpy(out->mint_url, mint->valuestring, sizeof(out->mint_url) - 1);
138 }
139 }
140 }
141
142 cJSON_Delete(root);
143 return true;
144}
145
146static bool parse_session_response(const char *json_str, int64_t *allotment_ms_out)
147{
148 cJSON *root = cJSON_Parse(json_str);
149 if (!root) return false;
150
151 cJSON *kind = cJSON_GetObjectItemCaseSensitive(root, "kind");
152 if (!kind || !cJSON_IsNumber(kind)) {
153 cJSON_Delete(root);
154 return false;
155 }
156
157 if (kind->valueint != 1022) {
158 cJSON_Delete(root);
159 return false;
160 }
161
162 cJSON *tags = cJSON_GetObjectItemCaseSensitive(root, "tags");
163 if (tags && cJSON_IsArray(tags)) {
164 int tag_count = cJSON_GetArraySize(tags);
165 for (int i = 0; i < tag_count; i++) {
166 cJSON *tag = cJSON_GetArrayItem(tags, i);
167 if (!tag || !cJSON_IsArray(tag)) continue;
168 cJSON *tag_name = cJSON_GetArrayItem(tag, 0);
169 if (tag_name && cJSON_IsString(tag_name) && strcmp(tag_name->valuestring, "allotment") == 0) {
170 cJSON *val = cJSON_GetArrayItem(tag, 1);
171 if (val && cJSON_IsString(val)) {
172 *allotment_ms_out = atoll(val->valuestring);
173 }
174 }
175 }
176 }
177
178 cJSON_Delete(root);
179 return true;
180}
181
182static bool parse_usage_response(const char *resp, int64_t *remaining_out, int64_t *total_out)
183{
184 char remaining_str[32] = {0};
185 char total_str[32] = {0};
186 const char *slash = strchr(resp, '/');
187 if (!slash) return false;
188
189 size_t rlen = slash - resp;
190 if (rlen >= sizeof(remaining_str)) return false;
191 memcpy(remaining_str, resp, rlen);
192 strncpy(total_str, slash + 1, sizeof(total_str) - 1);
193
194 *remaining_out = atoll(remaining_str);
195 *total_out = atoll(total_str);
196 return true;
197}
198
199esp_err_t tollgate_client_detect(const char *gw_ip, tollgate_discovery_t *discovery)
200{
201 char url[128];
202 snprintf(url, sizeof(url), "http://%s:2121/", gw_ip);
203
204 char *resp_buf = malloc(4096);
205 if (!resp_buf) return ESP_ERR_NO_MEM;
206
207 int status = 0;
208 esp_err_t err = http_get(url, resp_buf, 4096, &status);
209
210 if (err != ESP_OK || status != 200) {
211 ESP_LOGI(TAG, "detect: no TollGate at %s (status=%d, err=%s)", gw_ip, status, esp_err_to_name(err));
212 free(resp_buf);
213 return ESP_ERR_NOT_FOUND;
214 }
215
216 bool found = parse_discovery_response(resp_buf, discovery);
217 free(resp_buf);
218
219 if (found && discovery->is_tollgate) {
220 ESP_LOGI(TAG, "TollGate detected at %s: price=%d sats, step=%dms, mint=%s, metric=%s",
221 gw_ip, discovery->price_per_step, discovery->step_size_ms,
222 discovery->mint_url, discovery->metric);
223 return ESP_OK;
224 }
225
226 ESP_LOGI(TAG, "detect: response at %s not a TollGate", gw_ip);
227 return ESP_ERR_NOT_FOUND;
228}
229
230static esp_err_t tollgate_client_pay(const char *gw_ip, int amount_sats, int64_t *allotment_ms_out)
231{
232 uint64_t balance = nucula_wallet_balance();
233 if (balance < (uint64_t)amount_sats) {
234 ESP_LOGW(TAG, "insufficient balance: %llu < %d", (unsigned long long)balance, amount_sats);
235 return ESP_ERR_INVALID_STATE;
236 }
237
238 char token_buf[8192];
239 esp_err_t err = nucula_wallet_send((uint64_t)amount_sats, token_buf, sizeof(token_buf));
240 if (err != ESP_OK) {
241 ESP_LOGE(TAG, "wallet send failed: %s", esp_err_to_name(err));
242 return err;
243 }
244
245 ESP_LOGI(TAG, "created token (%d sats), posting to %s:2121", amount_sats, gw_ip);
246
247 char url[128];
248 snprintf(url, sizeof(url), "http://%s:2121/", gw_ip);
249
250 char *resp_buf = malloc(8192);
251 if (!resp_buf) return ESP_ERR_NO_MEM;
252
253 int status = 0;
254 err = http_post_text(url, token_buf, resp_buf, 8192, &status);
255 if (err != ESP_OK) {
256 ESP_LOGE(TAG, "payment POST failed: %s", esp_err_to_name(err));
257 free(resp_buf);
258 return err;
259 }
260
261 ESP_LOGI(TAG, "payment response: status=%d, body=%s", status, resp_buf);
262
263 int64_t allotment = 0;
264 if (status == 200 && parse_session_response(resp_buf, &allotment)) {
265 *allotment_ms_out = allotment;
266 ESP_LOGI(TAG, "payment accepted: allotment=%lldms", (long long)allotment);
267 free(resp_buf);
268 return ESP_OK;
269 }
270
271 ESP_LOGE(TAG, "payment rejected: status=%d", status);
272 free(resp_buf);
273 return ESP_FAIL;
274}
275
276static esp_err_t tollgate_client_query_usage(const char *gw_ip, int64_t *remaining_ms, int64_t *total_ms)
277{
278 char url[128];
279 snprintf(url, sizeof(url), "http://%s:2121/usage", gw_ip);
280
281 char resp_buf[256];
282 int status = 0;
283 esp_err_t err = http_get(url, resp_buf, sizeof(resp_buf), &status);
284 if (err != ESP_OK || status != 200) {
285 return ESP_FAIL;
286 }
287
288 return parse_usage_response(resp_buf, remaining_ms, total_ms) ? ESP_OK : ESP_FAIL;
289}
290
291esp_err_t tollgate_client_init(void)
292{
293 s_state = TG_CLIENT_IDLE;
294 memset(&s_discovery, 0, sizeof(s_discovery));
295 memset(s_gw_ip, 0, sizeof(s_gw_ip));
296 s_allotment_ms = 0;
297 s_remaining_ms = -1;
298 s_last_pay_time_ms = 0;
299 s_retry_count = 0;
300 return ESP_OK;
301}
302
303esp_err_t tollgate_client_on_sta_connected(const char *gw_ip_str)
304{
305 const tollgate_config_t *cfg = tollgate_config_get();
306
307 if (!cfg->client_enabled) {
308 ESP_LOGI(TAG, "client disabled, skipping upstream detection");
309 return ESP_OK;
310 }
311
312 strncpy(s_gw_ip, gw_ip_str, sizeof(s_gw_ip) - 1);
313 s_state = TG_CLIENT_DETECTING;
314 s_retry_count = 0;
315
316 ESP_LOGI(TAG, "detecting upstream TollGate at %s", gw_ip_str);
317
318 esp_err_t err = tollgate_client_detect(gw_ip_str, &s_discovery);
319 if (err != ESP_OK) {
320 s_state = TG_CLIENT_NO_TOLLGATE;
321 ESP_LOGI(TAG, "no upstream TollGate detected");
322 return ESP_OK;
323 }
324
325 s_state = TG_CLIENT_NEEDS_PAY;
326
327 int steps = cfg->client_steps_to_buy;
328 if (steps <= 0) steps = 1;
329 int amount_sats = steps * s_discovery.price_per_step;
330
331 s_state = TG_CLIENT_PAYING;
332 int64_t allotment = 0;
333 err = tollgate_client_pay(gw_ip_str, amount_sats, &allotment);
334 if (err != ESP_OK) {
335 s_state = TG_CLIENT_ERROR;
336 ESP_LOGE(TAG, "upstream payment failed");
337 return err;
338 }
339
340 s_allotment_ms = allotment;
341 s_remaining_ms = allotment;
342 s_last_pay_time_ms = get_time_ms();
343 s_state = TG_CLIENT_PAID;
344
345 ESP_LOGI(TAG, "upstream TollGate paid: %lldms allotment", (long long)allotment);
346 return ESP_OK;
347}
348
349void tollgate_client_on_sta_disconnected(void)
350{
351 ESP_LOGI(TAG, "STA disconnected, resetting client state");
352 s_state = TG_CLIENT_IDLE;
353 memset(&s_discovery, 0, sizeof(s_discovery));
354 memset(s_gw_ip, 0, sizeof(s_gw_ip));
355 s_allotment_ms = 0;
356 s_remaining_ms = -1;
357 s_last_pay_time_ms = 0;
358 s_retry_count = 0;
359}
360
361void tollgate_client_tick(void)
362{
363 if (s_state != TG_CLIENT_PAID && s_state != TG_CLIENT_RENEWING && s_state != TG_CLIENT_ERROR) {
364 return;
365 }
366
367 if (s_state == TG_CLIENT_ERROR) {
368 const tollgate_config_t *cfg = tollgate_config_get();
369 int64_t now = get_time_ms();
370 int64_t elapsed = now - s_last_pay_time_ms;
371 if (elapsed < cfg->client_retry_interval_ms) return;
372
373 if (s_gw_ip[0] == '\0') return;
374
375 s_state = TG_CLIENT_PAYING;
376 int steps = cfg->client_steps_to_buy;
377 if (steps <= 0) steps = 1;
378 int amount_sats = steps * s_discovery.price_per_step;
379
380 int64_t allotment = 0;
381 esp_err_t err = tollgate_client_pay(s_gw_ip, amount_sats, &allotment);
382 if (err == ESP_OK) {
383 s_allotment_ms = allotment;
384 s_remaining_ms = allotment;
385 s_last_pay_time_ms = get_time_ms();
386 s_state = TG_CLIENT_PAID;
387 s_retry_count = 0;
388 ESP_LOGI(TAG, "retry payment succeeded: %lldms", (long long)allotment);
389 } else {
390 s_last_pay_time_ms = get_time_ms();
391 s_retry_count++;
392 s_state = TG_CLIENT_ERROR;
393 ESP_LOGW(TAG, "retry payment failed (attempt %d)", s_retry_count);
394 }
395 return;
396 }
397
398 if (s_gw_ip[0] == '\0') return;
399
400 int64_t remaining = 0, total = 0;
401 esp_err_t err = tollgate_client_query_usage(s_gw_ip, &remaining, &total);
402 if (err == ESP_OK) {
403 s_remaining_ms = remaining;
404 s_allotment_ms = total;
405 }
406
407 const tollgate_config_t *cfg = tollgate_config_get();
408 int threshold_pct = cfg->client_renewal_threshold_pct;
409 if (threshold_pct <= 0) threshold_pct = 20;
410
411 if (s_allotment_ms > 0 && s_remaining_ms >= 0) {
412 int remaining_pct = (int)((s_remaining_ms * 100) / s_allotment_ms);
413 if (remaining_pct <= threshold_pct) {
414 ESP_LOGI(TAG, "session nearing expiry (%lld/%lldms, %d%%), renewing",
415 (long long)s_remaining_ms, (long long)s_allotment_ms, remaining_pct);
416
417 s_state = TG_CLIENT_RENEWING;
418 int steps = cfg->client_steps_to_buy;
419 if (steps <= 0) steps = 1;
420 int amount_sats = steps * s_discovery.price_per_step;
421
422 int64_t allotment = 0;
423 err = tollgate_client_pay(s_gw_ip, amount_sats, &allotment);
424 if (err == ESP_OK) {
425 s_allotment_ms = allotment;
426 s_remaining_ms = allotment;
427 s_last_pay_time_ms = get_time_ms();
428 s_state = TG_CLIENT_PAID;
429 ESP_LOGI(TAG, "renewal succeeded: %lldms", (long long)allotment);
430 } else {
431 s_state = TG_CLIENT_ERROR;
432 s_last_pay_time_ms = get_time_ms();
433 ESP_LOGE(TAG, "renewal payment failed");
434 }
435 }
436 }
437}
438
439tollgate_client_state_t tollgate_client_get_state(void)
440{
441 return s_state;
442}
443
444const tollgate_discovery_t *tollgate_client_get_discovery(void)
445{
446 return &s_discovery;
447}
448
449int64_t tollgate_client_get_remaining_ms(void)
450{
451 return s_remaining_ms;
452}
453
454int64_t tollgate_client_get_allotment_ms(void)
455{
456 return s_allotment_ms;
457}
diff --git a/main/tollgate_client.h b/main/tollgate_client.h
new file mode 100644
index 0000000..2055e52
--- /dev/null
+++ b/main/tollgate_client.h
@@ -0,0 +1,46 @@
1#ifndef TOLLGATE_CLIENT_H
2#define TOLLGATE_CLIENT_H
3
4#include "esp_err.h"
5#include <stdint.h>
6#include <stdbool.h>
7
8#define TG_CLIENT_MAX_GW_IP_LEN 16
9#define TG_CLIENT_MAX_MINT_URL 256
10#define TG_CLIENT_MAX_METRIC 32
11
12typedef enum {
13 TG_CLIENT_IDLE,
14 TG_CLIENT_DETECTING,
15 TG_CLIENT_NO_TOLLGATE,
16 TG_CLIENT_NEEDS_PAY,
17 TG_CLIENT_PAYING,
18 TG_CLIENT_PAID,
19 TG_CLIENT_RENEWING,
20 TG_CLIENT_ERROR
21} tollgate_client_state_t;
22
23typedef struct {
24 bool is_tollgate;
25 int price_per_step;
26 int step_size_ms;
27 char mint_url[TG_CLIENT_MAX_MINT_URL];
28 char metric[TG_CLIENT_MAX_METRIC];
29} tollgate_discovery_t;
30
31esp_err_t tollgate_client_init(void);
32
33esp_err_t tollgate_client_on_sta_connected(const char *gw_ip_str);
34
35void tollgate_client_on_sta_disconnected(void);
36
37void tollgate_client_tick(void);
38
39tollgate_client_state_t tollgate_client_get_state(void);
40
41const tollgate_discovery_t *tollgate_client_get_discovery(void);
42
43int64_t tollgate_client_get_remaining_ms(void);
44int64_t tollgate_client_get_allotment_ms(void);
45
46#endif
diff --git a/main/tollgate_main.c b/main/tollgate_main.c
index 7fa1be1..d4dcf0d 100644
--- a/main/tollgate_main.c
+++ b/main/tollgate_main.c
@@ -19,6 +19,7 @@
19#include "tollgate_api.h" 19#include "tollgate_api.h"
20#include "nucula_wallet.h" 20#include "nucula_wallet.h"
21#include "wifistr.h" 21#include "wifistr.h"
22#include "tollgate_client.h"
22 23
23#define MAX_STA_RETRY 5 24#define MAX_STA_RETRY 5
24static const char *TAG = "tollgate_main"; 25static const char *TAG = "tollgate_main";
@@ -48,6 +49,7 @@ static void wifi_event_handler(void *arg, esp_event_base_t event_base,
48 } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { 49 } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
49 s_retry_count++; 50 s_retry_count++;
50 ESP_LOGW(TAG, "WiFi disconnected, retry %d/%d", s_retry_count, MAX_STA_RETRY); 51 ESP_LOGW(TAG, "WiFi disconnected, retry %d/%d", s_retry_count, MAX_STA_RETRY);
52 tollgate_client_on_sta_disconnected();
51 if (s_services_running) stop_services(); 53 if (s_services_running) stop_services();
52 if (s_retry_count < MAX_STA_RETRY) { 54 if (s_retry_count < MAX_STA_RETRY) {
53 esp_wifi_connect(); 55 esp_wifi_connect();
@@ -80,9 +82,17 @@ static void ip_event_handler(void *arg, esp_event_base_t event_base,
80{ 82{
81 if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { 83 if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
82 ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data; 84 ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
83 ESP_LOGI(TAG, "Got IP:" IPSTR, IP2STR(&event->ip_info.ip)); 85 ESP_LOGI(TAG, "Got IP:" IPSTR ", GW:" IPSTR, IP2STR(&event->ip_info.ip), IP2STR(&event->ip_info.gw));
84 s_retry_count = 0; 86 s_retry_count = 0;
85 xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT); 87 xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
88
89 const tollgate_config_t *cfg = tollgate_config_get();
90 nucula_wallet_init(cfg->mint_url);
91
92 char gw_ip_str[16];
93 snprintf(gw_ip_str, sizeof(gw_ip_str), IPSTR, IP2STR(&event->ip_info.gw));
94 tollgate_client_on_sta_connected(gw_ip_str);
95
86 start_services(); 96 start_services();
87 } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_LOST_IP) { 97 } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_LOST_IP) {
88 ESP_LOGW(TAG, "Lost IP address"); 98 ESP_LOGW(TAG, "Lost IP address");
@@ -126,8 +136,6 @@ static void start_services(void)
126 firewall_init(ap_ip_info.ip); 136 firewall_init(ap_ip_info.ip);
127 session_manager_init(); 137 session_manager_init();
128 138
129 xTaskCreate(wallet_init_task, "wallet_init", 32768, NULL, 5, NULL);
130
131 const tollgate_config_t *cfg = tollgate_config_get(); 139 const tollgate_config_t *cfg = tollgate_config_get();
132 dns_server_start(ap_ip_info.ip, upstream_dns); 140 dns_server_start(ap_ip_info.ip, upstream_dns);
133 captive_portal_start(cfg->ap_ip_str); 141 captive_portal_start(cfg->ap_ip_str);
@@ -273,5 +281,6 @@ void app_main(void)
273 while (1) { 281 while (1) {
274 vTaskDelay(pdMS_TO_TICKS(1000)); 282 vTaskDelay(pdMS_TO_TICKS(1000));
275 session_tick(); 283 session_tick();
284 tollgate_client_tick();
276 } 285 }
277} 286}
diff --git a/tests/unit/Makefile b/tests/unit/Makefile
index ab41175..e4ea388 100644
--- a/tests/unit/Makefile
+++ b/tests/unit/Makefile
@@ -21,7 +21,7 @@ LDFLAGS := -lmbedcrypto -lcjson
21 21
22SECP256K1_OBJ := secp256k1.o precomputed_ecmult.o precomputed_ecmult_gen.o 22SECP256K1_OBJ := secp256k1.o precomputed_ecmult.o precomputed_ecmult_gen.o
23 23
24TESTS := test_geohash test_identity test_nostr_event test_cashu test_session 24TESTS := test_geohash test_identity test_nostr_event test_cashu test_session test_tollgate_client
25 25
26.PHONY: all test clean $(TESTS) 26.PHONY: all test clean $(TESTS)
27 27
@@ -62,5 +62,8 @@ test_cashu: test_cashu.c $(REPO_ROOT)/main/cashu.c
62test_session: test_session.c $(REPO_ROOT)/main/session.c 62test_session: test_session.c $(REPO_ROOT)/main/session.c
63 $(CC) $(CFLAGS) $< $(REPO_ROOT)/main/session.c -o $@ $(LDFLAGS) 63 $(CC) $(CFLAGS) $< $(REPO_ROOT)/main/session.c -o $@ $(LDFLAGS)
64 64
65test_tollgate_client: test_tollgate_client.c
66 $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS)
67
65clean: 68clean:
66 rm -f $(TESTS) $(SECP256K1_OBJ) 69 rm -f $(TESTS) $(SECP256K1_OBJ)
diff --git a/tests/unit/stubs/esp_err.h b/tests/unit/stubs/esp_err.h
index 9bedb72..2a8e216 100644
--- a/tests/unit/stubs/esp_err.h
+++ b/tests/unit/stubs/esp_err.h
@@ -12,6 +12,7 @@ typedef int esp_err_t;
12#define ESP_ERR_INVALID_ARG 0x102 12#define ESP_ERR_INVALID_ARG 0x102
13#define ESP_ERR_NO_MEM 0x101 13#define ESP_ERR_NO_MEM 0x101
14#define ESP_ERR_NOT_FOUND 0x104 14#define ESP_ERR_NOT_FOUND 0x104
15#define ESP_ERR_INVALID_STATE 0x103
15 16
16static inline const char *esp_err_to_name(esp_err_t err) { (void)err; return "ESP_OK"; } 17static inline const char *esp_err_to_name(esp_err_t err) { (void)err; return "ESP_OK"; }
17 18
diff --git a/tests/unit/stubs/nucula_wallet.h b/tests/unit/stubs/nucula_wallet.h
new file mode 100644
index 0000000..260ec35
--- /dev/null
+++ b/tests/unit/stubs/nucula_wallet.h
@@ -0,0 +1,17 @@
1#ifndef STUBS_NUCULA_WALLET_H
2#define STUBS_NUCULA_WALLET_H
3
4#include "esp_err.h"
5#include <stdint.h>
6#include <stddef.h>
7
8esp_err_t nucula_wallet_init(const char *mint_url);
9esp_err_t nucula_wallet_receive(const char *token_str);
10esp_err_t nucula_wallet_send(uint64_t amount_sat, char *token_out, size_t token_out_size);
11uint64_t nucula_wallet_balance(void);
12int nucula_wallet_proof_count(void);
13char *nucula_wallet_proofs_json(void);
14esp_err_t nucula_wallet_swap_all(void);
15void nucula_wallet_print_status(void);
16
17#endif
diff --git a/tests/unit/test_tollgate_client.c b/tests/unit/test_tollgate_client.c
new file mode 100644
index 0000000..686ad19
--- /dev/null
+++ b/tests/unit/test_tollgate_client.c
@@ -0,0 +1,186 @@
1#include "test_framework.h"
2#include "../../main/config.h"
3#include <string.h>
4#include <stdio.h>
5#include <stdlib.h>
6#include <cjson/cJSON.h>
7
8static tollgate_config_t g_test_config;
9
10const tollgate_config_t *tollgate_config_get(void) {
11 return &g_test_config;
12}
13
14uint64_t nucula_wallet_balance(void) { return 100; }
15esp_err_t nucula_wallet_send(uint64_t a, char *b, size_t c) { (void)a; (void)b; (void)c; return ESP_OK; }
16
17#include "freertos/FreeRTOS.h"
18
19#include "../../main/tollgate_client.c"
20
21static void reset_state(void) {
22 s_state = TG_CLIENT_IDLE;
23 memset(&s_discovery, 0, sizeof(s_discovery));
24 memset(s_gw_ip, 0, sizeof(s_gw_ip));
25 s_allotment_ms = 0;
26 s_remaining_ms = -1;
27 s_last_pay_time_ms = 0;
28 s_retry_count = 0;
29}
30
31int main(void)
32{
33 printf("=== test_tollgate_client ===\n");
34
35 memset(&g_test_config, 0, sizeof(g_test_config));
36 g_test_config.client_enabled = true;
37 g_test_config.client_steps_to_buy = 1;
38 g_test_config.client_renewal_threshold_pct = 20;
39 g_test_config.client_retry_interval_ms = 30000;
40
41 printf("\n--- parse_discovery_response (valid kind=10021) ---\n");
42 {
43 const char *json = "{\"kind\":10021,\"pubkey\":\"abcdef\",\"tags\":["
44 "[\"metric\",\"milliseconds\"],"
45 "[\"step_size\",\"60000\"],"
46 "[\"price_per_step\",\"cashu\",\"21\",\"sat\",\"https://testnut.cashu.space\",\"1\"],"
47 "[\"tips\",\"1\",\"2\",\"5\"]"
48 "],\"content\":\"\"}";
49
50 tollgate_discovery_t disc;
51 bool ok = parse_discovery_response(json, &disc);
52 ASSERT(ok, "valid discovery parsed");
53 ASSERT(disc.is_tollgate, "is_tollgate=true");
54 ASSERT_EQ_INT(21, disc.price_per_step, "price_per_step=21");
55 ASSERT_EQ_INT(60000, disc.step_size_ms, "step_size_ms=60000");
56 ASSERT_EQ_STR("milliseconds", disc.metric, "metric=milliseconds");
57 ASSERT_EQ_STR("https://testnut.cashu.space", disc.mint_url, "mint_url");
58 }
59
60 printf("\n--- parse_discovery_response (wrong kind) ---\n");
61 {
62 const char *json = "{\"kind\":1,\"tags\":[]}";
63 tollgate_discovery_t disc;
64 bool ok = parse_discovery_response(json, &disc);
65 ASSERT(!ok, "wrong kind rejected");
66 }
67
68 printf("\n--- parse_discovery_response (no tags) ---\n");
69 {
70 const char *json = "{\"kind\":10021,\"content\":\"\"}";
71 tollgate_discovery_t disc = {0};
72 bool ok = parse_discovery_response(json, &disc);
73 ASSERT(ok, "no tags still parses");
74 ASSERT(disc.is_tollgate, "is_tollgate=true even without tags");
75 ASSERT_EQ_INT(0, disc.price_per_step, "price=0 when no tags");
76 }
77
78 printf("\n--- parse_discovery_response (garbage) ---\n");
79 {
80 tollgate_discovery_t disc;
81 bool ok = parse_discovery_response("not json", &disc);
82 ASSERT(!ok, "garbage rejected");
83 }
84
85 printf("\n--- parse_session_response (valid kind=1022) ---\n");
86 {
87 const char *json = "{\"kind\":1022,\"pubkey\":\"abcdef\",\"tags\":["
88 "[\"p\",\"unknown\"],"
89 "[\"device-identifier\",\"mac\",\"10.0.0.2\"],"
90 "[\"allotment\",\"60000\"],"
91 "[\"metric\",\"milliseconds\"]"
92 "],\"content\":\"\"}";
93
94 int64_t allotment = 0;
95 bool ok = parse_session_response(json, &allotment);
96 ASSERT(ok, "valid session parsed");
97 ASSERT_EQ_INT(60000, (int)allotment, "allotment=60000");
98 }
99
100 printf("\n--- parse_session_response (error kind=21023) ---\n");
101 {
102 const char *json = "{\"kind\":21023,\"tags\":[[\"level\",\"error\"],[\"code\",\"payment-error-token-spent\"]],\"content\":\"Token spent\"}";
103 int64_t allotment = 999;
104 bool ok = parse_session_response(json, &allotment);
105 ASSERT(!ok, "error kind rejected");
106 ASSERT_EQ_INT(999, (int)allotment, "allotment unchanged on error");
107 }
108
109 printf("\n--- parse_usage_response ---\n");
110 {
111 int64_t remaining = 0, total = 0;
112 bool ok = parse_usage_response("30000/60000", &remaining, &total);
113 ASSERT(ok, "valid usage parsed");
114 ASSERT_EQ_INT(30000, (int)remaining, "remaining=30000");
115 ASSERT_EQ_INT(60000, (int)total, "total=60000");
116 }
117
118 printf("\n--- parse_usage_response (-1/-1) ---\n");
119 {
120 int64_t remaining = 0, total = 0;
121 bool ok = parse_usage_response("-1/-1", &remaining, &total);
122 ASSERT(ok, "no-session usage parsed");
123 ASSERT_EQ_INT(-1, (int)remaining, "remaining=-1");
124 ASSERT_EQ_INT(-1, (int)total, "total=-1");
125 }
126
127 printf("\n--- parse_usage_response (garbage) ---\n");
128 {
129 int64_t remaining = 0, total = 0;
130 bool ok = parse_usage_response("garbage", &remaining, &total);
131 ASSERT(!ok, "no slash rejected");
132 }
133
134 printf("\n--- renewal threshold calculation ---\n");
135 {
136 reset_state();
137 s_state = TG_CLIENT_PAID;
138 s_allotment_ms = 60000;
139 s_remaining_ms = 10000;
140 strncpy(s_gw_ip, "10.0.0.1", sizeof(s_gw_ip) - 1);
141
142 int remaining_pct = (int)((s_remaining_ms * 100) / s_allotment_ms);
143 ASSERT(remaining_pct <= 20, "10/60 = 16% <= 20% triggers renewal");
144 }
145
146 printf("\n--- renewal threshold no-renew (above 20%%) ---\n");
147 {
148 reset_state();
149 s_allotment_ms = 60000;
150 s_remaining_ms = 50000;
151 int remaining_pct = (int)((s_remaining_ms * 100) / s_allotment_ms);
152 ASSERT(remaining_pct > 20, "50/60 = 83% > 20% no renewal");
153 }
154
155 printf("\n--- state machine: init ---\n");
156 {
157 reset_state();
158 tollgate_client_init();
159 ASSERT_EQ_INT(TG_CLIENT_IDLE, (int)tollgate_client_get_state(), "init sets IDLE");
160 }
161
162 printf("\n--- config: client_enabled=false ---\n");
163 {
164 reset_state();
165 g_test_config.client_enabled = false;
166 esp_err_t ret = tollgate_client_on_sta_connected("10.0.0.1");
167 ASSERT_EQ_INT(ESP_OK, (int)ret, "returns OK when disabled");
168 ASSERT_EQ_INT(TG_CLIENT_IDLE, (int)tollgate_client_get_state(), "stays IDLE when disabled");
169 g_test_config.client_enabled = true;
170 }
171
172 printf("\n--- state machine: disconnect resets ---\n");
173 {
174 reset_state();
175 s_state = TG_CLIENT_PAID;
176 strncpy(s_gw_ip, "10.0.0.1", sizeof(s_gw_ip) - 1);
177 s_allotment_ms = 60000;
178 s_remaining_ms = 30000;
179 tollgate_client_on_sta_disconnected();
180 ASSERT_EQ_INT(TG_CLIENT_IDLE, (int)tollgate_client_get_state(), "disconnect resets to IDLE");
181 ASSERT_EQ_INT(-1, (int)tollgate_client_get_remaining_ms(), "remaining reset to -1");
182 ASSERT_EQ_INT(0, (int)tollgate_client_get_allotment_ms(), "allotment reset to 0");
183 }
184
185 TEST_SUMMARY();
186}