diff options
Diffstat (limited to 'PLAN.md')
| -rw-r--r-- | PLAN.md | 293 |
1 files changed, 217 insertions, 76 deletions
| @@ -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 | ||