From 78dd599277b8e8b2ddc39a4ae710ec91d737272e Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 17 May 2026 04:21:39 +0530 Subject: Phase 4: TollGate client detection + auto-payment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New tollgate_client.c/h: detect upstream TollGate (kind=10021), auto-pay via nucula wallet, session monitoring with 20% renewal - State machine: IDLE→DETECTING→NEEDS_PAY→PAYING→PAID→RENEWING - Blocking: upstream payment before local services start - Synchronous wallet init (was async task) - Client config: enabled, steps_to_buy, renewal_threshold_pct - Updated PLAN.md with Phases 4-7 (client, payout, bytes, CVM) - Updated CHECKLIST.md with all new phase items - 30 new unit tests (all passing), 116 total --- CHECKLIST.md | 148 +++++++++++- PLAN.md | 293 +++++++++++++++++------- main/CMakeLists.txt | 1 + main/config.c | 22 +- main/config.h | 5 + main/tollgate_client.c | 457 ++++++++++++++++++++++++++++++++++++++ main/tollgate_client.h | 46 ++++ main/tollgate_main.c | 15 +- tests/unit/Makefile | 5 +- tests/unit/stubs/esp_err.h | 1 + tests/unit/stubs/nucula_wallet.h | 17 ++ tests/unit/test_tollgate_client.c | 186 ++++++++++++++++ 12 files changed, 1112 insertions(+), 84 deletions(-) create mode 100644 main/tollgate_client.c create mode 100644 main/tollgate_client.h create mode 100644 tests/unit/stubs/nucula_wallet.h create mode 100644 tests/unit/test_tollgate_client.c diff --git a/CHECKLIST.md b/CHECKLIST.md index 9842390..dd21b0c 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -189,9 +189,151 @@ - [ ] Update `package.json` npm scripts for new paths - [ ] All `make test-*` targets work -## Phase 4: ESP32-to-OpenWRT TollGate Interop — NOT STARTED -- [ ] ESP32 pays OpenWRT TollGate using Cashu tokens -- [ ] Interoperability testing with existing OpenWRT TollGate on enx00e04c683d2d +## Phase 4: ESP32 TollGate Client Detection + Auto-Payment — IN PROGRESS + +### tollgate_client.c/h (New) +- [ ] Create `tollgate_client.h` — types: `tollgate_discovery_t`, `tollgate_client_state_t` enum (IDLE/DETECTING/NEEDS_PAY/PAYING/PAID/RENEWING) +- [ ] Create `tollgate_client.c` — detection, payment, monitoring, state machine +- [ ] `tollgate_client_detect(gw_ip)` — HTTP GET `http://{gw}:2121/`, parse kind=10021, extract price tags +- [ ] `tollgate_client_pay(gw_ip, amount_sats)` — `nucula_wallet_send()` → POST to upstream → parse kind=1022/21023 +- [ ] `tollgate_client_on_sta_connected()` — extract gw from DHCP, detect, pay (blocking) +- [ ] `tollgate_client_tick()` — GET `/usage`, renew at 20% remaining +- [ ] `tollgate_client_on_sta_disconnected()` — reset state +- [ ] `tollgate_client_get_usage(gw_ip)` — GET `/usage` → parse remaining/total + +### Config Changes +- [ ] Add to `config.h`: `client_enabled`, `client_steps_to_buy`, `client_renewal_threshold_pct`, `client_retry_interval_ms` +- [ ] Parse new fields in `config.c` + +### Integration (tollgate_main.c) +- [ ] Make wallet init synchronous (call `nucula_wallet_init()` directly, not as task) +- [ ] Add `tollgate_client_on_sta_connected()` in `ip_event_handler` (blocking, before `start_services()`) +- [ ] Add `tollgate_client_on_sta_disconnected()` in `wifi_event_handler` +- [ ] Add `tollgate_client_tick()` in main loop +- [ ] Update `main/CMakeLists.txt` — add `tollgate_client.c` + +### Unit Tests +- [ ] `tests/unit/test_tollgate_client.c` — discovery parsing, price extraction, state machine, renewal threshold +- [ ] All unit tests passing + +### Integration Tests +- [ ] ESP32→OpenWRT auto-payment (Scenario 4) +- [ ] ESP32→ESP32 auto-payment (Scenario 5, needs Board B) + +### Test Cases 39-43 +- [ ] Test 39: Client detection (kind=10021 parse) +- [ ] Test 40: Client payment flow (mock HTTP) +- [ ] Test 41: Session renewal (20% threshold) +- [ ] Test 42: ESP32→OpenWRT auto-pay +- [ ] Test 43: ESP32→ESP32 auto-pay + +## Phase 5: Lightning Auto-Payout — NOT STARTED + +### lnurl_pay.c/h (New) +- [ ] Create `lnurl_pay.h` — `lnurl_get_invoice(lightning_address, amount_sats, bolt11_out, out_size)` +- [ ] Create `lnurl_pay.c` — GET `.well-known/lnurlp/{user}` → parse callback → GET callback with amount → extract BOLT11 + +### lightning_payout.c/h (New) +- [ ] Create `lightning_payout.h` — `payout_recipient_t`, config, init/tick API +- [ ] Create `lightning_payout.c` — periodic balance check, threshold, multi-recipient split, melt with retry + +### nucula Bridge Extension +- [ ] Add `nucula_wallet_melt(bolt11, max_fee_sats)` to `nucula_wallet.h/cpp` +- [ ] Wraps `Wallet::request_melt_quote()` + `Wallet::melt_tokens()` (NUT-05) + +### Config Changes +- [ ] Add payout config to `config.h`: `payout_enabled`, `min_payout_amount`, `min_balance`, `fee_tolerance_pct`, `check_interval_s`, `recipients[]` +- [ ] Parse payout config in `config.c` + +### Integration (tollgate_main.c) +- [ ] Add periodic payout timer (60s interval) +- [ ] Update `main/CMakeLists.txt` + +### Unit Tests +- [ ] `tests/unit/test_lnurl_pay.c` — LNURL-pay URL construction, response parsing +- [ ] `tests/unit/test_lightning_payout.c` — threshold check, multi-recipient split, fee tolerance + +### Test Cases 44-48 +- [ ] Test 44: LNURL-pay flow +- [ ] Test 45: Payout threshold +- [ ] Test 46: Multi-recipient split +- [ ] Test 47: Melt with fee tolerance +- [ ] Test 48: Full payout cycle + +## Phase 6: Bytes-Based Billing — NOT STARTED + +### lwIP NAPT Stats Component (New) +- [ ] Create `components/lwip_napt_stats/` — patched `ip4_napt.c` with byte counters +- [ ] Add `uint64_t bytes_up/bytes_down` to `struct ip_napt_entry` +- [ ] Increment in `ip_napt_forward()` and `ip_napt_recv()` +- [ ] Add public API: `ip_napt_get_client_bytes(client_ip, &up, &down)` +- [ ] Create component CMakeLists.txt + +### Session Changes +- [ ] Add `allotment_bytes`, `bytes_consumed` to `session_t` +- [ ] Dual-metric `session_is_expired()` dispatches on metric type +- [ ] `session_add_bytes(client_ip, byte_count)` called from firewall counting + +### Config Changes +- [ ] Add `metric` field ("milliseconds" or "bytes") to `config.h` +- [ ] Add `step_size_bytes` to `config.h` +- [ ] Parse in `config.c` + +### TollGate API Changes +- [ ] Discovery endpoint advertises correct metric +- [ ] `/usage` returns byte-based or time-based values +- [ ] Allotment calculation dispatches on metric + +### Firewall Changes +- [ ] `firewall_count_traffic()` — queries NAPT byte counters per active client +- [ ] Called from `session_tick()` or main loop + +### Cashu Changes +- [ ] Unify `cashu_calculate_allotment()` for both metrics + +### Unit Tests +- [ ] `tests/unit/test_bytes_metric.c` — byte allotment calc, dual-metric session expiry + +### Test Cases 49-52 +- [ ] Test 49: Byte allotment calc +- [ ] Test 50: Byte session expiry +- [ ] Test 51: NAPT byte counting +- [ ] Test 52: Bytes metric end-to-end + +## Phase 7: ContextVM Server (MCP over Nostr) — NOT STARTED + +### NIP-44 Encryption (New) +- [ ] Create `nip44.h` — encrypt/decrypt API +- [ ] Create `nip44.c` — XChaCha20-Poly1305 + secp256k1 ECDH + conversation key derivation + +### MCP Handler (New) +- [ ] Create `mcp_handler.h` — tool registration, JSON-RPC parse/dispatch +- [ ] Create `mcp_handler.c` — register tools, handle requests, build responses + +### CVM Server (New) +- [ ] Create `cvm_server.h` — init/start/stop API +- [ ] Create `cvm_server.c` — WebSocket listener, DM subscription, NIP-44 decrypt, MCP dispatch + +### MCP Tool Registration +- [ ] `get_config`, `set_config`, `get_balance`, `get_sessions`, `get_usage` +- [ ] `set_payout`, `set_metric`, `set_price`, `wallet_send`, `wallet_melt` + +### Auth +- [ ] Only accept commands from owner npub + +### Integration (tollgate_main.c) +- [ ] Start CVM server alongside wifistr +- [ ] Update `main/CMakeLists.txt` + +### Unit Tests +- [ ] `tests/unit/test_nip44.c` — encrypt/decrypt roundtrip +- [ ] `tests/unit/test_mcp_handler.c` — JSON-RPC parse, tool dispatch + +### Test Cases 53-56 +- [ ] Test 53: NIP-44 encrypt/decrypt +- [ ] Test 54: MCP JSON-RPC parse +- [ ] Test 55: Config change via DM +- [ ] Test 56: Balance query via CVM ## Reminders - 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): | 37 | 5 consecutive payments | Loop | All authenticated | TODO | | 38 | Stress: rapid pay/expire | Loop with short sessions | No crash/leak | TODO | -### Phase 4: Mesh Service Discovery + ESP32-to-OpenWRT Interop — NOT STARTED +### Phase 4: ESP32 TollGate Client Detection + Auto-Payment — IN PROGRESS -**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. +**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. -#### 4A: Pre-Association Service Discovery via Vendor IE Beacons +**New files:** `main/tollgate_client.c`, `main/tollgate_client.h` -**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. +#### Architecture -**Solution: Vendor-Specific Information Elements in Beacon/Probe Response frames** +The ESP32 already runs `WIFI_MODE_APSTA` — STA connects to upstream WiFi. When STA gets an IP, the client module: +1. Extracts gateway IP from DHCP info +2. HTTP GET `http://{gw}:2121/` — check for TollGate (kind=10021) +3. Parse price/mint/metric from advertisement tags +4. Check wallet balance ≥ price +5. `nucula_wallet_send(price_sats)` → cashuA V3 token +6. POST token to `http://{gw}:2121/` +7. Parse kind=1022 response — session granted +8. Monitor: periodic GET `/usage`, auto-renew at 20% remaining -ESP-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. +#### Client State Machine ``` -┌─────────────────────────────────────────────────────────────┐ -│ Layer 2 (Pre-Association) │ -│ │ -│ Gateway AP broadcasts price in every Beacon (~100ms) │ -│ Client STA scans, reads price from beacon before connect │ -│ │ -│ ┌─────────────┐ ┌─────────────┐ │ -│ │ Gateway AP │ Beacon ──────────► │ Client STA │ │ -│ │ │ (with price IE) │ │ │ -│ │ Vendor IE: │ │ Scan result │ │ -│ │ OUI:TG │ │ includes │ │ -│ │ price/sats │ │ price data │ │ -│ │ step_ms │ └──────┬──────┘ │ -│ │ mint_url │ │ │ -│ └─────────────┘ Decision: connect? │ -│ │ │ -└──────────────────────────────────────────────┼──────────────┘ - │ - ┌────────────────▼──────────────┐ - │ Layer 3+ (Connected) │ - │ POST / with Cashu token │ - └───────────────────────────────┘ +IDLE → [STA got IP] → DETECTING → [kind=10021 found] → NEEDS_PAY + ↓ [no TollGate] ↓ [wallet has funds] + NO_TOLLGATE PAYING → [kind=1022] → PAID + ↓ [expiry near] + RENEWING → PAID ``` -**Beacon IE Payload Format (Vendor-Specific, Element ID 0xDD):** +#### Design Decisions +- **Blocking**: upstream payment must succeed before local services start +- **1 step per payment** (21 sats / 60s) — minimal, renew frequently +- **No budget cap** — keep paying as long as wallet has balance +- **Renew at 20% remaining** — re-pay when 80% of session consumed +- **Wallet init synchronous** — must complete before client can create tokens +#### Config Addition + +```json +{ + "client_enabled": true, + "client_steps_to_buy": 1, + "client_renewal_threshold_pct": 20, + "client_retry_interval_ms": 30000 +} +``` + +#### Integration with `tollgate_main.c` + +| Event | Action | +|-------|--------| +| `IP_EVENT_STA_GOT_IP` | Wallet init (sync) → `tollgate_client_on_sta_connected()` → start local services | +| `WIFI_EVENT_STA_DISCONNECTED` | `tollgate_client_on_sta_disconnected()` — reset state | +| Main loop (every 1s) | `tollgate_client_tick()` — check usage, renew if needed | + +#### Test Cases + +| # | Test | Method | Pass Criteria | Status | +|---|------|--------|---------------|--------| +| 39 | Client detection (kind=10021) | Unit test parse | Correct price/mint/metric extracted | TODO | +| 40 | Client payment flow | Mock HTTP | Token POSTed, kind=1022 parsed | TODO | +| 41 | Session renewal | Mock usage < 20% | Re-payment triggered | TODO | +| 42 | ESP32→OpenWRT auto-pay | Integration | NAT works after payment | TODO | +| 43 | ESP32→ESP32 auto-pay | Cross-board | Board B pays Board A | TODO | + +#### Vendor IE Beacon (Pre-Association Discovery) — DEFERRED + +Pre-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. + +### Phase 5: Lightning Auto-Payout — NOT STARTED + +**Goal:** When wallet balance exceeds a configurable threshold, automatically pay out to Lightning addresses via LNURL-pay + Cashu NUT-05 melt. + +**New files:** `main/lnurl_pay.c`, `main/lnurl_pay.h`, `main/lightning_payout.c`, `main/lightning_payout.h` +**Modified files:** `components/nucula_lib/nucula_wallet.h`, `components/nucula_lib/nucula_wallet.cpp` + +#### Architecture + +Mirrors the Go implementation in `tollgate-module-basic-go/src/merchant/` and `src/lightning/`: + +``` +Every 60s (per mint): + balance = nucula_wallet_balance() + balance >= min_payout_amount? No → skip + Yes: + payout_pool = balance - min_balance + For each recipient (factor): + share = payout_pool * factor + bolt11 = lnurl_get_invoice(lightning_address, share) + nucula_wallet_melt(bolt11, share + fee_tolerance%) +``` + +#### LNURL-pay Protocol (`lnurl_pay.c/h`) + +Pure HTTP implementation (2 GETs): +1. `GET https://{domain}/.well-known/lnurlp/{username}` → parse callback URL, min/max amounts +2. `GET {callback}?amount={millisats}` → extract BOLT11 invoice from response + +#### nucula Bridge Extension + +Add to `nucula_wallet.h`: +```c +esp_err_t nucula_wallet_melt(const char *bolt11_invoice, uint64_t max_fee_sats); +``` + +Wraps `Wallet::request_melt_quote()` + `Wallet::melt_tokens()` (NUT-05). + +#### Config Addition + +```json +{ + "payout": { + "enabled": true, + "min_payout_amount": 128, + "min_balance": 64, + "fee_tolerance_pct": 10, + "check_interval_s": 60, + "recipients": [ + {"lightning_address": "user@domain.com", "factor": 0.79}, + {"lightning_address": "dev@domain.com", "factor": 0.21} + ] + } +} +``` + +#### Test Cases + +| # | Test | Method | Pass Criteria | Status | +|---|------|--------|---------------|--------| +| 44 | LNURL-pay flow | Unit test HTTP parse | Correct BOLT11 extracted | TODO | +| 45 | Payout threshold | Unit test | Skip when below, trigger when above | TODO | +| 46 | Multi-recipient split | Unit test | Factors sum to 1.0 | TODO | +| 47 | Melt with fee tolerance | Integration | Invoice paid, change received | TODO | +| 48 | Full payout cycle | E2E | Wallet drains to min_balance | TODO | + +### Phase 6: Bytes-Based Billing — NOT STARTED + +**Goal:** Support both time-based (milliseconds) and data-based (bytes) billing metrics. Mirrors the Go implementation's dual-metric system. + +#### lwIP NAPT Byte Counting (Managed Component) + +**New component:** `components/lwip_napt_stats/` — patched copy of ESP-IDF's `ip4_napt.c` with per-entry byte counters. + +Patch adds to `struct ip_napt_entry`: +```c +uint64_t bytes_up; // bytes uploaded (client → internet) +uint64_t bytes_down; // bytes downloaded (internet → client) ``` -┌──────────┬────────┬─────────────┬──────────────┬──────────────────┐ -│ element_id│ length │ vendor_oui │ oui_type │ payload │ -│ (0xDD) │ │ (3 bytes) │ (1 byte) │ (variable) │ -├──────────┼────────┼─────────────┼──────────────┼──────────────────┤ -│ 0xDD │ N │ "TG" │ 0x01 (price) │ See below │ -│ │ │ 0x54:0x47 │ │ │ -└──────────┴────────┴─────────────┴──────────────┴──────────────────┘ - -Price Payload (oui_type 0x01): -┌─────────────┬─────────────┬──────────────┬───────────────┬────────────┐ -│ version (1B)│ price (2B) │ step_ms (2B) │ fee_ppk (2B) │ hop_count │ -│ = 0x01 │ sat/step │ ms/step │ or 0 │ (1B) │ -├─────────────┼─────────────┼──────────────┼───────────────┼────────────┤ -│ 0x01 │ uint16_le │ uint16_le │ uint16_le │ uint8 │ -└─────────────┴─────────────┴──────────────┴───────────────┴────────────┘ -Total payload: 9 bytes (fits easily in beacon, typical budget ~200 bytes) + +Increment in `ip_napt_forward()` (upload) and `ip_napt_recv()` (download). + +New public API: +```c +void ip_napt_get_client_bytes(uint32_t client_ip, uint64_t *bytes_up, uint64_t *bytes_down); ``` -**Implementation:** +~30 line patch. Lives in the project repo as a managed component, survives ESP-IDF updates. -**AP Side (Gateway — `beacon_price.c/h`):** -- `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` -- `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) -- Price derived from `tollgate_config_t` fields (`price_per_step`, `step_size_ms`) -- Can be called on-the-fly when market conditions change (e.g., upstream price changes) +#### Session Changes -**STA Side (Client — `beacon_scan.c/h`):** -- `beacon_scan_prices(wifi_ap_record_t *aps, int count, tollgate_price_t *prices, int *price_count)` — given scan results, extract price IEs -- Uses `esp_wifi_set_vendor_ie_cb()` to register a callback that fires during scan -- Or parses `vendor_ie_data_t` from scan results if available in `wifi_ap_record_t` -- Returns array of `{bssid, ssid, price_sat, step_ms, fee_ppk, hop_count}` -- Client selects cheapest/upstream gateway from scan results before connecting +`session_t` gains dual-metric support: +```c +uint64_t allotment_bytes; +uint64_t bytes_consumed; +``` -**Integration with existing config:** -- OUI: `0x54, 0x47` ("TG" in ASCII) — unique to TollGate -- oui_type: `0x01` = price advertisement, `0x02` = mesh routing (future) -- `hop_count`: indicates network depth (0 = directly connected to internet, 1 = one hop away) -- Price updates are rate-limited to once per 5 seconds to avoid beacon churn +`session_is_expired()` dispatches on metric type: +- `"milliseconds"`: elapsed time ≥ allotment_ms +- `"bytes"`: bytes_consumed ≥ allotment_bytes -**GL-MT3000 (OpenWrt) Compatibility:** -- OpenWrt supports vendor IEs via `hostapd_cli -i wlan0 set vendor_elements ` + `hostapd_cli -i wlan0 update_beacon` -- Client scans via `iw dev wlan0 scan` show vendor elements -- Requires stock OpenWrt 24 firmware (not GL.iNet default) for mac80211 driver access -- Same OUI/payload format ensures ESP32 ↔ OpenWrt interop +#### Config Addition -**Key Benefits:** -- Zero connection overhead for price discovery -- Works during normal passive/active scanning (no extra frames) -- Prices update live without disconnecting clients -- Supports multi-hop mesh routing via `hop_count` -- Compatible with both ESP32 and Linux (OpenWrt) platforms +```json +{ + "metric": "milliseconds", + "step_size_bytes": 22020096 +} +``` -#### 4B: ESP32-to-OpenWRT TollGate Interop +#### Test Cases -**Goal:** ESP32 can pay OpenWRT TollGate using Cashu tokens. Full interoperability with existing OpenWRT-based TollGate infrastructure. +| # | Test | Method | Pass Criteria | Status | +|---|------|--------|---------------|--------| +| 49 | Byte allotment calc | Unit test | Correct bytes per step | TODO | +| 50 | Byte session expiry | Unit test | Expired when consumed ≥ allotment | TODO | +| 51 | NAPT byte counting | Integration | Counters match actual traffic | TODO | +| 52 | Bytes metric end-to-end | E2E | Client disconnected after data cap | TODO | + +### Phase 7: ContextVM Server (MCP over Nostr) — NOT STARTED + +**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. + +**New files:** `main/cvm_server.c`, `main/cvm_server.h`, `main/nip44.c`, `main/nip44.h`, `main/mcp_handler.c`, `main/mcp_handler.h` + +#### Architecture + +ContextVM uses MCP (JSON-RPC 2.0) over NIP-44 encrypted Nostr DMs: +1. ESP32 subscribes to Nostr relays for DMs addressed to its npub +2. Incoming DMs are NIP-44 decrypted, parsed as MCP JSON-RPC requests +3. Dispatched to registered tool handlers +4. Responses sent back via NIP-44 encrypted DM + +#### MCP Tools Exposed + +| Tool | Input | Output | +|------|-------|--------| +| `get_config` | — | Full config JSON | +| `set_config` | `{key: value}` | Success/error | +| `get_balance` | — | `{balance, proof_count}` | +| `get_sessions` | — | Array of active sessions | +| `get_usage` | — | Upstream usage if client active | +| `set_payout` | `{recipients: [...]}` | Success/error | +| `set_metric` | `{"bytes" or "milliseconds"}` | Success/error | +| `set_price` | `{price_per_step: N}` | Success/error | +| `wallet_send` | `{amount_sats: N}` | `{token: "cashuA..."}` | +| `wallet_melt` | `{bolt11: "ln..."}` | `{preimage: "..."}` | + +#### Auth + +Only accept commands from owner npub (derived from nsec in config.json). + +#### Dependencies + +- XChaCha20-Poly1305 (from mbedtls or libsodium) +- Base64url encoding (already in cashu code) +- WebSocket listener (extends existing wifistr infrastructure) +- NIP-44 v2 encryption/decryption + +#### Test Cases + +| # | Test | Method | Pass Criteria | Status | +|---|------|--------|---------------|--------| +| 53 | NIP-44 encrypt/decrypt | Unit test | Roundtrip matches | TODO | +| 54 | MCP JSON-RPC parse | Unit test | Correct dispatch | TODO | +| 55 | Config change via DM | Integration | ESP32 applies new config | TODO | +| 56 | Balance query via CVM | Integration | Returns correct balance | TODO | -## Total: 38 + 20 Tests across 4 phases +## Total: 56 Tests across 7 phases ## Testing Infrastructure @@ -407,6 +547,7 @@ Host-compiled C tests that verify pure-logic functions with known input/output v | `test_nostr_event.c` | `nostr_event.c` | NIP-01 event ID (SHA-256 of canonical JSON), Schnorr signature generation + verification, JSON serialization | | `test_cashu.c` | `cashu.c` | `cashu_decode_token()`, `cashu_calculate_allotment_ms()`, `cashu_is_mint_accepted()` | | `test_session.c` | `session.c` | Session lifecycle: create/find/extend/expire/revoke, spent-secret dedup | +| `test_tollgate_client.c` | `tollgate_client.c` | Discovery parsing, payment flow, renewal logic, state machine | **Run:** `make test-unit` 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" "nostr_event.c" "geohash.c" "wifistr.c" + "tollgate_client.c" INCLUDE_DIRS "." REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server 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) g_config.step_size_ms = 60000; g_config.persist_threshold_sats = 1; g_config.nostr_publish_interval_s = 21600; + g_config.client_enabled = false; + g_config.client_steps_to_buy = 1; + g_config.client_renewal_threshold_pct = 20; + g_config.client_retry_interval_ms = 30000; esp_vfs_spiffs_conf_t conf = { .base_path = "/spiffs", @@ -49,7 +53,11 @@ esp_err_t tollgate_config_init(void) "\"step_size_ms\":60000," "\"nostr_geohash\":\"u281w0dfz\"," "\"nostr_relays\":[\"wss://relay.damus.io\",\"wss://nos.lol\"]," - "\"nostr_publish_interval_s\":21600" + "\"nostr_publish_interval_s\":21600," + "\"client_enabled\":false," + "\"client_steps_to_buy\":1," + "\"client_renewal_threshold_pct\":20," + "\"client_retry_interval_ms\":30000" "}"; f = fopen("/spiffs/config.json", "w"); if (f) { @@ -146,6 +154,18 @@ esp_err_t tollgate_config_init(void) cJSON *pub_interval = cJSON_GetObjectItem(root, "nostr_publish_interval_s"); if (pub_interval) g_config.nostr_publish_interval_s = pub_interval->valueint; + cJSON *client_enabled = cJSON_GetObjectItem(root, "client_enabled"); + if (client_enabled && cJSON_IsBool(client_enabled)) g_config.client_enabled = cJSON_IsTrue(client_enabled); + + cJSON *client_steps = cJSON_GetObjectItem(root, "client_steps_to_buy"); + if (client_steps) g_config.client_steps_to_buy = client_steps->valueint; + + cJSON *client_renewal = cJSON_GetObjectItem(root, "client_renewal_threshold_pct"); + if (client_renewal) g_config.client_renewal_threshold_pct = client_renewal->valueint; + + cJSON *client_retry = cJSON_GetObjectItem(root, "client_retry_interval_ms"); + if (client_retry) g_config.client_retry_interval_ms = client_retry->valueint; + cJSON_Delete(root); 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 { int nostr_publish_interval_s; bool identity_initialized; + + bool client_enabled; + int client_steps_to_buy; + int client_renewal_threshold_pct; + int client_retry_interval_ms; } tollgate_config_t; void 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 @@ +#include "tollgate_client.h" +#include "config.h" +#include "nucula_wallet.h" +#include "esp_log.h" +#include "esp_http_client.h" +#include "esp_crt_bundle.h" +#include "cJSON.h" +#include +#include + +static const char *TAG = "tg_client"; + +static tollgate_client_state_t s_state = TG_CLIENT_IDLE; +static tollgate_discovery_t s_discovery = {0}; +static char s_gw_ip[TG_CLIENT_MAX_GW_IP_LEN] = {0}; +static int64_t s_allotment_ms = 0; +static int64_t s_remaining_ms = -1; +static int64_t s_last_pay_time_ms = 0; +static int s_retry_count = 0; + +static int64_t get_time_ms(void) { + return (int64_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); +} + +static esp_err_t http_get(const char *url, char *resp_buf, size_t resp_buf_size, int *status_out) +{ + esp_http_client_config_t config = { + .url = url, + .method = HTTP_METHOD_GET, + .timeout_ms = 10000, + .crt_bundle_attach = esp_crt_bundle_attach, + }; + esp_http_client_handle_t client = esp_http_client_init(&config); + if (!client) return ESP_FAIL; + + esp_err_t err = esp_http_client_open(client, 0); + if (err != ESP_OK) { + esp_http_client_cleanup(client); + return ESP_FAIL; + } + + int content_length = esp_http_client_fetch_headers(client); + (void)content_length; + int status = esp_http_client_get_status_code(client); + if (status_out) *status_out = status; + + int resp_len = esp_http_client_read(client, resp_buf, resp_buf_size - 1); + esp_http_client_cleanup(client); + + if (resp_len < 0) return ESP_FAIL; + resp_buf[resp_len] = '\0'; + return ESP_OK; +} + +static esp_err_t http_post_text(const char *url, const char *body, char *resp_buf, size_t resp_buf_size, int *status_out) +{ + esp_http_client_config_t config = { + .url = url, + .method = HTTP_METHOD_POST, + .timeout_ms = 15000, + .crt_bundle_attach = esp_crt_bundle_attach, + }; + esp_http_client_handle_t client = esp_http_client_init(&config); + if (!client) return ESP_FAIL; + + esp_http_client_set_header(client, "Content-Type", "text/plain"); + esp_err_t err = esp_http_client_open(client, strlen(body)); + if (err != ESP_OK) { + esp_http_client_cleanup(client); + return ESP_FAIL; + } + + esp_http_client_write(client, body, strlen(body)); + + int content_length = esp_http_client_fetch_headers(client); + (void)content_length; + int status = esp_http_client_get_status_code(client); + if (status_out) *status_out = status; + + int resp_len = esp_http_client_read(client, resp_buf, resp_buf_size - 1); + esp_http_client_cleanup(client); + + if (resp_len < 0) return ESP_FAIL; + resp_buf[resp_len] = '\0'; + return ESP_OK; +} + +static bool parse_discovery_response(const char *json_str, tollgate_discovery_t *out) +{ + cJSON *root = cJSON_Parse(json_str); + if (!root) return false; + + cJSON *kind = cJSON_GetObjectItemCaseSensitive(root, "kind"); + if (!kind || !cJSON_IsNumber(kind) || kind->valueint != 10021) { + cJSON_Delete(root); + return false; + } + + memset(out, 0, sizeof(tollgate_discovery_t)); + out->is_tollgate = true; + + cJSON *tags = cJSON_GetObjectItemCaseSensitive(root, "tags"); + if (!tags || !cJSON_IsArray(tags)) { + cJSON_Delete(root); + return true; + } + + int tag_count = cJSON_GetArraySize(tags); + for (int i = 0; i < tag_count; i++) { + cJSON *tag = cJSON_GetArrayItem(tags, i); + if (!tag || !cJSON_IsArray(tag)) continue; + + int tag_len = cJSON_GetArraySize(tag); + if (tag_len < 2) continue; + + cJSON *tag_name = cJSON_GetArrayItem(tag, 0); + if (!tag_name || !cJSON_IsString(tag_name)) continue; + + if (strcmp(tag_name->valuestring, "metric") == 0) { + cJSON *val = cJSON_GetArrayItem(tag, 1); + if (val && cJSON_IsString(val)) { + strncpy(out->metric, val->valuestring, sizeof(out->metric) - 1); + } + } else if (strcmp(tag_name->valuestring, "step_size") == 0) { + cJSON *val = cJSON_GetArrayItem(tag, 1); + if (val && cJSON_IsString(val)) { + out->step_size_ms = atoi(val->valuestring); + } + } else if (strcmp(tag_name->valuestring, "price_per_step") == 0 && tag_len >= 6) { + cJSON *amount = cJSON_GetArrayItem(tag, 2); + cJSON *mint = cJSON_GetArrayItem(tag, 4); + + if (amount && cJSON_IsString(amount)) { + out->price_per_step = atoi(amount->valuestring); + } + if (mint && cJSON_IsString(mint)) { + strncpy(out->mint_url, mint->valuestring, sizeof(out->mint_url) - 1); + } + } + } + + cJSON_Delete(root); + return true; +} + +static bool parse_session_response(const char *json_str, int64_t *allotment_ms_out) +{ + cJSON *root = cJSON_Parse(json_str); + if (!root) return false; + + cJSON *kind = cJSON_GetObjectItemCaseSensitive(root, "kind"); + if (!kind || !cJSON_IsNumber(kind)) { + cJSON_Delete(root); + return false; + } + + if (kind->valueint != 1022) { + cJSON_Delete(root); + return false; + } + + cJSON *tags = cJSON_GetObjectItemCaseSensitive(root, "tags"); + if (tags && cJSON_IsArray(tags)) { + int tag_count = cJSON_GetArraySize(tags); + for (int i = 0; i < tag_count; i++) { + cJSON *tag = cJSON_GetArrayItem(tags, i); + if (!tag || !cJSON_IsArray(tag)) continue; + cJSON *tag_name = cJSON_GetArrayItem(tag, 0); + if (tag_name && cJSON_IsString(tag_name) && strcmp(tag_name->valuestring, "allotment") == 0) { + cJSON *val = cJSON_GetArrayItem(tag, 1); + if (val && cJSON_IsString(val)) { + *allotment_ms_out = atoll(val->valuestring); + } + } + } + } + + cJSON_Delete(root); + return true; +} + +static bool parse_usage_response(const char *resp, int64_t *remaining_out, int64_t *total_out) +{ + char remaining_str[32] = {0}; + char total_str[32] = {0}; + const char *slash = strchr(resp, '/'); + if (!slash) return false; + + size_t rlen = slash - resp; + if (rlen >= sizeof(remaining_str)) return false; + memcpy(remaining_str, resp, rlen); + strncpy(total_str, slash + 1, sizeof(total_str) - 1); + + *remaining_out = atoll(remaining_str); + *total_out = atoll(total_str); + return true; +} + +esp_err_t tollgate_client_detect(const char *gw_ip, tollgate_discovery_t *discovery) +{ + char url[128]; + snprintf(url, sizeof(url), "http://%s:2121/", gw_ip); + + char *resp_buf = malloc(4096); + if (!resp_buf) return ESP_ERR_NO_MEM; + + int status = 0; + esp_err_t err = http_get(url, resp_buf, 4096, &status); + + if (err != ESP_OK || status != 200) { + ESP_LOGI(TAG, "detect: no TollGate at %s (status=%d, err=%s)", gw_ip, status, esp_err_to_name(err)); + free(resp_buf); + return ESP_ERR_NOT_FOUND; + } + + bool found = parse_discovery_response(resp_buf, discovery); + free(resp_buf); + + if (found && discovery->is_tollgate) { + ESP_LOGI(TAG, "TollGate detected at %s: price=%d sats, step=%dms, mint=%s, metric=%s", + gw_ip, discovery->price_per_step, discovery->step_size_ms, + discovery->mint_url, discovery->metric); + return ESP_OK; + } + + ESP_LOGI(TAG, "detect: response at %s not a TollGate", gw_ip); + return ESP_ERR_NOT_FOUND; +} + +static esp_err_t tollgate_client_pay(const char *gw_ip, int amount_sats, int64_t *allotment_ms_out) +{ + uint64_t balance = nucula_wallet_balance(); + if (balance < (uint64_t)amount_sats) { + ESP_LOGW(TAG, "insufficient balance: %llu < %d", (unsigned long long)balance, amount_sats); + return ESP_ERR_INVALID_STATE; + } + + char token_buf[8192]; + esp_err_t err = nucula_wallet_send((uint64_t)amount_sats, token_buf, sizeof(token_buf)); + if (err != ESP_OK) { + ESP_LOGE(TAG, "wallet send failed: %s", esp_err_to_name(err)); + return err; + } + + ESP_LOGI(TAG, "created token (%d sats), posting to %s:2121", amount_sats, gw_ip); + + char url[128]; + snprintf(url, sizeof(url), "http://%s:2121/", gw_ip); + + char *resp_buf = malloc(8192); + if (!resp_buf) return ESP_ERR_NO_MEM; + + int status = 0; + err = http_post_text(url, token_buf, resp_buf, 8192, &status); + if (err != ESP_OK) { + ESP_LOGE(TAG, "payment POST failed: %s", esp_err_to_name(err)); + free(resp_buf); + return err; + } + + ESP_LOGI(TAG, "payment response: status=%d, body=%s", status, resp_buf); + + int64_t allotment = 0; + if (status == 200 && parse_session_response(resp_buf, &allotment)) { + *allotment_ms_out = allotment; + ESP_LOGI(TAG, "payment accepted: allotment=%lldms", (long long)allotment); + free(resp_buf); + return ESP_OK; + } + + ESP_LOGE(TAG, "payment rejected: status=%d", status); + free(resp_buf); + return ESP_FAIL; +} + +static esp_err_t tollgate_client_query_usage(const char *gw_ip, int64_t *remaining_ms, int64_t *total_ms) +{ + char url[128]; + snprintf(url, sizeof(url), "http://%s:2121/usage", gw_ip); + + char resp_buf[256]; + int status = 0; + esp_err_t err = http_get(url, resp_buf, sizeof(resp_buf), &status); + if (err != ESP_OK || status != 200) { + return ESP_FAIL; + } + + return parse_usage_response(resp_buf, remaining_ms, total_ms) ? ESP_OK : ESP_FAIL; +} + +esp_err_t tollgate_client_init(void) +{ + s_state = TG_CLIENT_IDLE; + memset(&s_discovery, 0, sizeof(s_discovery)); + memset(s_gw_ip, 0, sizeof(s_gw_ip)); + s_allotment_ms = 0; + s_remaining_ms = -1; + s_last_pay_time_ms = 0; + s_retry_count = 0; + return ESP_OK; +} + +esp_err_t tollgate_client_on_sta_connected(const char *gw_ip_str) +{ + const tollgate_config_t *cfg = tollgate_config_get(); + + if (!cfg->client_enabled) { + ESP_LOGI(TAG, "client disabled, skipping upstream detection"); + return ESP_OK; + } + + strncpy(s_gw_ip, gw_ip_str, sizeof(s_gw_ip) - 1); + s_state = TG_CLIENT_DETECTING; + s_retry_count = 0; + + ESP_LOGI(TAG, "detecting upstream TollGate at %s", gw_ip_str); + + esp_err_t err = tollgate_client_detect(gw_ip_str, &s_discovery); + if (err != ESP_OK) { + s_state = TG_CLIENT_NO_TOLLGATE; + ESP_LOGI(TAG, "no upstream TollGate detected"); + return ESP_OK; + } + + s_state = TG_CLIENT_NEEDS_PAY; + + int steps = cfg->client_steps_to_buy; + if (steps <= 0) steps = 1; + int amount_sats = steps * s_discovery.price_per_step; + + s_state = TG_CLIENT_PAYING; + int64_t allotment = 0; + err = tollgate_client_pay(gw_ip_str, amount_sats, &allotment); + if (err != ESP_OK) { + s_state = TG_CLIENT_ERROR; + ESP_LOGE(TAG, "upstream payment failed"); + return err; + } + + s_allotment_ms = allotment; + s_remaining_ms = allotment; + s_last_pay_time_ms = get_time_ms(); + s_state = TG_CLIENT_PAID; + + ESP_LOGI(TAG, "upstream TollGate paid: %lldms allotment", (long long)allotment); + return ESP_OK; +} + +void tollgate_client_on_sta_disconnected(void) +{ + ESP_LOGI(TAG, "STA disconnected, resetting client state"); + s_state = TG_CLIENT_IDLE; + memset(&s_discovery, 0, sizeof(s_discovery)); + memset(s_gw_ip, 0, sizeof(s_gw_ip)); + s_allotment_ms = 0; + s_remaining_ms = -1; + s_last_pay_time_ms = 0; + s_retry_count = 0; +} + +void tollgate_client_tick(void) +{ + if (s_state != TG_CLIENT_PAID && s_state != TG_CLIENT_RENEWING && s_state != TG_CLIENT_ERROR) { + return; + } + + if (s_state == TG_CLIENT_ERROR) { + const tollgate_config_t *cfg = tollgate_config_get(); + int64_t now = get_time_ms(); + int64_t elapsed = now - s_last_pay_time_ms; + if (elapsed < cfg->client_retry_interval_ms) return; + + if (s_gw_ip[0] == '\0') return; + + s_state = TG_CLIENT_PAYING; + int steps = cfg->client_steps_to_buy; + if (steps <= 0) steps = 1; + int amount_sats = steps * s_discovery.price_per_step; + + int64_t allotment = 0; + esp_err_t err = tollgate_client_pay(s_gw_ip, amount_sats, &allotment); + if (err == ESP_OK) { + s_allotment_ms = allotment; + s_remaining_ms = allotment; + s_last_pay_time_ms = get_time_ms(); + s_state = TG_CLIENT_PAID; + s_retry_count = 0; + ESP_LOGI(TAG, "retry payment succeeded: %lldms", (long long)allotment); + } else { + s_last_pay_time_ms = get_time_ms(); + s_retry_count++; + s_state = TG_CLIENT_ERROR; + ESP_LOGW(TAG, "retry payment failed (attempt %d)", s_retry_count); + } + return; + } + + if (s_gw_ip[0] == '\0') return; + + int64_t remaining = 0, total = 0; + esp_err_t err = tollgate_client_query_usage(s_gw_ip, &remaining, &total); + if (err == ESP_OK) { + s_remaining_ms = remaining; + s_allotment_ms = total; + } + + const tollgate_config_t *cfg = tollgate_config_get(); + int threshold_pct = cfg->client_renewal_threshold_pct; + if (threshold_pct <= 0) threshold_pct = 20; + + if (s_allotment_ms > 0 && s_remaining_ms >= 0) { + int remaining_pct = (int)((s_remaining_ms * 100) / s_allotment_ms); + if (remaining_pct <= threshold_pct) { + ESP_LOGI(TAG, "session nearing expiry (%lld/%lldms, %d%%), renewing", + (long long)s_remaining_ms, (long long)s_allotment_ms, remaining_pct); + + s_state = TG_CLIENT_RENEWING; + int steps = cfg->client_steps_to_buy; + if (steps <= 0) steps = 1; + int amount_sats = steps * s_discovery.price_per_step; + + int64_t allotment = 0; + err = tollgate_client_pay(s_gw_ip, amount_sats, &allotment); + if (err == ESP_OK) { + s_allotment_ms = allotment; + s_remaining_ms = allotment; + s_last_pay_time_ms = get_time_ms(); + s_state = TG_CLIENT_PAID; + ESP_LOGI(TAG, "renewal succeeded: %lldms", (long long)allotment); + } else { + s_state = TG_CLIENT_ERROR; + s_last_pay_time_ms = get_time_ms(); + ESP_LOGE(TAG, "renewal payment failed"); + } + } + } +} + +tollgate_client_state_t tollgate_client_get_state(void) +{ + return s_state; +} + +const tollgate_discovery_t *tollgate_client_get_discovery(void) +{ + return &s_discovery; +} + +int64_t tollgate_client_get_remaining_ms(void) +{ + return s_remaining_ms; +} + +int64_t tollgate_client_get_allotment_ms(void) +{ + return s_allotment_ms; +} 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 @@ +#ifndef TOLLGATE_CLIENT_H +#define TOLLGATE_CLIENT_H + +#include "esp_err.h" +#include +#include + +#define TG_CLIENT_MAX_GW_IP_LEN 16 +#define TG_CLIENT_MAX_MINT_URL 256 +#define TG_CLIENT_MAX_METRIC 32 + +typedef enum { + TG_CLIENT_IDLE, + TG_CLIENT_DETECTING, + TG_CLIENT_NO_TOLLGATE, + TG_CLIENT_NEEDS_PAY, + TG_CLIENT_PAYING, + TG_CLIENT_PAID, + TG_CLIENT_RENEWING, + TG_CLIENT_ERROR +} tollgate_client_state_t; + +typedef struct { + bool is_tollgate; + int price_per_step; + int step_size_ms; + char mint_url[TG_CLIENT_MAX_MINT_URL]; + char metric[TG_CLIENT_MAX_METRIC]; +} tollgate_discovery_t; + +esp_err_t tollgate_client_init(void); + +esp_err_t tollgate_client_on_sta_connected(const char *gw_ip_str); + +void tollgate_client_on_sta_disconnected(void); + +void tollgate_client_tick(void); + +tollgate_client_state_t tollgate_client_get_state(void); + +const tollgate_discovery_t *tollgate_client_get_discovery(void); + +int64_t tollgate_client_get_remaining_ms(void); +int64_t tollgate_client_get_allotment_ms(void); + +#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 @@ #include "tollgate_api.h" #include "nucula_wallet.h" #include "wifistr.h" +#include "tollgate_client.h" #define MAX_STA_RETRY 5 static const char *TAG = "tollgate_main"; @@ -48,6 +49,7 @@ static void wifi_event_handler(void *arg, esp_event_base_t event_base, } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { s_retry_count++; ESP_LOGW(TAG, "WiFi disconnected, retry %d/%d", s_retry_count, MAX_STA_RETRY); + tollgate_client_on_sta_disconnected(); if (s_services_running) stop_services(); if (s_retry_count < MAX_STA_RETRY) { esp_wifi_connect(); @@ -80,9 +82,17 @@ static void ip_event_handler(void *arg, esp_event_base_t event_base, { if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data; - ESP_LOGI(TAG, "Got IP:" IPSTR, IP2STR(&event->ip_info.ip)); + ESP_LOGI(TAG, "Got IP:" IPSTR ", GW:" IPSTR, IP2STR(&event->ip_info.ip), IP2STR(&event->ip_info.gw)); s_retry_count = 0; xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT); + + const tollgate_config_t *cfg = tollgate_config_get(); + nucula_wallet_init(cfg->mint_url); + + char gw_ip_str[16]; + snprintf(gw_ip_str, sizeof(gw_ip_str), IPSTR, IP2STR(&event->ip_info.gw)); + tollgate_client_on_sta_connected(gw_ip_str); + start_services(); } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_LOST_IP) { ESP_LOGW(TAG, "Lost IP address"); @@ -126,8 +136,6 @@ static void start_services(void) firewall_init(ap_ip_info.ip); session_manager_init(); - xTaskCreate(wallet_init_task, "wallet_init", 32768, NULL, 5, NULL); - const tollgate_config_t *cfg = tollgate_config_get(); dns_server_start(ap_ip_info.ip, upstream_dns); captive_portal_start(cfg->ap_ip_str); @@ -273,5 +281,6 @@ void app_main(void) while (1) { vTaskDelay(pdMS_TO_TICKS(1000)); session_tick(); + tollgate_client_tick(); } } 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 SECP256K1_OBJ := secp256k1.o precomputed_ecmult.o precomputed_ecmult_gen.o -TESTS := test_geohash test_identity test_nostr_event test_cashu test_session +TESTS := test_geohash test_identity test_nostr_event test_cashu test_session test_tollgate_client .PHONY: all test clean $(TESTS) @@ -62,5 +62,8 @@ test_cashu: test_cashu.c $(REPO_ROOT)/main/cashu.c test_session: test_session.c $(REPO_ROOT)/main/session.c $(CC) $(CFLAGS) $< $(REPO_ROOT)/main/session.c -o $@ $(LDFLAGS) +test_tollgate_client: test_tollgate_client.c + $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) + clean: 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; #define ESP_ERR_INVALID_ARG 0x102 #define ESP_ERR_NO_MEM 0x101 #define ESP_ERR_NOT_FOUND 0x104 +#define ESP_ERR_INVALID_STATE 0x103 static inline const char *esp_err_to_name(esp_err_t err) { (void)err; return "ESP_OK"; } 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 @@ +#ifndef STUBS_NUCULA_WALLET_H +#define STUBS_NUCULA_WALLET_H + +#include "esp_err.h" +#include +#include + +esp_err_t nucula_wallet_init(const char *mint_url); +esp_err_t nucula_wallet_receive(const char *token_str); +esp_err_t nucula_wallet_send(uint64_t amount_sat, char *token_out, size_t token_out_size); +uint64_t nucula_wallet_balance(void); +int nucula_wallet_proof_count(void); +char *nucula_wallet_proofs_json(void); +esp_err_t nucula_wallet_swap_all(void); +void nucula_wallet_print_status(void); + +#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 @@ +#include "test_framework.h" +#include "../../main/config.h" +#include +#include +#include +#include + +static tollgate_config_t g_test_config; + +const tollgate_config_t *tollgate_config_get(void) { + return &g_test_config; +} + +uint64_t nucula_wallet_balance(void) { return 100; } +esp_err_t nucula_wallet_send(uint64_t a, char *b, size_t c) { (void)a; (void)b; (void)c; return ESP_OK; } + +#include "freertos/FreeRTOS.h" + +#include "../../main/tollgate_client.c" + +static void reset_state(void) { + s_state = TG_CLIENT_IDLE; + memset(&s_discovery, 0, sizeof(s_discovery)); + memset(s_gw_ip, 0, sizeof(s_gw_ip)); + s_allotment_ms = 0; + s_remaining_ms = -1; + s_last_pay_time_ms = 0; + s_retry_count = 0; +} + +int main(void) +{ + printf("=== test_tollgate_client ===\n"); + + memset(&g_test_config, 0, sizeof(g_test_config)); + g_test_config.client_enabled = true; + g_test_config.client_steps_to_buy = 1; + g_test_config.client_renewal_threshold_pct = 20; + g_test_config.client_retry_interval_ms = 30000; + + printf("\n--- parse_discovery_response (valid kind=10021) ---\n"); + { + const char *json = "{\"kind\":10021,\"pubkey\":\"abcdef\",\"tags\":[" + "[\"metric\",\"milliseconds\"]," + "[\"step_size\",\"60000\"]," + "[\"price_per_step\",\"cashu\",\"21\",\"sat\",\"https://testnut.cashu.space\",\"1\"]," + "[\"tips\",\"1\",\"2\",\"5\"]" + "],\"content\":\"\"}"; + + tollgate_discovery_t disc; + bool ok = parse_discovery_response(json, &disc); + ASSERT(ok, "valid discovery parsed"); + ASSERT(disc.is_tollgate, "is_tollgate=true"); + ASSERT_EQ_INT(21, disc.price_per_step, "price_per_step=21"); + ASSERT_EQ_INT(60000, disc.step_size_ms, "step_size_ms=60000"); + ASSERT_EQ_STR("milliseconds", disc.metric, "metric=milliseconds"); + ASSERT_EQ_STR("https://testnut.cashu.space", disc.mint_url, "mint_url"); + } + + printf("\n--- parse_discovery_response (wrong kind) ---\n"); + { + const char *json = "{\"kind\":1,\"tags\":[]}"; + tollgate_discovery_t disc; + bool ok = parse_discovery_response(json, &disc); + ASSERT(!ok, "wrong kind rejected"); + } + + printf("\n--- parse_discovery_response (no tags) ---\n"); + { + const char *json = "{\"kind\":10021,\"content\":\"\"}"; + tollgate_discovery_t disc = {0}; + bool ok = parse_discovery_response(json, &disc); + ASSERT(ok, "no tags still parses"); + ASSERT(disc.is_tollgate, "is_tollgate=true even without tags"); + ASSERT_EQ_INT(0, disc.price_per_step, "price=0 when no tags"); + } + + printf("\n--- parse_discovery_response (garbage) ---\n"); + { + tollgate_discovery_t disc; + bool ok = parse_discovery_response("not json", &disc); + ASSERT(!ok, "garbage rejected"); + } + + printf("\n--- parse_session_response (valid kind=1022) ---\n"); + { + const char *json = "{\"kind\":1022,\"pubkey\":\"abcdef\",\"tags\":[" + "[\"p\",\"unknown\"]," + "[\"device-identifier\",\"mac\",\"10.0.0.2\"]," + "[\"allotment\",\"60000\"]," + "[\"metric\",\"milliseconds\"]" + "],\"content\":\"\"}"; + + int64_t allotment = 0; + bool ok = parse_session_response(json, &allotment); + ASSERT(ok, "valid session parsed"); + ASSERT_EQ_INT(60000, (int)allotment, "allotment=60000"); + } + + printf("\n--- parse_session_response (error kind=21023) ---\n"); + { + const char *json = "{\"kind\":21023,\"tags\":[[\"level\",\"error\"],[\"code\",\"payment-error-token-spent\"]],\"content\":\"Token spent\"}"; + int64_t allotment = 999; + bool ok = parse_session_response(json, &allotment); + ASSERT(!ok, "error kind rejected"); + ASSERT_EQ_INT(999, (int)allotment, "allotment unchanged on error"); + } + + printf("\n--- parse_usage_response ---\n"); + { + int64_t remaining = 0, total = 0; + bool ok = parse_usage_response("30000/60000", &remaining, &total); + ASSERT(ok, "valid usage parsed"); + ASSERT_EQ_INT(30000, (int)remaining, "remaining=30000"); + ASSERT_EQ_INT(60000, (int)total, "total=60000"); + } + + printf("\n--- parse_usage_response (-1/-1) ---\n"); + { + int64_t remaining = 0, total = 0; + bool ok = parse_usage_response("-1/-1", &remaining, &total); + ASSERT(ok, "no-session usage parsed"); + ASSERT_EQ_INT(-1, (int)remaining, "remaining=-1"); + ASSERT_EQ_INT(-1, (int)total, "total=-1"); + } + + printf("\n--- parse_usage_response (garbage) ---\n"); + { + int64_t remaining = 0, total = 0; + bool ok = parse_usage_response("garbage", &remaining, &total); + ASSERT(!ok, "no slash rejected"); + } + + printf("\n--- renewal threshold calculation ---\n"); + { + reset_state(); + s_state = TG_CLIENT_PAID; + s_allotment_ms = 60000; + s_remaining_ms = 10000; + strncpy(s_gw_ip, "10.0.0.1", sizeof(s_gw_ip) - 1); + + int remaining_pct = (int)((s_remaining_ms * 100) / s_allotment_ms); + ASSERT(remaining_pct <= 20, "10/60 = 16% <= 20% triggers renewal"); + } + + printf("\n--- renewal threshold no-renew (above 20%%) ---\n"); + { + reset_state(); + s_allotment_ms = 60000; + s_remaining_ms = 50000; + int remaining_pct = (int)((s_remaining_ms * 100) / s_allotment_ms); + ASSERT(remaining_pct > 20, "50/60 = 83% > 20% no renewal"); + } + + printf("\n--- state machine: init ---\n"); + { + reset_state(); + tollgate_client_init(); + ASSERT_EQ_INT(TG_CLIENT_IDLE, (int)tollgate_client_get_state(), "init sets IDLE"); + } + + printf("\n--- config: client_enabled=false ---\n"); + { + reset_state(); + g_test_config.client_enabled = false; + esp_err_t ret = tollgate_client_on_sta_connected("10.0.0.1"); + ASSERT_EQ_INT(ESP_OK, (int)ret, "returns OK when disabled"); + ASSERT_EQ_INT(TG_CLIENT_IDLE, (int)tollgate_client_get_state(), "stays IDLE when disabled"); + g_test_config.client_enabled = true; + } + + printf("\n--- state machine: disconnect resets ---\n"); + { + reset_state(); + s_state = TG_CLIENT_PAID; + strncpy(s_gw_ip, "10.0.0.1", sizeof(s_gw_ip) - 1); + s_allotment_ms = 60000; + s_remaining_ms = 30000; + tollgate_client_on_sta_disconnected(); + ASSERT_EQ_INT(TG_CLIENT_IDLE, (int)tollgate_client_get_state(), "disconnect resets to IDLE"); + ASSERT_EQ_INT(-1, (int)tollgate_client_get_remaining_ms(), "remaining reset to -1"); + ASSERT_EQ_INT(0, (int)tollgate_client_get_allotment_ms(), "allotment reset to 0"); + } + + TEST_SUMMARY(); +} -- cgit v1.2.3