upleb.uk

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

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYour Name <you@example.com>2026-05-18 03:37:27 +0530
committerYour Name <you@example.com>2026-05-18 03:37:27 +0530
commit8a2f7a6c9423e0c00fae3c1233bee9e0bb3ae239 (patch)
tree8f8d2ede379b7e3cc0da82d472bcf0eeedcbf03b
parentfe7c3be2fd9d464dbc837d1913409d2691bd50f5 (diff)
feat: ContextVM (MCP over Nostr) server with WS masking fix
- Full CVM server: persistent WS relay listener, kind 25910 subscription - MCP protocol handlers: initialize, tools/list, tools/call, ping - 10 MCP tools: get_config, set_config, get_balance, wallet_send, get_sessions, get_usage, set_payout, set_metric, set_price, wallet_melt - CEP-6 announcements via WS (kinds 11316, 11317, 10002) - Auth check: owner npub only - Fix: WebSocket client-to-server frame masking (RFC 6455 requirement) - Fix: Raw event JSON in EVENT wrapper (no re-parsing that breaks sig) - SNTP init after STA gets IP - 282 unit tests passing (61 CVM + 60 MCP handler + 161 existing) - Integration test scaffold: tests/integration/test-cvm.mjs
-rw-r--r--CHECKLIST.md35
-rw-r--r--Makefile41
-rw-r--r--PLAN.md175
-rw-r--r--main/CMakeLists.txt4
-rw-r--r--main/config.c4
-rw-r--r--main/cvm_server.c727
-rw-r--r--main/cvm_server.h4
-rw-r--r--main/mcp_handler.c236
-rw-r--r--main/mcp_handler.h12
-rw-r--r--main/session.c10
-rw-r--r--main/session.h3
-rw-r--r--main/tollgate_main.c17
-rw-r--r--tests/integration/test-cvm.mjs94
-rw-r--r--tests/unit/Makefile5
-rw-r--r--tests/unit/test_cvm_server.c434
-rw-r--r--tests/unit/test_mcp_handler.c146
16 files changed, 1785 insertions, 162 deletions
diff --git a/CHECKLIST.md b/CHECKLIST.md
index c5dfbe4..4e4411a 100644
--- a/CHECKLIST.md
+++ b/CHECKLIST.md
@@ -48,10 +48,26 @@
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 — IN PROGRESS
57- [ ] Add 6 new tools to mcp_handler.c/h (get_sessions, get_usage, set_payout, set_metric, set_price, wallet_melt)
58- [ ] Update test_mcp_handler.c with tests for 6 new tools
59- [ ] Rewrite cvm_server.c: persistent WebSocket listener, kind 25910 subscription
60- [ ] MCP protocol handlers: initialize, notifications/initialized, tools/list, tools/call, ping
61- [ ] Auth check: only accept from owner npub
62- [ ] CEP-6: publish kind 11316 server announcement on startup
63- [ ] CEP-6: publish kind 11317 tools list on startup
64- [ ] CEP-17: publish kind 10002 relay list on startup
65- [ ] Update config.c: default cvm_enabled = true
66- [ ] Create test_cvm_server.c unit test (event parsing, announcement construction, auth)
67- [ ] Update tests/unit/Makefile with test_cvm_server target
68- [ ] Create tests/integration/test-cvm.mjs (nak-based integration test)
69- [ ] Update Makefile with cvm-* targets (test-cvm, cvm-pubkey, cvm-test-tool)
70- [ ] Verify on contextvm.org/servers with board's npub
55 71
56## Bug Fixes — COMPLETE (commit `3342c8e`) 72## Bug Fixes — COMPLETE (commit `3342c8e`)
57- [x] reset_auth, /usage, metric default, sys_evt stack overflow fixes 73- [x] reset_auth, /usage, metric default, sys_evt stack overflow fixes
@@ -78,6 +94,21 @@
78- [x] Update `tests/unit/test_session.c` 94- [x] Update `tests/unit/test_session.c`
79- [x] 186 unit tests passing 95- [x] 186 unit tests passing
80 96
97## TFT Display (JC3248W535 / AXS15231B) — IN PROGRESS
98- [x] Create QR code component (port qrcoded from NSD, fix bool/pragma/comparison warnings)
99- [x] Create AXS15231B QSPI display driver component (init sequence, PSRAM framebuffer, chunked flush)
100- [x] Create 8x8 bitmap font (ASCII 32-127)
101- [x] Create display abstraction layer (display.h/c — boot/ready/payment/error states)
102- [x] Integrate display into tollgate_main.c and main/CMakeLists.txt
103- [x] Build succeeds (binary 1.2MB, 71% free in partition)
104- [x] Wi-Fi QR code encoding: `WIFI:S:<escaped_ssid>;T:nopass;;` with special char escaping (`\;:,"`)
105- [x] QR cycling: alternate between Wi-Fi QR and portal URL QR every 5 seconds
106- [ ] Flash to JC3248W535 board at `/dev/ttyACM0` and test
107- [ ] Verify Wi-Fi QR is scannable by Android/iOS camera
108- [ ] Verify portal URL QR is scannable and loads captive portal
109- [ ] Add unit tests for QR generation and escape_wifi_field()
110- [ ] Update AGENTS.md with display module docs
111
81--- 112---
82 113
83## TODO — Remaining 114## TODO — Remaining
diff --git a/Makefile b/Makefile
index 40f0e7b..c84acdf 100644
--- a/Makefile
+++ b/Makefile
@@ -23,9 +23,10 @@ TOLLGATE_IP ?= 10.192.45.1
23.PHONY: flash flash-a flash-b monitor monitor-a monitor-b 23.PHONY: flash flash-a flash-b monitor monitor-a monitor-b
24.PHONY: test test-unit test-integration test-e2e test-all 24.PHONY: test test-unit test-integration test-e2e test-all
25.PHONY: test-smoke test-api test-network test-portal test-payment 25.PHONY: test-smoke test-api test-network test-portal test-payment
26.PHONY: test-reset-auth test-session-expiry test-dns-firewall 26.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 27.PHONY: tokens wallet-setup wallet-info wallet-balance mint-token send-token
28.PHONY: clean erase-nvs reset serial-log bootstrap-config 28.PHONY: clean erase-nvs reset serial-log bootstrap-config
29.PHONY: cvm-pubkey cvm-test-tool cvm-announce
29 30
30help: 31help:
31 @echo "TollGate ESP32 — Makefile" 32 @echo "TollGate ESP32 — Makefile"
@@ -50,6 +51,12 @@ help:
50 @echo " test-reset-auth Reset auth + per-client NAT filter test" 51 @echo " test-reset-auth Reset auth + per-client NAT filter test"
51 @echo " test-dns-firewall DNS hijack + NAT filter test" 52 @echo " test-dns-firewall DNS hijack + NAT filter test"
52 @echo " test-session-expiry Session lifecycle with 65s expiry wait" 53 @echo " test-session-expiry Session lifecycle with 65s expiry wait"
54 @echo " test-cvm ContextVM protocol integration test"
55 @echo ""
56 @echo "ContextVM:"
57 @echo " cvm-pubkey Print board's ContextVM npub"
58 @echo " cvm-announce Trigger re-publish of CEP-6 announcements"
59 @echo " cvm-test-tool Send single MCP tools/call (METHOD=get_config)"
53 @echo "" 60 @echo ""
54 @echo "Wallet:" 61 @echo "Wallet:"
55 @echo " wallet-setup Initialize nutshell wallet for test mint" 62 @echo " wallet-setup Initialize nutshell wallet for test mint"
@@ -153,7 +160,7 @@ test-unit:
153 @echo "=== Running host unit tests ===" 160 @echo "=== Running host unit tests ==="
154 $(MAKE) -C tests/unit test 161 $(MAKE) -C tests/unit test
155 162
156test-integration: test-api test-network test-reset-auth test-dns-firewall 163test-integration: test-api test-network test-reset-auth test-dns-firewall test-cvm
157 @echo "=== Integration tests passed ===" 164 @echo "=== Integration tests passed ==="
158 165
159test-e2e: 166test-e2e:
@@ -198,6 +205,10 @@ test-dns-firewall:
198 @echo "=== Running DNS + firewall test ===" 205 @echo "=== Running DNS + firewall test ==="
199 TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/test-dns-firewall.mjs 206 TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/test-dns-firewall.mjs
200 207
208test-cvm:
209 @echo "=== Running CVM integration test ==="
210 TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/test-cvm.mjs
211
201# ────────────────────────────────────────────── 212# ──────────────────────────────────────────────
202# Wallet 213# Wallet
203# ────────────────────────────────────────────── 214# ──────────────────────────────────────────────
@@ -230,6 +241,32 @@ send-token:
230tokens: send-token 241tokens: send-token
231 242
232# ────────────────────────────────────────────── 243# ──────────────────────────────────────────────
244# ContextVM
245# ──────────────────────────────────────────────
246
247cvm-pubkey:
248 @echo "=== Board ContextVM npub ==="
249 @nak key public a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 | xargs -I{} nak encode npub {}
250 @echo ""
251 @echo "Search for this npub on https://contextvm.org/servers"
252
253cvm-announce:
254 @echo "=== Triggering CEP-6 re-announcement ==="
255 curl -s http://$(TOLLGATE_IP):2121/ | head -1 || echo "Board not reachable"
256
257cvm-test-tool:
258 @METHOD=$${METHOD:-get_config}; \
259 PARAMS=$${PARAMS:-{}}; \
260 echo "=== Calling $$METHOD via CVM ==="; \
261 NPUB_HEX=$$(nak key public a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2); \
262 CONTENT="$$(echo "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"$$METHOD\",\"arguments\":$$PARAMS}}" | jq -c .)"; \
263 EVENT_JSON="$$(nak event --kind 25910 --tag p=$$NPUB_HEX --content "$$CONTENT" wss://relay.damus.io 2>/dev/null)"; \
264 echo "Published: $$EVENT_JSON"; \
265 echo "Waiting for response..."; \
266 sleep 3; \
267 nak req -k 25910 -a $$NPUB_HEX -l 5 wss://relay.damus.io
268
269# ──────────────────────────────────────────────
233# Utilities 270# Utilities
234# ────────────────────────────────────────────── 271# ──────────────────────────────────────────────
235 272
diff --git a/PLAN.md b/PLAN.md
index 5690c1b..5bd12f9 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -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
484ContextVM uses MCP (JSON-RPC 2.0) over NIP-44 encrypted Nostr DMs: 484```
4851. ESP32 subscribes to Nostr relays for DMs addressed to its npub 485Client (nak/ContextVM SDK)
4862. 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"}})
4873. Dispatched to registered tool handlers 487 → ESP32 cvm_server reads from persistent WebSocket subscription
4884. 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 5051. ESP32 publishes kind 11316 (server announcement) + kind 11317 (tools list) + kind 10002 (relay list) on startup
5062. ESP32 opens persistent WebSocket to relays, subscribes to `{"kinds":[25910],"#p":["<npub>"]}`
5073. Client sends kind 25910 `initialize` request
5084. ESP32 responds with kind 25910 `initialize` result (capabilities, serverInfo)
5095. Client sends `notifications/initialized`
5106. Client calls `tools/list` or `tools/call`
5117. ESP32 dispatches to `mcp_handler.c`, returns result
512
513#### Encryption
514
515Phase 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,60 @@ 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
507Only accept commands from owner npub (derived from nsec in config.json). 534Only 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 | TODO |
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 | TODO |
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 | TODO |
523| 56 | Balance query via CVM | Integration | Returns correct balance | TODO | 565| 56 | Kind 10002 relay list construction | Unit test | Correct `r` tags | TODO |
524 566| 57 | Auth rejection for non-owner | Unit test | Non-owner events dropped | TODO |
525## Total: 56 Tests across 7 phases 567| 58 | MCP initialize response | Unit test | Correct capabilities + serverInfo | TODO |
568| 59 | New tool: get_sessions | Unit test | Returns session array | TODO |
569| 60 | New tool: get_usage | Unit test | Returns usage stats | TODO |
570| 61 | New tool: set_payout | Unit test | Updates payout config | TODO |
571| 62 | New tool: set_metric | Unit test | Updates metric field | TODO |
572| 63 | New tool: set_price | Unit test | Updates price_per_step | TODO |
573| 64 | New tool: wallet_melt | Unit test | Calls nucula_wallet_melt | TODO |
574| 65 | Kind 11316 on relay | Integration | Announcement found on relay | TODO |
575| 66 | MCP initialize roundtrip | Integration | Response received via nak | TODO |
576| 67 | get_config via CVM | Integration | Returns valid JSON config | TODO |
577| 68 | get_balance via CVM | Integration | Returns balance + proofs | TODO |
578| 69 | set_price via CVM | Integration | Price updated on device | TODO |
579
580## Total: 78 Tests across 8 phases
526 581
527## Post-Phase 7: Bug Fixes & Architecture Improvements 582## Post-Phase 7: Bug Fixes & Architecture Improvements
528 583
@@ -591,6 +646,78 @@ int tollgate_ip4_canforward_filter(struct pbuf *p, u32_t dest_addr_hostorder) {
591 646
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. 647**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 648
649### Phase 8: TFT Display (JC3248W535 / AXS15231B) — IN PROGRESS
650
651**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).
652
653**Hardware:** JC3248W535 board — ESP32-S3, AXS15231B 320x480 QSPI TFT, capacitive touch
654**Pin mapping:** CS=45, CLK=47, D0=21, D1=48, D2=40, D3=39, BL=1, Touch SDA=4, Touch SCL=8
655
656#### Components Created
657
658| Component | Path | Purpose |
659|-----------|------|---------|
660| `components/qrcode/` | `qrcoded.c/h` + CMakeLists.txt | QR code generation (ported from NSD, MIT license) |
661| `components/axs15231b/` | `axs15231b.c/h` + CMakeLists.txt | AXS15231B QSPI display driver |
662| `main/display.c/h` | Display abstraction | FreeRTOS display task, state machine, QR cycling |
663| `main/font.c/h` | 8x8 bitmap font | ASCII 32-127 for status text rendering |
664
665#### Display States
666
667| State | Screen | QR Content |
668|-------|--------|------------|
669| `DISPLAY_BOOT` | "TollGate starting..." | None |
670| `DISPLAY_READY` | QR code + SSID label | Cycles: Wi-Fi QR ↔ Portal URL QR every 5s |
671| `DISPLAY_PAYMENT_RECEIVED` | Green "Paid! Access granted" | None (2s, then READY) |
672| `DISPLAY_ERROR` | Red "No upstream" | None |
673
674#### Wi-Fi QR Code Format
675
676Uses the standardized ZXing `WIFI:` URI scheme — natively recognized by Android and iOS camera apps:
677
678```
679WIFI:S:<escaped_ssid>;T:nopass;;
680```
681
682**Special character escaping**: `;`, `:`, `\`, `,`, `"` are backslash-escaped in the SSID field per spec.
683
684**Example:**
685```
686SSID: TollGate-C0E9CA → WIFI:S:TollGate-C0E9CA;T:nopass;;
687SSID: My;WiFi:Test → WIFI:S:My\;WiFi\:Test;T:nopass;;
688```
689
690When scanned, the phone **automatically connects** to the TollGate AP — then the captive portal takes over for payment.
691
692#### QR Cycling
693
694The display alternates between two QR modes every 5 seconds:
6951. **Wi-Fi QR** — `WIFI:S:...;T:nopass;;` — auto-connects phone to AP
6962. **Portal URL QR** — `http://10.x.x.1/` — direct link to captive portal
697
698Label text below QR changes to indicate current mode: "Scan to connect" vs "Portal URL".
699
700#### Display Driver Architecture
701
702- **Interface**: Single-line SPI (MOSI on D0/GPIO21) — simpler than QSPI, reliable for V1
703- **Framebuffer**: 307,200 bytes (480x320x2 RGB565) in PSRAM via `heap_caps_malloc`
704- **Flush**: 10 chunks of 32KB via `spi_device_polling_transmit`
705- **Rotation**: Landscape (MADCTL=0x60, MX|MV)
706- **Backlight**: GPIO1 active-high
707
708#### Test Cases
709
710| # | Test | Method | Pass Criteria | Status |
711|---|------|--------|---------------|--------|
712| 70 | Wi-Fi QR scannable | Android camera scan | Phone connects to AP | TODO |
713| 71 | Portal URL QR scannable | Android camera scan | Browser opens portal | TODO |
714| 72 | QR cycling | Watch display | Mode changes every 5s | TODO |
715| 73 | Boot screen | Visual | "TollGate starting..." shown | TODO |
716| 74 | Payment screen | Trigger payment | Green "Paid!" for 2s | TODO |
717| 75 | Error screen | Disconnect upstream | Red "No upstream" | TODO |
718| 76 | Special char escape | Unit test | `\;:,"` correctly escaped | TODO |
719| 77 | QR generation | Unit test | Valid QR matrix for various string lengths | TODO |
720
594## Testing Infrastructure 721## Testing Infrastructure
595 722
596### Three-Layer Test Architecture 723### 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..7c8ebed 100644
--- a/main/config.c
+++ b/main/config.c
@@ -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",
diff --git a/main/cvm_server.c b/main/cvm_server.c
index 5addd88..cf052df 100644
--- a/main/cvm_server.c
+++ b/main/cvm_server.c
@@ -2,217 +2,679 @@
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"
10#include "freertos/FreeRTOS.h" 14#include "freertos/FreeRTOS.h"
11#include "freertos/task.h" 15#include "freertos/task.h"
12#include <string.h> 16#include <string.h>
13#include <stdio.h> 17#include <stdio.h>
18#include <stdlib.h>
14 19
15static const char *TAG = "cvm_server"; 20static const char *TAG = "cvm_server";
16 21
17static bool g_running = false; 22static bool g_running = false;
18static TaskHandle_t g_task = NULL; 23static TaskHandle_t g_task = NULL;
19 24
20static const char *DEFAULT_RELAY = "wss://relay.damus.io"; 25static void publish_announcements_via_ws(esp_tls_t *tls);
21 26
22static char *fetch_relays(void) 27#define CVM_VERSION "2025-07-02"
28#define CVM_SERVER_NAME "TollGate"
29#define CVM_SERVER_VERSION "1.0.0"
30#define CVM_WS_BUF_SIZE 8192
31#define CVM_MAX_RESPONSE_SIZE 4096
32#define CVM_RECONNECT_DELAY_MS 5000
33
34static char *parse_ws_text_frame(const uint8_t *buf, int len)
23{ 35{
24 const tollgate_config_t *cfg = tollgate_config_get(); 36 if (len < 2) return NULL;
25 if (cfg && cfg->cvm_relays[0]) { 37 bool masked = (buf[1] & 0x80) != 0;
26 return cfg->cvm_relays; 38 uint64_t payload_len = buf[1] & 0x7F;
39 int offset = 2;
40
41 if (payload_len == 126) {
42 if (len < 4) return NULL;
43 payload_len = ((uint64_t)buf[2] << 8) | buf[3];
44 offset = 4;
45 } else if (payload_len == 127) {
46 if (len < 10) return NULL;
47 payload_len = 0;
48 for (int i = 0; i < 8; i++)
49 payload_len = (payload_len << 8) | buf[2 + i];
50 offset = 10;
51 }
52
53 if (masked) offset += 4;
54 if (offset + payload_len > (uint64_t)len) return NULL;
55
56 char *text = malloc((size_t)payload_len + 1);
57 if (!text) return NULL;
58
59 if (masked) {
60 uint8_t mask[4] = { buf[offset - 4], buf[offset - 3], buf[offset - 2], buf[offset - 1] };
61 for (uint64_t i = 0; i < payload_len; i++)
62 text[i] = buf[offset + i] ^ mask[i & 3];
63 } else {
64 memcpy(text, buf + offset, (size_t)payload_len);
27 } 65 }
28 return (char *)DEFAULT_RELAY; 66 text[payload_len] = '\0';
67 return text;
29} 68}
30 69
31static char *http_get(const char *url, int timeout_ms) 70static int ws_send_text(esp_tls_t *tls, const char *msg)
32{ 71{
33 char *buf = malloc(8192); 72 size_t len = strlen(msg);
34 if (!buf) return NULL; 73 uint8_t mask[4];
35 int total = 0; 74 esp_fill_random(mask, 4);
75
76 size_t frame_len = 6 + len;
77 if (len > 125) frame_len += 2;
78 if (len > 65535) frame_len += 6;
79
80 uint8_t *frame = malloc(frame_len + len);
81 if (!frame) return -1;
82
83 int pos = 0;
84 frame[pos++] = 0x81;
85 if (len <= 125) {
86 frame[pos++] = (uint8_t)(0x80 | len);
87 } else if (len <= 65535) {
88 frame[pos++] = 0x80 | 126;
89 frame[pos++] = (uint8_t)((len >> 8) & 0xff);
90 frame[pos++] = (uint8_t)(len & 0xff);
91 } else {
92 frame[pos++] = 0x80 | 127;
93 for (int i = 0; i < 8; i++)
94 frame[pos++] = (uint8_t)((len >> (56 - i * 8)) & 0xff);
95 }
96 memcpy(frame + pos, mask, 4);
97 pos += 4;
98
99 for (size_t i = 0; i < len; i++)
100 frame[pos + i] = (uint8_t)msg[i] ^ mask[i & 3];
101 pos += len;
36 102
37 esp_http_client_config_t config = { 103 int w = esp_tls_conn_write(tls, frame, pos);
38 .url = url, 104 free(frame);
39 .method = HTTP_METHOD_GET, 105 return w > 0 ? 0 : -1;
40 .timeout_ms = timeout_ms, 106}
107
108static esp_err_t ws_connect(const char *relay_url, esp_tls_t **tls_out)
109{
110 char host[128] = {0};
111 int port = 443;
112 char path[128] = "/";
113
114 if (strncmp(relay_url, "wss://", 6) != 0) return ESP_ERR_INVALID_ARG;
115
116 const char *url_start = relay_url + 6;
117 const char *path_ptr = strchr(url_start, '/');
118 if (path_ptr) {
119 size_t host_len = path_ptr - url_start;
120 if (host_len >= sizeof(host)) host_len = sizeof(host) - 1;
121 memcpy(host, url_start, host_len);
122 host[host_len] = '\0';
123 strncpy(path, path_ptr, sizeof(path) - 1);
124 } else {
125 strncpy(host, url_start, sizeof(host) - 1);
126 }
127
128 char *colon = strchr(host, ':');
129 if (colon) {
130 *colon = '\0';
131 port = atoi(colon + 1);
132 }
133
134 esp_tls_cfg_t tls_cfg = {
135 .crt_bundle_attach = esp_crt_bundle_attach,
136 .timeout_ms = 15000,
41 }; 137 };
42 esp_http_client_handle_t client = esp_http_client_init(&config); 138 esp_tls_t *tls = esp_tls_init();
43 if (!client) { free(buf); return NULL; } 139 if (!tls) return ESP_ERR_NO_MEM;
44 140
45 esp_err_t err = esp_http_client_open(client, 0); 141 int ret = esp_tls_conn_new_sync(host, strlen(host), port, &tls_cfg, tls);
46 if (err != ESP_OK) { 142 if (ret < 0) {
47 esp_http_client_cleanup(client); 143 esp_tls_conn_destroy(tls);
48 free(buf); 144 return ESP_FAIL;
49 return NULL;
50 } 145 }
51 146
52 int content_length = esp_http_client_fetch_headers(client); 147 char upgrade[512];
53 int max_read = content_length > 0 ? content_length : 8191; 148 snprintf(upgrade, sizeof(upgrade),
149 "GET %s HTTP/1.1\r\n"
150 "Host: %s\r\n"
151 "Upgrade: websocket\r\n"
152 "Connection: Upgrade\r\n"
153 "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
154 "Sec-WebSocket-Version: 13\r\n"
155 "\r\n",
156 path, host);
157
158 int written = esp_tls_conn_write(tls, (const unsigned char *)upgrade, strlen(upgrade));
159 if (written < 0) {
160 esp_tls_conn_destroy(tls);
161 return ESP_FAIL;
162 }
54 163
55 while (total < max_read) { 164 char resp[1024];
56 int n = esp_http_client_read(client, buf + total, max_read - total); 165 int rlen = esp_tls_conn_read(tls, (unsigned char *)resp, sizeof(resp) - 1);
57 if (n <= 0) break; 166 if (rlen <= 0 || !strstr(resp, "101")) {
58 total += n; 167 ESP_LOGE(TAG, "WS upgrade failed to %s (read %d)", host, rlen);
168 esp_tls_conn_destroy(tls);
169 return ESP_FAIL;
59 } 170 }
60 buf[total] = '\0'; 171
61 esp_http_client_cleanup(client); 172 *tls_out = tls;
62 return buf; 173 ESP_LOGI(TAG, "Connected to %s", host);
174 return ESP_OK;
63} 175}
64 176
65static cJSON *build_filter(const char *npub) 177static cJSON *build_tools_list(void)
66{ 178{
67 cJSON *filter = cJSON_CreateObject(); 179 cJSON *tools = cJSON_CreateArray();
68 cJSON *kinds = cJSON_CreateArray(); 180
69 cJSON_AddItemToArray(kinds, cJSON_CreateNumber(4)); 181 const char *tool_defs[][3] = {
70 cJSON_AddItemToObject(filter, "kinds", kinds); 182 {"get_config", "Get current device configuration", "{\"type\":\"object\",\"properties\":{},\"required\":[]}"},
71 cJSON_AddStringToObject(filter, "#p", npub); 183 {"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); 184 {"get_balance", "Get wallet balance and proof count", "{\"type\":\"object\",\"properties\":{},\"required\":[]}"},
73 return filter; 185 {"wallet_send", "Send e-cash tokens from wallet", "{\"type\":\"object\",\"properties\":{\"amount\":{\"type\":\"integer\",\"description\":\"Amount in sats\"}},\"required\":[\"amount\"]}"},
186 {"get_sessions","Get active client sessions", "{\"type\":\"object\",\"properties\":{},\"required\":[]}"},
187 {"get_usage", "Get current billing usage info", "{\"type\":\"object\",\"properties\":{},\"required\":[]}"},
188 {"set_payout", "Configure payout recipients", "{\"type\":\"object\",\"properties\":{\"enabled\":{\"type\":\"boolean\"},\"recipients\":{\"type\":\"array\"}}}"},
189 {"set_metric", "Set billing metric", "{\"type\":\"object\",\"properties\":{\"metric\":{\"type\":\"string\",\"enum\":[\"bytes\",\"milliseconds\"]}},\"required\":[\"metric\"]}"},
190 {"set_price", "Set price per step", "{\"type\":\"object\",\"properties\":{\"price_per_step\":{\"type\":\"integer\",\"minimum\":1}},\"required\":[\"price_per_step\"]}"},
191 {"wallet_melt", "Melt tokens for lightning payment", "{\"type\":\"object\",\"properties\":{\"bolt11\":{\"type\":\"string\"},\"max_fee_sats\":{\"type\":\"integer\"}},\"required\":[\"bolt11\"]}"},
192 };
193
194 for (int i = 0; i < 10; i++) {
195 cJSON *tool = cJSON_CreateObject();
196 cJSON_AddStringToObject(tool, "name", tool_defs[i][0]);
197 cJSON_AddStringToObject(tool, "description", tool_defs[i][1]);
198 cJSON *schema = cJSON_Parse(tool_defs[i][2]);
199 if (schema) cJSON_AddItemToObject(tool, "inputSchema", schema);
200 cJSON_AddItemToArray(tools, tool);
201 }
202
203 return tools;
74} 204}
75 205
76static cJSON *build_subscription(const char *npub) 206static char *build_initialize_response(const char *request_id_str, const char *client_pubkey)
77{ 207{
78 cJSON *sub = cJSON_CreateArray(); 208 cJSON *response = cJSON_CreateObject();
79 cJSON_AddItemToArray(sub, cJSON_CreateString("REQ")); 209 cJSON_AddStringToObject(response, "jsonrpc", "2.0");
80 cJSON_AddItemToArray(sub, cJSON_CreateString("cvm_sub_01")); 210 cJSON_AddNumberToObject(response, "id", request_id_str ? atof(request_id_str) : 0);
81 cJSON_AddItemToArray(sub, build_filter(npub)); 211
82 return sub; 212 cJSON *result = cJSON_CreateObject();
213 cJSON_AddStringToObject(result, "protocolVersion", CVM_VERSION);
214
215 cJSON *capabilities = cJSON_CreateObject();
216 cJSON_AddItemToObject(capabilities, "tools", cJSON_CreateObject());
217 cJSON_AddItemToObject(result, "capabilities", capabilities);
218
219 cJSON *serverInfo = cJSON_CreateObject();
220 cJSON_AddStringToObject(serverInfo, "name", CVM_SERVER_NAME);
221 cJSON_AddStringToObject(serverInfo, "version", CVM_SERVER_VERSION);
222 cJSON_AddItemToObject(result, "serverInfo", serverInfo);
223
224 cJSON_AddItemToObject(response, "result", result);
225
226 char *json = cJSON_PrintUnformatted(response);
227 cJSON_Delete(response);
228 return json;
83} 229}
84 230
85static void process_dm(const char *sender_pubkey, const char *encrypted_content) 231static char *build_tools_list_response(const char *request_id_str)
232{
233 cJSON *response = cJSON_CreateObject();
234 cJSON_AddStringToObject(response, "jsonrpc", "2.0");
235 cJSON_AddNumberToObject(response, "id", request_id_str ? atof(request_id_str) : 1);
236
237 cJSON *result = cJSON_CreateObject();
238 cJSON *tools = build_tools_list();
239 cJSON_AddItemToObject(result, "tools", tools);
240 cJSON_AddItemToObject(response, "result", result);
241
242 char *json = cJSON_PrintUnformatted(response);
243 cJSON_Delete(response);
244 return json;
245}
246
247static char *build_tool_call_response(const char *request_id_str, const mcp_response_t *mcp_resp)
248{
249 cJSON *response = cJSON_CreateObject();
250 cJSON_AddStringToObject(response, "jsonrpc", "2.0");
251 cJSON_AddNumberToObject(response, "id", request_id_str ? atof(request_id_str) : 2);
252
253 if (mcp_resp->success) {
254 cJSON *result = cJSON_CreateObject();
255 cJSON_AddItemToObject(result, "content", cJSON_CreateArray());
256 cJSON *content_arr = cJSON_GetObjectItem(result, "content");
257 cJSON *text_item = cJSON_CreateObject();
258 cJSON_AddStringToObject(text_item, "type", "text");
259 cJSON_AddStringToObject(text_item, "text", mcp_resp->result_json);
260 cJSON_AddItemToArray(content_arr, text_item);
261 cJSON_AddBoolToObject(result, "isError", false);
262 cJSON_AddItemToObject(response, "result", result);
263 } else {
264 cJSON *error = cJSON_CreateObject();
265 cJSON_AddNumberToObject(error, "code", -32603);
266 cJSON_AddStringToObject(error, "message", mcp_resp->error);
267 cJSON_AddItemToObject(response, "error", error);
268 }
269
270 char *json = cJSON_PrintUnformatted(response);
271 cJSON_Delete(response);
272 return json;
273}
274
275static char *build_ping_response(const char *request_id_str)
276{
277 cJSON *response = cJSON_CreateObject();
278 cJSON_AddStringToObject(response, "jsonrpc", "2.0");
279 cJSON_AddNumberToObject(response, "id", request_id_str ? atof(request_id_str) : 0);
280 cJSON *result = cJSON_CreateObject();
281 cJSON_AddItemToObject(response, "result", result);
282 char *json = cJSON_PrintUnformatted(response);
283 cJSON_Delete(response);
284 return json;
285}
286
287static esp_err_t publish_event_to_relay(const char *relay_url, const char *event_json)
288{
289 esp_tls_t *tls = NULL;
290 esp_err_t err = ws_connect(relay_url, &tls);
291 if (err != ESP_OK) return err;
292
293 char *msg;
294 size_t event_len2 = strlen(event_json);
295 size_t msg_len2 = 8 + event_len2 + 1;
296 msg = malloc(msg_len2);
297 snprintf(msg, msg_len2, "[\"EVENT\",%s]", event_json);
298
299 ws_send_text(tls, msg);
300 free(msg);
301
302 uint8_t resp_buf[256];
303 esp_tls_conn_read(tls, resp_buf, sizeof(resp_buf) - 1);
304
305 uint8_t close_frame[2] = {0x88, 0x00};
306 esp_tls_conn_write(tls, close_frame, 2);
307 esp_tls_conn_destroy(tls);
308 return ESP_OK;
309}
310
311static esp_err_t publish_kind_25910_response(const char *relay_url,
312 const char *content_json,
313 const char *request_event_id)
86{ 314{
87 const tollgate_identity_t *id = identity_get(); 315 const tollgate_identity_t *id = identity_get();
88 if (!id || !id->initialized) { 316 if (!id || !id->initialized) return ESP_FAIL;
89 ESP_LOGE(TAG, "Identity not initialized"); 317
318 cJSON *tags = cJSON_CreateArray();
319 cJSON *e_tag = cJSON_CreateArray();
320 cJSON_AddItemToArray(e_tag, cJSON_CreateString("e"));
321 cJSON_AddItemToArray(e_tag, cJSON_CreateString(request_event_id));
322 cJSON_AddItemToArray(tags, e_tag);
323
324 char *tags_str = cJSON_PrintUnformatted(tags);
325 cJSON_Delete(tags);
326
327 nostr_event_t event;
328 nostr_event_init(&event, id->npub_hex, 25910, tags_str, content_json);
329 nostr_event_sign(&event, id->nsec);
330 free(tags_str);
331
332 char *event_json = malloc(8192);
333 if (!event_json) return ESP_ERR_NO_MEM;
334
335 esp_err_t ret = nostr_event_to_json(&event, event_json, 8192);
336 if (ret != ESP_OK) {
337 free(event_json);
338 return ret;
339 }
340
341 ret = publish_event_to_relay(relay_url, event_json);
342 free(event_json);
343 return ret;
344}
345
346static bool is_owner_pubkey(const char *pubkey_hex)
347{
348 const tollgate_identity_t *id = identity_get();
349 if (!id || !id->initialized) return false;
350 if (!pubkey_hex) return false;
351 return strcmp(id->npub_hex, pubkey_hex) == 0;
352}
353
354static void handle_mcp_message(const char *relay_url, const char *sender_pubkey,
355 const char *event_id, const char *content)
356{
357 cJSON *msg = cJSON_Parse(content);
358 if (!msg) {
359 ESP_LOGW(TAG, "Invalid JSON in kind 25910 content");
90 return; 360 return;
91 } 361 }
92 362
93 uint8_t sender_pk[64]; 363 cJSON *method = cJSON_GetObjectItem(msg, "method");
94 for (int i = 0; i < 64; i++) { 364 cJSON *id_field = cJSON_GetObjectItem(msg, "id");
95 char hex[3] = { sender_pubkey[i*2], sender_pubkey[i*2+1], 0 }; 365 const char *id_str = (id_field && cJSON_IsNumber(id_field))
96 sender_pk[i] = (uint8_t)strtol(hex, NULL, 16); 366 ? cJSON_PrintUnformatted(id_field) : "0";
367
368 if (method && cJSON_IsString(method)) {
369 const char *m = method->valuestring;
370
371 if (strcmp(m, "initialize") == 0) {
372 ESP_LOGI(TAG, "MCP initialize from %s", sender_pubkey);
373 char *resp = build_initialize_response(id_str, sender_pubkey);
374 publish_kind_25910_response(relay_url, resp, event_id);
375 free(resp);
376 } else if (strcmp(m, "notifications/initialized") == 0) {
377 ESP_LOGI(TAG, "Client initialized: %s", sender_pubkey);
378 } else if (strcmp(m, "tools/list") == 0) {
379 ESP_LOGI(TAG, "tools/list from %s", sender_pubkey);
380 char *resp = build_tools_list_response(id_str);
381 publish_kind_25910_response(relay_url, resp, event_id);
382 free(resp);
383 } else if (strcmp(m, "tools/call") == 0) {
384 cJSON *params = cJSON_GetObjectItem(msg, "params");
385 cJSON *name = params ? cJSON_GetObjectItem(params, "name") : NULL;
386 cJSON *arguments = params ? cJSON_GetObjectItem(params, "arguments") : NULL;
387
388 if (name && cJSON_IsString(name)) {
389 ESP_LOGI(TAG, "tools/call %s from %s", name->valuestring, sender_pubkey);
390
391 mcp_request_t req = {0};
392 req.tool = mcp_parse_tool(name->valuestring);
393 strncpy(req.method, name->valuestring, sizeof(req.method) - 1);
394 if (arguments) {
395 char *ajson = cJSON_PrintUnformatted(arguments);
396 strncpy(req.params_json, ajson, sizeof(req.params_json) - 1);
397 cJSON_free(ajson);
398 }
399
400 mcp_response_t mcp_resp = mcp_dispatch(&req);
401 char *resp = build_tool_call_response(id_str, &mcp_resp);
402 publish_kind_25910_response(relay_url, resp, event_id);
403 free(resp);
404 }
405 } else if (strcmp(m, "ping") == 0) {
406 char *resp = build_ping_response(id_str);
407 publish_kind_25910_response(relay_url, resp, event_id);
408 free(resp);
409 } else {
410 ESP_LOGW(TAG, "Unknown MCP method: %s", m);
411 }
412 }
413
414 if (id_field && cJSON_IsNumber(id_field) && id_str[0] != '0') {
415 free((void *)id_str);
416 } else if (id_str[0] != '0') {
97 } 417 }
418 cJSON_Delete(msg);
419}
98 420
99 char plaintext[2048]; 421static void process_relay_message(const char *relay_url, const char *msg_str)
100 int pt_len = nip04_decrypt(id->nsec, sender_pk, encrypted_content, plaintext, sizeof(plaintext)); 422{
101 if (pt_len < 0) { 423 cJSON *arr = cJSON_Parse(msg_str);
102 ESP_LOGE(TAG, "Failed to decrypt DM from %.8s", sender_pubkey); 424 if (!arr || !cJSON_IsArray(arr)) {
425 if (arr) cJSON_Delete(arr);
103 return; 426 return;
104 } 427 }
105 428
106 ESP_LOGI(TAG, "Decrypted DM from %.8s: %s", sender_pubkey, plaintext); 429 cJSON *cmd = cJSON_GetArrayItem(arr, 0);
430 if (!cmd || !cJSON_IsString(cmd)) {
431 cJSON_Delete(arr);
432 return;
433 }
107 434
108 cJSON *msg = cJSON_Parse(plaintext); 435 if (strcmp(cmd->valuestring, "OK") == 0) {
109 if (!msg) { 436 cJSON *ev_id = cJSON_GetArrayItem(arr, 1);
110 ESP_LOGE(TAG, "Invalid JSON in DM"); 437 cJSON *ok = cJSON_GetArrayItem(arr, 2);
438 cJSON *reason = cJSON_GetArrayItem(arr, 3);
439 ESP_LOGI(TAG, "Relay OK: id=%.16s success=%s reason=%s",
440 ev_id ? ev_id->valuestring : "?",
441 ok ? (cJSON_IsTrue(ok) ? "true" : "FALSE") : "?",
442 reason ? reason->valuestring : "");
443 cJSON_Delete(arr);
111 return; 444 return;
112 } 445 }
113 446
114 cJSON *method = cJSON_GetObjectItem(msg, "method"); 447 if (strcmp(cmd->valuestring, "EVENT") != 0) {
115 cJSON *params = cJSON_GetObjectItem(msg, "params"); 448 ESP_LOGI(TAG, "Relay msg: %.100s", msg_str);
116 if (!method || !cJSON_IsString(method)) { 449 cJSON_Delete(arr);
117 cJSON_Delete(msg);
118 ESP_LOGE(TAG, "Missing 'method' in CVM request");
119 return; 450 return;
120 } 451 }
121 452
122 mcp_request_t req = {0}; 453 cJSON *event = cJSON_GetArrayItem(arr, 2);
123 req.tool = mcp_parse_tool(method->valuestring); 454 if (!event) {
124 strncpy(req.method, method->valuestring, sizeof(req.method) - 1); 455 cJSON_Delete(arr);
125 if (params && cJSON_IsString(params)) { 456 return;
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 } 457 }
132 458
133 mcp_response_t resp = mcp_dispatch(&req); 459 cJSON *kind = cJSON_GetObjectItem(event, "kind");
134 cJSON_Delete(msg); 460 if (!kind || kind->valueint != 25910) {
461 cJSON_Delete(arr);
462 return;
463 }
135 464
136 cJSON *response_msg = cJSON_CreateObject(); 465 cJSON *pubkey = cJSON_GetObjectItem(event, "pubkey");
137 if (resp.success) { 466 cJSON *event_id = cJSON_GetObjectItem(event, "id");
138 cJSON_AddStringToObject(response_msg, "status", "ok"); 467 cJSON *content = cJSON_GetObjectItem(event, "content");
139 cJSON_AddItemToObject(response_msg, "result", cJSON_Parse(resp.result_json)); 468
140 } else { 469 if (!pubkey || !content || !event_id) {
141 cJSON_AddStringToObject(response_msg, "status", "error"); 470 cJSON_Delete(arr);
142 cJSON_AddStringToObject(response_msg, "error", resp.error); 471 return;
472 }
473
474 if (!is_owner_pubkey(pubkey->valuestring)) {
475 ESP_LOGW(TAG, "Ignoring request from non-owner: %.16s...", pubkey->valuestring);
476 cJSON_Delete(arr);
477 return;
143 } 478 }
144 479
145 char *response_str = cJSON_PrintUnformatted(response_msg); 480 handle_mcp_message(relay_url, pubkey->valuestring, event_id->valuestring, content->valuestring);
146 cJSON_Delete(response_msg); 481 cJSON_Delete(arr);
482}
483
484static esp_err_t subscribe_to_relay(esp_tls_t *tls, const char *npub)
485{
486 cJSON *sub = cJSON_CreateArray();
487 cJSON_AddItemToArray(sub, cJSON_CreateString("REQ"));
488 cJSON_AddItemToArray(sub, cJSON_CreateString("cvm_sub"));
489 cJSON *filter = cJSON_CreateObject();
490 cJSON *kinds = cJSON_CreateArray();
491 cJSON_AddItemToArray(kinds, cJSON_CreateNumber(25910));
492 cJSON_AddItemToObject(filter, "kinds", kinds);
493 cJSON_AddStringToObject(filter, "#p", npub);
494 cJSON_AddNumberToObject(filter, "limit", 100);
495 cJSON_AddItemToArray(sub, filter);
147 496
148 uint8_t response_ct[4096]; 497 char *msg = cJSON_PrintUnformatted(sub);
149 size_t ct_len = 0; 498 cJSON_Delete(sub);
150 nip04_encrypt(id->nsec, sender_pk, response_str, response_ct, &ct_len);
151 free(response_str);
152 499
153 ESP_LOGI(TAG, "CVM response prepared (%zu bytes encrypted), would send to %.8s", ct_len, sender_pubkey); 500 int rc = ws_send_text(tls, msg);
501 free(msg);
502 return rc == 0 ? ESP_OK : ESP_FAIL;
154} 503}
155 504
156static void parse_nostr_events(const char *data) 505static void cvm_relay_task(void *arg)
157{ 506{
158 cJSON *arr = cJSON_Parse(data); 507 const char *relay_url = (const char *)arg;
159 if (!arr || !cJSON_IsArray(arr)) { 508 const tollgate_identity_t *id = identity_get();
160 if (arr) cJSON_Delete(arr); 509 if (!id || !id->initialized) {
510 ESP_LOGE(TAG, "Identity not initialized");
511 vTaskDelete(NULL);
161 return; 512 return;
162 } 513 }
163 514
164 cJSON *item = NULL; 515 while (g_running) {
165 cJSON_ArrayForEach(item, arr) { 516 esp_tls_t *tls = NULL;
166 if (!cJSON_IsArray(item)) continue; 517 esp_err_t err = ws_connect(relay_url, &tls);
167 int arr_size = cJSON_GetArraySize(item); 518 if (err != ESP_OK) {
168 if (arr_size < 3) continue; 519 ESP_LOGW(TAG, "Connect failed to %s, retrying", relay_url);
520 vTaskDelay(pdMS_TO_TICKS(CVM_RECONNECT_DELAY_MS));
521 continue;
522 }
169 523
170 cJSON *cmd = cJSON_GetArrayItem(item, 0); 524 err = subscribe_to_relay(tls, id->npub_hex);
171 if (!cmd || !cJSON_IsString(cmd) || strcmp(cmd->valuestring, "EVENT") != 0) continue; 525 if (err != ESP_OK) {
526 esp_tls_conn_destroy(tls);
527 vTaskDelay(pdMS_TO_TICKS(CVM_RECONNECT_DELAY_MS));
528 continue;
529 }
172 530
173 cJSON *event = cJSON_GetArrayItem(item, 2); 531 ESP_LOGI(TAG, "Listening on %s for kind 25910 events", relay_url);
174 if (!event) continue; 532 publish_announcements_via_ws(tls);
175 533
176 cJSON *kind = cJSON_GetObjectItem(event, "kind"); 534 uint8_t *buf = malloc(CVM_WS_BUF_SIZE);
177 if (!kind || kind->valueint != 4) continue; 535 if (!buf) {
536 esp_tls_conn_destroy(tls);
537 vTaskDelete(NULL);
538 return;
539 }
178 540
179 cJSON *pubkey = cJSON_GetObjectItem(event, "pubkey"); 541 while (g_running) {
180 cJSON *content = cJSON_GetObjectItem(event, "content"); 542 int rlen = esp_tls_conn_read(tls, buf, CVM_WS_BUF_SIZE - 1);
181 if (pubkey && content) { 543 if (rlen < 0) {
182 process_dm(pubkey->valuestring, content->valuestring); 544 ESP_LOGW(TAG, "Read error on %s (rlen=%d)", relay_url, rlen);
545 break;
546 }
547 if (rlen == 0) {
548 break;
549 }
550
551 if ((buf[0] & 0x0F) == 0x01) {
552 char *text = parse_ws_text_frame(buf, rlen);
553 if (text) {
554 if (strlen(text) > 0) {
555 process_relay_message(relay_url, text);
556 }
557 free(text);
558 }
559 }
183 } 560 }
561
562 free(buf);
563 uint8_t close_frame[2] = {0x88, 0x00};
564 esp_tls_conn_write(tls, close_frame, 2);
565 esp_tls_conn_destroy(tls);
566 ESP_LOGW(TAG, "Disconnected from %s, reconnecting", relay_url);
567 vTaskDelay(pdMS_TO_TICKS(CVM_RECONNECT_DELAY_MS));
184 } 568 }
185 cJSON_Delete(arr); 569
570 vTaskDelete(NULL);
186} 571}
187 572
188static void cvm_task(void *arg) 573static esp_err_t publish_event_via_ws(esp_tls_t *tls, int kind,
574 const char *content, const char *tags_json)
189{ 575{
190 const tollgate_identity_t *id = identity_get(); 576 const tollgate_identity_t *id = identity_get();
191 if (!id || !id->initialized) { 577 if (!id || !id->initialized) return ESP_FAIL;
192 ESP_LOGE(TAG, "Cannot start: identity not initialized");
193 vTaskDelete(NULL);
194 return;
195 }
196 578
197 char *relays = fetch_relays(); 579 nostr_event_t event;
198 ESP_LOGI(TAG, "CVM server started, relays: %s", relays); 580 nostr_event_init(&event, id->npub_hex, kind, tags_json, content);
581 nostr_event_sign(&event, id->nsec);
199 582
200 while (g_running) { 583 char *event_json = malloc(4096);
201 ESP_LOGI(TAG, "Polling for DMs..."); 584 if (!event_json) return ESP_ERR_NO_MEM;
585
586 esp_err_t ret = nostr_event_to_json(&event, event_json, 4096);
587 if (ret != ESP_OK) {
588 free(event_json);
589 return ret;
590 }
202 591
203 cJSON *sub = build_subscription(id->npub_hex); 592 char *msg;
204 char *sub_json = cJSON_PrintUnformatted(sub); 593 size_t event_len = strlen(event_json);
205 cJSON_Delete(sub); 594 size_t msg_len = 8 + event_len + 1;
595 msg = malloc(msg_len);
596 snprintf(msg, msg_len, "[\"EVENT\",%s]", event_json);
206 597
207 char url[256]; 598 ws_send_text(tls, msg);
208 snprintf(url, sizeof(url), "%s/cvm_poll", relays); 599 ESP_LOGI(TAG, "Published kind %d event (%d bytes)", kind, (int)strlen(event_json));
209 free(sub_json); 600 free(msg);
601 free(event_json);
602 return ESP_OK;
603}
210 604
211 vTaskDelay(pdMS_TO_TICKS(30000)); 605static void publish_announcements_via_ws(esp_tls_t *tls)
606{
607 const tollgate_identity_t *id = identity_get();
608 if (!id || !id->initialized) return;
609
610 ESP_LOGI(TAG, "Publishing CEP-6 announcements via active WS");
611
612 cJSON *ann_content = cJSON_CreateObject();
613 cJSON_AddStringToObject(ann_content, "protocolVersion", CVM_VERSION);
614 cJSON *capabilities = cJSON_CreateObject();
615 cJSON *tools_cap = cJSON_CreateObject();
616 cJSON_AddBoolToObject(tools_cap, "listChanged", true);
617 cJSON_AddItemToObject(capabilities, "tools", tools_cap);
618 cJSON_AddItemToObject(ann_content, "capabilities", capabilities);
619 cJSON *serverInfo = cJSON_CreateObject();
620 cJSON_AddStringToObject(serverInfo, "name", CVM_SERVER_NAME);
621 cJSON_AddStringToObject(serverInfo, "version", CVM_SERVER_VERSION);
622 cJSON_AddItemToObject(ann_content, "serverInfo", serverInfo);
623 char *ann_str = cJSON_PrintUnformatted(ann_content);
624 cJSON_Delete(ann_content);
625
626 cJSON *ann_tags = cJSON_CreateArray();
627 cJSON *name_tag = cJSON_CreateArray();
628 cJSON_AddItemToArray(name_tag, cJSON_CreateString("name"));
629 cJSON_AddItemToArray(name_tag, cJSON_CreateString(CVM_SERVER_NAME));
630 cJSON_AddItemToArray(ann_tags, name_tag);
631 cJSON *about_tag = cJSON_CreateArray();
632 cJSON_AddItemToArray(about_tag, cJSON_CreateString("about"));
633 cJSON_AddItemToArray(about_tag, cJSON_CreateString("ESP32 TollGate WiFi hotspot with Cashu e-cash payments"));
634 cJSON_AddItemToArray(ann_tags, about_tag);
635 char *ann_tags_str = cJSON_PrintUnformatted(ann_tags);
636 cJSON_Delete(ann_tags);
637
638 publish_event_via_ws(tls, 11316, ann_str, ann_tags_str);
639 free(ann_str);
640 free(ann_tags_str);
641
642 cJSON *tools = build_tools_list();
643 cJSON *tools_content = cJSON_CreateObject();
644 cJSON_AddItemToObject(tools_content, "tools", tools);
645 char *tools_str = cJSON_PrintUnformatted(tools_content);
646 cJSON_Delete(tools_content);
647
648 publish_event_via_ws(tls, 11317, tools_str, "[]");
649 free(tools_str);
650
651 cJSON *relay_tags = cJSON_CreateArray();
652 const char *relays[] = {"wss://relay.primal.net", "wss://nostr-pub.wellorder.net", NULL};
653 for (int i = 0; relays[i]; i++) {
654 cJSON *r_tag = cJSON_CreateArray();
655 cJSON_AddItemToArray(r_tag, cJSON_CreateString("r"));
656 cJSON_AddItemToArray(r_tag, cJSON_CreateString(relays[i]));
657 cJSON_AddItemToArray(relay_tags, r_tag);
212 } 658 }
659 char *relay_tags_str = cJSON_PrintUnformatted(relay_tags);
660 cJSON_Delete(relay_tags);
213 661
214 ESP_LOGI(TAG, "CVM server stopped"); 662 publish_event_via_ws(tls, 10002, "", relay_tags_str);
215 vTaskDelete(NULL); 663 free(relay_tags_str);
664
665 ESP_LOGI(TAG, "CEP-6 announcements published (kinds 11316, 11317, 10002)");
666}
667
668esp_err_t cvm_publish_announcements(void)
669{
670 return ESP_OK;
671}
672
673const char *cvm_get_pubkey_hex(void)
674{
675 const tollgate_identity_t *id = identity_get();
676 if (!id || !id->initialized) return NULL;
677 return id->npub_hex;
216} 678}
217 679
218esp_err_t cvm_server_init(void) 680esp_err_t cvm_server_init(void)
@@ -225,7 +687,12 @@ void cvm_server_start(void)
225{ 687{
226 if (g_running) return; 688 if (g_running) return;
227 g_running = true; 689 g_running = true;
228 xTaskCreate(cvm_task, "cvm_server", 8192, NULL, 5, &g_task); 690
691 const tollgate_config_t *cfg = tollgate_config_get();
692 const char *relay = (cfg->cvm_relays[0]) ? cfg->cvm_relays : "wss://relay.primal.net";
693
694 char *relay_copy = strdup(relay);
695 xTaskCreate(cvm_relay_task, "cvm_relay", 16384, relay_copy, 5, &g_task);
229} 696}
230 697
231void cvm_server_stop(void) 698void 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);
7void cvm_server_start(void); 7void cvm_server_start(void);
8void cvm_server_stop(void); 8void cvm_server_stop(void);
9 9
10esp_err_t cvm_publish_announcements(void);
11
12const 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
157mcp_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
193mcp_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
213mcp_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
259mcp_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
301mcp_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
336mcp_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
149mcp_response_t mcp_dispatch(const mcp_request_t *req) 373mcp_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);
30mcp_response_t mcp_handle_set_config(const char *params_json); 36mcp_response_t mcp_handle_set_config(const char *params_json);
31mcp_response_t mcp_handle_get_balance(void); 37mcp_response_t mcp_handle_get_balance(void);
32mcp_response_t mcp_handle_wallet_send(const char *params_json); 38mcp_response_t mcp_handle_wallet_send(const char *params_json);
39mcp_response_t mcp_handle_get_sessions(void);
40mcp_response_t mcp_handle_get_usage(void);
41mcp_response_t mcp_handle_set_payout(const char *params_json);
42mcp_response_t mcp_handle_set_metric(const char *params_json);
43mcp_response_t mcp_handle_set_price(const char *params_json);
44mcp_response_t mcp_handle_wallet_melt(const char *params_json);
33 45
34mcp_response_t mcp_dispatch(const mcp_request_t *req); 46mcp_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
182session_t *cvm_get_sessions_array(void)
183{
184 return s_sessions;
185}
186
187int 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
44void session_tick(void); 44void session_tick(void);
45 45
46session_t *cvm_get_sessions_array(void);
47int cvm_get_sessions_count(void);
48
46#endif 49#endif
diff --git a/main/tollgate_main.c b/main/tollgate_main.c
index 1350d70..c0ff65f 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
27static const char *TAG = "tollgate_main"; 29static const char *TAG = "tollgate_main";
@@ -94,6 +96,13 @@ static void ip_event_handler(void *arg, esp_event_base_t event_base,
94 s_retry_count = 0; 96 s_retry_count = 0;
95 xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT); 97 xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
96 98
99 esp_sntp_stop();
100 esp_sntp_setoperatingmode(SNTP_OPMODE_POLL);
101 esp_sntp_setservername(0, "pool.ntp.org");
102 esp_sntp_setservername(1, "time.google.com");
103 esp_sntp_init();
104 ESP_LOGI(TAG, "SNTP time sync started");
105
97 char gw_ip_str[16]; 106 char gw_ip_str[16];
98 snprintf(gw_ip_str, sizeof(gw_ip_str), IPSTR, IP2STR(&event->ip_info.gw)); 107 snprintf(gw_ip_str, sizeof(gw_ip_str), IPSTR, IP2STR(&event->ip_info.gw));
99 tollgate_client_on_sta_connected(gw_ip_str); 108 tollgate_client_on_sta_connected(gw_ip_str);
@@ -160,6 +169,11 @@ static void start_services(void)
160 s_services_running = true; 169 s_services_running = true;
161 if (s_services_mutex) xSemaphoreGive(s_services_mutex); 170 if (s_services_mutex) xSemaphoreGive(s_services_mutex);
162 ESP_LOGI(TAG, "=== TollGate services started ==="); 171 ESP_LOGI(TAG, "=== TollGate services started ===");
172
173 display_set_state(DISPLAY_READY);
174 char portal_url[128];
175 snprintf(portal_url, sizeof(portal_url), "http://%s/", cfg->ap_ip_str);
176 display_update(cfg->ap_ssid, 0, 0, portal_url);
163} 177}
164 178
165static void stop_services(void) 179static void stop_services(void)
@@ -240,6 +254,9 @@ void app_main(void)
240{ 254{
241 ESP_LOGI(TAG, "=== TollGate ESP32 Starting ==="); 255 ESP_LOGI(TAG, "=== TollGate ESP32 Starting ===");
242 256
257 display_init();
258 display_set_state(DISPLAY_BOOT);
259
243 esp_err_t ret = nvs_flash_init(); 260 esp_err_t ret = nvs_flash_init();
244 if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { 261 if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
245 ESP_ERROR_CHECK(nvs_flash_erase()); 262 ESP_ERROR_CHECK(nvs_flash_erase());
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 @@
1import { execSync } from 'child_process';
2
3const IP = process.env.TOLLGATE_IP || '10.192.45.1';
4const RELAYS = ['wss://relay.damus.io', 'wss://nos.lol'];
5
6let passed = 0, failed = 0;
7
8function assert(condition, test) {
9 if (condition) { console.log(` \u2713 ${test}`); passed++; }
10 else { console.log(` \u2717 ${test}`); failed++; }
11}
12
13function 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
25function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
26
27async 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
91runTests().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
23SECP256K1_OBJ := secp256k1.o precomputed_ecmult.o precomputed_ecmult_gen.o 23SECP256K1_OBJ := secp256k1.o precomputed_ecmult.o precomputed_ecmult_gen.o
24 24
25TESTS := 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 25TESTS := 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
78test_nip04: test_nip04.c $(REPO_ROOT)/main/nip04.c $(SECP256K1_OBJ) 78test_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
81test_cvm_server: test_cvm_server.c
82 $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS)
83
81clean: 84clean:
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
8static 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
33static 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
62static 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
91static 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
103static 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
124static 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
151static 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
166static 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
191static 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
219static 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
240static 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
261static 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
277static 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
291static 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
311static 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
329static 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
350static 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
382static 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
395static 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
419int 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;
11static int g_wallet_proof_count = 0; 12static int g_wallet_proof_count = 0;
12static int g_wallet_send_rc = 0; 13static int g_wallet_send_rc = 0;
13static char g_wallet_send_token[256] = "cashuA_test_token"; 14static char g_wallet_send_token[256] = "cashuA_test_token";
15static esp_err_t g_wallet_melt_rc = ESP_OK;
14 16
15const tollgate_config_t *tollgate_config_get(void) { 17const 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
38esp_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
44static session_t g_test_sessions[SESSION_MAX_CLIENTS];
45static int g_test_session_count = 0;
46
47session_t *cvm_get_sessions_array(void) {
48 return g_test_sessions;
49}
50
51int cvm_get_sessions_count(void) {
52 return SESSION_MAX_CLIENTS;
53}
54
36static void test_mcp_parse_tool(void) 55static 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
163static 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
191static 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
209static 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
225static 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
245static 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
262static 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
138int main(void) 278int 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}