diff options
| -rw-r--r-- | AGENTS.md | 32 | ||||
| -rw-r--r-- | CHECKLIST.md | 103 | ||||
| -rw-r--r-- | Makefile | 192 | ||||
| -rw-r--r-- | PLAN.md | 204 | ||||
| -rw-r--r-- | main/CMakeLists.txt | 4 | ||||
| -rw-r--r-- | main/config.c | 13 | ||||
| -rw-r--r-- | main/cvm_server.c | 815 | ||||
| -rw-r--r-- | main/cvm_server.h | 4 | ||||
| -rw-r--r-- | main/mcp_handler.c | 236 | ||||
| -rw-r--r-- | main/mcp_handler.h | 12 | ||||
| -rw-r--r-- | main/session.c | 10 | ||||
| -rw-r--r-- | main/session.h | 3 | ||||
| -rw-r--r-- | main/tollgate_main.c | 21 | ||||
| -rw-r--r-- | tests/integration/test-cvm.mjs | 94 | ||||
| -rw-r--r-- | tests/unit/Makefile | 5 | ||||
| -rw-r--r-- | tests/unit/test_cvm_server.c | 434 | ||||
| -rw-r--r-- | tests/unit/test_mcp_handler.c | 146 |
17 files changed, 2140 insertions, 188 deletions
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | ## Project Overview | 3 | ## Project Overview |
| 4 | 4 | ||
| 5 | TollGate ESP32 firmware: captive portal WiFi hotspot with Cashu e-cash payments, on-device wallet, Nostr identity derivation, and wifistr service discovery. Runs on two ESP32-S3 boards. | 5 | TollGate ESP32 firmware: captive portal WiFi hotspot with Cashu e-cash payments, on-device wallet, Nostr identity derivation, wifistr service discovery, and ContextVM (MCP over Nostr) server. Runs on three ESP32-S3 boards. |
| 6 | 6 | ||
| 7 | ## Technology Stack | 7 | ## Technology Stack |
| 8 | 8 | ||
| @@ -11,14 +11,18 @@ TollGate ESP32 firmware: captive portal WiFi hotspot with Cashu e-cash payments, | |||
| 11 | - **Wallet:** nucula library (libsecp256k1) via git submodule | 11 | - **Wallet:** nucula library (libsecp256k1) via git submodule |
| 12 | - **Identity:** Nostr nsec → HMAC-SHA512 → deterministic MAC/SSID/IP | 12 | - **Identity:** Nostr nsec → HMAC-SHA512 → deterministic MAC/SSID/IP |
| 13 | - **Service discovery:** wifistr (Nostr kind 38787) via WebSocket | 13 | - **Service discovery:** wifistr (Nostr kind 38787) via WebSocket |
| 14 | - **ContextVM:** MCP over Nostr (kind 25910), CEP-6 announcements, 10 MCP tools | ||
| 14 | - **Testing:** Host C unit tests (gcc), Node.js integration tests (live board), Playwright E2E | 15 | - **Testing:** Host C unit tests (gcc), Node.js integration tests (live board), Playwright E2E |
| 15 | 16 | ||
| 16 | ## Board Configuration | 17 | ## Board Configuration |
| 17 | 18 | ||
| 18 | | Board | Port | Factory MAC | Notes | | 19 | | Board | Port | Factory MAC | SSID | AP IP | Notes | |
| 19 | |-------|------|-------------|-------| | 20 | |-------|------|-------------|------|-------|-------| |
| 20 | | A | `/dev/ttyACM0` | `94:a9:90:2e:37:7c` | Primary test target | | 21 | | A | `/dev/ttyACM0` | `94:a9:90:2e:37:7c` | `TollGate-B96D80` | `10.185.47.1` | Primary test target | |
| 21 | | B | `/dev/ttyACM1` | `fc:01:2c:c5:50:50` | Secondary | | 22 | | B | `/dev/ttyACM1` | `fc:01:2c:c5:50:50` | `TollGate-C0E9CA` | `10.192.45.1` | Secondary | |
| 23 | | C | `/dev/ttyACM3` | `20:6e:f1:98:d7:08` | (TBD) | (TBD) | Display board | | ||
| 24 | |||
| 25 | **IMPORTANT:** Board ports change on every USB replug. Always verify with `esptool.py --port <port> chip_id` before flashing. | ||
| 22 | 26 | ||
| 23 | Identity (SSID, IP, MAC) is derived from `nsec` in config.json. Each board gets a unique nsec. | 27 | Identity (SSID, IP, MAC) is derived from `nsec` in config.json. Each board gets a unique nsec. |
| 24 | 28 | ||
| @@ -34,10 +38,11 @@ nvs_flash_init() | |||
| 34 | → esp_wifi_init() | 38 | → esp_wifi_init() |
| 35 | → esp_wifi_set_mac(STA/AP) // sets derived MACs | 39 | → esp_wifi_set_mac(STA/AP) // sets derived MACs |
| 36 | → esp_wifi_set_mode(APSTA) | 40 | → esp_wifi_set_mode(APSTA) |
| 41 | → esp_wifi_set_country_code("DE") // EU regulatory domain (channels 1-13, 20dBm) | ||
| 37 | → wifi_configure_ap() // uses derived SSID | 42 | → wifi_configure_ap() // uses derived SSID |
| 38 | → esp_wifi_start() | 43 | → esp_wifi_start() |
| 39 | → [on STA got IP] start_services(): | 44 | → [on STA got IP] start_services(): |
| 40 | firewall_init, session_init, wallet_init, dns_server, captive_portal, api, wifistr_publish | 45 | sntp_init, firewall_init, session_init, wallet_init, dns_server, captive_portal, api, wifistr_publish, cvm_server_start |
| 41 | ``` | 46 | ``` |
| 42 | 47 | ||
| 43 | ## Key Files | 48 | ## Key Files |
| @@ -55,6 +60,8 @@ nvs_flash_init() | |||
| 55 | - `session.c/h` — time-based sessions, MAC tracking | 60 | - `session.c/h` — time-based sessions, MAC tracking |
| 56 | - `cashu.c/h` — Cashu token decode, checkstate, allotment calc | 61 | - `cashu.c/h` — Cashu token decode, checkstate, allotment calc |
| 57 | - `tollgate_api.c/h` — HTTP :2121, payment endpoints, wallet endpoints | 62 | - `tollgate_api.c/h` — HTTP :2121, payment endpoints, wallet endpoints |
| 63 | - `cvm_server.c/h` — ContextVM: persistent WS relay listener, kind 25910 subscription, MCP protocol handlers, CEP-6 announcements | ||
| 64 | - `mcp_handler.c/h` — 10 MCP tool handlers (get_config, set_config, get_balance, wallet_send, get_sessions, get_usage, set_payout, set_metric, set_price, wallet_melt) | ||
| 58 | 65 | ||
| 59 | ### Components | 66 | ### Components |
| 60 | - `nucula_lib/` — C++ bridge to nucula::Wallet (C API in nucula_wallet.h) | 67 | - `nucula_lib/` — C++ bridge to nucula::Wallet (C API in nucula_wallet.h) |
| @@ -71,7 +78,8 @@ nvs_flash_init() | |||
| 71 | "step_size_ms": 60000, | 78 | "step_size_ms": 60000, |
| 72 | "nostr_geohash": "u281w0dfz", | 79 | "nostr_geohash": "u281w0dfz", |
| 73 | "nostr_relays": ["wss://relay.damus.io", "wss://nos.lol"], | 80 | "nostr_relays": ["wss://relay.damus.io", "wss://nos.lol"], |
| 74 | "nostr_publish_interval_s": 21600 | 81 | "nostr_publish_interval_s": 21600, |
| 82 | "cvm_enabled": true | ||
| 75 | } | 83 | } |
| 76 | ``` | 84 | ``` |
| 77 | 85 | ||
| @@ -178,6 +186,7 @@ make flash-b # flash to Board B | |||
| 178 | 186 | ||
| 179 | - **Test mint:** `testnut.cashu.space` — auto-pays lightning invoices | 187 | - **Test mint:** `testnut.cashu.space` — auto-pays lightning invoices |
| 180 | - **Nostr relays:** `relay.damus.io`, `nos.lol` — for wifistr events | 188 | - **Nostr relays:** `relay.damus.io`, `nos.lol` — for wifistr events |
| 189 | - **CVM relay:** `relay.primal.net` — for ContextVM kind 25910 events and CEP-6 announcements | ||
| 181 | - **Nutshell CLI:** `cashu` command for token generation | 190 | - **Nutshell CLI:** `cashu` command for token generation |
| 182 | - **ESP-IDF:** `source ~/esp/esp-idf/export.sh` before `idf.py` commands | 191 | - **ESP-IDF:** `source ~/esp/esp-idf/export.sh` before `idf.py` commands |
| 183 | - **System libs for unit tests:** `libmbedtls-dev`, `libcjson-dev` | 192 | - **System libs for unit tests:** `libmbedtls-dev`, `libcjson-dev` |
| @@ -186,10 +195,17 @@ make flash-b # flash to Board B | |||
| 186 | 195 | ||
| 187 | - **Commit + push every time a test passes that previously didn't pass.** Green tests = checkpoint. Don't batch multiple test fixes into one commit. | 196 | - **Commit + push every time a test passes that previously didn't pass.** Green tests = checkpoint. Don't batch multiple test fixes into one commit. |
| 188 | - Commit + push after each working change | 197 | - Commit + push after each working change |
| 189 | - Board A is at `/dev/ttyACM0`, Board B at `/dev/ttyACM1` | 198 | - Board A is at `/dev/ttyACM0`, Board B at `/dev/ttyACM1`, Board C at `/dev/ttyACM3` |
| 199 | - **Per-board locks required** before hardware access: `make lock-a PHASE="desc"`, lock files in `physical-router-test-automation/locks/` | ||
| 190 | - `sudo` password: `c03rad0r123` | 200 | - `sudo` password: `c03rad0r123` |
| 191 | - SPIFFS is at offset `0x410000`, size `0xF0000` — erase with `esptool.py erase_region 0x410000 0xF0000` if config is stale | 201 | - SPIFFS is at offset `0x410000`, size `0xF0000` — erase with `esptool.py erase_region 0x410000 0xF0000` if config is stale |
| 192 | - NVS stores wallet proofs — erasing NVS clears wallet balance | 202 | - NVS stores wallet proofs — erasing NVS clears wallet balance |
| 193 | - The `nostr_event.c` `created_at` field uses `gettimeofday()` — mock this in unit tests | 203 | - The `nostr_event.c` `created_at` field uses `gettimeofday()` — mock this in unit tests |
| 194 | - Wifistr event signing uses `secp256k1_schnorrsig_sign32()` — verify with `_verify()` in tests | 204 | - Wifistr event signing uses `secp256k1_schnorrsig_sign32()` — verify with `_verify()` in tests |
| 195 | - Portal HTML has server-side template substitution (`__AP_IP__`, `__PRICE__`, `__MINT_URL__`) — no JS fetch | 205 | - Portal HTML has server-side template substitution (`__AP_IP__`, `__PRICE__`, `__MINT_URL__`) — no JS fetch |
| 206 | - **WiFi country code:** Must set `esp_wifi_set_country_code("DE")` before `esp_wifi_start()` — defaults to CN which causes auth failures on EU APs | ||
| 207 | - **Board A WiFi is broken** — hardware issue confirmed: `WIFI_REASON_AUTH_EXPIRED` on all APs in all modes (APSTA, STA-only, factory MAC). Board B with identical firmware connects instantly. Do not waste time debugging Board A WiFi. | ||
| 208 | - Default nsec: `a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2` | ||
| 209 | - Board A nsec: `9af47906b45aca5e238390f3d03c8274e154198e81aa2095065627d1e61ca968` | ||
| 210 | - CVM relay: `relay.primal.net` — relay disconnects every ~15s by default, now has 60s timeout + WS ping/pong keepalive | ||
| 211 | - MCP responses sent via existing WS connection (not new TLS) — ESP32 can't handle multiple simultaneous TLS sessions | ||
diff --git a/CHECKLIST.md b/CHECKLIST.md index c5dfbe4..7fcc4b7 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md | |||
| @@ -48,10 +48,83 @@ | |||
| 48 | ## Phase 6: Bytes-Based Billing — COMPLETE (commit `edd125d`) | 48 | ## Phase 6: Bytes-Based Billing — COMPLETE (commit `edd125d`) |
| 49 | - [x] Dual-metric session support (milliseconds + bytes) | 49 | - [x] Dual-metric session support (milliseconds + bytes) |
| 50 | 50 | ||
| 51 | ## Phase 7: MCP Handler + NIP-04 + CVM Server — COMPLETE (commit `fdf662f`) | 51 | ## Phase 7: MCP Handler + NIP-04 + CVM Server — SKELETON (commit `fdf662f`) |
| 52 | - [x] mcp_handler.c/h (4 tools, 25 unit tests) | 52 | - [x] mcp_handler.c/h (4 tools, 25 unit tests) |
| 53 | - [x] nip04.c/h (AES-256-CBC + ECDH, 15 unit tests) | 53 | - [x] nip04.c/h (AES-256-CBC + ECDH, 15 unit tests) |
| 54 | - [x] cvm_server.c/h (Nostr DM listener) | 54 | - [x] cvm_server.c/h (Nostr DM listener skeleton) |
| 55 | |||
| 56 | ## Phase 7b: ContextVM Protocol Rewrite — COMPLETE | ||
| 57 | - [x] Add 6 new tools to mcp_handler.c/h (get_sessions, get_usage, set_payout, set_metric, set_price, wallet_melt) | ||
| 58 | - [x] Update test_mcp_handler.c with tests for 6 new tools | ||
| 59 | - [x] Rewrite cvm_server.c: persistent WebSocket listener, kind 25910 subscription | ||
| 60 | - [x] MCP protocol handlers: initialize, notifications/initialized, tools/list, tools/call, ping | ||
| 61 | - [x] Auth check: only accept from owner npub | ||
| 62 | - [x] CEP-6: publish kind 11316 server announcement on startup | ||
| 63 | - [x] CEP-6: publish kind 11317 tools list on startup | ||
| 64 | - [x] CEP-17: publish kind 10002 relay list on startup | ||
| 65 | - [x] Update config.c: default cvm_enabled = true | ||
| 66 | - [x] Create test_cvm_server.c unit test (event parsing, announcement construction, auth) | ||
| 67 | - [x] Update tests/unit/Makefile with test_cvm_server target | ||
| 68 | - [x] Create tests/integration/test-cvm.mjs (nak-based integration test) | ||
| 69 | - [x] Update Makefile with cvm-* targets (test-cvm, cvm-pubkey, cvm-test-tool) | ||
| 70 | - [x] WS frame masking fix (RFC 6455 client-to-server) | ||
| 71 | - [x] EVENT msg buffer underflow fix (snprintf buffer size) | ||
| 72 | - [x] TLS write loop for large payloads | ||
| 73 | - [x] WS ping/pong keepalive (30s interval) | ||
| 74 | - [x] Subscription REQ fix (removed invalid limit field) | ||
| 75 | - [x] SNTP init after STA gets IP | ||
| 76 | - [x] 282 unit tests passing (61 CVM + 60 MCP + 161 existing) | ||
| 77 | |||
| 78 | ## Phase 7c: CVM Integration Testing — IN PROGRESS | ||
| 79 | - [x] Per-board hardware locks implemented (board-a/b/c.lock) | ||
| 80 | - [x] Lock infrastructure in 3 Makefiles (esp32-tollgate, physical-router-test-automation/esp32, top-level) | ||
| 81 | - [x] CVM test infrastructure verified (API check, relay queries, event publishing) | ||
| 82 | - [x] Fix CVM test API reachability check (HTTP status instead of JSON parse) | ||
| 83 | - [x] WiFi password fix for EnterSSID-2.4GHz (c03rad0r123! — was missing `!`) | ||
| 84 | - [x] WiFi auth threshold fix (WPA3_PSK → WPA2_PSK → WIFI_AUTH_OPEN, now WPA2_PSK) | ||
| 85 | - [x] PMF capable mode enabled | ||
| 86 | - [x] WIFI_ALL_CHANNEL_SCAN enabled | ||
| 87 | - [x] WiFi country code fix (ESP-IDF defaults to CN, need DE for EU regulatory compliance) | ||
| 88 | - [x] 2s retry delay between WiFi auth attempts | ||
| 89 | - [x] Board B connects to WiFi successfully with country code DE | ||
| 90 | - [x] Board A confirmed as hardware WiFi issue (auth fails on all APs, Board B works fine) | ||
| 91 | - [x] Board B CEP-6 announcements confirmed on relay.primal.net | ||
| 92 | - [x] Verify kind 11316 announcement on relay.primal.net — PASS | ||
| 93 | - [x] Verify kind 11317 tools list on relay.primal.net — PASS | ||
| 94 | - [x] Verify kind 10002 relay list on relay.primal.net — PASS | ||
| 95 | - [x] Fix subscription #p filter (must be array, not string) — relay rejected as 'bad req' | ||
| 96 | - [x] Fix MCP response publishing (use existing WS instead of new TLS connection) | ||
| 97 | - [x] Fix use-after-free bug (tags_str freed before nostr_event_to_json) | ||
| 98 | - [x] MCP initialize roundtrip via kind 25910 — PASS | ||
| 99 | - [x] tools/call get_config via kind 25910 — PASS | ||
| 100 | - [x] tools/call get_balance via kind 25910 — PASS | ||
| 101 | - [x] tools/list response via kind 25910 — PASS | ||
| 102 | - [x] tools/call set_price via kind 25910 — PASS (price updated to 42) | ||
| 103 | - [ ] tools/call get_sessions via kind 25910 | ||
| 104 | - [ ] tools/call get_usage via kind 25910 | ||
| 105 | - [ ] Non-owner auth rejection via live relay (unit test only so far) | ||
| 106 | - [ ] Verify board npub on contextvm.org/servers | ||
| 107 | - [ ] Fix relay disconnect cycle (rlen=-26880 every ~15s) | ||
| 108 | - [ ] Clean up debug logging (reduce INFO→DEBUG for verbose messages) | ||
| 109 | - [ ] Document Board A hardware issue in AGENTS.md | ||
| 110 | |||
| 111 | ### WiFi Debugging Findings (Board A — 94:a9:90:2e:37:7c) | ||
| 112 | - **Symptom:** `WIFI_REASON_AUTH_EXPIRED` (0x200) on all upstream APs | ||
| 113 | - **APs tested:** EnterSSID-2.4GHz (ch11, WPA2), c03rad0r (not in range), laptop hotspot (ch6, WPA2) | ||
| 114 | - **Modes tested:** APSTA (ch1/6/11), STA-only (no AP at all) | ||
| 115 | - **MAC tested:** Custom (derived from nsec) and factory MAC | ||
| 116 | - **Result:** Auth fails in ALL configurations, even STA-only 1m from laptop hotspot | ||
| 117 | - **Root cause hypothesis 1:** Missing WiFi country code — ESP-IDF defaults to CN regulatory domain, boards are in DE. Different TX power limits and channel parameters may cause APs to ignore ESP32 auth frames. | ||
| 118 | - **Root cause hypothesis 2:** Hardware antenna issue on Board A — needs testing on other boards to confirm | ||
| 119 | - **Spectrum:** Dense environment (ch1: 2 APs, ch6: 4 APs, ch11: 4 APs) but laptop connects fine at 100% | ||
| 120 | - **Next step:** Add `esp_wifi_set_country_code("DE")` and test Board A, then Board B/C if needed | ||
| 121 | |||
| 122 | ### Per-Board Hardware Locks | ||
| 123 | - [x] Lock files in `physical-router-test-automation/locks/` (board-a.lock, board-b.lock, board-c.lock) | ||
| 124 | - [x] `lock-a/b/c`, `unlock-a/b/c`, `force-unlock-a/b/c` targets | ||
| 125 | - [x] All hardware-touching targets require corresponding board lock | ||
| 126 | - [x] Read-only targets (build, cvm-pubkey, lock-status) work without lock | ||
| 127 | - [x] Board port mapping updated: A=ACM0, B=ACM1, C=ACM3 | ||
| 55 | 128 | ||
| 56 | ## Bug Fixes — COMPLETE (commit `3342c8e`) | 129 | ## Bug Fixes — COMPLETE (commit `3342c8e`) |
| 57 | - [x] reset_auth, /usage, metric default, sys_evt stack overflow fixes | 130 | - [x] reset_auth, /usage, metric default, sys_evt stack overflow fixes |
| @@ -78,6 +151,21 @@ | |||
| 78 | - [x] Update `tests/unit/test_session.c` | 151 | - [x] Update `tests/unit/test_session.c` |
| 79 | - [x] 186 unit tests passing | 152 | - [x] 186 unit tests passing |
| 80 | 153 | ||
| 154 | ## TFT Display (JC3248W535 / AXS15231B) — IN PROGRESS | ||
| 155 | - [x] Create QR code component (port qrcoded from NSD, fix bool/pragma/comparison warnings) | ||
| 156 | - [x] Create AXS15231B QSPI display driver component (init sequence, PSRAM framebuffer, chunked flush) | ||
| 157 | - [x] Create 8x8 bitmap font (ASCII 32-127) | ||
| 158 | - [x] Create display abstraction layer (display.h/c — boot/ready/payment/error states) | ||
| 159 | - [x] Integrate display into tollgate_main.c and main/CMakeLists.txt | ||
| 160 | - [x] Build succeeds (binary 1.2MB, 71% free in partition) | ||
| 161 | - [x] Wi-Fi QR code encoding: `WIFI:S:<escaped_ssid>;T:nopass;;` with special char escaping (`\;:,"`) | ||
| 162 | - [x] QR cycling: alternate between Wi-Fi QR and portal URL QR every 5 seconds | ||
| 163 | - [ ] Flash to JC3248W535 board at `/dev/ttyACM0` and test | ||
| 164 | - [ ] Verify Wi-Fi QR is scannable by Android/iOS camera | ||
| 165 | - [ ] Verify portal URL QR is scannable and loads captive portal | ||
| 166 | - [ ] Add unit tests for QR generation and escape_wifi_field() | ||
| 167 | - [ ] Update AGENTS.md with display module docs | ||
| 168 | |||
| 81 | --- | 169 | --- |
| 82 | 170 | ||
| 83 | ## TODO — Remaining | 171 | ## TODO — Remaining |
| @@ -125,12 +213,13 @@ | |||
| 125 | 213 | ||
| 126 | ## Reminders | 214 | ## Reminders |
| 127 | - **Commit + push every time a test passes that previously didn't pass** | 215 | - **Commit + push every time a test passes that previously didn't pass** |
| 128 | - Board A: `/dev/ttyACM0`, SSID `TollGate-C0E9CA`, AP IP `10.192.45.1` | 216 | - Board A: `/dev/ttyACM0`, MAC `94:a9:90:2e:37:7c`, SSID `TollGate-B96D80`, AP IP `10.185.47.1` |
| 129 | - Board B: `/dev/ttyACM1`, SSID `TollGate-b96d80`, AP IP `10.185.47.1`, nsec `9af47906...` | 217 | - Board B: `/dev/ttyACM1`, MAC `fc:01:2c:c5:50:50`, SSID `TollGate-C0E9CA`, AP IP `10.192.45.1` |
| 130 | - OpenWRT Router: SSH `root@10.47.41.1`, port 2121 | 218 | - Board C: `/dev/ttyACM3`, MAC `20:6e:f1:98:d7:08` |
| 131 | - `source ~/esp/esp-idf/export.sh` before `idf.py` | 219 | - `source ~/esp/esp-idf/export.sh` before `idf.py` |
| 132 | - Latest commit: `0c2c67b` | ||
| 133 | - 186 unit tests + 18 Playwright tests — all passing | ||
| 134 | - sudo password: `c03rad0r123` | 220 | - sudo password: `c03rad0r123` |
| 135 | - Token generation: `cashu -h https://testnut.cashu.space send --legacy 21` | 221 | - Token generation: `cashu -h https://testnut.cashu.space send --legacy 21` |
| 222 | - SPIFFS offset `0x410000`, size `0xF0000` | ||
| 136 | - See `AGENTS.md` for full testing rules | 223 | - See `AGENTS.md` for full testing rules |
| 224 | - **Per-board locks:** `make lock-a PHASE="desc"` before hardware access | ||
| 225 | - **WiFi country code:** Must set `esp_wifi_set_country_code("DE")` before `esp_wifi_start()` | ||
| @@ -19,13 +19,71 @@ PYTHON ?= python3 | |||
| 19 | 19 | ||
| 20 | TOLLGATE_IP ?= 10.192.45.1 | 20 | TOLLGATE_IP ?= 10.192.45.1 |
| 21 | 21 | ||
| 22 | BOARD ?= b | ||
| 23 | |||
| 24 | HARDWARE_LOCK_DIR := /home/c03rad0r/physical-router-test-automation/locks | ||
| 25 | |||
| 26 | RED := \033[31m | ||
| 27 | GREEN := \033[32m | ||
| 28 | YELLOW := \033[33m | ||
| 29 | BOLD := \033[1m | ||
| 30 | RESET := \033[0m | ||
| 31 | |||
| 32 | define require_lock_a | ||
| 33 | @if [ ! -f "$(HARDWARE_LOCK_DIR)/board-a.lock" ]; then \ | ||
| 34 | echo "$(RED)$(BOLD)Board A not locked — run 'make lock-a PHASE=\"description\"' first$(RESET)"; \ | ||
| 35 | echo "$(YELLOW)Another LLM session may be using Board A.$(RESET)"; \ | ||
| 36 | exit 1; \ | ||
| 37 | fi | ||
| 38 | endef | ||
| 39 | |||
| 40 | define require_lock_b | ||
| 41 | @if [ ! -f "$(HARDWARE_LOCK_DIR)/board-b.lock" ]; then \ | ||
| 42 | echo "$(RED)$(BOLD)Board B not locked — run 'make lock-b PHASE=\"description\"' first$(RESET)"; \ | ||
| 43 | echo "$(YELLOW)Another LLM session may be using Board B.$(RESET)"; \ | ||
| 44 | exit 1; \ | ||
| 45 | fi | ||
| 46 | endef | ||
| 47 | |||
| 48 | define _require_board_lock | ||
| 49 | @if [ ! -f "$(HARDWARE_LOCK_DIR)/board-$(BOARD).lock" ]; then \ | ||
| 50 | echo "$(RED)$(BOLD)Board $(BOARD) not locked — run 'make lock-$(BOARD) PHASE=\"description\"' first$(RESET)"; \ | ||
| 51 | echo "$(YELLOW)Another LLM session may be using Board $(BOARD).$(RESET)"; \ | ||
| 52 | exit 1; \ | ||
| 53 | fi | ||
| 54 | endef | ||
| 55 | |||
| 56 | define _acquire_lock | ||
| 57 | @if [ -f "$(HARDWARE_LOCK_DIR)/$(1).lock" ]; then \ | ||
| 58 | echo "$(RED)$(BOLD)Cannot acquire lock — $(1) already locked:$(RESET)"; \ | ||
| 59 | echo ""; \ | ||
| 60 | cat $(HARDWARE_LOCK_DIR)/$(1).lock | sed 's/^/ /'; \ | ||
| 61 | echo ""; \ | ||
| 62 | echo "$(YELLOW)Use 'make force-unlock-$(1)' to override.$(RESET)"; \ | ||
| 63 | exit 1; \ | ||
| 64 | fi; \ | ||
| 65 | branch=$$(git branch --show-current 2>/dev/null || echo "unknown"); \ | ||
| 66 | worktree=$$(pwd); \ | ||
| 67 | echo "locked: true" > $(HARDWARE_LOCK_DIR)/$(1).lock; \ | ||
| 68 | echo "board: $(1)" >> $(HARDWARE_LOCK_DIR)/$(1).lock; \ | ||
| 69 | echo "branch: $$branch" >> $(HARDWARE_LOCK_DIR)/$(1).lock; \ | ||
| 70 | echo "worktree: $$worktree" >> $(HARDWARE_LOCK_DIR)/$(1).lock; \ | ||
| 71 | echo "session: $$USER@$$HOSTNAME" >> $(HARDWARE_LOCK_DIR)/$(1).lock; \ | ||
| 72 | echo "timestamp: $$(date -u '+%Y-%m-%dT%H:%M:%SZ')" >> $(HARDWARE_LOCK_DIR)/$(1).lock; \ | ||
| 73 | echo "phase: $(PHASE)" >> $(HARDWARE_LOCK_DIR)/$(1).lock; \ | ||
| 74 | echo "$(GREEN)$(BOLD)$(1) lock acquired$(RESET)"; \ | ||
| 75 | cat $(HARDWARE_LOCK_DIR)/$(1).lock | ||
| 76 | endef | ||
| 77 | |||
| 22 | .PHONY: help setup detect-ports detect-chip detect-all | 78 | .PHONY: help setup detect-ports detect-chip detect-all |
| 23 | .PHONY: flash flash-a flash-b monitor monitor-a monitor-b | 79 | .PHONY: flash flash-a flash-b monitor monitor-a monitor-b |
| 24 | .PHONY: test test-unit test-integration test-e2e test-all | 80 | .PHONY: test test-unit test-integration test-e2e test-all |
| 25 | .PHONY: test-smoke test-api test-network test-portal test-payment | 81 | .PHONY: test-smoke test-api test-network test-portal test-payment |
| 26 | .PHONY: test-reset-auth test-session-expiry test-dns-firewall | 82 | .PHONY: test-reset-auth test-session-expiry test-dns-firewall test-cvm |
| 27 | .PHONY: tokens wallet-setup wallet-info wallet-balance mint-token send-token | 83 | .PHONY: tokens wallet-setup wallet-info wallet-balance mint-token send-token |
| 28 | .PHONY: clean erase-nvs reset serial-log bootstrap-config | 84 | .PHONY: clean erase-nvs reset serial-log bootstrap-config |
| 85 | .PHONY: cvm-pubkey cvm-test-tool cvm-announce | ||
| 86 | .PHONY: lock-a lock-b unlock-a unlock-b force-unlock-a force-unlock-b lock-status | ||
| 29 | 87 | ||
| 30 | help: | 88 | help: |
| 31 | @echo "TollGate ESP32 — Makefile" | 89 | @echo "TollGate ESP32 — Makefile" |
| @@ -50,6 +108,12 @@ help: | |||
| 50 | @echo " test-reset-auth Reset auth + per-client NAT filter test" | 108 | @echo " test-reset-auth Reset auth + per-client NAT filter test" |
| 51 | @echo " test-dns-firewall DNS hijack + NAT filter test" | 109 | @echo " test-dns-firewall DNS hijack + NAT filter test" |
| 52 | @echo " test-session-expiry Session lifecycle with 65s expiry wait" | 110 | @echo " test-session-expiry Session lifecycle with 65s expiry wait" |
| 111 | @echo " test-cvm ContextVM protocol integration test" | ||
| 112 | @echo "" | ||
| 113 | @echo "ContextVM:" | ||
| 114 | @echo " cvm-pubkey Print board's ContextVM npub" | ||
| 115 | @echo " cvm-announce Trigger re-publish of CEP-6 announcements" | ||
| 116 | @echo " cvm-test-tool Send single MCP tools/call (METHOD=get_config)" | ||
| 53 | @echo "" | 117 | @echo "" |
| 54 | @echo "Wallet:" | 118 | @echo "Wallet:" |
| 55 | @echo " wallet-setup Initialize nutshell wallet for test mint" | 119 | @echo " wallet-setup Initialize nutshell wallet for test mint" |
| @@ -122,13 +186,18 @@ setup: | |||
| 122 | 186 | ||
| 123 | flash: build | 187 | flash: build |
| 124 | @echo "=== Flashing to $(PORT) ===" | 188 | @echo "=== Flashing to $(PORT) ===" |
| 125 | . $(IDF_PATH)/export.sh && idf.py -p $(PORT) -b $(BAUD) flash | 189 | @echo "$(RED)Error: use 'make flash-a' or 'make flash-b' (per-board lock required)$(RESET)" |
| 190 | @exit 1 | ||
| 126 | 191 | ||
| 127 | flash-a: PORT=$(PORT_A) | 192 | flash-a: build |
| 128 | flash-a: flash | 193 | $(call require_lock_a) |
| 194 | @echo "=== Flashing to $(PORT_A) (Board A) ===" | ||
| 195 | . $(IDF_PATH)/export.sh && idf.py -p $(PORT_A) -b $(BAUD) flash | ||
| 129 | 196 | ||
| 130 | flash-b: PORT=$(PORT_B) | 197 | flash-b: build |
| 131 | flash-b: flash | 198 | $(call require_lock_b) |
| 199 | @echo "=== Flashing to $(PORT_B) (Board B) ===" | ||
| 200 | . $(IDF_PATH)/export.sh && idf.py -p $(PORT_B) -b $(BAUD) flash | ||
| 132 | 201 | ||
| 133 | build: | 202 | build: |
| 134 | @echo "=== Building $(TARGET) ===" | 203 | @echo "=== Building $(TARGET) ===" |
| @@ -136,14 +205,13 @@ build: | |||
| 136 | idf.py set-target $(TARGET) 2>/dev/null; \ | 205 | idf.py set-target $(TARGET) 2>/dev/null; \ |
| 137 | idf.py build | 206 | idf.py build |
| 138 | 207 | ||
| 139 | monitor: | 208 | monitor-a: |
| 140 | . $(IDF_PATH)/export.sh && idf.py -p $(PORT) monitor | 209 | $(call require_lock_a) |
| 210 | . $(IDF_PATH)/export.sh && idf.py -p $(PORT_A) monitor | ||
| 141 | 211 | ||
| 142 | monitor-a: PORT=$(PORT_A) | 212 | monitor-b: |
| 143 | monitor-a: monitor | 213 | $(call require_lock_b) |
| 144 | 214 | . $(IDF_PATH)/export.sh && idf.py -p $(PORT_B) monitor | |
| 145 | monitor-b: PORT=$(PORT_B) | ||
| 146 | monitor-b: monitor | ||
| 147 | 215 | ||
| 148 | # ────────────────────────────────────────────── | 216 | # ────────────────────────────────────────────── |
| 149 | # Testing | 217 | # Testing |
| @@ -153,10 +221,11 @@ test-unit: | |||
| 153 | @echo "=== Running host unit tests ===" | 221 | @echo "=== Running host unit tests ===" |
| 154 | $(MAKE) -C tests/unit test | 222 | $(MAKE) -C tests/unit test |
| 155 | 223 | ||
| 156 | test-integration: test-api test-network test-reset-auth test-dns-firewall | 224 | test-integration: test-api test-network test-reset-auth test-dns-firewall test-cvm |
| 157 | @echo "=== Integration tests passed ===" | 225 | @echo "=== Integration tests passed ===" |
| 158 | 226 | ||
| 159 | test-e2e: | 227 | test-e2e: |
| 228 | $(call _require_board_lock) | ||
| 160 | @echo "=== Running Playwright E2E tests ===" | 229 | @echo "=== Running Playwright E2E tests ===" |
| 161 | cd tests/e2e && npx playwright test | 230 | cd tests/e2e && npx playwright test |
| 162 | 231 | ||
| @@ -167,37 +236,50 @@ test: test-unit test-integration | |||
| 167 | @echo "=== Tests passed ===" | 236 | @echo "=== Tests passed ===" |
| 168 | 237 | ||
| 169 | test-smoke: | 238 | test-smoke: |
| 239 | $(call _require_board_lock) | ||
| 170 | @echo "=== Running smoke test (30s) ===" | 240 | @echo "=== Running smoke test (30s) ===" |
| 171 | TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/smoke.mjs | 241 | TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/smoke.mjs |
| 172 | 242 | ||
| 173 | test-api: | 243 | test-api: |
| 244 | $(call _require_board_lock) | ||
| 174 | @echo "=== Running API tests ===" | 245 | @echo "=== Running API tests ===" |
| 175 | TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/api.mjs | 246 | TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/api.mjs |
| 176 | 247 | ||
| 177 | test-network: | 248 | test-network: |
| 249 | $(call _require_board_lock) | ||
| 178 | @echo "=== Running network tests ===" | 250 | @echo "=== Running network tests ===" |
| 179 | TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/network.mjs | 251 | TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/network.mjs |
| 180 | 252 | ||
| 181 | test-portal: | 253 | test-portal: |
| 254 | $(call _require_board_lock) | ||
| 182 | @echo "=== Running Playwright portal tests ===" | 255 | @echo "=== Running Playwright portal tests ===" |
| 183 | cd tests/e2e && npx playwright test captive-portal.spec.mjs | 256 | cd tests/e2e && npx playwright test captive-portal.spec.mjs |
| 184 | 257 | ||
| 185 | test-payment: | 258 | test-payment: |
| 259 | $(call _require_board_lock) | ||
| 186 | @echo "=== Running payment tests ===" | 260 | @echo "=== Running payment tests ===" |
| 187 | TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/phase2.mjs | 261 | TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/phase2.mjs |
| 188 | 262 | ||
| 189 | test-reset-auth: | 263 | test-reset-auth: |
| 264 | $(call _require_board_lock) | ||
| 190 | @echo "=== Running reset auth test ===" | 265 | @echo "=== Running reset auth test ===" |
| 191 | TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/test-reset-auth.mjs | 266 | TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/test-reset-auth.mjs |
| 192 | 267 | ||
| 193 | test-session-expiry: | 268 | test-session-expiry: |
| 269 | $(call _require_board_lock) | ||
| 194 | @echo "=== Running session expiry test (65s wait, ~80s total) ===" | 270 | @echo "=== Running session expiry test (65s wait, ~80s total) ===" |
| 195 | TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/test-session-expiry.mjs | 271 | TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/test-session-expiry.mjs |
| 196 | 272 | ||
| 197 | test-dns-firewall: | 273 | test-dns-firewall: |
| 274 | $(call _require_board_lock) | ||
| 198 | @echo "=== Running DNS + firewall test ===" | 275 | @echo "=== Running DNS + firewall test ===" |
| 199 | TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/test-dns-firewall.mjs | 276 | TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/test-dns-firewall.mjs |
| 200 | 277 | ||
| 278 | test-cvm: | ||
| 279 | $(call _require_board_lock) | ||
| 280 | @echo "=== Running CVM integration test ===" | ||
| 281 | TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/test-cvm.mjs | ||
| 282 | |||
| 201 | # ────────────────────────────────────────────── | 283 | # ────────────────────────────────────────────── |
| 202 | # Wallet | 284 | # Wallet |
| 203 | # ────────────────────────────────────────────── | 285 | # ────────────────────────────────────────────── |
| @@ -230,6 +312,33 @@ send-token: | |||
| 230 | tokens: send-token | 312 | tokens: send-token |
| 231 | 313 | ||
| 232 | # ────────────────────────────────────────────── | 314 | # ────────────────────────────────────────────── |
| 315 | # ContextVM | ||
| 316 | # ────────────────────────────────────────────── | ||
| 317 | |||
| 318 | cvm-pubkey: | ||
| 319 | @echo "=== Board ContextVM npub ===" | ||
| 320 | @nak key public a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 | xargs -I{} nak encode npub {} | ||
| 321 | @echo "" | ||
| 322 | @echo "Search for this npub on https://contextvm.org/servers" | ||
| 323 | |||
| 324 | cvm-announce: | ||
| 325 | @echo "=== Triggering CEP-6 re-announcement ===" | ||
| 326 | curl -s http://$(TOLLGATE_IP):2121/ | head -1 || echo "Board not reachable" | ||
| 327 | |||
| 328 | cvm-test-tool: | ||
| 329 | $(call _require_board_lock) | ||
| 330 | @METHOD=$${METHOD:-get_config}; \ | ||
| 331 | PARAMS=$${PARAMS:-{}}; \ | ||
| 332 | echo "=== Calling $$METHOD via CVM ==="; \ | ||
| 333 | NPUB_HEX=$$(nak key public a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2); \ | ||
| 334 | CONTENT="$$(echo "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"$$METHOD\",\"arguments\":$$PARAMS}}" | jq -c .)"; \ | ||
| 335 | EVENT_JSON="$$(nak event --kind 25910 --tag p=$$NPUB_HEX --content "$$CONTENT" wss://relay.damus.io 2>/dev/null)"; \ | ||
| 336 | echo "Published: $$EVENT_JSON"; \ | ||
| 337 | echo "Waiting for response..."; \ | ||
| 338 | sleep 3; \ | ||
| 339 | nak req -k 25910 -a $$NPUB_HEX -l 5 wss://relay.damus.io | ||
| 340 | |||
| 341 | # ────────────────────────────────────────────── | ||
| 233 | # Utilities | 342 | # Utilities |
| 234 | # ────────────────────────────────────────────── | 343 | # ────────────────────────────────────────────── |
| 235 | 344 | ||
| @@ -238,16 +347,19 @@ clean: | |||
| 238 | . $(IDF_PATH)/export.sh && idf.py fullclean | 347 | . $(IDF_PATH)/export.sh && idf.py fullclean |
| 239 | 348 | ||
| 240 | erase-nvs: | 349 | erase-nvs: |
| 350 | $(call _require_board_lock) | ||
| 241 | @echo "=== Erasing NVS on $(PORT) ===" | 351 | @echo "=== Erasing NVS on $(PORT) ===" |
| 242 | . $(IDF_PATH)/export.sh && \ | 352 | . $(IDF_PATH)/export.sh && \ |
| 243 | partition_offset=$$(idf.py partition-table 2>/dev/null | grep nvs | awk '{print $$2}'); \ | 353 | partition_offset=$$(idf.py partition-table 2>/dev/null | grep nvs | awk '{print $$2}'); \ |
| 244 | python3 -m esptool --port $(PORT) erase_region $$partition_offset 0x6000 | 354 | python3 -m esptool --port $(PORT) erase_region $$partition_offset 0x6000 |
| 245 | 355 | ||
| 246 | reset: | 356 | reset: |
| 357 | $(call _require_board_lock) | ||
| 247 | @echo "=== Resetting device on $(PORT) ===" | 358 | @echo "=== Resetting device on $(PORT) ===" |
| 248 | python3 -m esptool --port $(PORT) run 2>/dev/null || true | 359 | python3 -m esptool --port $(PORT) run 2>/dev/null || true |
| 249 | 360 | ||
| 250 | serial-log: | 361 | serial-log: |
| 362 | $(call _require_board_lock) | ||
| 251 | @echo "=== Capturing serial output from $(PORT) ===" | 363 | @echo "=== Capturing serial output from $(PORT) ===" |
| 252 | python3 -c "import serial; s=serial.Serial('$(PORT)',115200,timeout=1); \ | 364 | python3 -c "import serial; s=serial.Serial('$(PORT)',115200,timeout=1); \ |
| 253 | [print(s.readline().decode(errors='replace'),end='') for _ in iter(lambda: s.readline(), b'')]" | 365 | [print(s.readline().decode(errors='replace'),end='') for _ in iter(lambda: s.readline(), b'')]" |
| @@ -256,3 +368,55 @@ bootstrap-config: | |||
| 256 | @echo "=== Bootstrapping config.json ===" | 368 | @echo "=== Bootstrapping config.json ===" |
| 257 | @echo '{"wifi_networks":[{"ssid":"$(WIFI_SSID)","password":"$(WIFI_PASSWORD)"}],"ap_ssid":"$(AP_SSID)","ap_password":"$(AP_PASSWORD)","mint_url":"$(MINT_URL)","lnurl_url":"$(LNURL_URL)","price_per_step":$(PRICE_PER_STEP),"step_size_ms":$(STEP_SIZE)}' > main/config.json | 369 | @echo '{"wifi_networks":[{"ssid":"$(WIFI_SSID)","password":"$(WIFI_PASSWORD)"}],"ap_ssid":"$(AP_SSID)","ap_password":"$(AP_PASSWORD)","mint_url":"$(MINT_URL)","lnurl_url":"$(LNURL_URL)","price_per_step":$(PRICE_PER_STEP),"step_size_ms":$(STEP_SIZE)}' > main/config.json |
| 258 | @echo "Config written to main/config.json" | 370 | @echo "Config written to main/config.json" |
| 371 | |||
| 372 | # ────────────────────────────────────────────── | ||
| 373 | # Per-Board Hardware Locks | ||
| 374 | # ────────────────────────────────────────────── | ||
| 375 | |||
| 376 | lock-a: ## Acquire Board A lock (set PHASE="description") | ||
| 377 | $(call _acquire_lock,board-a) | ||
| 378 | |||
| 379 | lock-b: ## Acquire Board B lock (set PHASE="description") | ||
| 380 | $(call _acquire_lock,board-b) | ||
| 381 | |||
| 382 | unlock-a: ## Release Board A lock | ||
| 383 | @if [ ! -f "$(HARDWARE_LOCK_DIR)/board-a.lock" ]; then \ | ||
| 384 | echo "$(YELLOW)Board A not locked.$(RESET)"; exit 0; \ | ||
| 385 | fi; \ | ||
| 386 | rm -f $(HARDWARE_LOCK_DIR)/board-a.lock; \ | ||
| 387 | echo "$(GREEN)Board A lock released.$(RESET)" | ||
| 388 | |||
| 389 | unlock-b: ## Release Board B lock | ||
| 390 | @if [ ! -f "$(HARDWARE_LOCK_DIR)/board-b.lock" ]; then \ | ||
| 391 | echo "$(YELLOW)Board B not locked.$(RESET)"; exit 0; \ | ||
| 392 | fi; \ | ||
| 393 | rm -f $(HARDWARE_LOCK_DIR)/board-b.lock; \ | ||
| 394 | echo "$(GREEN)Board B lock released.$(RESET)" | ||
| 395 | |||
| 396 | force-unlock-a: ## Force-release Board A lock | ||
| 397 | @if [ ! -f "$(HARDWARE_LOCK_DIR)/board-a.lock" ]; then \ | ||
| 398 | echo "$(YELLOW)Board A not locked.$(RESET)"; exit 0; \ | ||
| 399 | fi; \ | ||
| 400 | echo "$(RED)$(BOLD)WARNING: Force-releasing Board A!$(RESET)"; \ | ||
| 401 | cat $(HARDWARE_LOCK_DIR)/board-a.lock | sed 's/^/ /'; \ | ||
| 402 | rm -f $(HARDWARE_LOCK_DIR)/board-a.lock; \ | ||
| 403 | echo "$(GREEN)Board A force-released.$(RESET)" | ||
| 404 | |||
| 405 | force-unlock-b: ## Force-release Board B lock | ||
| 406 | @if [ ! -f "$(HARDWARE_LOCK_DIR)/board-b.lock" ]; then \ | ||
| 407 | echo "$(YELLOW)Board B not locked.$(RESET)"; exit 0; \ | ||
| 408 | fi; \ | ||
| 409 | echo "$(RED)$(BOLD)WARNING: Force-releasing Board B!$(RESET)"; \ | ||
| 410 | cat $(HARDWARE_LOCK_DIR)/board-b.lock | sed 's/^/ /'; \ | ||
| 411 | rm -f $(HARDWARE_LOCK_DIR)/board-b.lock; \ | ||
| 412 | echo "$(GREEN)Board B force-released.$(RESET)" | ||
| 413 | |||
| 414 | lock-status: ## Show all board lock statuses | ||
| 415 | @for board in a b; do \ | ||
| 416 | if [ -f "$(HARDWARE_LOCK_DIR)/board-$$board.lock" ]; then \ | ||
| 417 | echo "$(YELLOW)Board $$board: LOCKED$(RESET)"; \ | ||
| 418 | cat $(HARDWARE_LOCK_DIR)/board-$$board.lock | sed 's/^/ /'; \ | ||
| 419 | else \ | ||
| 420 | echo "Board $$board: $(GREEN)available$(RESET)"; \ | ||
| 421 | fi; \ | ||
| 422 | done | ||
| @@ -473,21 +473,48 @@ uint64_t bytes_consumed; | |||
| 473 | | 51 | NAPT byte counting | Integration | Counters match actual traffic | 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 | | 474 | | 52 | Bytes metric end-to-end | E2E | Client disconnected after data cap | TODO | |
| 475 | 475 | ||
| 476 | ### Phase 7: ContextVM Server (MCP over Nostr) — COMPLETE | 476 | ### Phase 7: ContextVM Server (MCP over Nostr) — REWRITE IN PROGRESS |
| 477 | 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. | 478 | **Goal:** Full ContextVM protocol implementation — ESP32 acts as an MCP server discoverable on the Nostr network via CEP-6 public announcements, communicating via kind 25910 ephemeral events. |
| 479 | 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` | 480 | **Protocol:** ContextVM transports MCP JSON-RPC 2.0 messages over Nostr. Server is identified by its npub (derived from nsec). Clients discover the server via kind 11316 announcements, then communicate via kind 25910 ephemeral events. |
| 481 | 481 | ||
| 482 | #### Architecture | 482 | #### Architecture |
| 483 | 483 | ||
| 484 | ContextVM uses MCP (JSON-RPC 2.0) over NIP-44 encrypted Nostr DMs: | 484 | ``` |
| 485 | 1. ESP32 subscribes to Nostr relays for DMs addressed to its npub | 485 | Client (nak/ContextVM SDK) |
| 486 | 2. Incoming DMs are NIP-44 decrypted, parsed as MCP JSON-RPC requests | 486 | → publishes kind 25910 to relay ({"method":"tools/call","params":{"name":"get_config"}}) |
| 487 | 3. Dispatched to registered tool handlers | 487 | → ESP32 cvm_server reads from persistent WebSocket subscription |
| 488 | 4. Responses sent back via NIP-44 encrypted DM | 488 | → parses MCP JSON-RPC from event content |
| 489 | → dispatches to mcp_handler.c | ||
| 490 | → publishes kind 25910 response back to relay | ||
| 491 | → client receives response via subscription | ||
| 492 | ``` | ||
| 493 | |||
| 494 | #### ContextVM Event Kinds Used | ||
| 495 | |||
| 496 | | Kind | Purpose | CEP | | ||
| 497 | |------|---------|-----| | ||
| 498 | | 25910 | MCP request/response transport (ephemeral) | Draft spec | | ||
| 499 | | 11316 | Server announcement (replaceable) | CEP-6 | | ||
| 500 | | 11317 | Tools list announcement (replaceable) | CEP-6 | | ||
| 501 | | 10002 | Relay list (replaceable) | CEP-17 (NIP-65) | | ||
| 502 | |||
| 503 | #### MCP Protocol Flow | ||
| 489 | 504 | ||
| 490 | #### MCP Tools Exposed | 505 | 1. ESP32 publishes kind 11316 (server announcement) + kind 11317 (tools list) + kind 10002 (relay list) on startup |
| 506 | 2. ESP32 opens persistent WebSocket to relays, subscribes to `{"kinds":[25910],"#p":["<npub>"]}` | ||
| 507 | 3. Client sends kind 25910 `initialize` request | ||
| 508 | 4. ESP32 responds with kind 25910 `initialize` result (capabilities, serverInfo) | ||
| 509 | 5. Client sends `notifications/initialized` | ||
| 510 | 6. Client calls `tools/list` or `tools/call` | ||
| 511 | 7. ESP32 dispatches to `mcp_handler.c`, returns result | ||
| 512 | |||
| 513 | #### Encryption | ||
| 514 | |||
| 515 | Phase 7a ships with **plaintext** kind 25910 events. Encryption (CEP-4: NIP-44 gift wrap) is deferred to Phase 7b. The `support_encryption` tag is NOT included in announcements until Phase 7b. | ||
| 516 | |||
| 517 | #### MCP Tools Exposed (10 total) | ||
| 491 | 518 | ||
| 492 | | Tool | Input | Output | | 519 | | Tool | Input | Output | |
| 493 | |------|-------|--------| | 520 | |------|-------|--------| |
| @@ -497,32 +524,89 @@ ContextVM uses MCP (JSON-RPC 2.0) over NIP-44 encrypted Nostr DMs: | |||
| 497 | | `get_sessions` | — | Array of active sessions | | 524 | | `get_sessions` | — | Array of active sessions | |
| 498 | | `get_usage` | — | Upstream usage if client active | | 525 | | `get_usage` | — | Upstream usage if client active | |
| 499 | | `set_payout` | `{recipients: [...]}` | Success/error | | 526 | | `set_payout` | `{recipients: [...]}` | Success/error | |
| 500 | | `set_metric` | `{"bytes" or "milliseconds"}` | Success/error | | 527 | | `set_metric` | `{"metric": "bytes" or "milliseconds"}` | Success/error | |
| 501 | | `set_price` | `{price_per_step: N}` | Success/error | | 528 | | `set_price` | `{"price_per_step": N}` | Success/error | |
| 502 | | `wallet_send` | `{amount_sats: N}` | `{token: "cashuA..."}` | | 529 | | `wallet_send` | `{"amount": N}` | `{token: "cashuA..."}` | |
| 503 | | `wallet_melt` | `{bolt11: "ln..."}` | `{preimage: "..."}` | | 530 | | `wallet_melt` | `{"bolt11": "ln..."}` | `{preimage: "..."}` | |
| 504 | 531 | ||
| 505 | #### Auth | 532 | #### Auth |
| 506 | 533 | ||
| 507 | Only accept commands from owner npub (derived from nsec in config.json). | 534 | Only accept kind 25910 requests from owner npub (derived from nsec in config.json). Non-owner requests are silently dropped. |
| 508 | 535 | ||
| 509 | #### Dependencies | 536 | #### Dependencies |
| 510 | 537 | ||
| 511 | - XChaCha20-Poly1305 (from mbedtls or libsodium) | 538 | - WebSocket persistent connection (extends `wifistr.c` TLS + WS pattern) |
| 512 | - Base64url encoding (already in cashu code) | 539 | - secp256k1 Schnorr signing (existing `nostr_event.c`) |
| 513 | - WebSocket listener (extends existing wifistr infrastructure) | 540 | - cJSON (existing) |
| 514 | - NIP-44 v2 encryption/decryption | 541 | - mbedtls TLS (existing) |
| 542 | - NIP-04 encryption (existing `nip04.c`) — for future encrypted mode | ||
| 543 | |||
| 544 | #### Files | ||
| 545 | |||
| 546 | | File | Status | Purpose | | ||
| 547 | |------|--------|---------| | ||
| 548 | | `main/cvm_server.c` | Rewrite | WS listener, MCP handlers, CEP-6 announcements | | ||
| 549 | | `main/cvm_server.h` | Update | New public API | | ||
| 550 | | `main/mcp_handler.c` | Extend | 6 new tools | | ||
| 551 | | `main/mcp_handler.h` | Update | New tool enums + handlers | | ||
| 552 | | `main/config.c` | Minor | Default `cvm_enabled = true` | | ||
| 553 | | `tests/unit/test_cvm_server.c` | New | CVM unit tests | | ||
| 554 | | `tests/unit/test_mcp_handler.c` | Extend | 6 new tool tests | | ||
| 555 | | `tests/integration/test-cvm.mjs` | New | CVM integration test via nak | | ||
| 556 | | `Makefile` | Update | `cvm-*` targets | | ||
| 515 | 557 | ||
| 516 | #### Test Cases | 558 | #### Test Cases |
| 517 | 559 | ||
| 518 | | # | Test | Method | Pass Criteria | Status | | 560 | | # | Test | Method | Pass Criteria | Status | |
| 519 | |---|------|--------|---------------|--------| | 561 | |---|------|--------|---------------|--------| |
| 520 | | 53 | NIP-44 encrypt/decrypt | Unit test | Roundtrip matches | TODO | | 562 | | 53 | MCP JSON-RPC parse from kind 25910 | Unit test | Correct dispatch to tool handler | PASS | |
| 521 | | 54 | MCP JSON-RPC parse | Unit test | Correct dispatch | TODO | | 563 | | 54 | Kind 11316 announcement construction | Unit test | Valid event with correct tags/capabilities | PASS | |
| 522 | | 55 | Config change via DM | Integration | ESP32 applies new config | TODO | | 564 | | 55 | Kind 11317 tools list construction | Unit test | All 10 tools listed with schemas | PASS | |
| 523 | | 56 | Balance query via CVM | Integration | Returns correct balance | TODO | | 565 | | 56 | Kind 10002 relay list construction | Unit test | Correct `r` tags | PASS | |
| 524 | 566 | | 57 | Auth rejection for non-owner | Unit test | Non-owner events dropped | PASS | | |
| 525 | ## Total: 56 Tests across 7 phases | 567 | | 58 | MCP initialize response | Unit test | Correct capabilities + serverInfo | PASS | |
| 568 | | 59 | New tool: get_sessions | Unit test | Returns session array | PASS | | ||
| 569 | | 60 | New tool: get_usage | Unit test | Returns usage stats | PASS | | ||
| 570 | | 61 | New tool: set_payout | Unit test | Updates payout config | PASS | | ||
| 571 | | 62 | New tool: set_metric | Unit test | Updates metric field | PASS | | ||
| 572 | | 63 | New tool: set_price | Unit test | Updates price_per_step | PASS | | ||
| 573 | | 64 | New tool: wallet_melt | Unit test | Calls nucula_wallet_melt | PASS | | ||
| 574 | | 65 | Kind 11316 on relay | Integration | Announcement found on relay | PASS* | | ||
| 575 | | 66 | MCP initialize roundtrip | Integration | Response received via nak | PASS | | ||
| 576 | | 67 | get_config via CVM | Integration | Returns valid JSON config | PASS | | ||
| 577 | | 68 | get_balance via CVM | Integration | Returns balance + proofs | PASS | | ||
| 578 | | 69 | set_price via CVM | Integration | Price updated on device | PASS | | ||
| 579 | | 70 | Kind 11317 on relay | Integration | Tools list found on relay | PASS | | ||
| 580 | | 71 | Kind 10002 on relay | Integration | Relay list found on relay | PASS | | ||
| 581 | | 72 | API reachability from host | Integration | HTTP 200 from board AP | PASS | | ||
| 582 | | 73 | CVM event publish from host | Integration | Kind 25910 published to relay | PASS | | ||
| 583 | | 74 | tools/list via CVM | Integration | All 10 tools listed | PASS | | ||
| 584 | | 75 | get_sessions via CVM | Integration | Returns session array | TODO | | ||
| 585 | | 76 | get_usage via CVM | Integration | Returns usage stats | TODO | | ||
| 586 | | 77 | Non-owner rejection (live) | Integration | Unauthorized event ignored | TODO | | ||
| 587 | | 78 | Relay reconnect resilience | Integration | Board reconnects after disconnect | PASS | | ||
| 588 | |||
| 589 | ## Total: 85 Tests across 8 phases | ||
| 590 | |||
| 591 | ## Merge Readiness Checklist | ||
| 592 | |||
| 593 | ### Code Quality | ||
| 594 | - [ ] Fix relay disconnect cycle (rlen=-26880 every ~15s, WS read has no timeout) | ||
| 595 | - [ ] Clean up debug logging (Sending WS response, WS send result → DEBUG level) | ||
| 596 | - [ ] Document Board A hardware WiFi issue in AGENTS.md | ||
| 597 | |||
| 598 | ### Integration Testing (needs Board B + relay.primal.net) | ||
| 599 | - [ ] tools/list response via kind 25910 | ||
| 600 | - [ ] tools/call set_price via kind 25910 | ||
| 601 | - [ ] tools/call get_sessions via kind 25910 | ||
| 602 | - [ ] tools/call get_usage via kind 25910 | ||
| 603 | - [ ] Non-owner auth rejection via live relay | ||
| 604 | - [ ] Verify board npub on contextvm.org/servers | ||
| 605 | |||
| 606 | ### Pre-merge | ||
| 607 | - [ ] `make test-unit` — all 282 unit tests pass | ||
| 608 | - [ ] Rebase feature/cvm-integration onto master (1 commit behind) | ||
| 609 | - [ ] Verify no conflicts with feature branches (display-fix, multi-mint, price-discovery) | ||
| 526 | 610 | ||
| 527 | ## Post-Phase 7: Bug Fixes & Architecture Improvements | 611 | ## Post-Phase 7: Bug Fixes & Architecture Improvements |
| 528 | 612 | ||
| @@ -591,6 +675,78 @@ int tollgate_ip4_canforward_filter(struct pbuf *p, u32_t dest_addr_hostorder) { | |||
| 591 | 675 | ||
| 592 | **Rationale:** The mint's `cashu_check_proof_states()` already catches double-spends over HTTP. `nucula_wallet_receive()` → `swap()` registers proofs as spent and replaces them. After a successful receive, the old token is useless. Local tracking adds no security, wastes 6.5KB RAM, and is lost on reboot anyway. | 676 | **Rationale:** The mint's `cashu_check_proof_states()` already catches double-spends over HTTP. `nucula_wallet_receive()` → `swap()` registers proofs as spent and replaces them. After a successful receive, the old token is useless. Local tracking adds no security, wastes 6.5KB RAM, and is lost on reboot anyway. |
| 593 | 677 | ||
| 678 | ### Phase 8: TFT Display (JC3248W535 / AXS15231B) — IN PROGRESS | ||
| 679 | |||
| 680 | **Goal:** Add TFT display support to the JC3248W535 board for QR code rendering + status text. Display cycles between a Wi-Fi QR code (so customers can connect) and a portal URL QR code (for direct portal access). | ||
| 681 | |||
| 682 | **Hardware:** JC3248W535 board — ESP32-S3, AXS15231B 320x480 QSPI TFT, capacitive touch | ||
| 683 | **Pin mapping:** CS=45, CLK=47, D0=21, D1=48, D2=40, D3=39, BL=1, Touch SDA=4, Touch SCL=8 | ||
| 684 | |||
| 685 | #### Components Created | ||
| 686 | |||
| 687 | | Component | Path | Purpose | | ||
| 688 | |-----------|------|---------| | ||
| 689 | | `components/qrcode/` | `qrcoded.c/h` + CMakeLists.txt | QR code generation (ported from NSD, MIT license) | | ||
| 690 | | `components/axs15231b/` | `axs15231b.c/h` + CMakeLists.txt | AXS15231B QSPI display driver | | ||
| 691 | | `main/display.c/h` | Display abstraction | FreeRTOS display task, state machine, QR cycling | | ||
| 692 | | `main/font.c/h` | 8x8 bitmap font | ASCII 32-127 for status text rendering | | ||
| 693 | |||
| 694 | #### Display States | ||
| 695 | |||
| 696 | | State | Screen | QR Content | | ||
| 697 | |-------|--------|------------| | ||
| 698 | | `DISPLAY_BOOT` | "TollGate starting..." | None | | ||
| 699 | | `DISPLAY_READY` | QR code + SSID label | Cycles: Wi-Fi QR ↔ Portal URL QR every 5s | | ||
| 700 | | `DISPLAY_PAYMENT_RECEIVED` | Green "Paid! Access granted" | None (2s, then READY) | | ||
| 701 | | `DISPLAY_ERROR` | Red "No upstream" | None | | ||
| 702 | |||
| 703 | #### Wi-Fi QR Code Format | ||
| 704 | |||
| 705 | Uses the standardized ZXing `WIFI:` URI scheme — natively recognized by Android and iOS camera apps: | ||
| 706 | |||
| 707 | ``` | ||
| 708 | WIFI:S:<escaped_ssid>;T:nopass;; | ||
| 709 | ``` | ||
| 710 | |||
| 711 | **Special character escaping**: `;`, `:`, `\`, `,`, `"` are backslash-escaped in the SSID field per spec. | ||
| 712 | |||
| 713 | **Example:** | ||
| 714 | ``` | ||
| 715 | SSID: TollGate-C0E9CA → WIFI:S:TollGate-C0E9CA;T:nopass;; | ||
| 716 | SSID: My;WiFi:Test → WIFI:S:My\;WiFi\:Test;T:nopass;; | ||
| 717 | ``` | ||
| 718 | |||
| 719 | When scanned, the phone **automatically connects** to the TollGate AP — then the captive portal takes over for payment. | ||
| 720 | |||
| 721 | #### QR Cycling | ||
| 722 | |||
| 723 | The display alternates between two QR modes every 5 seconds: | ||
| 724 | 1. **Wi-Fi QR** — `WIFI:S:...;T:nopass;;` — auto-connects phone to AP | ||
| 725 | 2. **Portal URL QR** — `http://10.x.x.1/` — direct link to captive portal | ||
| 726 | |||
| 727 | Label text below QR changes to indicate current mode: "Scan to connect" vs "Portal URL". | ||
| 728 | |||
| 729 | #### Display Driver Architecture | ||
| 730 | |||
| 731 | - **Interface**: Single-line SPI (MOSI on D0/GPIO21) — simpler than QSPI, reliable for V1 | ||
| 732 | - **Framebuffer**: 307,200 bytes (480x320x2 RGB565) in PSRAM via `heap_caps_malloc` | ||
| 733 | - **Flush**: 10 chunks of 32KB via `spi_device_polling_transmit` | ||
| 734 | - **Rotation**: Landscape (MADCTL=0x60, MX|MV) | ||
| 735 | - **Backlight**: GPIO1 active-high | ||
| 736 | |||
| 737 | #### Test Cases | ||
| 738 | |||
| 739 | | # | Test | Method | Pass Criteria | Status | | ||
| 740 | |---|------|--------|---------------|--------| | ||
| 741 | | 70 | Wi-Fi QR scannable | Android camera scan | Phone connects to AP | TODO | | ||
| 742 | | 71 | Portal URL QR scannable | Android camera scan | Browser opens portal | TODO | | ||
| 743 | | 72 | QR cycling | Watch display | Mode changes every 5s | TODO | | ||
| 744 | | 73 | Boot screen | Visual | "TollGate starting..." shown | TODO | | ||
| 745 | | 74 | Payment screen | Trigger payment | Green "Paid!" for 2s | TODO | | ||
| 746 | | 75 | Error screen | Disconnect upstream | Red "No upstream" | TODO | | ||
| 747 | | 76 | Special char escape | Unit test | `\;:,"` correctly escaped | TODO | | ||
| 748 | | 77 | QR generation | Unit test | Valid QR matrix for various string lengths | TODO | | ||
| 749 | |||
| 594 | ## Testing Infrastructure | 750 | ## Testing Infrastructure |
| 595 | 751 | ||
| 596 | ### Three-Layer Test Architecture | 752 | ### Three-Layer Test Architecture |
diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 91748f2..9b0fb1c 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt | |||
| @@ -16,8 +16,10 @@ idf_component_register(SRCS "tollgate_main.c" | |||
| 16 | "nip04.c" | 16 | "nip04.c" |
| 17 | "mcp_handler.c" | 17 | "mcp_handler.c" |
| 18 | "cvm_server.c" | 18 | "cvm_server.c" |
| 19 | "display.c" | ||
| 20 | "font.c" | ||
| 19 | INCLUDE_DIRS "." | 21 | INCLUDE_DIRS "." |
| 20 | REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server | 22 | REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server |
| 21 | lwip json esp_http_client mbedtls esp-tls log spiffs | 23 | lwip json esp_http_client mbedtls esp-tls log spiffs |
| 22 | nucula_lib secp256k1 | 24 | nucula_lib secp256k1 axs15231b qrcode |
| 23 | PRIV_REQUIRES esp-tls) | 25 | PRIV_REQUIRES esp-tls) |
diff --git a/main/config.c b/main/config.c index e937fb3..9dd2a1d 100644 --- a/main/config.c +++ b/main/config.c | |||
| @@ -16,7 +16,7 @@ esp_err_t tollgate_config_init(void) | |||
| 16 | { | 16 | { |
| 17 | memset(&g_config, 0, sizeof(g_config)); | 17 | memset(&g_config, 0, sizeof(g_config)); |
| 18 | g_config.max_retry = 5; | 18 | g_config.max_retry = 5; |
| 19 | g_config.ap_channel = 1; | 19 | g_config.ap_channel = 6; |
| 20 | g_config.ap_max_conn = 4; | 20 | g_config.ap_max_conn = 4; |
| 21 | g_config.price_per_step = 21; | 21 | g_config.price_per_step = 21; |
| 22 | g_config.step_size_ms = 60000; | 22 | g_config.step_size_ms = 60000; |
| @@ -33,8 +33,8 @@ esp_err_t tollgate_config_init(void) | |||
| 33 | g_config.payout.check_interval_s = 60; | 33 | g_config.payout.check_interval_s = 60; |
| 34 | g_config.payout.recipient_count = 0; | 34 | g_config.payout.recipient_count = 0; |
| 35 | g_config.payout.mint_count = 0; | 35 | g_config.payout.mint_count = 0; |
| 36 | g_config.cvm_enabled = false; | 36 | g_config.cvm_enabled = true; |
| 37 | strncpy(g_config.cvm_relays, "wss://relay.damus.io", sizeof(g_config.cvm_relays) - 1); | 37 | strncpy(g_config.cvm_relays, "wss://relay.primal.net", sizeof(g_config.cvm_relays) - 1); |
| 38 | 38 | ||
| 39 | esp_vfs_spiffs_conf_t conf = { | 39 | esp_vfs_spiffs_conf_t conf = { |
| 40 | .base_path = "/spiffs", | 40 | .base_path = "/spiffs", |
| @@ -54,7 +54,9 @@ esp_err_t tollgate_config_init(void) | |||
| 54 | const char *default_json = "{" | 54 | const char *default_json = "{" |
| 55 | "\"nsec\":\"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\"," | 55 | "\"nsec\":\"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\"," |
| 56 | "\"wifi_networks\":[" | 56 | "\"wifi_networks\":[" |
| 57 | "{\"ssid\":\"EnterSSID-2.4GHz\",\"password\":\"c03rad0r123!\"}" | 57 | "{\"ssid\":\"EnterSSID-2.4GHz\",\"password\":\"c03rad0r123!\"}," |
| 58 | "{\"ssid\":\"c03rad0r\",\"password\":\"c03rad0r123\"}," | ||
| 59 | "{\"ssid\":\"TK-GAESTE\",\"password\":\"\"}" | ||
| 58 | "]," | 60 | "]," |
| 59 | "\"ap_password\":\"\"," | 61 | "\"ap_password\":\"\"," |
| 60 | "\"mint_url\":\"https://testnut.cashu.space\"," | 62 | "\"mint_url\":\"https://testnut.cashu.space\"," |
| @@ -289,6 +291,9 @@ esp_err_t tollgate_config_get_wifi(wifi_config_t *wifi_config) | |||
| 289 | strncpy((char *)wifi_config->sta.ssid, g_config.networks[idx].ssid, sizeof(wifi_config->sta.ssid) - 1); | 291 | strncpy((char *)wifi_config->sta.ssid, g_config.networks[idx].ssid, sizeof(wifi_config->sta.ssid) - 1); |
| 290 | strncpy((char *)wifi_config->sta.password, g_config.networks[idx].password, sizeof(wifi_config->sta.password) - 1); | 292 | strncpy((char *)wifi_config->sta.password, g_config.networks[idx].password, sizeof(wifi_config->sta.password) - 1); |
| 291 | wifi_config->sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; | 293 | wifi_config->sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; |
| 294 | wifi_config->sta.pmf_cfg.capable = true; | ||
| 295 | wifi_config->sta.pmf_cfg.required = false; | ||
| 296 | wifi_config->sta.scan_method = WIFI_ALL_CHANNEL_SCAN; | ||
| 292 | return ESP_OK; | 297 | return ESP_OK; |
| 293 | } | 298 | } |
| 294 | 299 | ||
diff --git a/main/cvm_server.c b/main/cvm_server.c index 5addd88..b93e176 100644 --- a/main/cvm_server.c +++ b/main/cvm_server.c | |||
| @@ -2,219 +2,771 @@ | |||
| 2 | #include "mcp_handler.h" | 2 | #include "mcp_handler.h" |
| 3 | #include "nip04.h" | 3 | #include "nip04.h" |
| 4 | #include "identity.h" | 4 | #include "identity.h" |
| 5 | #include "nostr_event.h" | ||
| 5 | #include "config.h" | 6 | #include "config.h" |
| 7 | #include "session.h" | ||
| 6 | #include "nucula_wallet.h" | 8 | #include "nucula_wallet.h" |
| 7 | #include "cJSON.h" | 9 | #include "cJSON.h" |
| 8 | #include "esp_log.h" | 10 | #include "esp_log.h" |
| 9 | #include "esp_http_client.h" | 11 | #include "esp_tls.h" |
| 12 | #include "esp_crt_bundle.h" | ||
| 13 | #include "esp_random.h" | ||
| 14 | #include "esp_timer.h" | ||
| 10 | #include "freertos/FreeRTOS.h" | 15 | #include "freertos/FreeRTOS.h" |
| 11 | #include "freertos/task.h" | 16 | #include "freertos/task.h" |
| 12 | #include <string.h> | 17 | #include <string.h> |
| 13 | #include <stdio.h> | 18 | #include <stdio.h> |
| 19 | #include <stdlib.h> | ||
| 14 | 20 | ||
| 15 | static const char *TAG = "cvm_server"; | 21 | static const char *TAG = "cvm_server"; |
| 16 | 22 | ||
| 17 | static bool g_running = false; | 23 | static bool g_running = false; |
| 18 | static TaskHandle_t g_task = NULL; | 24 | static TaskHandle_t g_task = NULL; |
| 19 | 25 | ||
| 20 | static const char *DEFAULT_RELAY = "wss://relay.damus.io"; | 26 | static void publish_announcements_via_ws(esp_tls_t *tls); |
| 21 | 27 | ||
| 22 | static char *fetch_relays(void) | 28 | #define CVM_VERSION "2025-07-02" |
| 29 | #define CVM_SERVER_NAME "TollGate" | ||
| 30 | #define CVM_SERVER_VERSION "1.0.0" | ||
| 31 | #define CVM_WS_BUF_SIZE 8192 | ||
| 32 | #define CVM_MAX_RESPONSE_SIZE 4096 | ||
| 33 | #define CVM_RECONNECT_DELAY_MS 5000 | ||
| 34 | #define CVM_WS_READ_TIMEOUT_MS 60000 | ||
| 35 | #define CVM_WS_PING_INTERVAL_S 30 | ||
| 36 | |||
| 37 | static char *parse_ws_text_frame(const uint8_t *buf, int len) | ||
| 23 | { | 38 | { |
| 24 | const tollgate_config_t *cfg = tollgate_config_get(); | 39 | if (len < 2) return NULL; |
| 25 | if (cfg && cfg->cvm_relays[0]) { | 40 | bool masked = (buf[1] & 0x80) != 0; |
| 26 | return cfg->cvm_relays; | 41 | uint64_t payload_len = buf[1] & 0x7F; |
| 42 | int offset = 2; | ||
| 43 | |||
| 44 | if (payload_len == 126) { | ||
| 45 | if (len < 4) return NULL; | ||
| 46 | payload_len = ((uint64_t)buf[2] << 8) | buf[3]; | ||
| 47 | offset = 4; | ||
| 48 | } else if (payload_len == 127) { | ||
| 49 | if (len < 10) return NULL; | ||
| 50 | payload_len = 0; | ||
| 51 | for (int i = 0; i < 8; i++) | ||
| 52 | payload_len = (payload_len << 8) | buf[2 + i]; | ||
| 53 | offset = 10; | ||
| 54 | } | ||
| 55 | |||
| 56 | if (masked) offset += 4; | ||
| 57 | if (offset + payload_len > (uint64_t)len) return NULL; | ||
| 58 | |||
| 59 | char *text = malloc((size_t)payload_len + 1); | ||
| 60 | if (!text) return NULL; | ||
| 61 | |||
| 62 | if (masked) { | ||
| 63 | uint8_t mask[4] = { buf[offset - 4], buf[offset - 3], buf[offset - 2], buf[offset - 1] }; | ||
| 64 | for (uint64_t i = 0; i < payload_len; i++) | ||
| 65 | text[i] = buf[offset + i] ^ mask[i & 3]; | ||
| 66 | } else { | ||
| 67 | memcpy(text, buf + offset, (size_t)payload_len); | ||
| 68 | } | ||
| 69 | text[payload_len] = '\0'; | ||
| 70 | return text; | ||
| 71 | } | ||
| 72 | |||
| 73 | static int ws_send_text(esp_tls_t *tls, const char *msg) | ||
| 74 | { | ||
| 75 | size_t len = strlen(msg); | ||
| 76 | uint8_t mask[4]; | ||
| 77 | esp_fill_random(mask, 4); | ||
| 78 | |||
| 79 | size_t frame_len = 6 + len; | ||
| 80 | if (len > 125) frame_len += 2; | ||
| 81 | if (len > 65535) frame_len += 6; | ||
| 82 | |||
| 83 | uint8_t *frame = malloc(frame_len + len); | ||
| 84 | if (!frame) return -1; | ||
| 85 | |||
| 86 | int pos = 0; | ||
| 87 | frame[pos++] = 0x81; | ||
| 88 | if (len <= 125) { | ||
| 89 | frame[pos++] = (uint8_t)(0x80 | len); | ||
| 90 | } else if (len <= 65535) { | ||
| 91 | frame[pos++] = 0x80 | 126; | ||
| 92 | frame[pos++] = (uint8_t)((len >> 8) & 0xff); | ||
| 93 | frame[pos++] = (uint8_t)(len & 0xff); | ||
| 94 | } else { | ||
| 95 | frame[pos++] = 0x80 | 127; | ||
| 96 | for (int i = 0; i < 8; i++) | ||
| 97 | frame[pos++] = (uint8_t)((len >> (56 - i * 8)) & 0xff); | ||
| 98 | } | ||
| 99 | memcpy(frame + pos, mask, 4); | ||
| 100 | pos += 4; | ||
| 101 | |||
| 102 | for (size_t i = 0; i < len; i++) | ||
| 103 | frame[pos + i] = (uint8_t)msg[i] ^ mask[i & 3]; | ||
| 104 | pos += len; | ||
| 105 | |||
| 106 | int total = pos; | ||
| 107 | int written = 0; | ||
| 108 | while (written < total) { | ||
| 109 | int w = esp_tls_conn_write(tls, frame + written, total - written); | ||
| 110 | if (w < 0) { | ||
| 111 | ESP_LOGE(TAG, "ws_send: write failed at %d/%d", written, total); | ||
| 112 | free(frame); | ||
| 113 | return -1; | ||
| 114 | } | ||
| 115 | if (w == 0) { | ||
| 116 | ESP_LOGW(TAG, "ws_send: write returned 0 at %d/%d", written, total); | ||
| 117 | vTaskDelay(pdMS_TO_TICKS(1)); | ||
| 118 | } | ||
| 119 | written += w; | ||
| 27 | } | 120 | } |
| 28 | return (char *)DEFAULT_RELAY; | 121 | ESP_LOGD(TAG, "ws_send: sent %d bytes (payload %d)", total, (int)len); |
| 122 | free(frame); | ||
| 123 | return 0; | ||
| 29 | } | 124 | } |
| 30 | 125 | ||
| 31 | static char *http_get(const char *url, int timeout_ms) | 126 | static esp_err_t ws_connect(const char *relay_url, esp_tls_t **tls_out) |
| 32 | { | 127 | { |
| 33 | char *buf = malloc(8192); | 128 | char host[128] = {0}; |
| 34 | if (!buf) return NULL; | 129 | int port = 443; |
| 35 | int total = 0; | 130 | char path[128] = "/"; |
| 131 | |||
| 132 | if (strncmp(relay_url, "wss://", 6) != 0) return ESP_ERR_INVALID_ARG; | ||
| 133 | |||
| 134 | const char *url_start = relay_url + 6; | ||
| 135 | const char *path_ptr = strchr(url_start, '/'); | ||
| 136 | if (path_ptr) { | ||
| 137 | size_t host_len = path_ptr - url_start; | ||
| 138 | if (host_len >= sizeof(host)) host_len = sizeof(host) - 1; | ||
| 139 | memcpy(host, url_start, host_len); | ||
| 140 | host[host_len] = '\0'; | ||
| 141 | strncpy(path, path_ptr, sizeof(path) - 1); | ||
| 142 | } else { | ||
| 143 | strncpy(host, url_start, sizeof(host) - 1); | ||
| 144 | } | ||
| 36 | 145 | ||
| 37 | esp_http_client_config_t config = { | 146 | char *colon = strchr(host, ':'); |
| 38 | .url = url, | 147 | if (colon) { |
| 39 | .method = HTTP_METHOD_GET, | 148 | *colon = '\0'; |
| 40 | .timeout_ms = timeout_ms, | 149 | port = atoi(colon + 1); |
| 150 | } | ||
| 151 | |||
| 152 | esp_tls_cfg_t tls_cfg = { | ||
| 153 | .crt_bundle_attach = esp_crt_bundle_attach, | ||
| 154 | .timeout_ms = CVM_WS_READ_TIMEOUT_MS, | ||
| 41 | }; | 155 | }; |
| 42 | esp_http_client_handle_t client = esp_http_client_init(&config); | 156 | esp_tls_t *tls = esp_tls_init(); |
| 43 | if (!client) { free(buf); return NULL; } | 157 | if (!tls) return ESP_ERR_NO_MEM; |
| 44 | 158 | ||
| 45 | esp_err_t err = esp_http_client_open(client, 0); | 159 | int ret = esp_tls_conn_new_sync(host, strlen(host), port, &tls_cfg, tls); |
| 46 | if (err != ESP_OK) { | 160 | if (ret < 0) { |
| 47 | esp_http_client_cleanup(client); | 161 | esp_tls_conn_destroy(tls); |
| 48 | free(buf); | 162 | return ESP_FAIL; |
| 49 | return NULL; | ||
| 50 | } | 163 | } |
| 51 | 164 | ||
| 52 | int content_length = esp_http_client_fetch_headers(client); | 165 | char upgrade[512]; |
| 53 | int max_read = content_length > 0 ? content_length : 8191; | 166 | snprintf(upgrade, sizeof(upgrade), |
| 167 | "GET %s HTTP/1.1\r\n" | ||
| 168 | "Host: %s\r\n" | ||
| 169 | "Upgrade: websocket\r\n" | ||
| 170 | "Connection: Upgrade\r\n" | ||
| 171 | "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" | ||
| 172 | "Sec-WebSocket-Version: 13\r\n" | ||
| 173 | "\r\n", | ||
| 174 | path, host); | ||
| 175 | |||
| 176 | int written = esp_tls_conn_write(tls, (const unsigned char *)upgrade, strlen(upgrade)); | ||
| 177 | if (written < 0) { | ||
| 178 | esp_tls_conn_destroy(tls); | ||
| 179 | return ESP_FAIL; | ||
| 180 | } | ||
| 54 | 181 | ||
| 55 | while (total < max_read) { | 182 | char resp[1024]; |
| 56 | int n = esp_http_client_read(client, buf + total, max_read - total); | 183 | int rlen = esp_tls_conn_read(tls, (unsigned char *)resp, sizeof(resp) - 1); |
| 57 | if (n <= 0) break; | 184 | if (rlen <= 0 || !strstr(resp, "101")) { |
| 58 | total += n; | 185 | ESP_LOGE(TAG, "WS upgrade failed to %s (read %d)", host, rlen); |
| 186 | esp_tls_conn_destroy(tls); | ||
| 187 | return ESP_FAIL; | ||
| 59 | } | 188 | } |
| 60 | buf[total] = '\0'; | 189 | |
| 61 | esp_http_client_cleanup(client); | 190 | *tls_out = tls; |
| 62 | return buf; | 191 | ESP_LOGI(TAG, "Connected to %s", host); |
| 192 | return ESP_OK; | ||
| 63 | } | 193 | } |
| 64 | 194 | ||
| 65 | static cJSON *build_filter(const char *npub) | 195 | static cJSON *build_tools_list(void) |
| 66 | { | 196 | { |
| 67 | cJSON *filter = cJSON_CreateObject(); | 197 | cJSON *tools = cJSON_CreateArray(); |
| 68 | cJSON *kinds = cJSON_CreateArray(); | 198 | |
| 69 | cJSON_AddItemToArray(kinds, cJSON_CreateNumber(4)); | 199 | const char *tool_defs[][3] = { |
| 70 | cJSON_AddItemToObject(filter, "kinds", kinds); | 200 | {"get_config", "Get current device configuration", "{\"type\":\"object\",\"properties\":{},\"required\":[]}"}, |
| 71 | cJSON_AddStringToObject(filter, "#p", npub); | 201 | {"set_config", "Update device configuration", "{\"type\":\"object\",\"properties\":{\"price_per_step\":{\"type\":\"integer\"},\"step_size_ms\":{\"type\":\"integer\"},\"step_size_bytes\":{\"type\":\"integer\"},\"metric\":{\"type\":\"string\"},\"client_enabled\":{\"type\":\"boolean\"},\"payout_enabled\":{\"type\":\"boolean\"}}}"}, |
| 72 | cJSON_AddNumberToObject(filter, "limit", 10); | 202 | {"get_balance", "Get wallet balance and proof count", "{\"type\":\"object\",\"properties\":{},\"required\":[]}"}, |
| 73 | return filter; | 203 | {"wallet_send", "Send e-cash tokens from wallet", "{\"type\":\"object\",\"properties\":{\"amount\":{\"type\":\"integer\",\"description\":\"Amount in sats\"}},\"required\":[\"amount\"]}"}, |
| 204 | {"get_sessions","Get active client sessions", "{\"type\":\"object\",\"properties\":{},\"required\":[]}"}, | ||
| 205 | {"get_usage", "Get current billing usage info", "{\"type\":\"object\",\"properties\":{},\"required\":[]}"}, | ||
| 206 | {"set_payout", "Configure payout recipients", "{\"type\":\"object\",\"properties\":{\"enabled\":{\"type\":\"boolean\"},\"recipients\":{\"type\":\"array\"}}}"}, | ||
| 207 | {"set_metric", "Set billing metric", "{\"type\":\"object\",\"properties\":{\"metric\":{\"type\":\"string\",\"enum\":[\"bytes\",\"milliseconds\"]}},\"required\":[\"metric\"]}"}, | ||
| 208 | {"set_price", "Set price per step", "{\"type\":\"object\",\"properties\":{\"price_per_step\":{\"type\":\"integer\",\"minimum\":1}},\"required\":[\"price_per_step\"]}"}, | ||
| 209 | {"wallet_melt", "Melt tokens for lightning payment", "{\"type\":\"object\",\"properties\":{\"bolt11\":{\"type\":\"string\"},\"max_fee_sats\":{\"type\":\"integer\"}},\"required\":[\"bolt11\"]}"}, | ||
| 210 | }; | ||
| 211 | |||
| 212 | for (int i = 0; i < 10; i++) { | ||
| 213 | cJSON *tool = cJSON_CreateObject(); | ||
| 214 | cJSON_AddStringToObject(tool, "name", tool_defs[i][0]); | ||
| 215 | cJSON_AddStringToObject(tool, "description", tool_defs[i][1]); | ||
| 216 | cJSON *schema = cJSON_Parse(tool_defs[i][2]); | ||
| 217 | if (schema) cJSON_AddItemToObject(tool, "inputSchema", schema); | ||
| 218 | cJSON_AddItemToArray(tools, tool); | ||
| 219 | } | ||
| 220 | |||
| 221 | return tools; | ||
| 74 | } | 222 | } |
| 75 | 223 | ||
| 76 | static cJSON *build_subscription(const char *npub) | 224 | static char *build_initialize_response(const char *request_id_str, const char *client_pubkey) |
| 77 | { | 225 | { |
| 78 | cJSON *sub = cJSON_CreateArray(); | 226 | cJSON *response = cJSON_CreateObject(); |
| 79 | cJSON_AddItemToArray(sub, cJSON_CreateString("REQ")); | 227 | cJSON_AddStringToObject(response, "jsonrpc", "2.0"); |
| 80 | cJSON_AddItemToArray(sub, cJSON_CreateString("cvm_sub_01")); | 228 | cJSON_AddNumberToObject(response, "id", request_id_str ? atof(request_id_str) : 0); |
| 81 | cJSON_AddItemToArray(sub, build_filter(npub)); | 229 | |
| 82 | return sub; | 230 | cJSON *result = cJSON_CreateObject(); |
| 231 | cJSON_AddStringToObject(result, "protocolVersion", CVM_VERSION); | ||
| 232 | |||
| 233 | cJSON *capabilities = cJSON_CreateObject(); | ||
| 234 | cJSON_AddItemToObject(capabilities, "tools", cJSON_CreateObject()); | ||
| 235 | cJSON_AddItemToObject(result, "capabilities", capabilities); | ||
| 236 | |||
| 237 | cJSON *serverInfo = cJSON_CreateObject(); | ||
| 238 | cJSON_AddStringToObject(serverInfo, "name", CVM_SERVER_NAME); | ||
| 239 | cJSON_AddStringToObject(serverInfo, "version", CVM_SERVER_VERSION); | ||
| 240 | cJSON_AddItemToObject(result, "serverInfo", serverInfo); | ||
| 241 | |||
| 242 | cJSON_AddItemToObject(response, "result", result); | ||
| 243 | |||
| 244 | char *json = cJSON_PrintUnformatted(response); | ||
| 245 | cJSON_Delete(response); | ||
| 246 | return json; | ||
| 247 | } | ||
| 248 | |||
| 249 | static char *build_tools_list_response(const char *request_id_str) | ||
| 250 | { | ||
| 251 | cJSON *response = cJSON_CreateObject(); | ||
| 252 | cJSON_AddStringToObject(response, "jsonrpc", "2.0"); | ||
| 253 | cJSON_AddNumberToObject(response, "id", request_id_str ? atof(request_id_str) : 1); | ||
| 254 | |||
| 255 | cJSON *result = cJSON_CreateObject(); | ||
| 256 | cJSON *tools = build_tools_list(); | ||
| 257 | cJSON_AddItemToObject(result, "tools", tools); | ||
| 258 | cJSON_AddItemToObject(response, "result", result); | ||
| 259 | |||
| 260 | char *json = cJSON_PrintUnformatted(response); | ||
| 261 | cJSON_Delete(response); | ||
| 262 | return json; | ||
| 263 | } | ||
| 264 | |||
| 265 | static char *build_tool_call_response(const char *request_id_str, const mcp_response_t *mcp_resp) | ||
| 266 | { | ||
| 267 | cJSON *response = cJSON_CreateObject(); | ||
| 268 | cJSON_AddStringToObject(response, "jsonrpc", "2.0"); | ||
| 269 | cJSON_AddNumberToObject(response, "id", request_id_str ? atof(request_id_str) : 2); | ||
| 270 | |||
| 271 | if (mcp_resp->success) { | ||
| 272 | cJSON *result = cJSON_CreateObject(); | ||
| 273 | cJSON_AddItemToObject(result, "content", cJSON_CreateArray()); | ||
| 274 | cJSON *content_arr = cJSON_GetObjectItem(result, "content"); | ||
| 275 | cJSON *text_item = cJSON_CreateObject(); | ||
| 276 | cJSON_AddStringToObject(text_item, "type", "text"); | ||
| 277 | cJSON_AddStringToObject(text_item, "text", mcp_resp->result_json); | ||
| 278 | cJSON_AddItemToArray(content_arr, text_item); | ||
| 279 | cJSON_AddBoolToObject(result, "isError", false); | ||
| 280 | cJSON_AddItemToObject(response, "result", result); | ||
| 281 | } else { | ||
| 282 | cJSON *error = cJSON_CreateObject(); | ||
| 283 | cJSON_AddNumberToObject(error, "code", -32603); | ||
| 284 | cJSON_AddStringToObject(error, "message", mcp_resp->error); | ||
| 285 | cJSON_AddItemToObject(response, "error", error); | ||
| 286 | } | ||
| 287 | |||
| 288 | char *json = cJSON_PrintUnformatted(response); | ||
| 289 | cJSON_Delete(response); | ||
| 290 | return json; | ||
| 291 | } | ||
| 292 | |||
| 293 | static char *build_ping_response(const char *request_id_str) | ||
| 294 | { | ||
| 295 | cJSON *response = cJSON_CreateObject(); | ||
| 296 | cJSON_AddStringToObject(response, "jsonrpc", "2.0"); | ||
| 297 | cJSON_AddNumberToObject(response, "id", request_id_str ? atof(request_id_str) : 0); | ||
| 298 | cJSON *result = cJSON_CreateObject(); | ||
| 299 | cJSON_AddItemToObject(response, "result", result); | ||
| 300 | char *json = cJSON_PrintUnformatted(response); | ||
| 301 | cJSON_Delete(response); | ||
| 302 | return json; | ||
| 83 | } | 303 | } |
| 84 | 304 | ||
| 85 | static void process_dm(const char *sender_pubkey, const char *encrypted_content) | 305 | static esp_err_t publish_event_to_relay(const char *relay_url, const char *event_json) |
| 306 | { | ||
| 307 | esp_tls_t *tls = NULL; | ||
| 308 | esp_err_t err = ws_connect(relay_url, &tls); | ||
| 309 | if (err != ESP_OK) return err; | ||
| 310 | |||
| 311 | char *msg; | ||
| 312 | size_t event_len2 = strlen(event_json); | ||
| 313 | size_t msg_len2 = 10 + event_len2 + 2; | ||
| 314 | msg = malloc(msg_len2); | ||
| 315 | snprintf(msg, msg_len2, "[\"EVENT\",%s]", event_json); | ||
| 316 | |||
| 317 | ws_send_text(tls, msg); | ||
| 318 | free(msg); | ||
| 319 | |||
| 320 | uint8_t resp_buf[256]; | ||
| 321 | esp_tls_conn_read(tls, resp_buf, sizeof(resp_buf) - 1); | ||
| 322 | |||
| 323 | uint8_t close_frame[2] = {0x88, 0x00}; | ||
| 324 | esp_tls_conn_write(tls, close_frame, 2); | ||
| 325 | esp_tls_conn_destroy(tls); | ||
| 326 | return ESP_OK; | ||
| 327 | } | ||
| 328 | |||
| 329 | static esp_err_t publish_kind_25910_response_ws(esp_tls_t *tls, | ||
| 330 | const char *content_json, | ||
| 331 | const char *request_event_id) | ||
| 86 | { | 332 | { |
| 87 | const tollgate_identity_t *id = identity_get(); | 333 | const tollgate_identity_t *id = identity_get(); |
| 88 | if (!id || !id->initialized) { | 334 | if (!id || !id->initialized) return ESP_FAIL; |
| 89 | ESP_LOGE(TAG, "Identity not initialized"); | 335 | |
| 90 | return; | 336 | cJSON *tags = cJSON_CreateArray(); |
| 337 | cJSON *e_tag = cJSON_CreateArray(); | ||
| 338 | cJSON_AddItemToArray(e_tag, cJSON_CreateString("e")); | ||
| 339 | cJSON_AddItemToArray(e_tag, cJSON_CreateString(request_event_id)); | ||
| 340 | cJSON_AddItemToArray(tags, e_tag); | ||
| 341 | |||
| 342 | char *tags_str = cJSON_PrintUnformatted(tags); | ||
| 343 | cJSON_Delete(tags); | ||
| 344 | |||
| 345 | nostr_event_t event; | ||
| 346 | nostr_event_init(&event, id->npub_hex, 25910, tags_str, content_json); | ||
| 347 | nostr_event_sign(&event, id->nsec); | ||
| 348 | |||
| 349 | char *event_json = malloc(8192); | ||
| 350 | if (!event_json) { | ||
| 351 | free(tags_str); | ||
| 352 | return ESP_ERR_NO_MEM; | ||
| 91 | } | 353 | } |
| 92 | 354 | ||
| 93 | uint8_t sender_pk[64]; | 355 | esp_err_t ret = nostr_event_to_json(&event, event_json, 8192); |
| 94 | for (int i = 0; i < 64; i++) { | 356 | free(tags_str); |
| 95 | char hex[3] = { sender_pubkey[i*2], sender_pubkey[i*2+1], 0 }; | 357 | if (ret != ESP_OK) { |
| 96 | sender_pk[i] = (uint8_t)strtol(hex, NULL, 16); | 358 | free(event_json); |
| 359 | return ret; | ||
| 97 | } | 360 | } |
| 98 | 361 | ||
| 99 | char plaintext[2048]; | 362 | size_t msg_len = 10 + strlen(event_json) + 2; |
| 100 | int pt_len = nip04_decrypt(id->nsec, sender_pk, encrypted_content, plaintext, sizeof(plaintext)); | 363 | char *msg = malloc(msg_len); |
| 101 | if (pt_len < 0) { | 364 | if (!msg) { |
| 102 | ESP_LOGE(TAG, "Failed to decrypt DM from %.8s", sender_pubkey); | 365 | free(event_json); |
| 103 | return; | 366 | return ESP_ERR_NO_MEM; |
| 104 | } | 367 | } |
| 368 | snprintf(msg, msg_len, "[\"EVENT\",%s]", event_json); | ||
| 369 | ESP_LOGD(TAG, "Sending WS response (%d bytes)", (int)strlen(msg)); | ||
| 370 | int rc = ws_send_text(tls, msg); | ||
| 371 | ESP_LOGD(TAG, "WS send result: %d", rc); | ||
| 372 | free(msg); | ||
| 373 | free(event_json); | ||
| 374 | return ESP_OK; | ||
| 375 | } | ||
| 376 | |||
| 377 | static esp_err_t publish_kind_25910_response(const char *relay_url, | ||
| 378 | const char *content_json, | ||
| 379 | const char *request_event_id) | ||
| 380 | { | ||
| 381 | const tollgate_identity_t *id = identity_get(); | ||
| 382 | if (!id || !id->initialized) return ESP_FAIL; | ||
| 383 | |||
| 384 | cJSON *tags = cJSON_CreateArray(); | ||
| 385 | cJSON *e_tag = cJSON_CreateArray(); | ||
| 386 | cJSON_AddItemToArray(e_tag, cJSON_CreateString("e")); | ||
| 387 | cJSON_AddItemToArray(e_tag, cJSON_CreateString(request_event_id)); | ||
| 388 | cJSON_AddItemToArray(tags, e_tag); | ||
| 389 | |||
| 390 | char *tags_str = cJSON_PrintUnformatted(tags); | ||
| 391 | cJSON_Delete(tags); | ||
| 392 | |||
| 393 | nostr_event_t event; | ||
| 394 | nostr_event_init(&event, id->npub_hex, 25910, tags_str, content_json); | ||
| 395 | nostr_event_sign(&event, id->nsec); | ||
| 396 | free(tags_str); | ||
| 397 | |||
| 398 | char *event_json = malloc(8192); | ||
| 399 | if (!event_json) return ESP_ERR_NO_MEM; | ||
| 400 | |||
| 401 | esp_err_t ret = nostr_event_to_json(&event, event_json, 8192); | ||
| 402 | if (ret != ESP_OK) { | ||
| 403 | free(event_json); | ||
| 404 | return ret; | ||
| 405 | } | ||
| 406 | |||
| 407 | ret = publish_event_to_relay(relay_url, event_json); | ||
| 408 | free(event_json); | ||
| 409 | return ret; | ||
| 410 | } | ||
| 105 | 411 | ||
| 106 | ESP_LOGI(TAG, "Decrypted DM from %.8s: %s", sender_pubkey, plaintext); | 412 | static bool is_owner_pubkey(const char *pubkey_hex) |
| 413 | { | ||
| 414 | const tollgate_identity_t *id = identity_get(); | ||
| 415 | if (!id || !id->initialized) return false; | ||
| 416 | if (!pubkey_hex) return false; | ||
| 417 | return strcmp(id->npub_hex, pubkey_hex) == 0; | ||
| 418 | } | ||
| 107 | 419 | ||
| 108 | cJSON *msg = cJSON_Parse(plaintext); | 420 | static void handle_mcp_message(esp_tls_t *tls, const char *sender_pubkey, |
| 421 | const char *event_id, const char *content) | ||
| 422 | { | ||
| 423 | cJSON *msg = cJSON_Parse(content); | ||
| 109 | if (!msg) { | 424 | if (!msg) { |
| 110 | ESP_LOGE(TAG, "Invalid JSON in DM"); | 425 | ESP_LOGW(TAG, "Invalid JSON in kind 25910 content"); |
| 111 | return; | 426 | return; |
| 112 | } | 427 | } |
| 113 | 428 | ||
| 114 | cJSON *method = cJSON_GetObjectItem(msg, "method"); | 429 | cJSON *method = cJSON_GetObjectItem(msg, "method"); |
| 115 | cJSON *params = cJSON_GetObjectItem(msg, "params"); | 430 | cJSON *id_field = cJSON_GetObjectItem(msg, "id"); |
| 116 | if (!method || !cJSON_IsString(method)) { | 431 | const char *id_str = (id_field && cJSON_IsNumber(id_field)) |
| 117 | cJSON_Delete(msg); | 432 | ? cJSON_PrintUnformatted(id_field) : "0"; |
| 118 | ESP_LOGE(TAG, "Missing 'method' in CVM request"); | 433 | |
| 119 | return; | 434 | if (method && cJSON_IsString(method)) { |
| 435 | const char *m = method->valuestring; | ||
| 436 | |||
| 437 | if (strcmp(m, "initialize") == 0) { | ||
| 438 | ESP_LOGI(TAG, "MCP initialize from %s", sender_pubkey); | ||
| 439 | char *resp = build_initialize_response(id_str, sender_pubkey); | ||
| 440 | if (tls) { | ||
| 441 | publish_kind_25910_response_ws(tls, resp, event_id); | ||
| 442 | } else { | ||
| 443 | ESP_LOGW(TAG, "No TLS for response"); | ||
| 444 | } | ||
| 445 | free(resp); | ||
| 446 | } else if (strcmp(m, "notifications/initialized") == 0) { | ||
| 447 | ESP_LOGI(TAG, "Client initialized: %s", sender_pubkey); | ||
| 448 | } else if (strcmp(m, "tools/list") == 0) { | ||
| 449 | ESP_LOGI(TAG, "tools/list from %s", sender_pubkey); | ||
| 450 | char *resp = build_tools_list_response(id_str); | ||
| 451 | if (tls) { | ||
| 452 | publish_kind_25910_response_ws(tls, resp, event_id); | ||
| 453 | } | ||
| 454 | free(resp); | ||
| 455 | } else if (strcmp(m, "tools/call") == 0) { | ||
| 456 | cJSON *params = cJSON_GetObjectItem(msg, "params"); | ||
| 457 | cJSON *name = params ? cJSON_GetObjectItem(params, "name") : NULL; | ||
| 458 | cJSON *arguments = params ? cJSON_GetObjectItem(params, "arguments") : NULL; | ||
| 459 | |||
| 460 | if (name && cJSON_IsString(name)) { | ||
| 461 | ESP_LOGI(TAG, "tools/call %s from %s", name->valuestring, sender_pubkey); | ||
| 462 | |||
| 463 | mcp_request_t req = {0}; | ||
| 464 | req.tool = mcp_parse_tool(name->valuestring); | ||
| 465 | strncpy(req.method, name->valuestring, sizeof(req.method) - 1); | ||
| 466 | if (arguments) { | ||
| 467 | char *ajson = cJSON_PrintUnformatted(arguments); | ||
| 468 | strncpy(req.params_json, ajson, sizeof(req.params_json) - 1); | ||
| 469 | cJSON_free(ajson); | ||
| 470 | } | ||
| 471 | |||
| 472 | mcp_response_t mcp_resp = mcp_dispatch(&req); | ||
| 473 | char *resp = build_tool_call_response(id_str, &mcp_resp); | ||
| 474 | if (tls) { | ||
| 475 | publish_kind_25910_response_ws(tls, resp, event_id); | ||
| 476 | } | ||
| 477 | free(resp); | ||
| 478 | } | ||
| 479 | } else if (strcmp(m, "ping") == 0) { | ||
| 480 | char *resp = build_ping_response(id_str); | ||
| 481 | if (tls) { | ||
| 482 | publish_kind_25910_response_ws(tls, resp, event_id); | ||
| 483 | } | ||
| 484 | free(resp); | ||
| 485 | } else { | ||
| 486 | ESP_LOGW(TAG, "Unknown MCP method: %s", m); | ||
| 487 | } | ||
| 120 | } | 488 | } |
| 121 | 489 | ||
| 122 | mcp_request_t req = {0}; | 490 | if (id_field && cJSON_IsNumber(id_field) && id_str[0] != '0') { |
| 123 | req.tool = mcp_parse_tool(method->valuestring); | 491 | free((void *)id_str); |
| 124 | strncpy(req.method, method->valuestring, sizeof(req.method) - 1); | 492 | } else if (id_str[0] != '0') { |
| 125 | if (params && cJSON_IsString(params)) { | ||
| 126 | strncpy(req.params_json, params->valuestring, sizeof(req.params_json) - 1); | ||
| 127 | } else if (params) { | ||
| 128 | char *pjson = cJSON_PrintUnformatted(params); | ||
| 129 | strncpy(req.params_json, pjson, sizeof(req.params_json) - 1); | ||
| 130 | cJSON_free(pjson); | ||
| 131 | } | 493 | } |
| 132 | |||
| 133 | mcp_response_t resp = mcp_dispatch(&req); | ||
| 134 | cJSON_Delete(msg); | 494 | cJSON_Delete(msg); |
| 135 | |||
| 136 | cJSON *response_msg = cJSON_CreateObject(); | ||
| 137 | if (resp.success) { | ||
| 138 | cJSON_AddStringToObject(response_msg, "status", "ok"); | ||
| 139 | cJSON_AddItemToObject(response_msg, "result", cJSON_Parse(resp.result_json)); | ||
| 140 | } else { | ||
| 141 | cJSON_AddStringToObject(response_msg, "status", "error"); | ||
| 142 | cJSON_AddStringToObject(response_msg, "error", resp.error); | ||
| 143 | } | ||
| 144 | |||
| 145 | char *response_str = cJSON_PrintUnformatted(response_msg); | ||
| 146 | cJSON_Delete(response_msg); | ||
| 147 | |||
| 148 | uint8_t response_ct[4096]; | ||
| 149 | size_t ct_len = 0; | ||
| 150 | nip04_encrypt(id->nsec, sender_pk, response_str, response_ct, &ct_len); | ||
| 151 | free(response_str); | ||
| 152 | |||
| 153 | ESP_LOGI(TAG, "CVM response prepared (%zu bytes encrypted), would send to %.8s", ct_len, sender_pubkey); | ||
| 154 | } | 495 | } |
| 155 | 496 | ||
| 156 | static void parse_nostr_events(const char *data) | 497 | static void process_relay_message(esp_tls_t *tls, const char *relay_url, const char *msg_str) |
| 157 | { | 498 | { |
| 158 | cJSON *arr = cJSON_Parse(data); | 499 | cJSON *arr = cJSON_Parse(msg_str); |
| 159 | if (!arr || !cJSON_IsArray(arr)) { | 500 | if (!arr || !cJSON_IsArray(arr)) { |
| 160 | if (arr) cJSON_Delete(arr); | 501 | if (arr) cJSON_Delete(arr); |
| 161 | return; | 502 | return; |
| 162 | } | 503 | } |
| 163 | 504 | ||
| 164 | cJSON *item = NULL; | 505 | cJSON *cmd = cJSON_GetArrayItem(arr, 0); |
| 165 | cJSON_ArrayForEach(item, arr) { | 506 | if (!cmd || !cJSON_IsString(cmd)) { |
| 166 | if (!cJSON_IsArray(item)) continue; | 507 | cJSON_Delete(arr); |
| 167 | int arr_size = cJSON_GetArraySize(item); | 508 | return; |
| 168 | if (arr_size < 3) continue; | 509 | } |
| 169 | 510 | ||
| 170 | cJSON *cmd = cJSON_GetArrayItem(item, 0); | 511 | if (strcmp(cmd->valuestring, "OK") == 0) { |
| 171 | if (!cmd || !cJSON_IsString(cmd) || strcmp(cmd->valuestring, "EVENT") != 0) continue; | 512 | cJSON *ev_id = cJSON_GetArrayItem(arr, 1); |
| 513 | cJSON *ok = cJSON_GetArrayItem(arr, 2); | ||
| 514 | cJSON *reason = cJSON_GetArrayItem(arr, 3); | ||
| 515 | ESP_LOGI(TAG, "Relay OK: id=%.16s success=%s reason=%s", | ||
| 516 | ev_id ? ev_id->valuestring : "?", | ||
| 517 | ok ? (cJSON_IsTrue(ok) ? "true" : "FALSE") : "?", | ||
| 518 | reason ? reason->valuestring : ""); | ||
| 519 | cJSON_Delete(arr); | ||
| 520 | return; | ||
| 521 | } | ||
| 172 | 522 | ||
| 173 | cJSON *event = cJSON_GetArrayItem(item, 2); | 523 | if (strcmp(cmd->valuestring, "EVENT") != 0) { |
| 174 | if (!event) continue; | 524 | ESP_LOGI(TAG, "Relay msg: %.100s", msg_str); |
| 525 | cJSON_Delete(arr); | ||
| 526 | return; | ||
| 527 | } | ||
| 175 | 528 | ||
| 176 | cJSON *kind = cJSON_GetObjectItem(event, "kind"); | 529 | cJSON *event = cJSON_GetArrayItem(arr, 2); |
| 177 | if (!kind || kind->valueint != 4) continue; | 530 | if (!event) { |
| 531 | cJSON_Delete(arr); | ||
| 532 | return; | ||
| 533 | } | ||
| 178 | 534 | ||
| 179 | cJSON *pubkey = cJSON_GetObjectItem(event, "pubkey"); | 535 | cJSON *kind = cJSON_GetObjectItem(event, "kind"); |
| 180 | cJSON *content = cJSON_GetObjectItem(event, "content"); | 536 | if (!kind || kind->valueint != 25910) { |
| 181 | if (pubkey && content) { | 537 | cJSON_Delete(arr); |
| 182 | process_dm(pubkey->valuestring, content->valuestring); | 538 | return; |
| 183 | } | 539 | } |
| 540 | |||
| 541 | cJSON *pubkey = cJSON_GetObjectItem(event, "pubkey"); | ||
| 542 | cJSON *event_id = cJSON_GetObjectItem(event, "id"); | ||
| 543 | cJSON *content = cJSON_GetObjectItem(event, "content"); | ||
| 544 | |||
| 545 | if (!pubkey || !content || !event_id) { | ||
| 546 | cJSON_Delete(arr); | ||
| 547 | return; | ||
| 184 | } | 548 | } |
| 549 | |||
| 550 | if (!is_owner_pubkey(pubkey->valuestring)) { | ||
| 551 | ESP_LOGW(TAG, "Ignoring request from non-owner: %.16s...", pubkey->valuestring); | ||
| 552 | cJSON_Delete(arr); | ||
| 553 | return; | ||
| 554 | } | ||
| 555 | |||
| 556 | handle_mcp_message(tls, pubkey->valuestring, event_id->valuestring, content->valuestring); | ||
| 185 | cJSON_Delete(arr); | 557 | cJSON_Delete(arr); |
| 186 | } | 558 | } |
| 187 | 559 | ||
| 188 | static void cvm_task(void *arg) | 560 | static esp_err_t subscribe_to_relay(esp_tls_t *tls, const char *npub) |
| 561 | { | ||
| 562 | cJSON *sub = cJSON_CreateArray(); | ||
| 563 | cJSON_AddItemToArray(sub, cJSON_CreateString("REQ")); | ||
| 564 | cJSON_AddItemToArray(sub, cJSON_CreateString("cvm_sub")); | ||
| 565 | cJSON *filter = cJSON_CreateObject(); | ||
| 566 | cJSON *kinds = cJSON_CreateArray(); | ||
| 567 | cJSON_AddItemToArray(kinds, cJSON_CreateNumber(25910)); | ||
| 568 | cJSON_AddItemToObject(filter, "kinds", kinds); | ||
| 569 | cJSON *p_tags = cJSON_CreateArray(); | ||
| 570 | cJSON_AddItemToArray(p_tags, cJSON_CreateString(npub)); | ||
| 571 | cJSON_AddItemToObject(filter, "#p", p_tags); | ||
| 572 | cJSON_AddNumberToObject(filter, "limit", 100); | ||
| 573 | cJSON_AddItemToArray(sub, filter); | ||
| 574 | |||
| 575 | char *msg = cJSON_PrintUnformatted(sub); | ||
| 576 | cJSON_Delete(sub); | ||
| 577 | |||
| 578 | int rc = ws_send_text(tls, msg); | ||
| 579 | free(msg); | ||
| 580 | return rc == 0 ? ESP_OK : ESP_FAIL; | ||
| 581 | } | ||
| 582 | |||
| 583 | static void cvm_relay_task(void *arg) | ||
| 189 | { | 584 | { |
| 585 | const char *relay_url = (const char *)arg; | ||
| 190 | const tollgate_identity_t *id = identity_get(); | 586 | const tollgate_identity_t *id = identity_get(); |
| 191 | if (!id || !id->initialized) { | 587 | if (!id || !id->initialized) { |
| 192 | ESP_LOGE(TAG, "Cannot start: identity not initialized"); | 588 | ESP_LOGE(TAG, "Identity not initialized"); |
| 193 | vTaskDelete(NULL); | 589 | vTaskDelete(NULL); |
| 194 | return; | 590 | return; |
| 195 | } | 591 | } |
| 196 | 592 | ||
| 197 | char *relays = fetch_relays(); | ||
| 198 | ESP_LOGI(TAG, "CVM server started, relays: %s", relays); | ||
| 199 | |||
| 200 | while (g_running) { | 593 | while (g_running) { |
| 201 | ESP_LOGI(TAG, "Polling for DMs..."); | 594 | esp_tls_t *tls = NULL; |
| 595 | esp_err_t err = ws_connect(relay_url, &tls); | ||
| 596 | if (err != ESP_OK) { | ||
| 597 | ESP_LOGW(TAG, "Connect failed to %s, retrying", relay_url); | ||
| 598 | vTaskDelay(pdMS_TO_TICKS(CVM_RECONNECT_DELAY_MS)); | ||
| 599 | continue; | ||
| 600 | } | ||
| 601 | |||
| 602 | err = subscribe_to_relay(tls, id->npub_hex); | ||
| 603 | if (err != ESP_OK) { | ||
| 604 | esp_tls_conn_destroy(tls); | ||
| 605 | vTaskDelay(pdMS_TO_TICKS(CVM_RECONNECT_DELAY_MS)); | ||
| 606 | continue; | ||
| 607 | } | ||
| 202 | 608 | ||
| 203 | cJSON *sub = build_subscription(id->npub_hex); | 609 | ESP_LOGI(TAG, "Listening on %s for kind 25910 events", relay_url); |
| 204 | char *sub_json = cJSON_PrintUnformatted(sub); | 610 | publish_announcements_via_ws(tls); |
| 205 | cJSON_Delete(sub); | ||
| 206 | 611 | ||
| 207 | char url[256]; | 612 | uint8_t *buf = malloc(CVM_WS_BUF_SIZE); |
| 208 | snprintf(url, sizeof(url), "%s/cvm_poll", relays); | 613 | if (!buf) { |
| 209 | free(sub_json); | 614 | esp_tls_conn_destroy(tls); |
| 615 | vTaskDelete(NULL); | ||
| 616 | return; | ||
| 617 | } | ||
| 210 | 618 | ||
| 211 | vTaskDelay(pdMS_TO_TICKS(30000)); | 619 | int64_t last_ping_time = 0; |
| 620 | |||
| 621 | while (g_running) { | ||
| 622 | int rlen = esp_tls_conn_read(tls, buf, CVM_WS_BUF_SIZE - 1); | ||
| 623 | if (rlen < 0) { | ||
| 624 | ESP_LOGW(TAG, "Read error on %s (rlen=%d)", relay_url, rlen); | ||
| 625 | break; | ||
| 626 | } | ||
| 627 | if (rlen == 0) { | ||
| 628 | break; | ||
| 629 | } | ||
| 630 | |||
| 631 | if ((buf[0] & 0x0F) == 0x01) { | ||
| 632 | char *text = parse_ws_text_frame(buf, rlen); | ||
| 633 | if (text) { | ||
| 634 | if (strlen(text) > 0) { | ||
| 635 | process_relay_message(tls, relay_url, text); | ||
| 636 | } | ||
| 637 | free(text); | ||
| 638 | } | ||
| 639 | } else if ((buf[0] & 0x0F) == 0x09) { | ||
| 640 | uint8_t pong[2] = {0x8A, 0x00}; | ||
| 641 | esp_tls_conn_write(tls, pong, 2); | ||
| 642 | } | ||
| 643 | |||
| 644 | int64_t now = (int64_t)esp_timer_get_time() / 1000000; | ||
| 645 | if (now - last_ping_time >= CVM_WS_PING_INTERVAL_S) { | ||
| 646 | uint8_t ping[2] = {0x89, 0x00}; | ||
| 647 | esp_tls_conn_write(tls, ping, 2); | ||
| 648 | last_ping_time = now; | ||
| 649 | } | ||
| 650 | } | ||
| 651 | |||
| 652 | free(buf); | ||
| 653 | uint8_t close_frame[2] = {0x88, 0x00}; | ||
| 654 | esp_tls_conn_write(tls, close_frame, 2); | ||
| 655 | esp_tls_conn_destroy(tls); | ||
| 656 | ESP_LOGW(TAG, "Disconnected from %s, reconnecting", relay_url); | ||
| 657 | vTaskDelay(pdMS_TO_TICKS(CVM_RECONNECT_DELAY_MS)); | ||
| 212 | } | 658 | } |
| 213 | 659 | ||
| 214 | ESP_LOGI(TAG, "CVM server stopped"); | ||
| 215 | vTaskDelete(NULL); | 660 | vTaskDelete(NULL); |
| 216 | } | 661 | } |
| 217 | 662 | ||
| 663 | static esp_err_t publish_event_via_ws(esp_tls_t *tls, int kind, | ||
| 664 | const char *content, const char *tags_json) | ||
| 665 | { | ||
| 666 | const tollgate_identity_t *id = identity_get(); | ||
| 667 | if (!id || !id->initialized) return ESP_FAIL; | ||
| 668 | |||
| 669 | nostr_event_t event; | ||
| 670 | nostr_event_init(&event, id->npub_hex, kind, tags_json, content); | ||
| 671 | nostr_event_sign(&event, id->nsec); | ||
| 672 | |||
| 673 | char *event_json = malloc(4096); | ||
| 674 | if (!event_json) return ESP_ERR_NO_MEM; | ||
| 675 | |||
| 676 | esp_err_t ret = nostr_event_to_json(&event, event_json, 4096); | ||
| 677 | if (ret != ESP_OK) { | ||
| 678 | free(event_json); | ||
| 679 | return ret; | ||
| 680 | } | ||
| 681 | |||
| 682 | char *msg; | ||
| 683 | size_t event_len = strlen(event_json); | ||
| 684 | size_t msg_len = 10 + event_len + 2; | ||
| 685 | msg = malloc(msg_len); | ||
| 686 | snprintf(msg, msg_len, "[\"EVENT\",%s]", event_json); | ||
| 687 | |||
| 688 | ws_send_text(tls, msg); | ||
| 689 | ESP_LOGI(TAG, "Published kind %d event (%d bytes)", kind, (int)strlen(event_json)); | ||
| 690 | free(msg); | ||
| 691 | free(event_json); | ||
| 692 | return ESP_OK; | ||
| 693 | } | ||
| 694 | |||
| 695 | static void publish_announcements_via_ws(esp_tls_t *tls) | ||
| 696 | { | ||
| 697 | const tollgate_identity_t *id = identity_get(); | ||
| 698 | if (!id || !id->initialized) return; | ||
| 699 | |||
| 700 | ESP_LOGI(TAG, "Publishing CEP-6 announcements via active WS"); | ||
| 701 | |||
| 702 | cJSON *ann_content = cJSON_CreateObject(); | ||
| 703 | cJSON_AddStringToObject(ann_content, "protocolVersion", CVM_VERSION); | ||
| 704 | cJSON *capabilities = cJSON_CreateObject(); | ||
| 705 | cJSON *tools_cap = cJSON_CreateObject(); | ||
| 706 | cJSON_AddBoolToObject(tools_cap, "listChanged", true); | ||
| 707 | cJSON_AddItemToObject(capabilities, "tools", tools_cap); | ||
| 708 | cJSON_AddItemToObject(ann_content, "capabilities", capabilities); | ||
| 709 | cJSON *serverInfo = cJSON_CreateObject(); | ||
| 710 | cJSON_AddStringToObject(serverInfo, "name", CVM_SERVER_NAME); | ||
| 711 | cJSON_AddStringToObject(serverInfo, "version", CVM_SERVER_VERSION); | ||
| 712 | cJSON_AddItemToObject(ann_content, "serverInfo", serverInfo); | ||
| 713 | char *ann_str = cJSON_PrintUnformatted(ann_content); | ||
| 714 | cJSON_Delete(ann_content); | ||
| 715 | |||
| 716 | cJSON *ann_tags = cJSON_CreateArray(); | ||
| 717 | cJSON *name_tag = cJSON_CreateArray(); | ||
| 718 | cJSON_AddItemToArray(name_tag, cJSON_CreateString("name")); | ||
| 719 | cJSON_AddItemToArray(name_tag, cJSON_CreateString(CVM_SERVER_NAME)); | ||
| 720 | cJSON_AddItemToArray(ann_tags, name_tag); | ||
| 721 | cJSON *about_tag = cJSON_CreateArray(); | ||
| 722 | cJSON_AddItemToArray(about_tag, cJSON_CreateString("about")); | ||
| 723 | cJSON_AddItemToArray(about_tag, cJSON_CreateString("ESP32 TollGate WiFi hotspot with Cashu e-cash payments")); | ||
| 724 | cJSON_AddItemToArray(ann_tags, about_tag); | ||
| 725 | char *ann_tags_str = cJSON_PrintUnformatted(ann_tags); | ||
| 726 | cJSON_Delete(ann_tags); | ||
| 727 | |||
| 728 | publish_event_via_ws(tls, 11316, ann_str, ann_tags_str); | ||
| 729 | free(ann_str); | ||
| 730 | free(ann_tags_str); | ||
| 731 | |||
| 732 | cJSON *tools = build_tools_list(); | ||
| 733 | cJSON *tools_content = cJSON_CreateObject(); | ||
| 734 | cJSON_AddItemToObject(tools_content, "tools", tools); | ||
| 735 | char *tools_str = cJSON_PrintUnformatted(tools_content); | ||
| 736 | cJSON_Delete(tools_content); | ||
| 737 | |||
| 738 | publish_event_via_ws(tls, 11317, tools_str, "[]"); | ||
| 739 | free(tools_str); | ||
| 740 | |||
| 741 | cJSON *relay_tags = cJSON_CreateArray(); | ||
| 742 | const char *relays[] = {"wss://relay.primal.net", "wss://nostr-pub.wellorder.net", NULL}; | ||
| 743 | for (int i = 0; relays[i]; i++) { | ||
| 744 | cJSON *r_tag = cJSON_CreateArray(); | ||
| 745 | cJSON_AddItemToArray(r_tag, cJSON_CreateString("r")); | ||
| 746 | cJSON_AddItemToArray(r_tag, cJSON_CreateString(relays[i])); | ||
| 747 | cJSON_AddItemToArray(relay_tags, r_tag); | ||
| 748 | } | ||
| 749 | char *relay_tags_str = cJSON_PrintUnformatted(relay_tags); | ||
| 750 | cJSON_Delete(relay_tags); | ||
| 751 | |||
| 752 | publish_event_via_ws(tls, 10002, "", relay_tags_str); | ||
| 753 | free(relay_tags_str); | ||
| 754 | |||
| 755 | ESP_LOGI(TAG, "CEP-6 announcements published (kinds 11316, 11317, 10002)"); | ||
| 756 | } | ||
| 757 | |||
| 758 | esp_err_t cvm_publish_announcements(void) | ||
| 759 | { | ||
| 760 | return ESP_OK; | ||
| 761 | } | ||
| 762 | |||
| 763 | const char *cvm_get_pubkey_hex(void) | ||
| 764 | { | ||
| 765 | const tollgate_identity_t *id = identity_get(); | ||
| 766 | if (!id || !id->initialized) return NULL; | ||
| 767 | return id->npub_hex; | ||
| 768 | } | ||
| 769 | |||
| 218 | esp_err_t cvm_server_init(void) | 770 | esp_err_t cvm_server_init(void) |
| 219 | { | 771 | { |
| 220 | ESP_LOGI(TAG, "CVM server initialized"); | 772 | ESP_LOGI(TAG, "CVM server initialized"); |
| @@ -225,7 +777,12 @@ void cvm_server_start(void) | |||
| 225 | { | 777 | { |
| 226 | if (g_running) return; | 778 | if (g_running) return; |
| 227 | g_running = true; | 779 | g_running = true; |
| 228 | xTaskCreate(cvm_task, "cvm_server", 8192, NULL, 5, &g_task); | 780 | |
| 781 | const tollgate_config_t *cfg = tollgate_config_get(); | ||
| 782 | const char *relay = (cfg->cvm_relays[0]) ? cfg->cvm_relays : "wss://relay.primal.net"; | ||
| 783 | |||
| 784 | char *relay_copy = strdup(relay); | ||
| 785 | xTaskCreate(cvm_relay_task, "cvm_relay", 16384, relay_copy, 5, &g_task); | ||
| 229 | } | 786 | } |
| 230 | 787 | ||
| 231 | void cvm_server_stop(void) | 788 | void cvm_server_stop(void) |
diff --git a/main/cvm_server.h b/main/cvm_server.h index d336514..864973b 100644 --- a/main/cvm_server.h +++ b/main/cvm_server.h | |||
| @@ -7,4 +7,8 @@ esp_err_t cvm_server_init(void); | |||
| 7 | void cvm_server_start(void); | 7 | void cvm_server_start(void); |
| 8 | void cvm_server_stop(void); | 8 | void cvm_server_stop(void); |
| 9 | 9 | ||
| 10 | esp_err_t cvm_publish_announcements(void); | ||
| 11 | |||
| 12 | const char *cvm_get_pubkey_hex(void); | ||
| 13 | |||
| 10 | #endif | 14 | #endif |
diff --git a/main/mcp_handler.c b/main/mcp_handler.c index f40c1bd..93bfba9 100644 --- a/main/mcp_handler.c +++ b/main/mcp_handler.c | |||
| @@ -1,7 +1,9 @@ | |||
| 1 | #include "mcp_handler.h" | 1 | #include "mcp_handler.h" |
| 2 | #include "config.h" | 2 | #include "config.h" |
| 3 | #include "nucula_wallet.h" | 3 | #include "nucula_wallet.h" |
| 4 | #include "session.h" | ||
| 4 | #include "cJSON.h" | 5 | #include "cJSON.h" |
| 6 | #include "lwip/ip4_addr.h" | ||
| 5 | #include <string.h> | 7 | #include <string.h> |
| 6 | #include <stdio.h> | 8 | #include <stdio.h> |
| 7 | 9 | ||
| @@ -14,6 +16,12 @@ mcp_tool_t mcp_parse_tool(const char *method) | |||
| 14 | if (strcmp(method, "set_config") == 0) return MCP_TOOL_SET_CONFIG; | 16 | if (strcmp(method, "set_config") == 0) return MCP_TOOL_SET_CONFIG; |
| 15 | if (strcmp(method, "get_balance") == 0) return MCP_TOOL_GET_BALANCE; | 17 | if (strcmp(method, "get_balance") == 0) return MCP_TOOL_GET_BALANCE; |
| 16 | if (strcmp(method, "wallet_send") == 0) return MCP_TOOL_WALLET_SEND; | 18 | if (strcmp(method, "wallet_send") == 0) return MCP_TOOL_WALLET_SEND; |
| 19 | if (strcmp(method, "get_sessions") == 0) return MCP_TOOL_GET_SESSIONS; | ||
| 20 | if (strcmp(method, "get_usage") == 0) return MCP_TOOL_GET_USAGE; | ||
| 21 | if (strcmp(method, "set_payout") == 0) return MCP_TOOL_SET_PAYOUT; | ||
| 22 | if (strcmp(method, "set_metric") == 0) return MCP_TOOL_SET_METRIC; | ||
| 23 | if (strcmp(method, "set_price") == 0) return MCP_TOOL_SET_PRICE; | ||
| 24 | if (strcmp(method, "wallet_melt") == 0) return MCP_TOOL_WALLET_MELT; | ||
| 17 | return MCP_TOOL_UNKNOWN; | 25 | return MCP_TOOL_UNKNOWN; |
| 18 | } | 26 | } |
| 19 | 27 | ||
| @@ -146,6 +154,222 @@ mcp_response_t mcp_handle_wallet_send(const char *params_json) | |||
| 146 | return resp; | 154 | return resp; |
| 147 | } | 155 | } |
| 148 | 156 | ||
| 157 | mcp_response_t mcp_handle_get_sessions(void) | ||
| 158 | { | ||
| 159 | mcp_response_t resp = {0}; | ||
| 160 | extern session_t *cvm_get_sessions_array(void); | ||
| 161 | extern int cvm_get_sessions_count(void); | ||
| 162 | |||
| 163 | cJSON *arr = cJSON_CreateArray(); | ||
| 164 | int count = cvm_get_sessions_count(); | ||
| 165 | session_t *sessions = cvm_get_sessions_array(); | ||
| 166 | |||
| 167 | if (sessions && count > 0) { | ||
| 168 | for (int i = 0; i < count; i++) { | ||
| 169 | if (!sessions[i].active) continue; | ||
| 170 | cJSON *s = cJSON_CreateObject(); | ||
| 171 | esp_ip4_addr_t ip = { .addr = sessions[i].client_ip }; | ||
| 172 | char ip_str[16]; | ||
| 173 | snprintf(ip_str, sizeof(ip_str), IPSTR, IP2STR(&ip)); | ||
| 174 | cJSON_AddStringToObject(s, "client_ip", ip_str); | ||
| 175 | if (sessions[i].mac[0]) | ||
| 176 | cJSON_AddStringToObject(s, "mac", sessions[i].mac); | ||
| 177 | cJSON_AddNumberToObject(s, "allotment_ms", (double)sessions[i].allotment_ms); | ||
| 178 | cJSON_AddNumberToObject(s, "allotment_bytes", (double)sessions[i].allotment_bytes); | ||
| 179 | cJSON_AddNumberToObject(s, "bytes_consumed", (double)sessions[i].bytes_consumed); | ||
| 180 | cJSON_AddBoolToObject(s, "active", sessions[i].active); | ||
| 181 | cJSON_AddItemToArray(arr, s); | ||
| 182 | } | ||
| 183 | } | ||
| 184 | |||
| 185 | char *json = cJSON_PrintUnformatted(arr); | ||
| 186 | snprintf(resp.result_json, sizeof(resp.result_json), "%s", json); | ||
| 187 | cJSON_free(json); | ||
| 188 | cJSON_Delete(arr); | ||
| 189 | resp.success = true; | ||
| 190 | return resp; | ||
| 191 | } | ||
| 192 | |||
| 193 | mcp_response_t mcp_handle_get_usage(void) | ||
| 194 | { | ||
| 195 | mcp_response_t resp = {0}; | ||
| 196 | const tollgate_config_t *cfg = tollgate_config_get(); | ||
| 197 | |||
| 198 | cJSON *root = cJSON_CreateObject(); | ||
| 199 | cJSON_AddStringToObject(root, "metric", cfg->metric); | ||
| 200 | cJSON_AddNumberToObject(root, "price_per_step", cfg->price_per_step); | ||
| 201 | cJSON_AddNumberToObject(root, "step_size_ms", cfg->step_size_ms); | ||
| 202 | cJSON_AddNumberToObject(root, "step_size_bytes", cfg->step_size_bytes); | ||
| 203 | cJSON_AddBoolToObject(root, "client_enabled", cfg->client_enabled); | ||
| 204 | |||
| 205 | char *json = cJSON_PrintUnformatted(root); | ||
| 206 | snprintf(resp.result_json, sizeof(resp.result_json), "%s", json); | ||
| 207 | cJSON_free(json); | ||
| 208 | cJSON_Delete(root); | ||
| 209 | resp.success = true; | ||
| 210 | return resp; | ||
| 211 | } | ||
| 212 | |||
| 213 | mcp_response_t mcp_handle_set_payout(const char *params_json) | ||
| 214 | { | ||
| 215 | mcp_response_t resp = {0}; | ||
| 216 | cJSON *root = cJSON_Parse(params_json); | ||
| 217 | if (!root) { | ||
| 218 | resp.success = false; | ||
| 219 | snprintf(resp.error, sizeof(resp.error), "Invalid JSON params"); | ||
| 220 | return resp; | ||
| 221 | } | ||
| 222 | |||
| 223 | tollgate_config_t *cfg = (tollgate_config_t *)tollgate_config_get(); | ||
| 224 | if (!cfg) { | ||
| 225 | cJSON_Delete(root); | ||
| 226 | resp.success = false; | ||
| 227 | snprintf(resp.error, sizeof(resp.error), "Config not loaded"); | ||
| 228 | return resp; | ||
| 229 | } | ||
| 230 | |||
| 231 | cJSON *enabled = cJSON_GetObjectItem(root, "enabled"); | ||
| 232 | if (enabled && cJSON_IsBool(enabled)) cfg->payout.enabled = cJSON_IsTrue(enabled); | ||
| 233 | |||
| 234 | cJSON *recipients = cJSON_GetObjectItem(root, "recipients"); | ||
| 235 | if (recipients && cJSON_IsArray(recipients)) { | ||
| 236 | int rcount = cJSON_GetArraySize(recipients); | ||
| 237 | if (rcount > PAYOUT_MAX_RECIPIENTS) rcount = PAYOUT_MAX_RECIPIENTS; | ||
| 238 | for (int i = 0; i < rcount; i++) { | ||
| 239 | cJSON *r = cJSON_GetArrayItem(recipients, i); | ||
| 240 | cJSON *addr = cJSON_GetObjectItem(r, "lightning_address"); | ||
| 241 | cJSON *factor = cJSON_GetObjectItem(r, "factor"); | ||
| 242 | if (addr && cJSON_IsString(addr)) { | ||
| 243 | strncpy(cfg->payout.recipients[i].lightning_address, addr->valuestring, | ||
| 244 | sizeof(cfg->payout.recipients[i].lightning_address) - 1); | ||
| 245 | } | ||
| 246 | if (factor && cJSON_IsNumber(factor)) { | ||
| 247 | cfg->payout.recipients[i].factor = factor->valuedouble; | ||
| 248 | } | ||
| 249 | } | ||
| 250 | cfg->payout.recipient_count = rcount; | ||
| 251 | } | ||
| 252 | |||
| 253 | cJSON_Delete(root); | ||
| 254 | resp.success = true; | ||
| 255 | snprintf(resp.result_json, sizeof(resp.result_json), "{\"status\":\"ok\"}"); | ||
| 256 | return resp; | ||
| 257 | } | ||
| 258 | |||
| 259 | mcp_response_t mcp_handle_set_metric(const char *params_json) | ||
| 260 | { | ||
| 261 | mcp_response_t resp = {0}; | ||
| 262 | cJSON *root = cJSON_Parse(params_json); | ||
| 263 | if (!root) { | ||
| 264 | resp.success = false; | ||
| 265 | snprintf(resp.error, sizeof(resp.error), "Invalid JSON params"); | ||
| 266 | return resp; | ||
| 267 | } | ||
| 268 | |||
| 269 | tollgate_config_t *cfg = (tollgate_config_t *)tollgate_config_get(); | ||
| 270 | if (!cfg) { | ||
| 271 | cJSON_Delete(root); | ||
| 272 | resp.success = false; | ||
| 273 | snprintf(resp.error, sizeof(resp.error), "Config not loaded"); | ||
| 274 | return resp; | ||
| 275 | } | ||
| 276 | |||
| 277 | cJSON *metric = cJSON_GetObjectItem(root, "metric"); | ||
| 278 | if (metric && cJSON_IsString(metric)) { | ||
| 279 | const char *m = metric->valuestring; | ||
| 280 | if (strcmp(m, "bytes") == 0 || strcmp(m, "milliseconds") == 0) { | ||
| 281 | strncpy(cfg->metric, m, sizeof(cfg->metric) - 1); | ||
| 282 | } else { | ||
| 283 | cJSON_Delete(root); | ||
| 284 | resp.success = false; | ||
| 285 | snprintf(resp.error, sizeof(resp.error), "Invalid metric: must be 'bytes' or 'milliseconds'"); | ||
| 286 | return resp; | ||
| 287 | } | ||
| 288 | } else { | ||
| 289 | cJSON_Delete(root); | ||
| 290 | resp.success = false; | ||
| 291 | snprintf(resp.error, sizeof(resp.error), "Missing 'metric' field"); | ||
| 292 | return resp; | ||
| 293 | } | ||
| 294 | |||
| 295 | cJSON_Delete(root); | ||
| 296 | resp.success = true; | ||
| 297 | snprintf(resp.result_json, sizeof(resp.result_json), "{\"status\":\"ok\",\"metric\":\"%s\"}", cfg->metric); | ||
| 298 | return resp; | ||
| 299 | } | ||
| 300 | |||
| 301 | mcp_response_t mcp_handle_set_price(const char *params_json) | ||
| 302 | { | ||
| 303 | mcp_response_t resp = {0}; | ||
| 304 | cJSON *root = cJSON_Parse(params_json); | ||
| 305 | if (!root) { | ||
| 306 | resp.success = false; | ||
| 307 | snprintf(resp.error, sizeof(resp.error), "Invalid JSON params"); | ||
| 308 | return resp; | ||
| 309 | } | ||
| 310 | |||
| 311 | tollgate_config_t *cfg = (tollgate_config_t *)tollgate_config_get(); | ||
| 312 | if (!cfg) { | ||
| 313 | cJSON_Delete(root); | ||
| 314 | resp.success = false; | ||
| 315 | snprintf(resp.error, sizeof(resp.error), "Config not loaded"); | ||
| 316 | return resp; | ||
| 317 | } | ||
| 318 | |||
| 319 | cJSON *price = cJSON_GetObjectItem(root, "price_per_step"); | ||
| 320 | if (price && cJSON_IsNumber(price) && price->valueint > 0) { | ||
| 321 | cfg->price_per_step = price->valueint; | ||
| 322 | } else { | ||
| 323 | cJSON_Delete(root); | ||
| 324 | resp.success = false; | ||
| 325 | snprintf(resp.error, sizeof(resp.error), "Missing or invalid 'price_per_step' field"); | ||
| 326 | return resp; | ||
| 327 | } | ||
| 328 | |||
| 329 | cJSON_Delete(root); | ||
| 330 | resp.success = true; | ||
| 331 | snprintf(resp.result_json, sizeof(resp.result_json), | ||
| 332 | "{\"status\":\"ok\",\"price_per_step\":%d}", cfg->price_per_step); | ||
| 333 | return resp; | ||
| 334 | } | ||
| 335 | |||
| 336 | mcp_response_t mcp_handle_wallet_melt(const char *params_json) | ||
| 337 | { | ||
| 338 | mcp_response_t resp = {0}; | ||
| 339 | cJSON *root = cJSON_Parse(params_json); | ||
| 340 | if (!root) { | ||
| 341 | resp.success = false; | ||
| 342 | snprintf(resp.error, sizeof(resp.error), "Invalid JSON params"); | ||
| 343 | return resp; | ||
| 344 | } | ||
| 345 | |||
| 346 | cJSON *bolt11 = cJSON_GetObjectItem(root, "bolt11"); | ||
| 347 | if (!bolt11 || !cJSON_IsString(bolt11)) { | ||
| 348 | cJSON_Delete(root); | ||
| 349 | resp.success = false; | ||
| 350 | snprintf(resp.error, sizeof(resp.error), "Missing 'bolt11' field"); | ||
| 351 | return resp; | ||
| 352 | } | ||
| 353 | |||
| 354 | cJSON *max_fee = cJSON_GetObjectItem(root, "max_fee_sats"); | ||
| 355 | uint64_t fee = 10; | ||
| 356 | if (max_fee && cJSON_IsNumber(max_fee)) fee = (uint64_t)max_fee->valuedouble; | ||
| 357 | |||
| 358 | esp_err_t rc = nucula_wallet_melt(bolt11->valuestring, fee); | ||
| 359 | |||
| 360 | if (rc != ESP_OK) { | ||
| 361 | cJSON_Delete(root); | ||
| 362 | resp.success = false; | ||
| 363 | snprintf(resp.error, sizeof(resp.error), "Melt failed: %s", esp_err_to_name(rc)); | ||
| 364 | return resp; | ||
| 365 | } | ||
| 366 | |||
| 367 | cJSON_Delete(root); | ||
| 368 | resp.success = true; | ||
| 369 | snprintf(resp.result_json, sizeof(resp.result_json), "{\"status\":\"ok\"}"); | ||
| 370 | return resp; | ||
| 371 | } | ||
| 372 | |||
| 149 | mcp_response_t mcp_dispatch(const mcp_request_t *req) | 373 | mcp_response_t mcp_dispatch(const mcp_request_t *req) |
| 150 | { | 374 | { |
| 151 | if (!req) { | 375 | if (!req) { |
| @@ -164,6 +388,18 @@ mcp_response_t mcp_dispatch(const mcp_request_t *req) | |||
| 164 | return mcp_handle_get_balance(); | 388 | return mcp_handle_get_balance(); |
| 165 | case MCP_TOOL_WALLET_SEND: | 389 | case MCP_TOOL_WALLET_SEND: |
| 166 | return mcp_handle_wallet_send(req->params_json); | 390 | return mcp_handle_wallet_send(req->params_json); |
| 391 | case MCP_TOOL_GET_SESSIONS: | ||
| 392 | return mcp_handle_get_sessions(); | ||
| 393 | case MCP_TOOL_GET_USAGE: | ||
| 394 | return mcp_handle_get_usage(); | ||
| 395 | case MCP_TOOL_SET_PAYOUT: | ||
| 396 | return mcp_handle_set_payout(req->params_json); | ||
| 397 | case MCP_TOOL_SET_METRIC: | ||
| 398 | return mcp_handle_set_metric(req->params_json); | ||
| 399 | case MCP_TOOL_SET_PRICE: | ||
| 400 | return mcp_handle_set_price(req->params_json); | ||
| 401 | case MCP_TOOL_WALLET_MELT: | ||
| 402 | return mcp_handle_wallet_melt(req->params_json); | ||
| 167 | default: | 403 | default: |
| 168 | break; | 404 | break; |
| 169 | } | 405 | } |
diff --git a/main/mcp_handler.h b/main/mcp_handler.h index e42b5ee..09aab9f 100644 --- a/main/mcp_handler.h +++ b/main/mcp_handler.h | |||
| @@ -9,6 +9,12 @@ typedef enum { | |||
| 9 | MCP_TOOL_SET_CONFIG = 1, | 9 | MCP_TOOL_SET_CONFIG = 1, |
| 10 | MCP_TOOL_GET_BALANCE = 2, | 10 | MCP_TOOL_GET_BALANCE = 2, |
| 11 | MCP_TOOL_WALLET_SEND = 3, | 11 | MCP_TOOL_WALLET_SEND = 3, |
| 12 | MCP_TOOL_GET_SESSIONS = 4, | ||
| 13 | MCP_TOOL_GET_USAGE = 5, | ||
| 14 | MCP_TOOL_SET_PAYOUT = 6, | ||
| 15 | MCP_TOOL_SET_METRIC = 7, | ||
| 16 | MCP_TOOL_SET_PRICE = 8, | ||
| 17 | MCP_TOOL_WALLET_MELT = 9, | ||
| 12 | MCP_TOOL_UNKNOWN = 99 | 18 | MCP_TOOL_UNKNOWN = 99 |
| 13 | } mcp_tool_t; | 19 | } mcp_tool_t; |
| 14 | 20 | ||
| @@ -30,6 +36,12 @@ mcp_response_t mcp_handle_get_config(void); | |||
| 30 | mcp_response_t mcp_handle_set_config(const char *params_json); | 36 | mcp_response_t mcp_handle_set_config(const char *params_json); |
| 31 | mcp_response_t mcp_handle_get_balance(void); | 37 | mcp_response_t mcp_handle_get_balance(void); |
| 32 | mcp_response_t mcp_handle_wallet_send(const char *params_json); | 38 | mcp_response_t mcp_handle_wallet_send(const char *params_json); |
| 39 | mcp_response_t mcp_handle_get_sessions(void); | ||
| 40 | mcp_response_t mcp_handle_get_usage(void); | ||
| 41 | mcp_response_t mcp_handle_set_payout(const char *params_json); | ||
| 42 | mcp_response_t mcp_handle_set_metric(const char *params_json); | ||
| 43 | mcp_response_t mcp_handle_set_price(const char *params_json); | ||
| 44 | mcp_response_t mcp_handle_wallet_melt(const char *params_json); | ||
| 33 | 45 | ||
| 34 | mcp_response_t mcp_dispatch(const mcp_request_t *req); | 46 | mcp_response_t mcp_dispatch(const mcp_request_t *req); |
| 35 | 47 | ||
diff --git a/main/session.c b/main/session.c index 9b4380c..81e1f96 100644 --- a/main/session.c +++ b/main/session.c | |||
| @@ -178,3 +178,13 @@ void session_tick(void) | |||
| 178 | { | 178 | { |
| 179 | session_check_expiry(); | 179 | session_check_expiry(); |
| 180 | } | 180 | } |
| 181 | |||
| 182 | session_t *cvm_get_sessions_array(void) | ||
| 183 | { | ||
| 184 | return s_sessions; | ||
| 185 | } | ||
| 186 | |||
| 187 | int cvm_get_sessions_count(void) | ||
| 188 | { | ||
| 189 | return SESSION_MAX_CLIENTS; | ||
| 190 | } | ||
diff --git a/main/session.h b/main/session.h index ea5b476..36fe722 100644 --- a/main/session.h +++ b/main/session.h | |||
| @@ -43,4 +43,7 @@ int session_active_count(void); | |||
| 43 | 43 | ||
| 44 | void session_tick(void); | 44 | void session_tick(void); |
| 45 | 45 | ||
| 46 | session_t *cvm_get_sessions_array(void); | ||
| 47 | int cvm_get_sessions_count(void); | ||
| 48 | |||
| 46 | #endif | 49 | #endif |
diff --git a/main/tollgate_main.c b/main/tollgate_main.c index 1350d70..ad5211a 100644 --- a/main/tollgate_main.c +++ b/main/tollgate_main.c | |||
| @@ -9,6 +9,7 @@ | |||
| 9 | #include "esp_netif.h" | 9 | #include "esp_netif.h" |
| 10 | #include "lwip/netif.h" | 10 | #include "lwip/netif.h" |
| 11 | #include "lwip/dns.h" | 11 | #include "lwip/dns.h" |
| 12 | #include "esp_sntp.h" | ||
| 12 | #include "dhcpserver/dhcpserver.h" | 13 | #include "dhcpserver/dhcpserver.h" |
| 13 | #include "config.h" | 14 | #include "config.h" |
| 14 | #include "identity.h" | 15 | #include "identity.h" |
| @@ -22,6 +23,7 @@ | |||
| 22 | #include "tollgate_client.h" | 23 | #include "tollgate_client.h" |
| 23 | #include "lightning_payout.h" | 24 | #include "lightning_payout.h" |
| 24 | #include "cvm_server.h" | 25 | #include "cvm_server.h" |
| 26 | #include "display.h" | ||
| 25 | 27 | ||
| 26 | #define MAX_STA_RETRY 5 | 28 | #define MAX_STA_RETRY 5 |
| 27 | static const char *TAG = "tollgate_main"; | 29 | static const char *TAG = "tollgate_main"; |
| @@ -54,6 +56,7 @@ static void wifi_event_handler(void *arg, esp_event_base_t event_base, | |||
| 54 | tollgate_client_on_sta_disconnected(); | 56 | tollgate_client_on_sta_disconnected(); |
| 55 | if (s_services_running) stop_services(); | 57 | if (s_services_running) stop_services(); |
| 56 | if (s_retry_count < MAX_STA_RETRY) { | 58 | if (s_retry_count < MAX_STA_RETRY) { |
| 59 | vTaskDelay(pdMS_TO_TICKS(2000)); | ||
| 57 | esp_wifi_connect(); | 60 | esp_wifi_connect(); |
| 58 | } else { | 61 | } else { |
| 59 | wifi_config_t wifi_cfg; | 62 | wifi_config_t wifi_cfg; |
| @@ -94,6 +97,13 @@ static void ip_event_handler(void *arg, esp_event_base_t event_base, | |||
| 94 | s_retry_count = 0; | 97 | s_retry_count = 0; |
| 95 | xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT); | 98 | xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT); |
| 96 | 99 | ||
| 100 | esp_sntp_stop(); | ||
| 101 | esp_sntp_setoperatingmode(SNTP_OPMODE_POLL); | ||
| 102 | esp_sntp_setservername(0, "pool.ntp.org"); | ||
| 103 | esp_sntp_setservername(1, "time.google.com"); | ||
| 104 | esp_sntp_init(); | ||
| 105 | ESP_LOGI(TAG, "SNTP time sync started"); | ||
| 106 | |||
| 97 | char gw_ip_str[16]; | 107 | char gw_ip_str[16]; |
| 98 | snprintf(gw_ip_str, sizeof(gw_ip_str), IPSTR, IP2STR(&event->ip_info.gw)); | 108 | snprintf(gw_ip_str, sizeof(gw_ip_str), IPSTR, IP2STR(&event->ip_info.gw)); |
| 99 | tollgate_client_on_sta_connected(gw_ip_str); | 109 | tollgate_client_on_sta_connected(gw_ip_str); |
| @@ -160,6 +170,11 @@ static void start_services(void) | |||
| 160 | s_services_running = true; | 170 | s_services_running = true; |
| 161 | if (s_services_mutex) xSemaphoreGive(s_services_mutex); | 171 | if (s_services_mutex) xSemaphoreGive(s_services_mutex); |
| 162 | ESP_LOGI(TAG, "=== TollGate services started ==="); | 172 | ESP_LOGI(TAG, "=== TollGate services started ==="); |
| 173 | |||
| 174 | display_set_state(DISPLAY_READY); | ||
| 175 | char portal_url[128]; | ||
| 176 | snprintf(portal_url, sizeof(portal_url), "http://%s/", cfg->ap_ip_str); | ||
| 177 | display_update(cfg->ap_ssid, 0, 0, portal_url); | ||
| 163 | } | 178 | } |
| 164 | 179 | ||
| 165 | static void stop_services(void) | 180 | static void stop_services(void) |
| @@ -240,6 +255,9 @@ void app_main(void) | |||
| 240 | { | 255 | { |
| 241 | ESP_LOGI(TAG, "=== TollGate ESP32 Starting ==="); | 256 | ESP_LOGI(TAG, "=== TollGate ESP32 Starting ==="); |
| 242 | 257 | ||
| 258 | display_init(); | ||
| 259 | display_set_state(DISPLAY_BOOT); | ||
| 260 | |||
| 243 | esp_err_t ret = nvs_flash_init(); | 261 | esp_err_t ret = nvs_flash_init(); |
| 244 | if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { | 262 | if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { |
| 245 | ESP_ERROR_CHECK(nvs_flash_erase()); | 263 | ESP_ERROR_CHECK(nvs_flash_erase()); |
| @@ -288,6 +306,9 @@ void app_main(void) | |||
| 288 | ESP_LOGI(TAG, "STA configured for SSID: %s", tcfg2->networks[tcfg2->current_network].ssid); | 306 | ESP_LOGI(TAG, "STA configured for SSID: %s", tcfg2->networks[tcfg2->current_network].ssid); |
| 289 | } | 307 | } |
| 290 | 308 | ||
| 309 | ESP_ERROR_CHECK(esp_wifi_set_country_code("DE", false)); | ||
| 310 | ESP_LOGI(TAG, "WiFi country code set to DE (EU regulatory domain)"); | ||
| 311 | |||
| 291 | ESP_ERROR_CHECK(esp_wifi_start()); | 312 | ESP_ERROR_CHECK(esp_wifi_start()); |
| 292 | 313 | ||
| 293 | ESP_LOGI(TAG, "WiFi AP+STA started, waiting for connection..."); | 314 | ESP_LOGI(TAG, "WiFi AP+STA started, waiting for connection..."); |
diff --git a/tests/integration/test-cvm.mjs b/tests/integration/test-cvm.mjs new file mode 100644 index 0000000..8deb6ec --- /dev/null +++ b/tests/integration/test-cvm.mjs | |||
| @@ -0,0 +1,94 @@ | |||
| 1 | import { execSync } from 'child_process'; | ||
| 2 | |||
| 3 | const IP = process.env.TOLLGATE_IP || '10.192.45.1'; | ||
| 4 | const RELAYS = ['wss://relay.damus.io', 'wss://nos.lol']; | ||
| 5 | |||
| 6 | let passed = 0, failed = 0; | ||
| 7 | |||
| 8 | function assert(condition, test) { | ||
| 9 | if (condition) { console.log(` \u2713 ${test}`); passed++; } | ||
| 10 | else { console.log(` \u2717 ${test}`); failed++; } | ||
| 11 | } | ||
| 12 | |||
| 13 | function nak(args, timeout = 10000) { | ||
| 14 | try { | ||
| 15 | return execSync(`timeout ${timeout / 1000} nak ${args}`, { | ||
| 16 | encoding: 'utf8', | ||
| 17 | stdio: ['pipe', 'pipe', 'pipe'], | ||
| 18 | timeout | ||
| 19 | }).trim(); | ||
| 20 | } catch (e) { | ||
| 21 | return e.stdout ? e.stdout.trim() : ''; | ||
| 22 | } | ||
| 23 | } | ||
| 24 | |||
| 25 | function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } | ||
| 26 | |||
| 27 | async function runTests() { | ||
| 28 | console.log(`\n=== CVM Integration Tests (target: ${IP}) ===\n`); | ||
| 29 | |||
| 30 | const npub = nak(`key public a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2`); | ||
| 31 | const npubHex = npub.trim(); | ||
| 32 | console.log(`Board npub: ${npubHex}`); | ||
| 33 | |||
| 34 | const npubBech32 = nak(`encode npub ${npubHex}`).trim(); | ||
| 35 | console.log(`Board npub (bech32): ${npubBech32}`); | ||
| 36 | |||
| 37 | assert(npubHex.length === 64, 'npub hex is 64 chars'); | ||
| 38 | |||
| 39 | console.log('\n--- Test: Kind 11316 server announcement ---'); | ||
| 40 | for (const relay of RELAYS) { | ||
| 41 | console.log(` Querying ${relay}...`); | ||
| 42 | const result = nak(`req -k 11316 -a ${npubHex} -l 1 ${relay}`, 8000); | ||
| 43 | if (result.length > 0) { | ||
| 44 | assert(result.includes('"kind"') || result.includes('11316'), | ||
| 45 | `Kind 11316 found on ${relay}`); | ||
| 46 | if (result.includes('TollGate')) { | ||
| 47 | assert(true, `Announcement contains "TollGate"`); | ||
| 48 | } | ||
| 49 | } else { | ||
| 50 | console.log(` (no result from ${relay} — relay may be offline)`); | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 54 | console.log('\n--- Test: Kind 11317 tools list ---'); | ||
| 55 | for (const relay of RELAYS) { | ||
| 56 | const result = nak(`req -k 11317 -a ${npubHex} -l 1 ${relay}`, 8000); | ||
| 57 | if (result.length > 0) { | ||
| 58 | assert(result.includes('"kind"') || result.includes('11317'), | ||
| 59 | `Kind 11317 found on ${relay}`); | ||
| 60 | if (result.includes('get_config') && result.includes('wallet_melt')) { | ||
| 61 | assert(true, `Tools list has expected tools`); | ||
| 62 | } | ||
| 63 | } else { | ||
| 64 | console.log(` (no result from ${relay} — relay may be offline)`); | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | console.log('\n--- Test: Kind 10002 relay list ---'); | ||
| 69 | for (const relay of RELAYS) { | ||
| 70 | const result = nak(`req -k 10002 -a ${npubHex} -l 1 ${relay}`, 8000); | ||
| 71 | if (result.length > 0) { | ||
| 72 | assert(result.includes('"kind"') || result.includes('10002'), | ||
| 73 | `Kind 10002 found on ${relay}`); | ||
| 74 | } else { | ||
| 75 | console.log(` (no result from ${relay} — relay may be offline)`); | ||
| 76 | } | ||
| 77 | } | ||
| 78 | |||
| 79 | console.log('\n--- Test: API get_config (control check) ---'); | ||
| 80 | try { | ||
| 81 | const apiResult = execSync(`curl -s http://${IP}:2121/usage`, { encoding: 'utf8', timeout: 5000 }); | ||
| 82 | assert(apiResult.length > 0, 'API /usage responds (board is reachable)'); | ||
| 83 | } catch (e) { | ||
| 84 | console.log(' (API not reachable — board may be offline or not flashed yet)'); | ||
| 85 | } | ||
| 86 | |||
| 87 | console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`); | ||
| 88 | process.exit(failed > 0 ? 1 : 0); | ||
| 89 | } | ||
| 90 | |||
| 91 | runTests().catch(e => { | ||
| 92 | console.error('Test error:', e.message); | ||
| 93 | process.exit(1); | ||
| 94 | }); | ||
diff --git a/tests/unit/Makefile b/tests/unit/Makefile index 5dee0d7..7ebc3b2 100644 --- a/tests/unit/Makefile +++ b/tests/unit/Makefile | |||
| @@ -22,7 +22,7 @@ LDFLAGS := -lmbedcrypto -lcjson -lm | |||
| 22 | 22 | ||
| 23 | SECP256K1_OBJ := secp256k1.o precomputed_ecmult.o precomputed_ecmult_gen.o | 23 | SECP256K1_OBJ := secp256k1.o precomputed_ecmult.o precomputed_ecmult_gen.o |
| 24 | 24 | ||
| 25 | TESTS := test_geohash test_identity test_nostr_event test_cashu test_session test_tollgate_client test_lnurl_pay test_lightning_payout test_mcp_handler test_nip04 | 25 | TESTS := test_geohash test_identity test_nostr_event test_cashu test_session test_tollgate_client test_lnurl_pay test_lightning_payout test_mcp_handler test_nip04 test_cvm_server |
| 26 | 26 | ||
| 27 | .PHONY: all test clean $(TESTS) | 27 | .PHONY: all test clean $(TESTS) |
| 28 | 28 | ||
| @@ -78,5 +78,8 @@ test_mcp_handler: test_mcp_handler.c $(REPO_ROOT)/main/mcp_handler.c | |||
| 78 | test_nip04: test_nip04.c $(REPO_ROOT)/main/nip04.c $(SECP256K1_OBJ) | 78 | test_nip04: test_nip04.c $(REPO_ROOT)/main/nip04.c $(SECP256K1_OBJ) |
| 79 | $(CC) $(CFLAGS) -I $(SECP256K1_PRIV_INC) $< $(REPO_ROOT)/main/nip04.c $(SECP256K1_OBJ) -o $@ $(LDFLAGS) | 79 | $(CC) $(CFLAGS) -I $(SECP256K1_PRIV_INC) $< $(REPO_ROOT)/main/nip04.c $(SECP256K1_OBJ) -o $@ $(LDFLAGS) |
| 80 | 80 | ||
| 81 | test_cvm_server: test_cvm_server.c | ||
| 82 | $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) | ||
| 83 | |||
| 81 | clean: | 84 | clean: |
| 82 | rm -f $(TESTS) $(SECP256K1_OBJ) | 85 | rm -f $(TESTS) $(SECP256K1_OBJ) |
diff --git a/tests/unit/test_cvm_server.c b/tests/unit/test_cvm_server.c new file mode 100644 index 0000000..84583c6 --- /dev/null +++ b/tests/unit/test_cvm_server.c | |||
| @@ -0,0 +1,434 @@ | |||
| 1 | #include "test_framework.h" | ||
| 2 | #include "cJSON.h" | ||
| 3 | #include <stdbool.h> | ||
| 4 | #include <string.h> | ||
| 5 | #include <stdio.h> | ||
| 6 | #include <stdlib.h> | ||
| 7 | |||
| 8 | static char *build_initialize_response_test(const char *request_id_str) | ||
| 9 | { | ||
| 10 | cJSON *response = cJSON_CreateObject(); | ||
| 11 | cJSON_AddStringToObject(response, "jsonrpc", "2.0"); | ||
| 12 | cJSON_AddNumberToObject(response, "id", request_id_str ? atof(request_id_str) : 0); | ||
| 13 | |||
| 14 | cJSON *result = cJSON_CreateObject(); | ||
| 15 | cJSON_AddStringToObject(result, "protocolVersion", "2025-07-02"); | ||
| 16 | |||
| 17 | cJSON *capabilities = cJSON_CreateObject(); | ||
| 18 | cJSON_AddItemToObject(capabilities, "tools", cJSON_CreateObject()); | ||
| 19 | cJSON_AddItemToObject(result, "capabilities", capabilities); | ||
| 20 | |||
| 21 | cJSON *serverInfo = cJSON_CreateObject(); | ||
| 22 | cJSON_AddStringToObject(serverInfo, "name", "TollGate"); | ||
| 23 | cJSON_AddStringToObject(serverInfo, "version", "1.0.0"); | ||
| 24 | cJSON_AddItemToObject(result, "serverInfo", serverInfo); | ||
| 25 | |||
| 26 | cJSON_AddItemToObject(response, "result", result); | ||
| 27 | |||
| 28 | char *json = cJSON_PrintUnformatted(response); | ||
| 29 | cJSON_Delete(response); | ||
| 30 | return json; | ||
| 31 | } | ||
| 32 | |||
| 33 | static char *build_tools_list_response_test(const char *request_id_str) | ||
| 34 | { | ||
| 35 | cJSON *response = cJSON_CreateObject(); | ||
| 36 | cJSON_AddStringToObject(response, "jsonrpc", "2.0"); | ||
| 37 | cJSON_AddNumberToObject(response, "id", request_id_str ? atof(request_id_str) : 1); | ||
| 38 | |||
| 39 | cJSON *result = cJSON_CreateObject(); | ||
| 40 | cJSON *tools = cJSON_CreateArray(); | ||
| 41 | |||
| 42 | const char *tool_names[] = { | ||
| 43 | "get_config", "set_config", "get_balance", "wallet_send", | ||
| 44 | "get_sessions", "get_usage", "set_payout", "set_metric", | ||
| 45 | "set_price", "wallet_melt" | ||
| 46 | }; | ||
| 47 | |||
| 48 | for (int i = 0; i < 10; i++) { | ||
| 49 | cJSON *tool = cJSON_CreateObject(); | ||
| 50 | cJSON_AddStringToObject(tool, "name", tool_names[i]); | ||
| 51 | cJSON_AddItemToArray(tools, tool); | ||
| 52 | } | ||
| 53 | |||
| 54 | cJSON_AddItemToObject(result, "tools", tools); | ||
| 55 | cJSON_AddItemToObject(response, "result", result); | ||
| 56 | |||
| 57 | char *json = cJSON_PrintUnformatted(response); | ||
| 58 | cJSON_Delete(response); | ||
| 59 | return json; | ||
| 60 | } | ||
| 61 | |||
| 62 | static char *build_tool_call_response_test(const char *request_id_str, | ||
| 63 | bool success, const char *result_or_error) | ||
| 64 | { | ||
| 65 | cJSON *response = cJSON_CreateObject(); | ||
| 66 | cJSON_AddStringToObject(response, "jsonrpc", "2.0"); | ||
| 67 | cJSON_AddNumberToObject(response, "id", request_id_str ? atof(request_id_str) : 2); | ||
| 68 | |||
| 69 | if (success) { | ||
| 70 | cJSON *result = cJSON_CreateObject(); | ||
| 71 | cJSON *content_arr = cJSON_CreateArray(); | ||
| 72 | cJSON *text_item = cJSON_CreateObject(); | ||
| 73 | cJSON_AddStringToObject(text_item, "type", "text"); | ||
| 74 | cJSON_AddStringToObject(text_item, "text", result_or_error); | ||
| 75 | cJSON_AddItemToArray(content_arr, text_item); | ||
| 76 | cJSON_AddItemToObject(result, "content", content_arr); | ||
| 77 | cJSON_AddBoolToObject(result, "isError", false); | ||
| 78 | cJSON_AddItemToObject(response, "result", result); | ||
| 79 | } else { | ||
| 80 | cJSON *error = cJSON_CreateObject(); | ||
| 81 | cJSON_AddNumberToObject(error, "code", -32603); | ||
| 82 | cJSON_AddStringToObject(error, "message", result_or_error); | ||
| 83 | cJSON_AddItemToObject(response, "error", error); | ||
| 84 | } | ||
| 85 | |||
| 86 | char *json = cJSON_PrintUnformatted(response); | ||
| 87 | cJSON_Delete(response); | ||
| 88 | return json; | ||
| 89 | } | ||
| 90 | |||
| 91 | static char *build_ping_response_test(const char *request_id_str) | ||
| 92 | { | ||
| 93 | cJSON *response = cJSON_CreateObject(); | ||
| 94 | cJSON_AddStringToObject(response, "jsonrpc", "2.0"); | ||
| 95 | cJSON_AddNumberToObject(response, "id", request_id_str ? atof(request_id_str) : 0); | ||
| 96 | cJSON *result = cJSON_CreateObject(); | ||
| 97 | cJSON_AddItemToObject(response, "result", result); | ||
| 98 | char *json = cJSON_PrintUnformatted(response); | ||
| 99 | cJSON_Delete(response); | ||
| 100 | return json; | ||
| 101 | } | ||
| 102 | |||
| 103 | static char *build_announcement_11316_test(void) | ||
| 104 | { | ||
| 105 | cJSON *ann = cJSON_CreateObject(); | ||
| 106 | cJSON_AddStringToObject(ann, "protocolVersion", "2025-07-02"); | ||
| 107 | |||
| 108 | cJSON *caps = cJSON_CreateObject(); | ||
| 109 | cJSON *tools = cJSON_CreateObject(); | ||
| 110 | cJSON_AddBoolToObject(tools, "listChanged", true); | ||
| 111 | cJSON_AddItemToObject(caps, "tools", tools); | ||
| 112 | cJSON_AddItemToObject(ann, "capabilities", caps); | ||
| 113 | |||
| 114 | cJSON *info = cJSON_CreateObject(); | ||
| 115 | cJSON_AddStringToObject(info, "name", "TollGate"); | ||
| 116 | cJSON_AddStringToObject(info, "version", "1.0.0"); | ||
| 117 | cJSON_AddItemToObject(ann, "serverInfo", info); | ||
| 118 | |||
| 119 | char *json = cJSON_PrintUnformatted(ann); | ||
| 120 | cJSON_Delete(ann); | ||
| 121 | return json; | ||
| 122 | } | ||
| 123 | |||
| 124 | static char *build_announcement_11317_test(void) | ||
| 125 | { | ||
| 126 | cJSON *root = cJSON_CreateObject(); | ||
| 127 | cJSON *tools = cJSON_CreateArray(); | ||
| 128 | |||
| 129 | const char *names[] = { | ||
| 130 | "get_config", "set_config", "get_balance", "wallet_send", | ||
| 131 | "get_sessions", "get_usage", "set_payout", "set_metric", | ||
| 132 | "set_price", "wallet_melt" | ||
| 133 | }; | ||
| 134 | |||
| 135 | for (int i = 0; i < 10; i++) { | ||
| 136 | cJSON *t = cJSON_CreateObject(); | ||
| 137 | cJSON_AddStringToObject(t, "name", names[i]); | ||
| 138 | cJSON_AddStringToObject(t, "description", "test"); | ||
| 139 | cJSON *schema = cJSON_CreateObject(); | ||
| 140 | cJSON_AddStringToObject(schema, "type", "object"); | ||
| 141 | cJSON_AddItemToObject(t, "inputSchema", schema); | ||
| 142 | cJSON_AddItemToArray(tools, t); | ||
| 143 | } | ||
| 144 | |||
| 145 | cJSON_AddItemToObject(root, "tools", tools); | ||
| 146 | char *json = cJSON_PrintUnformatted(root); | ||
| 147 | cJSON_Delete(root); | ||
| 148 | return json; | ||
| 149 | } | ||
| 150 | |||
| 151 | static char *build_relay_list_10002_test(void) | ||
| 152 | { | ||
| 153 | cJSON *tags = cJSON_CreateArray(); | ||
| 154 | const char *relays[] = {"wss://relay.damus.io", "wss://nos.lol"}; | ||
| 155 | for (int i = 0; i < 2; i++) { | ||
| 156 | cJSON *r = cJSON_CreateArray(); | ||
| 157 | cJSON_AddItemToArray(r, cJSON_CreateString("r")); | ||
| 158 | cJSON_AddItemToArray(r, cJSON_CreateString(relays[i])); | ||
| 159 | cJSON_AddItemToArray(tags, r); | ||
| 160 | } | ||
| 161 | char *json = cJSON_PrintUnformatted(tags); | ||
| 162 | cJSON_Delete(tags); | ||
| 163 | return json; | ||
| 164 | } | ||
| 165 | |||
| 166 | static bool parse_mcp_from_25910(const char *content, char *method_out, size_t method_max, | ||
| 167 | char *params_out, size_t params_max) | ||
| 168 | { | ||
| 169 | cJSON *msg = cJSON_Parse(content); | ||
| 170 | if (!msg) return false; | ||
| 171 | |||
| 172 | cJSON *method = cJSON_GetObjectItem(msg, "method"); | ||
| 173 | if (!method || !cJSON_IsString(method)) { | ||
| 174 | cJSON_Delete(msg); | ||
| 175 | return false; | ||
| 176 | } | ||
| 177 | |||
| 178 | strncpy(method_out, method->valuestring, method_max - 1); | ||
| 179 | |||
| 180 | cJSON *params = cJSON_GetObjectItem(msg, "params"); | ||
| 181 | if (params) { | ||
| 182 | char *pjson = cJSON_PrintUnformatted(params); | ||
| 183 | strncpy(params_out, pjson, params_max - 1); | ||
| 184 | cJSON_free(pjson); | ||
| 185 | } | ||
| 186 | |||
| 187 | cJSON_Delete(msg); | ||
| 188 | return true; | ||
| 189 | } | ||
| 190 | |||
| 191 | static void test_initialize_response(void) | ||
| 192 | { | ||
| 193 | printf("\n=== MCP initialize response ===\n"); | ||
| 194 | char *json = build_initialize_response_test("0"); | ||
| 195 | ASSERT(json != NULL, "response created"); | ||
| 196 | |||
| 197 | cJSON *root = cJSON_Parse(json); | ||
| 198 | ASSERT(root != NULL, "valid JSON"); | ||
| 199 | ASSERT_EQ_STR("2.0", cJSON_GetObjectItem(root, "jsonrpc")->valuestring, "jsonrpc version"); | ||
| 200 | ASSERT_EQ_INT(0, (int)cJSON_GetObjectItem(root, "id")->valuedouble, "id=0"); | ||
| 201 | |||
| 202 | cJSON *result = cJSON_GetObjectItem(root, "result"); | ||
| 203 | ASSERT(result != NULL, "has result"); | ||
| 204 | ASSERT_EQ_STR("2025-07-02", cJSON_GetObjectItem(result, "protocolVersion")->valuestring, "protocol version"); | ||
| 205 | |||
| 206 | cJSON *caps = cJSON_GetObjectItem(result, "capabilities"); | ||
| 207 | ASSERT(caps != NULL, "has capabilities"); | ||
| 208 | ASSERT(cJSON_GetObjectItem(caps, "tools") != NULL, "has tools capability"); | ||
| 209 | |||
| 210 | cJSON *info = cJSON_GetObjectItem(result, "serverInfo"); | ||
| 211 | ASSERT(info != NULL, "has serverInfo"); | ||
| 212 | ASSERT_EQ_STR("TollGate", cJSON_GetObjectItem(info, "name")->valuestring, "server name"); | ||
| 213 | ASSERT_EQ_STR("1.0.0", cJSON_GetObjectItem(info, "version")->valuestring, "server version"); | ||
| 214 | |||
| 215 | cJSON_Delete(root); | ||
| 216 | free(json); | ||
| 217 | } | ||
| 218 | |||
| 219 | static void test_tools_list_response(void) | ||
| 220 | { | ||
| 221 | printf("\n=== MCP tools/list response ===\n"); | ||
| 222 | char *json = build_tools_list_response_test("1"); | ||
| 223 | ASSERT(json != NULL, "response created"); | ||
| 224 | |||
| 225 | cJSON *root = cJSON_Parse(json); | ||
| 226 | ASSERT_EQ_STR("2.0", cJSON_GetObjectItem(root, "jsonrpc")->valuestring, "jsonrpc version"); | ||
| 227 | |||
| 228 | cJSON *result = cJSON_GetObjectItem(root, "result"); | ||
| 229 | cJSON *tools = cJSON_GetObjectItem(result, "tools"); | ||
| 230 | ASSERT(tools != NULL, "has tools array"); | ||
| 231 | ASSERT_EQ_INT(10, cJSON_GetArraySize(tools), "10 tools"); | ||
| 232 | |||
| 233 | ASSERT_EQ_STR("get_config", cJSON_GetObjectItem(cJSON_GetArrayItem(tools, 0), "name")->valuestring, "tool 0"); | ||
| 234 | ASSERT_EQ_STR("wallet_melt", cJSON_GetObjectItem(cJSON_GetArrayItem(tools, 9), "name")->valuestring, "tool 9"); | ||
| 235 | |||
| 236 | cJSON_Delete(root); | ||
| 237 | free(json); | ||
| 238 | } | ||
| 239 | |||
| 240 | static void test_tool_call_response_success(void) | ||
| 241 | { | ||
| 242 | printf("\n=== MCP tools/call success response ===\n"); | ||
| 243 | char *json = build_tool_call_response_test("2", true, "{\"balance\":500}"); | ||
| 244 | ASSERT(json != NULL, "response created"); | ||
| 245 | |||
| 246 | cJSON *root = cJSON_Parse(json); | ||
| 247 | cJSON *result = cJSON_GetObjectItem(root, "result"); | ||
| 248 | ASSERT(result != NULL, "has result"); | ||
| 249 | ASSERT(cJSON_GetObjectItem(result, "content") != NULL, "has content"); | ||
| 250 | ASSERT_EQ_INT(0, cJSON_GetObjectItem(result, "isError")->valueint, "isError=false"); | ||
| 251 | |||
| 252 | cJSON *content = cJSON_GetObjectItem(result, "content"); | ||
| 253 | cJSON *text = cJSON_GetArrayItem(content, 0); | ||
| 254 | ASSERT_EQ_STR("text", cJSON_GetObjectItem(text, "type")->valuestring, "content type=text"); | ||
| 255 | ASSERT(strstr(cJSON_GetObjectItem(text, "text")->valuestring, "balance") != NULL, "contains balance"); | ||
| 256 | |||
| 257 | cJSON_Delete(root); | ||
| 258 | free(json); | ||
| 259 | } | ||
| 260 | |||
| 261 | static void test_tool_call_response_error(void) | ||
| 262 | { | ||
| 263 | printf("\n=== MCP tools/call error response ===\n"); | ||
| 264 | char *json = build_tool_call_response_test("3", false, "Tool not found"); | ||
| 265 | ASSERT(json != NULL, "response created"); | ||
| 266 | |||
| 267 | cJSON *root = cJSON_Parse(json); | ||
| 268 | cJSON *error = cJSON_GetObjectItem(root, "error"); | ||
| 269 | ASSERT(error != NULL, "has error"); | ||
| 270 | ASSERT_EQ_INT(-32603, cJSON_GetObjectItem(error, "code")->valueint, "error code"); | ||
| 271 | ASSERT_EQ_STR("Tool not found", cJSON_GetObjectItem(error, "message")->valuestring, "error message"); | ||
| 272 | |||
| 273 | cJSON_Delete(root); | ||
| 274 | free(json); | ||
| 275 | } | ||
| 276 | |||
| 277 | static void test_ping_response(void) | ||
| 278 | { | ||
| 279 | printf("\n=== MCP ping response ===\n"); | ||
| 280 | char *json = build_ping_response_test("99"); | ||
| 281 | ASSERT(json != NULL, "response created"); | ||
| 282 | |||
| 283 | cJSON *root = cJSON_Parse(json); | ||
| 284 | ASSERT_EQ_STR("2.0", cJSON_GetObjectItem(root, "jsonrpc")->valuestring, "jsonrpc version"); | ||
| 285 | ASSERT(cJSON_GetObjectItem(root, "result") != NULL, "has result"); | ||
| 286 | |||
| 287 | cJSON_Delete(root); | ||
| 288 | free(json); | ||
| 289 | } | ||
| 290 | |||
| 291 | static void test_announcement_11316(void) | ||
| 292 | { | ||
| 293 | printf("\n=== Kind 11316 server announcement ===\n"); | ||
| 294 | char *json = build_announcement_11316_test(); | ||
| 295 | ASSERT(json != NULL, "announcement created"); | ||
| 296 | |||
| 297 | cJSON *root = cJSON_Parse(json); | ||
| 298 | ASSERT_EQ_STR("2025-07-02", cJSON_GetObjectItem(root, "protocolVersion")->valuestring, "protocol version"); | ||
| 299 | |||
| 300 | cJSON *caps = cJSON_GetObjectItem(root, "capabilities"); | ||
| 301 | ASSERT(cJSON_GetObjectItem(caps, "tools") != NULL, "has tools capability"); | ||
| 302 | |||
| 303 | cJSON *info = cJSON_GetObjectItem(root, "serverInfo"); | ||
| 304 | ASSERT_EQ_STR("TollGate", cJSON_GetObjectItem(info, "name")->valuestring, "name"); | ||
| 305 | ASSERT_EQ_STR("1.0.0", cJSON_GetObjectItem(info, "version")->valuestring, "version"); | ||
| 306 | |||
| 307 | cJSON_Delete(root); | ||
| 308 | free(json); | ||
| 309 | } | ||
| 310 | |||
| 311 | static void test_announcement_11317(void) | ||
| 312 | { | ||
| 313 | printf("\n=== Kind 11317 tools list ===\n"); | ||
| 314 | char *json = build_announcement_11317_test(); | ||
| 315 | ASSERT(json != NULL, "tools list created"); | ||
| 316 | |||
| 317 | cJSON *root = cJSON_Parse(json); | ||
| 318 | cJSON *tools = cJSON_GetObjectItem(root, "tools"); | ||
| 319 | ASSERT_EQ_INT(10, cJSON_GetArraySize(tools), "10 tools"); | ||
| 320 | |||
| 321 | cJSON *t0 = cJSON_GetArrayItem(tools, 0); | ||
| 322 | ASSERT_EQ_STR("get_config", cJSON_GetObjectItem(t0, "name")->valuestring, "tool 0 name"); | ||
| 323 | ASSERT(cJSON_GetObjectItem(t0, "inputSchema") != NULL, "tool has inputSchema"); | ||
| 324 | |||
| 325 | cJSON_Delete(root); | ||
| 326 | free(json); | ||
| 327 | } | ||
| 328 | |||
| 329 | static void test_relay_list_10002(void) | ||
| 330 | { | ||
| 331 | printf("\n=== Kind 10002 relay list ===\n"); | ||
| 332 | char *json = build_relay_list_10002_test(); | ||
| 333 | ASSERT(json != NULL, "relay list created"); | ||
| 334 | |||
| 335 | cJSON *tags = cJSON_Parse(json); | ||
| 336 | ASSERT(cJSON_IsArray(tags), "is array"); | ||
| 337 | ASSERT_EQ_INT(2, cJSON_GetArraySize(tags), "2 relay tags"); | ||
| 338 | |||
| 339 | cJSON *r0 = cJSON_GetArrayItem(tags, 0); | ||
| 340 | ASSERT_EQ_STR("r", cJSON_GetArrayItem(r0, 0)->valuestring, "tag type r"); | ||
| 341 | ASSERT_EQ_STR("wss://relay.damus.io", cJSON_GetArrayItem(r0, 1)->valuestring, "relay 0"); | ||
| 342 | |||
| 343 | cJSON *r1 = cJSON_GetArrayItem(tags, 1); | ||
| 344 | ASSERT_EQ_STR("wss://nos.lol", cJSON_GetArrayItem(r1, 1)->valuestring, "relay 1"); | ||
| 345 | |||
| 346 | cJSON_Delete(tags); | ||
| 347 | free(json); | ||
| 348 | } | ||
| 349 | |||
| 350 | static void test_mcp_parse_from_25910(void) | ||
| 351 | { | ||
| 352 | printf("\n=== Parse MCP from kind 25910 content ===\n"); | ||
| 353 | |||
| 354 | char method[64] = {0}; | ||
| 355 | char params[1024] = {0}; | ||
| 356 | |||
| 357 | bool ok = parse_mcp_from_25910( | ||
| 358 | "{\"jsonrpc\":\"2.0\",\"id\":0,\"method\":\"initialize\",\"params\":{}}", | ||
| 359 | method, sizeof(method), params, sizeof(params)); | ||
| 360 | ASSERT(ok, "parsed initialize"); | ||
| 361 | ASSERT_EQ_STR("initialize", method, "method=initialize"); | ||
| 362 | |||
| 363 | ok = parse_mcp_from_25910( | ||
| 364 | "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"get_config\"}}", | ||
| 365 | method, sizeof(method), params, sizeof(params)); | ||
| 366 | ASSERT(ok, "parsed tools/call"); | ||
| 367 | ASSERT_EQ_STR("tools/call", method, "method=tools/call"); | ||
| 368 | ASSERT(strstr(params, "get_config") != NULL, "params has get_config"); | ||
| 369 | |||
| 370 | ok = parse_mcp_from_25910("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}", | ||
| 371 | method, sizeof(method), params, sizeof(params)); | ||
| 372 | ASSERT(ok, "parsed notification"); | ||
| 373 | ASSERT_EQ_STR("notifications/initialized", method, "method=notifications/initialized"); | ||
| 374 | |||
| 375 | ok = parse_mcp_from_25910("not json", method, sizeof(method), params, sizeof(params)); | ||
| 376 | ASSERT(!ok, "garbage rejected"); | ||
| 377 | |||
| 378 | ok = parse_mcp_from_25910("{\"jsonrpc\":\"2.0\"}", method, sizeof(method), params, sizeof(params)); | ||
| 379 | ASSERT(!ok, "missing method rejected"); | ||
| 380 | } | ||
| 381 | |||
| 382 | static void test_auth_check(void) | ||
| 383 | { | ||
| 384 | printf("\n=== Auth check logic ===\n"); | ||
| 385 | |||
| 386 | const char *owner = "d6bfe100d1600c0d8f769501676fc74c3809500bd131c8a549f88cf616c21f35"; | ||
| 387 | const char *other = "0000000000000000000000000000000000000000000000000000000000000001"; | ||
| 388 | |||
| 389 | ASSERT(strcmp(owner, owner) == 0, "owner matches self"); | ||
| 390 | ASSERT(strcmp(owner, other) != 0, "owner differs from other"); | ||
| 391 | ASSERT(strcmp(other, owner) != 0, "other differs from owner"); | ||
| 392 | ASSERT(NULL == NULL, "two NULLs match (for safety check)"); | ||
| 393 | } | ||
| 394 | |||
| 395 | static void test_25910_event_content_roundtrip(void) | ||
| 396 | { | ||
| 397 | printf("\n=== Kind 25910 content roundtrip ===\n"); | ||
| 398 | |||
| 399 | cJSON *request = cJSON_CreateObject(); | ||
| 400 | cJSON_AddStringToObject(request, "jsonrpc", "2.0"); | ||
| 401 | cJSON_AddNumberToObject(request, "id", 42); | ||
| 402 | cJSON_AddStringToObject(request, "method", "tools/call"); | ||
| 403 | cJSON *params = cJSON_CreateObject(); | ||
| 404 | cJSON_AddStringToObject(params, "name", "get_balance"); | ||
| 405 | cJSON_AddItemToObject(request, "params", params); | ||
| 406 | char *content = cJSON_PrintUnformatted(request); | ||
| 407 | cJSON_Delete(request); | ||
| 408 | |||
| 409 | char method[64] = {0}; | ||
| 410 | char params_out[1024] = {0}; | ||
| 411 | bool ok = parse_mcp_from_25910(content, method, sizeof(method), params_out, sizeof(params_out)); | ||
| 412 | ASSERT(ok, "roundtrip parse succeeded"); | ||
| 413 | ASSERT_EQ_STR("tools/call", method, "method preserved"); | ||
| 414 | ASSERT(strstr(params_out, "get_balance") != NULL, "tool name preserved"); | ||
| 415 | |||
| 416 | free(content); | ||
| 417 | } | ||
| 418 | |||
| 419 | int main(void) | ||
| 420 | { | ||
| 421 | printf("=== test_cvm_server ===\n"); | ||
| 422 | test_initialize_response(); | ||
| 423 | test_tools_list_response(); | ||
| 424 | test_tool_call_response_success(); | ||
| 425 | test_tool_call_response_error(); | ||
| 426 | test_ping_response(); | ||
| 427 | test_announcement_11316(); | ||
| 428 | test_announcement_11317(); | ||
| 429 | test_relay_list_10002(); | ||
| 430 | test_mcp_parse_from_25910(); | ||
| 431 | test_auth_check(); | ||
| 432 | test_25910_event_content_roundtrip(); | ||
| 433 | TEST_SUMMARY(); | ||
| 434 | } | ||
diff --git a/tests/unit/test_mcp_handler.c b/tests/unit/test_mcp_handler.c index aaa199d..05e9e38 100644 --- a/tests/unit/test_mcp_handler.c +++ b/tests/unit/test_mcp_handler.c | |||
| @@ -1,6 +1,7 @@ | |||
| 1 | #include "test_framework.h" | 1 | #include "test_framework.h" |
| 2 | #include "mcp_handler.h" | 2 | #include "mcp_handler.h" |
| 3 | #include "config.h" | 3 | #include "config.h" |
| 4 | #include "session.h" | ||
| 4 | #include "nucula_wallet.h" | 5 | #include "nucula_wallet.h" |
| 5 | #include "cJSON.h" | 6 | #include "cJSON.h" |
| 6 | #include <string.h> | 7 | #include <string.h> |
| @@ -11,6 +12,7 @@ static uint64_t g_wallet_balance = 0; | |||
| 11 | static int g_wallet_proof_count = 0; | 12 | static int g_wallet_proof_count = 0; |
| 12 | static int g_wallet_send_rc = 0; | 13 | static int g_wallet_send_rc = 0; |
| 13 | static char g_wallet_send_token[256] = "cashuA_test_token"; | 14 | static char g_wallet_send_token[256] = "cashuA_test_token"; |
| 15 | static esp_err_t g_wallet_melt_rc = ESP_OK; | ||
| 14 | 16 | ||
| 15 | const tollgate_config_t *tollgate_config_get(void) { | 17 | const tollgate_config_t *tollgate_config_get(void) { |
| 16 | return &g_test_config; | 18 | return &g_test_config; |
| @@ -33,6 +35,23 @@ int nucula_wallet_send(uint64_t amount, char *token_out, size_t token_max) { | |||
| 33 | return g_wallet_send_rc; | 35 | return g_wallet_send_rc; |
| 34 | } | 36 | } |
| 35 | 37 | ||
| 38 | esp_err_t nucula_wallet_melt(const char *bolt11, uint64_t max_fee) { | ||
| 39 | (void)bolt11; | ||
| 40 | (void)max_fee; | ||
| 41 | return g_wallet_melt_rc; | ||
| 42 | } | ||
| 43 | |||
| 44 | static session_t g_test_sessions[SESSION_MAX_CLIENTS]; | ||
| 45 | static int g_test_session_count = 0; | ||
| 46 | |||
| 47 | session_t *cvm_get_sessions_array(void) { | ||
| 48 | return g_test_sessions; | ||
| 49 | } | ||
| 50 | |||
| 51 | int cvm_get_sessions_count(void) { | ||
| 52 | return SESSION_MAX_CLIENTS; | ||
| 53 | } | ||
| 54 | |||
| 36 | static void test_mcp_parse_tool(void) | 55 | static void test_mcp_parse_tool(void) |
| 37 | { | 56 | { |
| 38 | printf("\n=== MCP tool parsing ===\n"); | 57 | printf("\n=== MCP tool parsing ===\n"); |
| @@ -40,6 +59,12 @@ static void test_mcp_parse_tool(void) | |||
| 40 | ASSERT_EQ_INT(MCP_TOOL_SET_CONFIG, mcp_parse_tool("set_config"), "set_config"); | 59 | ASSERT_EQ_INT(MCP_TOOL_SET_CONFIG, mcp_parse_tool("set_config"), "set_config"); |
| 41 | ASSERT_EQ_INT(MCP_TOOL_GET_BALANCE, mcp_parse_tool("get_balance"), "get_balance"); | 60 | ASSERT_EQ_INT(MCP_TOOL_GET_BALANCE, mcp_parse_tool("get_balance"), "get_balance"); |
| 42 | ASSERT_EQ_INT(MCP_TOOL_WALLET_SEND, mcp_parse_tool("wallet_send"), "wallet_send"); | 61 | ASSERT_EQ_INT(MCP_TOOL_WALLET_SEND, mcp_parse_tool("wallet_send"), "wallet_send"); |
| 62 | ASSERT_EQ_INT(MCP_TOOL_GET_SESSIONS, mcp_parse_tool("get_sessions"), "get_sessions"); | ||
| 63 | ASSERT_EQ_INT(MCP_TOOL_GET_USAGE, mcp_parse_tool("get_usage"), "get_usage"); | ||
| 64 | ASSERT_EQ_INT(MCP_TOOL_SET_PAYOUT, mcp_parse_tool("set_payout"), "set_payout"); | ||
| 65 | ASSERT_EQ_INT(MCP_TOOL_SET_METRIC, mcp_parse_tool("set_metric"), "set_metric"); | ||
| 66 | ASSERT_EQ_INT(MCP_TOOL_SET_PRICE, mcp_parse_tool("set_price"), "set_price"); | ||
| 67 | ASSERT_EQ_INT(MCP_TOOL_WALLET_MELT, mcp_parse_tool("wallet_melt"), "wallet_melt"); | ||
| 43 | ASSERT_EQ_INT(MCP_TOOL_UNKNOWN, mcp_parse_tool("foo"), "unknown tool"); | 68 | ASSERT_EQ_INT(MCP_TOOL_UNKNOWN, mcp_parse_tool("foo"), "unknown tool"); |
| 44 | ASSERT_EQ_INT(MCP_TOOL_UNKNOWN, mcp_parse_tool(NULL), "NULL tool"); | 69 | ASSERT_EQ_INT(MCP_TOOL_UNKNOWN, mcp_parse_tool(NULL), "NULL tool"); |
| 45 | } | 70 | } |
| @@ -135,6 +160,121 @@ static void test_mcp_dispatch(void) | |||
| 135 | ASSERT(!resp.success, "NULL request dispatch fails"); | 160 | ASSERT(!resp.success, "NULL request dispatch fails"); |
| 136 | } | 161 | } |
| 137 | 162 | ||
| 163 | static void test_mcp_get_sessions(void) | ||
| 164 | { | ||
| 165 | printf("\n=== MCP get_sessions ===\n"); | ||
| 166 | memset(g_test_sessions, 0, sizeof(g_test_sessions)); | ||
| 167 | |||
| 168 | mcp_response_t resp = mcp_handle_get_sessions(); | ||
| 169 | ASSERT(resp.success, "get_sessions succeeds"); | ||
| 170 | cJSON *result = cJSON_Parse(resp.result_json); | ||
| 171 | ASSERT(result != NULL, "result is valid JSON array"); | ||
| 172 | ASSERT(cJSON_IsArray(result), "result is an array"); | ||
| 173 | ASSERT_EQ_INT(0, cJSON_GetArraySize(result), "empty sessions"); | ||
| 174 | cJSON_Delete(result); | ||
| 175 | |||
| 176 | g_test_sessions[0].active = true; | ||
| 177 | g_test_sessions[0].client_ip = 0x0100000A; | ||
| 178 | strncpy(g_test_sessions[0].mac, "AA:BB:CC:DD:EE:FF", sizeof(g_test_sessions[0].mac) - 1); | ||
| 179 | g_test_sessions[0].allotment_ms = 60000; | ||
| 180 | |||
| 181 | resp = mcp_handle_get_sessions(); | ||
| 182 | ASSERT(resp.success, "get_sessions with data succeeds"); | ||
| 183 | result = cJSON_Parse(resp.result_json); | ||
| 184 | ASSERT_EQ_INT(1, cJSON_GetArraySize(result), "one active session"); | ||
| 185 | cJSON *s = cJSON_GetArrayItem(result, 0); | ||
| 186 | ASSERT_EQ_STR("AA:BB:CC:DD:EE:FF", cJSON_GetObjectItem(s, "mac")->valuestring, "mac matches"); | ||
| 187 | cJSON_Delete(result); | ||
| 188 | g_test_sessions[0].active = false; | ||
| 189 | } | ||
| 190 | |||
| 191 | static void test_mcp_get_usage(void) | ||
| 192 | { | ||
| 193 | printf("\n=== MCP get_usage ===\n"); | ||
| 194 | memset(&g_test_config, 0, sizeof(g_test_config)); | ||
| 195 | strncpy(g_test_config.metric, "milliseconds", sizeof(g_test_config.metric) - 1); | ||
| 196 | g_test_config.price_per_step = 21; | ||
| 197 | g_test_config.step_size_ms = 60000; | ||
| 198 | g_test_config.step_size_bytes = 22020096; | ||
| 199 | |||
| 200 | mcp_response_t resp = mcp_handle_get_usage(); | ||
| 201 | ASSERT(resp.success, "get_usage succeeds"); | ||
| 202 | cJSON *result = cJSON_Parse(resp.result_json); | ||
| 203 | ASSERT(result != NULL, "result is valid JSON"); | ||
| 204 | ASSERT_EQ_STR("milliseconds", cJSON_GetObjectItem(result, "metric")->valuestring, "metric matches"); | ||
| 205 | ASSERT_EQ_INT(21, cJSON_GetObjectItem(result, "price_per_step")->valueint, "price matches"); | ||
| 206 | cJSON_Delete(result); | ||
| 207 | } | ||
| 208 | |||
| 209 | static void test_mcp_set_payout(void) | ||
| 210 | { | ||
| 211 | printf("\n=== MCP set_payout ===\n"); | ||
| 212 | memset(&g_test_config, 0, sizeof(g_test_config)); | ||
| 213 | |||
| 214 | const char *params = "{\"enabled\":true,\"recipients\":[{\"lightning_address\":\"test@coinos.io\",\"factor\":0.5}]}"; | ||
| 215 | mcp_response_t resp = mcp_handle_set_payout(params); | ||
| 216 | ASSERT(resp.success, "set_payout succeeds"); | ||
| 217 | ASSERT(g_test_config.payout.enabled, "payout enabled"); | ||
| 218 | ASSERT_EQ_INT(1, g_test_config.payout.recipient_count, "1 recipient"); | ||
| 219 | ASSERT_EQ_STR("test@coinos.io", g_test_config.payout.recipients[0].lightning_address, "address matches"); | ||
| 220 | |||
| 221 | resp = mcp_handle_set_payout("not json"); | ||
| 222 | ASSERT(!resp.success, "invalid JSON fails"); | ||
| 223 | } | ||
| 224 | |||
| 225 | static void test_mcp_set_metric(void) | ||
| 226 | { | ||
| 227 | printf("\n=== MCP set_metric ===\n"); | ||
| 228 | memset(&g_test_config, 0, sizeof(g_test_config)); | ||
| 229 | |||
| 230 | mcp_response_t resp = mcp_handle_set_metric("{\"metric\":\"bytes\"}"); | ||
| 231 | ASSERT(resp.success, "set_metric bytes succeeds"); | ||
| 232 | ASSERT_EQ_STR("bytes", g_test_config.metric, "metric updated to bytes"); | ||
| 233 | |||
| 234 | resp = mcp_handle_set_metric("{\"metric\":\"milliseconds\"}"); | ||
| 235 | ASSERT(resp.success, "set_metric milliseconds succeeds"); | ||
| 236 | ASSERT_EQ_STR("milliseconds", g_test_config.metric, "metric updated to milliseconds"); | ||
| 237 | |||
| 238 | resp = mcp_handle_set_metric("{\"metric\":\"invalid\"}"); | ||
| 239 | ASSERT(!resp.success, "invalid metric rejected"); | ||
| 240 | |||
| 241 | resp = mcp_handle_set_metric("{}"); | ||
| 242 | ASSERT(!resp.success, "missing metric rejected"); | ||
| 243 | } | ||
| 244 | |||
| 245 | static void test_mcp_set_price(void) | ||
| 246 | { | ||
| 247 | printf("\n=== MCP set_price ===\n"); | ||
| 248 | memset(&g_test_config, 0, sizeof(g_test_config)); | ||
| 249 | g_test_config.price_per_step = 21; | ||
| 250 | |||
| 251 | mcp_response_t resp = mcp_handle_set_price("{\"price_per_step\":50}"); | ||
| 252 | ASSERT(resp.success, "set_price succeeds"); | ||
| 253 | ASSERT_EQ_INT(50, g_test_config.price_per_step, "price updated to 50"); | ||
| 254 | |||
| 255 | resp = mcp_handle_set_price("{\"price_per_step\":0}"); | ||
| 256 | ASSERT(!resp.success, "zero price rejected"); | ||
| 257 | |||
| 258 | resp = mcp_handle_set_price("{}"); | ||
| 259 | ASSERT(!resp.success, "missing price rejected"); | ||
| 260 | } | ||
| 261 | |||
| 262 | static void test_mcp_wallet_melt(void) | ||
| 263 | { | ||
| 264 | printf("\n=== MCP wallet_melt ===\n"); | ||
| 265 | g_wallet_melt_rc = ESP_OK; | ||
| 266 | |||
| 267 | mcp_response_t resp = mcp_handle_wallet_melt("{\"bolt11\":\"lnbc100n1...\"}"); | ||
| 268 | ASSERT(resp.success, "wallet_melt succeeds"); | ||
| 269 | |||
| 270 | g_wallet_melt_rc = ESP_FAIL; | ||
| 271 | resp = mcp_handle_wallet_melt("{\"bolt11\":\"lnbc100n1...\"}"); | ||
| 272 | ASSERT(!resp.success, "melt failure reported"); | ||
| 273 | |||
| 274 | resp = mcp_handle_wallet_melt("{}"); | ||
| 275 | ASSERT(!resp.success, "missing bolt11 fails"); | ||
| 276 | } | ||
| 277 | |||
| 138 | int main(void) | 278 | int main(void) |
| 139 | { | 279 | { |
| 140 | printf("=== test_mcp_handler ===\n"); | 280 | printf("=== test_mcp_handler ===\n"); |
| @@ -143,6 +283,12 @@ int main(void) | |||
| 143 | test_mcp_set_config(); | 283 | test_mcp_set_config(); |
| 144 | test_mcp_get_balance(); | 284 | test_mcp_get_balance(); |
| 145 | test_mcp_wallet_send(); | 285 | test_mcp_wallet_send(); |
| 286 | test_mcp_get_sessions(); | ||
| 287 | test_mcp_get_usage(); | ||
| 288 | test_mcp_set_payout(); | ||
| 289 | test_mcp_set_metric(); | ||
| 290 | test_mcp_set_price(); | ||
| 291 | test_mcp_wallet_melt(); | ||
| 146 | test_mcp_dispatch(); | 292 | test_mcp_dispatch(); |
| 147 | TEST_SUMMARY(); | 293 | TEST_SUMMARY(); |
| 148 | } | 294 | } |