diff options
| author | Your Name <you@example.com> | 2026-05-17 04:21:39 +0530 |
|---|---|---|
| committer | Your Name <you@example.com> | 2026-05-17 04:21:39 +0530 |
| commit | 78dd599277b8e8b2ddc39a4ae710ec91d737272e (patch) | |
| tree | 9fbd89695cede00b8ff3b12ce428e96a2aa70e9b | |
| parent | b0d7394e089f00a9ffa67a2b33a502e47b778a93 (diff) | |
Phase 4: TollGate client detection + auto-payment
- 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
| -rw-r--r-- | CHECKLIST.md | 148 | ||||
| -rw-r--r-- | PLAN.md | 293 | ||||
| -rw-r--r-- | main/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | main/config.c | 22 | ||||
| -rw-r--r-- | main/config.h | 5 | ||||
| -rw-r--r-- | main/tollgate_client.c | 457 | ||||
| -rw-r--r-- | main/tollgate_client.h | 46 | ||||
| -rw-r--r-- | main/tollgate_main.c | 15 | ||||
| -rw-r--r-- | tests/unit/Makefile | 5 | ||||
| -rw-r--r-- | tests/unit/stubs/esp_err.h | 1 | ||||
| -rw-r--r-- | tests/unit/stubs/nucula_wallet.h | 17 | ||||
| -rw-r--r-- | tests/unit/test_tollgate_client.c | 186 |
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 |
| @@ -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** | 297 | The ESP32 already runs `WIFI_MODE_APSTA` — STA connects to upstream WiFi. When STA gets an IP, the client module: |
| 298 | 1. Extracts gateway IP from DHCP info | ||
| 299 | 2. HTTP GET `http://{gw}:2121/` — check for TollGate (kind=10021) | ||
| 300 | 3. Parse price/mint/metric from advertisement tags | ||
| 301 | 4. Check wallet balance ≥ price | ||
| 302 | 5. `nucula_wallet_send(price_sats)` → cashuA V3 token | ||
| 303 | 6. POST token to `http://{gw}:2121/` | ||
| 304 | 7. Parse kind=1022 response — session granted | ||
| 305 | 8. Monitor: periodic GET `/usage`, auto-renew at 20% remaining | ||
| 298 | 306 | ||
| 299 | 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. | 307 | #### Client State Machine |
| 300 | 308 | ||
| 301 | ``` | 309 | ``` |
| 302 | ┌─────────────────────────────────────────────────────────────┐ | 310 | IDLE → [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 | |||
| 355 | 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. | ||
| 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 | |||
| 366 | Mirrors the Go implementation in `tollgate-module-basic-go/src/merchant/` and `src/lightning/`: | ||
| 367 | |||
| 368 | ``` | ||
| 369 | Every 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 | |||
| 382 | Pure HTTP implementation (2 GETs): | ||
| 383 | 1. `GET https://{domain}/.well-known/lnurlp/{username}` → parse callback URL, min/max amounts | ||
| 384 | 2. `GET {callback}?amount={millisats}` → extract BOLT11 invoice from response | ||
| 385 | |||
| 386 | #### nucula Bridge Extension | ||
| 387 | |||
| 388 | Add to `nucula_wallet.h`: | ||
| 389 | ```c | ||
| 390 | esp_err_t nucula_wallet_melt(const char *bolt11_invoice, uint64_t max_fee_sats); | ||
| 391 | ``` | ||
| 392 | |||
| 393 | Wraps `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 | |||
| 431 | Patch adds to `struct ip_napt_entry`: | ||
| 432 | ```c | ||
| 433 | uint64_t bytes_up; // bytes uploaded (client → internet) | ||
| 434 | uint64_t bytes_down; // bytes downloaded (internet → client) | ||
| 328 | ``` | 435 | ``` |
| 329 | ┌──────────┬────────┬─────────────┬──────────────┬──────────────────┐ | 436 | |
| 330 | │ element_id│ length │ vendor_oui │ oui_type │ payload │ | 437 | Increment in `ip_napt_forward()` (upload) and `ip_napt_recv()` (download). |
| 331 | │ (0xDD) │ │ (3 bytes) │ (1 byte) │ (variable) │ | 438 | |
| 332 | ├──────────┼────────┼─────────────┼──────────────┼──────────────────┤ | 439 | New public API: |
| 333 | │ 0xDD │ N │ "TG" │ 0x01 (price) │ See below │ | 440 | ```c |
| 334 | │ │ │ 0x54:0x47 │ │ │ | 441 | void ip_napt_get_client_bytes(uint32_t client_ip, uint64_t *bytes_up, uint64_t *bytes_down); |
| 335 | └──────────┴────────┴─────────────┴──────────────┴──────────────────┘ | ||
| 336 | |||
| 337 | Price 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 | └─────────────┴─────────────┴──────────────┴───────────────┴────────────┘ | ||
| 344 | Total 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 | 450 | uint64_t allotment_bytes; |
| 358 | - Or parses `vendor_ie_data_t` from scan results if available in `wifi_ap_record_t` | 451 | uint64_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 | |||
| 484 | ContextVM uses MCP (JSON-RPC 2.0) over NIP-44 encrypted Nostr DMs: | ||
| 485 | 1. ESP32 subscribes to Nostr relays for DMs addressed to its npub | ||
| 486 | 2. Incoming DMs are NIP-44 decrypted, parsed as MCP JSON-RPC requests | ||
| 487 | 3. Dispatched to registered tool handlers | ||
| 488 | 4. 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 | |||
| 507 | Only 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 | ||
| 54 | void tollgate_config_derive_unique(tollgate_config_t *cfg); | 59 | 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 @@ | |||
| 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 | |||
| 11 | static const char *TAG = "tg_client"; | ||
| 12 | |||
| 13 | static tollgate_client_state_t s_state = TG_CLIENT_IDLE; | ||
| 14 | static tollgate_discovery_t s_discovery = {0}; | ||
| 15 | static char s_gw_ip[TG_CLIENT_MAX_GW_IP_LEN] = {0}; | ||
| 16 | static int64_t s_allotment_ms = 0; | ||
| 17 | static int64_t s_remaining_ms = -1; | ||
| 18 | static int64_t s_last_pay_time_ms = 0; | ||
| 19 | static int s_retry_count = 0; | ||
| 20 | |||
| 21 | static int64_t get_time_ms(void) { | ||
| 22 | return (int64_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); | ||
| 23 | } | ||
| 24 | |||
| 25 | static 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 | |||
| 55 | static 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 | |||
| 88 | static 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 | |||
| 146 | static 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 | |||
| 182 | static 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 | |||
| 199 | esp_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 | |||
| 230 | static 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 | |||
| 276 | static 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 | |||
| 291 | esp_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 | |||
| 303 | esp_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 | |||
| 349 | void 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 | |||
| 361 | void 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 | |||
| 439 | tollgate_client_state_t tollgate_client_get_state(void) | ||
| 440 | { | ||
| 441 | return s_state; | ||
| 442 | } | ||
| 443 | |||
| 444 | const tollgate_discovery_t *tollgate_client_get_discovery(void) | ||
| 445 | { | ||
| 446 | return &s_discovery; | ||
| 447 | } | ||
| 448 | |||
| 449 | int64_t tollgate_client_get_remaining_ms(void) | ||
| 450 | { | ||
| 451 | return s_remaining_ms; | ||
| 452 | } | ||
| 453 | |||
| 454 | int64_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 | |||
| 12 | typedef 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 | |||
| 23 | typedef 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 | |||
| 31 | esp_err_t tollgate_client_init(void); | ||
| 32 | |||
| 33 | esp_err_t tollgate_client_on_sta_connected(const char *gw_ip_str); | ||
| 34 | |||
| 35 | void tollgate_client_on_sta_disconnected(void); | ||
| 36 | |||
| 37 | void tollgate_client_tick(void); | ||
| 38 | |||
| 39 | tollgate_client_state_t tollgate_client_get_state(void); | ||
| 40 | |||
| 41 | const tollgate_discovery_t *tollgate_client_get_discovery(void); | ||
| 42 | |||
| 43 | int64_t tollgate_client_get_remaining_ms(void); | ||
| 44 | int64_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 |
| 24 | static const char *TAG = "tollgate_main"; | 25 | static 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 | ||
| 22 | SECP256K1_OBJ := secp256k1.o precomputed_ecmult.o precomputed_ecmult_gen.o | 22 | SECP256K1_OBJ := secp256k1.o precomputed_ecmult.o precomputed_ecmult_gen.o |
| 23 | 23 | ||
| 24 | TESTS := test_geohash test_identity test_nostr_event test_cashu test_session | 24 | TESTS := 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 | |||
| 62 | test_session: test_session.c $(REPO_ROOT)/main/session.c | 62 | test_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 | ||
| 65 | test_tollgate_client: test_tollgate_client.c | ||
| 66 | $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) | ||
| 67 | |||
| 65 | clean: | 68 | clean: |
| 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 | ||
| 16 | static inline const char *esp_err_to_name(esp_err_t err) { (void)err; return "ESP_OK"; } | 17 | static 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 | |||
| 8 | esp_err_t nucula_wallet_init(const char *mint_url); | ||
| 9 | esp_err_t nucula_wallet_receive(const char *token_str); | ||
| 10 | esp_err_t nucula_wallet_send(uint64_t amount_sat, char *token_out, size_t token_out_size); | ||
| 11 | uint64_t nucula_wallet_balance(void); | ||
| 12 | int nucula_wallet_proof_count(void); | ||
| 13 | char *nucula_wallet_proofs_json(void); | ||
| 14 | esp_err_t nucula_wallet_swap_all(void); | ||
| 15 | void 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 | |||
| 8 | static tollgate_config_t g_test_config; | ||
| 9 | |||
| 10 | const tollgate_config_t *tollgate_config_get(void) { | ||
| 11 | return &g_test_config; | ||
| 12 | } | ||
| 13 | |||
| 14 | uint64_t nucula_wallet_balance(void) { return 100; } | ||
| 15 | esp_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 | |||
| 21 | static 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 | |||
| 31 | int 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 | } | ||