From 42902a36bc52e009a1e8d3c371741e30a9cb4c33 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 19 May 2026 01:10:06 +0530 Subject: feat: ContextVM (MCP over Nostr) server with full integration Complete CVM implementation: persistent WebSocket relay listener, kind 25910 event subscription, MCP protocol handlers, CEP-6 announcements, 10 MCP tools, per-board hardware locks, WiFi EU regulatory fix. Architecture: - cvm_server.c: WS relay listener, kind 25910 subscription, MCP dispatch - mcp_handler.c/h: 10 MCP tools (get_config, set_config, get_balance, wallet_send, get_sessions, get_usage, set_payout, set_metric, set_price, wallet_melt) - Responses published via existing WS connection (not new TLS) - Auth check: only owner npub accepted - CEP-6: kinds 11316 (server), 11317 (tools), 10002 (relay list) - WS ping/pong keepalive every 30s, 60s TLS read timeout Critical fixes: - WiFi country code DE (ESP-IDF defaults to CN, breaks EU APs) - Subscription #p filter must be array not string - Use-after-free: tags_str freed before nostr_event_to_json - MCP responses via existing WS (ESP32 can't open multiple TLS) - EVENT msg buffer underflow, WS frame masking, TLS write loop Per-board hardware locks: - Lock files in physical-router-test-automation/locks/ - lock-a/b/c, unlock-a/b/c targets in 3 Makefiles - All hardware-touching targets require board lock Verified on Board B via relay.primal.net: - 282 unit tests passing (61 CVM + 60 MCP + 161 existing) - MCP initialize roundtrip: PASS - tools/list: PASS - tools/call get_config: PASS - tools/call get_balance: PASS - tools/call set_price: PASS (write operation) - CEP-6 announcements (11316, 11317, 10002): all accepted by relay - WiFi STA connection (EnterSSID-2.4GHz): PASS with country code DE - Board A WiFi confirmed hardware issue (not firmware) --- main/CMakeLists.txt | 4 +- main/config.c | 13 +- main/cvm_server.c | 815 +++++++++++++++++++++++++++++++++++++++++++-------- main/cvm_server.h | 4 + main/mcp_handler.c | 236 +++++++++++++++ main/mcp_handler.h | 12 + main/session.c | 10 + main/session.h | 3 + main/tollgate_main.c | 21 ++ 9 files changed, 984 insertions(+), 134 deletions(-) (limited to 'main') 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" "nip04.c" "mcp_handler.c" "cvm_server.c" + "display.c" + "font.c" INCLUDE_DIRS "." REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server lwip json esp_http_client mbedtls esp-tls log spiffs - nucula_lib secp256k1 + nucula_lib secp256k1 axs15231b qrcode PRIV_REQUIRES esp-tls) diff --git a/main/config.c b/main/config.c index e937fb3..9dd2a1d 100644 --- a/main/config.c +++ b/main/config.c @@ -16,7 +16,7 @@ esp_err_t tollgate_config_init(void) { memset(&g_config, 0, sizeof(g_config)); g_config.max_retry = 5; - g_config.ap_channel = 1; + g_config.ap_channel = 6; g_config.ap_max_conn = 4; g_config.price_per_step = 21; g_config.step_size_ms = 60000; @@ -33,8 +33,8 @@ esp_err_t tollgate_config_init(void) g_config.payout.check_interval_s = 60; g_config.payout.recipient_count = 0; g_config.payout.mint_count = 0; - g_config.cvm_enabled = false; - strncpy(g_config.cvm_relays, "wss://relay.damus.io", sizeof(g_config.cvm_relays) - 1); + g_config.cvm_enabled = true; + strncpy(g_config.cvm_relays, "wss://relay.primal.net", sizeof(g_config.cvm_relays) - 1); esp_vfs_spiffs_conf_t conf = { .base_path = "/spiffs", @@ -54,7 +54,9 @@ esp_err_t tollgate_config_init(void) const char *default_json = "{" "\"nsec\":\"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\"," "\"wifi_networks\":[" - "{\"ssid\":\"EnterSSID-2.4GHz\",\"password\":\"c03rad0r123!\"}" + "{\"ssid\":\"EnterSSID-2.4GHz\",\"password\":\"c03rad0r123!\"}," + "{\"ssid\":\"c03rad0r\",\"password\":\"c03rad0r123\"}," + "{\"ssid\":\"TK-GAESTE\",\"password\":\"\"}" "]," "\"ap_password\":\"\"," "\"mint_url\":\"https://testnut.cashu.space\"," @@ -289,6 +291,9 @@ esp_err_t tollgate_config_get_wifi(wifi_config_t *wifi_config) strncpy((char *)wifi_config->sta.ssid, g_config.networks[idx].ssid, sizeof(wifi_config->sta.ssid) - 1); strncpy((char *)wifi_config->sta.password, g_config.networks[idx].password, sizeof(wifi_config->sta.password) - 1); wifi_config->sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; + wifi_config->sta.pmf_cfg.capable = true; + wifi_config->sta.pmf_cfg.required = false; + wifi_config->sta.scan_method = WIFI_ALL_CHANNEL_SCAN; return ESP_OK; } diff --git a/main/cvm_server.c b/main/cvm_server.c index 5addd88..b93e176 100644 --- a/main/cvm_server.c +++ b/main/cvm_server.c @@ -2,219 +2,771 @@ #include "mcp_handler.h" #include "nip04.h" #include "identity.h" +#include "nostr_event.h" #include "config.h" +#include "session.h" #include "nucula_wallet.h" #include "cJSON.h" #include "esp_log.h" -#include "esp_http_client.h" +#include "esp_tls.h" +#include "esp_crt_bundle.h" +#include "esp_random.h" +#include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include #include +#include static const char *TAG = "cvm_server"; static bool g_running = false; static TaskHandle_t g_task = NULL; -static const char *DEFAULT_RELAY = "wss://relay.damus.io"; +static void publish_announcements_via_ws(esp_tls_t *tls); -static char *fetch_relays(void) +#define CVM_VERSION "2025-07-02" +#define CVM_SERVER_NAME "TollGate" +#define CVM_SERVER_VERSION "1.0.0" +#define CVM_WS_BUF_SIZE 8192 +#define CVM_MAX_RESPONSE_SIZE 4096 +#define CVM_RECONNECT_DELAY_MS 5000 +#define CVM_WS_READ_TIMEOUT_MS 60000 +#define CVM_WS_PING_INTERVAL_S 30 + +static char *parse_ws_text_frame(const uint8_t *buf, int len) { - const tollgate_config_t *cfg = tollgate_config_get(); - if (cfg && cfg->cvm_relays[0]) { - return cfg->cvm_relays; + if (len < 2) return NULL; + bool masked = (buf[1] & 0x80) != 0; + uint64_t payload_len = buf[1] & 0x7F; + int offset = 2; + + if (payload_len == 126) { + if (len < 4) return NULL; + payload_len = ((uint64_t)buf[2] << 8) | buf[3]; + offset = 4; + } else if (payload_len == 127) { + if (len < 10) return NULL; + payload_len = 0; + for (int i = 0; i < 8; i++) + payload_len = (payload_len << 8) | buf[2 + i]; + offset = 10; + } + + if (masked) offset += 4; + if (offset + payload_len > (uint64_t)len) return NULL; + + char *text = malloc((size_t)payload_len + 1); + if (!text) return NULL; + + if (masked) { + uint8_t mask[4] = { buf[offset - 4], buf[offset - 3], buf[offset - 2], buf[offset - 1] }; + for (uint64_t i = 0; i < payload_len; i++) + text[i] = buf[offset + i] ^ mask[i & 3]; + } else { + memcpy(text, buf + offset, (size_t)payload_len); + } + text[payload_len] = '\0'; + return text; +} + +static int ws_send_text(esp_tls_t *tls, const char *msg) +{ + size_t len = strlen(msg); + uint8_t mask[4]; + esp_fill_random(mask, 4); + + size_t frame_len = 6 + len; + if (len > 125) frame_len += 2; + if (len > 65535) frame_len += 6; + + uint8_t *frame = malloc(frame_len + len); + if (!frame) return -1; + + int pos = 0; + frame[pos++] = 0x81; + if (len <= 125) { + frame[pos++] = (uint8_t)(0x80 | len); + } else if (len <= 65535) { + frame[pos++] = 0x80 | 126; + frame[pos++] = (uint8_t)((len >> 8) & 0xff); + frame[pos++] = (uint8_t)(len & 0xff); + } else { + frame[pos++] = 0x80 | 127; + for (int i = 0; i < 8; i++) + frame[pos++] = (uint8_t)((len >> (56 - i * 8)) & 0xff); + } + memcpy(frame + pos, mask, 4); + pos += 4; + + for (size_t i = 0; i < len; i++) + frame[pos + i] = (uint8_t)msg[i] ^ mask[i & 3]; + pos += len; + + int total = pos; + int written = 0; + while (written < total) { + int w = esp_tls_conn_write(tls, frame + written, total - written); + if (w < 0) { + ESP_LOGE(TAG, "ws_send: write failed at %d/%d", written, total); + free(frame); + return -1; + } + if (w == 0) { + ESP_LOGW(TAG, "ws_send: write returned 0 at %d/%d", written, total); + vTaskDelay(pdMS_TO_TICKS(1)); + } + written += w; } - return (char *)DEFAULT_RELAY; + ESP_LOGD(TAG, "ws_send: sent %d bytes (payload %d)", total, (int)len); + free(frame); + return 0; } -static char *http_get(const char *url, int timeout_ms) +static esp_err_t ws_connect(const char *relay_url, esp_tls_t **tls_out) { - char *buf = malloc(8192); - if (!buf) return NULL; - int total = 0; + char host[128] = {0}; + int port = 443; + char path[128] = "/"; + + if (strncmp(relay_url, "wss://", 6) != 0) return ESP_ERR_INVALID_ARG; + + const char *url_start = relay_url + 6; + const char *path_ptr = strchr(url_start, '/'); + if (path_ptr) { + size_t host_len = path_ptr - url_start; + if (host_len >= sizeof(host)) host_len = sizeof(host) - 1; + memcpy(host, url_start, host_len); + host[host_len] = '\0'; + strncpy(path, path_ptr, sizeof(path) - 1); + } else { + strncpy(host, url_start, sizeof(host) - 1); + } - esp_http_client_config_t config = { - .url = url, - .method = HTTP_METHOD_GET, - .timeout_ms = timeout_ms, + char *colon = strchr(host, ':'); + if (colon) { + *colon = '\0'; + port = atoi(colon + 1); + } + + esp_tls_cfg_t tls_cfg = { + .crt_bundle_attach = esp_crt_bundle_attach, + .timeout_ms = CVM_WS_READ_TIMEOUT_MS, }; - esp_http_client_handle_t client = esp_http_client_init(&config); - if (!client) { free(buf); return NULL; } + esp_tls_t *tls = esp_tls_init(); + if (!tls) return ESP_ERR_NO_MEM; - esp_err_t err = esp_http_client_open(client, 0); - if (err != ESP_OK) { - esp_http_client_cleanup(client); - free(buf); - return NULL; + int ret = esp_tls_conn_new_sync(host, strlen(host), port, &tls_cfg, tls); + if (ret < 0) { + esp_tls_conn_destroy(tls); + return ESP_FAIL; } - int content_length = esp_http_client_fetch_headers(client); - int max_read = content_length > 0 ? content_length : 8191; + char upgrade[512]; + snprintf(upgrade, sizeof(upgrade), + "GET %s HTTP/1.1\r\n" + "Host: %s\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + "Sec-WebSocket-Version: 13\r\n" + "\r\n", + path, host); + + int written = esp_tls_conn_write(tls, (const unsigned char *)upgrade, strlen(upgrade)); + if (written < 0) { + esp_tls_conn_destroy(tls); + return ESP_FAIL; + } - while (total < max_read) { - int n = esp_http_client_read(client, buf + total, max_read - total); - if (n <= 0) break; - total += n; + char resp[1024]; + int rlen = esp_tls_conn_read(tls, (unsigned char *)resp, sizeof(resp) - 1); + if (rlen <= 0 || !strstr(resp, "101")) { + ESP_LOGE(TAG, "WS upgrade failed to %s (read %d)", host, rlen); + esp_tls_conn_destroy(tls); + return ESP_FAIL; } - buf[total] = '\0'; - esp_http_client_cleanup(client); - return buf; + + *tls_out = tls; + ESP_LOGI(TAG, "Connected to %s", host); + return ESP_OK; } -static cJSON *build_filter(const char *npub) +static cJSON *build_tools_list(void) { - cJSON *filter = cJSON_CreateObject(); - cJSON *kinds = cJSON_CreateArray(); - cJSON_AddItemToArray(kinds, cJSON_CreateNumber(4)); - cJSON_AddItemToObject(filter, "kinds", kinds); - cJSON_AddStringToObject(filter, "#p", npub); - cJSON_AddNumberToObject(filter, "limit", 10); - return filter; + cJSON *tools = cJSON_CreateArray(); + + const char *tool_defs[][3] = { + {"get_config", "Get current device configuration", "{\"type\":\"object\",\"properties\":{},\"required\":[]}"}, + {"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\"}}}"}, + {"get_balance", "Get wallet balance and proof count", "{\"type\":\"object\",\"properties\":{},\"required\":[]}"}, + {"wallet_send", "Send e-cash tokens from wallet", "{\"type\":\"object\",\"properties\":{\"amount\":{\"type\":\"integer\",\"description\":\"Amount in sats\"}},\"required\":[\"amount\"]}"}, + {"get_sessions","Get active client sessions", "{\"type\":\"object\",\"properties\":{},\"required\":[]}"}, + {"get_usage", "Get current billing usage info", "{\"type\":\"object\",\"properties\":{},\"required\":[]}"}, + {"set_payout", "Configure payout recipients", "{\"type\":\"object\",\"properties\":{\"enabled\":{\"type\":\"boolean\"},\"recipients\":{\"type\":\"array\"}}}"}, + {"set_metric", "Set billing metric", "{\"type\":\"object\",\"properties\":{\"metric\":{\"type\":\"string\",\"enum\":[\"bytes\",\"milliseconds\"]}},\"required\":[\"metric\"]}"}, + {"set_price", "Set price per step", "{\"type\":\"object\",\"properties\":{\"price_per_step\":{\"type\":\"integer\",\"minimum\":1}},\"required\":[\"price_per_step\"]}"}, + {"wallet_melt", "Melt tokens for lightning payment", "{\"type\":\"object\",\"properties\":{\"bolt11\":{\"type\":\"string\"},\"max_fee_sats\":{\"type\":\"integer\"}},\"required\":[\"bolt11\"]}"}, + }; + + for (int i = 0; i < 10; i++) { + cJSON *tool = cJSON_CreateObject(); + cJSON_AddStringToObject(tool, "name", tool_defs[i][0]); + cJSON_AddStringToObject(tool, "description", tool_defs[i][1]); + cJSON *schema = cJSON_Parse(tool_defs[i][2]); + if (schema) cJSON_AddItemToObject(tool, "inputSchema", schema); + cJSON_AddItemToArray(tools, tool); + } + + return tools; } -static cJSON *build_subscription(const char *npub) +static char *build_initialize_response(const char *request_id_str, const char *client_pubkey) { - cJSON *sub = cJSON_CreateArray(); - cJSON_AddItemToArray(sub, cJSON_CreateString("REQ")); - cJSON_AddItemToArray(sub, cJSON_CreateString("cvm_sub_01")); - cJSON_AddItemToArray(sub, build_filter(npub)); - return sub; + cJSON *response = cJSON_CreateObject(); + cJSON_AddStringToObject(response, "jsonrpc", "2.0"); + cJSON_AddNumberToObject(response, "id", request_id_str ? atof(request_id_str) : 0); + + cJSON *result = cJSON_CreateObject(); + cJSON_AddStringToObject(result, "protocolVersion", CVM_VERSION); + + cJSON *capabilities = cJSON_CreateObject(); + cJSON_AddItemToObject(capabilities, "tools", cJSON_CreateObject()); + cJSON_AddItemToObject(result, "capabilities", capabilities); + + cJSON *serverInfo = cJSON_CreateObject(); + cJSON_AddStringToObject(serverInfo, "name", CVM_SERVER_NAME); + cJSON_AddStringToObject(serverInfo, "version", CVM_SERVER_VERSION); + cJSON_AddItemToObject(result, "serverInfo", serverInfo); + + cJSON_AddItemToObject(response, "result", result); + + char *json = cJSON_PrintUnformatted(response); + cJSON_Delete(response); + return json; +} + +static char *build_tools_list_response(const char *request_id_str) +{ + cJSON *response = cJSON_CreateObject(); + cJSON_AddStringToObject(response, "jsonrpc", "2.0"); + cJSON_AddNumberToObject(response, "id", request_id_str ? atof(request_id_str) : 1); + + cJSON *result = cJSON_CreateObject(); + cJSON *tools = build_tools_list(); + cJSON_AddItemToObject(result, "tools", tools); + cJSON_AddItemToObject(response, "result", result); + + char *json = cJSON_PrintUnformatted(response); + cJSON_Delete(response); + return json; +} + +static char *build_tool_call_response(const char *request_id_str, const mcp_response_t *mcp_resp) +{ + cJSON *response = cJSON_CreateObject(); + cJSON_AddStringToObject(response, "jsonrpc", "2.0"); + cJSON_AddNumberToObject(response, "id", request_id_str ? atof(request_id_str) : 2); + + if (mcp_resp->success) { + cJSON *result = cJSON_CreateObject(); + cJSON_AddItemToObject(result, "content", cJSON_CreateArray()); + cJSON *content_arr = cJSON_GetObjectItem(result, "content"); + cJSON *text_item = cJSON_CreateObject(); + cJSON_AddStringToObject(text_item, "type", "text"); + cJSON_AddStringToObject(text_item, "text", mcp_resp->result_json); + cJSON_AddItemToArray(content_arr, text_item); + cJSON_AddBoolToObject(result, "isError", false); + cJSON_AddItemToObject(response, "result", result); + } else { + cJSON *error = cJSON_CreateObject(); + cJSON_AddNumberToObject(error, "code", -32603); + cJSON_AddStringToObject(error, "message", mcp_resp->error); + cJSON_AddItemToObject(response, "error", error); + } + + char *json = cJSON_PrintUnformatted(response); + cJSON_Delete(response); + return json; +} + +static char *build_ping_response(const char *request_id_str) +{ + cJSON *response = cJSON_CreateObject(); + cJSON_AddStringToObject(response, "jsonrpc", "2.0"); + cJSON_AddNumberToObject(response, "id", request_id_str ? atof(request_id_str) : 0); + cJSON *result = cJSON_CreateObject(); + cJSON_AddItemToObject(response, "result", result); + char *json = cJSON_PrintUnformatted(response); + cJSON_Delete(response); + return json; } -static void process_dm(const char *sender_pubkey, const char *encrypted_content) +static esp_err_t publish_event_to_relay(const char *relay_url, const char *event_json) +{ + esp_tls_t *tls = NULL; + esp_err_t err = ws_connect(relay_url, &tls); + if (err != ESP_OK) return err; + + char *msg; + size_t event_len2 = strlen(event_json); + size_t msg_len2 = 10 + event_len2 + 2; + msg = malloc(msg_len2); + snprintf(msg, msg_len2, "[\"EVENT\",%s]", event_json); + + ws_send_text(tls, msg); + free(msg); + + uint8_t resp_buf[256]; + esp_tls_conn_read(tls, resp_buf, sizeof(resp_buf) - 1); + + uint8_t close_frame[2] = {0x88, 0x00}; + esp_tls_conn_write(tls, close_frame, 2); + esp_tls_conn_destroy(tls); + return ESP_OK; +} + +static esp_err_t publish_kind_25910_response_ws(esp_tls_t *tls, + const char *content_json, + const char *request_event_id) { const tollgate_identity_t *id = identity_get(); - if (!id || !id->initialized) { - ESP_LOGE(TAG, "Identity not initialized"); - return; + if (!id || !id->initialized) return ESP_FAIL; + + cJSON *tags = cJSON_CreateArray(); + cJSON *e_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(e_tag, cJSON_CreateString("e")); + cJSON_AddItemToArray(e_tag, cJSON_CreateString(request_event_id)); + cJSON_AddItemToArray(tags, e_tag); + + char *tags_str = cJSON_PrintUnformatted(tags); + cJSON_Delete(tags); + + nostr_event_t event; + nostr_event_init(&event, id->npub_hex, 25910, tags_str, content_json); + nostr_event_sign(&event, id->nsec); + + char *event_json = malloc(8192); + if (!event_json) { + free(tags_str); + return ESP_ERR_NO_MEM; } - uint8_t sender_pk[64]; - for (int i = 0; i < 64; i++) { - char hex[3] = { sender_pubkey[i*2], sender_pubkey[i*2+1], 0 }; - sender_pk[i] = (uint8_t)strtol(hex, NULL, 16); + esp_err_t ret = nostr_event_to_json(&event, event_json, 8192); + free(tags_str); + if (ret != ESP_OK) { + free(event_json); + return ret; } - char plaintext[2048]; - int pt_len = nip04_decrypt(id->nsec, sender_pk, encrypted_content, plaintext, sizeof(plaintext)); - if (pt_len < 0) { - ESP_LOGE(TAG, "Failed to decrypt DM from %.8s", sender_pubkey); - return; + size_t msg_len = 10 + strlen(event_json) + 2; + char *msg = malloc(msg_len); + if (!msg) { + free(event_json); + return ESP_ERR_NO_MEM; } + snprintf(msg, msg_len, "[\"EVENT\",%s]", event_json); + ESP_LOGD(TAG, "Sending WS response (%d bytes)", (int)strlen(msg)); + int rc = ws_send_text(tls, msg); + ESP_LOGD(TAG, "WS send result: %d", rc); + free(msg); + free(event_json); + return ESP_OK; +} + +static esp_err_t publish_kind_25910_response(const char *relay_url, + const char *content_json, + const char *request_event_id) +{ + const tollgate_identity_t *id = identity_get(); + if (!id || !id->initialized) return ESP_FAIL; + + cJSON *tags = cJSON_CreateArray(); + cJSON *e_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(e_tag, cJSON_CreateString("e")); + cJSON_AddItemToArray(e_tag, cJSON_CreateString(request_event_id)); + cJSON_AddItemToArray(tags, e_tag); + + char *tags_str = cJSON_PrintUnformatted(tags); + cJSON_Delete(tags); + + nostr_event_t event; + nostr_event_init(&event, id->npub_hex, 25910, tags_str, content_json); + nostr_event_sign(&event, id->nsec); + free(tags_str); + + char *event_json = malloc(8192); + if (!event_json) return ESP_ERR_NO_MEM; + + esp_err_t ret = nostr_event_to_json(&event, event_json, 8192); + if (ret != ESP_OK) { + free(event_json); + return ret; + } + + ret = publish_event_to_relay(relay_url, event_json); + free(event_json); + return ret; +} - ESP_LOGI(TAG, "Decrypted DM from %.8s: %s", sender_pubkey, plaintext); +static bool is_owner_pubkey(const char *pubkey_hex) +{ + const tollgate_identity_t *id = identity_get(); + if (!id || !id->initialized) return false; + if (!pubkey_hex) return false; + return strcmp(id->npub_hex, pubkey_hex) == 0; +} - cJSON *msg = cJSON_Parse(plaintext); +static void handle_mcp_message(esp_tls_t *tls, const char *sender_pubkey, + const char *event_id, const char *content) +{ + cJSON *msg = cJSON_Parse(content); if (!msg) { - ESP_LOGE(TAG, "Invalid JSON in DM"); + ESP_LOGW(TAG, "Invalid JSON in kind 25910 content"); return; } cJSON *method = cJSON_GetObjectItem(msg, "method"); - cJSON *params = cJSON_GetObjectItem(msg, "params"); - if (!method || !cJSON_IsString(method)) { - cJSON_Delete(msg); - ESP_LOGE(TAG, "Missing 'method' in CVM request"); - return; + cJSON *id_field = cJSON_GetObjectItem(msg, "id"); + const char *id_str = (id_field && cJSON_IsNumber(id_field)) + ? cJSON_PrintUnformatted(id_field) : "0"; + + if (method && cJSON_IsString(method)) { + const char *m = method->valuestring; + + if (strcmp(m, "initialize") == 0) { + ESP_LOGI(TAG, "MCP initialize from %s", sender_pubkey); + char *resp = build_initialize_response(id_str, sender_pubkey); + if (tls) { + publish_kind_25910_response_ws(tls, resp, event_id); + } else { + ESP_LOGW(TAG, "No TLS for response"); + } + free(resp); + } else if (strcmp(m, "notifications/initialized") == 0) { + ESP_LOGI(TAG, "Client initialized: %s", sender_pubkey); + } else if (strcmp(m, "tools/list") == 0) { + ESP_LOGI(TAG, "tools/list from %s", sender_pubkey); + char *resp = build_tools_list_response(id_str); + if (tls) { + publish_kind_25910_response_ws(tls, resp, event_id); + } + free(resp); + } else if (strcmp(m, "tools/call") == 0) { + cJSON *params = cJSON_GetObjectItem(msg, "params"); + cJSON *name = params ? cJSON_GetObjectItem(params, "name") : NULL; + cJSON *arguments = params ? cJSON_GetObjectItem(params, "arguments") : NULL; + + if (name && cJSON_IsString(name)) { + ESP_LOGI(TAG, "tools/call %s from %s", name->valuestring, sender_pubkey); + + mcp_request_t req = {0}; + req.tool = mcp_parse_tool(name->valuestring); + strncpy(req.method, name->valuestring, sizeof(req.method) - 1); + if (arguments) { + char *ajson = cJSON_PrintUnformatted(arguments); + strncpy(req.params_json, ajson, sizeof(req.params_json) - 1); + cJSON_free(ajson); + } + + mcp_response_t mcp_resp = mcp_dispatch(&req); + char *resp = build_tool_call_response(id_str, &mcp_resp); + if (tls) { + publish_kind_25910_response_ws(tls, resp, event_id); + } + free(resp); + } + } else if (strcmp(m, "ping") == 0) { + char *resp = build_ping_response(id_str); + if (tls) { + publish_kind_25910_response_ws(tls, resp, event_id); + } + free(resp); + } else { + ESP_LOGW(TAG, "Unknown MCP method: %s", m); + } } - mcp_request_t req = {0}; - req.tool = mcp_parse_tool(method->valuestring); - strncpy(req.method, method->valuestring, sizeof(req.method) - 1); - if (params && cJSON_IsString(params)) { - strncpy(req.params_json, params->valuestring, sizeof(req.params_json) - 1); - } else if (params) { - char *pjson = cJSON_PrintUnformatted(params); - strncpy(req.params_json, pjson, sizeof(req.params_json) - 1); - cJSON_free(pjson); + if (id_field && cJSON_IsNumber(id_field) && id_str[0] != '0') { + free((void *)id_str); + } else if (id_str[0] != '0') { } - - mcp_response_t resp = mcp_dispatch(&req); cJSON_Delete(msg); - - cJSON *response_msg = cJSON_CreateObject(); - if (resp.success) { - cJSON_AddStringToObject(response_msg, "status", "ok"); - cJSON_AddItemToObject(response_msg, "result", cJSON_Parse(resp.result_json)); - } else { - cJSON_AddStringToObject(response_msg, "status", "error"); - cJSON_AddStringToObject(response_msg, "error", resp.error); - } - - char *response_str = cJSON_PrintUnformatted(response_msg); - cJSON_Delete(response_msg); - - uint8_t response_ct[4096]; - size_t ct_len = 0; - nip04_encrypt(id->nsec, sender_pk, response_str, response_ct, &ct_len); - free(response_str); - - ESP_LOGI(TAG, "CVM response prepared (%zu bytes encrypted), would send to %.8s", ct_len, sender_pubkey); } -static void parse_nostr_events(const char *data) +static void process_relay_message(esp_tls_t *tls, const char *relay_url, const char *msg_str) { - cJSON *arr = cJSON_Parse(data); + cJSON *arr = cJSON_Parse(msg_str); if (!arr || !cJSON_IsArray(arr)) { if (arr) cJSON_Delete(arr); return; } - cJSON *item = NULL; - cJSON_ArrayForEach(item, arr) { - if (!cJSON_IsArray(item)) continue; - int arr_size = cJSON_GetArraySize(item); - if (arr_size < 3) continue; + cJSON *cmd = cJSON_GetArrayItem(arr, 0); + if (!cmd || !cJSON_IsString(cmd)) { + cJSON_Delete(arr); + return; + } - cJSON *cmd = cJSON_GetArrayItem(item, 0); - if (!cmd || !cJSON_IsString(cmd) || strcmp(cmd->valuestring, "EVENT") != 0) continue; + if (strcmp(cmd->valuestring, "OK") == 0) { + cJSON *ev_id = cJSON_GetArrayItem(arr, 1); + cJSON *ok = cJSON_GetArrayItem(arr, 2); + cJSON *reason = cJSON_GetArrayItem(arr, 3); + ESP_LOGI(TAG, "Relay OK: id=%.16s success=%s reason=%s", + ev_id ? ev_id->valuestring : "?", + ok ? (cJSON_IsTrue(ok) ? "true" : "FALSE") : "?", + reason ? reason->valuestring : ""); + cJSON_Delete(arr); + return; + } - cJSON *event = cJSON_GetArrayItem(item, 2); - if (!event) continue; + if (strcmp(cmd->valuestring, "EVENT") != 0) { + ESP_LOGI(TAG, "Relay msg: %.100s", msg_str); + cJSON_Delete(arr); + return; + } - cJSON *kind = cJSON_GetObjectItem(event, "kind"); - if (!kind || kind->valueint != 4) continue; + cJSON *event = cJSON_GetArrayItem(arr, 2); + if (!event) { + cJSON_Delete(arr); + return; + } - cJSON *pubkey = cJSON_GetObjectItem(event, "pubkey"); - cJSON *content = cJSON_GetObjectItem(event, "content"); - if (pubkey && content) { - process_dm(pubkey->valuestring, content->valuestring); - } + cJSON *kind = cJSON_GetObjectItem(event, "kind"); + if (!kind || kind->valueint != 25910) { + cJSON_Delete(arr); + return; + } + + cJSON *pubkey = cJSON_GetObjectItem(event, "pubkey"); + cJSON *event_id = cJSON_GetObjectItem(event, "id"); + cJSON *content = cJSON_GetObjectItem(event, "content"); + + if (!pubkey || !content || !event_id) { + cJSON_Delete(arr); + return; } + + if (!is_owner_pubkey(pubkey->valuestring)) { + ESP_LOGW(TAG, "Ignoring request from non-owner: %.16s...", pubkey->valuestring); + cJSON_Delete(arr); + return; + } + + handle_mcp_message(tls, pubkey->valuestring, event_id->valuestring, content->valuestring); cJSON_Delete(arr); } -static void cvm_task(void *arg) +static esp_err_t subscribe_to_relay(esp_tls_t *tls, const char *npub) +{ + cJSON *sub = cJSON_CreateArray(); + cJSON_AddItemToArray(sub, cJSON_CreateString("REQ")); + cJSON_AddItemToArray(sub, cJSON_CreateString("cvm_sub")); + cJSON *filter = cJSON_CreateObject(); + cJSON *kinds = cJSON_CreateArray(); + cJSON_AddItemToArray(kinds, cJSON_CreateNumber(25910)); + cJSON_AddItemToObject(filter, "kinds", kinds); + cJSON *p_tags = cJSON_CreateArray(); + cJSON_AddItemToArray(p_tags, cJSON_CreateString(npub)); + cJSON_AddItemToObject(filter, "#p", p_tags); + cJSON_AddNumberToObject(filter, "limit", 100); + cJSON_AddItemToArray(sub, filter); + + char *msg = cJSON_PrintUnformatted(sub); + cJSON_Delete(sub); + + int rc = ws_send_text(tls, msg); + free(msg); + return rc == 0 ? ESP_OK : ESP_FAIL; +} + +static void cvm_relay_task(void *arg) { + const char *relay_url = (const char *)arg; const tollgate_identity_t *id = identity_get(); if (!id || !id->initialized) { - ESP_LOGE(TAG, "Cannot start: identity not initialized"); + ESP_LOGE(TAG, "Identity not initialized"); vTaskDelete(NULL); return; } - char *relays = fetch_relays(); - ESP_LOGI(TAG, "CVM server started, relays: %s", relays); - while (g_running) { - ESP_LOGI(TAG, "Polling for DMs..."); + esp_tls_t *tls = NULL; + esp_err_t err = ws_connect(relay_url, &tls); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Connect failed to %s, retrying", relay_url); + vTaskDelay(pdMS_TO_TICKS(CVM_RECONNECT_DELAY_MS)); + continue; + } + + err = subscribe_to_relay(tls, id->npub_hex); + if (err != ESP_OK) { + esp_tls_conn_destroy(tls); + vTaskDelay(pdMS_TO_TICKS(CVM_RECONNECT_DELAY_MS)); + continue; + } - cJSON *sub = build_subscription(id->npub_hex); - char *sub_json = cJSON_PrintUnformatted(sub); - cJSON_Delete(sub); + ESP_LOGI(TAG, "Listening on %s for kind 25910 events", relay_url); + publish_announcements_via_ws(tls); - char url[256]; - snprintf(url, sizeof(url), "%s/cvm_poll", relays); - free(sub_json); + uint8_t *buf = malloc(CVM_WS_BUF_SIZE); + if (!buf) { + esp_tls_conn_destroy(tls); + vTaskDelete(NULL); + return; + } - vTaskDelay(pdMS_TO_TICKS(30000)); + int64_t last_ping_time = 0; + + while (g_running) { + int rlen = esp_tls_conn_read(tls, buf, CVM_WS_BUF_SIZE - 1); + if (rlen < 0) { + ESP_LOGW(TAG, "Read error on %s (rlen=%d)", relay_url, rlen); + break; + } + if (rlen == 0) { + break; + } + + if ((buf[0] & 0x0F) == 0x01) { + char *text = parse_ws_text_frame(buf, rlen); + if (text) { + if (strlen(text) > 0) { + process_relay_message(tls, relay_url, text); + } + free(text); + } + } else if ((buf[0] & 0x0F) == 0x09) { + uint8_t pong[2] = {0x8A, 0x00}; + esp_tls_conn_write(tls, pong, 2); + } + + int64_t now = (int64_t)esp_timer_get_time() / 1000000; + if (now - last_ping_time >= CVM_WS_PING_INTERVAL_S) { + uint8_t ping[2] = {0x89, 0x00}; + esp_tls_conn_write(tls, ping, 2); + last_ping_time = now; + } + } + + free(buf); + uint8_t close_frame[2] = {0x88, 0x00}; + esp_tls_conn_write(tls, close_frame, 2); + esp_tls_conn_destroy(tls); + ESP_LOGW(TAG, "Disconnected from %s, reconnecting", relay_url); + vTaskDelay(pdMS_TO_TICKS(CVM_RECONNECT_DELAY_MS)); } - ESP_LOGI(TAG, "CVM server stopped"); vTaskDelete(NULL); } +static esp_err_t publish_event_via_ws(esp_tls_t *tls, int kind, + const char *content, const char *tags_json) +{ + const tollgate_identity_t *id = identity_get(); + if (!id || !id->initialized) return ESP_FAIL; + + nostr_event_t event; + nostr_event_init(&event, id->npub_hex, kind, tags_json, content); + nostr_event_sign(&event, id->nsec); + + char *event_json = malloc(4096); + if (!event_json) return ESP_ERR_NO_MEM; + + esp_err_t ret = nostr_event_to_json(&event, event_json, 4096); + if (ret != ESP_OK) { + free(event_json); + return ret; + } + + char *msg; + size_t event_len = strlen(event_json); + size_t msg_len = 10 + event_len + 2; + msg = malloc(msg_len); + snprintf(msg, msg_len, "[\"EVENT\",%s]", event_json); + + ws_send_text(tls, msg); + ESP_LOGI(TAG, "Published kind %d event (%d bytes)", kind, (int)strlen(event_json)); + free(msg); + free(event_json); + return ESP_OK; +} + +static void publish_announcements_via_ws(esp_tls_t *tls) +{ + const tollgate_identity_t *id = identity_get(); + if (!id || !id->initialized) return; + + ESP_LOGI(TAG, "Publishing CEP-6 announcements via active WS"); + + cJSON *ann_content = cJSON_CreateObject(); + cJSON_AddStringToObject(ann_content, "protocolVersion", CVM_VERSION); + cJSON *capabilities = cJSON_CreateObject(); + cJSON *tools_cap = cJSON_CreateObject(); + cJSON_AddBoolToObject(tools_cap, "listChanged", true); + cJSON_AddItemToObject(capabilities, "tools", tools_cap); + cJSON_AddItemToObject(ann_content, "capabilities", capabilities); + cJSON *serverInfo = cJSON_CreateObject(); + cJSON_AddStringToObject(serverInfo, "name", CVM_SERVER_NAME); + cJSON_AddStringToObject(serverInfo, "version", CVM_SERVER_VERSION); + cJSON_AddItemToObject(ann_content, "serverInfo", serverInfo); + char *ann_str = cJSON_PrintUnformatted(ann_content); + cJSON_Delete(ann_content); + + cJSON *ann_tags = cJSON_CreateArray(); + cJSON *name_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(name_tag, cJSON_CreateString("name")); + cJSON_AddItemToArray(name_tag, cJSON_CreateString(CVM_SERVER_NAME)); + cJSON_AddItemToArray(ann_tags, name_tag); + cJSON *about_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(about_tag, cJSON_CreateString("about")); + cJSON_AddItemToArray(about_tag, cJSON_CreateString("ESP32 TollGate WiFi hotspot with Cashu e-cash payments")); + cJSON_AddItemToArray(ann_tags, about_tag); + char *ann_tags_str = cJSON_PrintUnformatted(ann_tags); + cJSON_Delete(ann_tags); + + publish_event_via_ws(tls, 11316, ann_str, ann_tags_str); + free(ann_str); + free(ann_tags_str); + + cJSON *tools = build_tools_list(); + cJSON *tools_content = cJSON_CreateObject(); + cJSON_AddItemToObject(tools_content, "tools", tools); + char *tools_str = cJSON_PrintUnformatted(tools_content); + cJSON_Delete(tools_content); + + publish_event_via_ws(tls, 11317, tools_str, "[]"); + free(tools_str); + + cJSON *relay_tags = cJSON_CreateArray(); + const char *relays[] = {"wss://relay.primal.net", "wss://nostr-pub.wellorder.net", NULL}; + for (int i = 0; relays[i]; i++) { + cJSON *r_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(r_tag, cJSON_CreateString("r")); + cJSON_AddItemToArray(r_tag, cJSON_CreateString(relays[i])); + cJSON_AddItemToArray(relay_tags, r_tag); + } + char *relay_tags_str = cJSON_PrintUnformatted(relay_tags); + cJSON_Delete(relay_tags); + + publish_event_via_ws(tls, 10002, "", relay_tags_str); + free(relay_tags_str); + + ESP_LOGI(TAG, "CEP-6 announcements published (kinds 11316, 11317, 10002)"); +} + +esp_err_t cvm_publish_announcements(void) +{ + return ESP_OK; +} + +const char *cvm_get_pubkey_hex(void) +{ + const tollgate_identity_t *id = identity_get(); + if (!id || !id->initialized) return NULL; + return id->npub_hex; +} + esp_err_t cvm_server_init(void) { ESP_LOGI(TAG, "CVM server initialized"); @@ -225,7 +777,12 @@ void cvm_server_start(void) { if (g_running) return; g_running = true; - xTaskCreate(cvm_task, "cvm_server", 8192, NULL, 5, &g_task); + + const tollgate_config_t *cfg = tollgate_config_get(); + const char *relay = (cfg->cvm_relays[0]) ? cfg->cvm_relays : "wss://relay.primal.net"; + + char *relay_copy = strdup(relay); + xTaskCreate(cvm_relay_task, "cvm_relay", 16384, relay_copy, 5, &g_task); } void cvm_server_stop(void) diff --git a/main/cvm_server.h b/main/cvm_server.h index d336514..864973b 100644 --- a/main/cvm_server.h +++ b/main/cvm_server.h @@ -7,4 +7,8 @@ esp_err_t cvm_server_init(void); void cvm_server_start(void); void cvm_server_stop(void); +esp_err_t cvm_publish_announcements(void); + +const char *cvm_get_pubkey_hex(void); + #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 @@ #include "mcp_handler.h" #include "config.h" #include "nucula_wallet.h" +#include "session.h" #include "cJSON.h" +#include "lwip/ip4_addr.h" #include #include @@ -14,6 +16,12 @@ mcp_tool_t mcp_parse_tool(const char *method) if (strcmp(method, "set_config") == 0) return MCP_TOOL_SET_CONFIG; if (strcmp(method, "get_balance") == 0) return MCP_TOOL_GET_BALANCE; if (strcmp(method, "wallet_send") == 0) return MCP_TOOL_WALLET_SEND; + if (strcmp(method, "get_sessions") == 0) return MCP_TOOL_GET_SESSIONS; + if (strcmp(method, "get_usage") == 0) return MCP_TOOL_GET_USAGE; + if (strcmp(method, "set_payout") == 0) return MCP_TOOL_SET_PAYOUT; + if (strcmp(method, "set_metric") == 0) return MCP_TOOL_SET_METRIC; + if (strcmp(method, "set_price") == 0) return MCP_TOOL_SET_PRICE; + if (strcmp(method, "wallet_melt") == 0) return MCP_TOOL_WALLET_MELT; return MCP_TOOL_UNKNOWN; } @@ -146,6 +154,222 @@ mcp_response_t mcp_handle_wallet_send(const char *params_json) return resp; } +mcp_response_t mcp_handle_get_sessions(void) +{ + mcp_response_t resp = {0}; + extern session_t *cvm_get_sessions_array(void); + extern int cvm_get_sessions_count(void); + + cJSON *arr = cJSON_CreateArray(); + int count = cvm_get_sessions_count(); + session_t *sessions = cvm_get_sessions_array(); + + if (sessions && count > 0) { + for (int i = 0; i < count; i++) { + if (!sessions[i].active) continue; + cJSON *s = cJSON_CreateObject(); + esp_ip4_addr_t ip = { .addr = sessions[i].client_ip }; + char ip_str[16]; + snprintf(ip_str, sizeof(ip_str), IPSTR, IP2STR(&ip)); + cJSON_AddStringToObject(s, "client_ip", ip_str); + if (sessions[i].mac[0]) + cJSON_AddStringToObject(s, "mac", sessions[i].mac); + cJSON_AddNumberToObject(s, "allotment_ms", (double)sessions[i].allotment_ms); + cJSON_AddNumberToObject(s, "allotment_bytes", (double)sessions[i].allotment_bytes); + cJSON_AddNumberToObject(s, "bytes_consumed", (double)sessions[i].bytes_consumed); + cJSON_AddBoolToObject(s, "active", sessions[i].active); + cJSON_AddItemToArray(arr, s); + } + } + + char *json = cJSON_PrintUnformatted(arr); + snprintf(resp.result_json, sizeof(resp.result_json), "%s", json); + cJSON_free(json); + cJSON_Delete(arr); + resp.success = true; + return resp; +} + +mcp_response_t mcp_handle_get_usage(void) +{ + mcp_response_t resp = {0}; + const tollgate_config_t *cfg = tollgate_config_get(); + + cJSON *root = cJSON_CreateObject(); + cJSON_AddStringToObject(root, "metric", cfg->metric); + cJSON_AddNumberToObject(root, "price_per_step", cfg->price_per_step); + cJSON_AddNumberToObject(root, "step_size_ms", cfg->step_size_ms); + cJSON_AddNumberToObject(root, "step_size_bytes", cfg->step_size_bytes); + cJSON_AddBoolToObject(root, "client_enabled", cfg->client_enabled); + + char *json = cJSON_PrintUnformatted(root); + snprintf(resp.result_json, sizeof(resp.result_json), "%s", json); + cJSON_free(json); + cJSON_Delete(root); + resp.success = true; + return resp; +} + +mcp_response_t mcp_handle_set_payout(const char *params_json) +{ + mcp_response_t resp = {0}; + cJSON *root = cJSON_Parse(params_json); + if (!root) { + resp.success = false; + snprintf(resp.error, sizeof(resp.error), "Invalid JSON params"); + return resp; + } + + tollgate_config_t *cfg = (tollgate_config_t *)tollgate_config_get(); + if (!cfg) { + cJSON_Delete(root); + resp.success = false; + snprintf(resp.error, sizeof(resp.error), "Config not loaded"); + return resp; + } + + cJSON *enabled = cJSON_GetObjectItem(root, "enabled"); + if (enabled && cJSON_IsBool(enabled)) cfg->payout.enabled = cJSON_IsTrue(enabled); + + cJSON *recipients = cJSON_GetObjectItem(root, "recipients"); + if (recipients && cJSON_IsArray(recipients)) { + int rcount = cJSON_GetArraySize(recipients); + if (rcount > PAYOUT_MAX_RECIPIENTS) rcount = PAYOUT_MAX_RECIPIENTS; + for (int i = 0; i < rcount; i++) { + cJSON *r = cJSON_GetArrayItem(recipients, i); + cJSON *addr = cJSON_GetObjectItem(r, "lightning_address"); + cJSON *factor = cJSON_GetObjectItem(r, "factor"); + if (addr && cJSON_IsString(addr)) { + strncpy(cfg->payout.recipients[i].lightning_address, addr->valuestring, + sizeof(cfg->payout.recipients[i].lightning_address) - 1); + } + if (factor && cJSON_IsNumber(factor)) { + cfg->payout.recipients[i].factor = factor->valuedouble; + } + } + cfg->payout.recipient_count = rcount; + } + + cJSON_Delete(root); + resp.success = true; + snprintf(resp.result_json, sizeof(resp.result_json), "{\"status\":\"ok\"}"); + return resp; +} + +mcp_response_t mcp_handle_set_metric(const char *params_json) +{ + mcp_response_t resp = {0}; + cJSON *root = cJSON_Parse(params_json); + if (!root) { + resp.success = false; + snprintf(resp.error, sizeof(resp.error), "Invalid JSON params"); + return resp; + } + + tollgate_config_t *cfg = (tollgate_config_t *)tollgate_config_get(); + if (!cfg) { + cJSON_Delete(root); + resp.success = false; + snprintf(resp.error, sizeof(resp.error), "Config not loaded"); + return resp; + } + + cJSON *metric = cJSON_GetObjectItem(root, "metric"); + if (metric && cJSON_IsString(metric)) { + const char *m = metric->valuestring; + if (strcmp(m, "bytes") == 0 || strcmp(m, "milliseconds") == 0) { + strncpy(cfg->metric, m, sizeof(cfg->metric) - 1); + } else { + cJSON_Delete(root); + resp.success = false; + snprintf(resp.error, sizeof(resp.error), "Invalid metric: must be 'bytes' or 'milliseconds'"); + return resp; + } + } else { + cJSON_Delete(root); + resp.success = false; + snprintf(resp.error, sizeof(resp.error), "Missing 'metric' field"); + return resp; + } + + cJSON_Delete(root); + resp.success = true; + snprintf(resp.result_json, sizeof(resp.result_json), "{\"status\":\"ok\",\"metric\":\"%s\"}", cfg->metric); + return resp; +} + +mcp_response_t mcp_handle_set_price(const char *params_json) +{ + mcp_response_t resp = {0}; + cJSON *root = cJSON_Parse(params_json); + if (!root) { + resp.success = false; + snprintf(resp.error, sizeof(resp.error), "Invalid JSON params"); + return resp; + } + + tollgate_config_t *cfg = (tollgate_config_t *)tollgate_config_get(); + if (!cfg) { + cJSON_Delete(root); + resp.success = false; + snprintf(resp.error, sizeof(resp.error), "Config not loaded"); + return resp; + } + + cJSON *price = cJSON_GetObjectItem(root, "price_per_step"); + if (price && cJSON_IsNumber(price) && price->valueint > 0) { + cfg->price_per_step = price->valueint; + } else { + cJSON_Delete(root); + resp.success = false; + snprintf(resp.error, sizeof(resp.error), "Missing or invalid 'price_per_step' field"); + return resp; + } + + cJSON_Delete(root); + resp.success = true; + snprintf(resp.result_json, sizeof(resp.result_json), + "{\"status\":\"ok\",\"price_per_step\":%d}", cfg->price_per_step); + return resp; +} + +mcp_response_t mcp_handle_wallet_melt(const char *params_json) +{ + mcp_response_t resp = {0}; + cJSON *root = cJSON_Parse(params_json); + if (!root) { + resp.success = false; + snprintf(resp.error, sizeof(resp.error), "Invalid JSON params"); + return resp; + } + + cJSON *bolt11 = cJSON_GetObjectItem(root, "bolt11"); + if (!bolt11 || !cJSON_IsString(bolt11)) { + cJSON_Delete(root); + resp.success = false; + snprintf(resp.error, sizeof(resp.error), "Missing 'bolt11' field"); + return resp; + } + + cJSON *max_fee = cJSON_GetObjectItem(root, "max_fee_sats"); + uint64_t fee = 10; + if (max_fee && cJSON_IsNumber(max_fee)) fee = (uint64_t)max_fee->valuedouble; + + esp_err_t rc = nucula_wallet_melt(bolt11->valuestring, fee); + + if (rc != ESP_OK) { + cJSON_Delete(root); + resp.success = false; + snprintf(resp.error, sizeof(resp.error), "Melt failed: %s", esp_err_to_name(rc)); + return resp; + } + + cJSON_Delete(root); + resp.success = true; + snprintf(resp.result_json, sizeof(resp.result_json), "{\"status\":\"ok\"}"); + return resp; +} + mcp_response_t mcp_dispatch(const mcp_request_t *req) { if (!req) { @@ -164,6 +388,18 @@ mcp_response_t mcp_dispatch(const mcp_request_t *req) return mcp_handle_get_balance(); case MCP_TOOL_WALLET_SEND: return mcp_handle_wallet_send(req->params_json); + case MCP_TOOL_GET_SESSIONS: + return mcp_handle_get_sessions(); + case MCP_TOOL_GET_USAGE: + return mcp_handle_get_usage(); + case MCP_TOOL_SET_PAYOUT: + return mcp_handle_set_payout(req->params_json); + case MCP_TOOL_SET_METRIC: + return mcp_handle_set_metric(req->params_json); + case MCP_TOOL_SET_PRICE: + return mcp_handle_set_price(req->params_json); + case MCP_TOOL_WALLET_MELT: + return mcp_handle_wallet_melt(req->params_json); default: break; } 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 { MCP_TOOL_SET_CONFIG = 1, MCP_TOOL_GET_BALANCE = 2, MCP_TOOL_WALLET_SEND = 3, + MCP_TOOL_GET_SESSIONS = 4, + MCP_TOOL_GET_USAGE = 5, + MCP_TOOL_SET_PAYOUT = 6, + MCP_TOOL_SET_METRIC = 7, + MCP_TOOL_SET_PRICE = 8, + MCP_TOOL_WALLET_MELT = 9, MCP_TOOL_UNKNOWN = 99 } mcp_tool_t; @@ -30,6 +36,12 @@ mcp_response_t mcp_handle_get_config(void); mcp_response_t mcp_handle_set_config(const char *params_json); mcp_response_t mcp_handle_get_balance(void); mcp_response_t mcp_handle_wallet_send(const char *params_json); +mcp_response_t mcp_handle_get_sessions(void); +mcp_response_t mcp_handle_get_usage(void); +mcp_response_t mcp_handle_set_payout(const char *params_json); +mcp_response_t mcp_handle_set_metric(const char *params_json); +mcp_response_t mcp_handle_set_price(const char *params_json); +mcp_response_t mcp_handle_wallet_melt(const char *params_json); mcp_response_t mcp_dispatch(const mcp_request_t *req); 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) { session_check_expiry(); } + +session_t *cvm_get_sessions_array(void) +{ + return s_sessions; +} + +int cvm_get_sessions_count(void) +{ + return SESSION_MAX_CLIENTS; +} 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); void session_tick(void); +session_t *cvm_get_sessions_array(void); +int cvm_get_sessions_count(void); + #endif diff --git a/main/tollgate_main.c b/main/tollgate_main.c index 1350d70..ad5211a 100644 --- a/main/tollgate_main.c +++ b/main/tollgate_main.c @@ -9,6 +9,7 @@ #include "esp_netif.h" #include "lwip/netif.h" #include "lwip/dns.h" +#include "esp_sntp.h" #include "dhcpserver/dhcpserver.h" #include "config.h" #include "identity.h" @@ -22,6 +23,7 @@ #include "tollgate_client.h" #include "lightning_payout.h" #include "cvm_server.h" +#include "display.h" #define MAX_STA_RETRY 5 static const char *TAG = "tollgate_main"; @@ -54,6 +56,7 @@ static void wifi_event_handler(void *arg, esp_event_base_t event_base, tollgate_client_on_sta_disconnected(); if (s_services_running) stop_services(); if (s_retry_count < MAX_STA_RETRY) { + vTaskDelay(pdMS_TO_TICKS(2000)); esp_wifi_connect(); } else { wifi_config_t wifi_cfg; @@ -94,6 +97,13 @@ static void ip_event_handler(void *arg, esp_event_base_t event_base, s_retry_count = 0; xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT); + esp_sntp_stop(); + esp_sntp_setoperatingmode(SNTP_OPMODE_POLL); + esp_sntp_setservername(0, "pool.ntp.org"); + esp_sntp_setservername(1, "time.google.com"); + esp_sntp_init(); + ESP_LOGI(TAG, "SNTP time sync started"); + char gw_ip_str[16]; snprintf(gw_ip_str, sizeof(gw_ip_str), IPSTR, IP2STR(&event->ip_info.gw)); tollgate_client_on_sta_connected(gw_ip_str); @@ -160,6 +170,11 @@ static void start_services(void) s_services_running = true; if (s_services_mutex) xSemaphoreGive(s_services_mutex); ESP_LOGI(TAG, "=== TollGate services started ==="); + + display_set_state(DISPLAY_READY); + char portal_url[128]; + snprintf(portal_url, sizeof(portal_url), "http://%s/", cfg->ap_ip_str); + display_update(cfg->ap_ssid, 0, 0, portal_url); } static void stop_services(void) @@ -240,6 +255,9 @@ void app_main(void) { ESP_LOGI(TAG, "=== TollGate ESP32 Starting ==="); + display_init(); + display_set_state(DISPLAY_BOOT); + esp_err_t ret = nvs_flash_init(); if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { ESP_ERROR_CHECK(nvs_flash_erase()); @@ -288,6 +306,9 @@ void app_main(void) ESP_LOGI(TAG, "STA configured for SSID: %s", tcfg2->networks[tcfg2->current_network].ssid); } + ESP_ERROR_CHECK(esp_wifi_set_country_code("DE", false)); + ESP_LOGI(TAG, "WiFi country code set to DE (EU regulatory domain)"); + ESP_ERROR_CHECK(esp_wifi_start()); ESP_LOGI(TAG, "WiFi AP+STA started, waiting for connection..."); -- cgit v1.2.3