upleb.uk

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

summaryrefslogtreecommitdiff
path: root/main/cvm_server.c
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 /main/cvm_server.c
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
Diffstat (limited to 'main/cvm_server.c')
-rw-r--r--main/cvm_server.c727
1 files changed, 597 insertions, 130 deletions
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)