diff options
| author | Your Name <you@example.com> | 2026-05-16 15:32:55 +0530 |
|---|---|---|
| committer | Your Name <you@example.com> | 2026-05-16 15:32:55 +0530 |
| commit | 133e40c82afb4d7659758b1fa57925ac57af4621 (patch) | |
| tree | 130f43e668f42f8fcc8ef55808ba89b6db40f615 /main | |
| parent | 8f0aeb7d7b8216f1fc906cf855e5be9e90ecc0a8 (diff) | |
Phase 3: on-device Cashu wallet with mbedTLS secp256k1 + SPIFFS persistence + PSRAM
- wallet.c/h: secp256k1 ECP primitives (hash_to_curve, scalar_mul, point_add)
- wallet_persist.c/h: SPIFFS persistence with threshold-based write protection
- Fee accounting for swap (input_fee_ppk from /v1/keysets)
- Keyset fetch via /v1/keysets (586 bytes vs 21KB for /v1/keys)
- Wallet API: GET /wallet, POST /wallet/swap, POST /wallet/send
- Payment proofs auto-stored to wallet + persisted on SPIFFS
- PSRAM enabled for large allocations (ESP32-S3 has 8MB)
- Wallet init deferred to dedicated task (avoids sys_evt stack overflow)
- Cashu proof ID buffer size fixed (66 hex chars, not 16)
- HTTP client: added fetch_headers() call for proper response handling
- persist_threshold_sats config parameter (default: 1 sat)
Diffstat (limited to 'main')
| -rw-r--r-- | main/CMakeLists.txt | 2 | ||||
| -rw-r--r-- | main/cashu.h | 2 | ||||
| -rw-r--r-- | main/config.c | 4 | ||||
| -rw-r--r-- | main/config.h | 1 | ||||
| -rw-r--r-- | main/tollgate_api.c | 134 | ||||
| -rw-r--r-- | main/tollgate_main.c | 11 | ||||
| -rw-r--r-- | main/wallet.c | 639 | ||||
| -rw-r--r-- | main/wallet.h | 53 | ||||
| -rw-r--r-- | main/wallet_persist.c | 147 | ||||
| -rw-r--r-- | main/wallet_persist.h | 9 |
10 files changed, 999 insertions, 3 deletions
diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 5650309..2eef030 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt | |||
| @@ -6,6 +6,8 @@ idf_component_register(SRCS "tollgate_main.c" | |||
| 6 | "cashu.c" | 6 | "cashu.c" |
| 7 | "session.c" | 7 | "session.c" |
| 8 | "tollgate_api.c" | 8 | "tollgate_api.c" |
| 9 | "wallet.c" | ||
| 10 | "wallet_persist.c" | ||
| 9 | INCLUDE_DIRS "." "${IDF_PATH}/components/lwip/include/apps" | 11 | INCLUDE_DIRS "." "${IDF_PATH}/components/lwip/include/apps" |
| 10 | REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server | 12 | REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server |
| 11 | lwip json esp_http_client mbedtls esp-tls log spiffs | 13 | lwip json esp_http_client mbedtls esp-tls log spiffs |
diff --git a/main/cashu.h b/main/cashu.h index 17891c5..4c3d43b 100644 --- a/main/cashu.h +++ b/main/cashu.h | |||
| @@ -7,7 +7,7 @@ | |||
| 7 | 7 | ||
| 8 | #define CASHU_MAX_PROOFS 10 | 8 | #define CASHU_MAX_PROOFS 10 |
| 9 | #define CASHU_MAX_SECRET_LEN 128 | 9 | #define CASHU_MAX_SECRET_LEN 128 |
| 10 | #define CASHU_MAX_ID_LEN 16 | 10 | #define CASHU_MAX_ID_LEN 68 |
| 11 | #define CASHU_MAX_C_LEN 128 | 11 | #define CASHU_MAX_C_LEN 128 |
| 12 | 12 | ||
| 13 | typedef struct { | 13 | typedef struct { |
diff --git a/main/config.c b/main/config.c index d7837bc..7e8a14c 100644 --- a/main/config.c +++ b/main/config.c | |||
| @@ -19,6 +19,7 @@ esp_err_t tollgate_config_init(void) | |||
| 19 | g_config.ap_max_conn = 4; | 19 | g_config.ap_max_conn = 4; |
| 20 | g_config.price_per_step = 21; | 20 | g_config.price_per_step = 21; |
| 21 | g_config.step_size_ms = 60000; | 21 | g_config.step_size_ms = 60000; |
| 22 | g_config.persist_threshold_sats = 1; | ||
| 22 | 23 | ||
| 23 | esp_vfs_spiffs_conf_t conf = { | 24 | esp_vfs_spiffs_conf_t conf = { |
| 24 | .base_path = "/spiffs", | 25 | .base_path = "/spiffs", |
| @@ -117,6 +118,9 @@ esp_err_t tollgate_config_init(void) | |||
| 117 | cJSON *step = cJSON_GetObjectItem(root, "step_size_ms"); | 118 | cJSON *step = cJSON_GetObjectItem(root, "step_size_ms"); |
| 118 | if (step) g_config.step_size_ms = step->valueint; | 119 | if (step) g_config.step_size_ms = step->valueint; |
| 119 | 120 | ||
| 121 | cJSON *persist = cJSON_GetObjectItem(root, "persist_threshold_sats"); | ||
| 122 | if (persist) g_config.persist_threshold_sats = (uint64_t)persist->valuedouble; | ||
| 123 | |||
| 120 | cJSON_Delete(root); | 124 | cJSON_Delete(root); |
| 121 | ESP_LOGI(TAG, "Config loaded: AP='%s', %d WiFi networks, price=%d sats/%dms", | 125 | ESP_LOGI(TAG, "Config loaded: AP='%s', %d WiFi networks, price=%d sats/%dms", |
| 122 | g_config.ap_ssid, g_config.network_count, g_config.price_per_step, g_config.step_size_ms); | 126 | g_config.ap_ssid, g_config.network_count, g_config.price_per_step, g_config.step_size_ms); |
diff --git a/main/config.h b/main/config.h index dd3fe05..2bcd400 100644 --- a/main/config.h +++ b/main/config.h | |||
| @@ -34,6 +34,7 @@ typedef struct { | |||
| 34 | char lnurl_url[256]; | 34 | char lnurl_url[256]; |
| 35 | int price_per_step; | 35 | int price_per_step; |
| 36 | int step_size_ms; | 36 | int step_size_ms; |
| 37 | uint64_t persist_threshold_sats; | ||
| 37 | 38 | ||
| 38 | bool unique_derived; | 39 | bool unique_derived; |
| 39 | } tollgate_config_t; | 40 | } tollgate_config_t; |
diff --git a/main/tollgate_api.c b/main/tollgate_api.c index efb5cdf..e6880e0 100644 --- a/main/tollgate_api.c +++ b/main/tollgate_api.c | |||
| @@ -3,6 +3,7 @@ | |||
| 3 | #include "config.h" | 3 | #include "config.h" |
| 4 | #include "session.h" | 4 | #include "session.h" |
| 5 | #include "firewall.h" | 5 | #include "firewall.h" |
| 6 | #include "wallet.h" | ||
| 6 | #include "esp_log.h" | 7 | #include "esp_log.h" |
| 7 | #include "cJSON.h" | 8 | #include "cJSON.h" |
| 8 | #include "lwip/sockets.h" | 9 | #include "lwip/sockets.h" |
| @@ -298,9 +299,9 @@ static esp_err_t api_post_payment(httpd_req_t *req) | |||
| 298 | secrets[i] = token->proofs[i].secret; | 299 | secrets[i] = token->proofs[i].secret; |
| 299 | } | 300 | } |
| 300 | session_t *session = session_create(client_ip, allotment, secrets, secret_count); | 301 | session_t *session = session_create(client_ip, allotment, secrets, secret_count); |
| 301 | free(states); | ||
| 302 | free(token); | ||
| 303 | if (!session) { | 302 | if (!session) { |
| 303 | free(states); | ||
| 304 | free(token); | ||
| 304 | cJSON *notice = create_notice("error", "session-error", "Failed to create session"); | 305 | cJSON *notice = create_notice("error", "session-error", "Failed to create session"); |
| 305 | char *json = cJSON_PrintUnformatted(notice); | 306 | char *json = cJSON_PrintUnformatted(notice); |
| 306 | httpd_resp_set_status(req, "503 Service Unavailable"); | 307 | httpd_resp_set_status(req, "503 Service Unavailable"); |
| @@ -317,6 +318,21 @@ static esp_err_t api_post_payment(httpd_req_t *req) | |||
| 317 | httpd_resp_send(req, json, strlen(json)); | 318 | httpd_resp_send(req, json, strlen(json)); |
| 318 | cJSON_free(json); | 319 | cJSON_free(json); |
| 319 | cJSON_Delete(session_event); | 320 | cJSON_Delete(session_event); |
| 321 | |||
| 322 | { | ||
| 323 | wallet_proof_t wproofs[CASHU_MAX_PROOFS]; | ||
| 324 | int wcount = token->proof_count > CASHU_MAX_PROOFS ? CASHU_MAX_PROOFS : token->proof_count; | ||
| 325 | for (int i = 0; i < wcount; i++) { | ||
| 326 | wproofs[i].amount = token->proofs[i].amount; | ||
| 327 | strncpy(wproofs[i].id, token->proofs[i].id, WALLET_KEYSET_ID_LEN - 1); | ||
| 328 | strncpy(wproofs[i].secret, token->proofs[i].secret, WALLET_SECRET_LEN - 1); | ||
| 329 | strncpy(wproofs[i].c, token->proofs[i].c, WALLET_SIG_LEN - 1); | ||
| 330 | } | ||
| 331 | wallet_add_proofs(wproofs, wcount); | ||
| 332 | } | ||
| 333 | |||
| 334 | free(states); | ||
| 335 | free(token); | ||
| 320 | return ESP_OK; | 336 | return ESP_OK; |
| 321 | } | 337 | } |
| 322 | 338 | ||
| @@ -363,10 +379,121 @@ static esp_err_t api_get_whoami(httpd_req_t *req) | |||
| 363 | return ESP_OK; | 379 | return ESP_OK; |
| 364 | } | 380 | } |
| 365 | 381 | ||
| 382 | static esp_err_t api_get_wallet(httpd_req_t *req) | ||
| 383 | { | ||
| 384 | wallet_t *w = wallet_get(); | ||
| 385 | cJSON *root = cJSON_CreateObject(); | ||
| 386 | cJSON_AddNumberToObject(root, "balance", (double)w->balance); | ||
| 387 | cJSON_AddNumberToObject(root, "proof_count", w->proof_count); | ||
| 388 | cJSON_AddNumberToObject(root, "keyset_count", w->keyset_count); | ||
| 389 | |||
| 390 | cJSON *proofs = cJSON_CreateArray(); | ||
| 391 | for (int i = 0; i < w->proof_count; i++) { | ||
| 392 | cJSON *p = cJSON_CreateObject(); | ||
| 393 | cJSON_AddNumberToObject(p, "amount", (double)w->proofs[i].amount); | ||
| 394 | cJSON_AddStringToObject(p, "id", w->proofs[i].id); | ||
| 395 | cJSON_AddItemToArray(proofs, p); | ||
| 396 | } | ||
| 397 | cJSON_AddItemToObject(root, "proofs", proofs); | ||
| 398 | |||
| 399 | char *json = cJSON_PrintUnformatted(root); | ||
| 400 | httpd_resp_set_type(req, "application/json"); | ||
| 401 | httpd_resp_send(req, json, strlen(json)); | ||
| 402 | cJSON_free(json); | ||
| 403 | cJSON_Delete(root); | ||
| 404 | return ESP_OK; | ||
| 405 | } | ||
| 406 | |||
| 407 | static esp_err_t api_post_wallet_swap(httpd_req_t *req) | ||
| 408 | { | ||
| 409 | const tollgate_config_t *cfg = tollgate_config_get(); | ||
| 410 | |||
| 411 | if (wallet_balance() == 0) { | ||
| 412 | httpd_resp_set_status(req, "400 Bad Request"); | ||
| 413 | httpd_resp_set_type(req, "application/json"); | ||
| 414 | httpd_resp_send(req, "{\"error\":\"no proofs to swap\"}", 27); | ||
| 415 | return ESP_OK; | ||
| 416 | } | ||
| 417 | |||
| 418 | wallet_print_status(); | ||
| 419 | |||
| 420 | esp_err_t err = wallet_fetch_keysets(cfg->mint_url); | ||
| 421 | if (err != ESP_OK) { | ||
| 422 | httpd_resp_set_status(req, "502 Bad Gateway"); | ||
| 423 | httpd_resp_set_type(req, "application/json"); | ||
| 424 | httpd_resp_send(req, "{\"error\":\"keyset fetch failed\"}", 29); | ||
| 425 | return ESP_OK; | ||
| 426 | } | ||
| 427 | |||
| 428 | wallet_t *w = wallet_get(); | ||
| 429 | err = wallet_swap_proofs(cfg->mint_url, 0, w->proof_count); | ||
| 430 | if (err != ESP_OK) { | ||
| 431 | httpd_resp_set_status(req, "502 Bad Gateway"); | ||
| 432 | httpd_resp_set_type(req, "application/json"); | ||
| 433 | httpd_resp_send(req, "{\"error\":\"swap failed\"}", 21); | ||
| 434 | return ESP_OK; | ||
| 435 | } | ||
| 436 | |||
| 437 | wallet_print_status(); | ||
| 438 | |||
| 439 | cJSON *root = cJSON_CreateObject(); | ||
| 440 | cJSON_AddNumberToObject(root, "balance", (double)wallet_balance()); | ||
| 441 | cJSON_AddNumberToObject(root, "proof_count", wallet_get()->proof_count); | ||
| 442 | char *json = cJSON_PrintUnformatted(root); | ||
| 443 | httpd_resp_set_type(req, "application/json"); | ||
| 444 | httpd_resp_send(req, json, strlen(json)); | ||
| 445 | cJSON_free(json); | ||
| 446 | cJSON_Delete(root); | ||
| 447 | return ESP_OK; | ||
| 448 | } | ||
| 449 | |||
| 450 | static esp_err_t api_post_wallet_send(httpd_req_t *req) | ||
| 451 | { | ||
| 452 | int content_len = req->content_len; | ||
| 453 | if (content_len <= 0 || content_len > 32) { | ||
| 454 | httpd_resp_set_status(req, "400 Bad Request"); | ||
| 455 | httpd_resp_send(req, "invalid amount", 14); | ||
| 456 | return ESP_OK; | ||
| 457 | } | ||
| 458 | |||
| 459 | char body[32]; | ||
| 460 | int total = 0; | ||
| 461 | while (total < content_len) { | ||
| 462 | int r = httpd_req_recv(req, body + total, content_len - total); | ||
| 463 | if (r <= 0) { httpd_resp_send_500(req); return ESP_OK; } | ||
| 464 | total += r; | ||
| 465 | } | ||
| 466 | body[total] = '\0'; | ||
| 467 | |||
| 468 | uint64_t amount = strtoull(body, NULL, 10); | ||
| 469 | if (amount == 0) { | ||
| 470 | httpd_resp_set_status(req, "400 Bad Request"); | ||
| 471 | httpd_resp_send(req, "invalid amount", 14); | ||
| 472 | return ESP_OK; | ||
| 473 | } | ||
| 474 | |||
| 475 | const tollgate_config_t *cfg = tollgate_config_get(); | ||
| 476 | char token[4096]; | ||
| 477 | esp_err_t err = wallet_send(cfg->mint_url, amount, token, sizeof(token)); | ||
| 478 | if (err != ESP_OK) { | ||
| 479 | httpd_resp_set_status(req, "402 Payment Required"); | ||
| 480 | httpd_resp_set_type(req, "text/plain"); | ||
| 481 | httpd_resp_send(req, "insufficient balance", 20); | ||
| 482 | return ESP_OK; | ||
| 483 | } | ||
| 484 | |||
| 485 | httpd_resp_set_type(req, "text/plain"); | ||
| 486 | httpd_resp_send(req, token, strlen(token)); | ||
| 487 | return ESP_OK; | ||
| 488 | } | ||
| 489 | |||
| 366 | static const httpd_uri_t uri_discovery = { .uri = "/", .method = HTTP_GET, .handler = api_get_discovery }; | 490 | static const httpd_uri_t uri_discovery = { .uri = "/", .method = HTTP_GET, .handler = api_get_discovery }; |
| 367 | static const httpd_uri_t uri_payment = { .uri = "/", .method = HTTP_POST, .handler = api_post_payment }; | 491 | static const httpd_uri_t uri_payment = { .uri = "/", .method = HTTP_POST, .handler = api_post_payment }; |
| 368 | static const httpd_uri_t uri_usage = { .uri = "/usage", .method = HTTP_GET, .handler = api_get_usage }; | 492 | static const httpd_uri_t uri_usage = { .uri = "/usage", .method = HTTP_GET, .handler = api_get_usage }; |
| 369 | static const httpd_uri_t uri_whoami = { .uri = "/whoami", .method = HTTP_GET, .handler = api_get_whoami }; | 493 | static const httpd_uri_t uri_whoami = { .uri = "/whoami", .method = HTTP_GET, .handler = api_get_whoami }; |
| 494 | static const httpd_uri_t uri_wallet = { .uri = "/wallet", .method = HTTP_GET, .handler = api_get_wallet }; | ||
| 495 | static const httpd_uri_t uri_wallet_swap = { .uri = "/wallet/swap", .method = HTTP_POST, .handler = api_post_wallet_swap }; | ||
| 496 | static const httpd_uri_t uri_wallet_send = { .uri = "/wallet/send", .method = HTTP_POST, .handler = api_post_wallet_send }; | ||
| 370 | 497 | ||
| 371 | esp_err_t tollgate_api_start(void) | 498 | esp_err_t tollgate_api_start(void) |
| 372 | { | 499 | { |
| @@ -388,6 +515,9 @@ esp_err_t tollgate_api_start(void) | |||
| 388 | httpd_register_uri_handler(s_api_server, &uri_payment); | 515 | httpd_register_uri_handler(s_api_server, &uri_payment); |
| 389 | httpd_register_uri_handler(s_api_server, &uri_usage); | 516 | httpd_register_uri_handler(s_api_server, &uri_usage); |
| 390 | httpd_register_uri_handler(s_api_server, &uri_whoami); | 517 | httpd_register_uri_handler(s_api_server, &uri_whoami); |
| 518 | httpd_register_uri_handler(s_api_server, &uri_wallet); | ||
| 519 | httpd_register_uri_handler(s_api_server, &uri_wallet_swap); | ||
| 520 | httpd_register_uri_handler(s_api_server, &uri_wallet_send); | ||
| 391 | 521 | ||
| 392 | ESP_LOGI(TAG, "TollGate API started on port 2121"); | 522 | ESP_LOGI(TAG, "TollGate API started on port 2121"); |
| 393 | return ESP_OK; | 523 | return ESP_OK; |
diff --git a/main/tollgate_main.c b/main/tollgate_main.c index 9d2c392..d4b29bc 100644 --- a/main/tollgate_main.c +++ b/main/tollgate_main.c | |||
| @@ -16,6 +16,7 @@ | |||
| 16 | #include "firewall.h" | 16 | #include "firewall.h" |
| 17 | #include "session.h" | 17 | #include "session.h" |
| 18 | #include "tollgate_api.h" | 18 | #include "tollgate_api.h" |
| 19 | #include "wallet.h" | ||
| 19 | 20 | ||
| 20 | #define MAX_STA_RETRY 5 | 21 | #define MAX_STA_RETRY 5 |
| 21 | static const char *TAG = "tollgate_main"; | 22 | static const char *TAG = "tollgate_main"; |
| @@ -88,6 +89,14 @@ static void ip_event_handler(void *arg, esp_event_base_t event_base, | |||
| 88 | } | 89 | } |
| 89 | } | 90 | } |
| 90 | 91 | ||
| 92 | static void wallet_init_task(void *pvParameters) | ||
| 93 | { | ||
| 94 | const tollgate_config_t *cfg = tollgate_config_get(); | ||
| 95 | wallet_init(); | ||
| 96 | wallet_fetch_keysets(cfg->mint_url); | ||
| 97 | vTaskDelete(NULL); | ||
| 98 | } | ||
| 99 | |||
| 91 | static void start_services(void) | 100 | static void start_services(void) |
| 92 | { | 101 | { |
| 93 | if (s_services_mutex) xSemaphoreTake(s_services_mutex, portMAX_DELAY); | 102 | if (s_services_mutex) xSemaphoreTake(s_services_mutex, portMAX_DELAY); |
| @@ -107,6 +116,8 @@ static void start_services(void) | |||
| 107 | firewall_init(ap_ip_info.ip); | 116 | firewall_init(ap_ip_info.ip); |
| 108 | session_manager_init(); | 117 | session_manager_init(); |
| 109 | 118 | ||
| 119 | xTaskCreate(wallet_init_task, "wallet_init", 32768, NULL, 5, NULL); | ||
| 120 | |||
| 110 | const tollgate_config_t *cfg = tollgate_config_get(); | 121 | const tollgate_config_t *cfg = tollgate_config_get(); |
| 111 | dns_server_start(ap_ip_info.ip, upstream_dns); | 122 | dns_server_start(ap_ip_info.ip, upstream_dns); |
| 112 | captive_portal_start(cfg->ap_ip_str); | 123 | captive_portal_start(cfg->ap_ip_str); |
diff --git a/main/wallet.c b/main/wallet.c new file mode 100644 index 0000000..3f65220 --- /dev/null +++ b/main/wallet.c | |||
| @@ -0,0 +1,639 @@ | |||
| 1 | #include "wallet.h" | ||
| 2 | #include "wallet_persist.h" | ||
| 3 | #include "config.h" | ||
| 4 | #include "esp_log.h" | ||
| 5 | #include "esp_random.h" | ||
| 6 | #include "esp_http_client.h" | ||
| 7 | #include "esp_crt_bundle.h" | ||
| 8 | #include "cJSON.h" | ||
| 9 | #include "mbedtls/ecp.h" | ||
| 10 | #include "mbedtls/bignum.h" | ||
| 11 | #include "mbedtls/sha256.h" | ||
| 12 | #include "mbedtls/base64.h" | ||
| 13 | #include "freertos/FreeRTOS.h" | ||
| 14 | #include "freertos/task.h" | ||
| 15 | #include "freertos/semphr.h" | ||
| 16 | #include "esp_heap_caps.h" | ||
| 17 | #include <string.h> | ||
| 18 | #include <stdio.h> | ||
| 19 | |||
| 20 | static const char *TAG = "wallet"; | ||
| 21 | static wallet_t s_wallet; | ||
| 22 | |||
| 23 | static const char DOMAIN_SEPARATOR[] = "Secp256k1_HashToCurve_Cashu_"; | ||
| 24 | |||
| 25 | static mbedtls_ecp_group s_grp; | ||
| 26 | static mbedtls_mpi s_order; | ||
| 27 | static bool s_grp_loaded = false; | ||
| 28 | |||
| 29 | static esp_err_t init_ecp_group(void) | ||
| 30 | { | ||
| 31 | if (s_grp_loaded) return ESP_OK; | ||
| 32 | mbedtls_ecp_group_init(&s_grp); | ||
| 33 | mbedtls_mpi_init(&s_order); | ||
| 34 | int ret = mbedtls_ecp_group_load(&s_grp, MBEDTLS_ECP_DP_SECP256K1); | ||
| 35 | if (ret != 0) { | ||
| 36 | ESP_LOGE(TAG, "Failed to load secp256k1 group: -0x%x", -ret); | ||
| 37 | return ESP_FAIL; | ||
| 38 | } | ||
| 39 | mbedtls_mpi_copy(&s_order, &s_grp.N); | ||
| 40 | s_grp_loaded = true; | ||
| 41 | return ESP_OK; | ||
| 42 | } | ||
| 43 | |||
| 44 | static void random_bytes(uint8_t *buf, size_t len) | ||
| 45 | { | ||
| 46 | esp_fill_random(buf, len); | ||
| 47 | } | ||
| 48 | |||
| 49 | static esp_err_t random_scalar(mbedtls_mpi *r) | ||
| 50 | { | ||
| 51 | uint8_t buf[32]; | ||
| 52 | random_bytes(buf, 32); | ||
| 53 | mbedtls_mpi_init(r); | ||
| 54 | int ret = mbedtls_mpi_read_binary(r, buf, 32); | ||
| 55 | if (ret != 0) return ESP_FAIL; | ||
| 56 | ret = mbedtls_mpi_mod_mpi(r, r, &s_order); | ||
| 57 | if (ret != 0) return ESP_FAIL; | ||
| 58 | if (mbedtls_mpi_cmp_int(r, 1) < 0) { | ||
| 59 | mbedtls_mpi_add_int(r, r, 1); | ||
| 60 | } | ||
| 61 | return ESP_OK; | ||
| 62 | } | ||
| 63 | |||
| 64 | static esp_err_t hash_to_curve(const uint8_t *msg, size_t msg_len, mbedtls_ecp_point *Y) | ||
| 65 | { | ||
| 66 | uint8_t msg_hash[32]; | ||
| 67 | size_t ds_len = strlen(DOMAIN_SEPARATOR); | ||
| 68 | uint8_t *hash_input = malloc(ds_len + msg_len); | ||
| 69 | if (!hash_input) return ESP_FAIL; | ||
| 70 | memcpy(hash_input, DOMAIN_SEPARATOR, ds_len); | ||
| 71 | memcpy(hash_input + ds_len, msg, msg_len); | ||
| 72 | mbedtls_sha256(hash_input, ds_len + msg_len, msg_hash, 0); | ||
| 73 | free(hash_input); | ||
| 74 | |||
| 75 | mbedtls_ecp_point_init(Y); | ||
| 76 | for (uint32_t counter = 0; counter < 256; counter++) { | ||
| 77 | uint8_t counter_bytes[4]; | ||
| 78 | counter_bytes[0] = counter & 0xFF; | ||
| 79 | counter_bytes[1] = (counter >> 8) & 0xFF; | ||
| 80 | counter_bytes[2] = (counter >> 16) & 0xFF; | ||
| 81 | counter_bytes[3] = (counter >> 24) & 0xFF; | ||
| 82 | |||
| 83 | uint8_t to_hash[32 + 4 + 1]; | ||
| 84 | memcpy(to_hash, msg_hash, 32); | ||
| 85 | memcpy(to_hash + 32, counter_bytes, 4); | ||
| 86 | |||
| 87 | uint8_t point_hash[32]; | ||
| 88 | mbedtls_sha256(to_hash, 36, point_hash, 0); | ||
| 89 | |||
| 90 | uint8_t compressed[33]; | ||
| 91 | compressed[0] = 0x02; | ||
| 92 | memcpy(compressed + 1, point_hash, 32); | ||
| 93 | |||
| 94 | int ret = mbedtls_ecp_point_read_binary(&s_grp, Y, compressed, 33); | ||
| 95 | if (ret == 0) { | ||
| 96 | ret = mbedtls_ecp_check_pubkey(&s_grp, Y); | ||
| 97 | if (ret == 0) return ESP_OK; | ||
| 98 | } | ||
| 99 | |||
| 100 | compressed[0] = 0x03; | ||
| 101 | ret = mbedtls_ecp_point_read_binary(&s_grp, Y, compressed, 33); | ||
| 102 | if (ret == 0) { | ||
| 103 | ret = mbedtls_ecp_check_pubkey(&s_grp, Y); | ||
| 104 | if (ret == 0) return ESP_OK; | ||
| 105 | } | ||
| 106 | } | ||
| 107 | |||
| 108 | ESP_LOGE(TAG, "hash_to_curve failed after 256 attempts"); | ||
| 109 | return ESP_FAIL; | ||
| 110 | } | ||
| 111 | |||
| 112 | static esp_err_t point_add(const mbedtls_ecp_point *A, const mbedtls_ecp_point *B, | ||
| 113 | mbedtls_ecp_point *R) | ||
| 114 | { | ||
| 115 | mbedtls_mpi one; | ||
| 116 | mbedtls_mpi_init(&one); | ||
| 117 | mbedtls_mpi_lset(&one, 1); | ||
| 118 | int ret = mbedtls_ecp_muladd(&s_grp, R, &one, A, &one, B); | ||
| 119 | if (ret != 0) { | ||
| 120 | ESP_LOGE(TAG, "point_add failed: -0x%x", -ret); | ||
| 121 | } | ||
| 122 | mbedtls_mpi_free(&one); | ||
| 123 | return (ret == 0) ? ESP_OK : ESP_FAIL; | ||
| 124 | } | ||
| 125 | |||
| 126 | static esp_err_t scalar_mul(const mbedtls_mpi *m, const mbedtls_ecp_point *P, | ||
| 127 | mbedtls_ecp_point *R) | ||
| 128 | { | ||
| 129 | int ret = mbedtls_ecp_mul(&s_grp, R, m, P, NULL, NULL); | ||
| 130 | if (ret != 0) { | ||
| 131 | ESP_LOGE(TAG, "scalar_mul failed: -0x%x", -ret); | ||
| 132 | } | ||
| 133 | return (ret == 0) ? ESP_OK : ESP_FAIL; | ||
| 134 | } | ||
| 135 | |||
| 136 | static int hex_to_bytes(const char *hex, uint8_t *bytes, size_t bytes_len) | ||
| 137 | { | ||
| 138 | size_t hex_len = strlen(hex); | ||
| 139 | if (hex_len / 2 > bytes_len) return -1; | ||
| 140 | for (size_t i = 0; i < hex_len / 2; i++) { | ||
| 141 | unsigned int b; | ||
| 142 | sscanf(hex + i * 2, "%02x", &b); | ||
| 143 | bytes[i] = (uint8_t)b; | ||
| 144 | } | ||
| 145 | return hex_len / 2; | ||
| 146 | } | ||
| 147 | |||
| 148 | static void bytes_to_hex(const uint8_t *bytes, size_t len, char *hex) | ||
| 149 | { | ||
| 150 | for (size_t i = 0; i < len; i++) { | ||
| 151 | sprintf(hex + i * 2, "%02x", bytes[i]); | ||
| 152 | } | ||
| 153 | hex[len * 2] = '\0'; | ||
| 154 | } | ||
| 155 | |||
| 156 | esp_err_t wallet_init(void) | ||
| 157 | { | ||
| 158 | memset(&s_wallet, 0, sizeof(s_wallet)); | ||
| 159 | esp_err_t err = init_ecp_group(); | ||
| 160 | if (err != ESP_OK) return err; | ||
| 161 | wallet_persist_load(); | ||
| 162 | ESP_LOGI(TAG, "Wallet initialized (secp256k1 loaded)"); | ||
| 163 | return ESP_OK; | ||
| 164 | } | ||
| 165 | |||
| 166 | wallet_t *wallet_get(void) | ||
| 167 | { | ||
| 168 | return &s_wallet; | ||
| 169 | } | ||
| 170 | |||
| 171 | uint64_t wallet_balance(void) | ||
| 172 | { | ||
| 173 | return s_wallet.balance; | ||
| 174 | } | ||
| 175 | |||
| 176 | esp_err_t wallet_add_proofs(const wallet_proof_t *proofs, int count) | ||
| 177 | { | ||
| 178 | for (int i = 0; i < count; i++) { | ||
| 179 | if (s_wallet.proof_count >= WALLET_MAX_PROOFS) { | ||
| 180 | ESP_LOGW(TAG, "Wallet full, cannot add more proofs"); | ||
| 181 | return ESP_ERR_NO_MEM; | ||
| 182 | } | ||
| 183 | memcpy(&s_wallet.proofs[s_wallet.proof_count], &proofs[i], sizeof(wallet_proof_t)); | ||
| 184 | s_wallet.balance += proofs[i].amount; | ||
| 185 | s_wallet.proof_count++; | ||
| 186 | ESP_LOGI(TAG, "Added proof: amount=%llu, total_balance=%llu", | ||
| 187 | (unsigned long long)proofs[i].amount, | ||
| 188 | (unsigned long long)s_wallet.balance); | ||
| 189 | } | ||
| 190 | wallet_persist_save(); | ||
| 191 | return ESP_OK; | ||
| 192 | } | ||
| 193 | |||
| 194 | esp_err_t wallet_remove_proof(int index) | ||
| 195 | { | ||
| 196 | if (index < 0 || index >= s_wallet.proof_count) return ESP_ERR_INVALID_ARG; | ||
| 197 | s_wallet.balance -= s_wallet.proofs[index].amount; | ||
| 198 | for (int i = index; i < s_wallet.proof_count - 1; i++) { | ||
| 199 | memcpy(&s_wallet.proofs[i], &s_wallet.proofs[i + 1], sizeof(wallet_proof_t)); | ||
| 200 | } | ||
| 201 | memset(&s_wallet.proofs[s_wallet.proof_count - 1], 0, sizeof(wallet_proof_t)); | ||
| 202 | s_wallet.proof_count--; | ||
| 203 | wallet_persist_save(); | ||
| 204 | return ESP_OK; | ||
| 205 | } | ||
| 206 | |||
| 207 | void wallet_clear(void) | ||
| 208 | { | ||
| 209 | s_wallet.balance = 0; | ||
| 210 | s_wallet.proof_count = 0; | ||
| 211 | wallet_persist_save(); | ||
| 212 | } | ||
| 213 | |||
| 214 | esp_err_t wallet_fetch_keysets(const char *mint_url) | ||
| 215 | { | ||
| 216 | char url[512]; | ||
| 217 | snprintf(url, sizeof(url), "%s/v1/keysets", mint_url); | ||
| 218 | |||
| 219 | char *resp_buf = malloc(8192); | ||
| 220 | if (!resp_buf) return ESP_ERR_NO_MEM; | ||
| 221 | |||
| 222 | esp_http_client_config_t config = { | ||
| 223 | .url = url, | ||
| 224 | .method = HTTP_METHOD_GET, | ||
| 225 | .timeout_ms = 10000, | ||
| 226 | .crt_bundle_attach = esp_crt_bundle_attach, | ||
| 227 | }; | ||
| 228 | esp_http_client_handle_t client = esp_http_client_init(&config); | ||
| 229 | if (!client) { free(resp_buf); return ESP_FAIL; } | ||
| 230 | |||
| 231 | esp_err_t err = esp_http_client_open(client, 0); | ||
| 232 | if (err != ESP_OK) { | ||
| 233 | ESP_LOGE(TAG, "Keyset fetch open failed: %s", esp_err_to_name(err)); | ||
| 234 | esp_http_client_cleanup(client); | ||
| 235 | free(resp_buf); | ||
| 236 | return err; | ||
| 237 | } | ||
| 238 | |||
| 239 | int content_length = esp_http_client_fetch_headers(client); | ||
| 240 | int status = esp_http_client_get_status_code(client); | ||
| 241 | ESP_LOGI(TAG, "Keyset fetch: status=%d content_length=%d", status, content_length); | ||
| 242 | |||
| 243 | int resp_len = esp_http_client_read(client, resp_buf, 8191); | ||
| 244 | ESP_LOGI(TAG, "Keyset fetch: read %d bytes", resp_len); | ||
| 245 | esp_http_client_cleanup(client); | ||
| 246 | |||
| 247 | if (status != 200 || resp_len <= 0) { | ||
| 248 | ESP_LOGE(TAG, "Keyset fetch failed: status=%d len=%d", status, resp_len); | ||
| 249 | free(resp_buf); | ||
| 250 | return ESP_FAIL; | ||
| 251 | } | ||
| 252 | resp_buf[resp_len] = '\0'; | ||
| 253 | |||
| 254 | cJSON *root = cJSON_Parse(resp_buf); | ||
| 255 | free(resp_buf); | ||
| 256 | if (!root) return ESP_FAIL; | ||
| 257 | |||
| 258 | cJSON *keysets = cJSON_GetObjectItemCaseSensitive(root, "keysets"); | ||
| 259 | if (!keysets || !cJSON_IsArray(keysets)) { | ||
| 260 | cJSON_Delete(root); | ||
| 261 | return ESP_FAIL; | ||
| 262 | } | ||
| 263 | |||
| 264 | s_wallet.keyset_count = 0; | ||
| 265 | int n = cJSON_GetArraySize(keysets); | ||
| 266 | for (int i = 0; i < n && i < WALLET_MAX_KEYSETS; i++) { | ||
| 267 | cJSON *ks = cJSON_GetArrayItem(keysets, i); | ||
| 268 | cJSON *id = cJSON_GetObjectItemCaseSensitive(ks, "id"); | ||
| 269 | if (id && cJSON_IsString(id)) { | ||
| 270 | strncpy(s_wallet.keysets[s_wallet.keyset_count].id, id->valuestring, | ||
| 271 | WALLET_KEYSET_ID_LEN - 1); | ||
| 272 | cJSON *fee = cJSON_GetObjectItemCaseSensitive(ks, "input_fee_ppk"); | ||
| 273 | s_wallet.keysets[s_wallet.keyset_count].input_fee_ppk = fee ? fee->valueint : 0; | ||
| 274 | s_wallet.keyset_count++; | ||
| 275 | } | ||
| 276 | } | ||
| 277 | |||
| 278 | cJSON_Delete(root); | ||
| 279 | ESP_LOGI(TAG, "Fetched %d keysets from %s", s_wallet.keyset_count, mint_url); | ||
| 280 | return ESP_OK; | ||
| 281 | } | ||
| 282 | |||
| 283 | esp_err_t wallet_swap_proofs(const char *mint_url, int start_index, int count) | ||
| 284 | { | ||
| 285 | ESP_LOGI(TAG, "wallet_swap_proofs called: start=%d count=%d keysets=%d proofs=%d", | ||
| 286 | start_index, count, s_wallet.keyset_count, s_wallet.proof_count); | ||
| 287 | |||
| 288 | if (s_wallet.keyset_count == 0) { | ||
| 289 | ESP_LOGE(TAG, "No keysets loaded, fetch first"); | ||
| 290 | return ESP_FAIL; | ||
| 291 | } | ||
| 292 | if (start_index < 0 || start_index + count > s_wallet.proof_count) { | ||
| 293 | return ESP_ERR_INVALID_ARG; | ||
| 294 | } | ||
| 295 | |||
| 296 | wallet_proof_t *old_proofs = &s_wallet.proofs[start_index]; | ||
| 297 | int n = count; | ||
| 298 | |||
| 299 | uint64_t total_input = 0; | ||
| 300 | for (int i = 0; i < n; i++) total_input += old_proofs[i].amount; | ||
| 301 | |||
| 302 | int fee_ppk = s_wallet.keysets[0].input_fee_ppk; | ||
| 303 | uint64_t fee_sats = (total_input * fee_ppk + 999) / 1000; | ||
| 304 | uint64_t total_output = total_input - fee_sats; | ||
| 305 | ESP_LOGI(TAG, "Swap: total_input=%llu fee_ppk=%d fee=%llu total_output=%llu", | ||
| 306 | (unsigned long long)total_input, fee_ppk, | ||
| 307 | (unsigned long long)fee_sats, (unsigned long long)total_output); | ||
| 308 | |||
| 309 | cJSON *inputs = cJSON_CreateArray(); | ||
| 310 | for (int i = 0; i < n; i++) { | ||
| 311 | cJSON *p = cJSON_CreateObject(); | ||
| 312 | cJSON_AddNumberToObject(p, "amount", (double)old_proofs[i].amount); | ||
| 313 | cJSON_AddStringToObject(p, "id", old_proofs[i].id); | ||
| 314 | cJSON_AddStringToObject(p, "secret", old_proofs[i].secret); | ||
| 315 | cJSON_AddStringToObject(p, "C", old_proofs[i].c); | ||
| 316 | cJSON_AddItemToArray(inputs, p); | ||
| 317 | } | ||
| 318 | |||
| 319 | typedef struct { | ||
| 320 | uint8_t secret[32]; | ||
| 321 | mbedtls_mpi r; | ||
| 322 | mbedtls_ecp_point Y; | ||
| 323 | } swap_output_t; | ||
| 324 | |||
| 325 | swap_output_t *outputs = heap_caps_malloc(n * sizeof(swap_output_t), MALLOC_CAP_SPIRAM); | ||
| 326 | if (!outputs) { cJSON_Delete(inputs); return ESP_ERR_NO_MEM; } | ||
| 327 | |||
| 328 | cJSON *blinded_msgs = cJSON_CreateArray(); | ||
| 329 | for (int i = 0; i < n; i++) { | ||
| 330 | random_bytes(outputs[i].secret, 32); | ||
| 331 | mbedtls_ecp_point_init(&outputs[i].Y); | ||
| 332 | esp_err_t htc_ret = hash_to_curve(outputs[i].secret, 32, &outputs[i].Y); | ||
| 333 | if (htc_ret != ESP_OK) { | ||
| 334 | ESP_LOGE(TAG, "hash_to_curve failed for output %d", i); | ||
| 335 | } | ||
| 336 | mbedtls_mpi_init(&outputs[i].r); | ||
| 337 | random_scalar(&outputs[i].r); | ||
| 338 | |||
| 339 | mbedtls_ecp_point rG, B_; | ||
| 340 | mbedtls_ecp_point_init(&rG); | ||
| 341 | mbedtls_ecp_point_init(&B_); | ||
| 342 | |||
| 343 | esp_err_t sm_ret = scalar_mul(&outputs[i].r, &s_grp.G, &rG); | ||
| 344 | if (sm_ret != ESP_OK) { | ||
| 345 | ESP_LOGE(TAG, "scalar_mul failed for output %d", i); | ||
| 346 | } | ||
| 347 | esp_err_t pa_ret = point_add(&outputs[i].Y, &rG, &B_); | ||
| 348 | if (pa_ret != ESP_OK) { | ||
| 349 | ESP_LOGE(TAG, "point_add failed for output %d", i); | ||
| 350 | } | ||
| 351 | |||
| 352 | uint8_t b_bytes[33]; | ||
| 353 | size_t olen = 0; | ||
| 354 | int wret = mbedtls_ecp_point_write_binary(&s_grp, &B_, MBEDTLS_ECP_PF_COMPRESSED, &olen, b_bytes, 33); | ||
| 355 | if (wret != 0 || olen == 0) { | ||
| 356 | ESP_LOGE(TAG, "Blinded point write failed: ret=-0x%x olen=%zu", -wret, olen); | ||
| 357 | olen = 1; | ||
| 358 | b_bytes[0] = 0x00; | ||
| 359 | } | ||
| 360 | char b_hex[67]; | ||
| 361 | bytes_to_hex(b_bytes, olen, b_hex); | ||
| 362 | |||
| 363 | uint64_t out_amount = old_proofs[i].amount; | ||
| 364 | if (i == n - 1) { | ||
| 365 | uint64_t running = 0; | ||
| 366 | for (int j = 0; j < n - 1; j++) running += old_proofs[j].amount; | ||
| 367 | out_amount = total_output - running; | ||
| 368 | } | ||
| 369 | |||
| 370 | cJSON *bm = cJSON_CreateObject(); | ||
| 371 | cJSON_AddNumberToObject(bm, "amount", (double)out_amount); | ||
| 372 | cJSON_AddStringToObject(bm, "id", s_wallet.keysets[0].id); | ||
| 373 | cJSON_AddStringToObject(bm, "B_", b_hex); | ||
| 374 | cJSON_AddItemToArray(blinded_msgs, bm); | ||
| 375 | |||
| 376 | mbedtls_ecp_point_free(&rG); | ||
| 377 | mbedtls_ecp_point_free(&B_); | ||
| 378 | } | ||
| 379 | |||
| 380 | cJSON *body = cJSON_CreateObject(); | ||
| 381 | cJSON_AddItemToObject(body, "inputs", inputs); | ||
| 382 | cJSON_AddItemToObject(body, "outputs", blinded_msgs); | ||
| 383 | char *body_str = cJSON_PrintUnformatted(body); | ||
| 384 | cJSON_Delete(body); | ||
| 385 | |||
| 386 | ESP_LOGI(TAG, "Swap request body (%zu bytes): %s", strlen(body_str), body_str); | ||
| 387 | |||
| 388 | char url[512]; | ||
| 389 | snprintf(url, sizeof(url), "%s/v1/swap", mint_url); | ||
| 390 | |||
| 391 | char *resp_buf = malloc(8192); | ||
| 392 | if (!resp_buf) { | ||
| 393 | free(body_str); | ||
| 394 | for (int i = 0; i < n; i++) { | ||
| 395 | mbedtls_mpi_free(&outputs[i].r); | ||
| 396 | mbedtls_ecp_point_free(&outputs[i].Y); | ||
| 397 | } | ||
| 398 | free(outputs); | ||
| 399 | return ESP_ERR_NO_MEM; | ||
| 400 | } | ||
| 401 | |||
| 402 | esp_http_client_config_t config = { | ||
| 403 | .url = url, | ||
| 404 | .method = HTTP_METHOD_POST, | ||
| 405 | .timeout_ms = 15000, | ||
| 406 | .crt_bundle_attach = esp_crt_bundle_attach, | ||
| 407 | }; | ||
| 408 | esp_http_client_handle_t client = esp_http_client_init(&config); | ||
| 409 | if (!client) { | ||
| 410 | free(body_str); | ||
| 411 | free(resp_buf); | ||
| 412 | for (int i = 0; i < n; i++) { | ||
| 413 | mbedtls_mpi_free(&outputs[i].r); | ||
| 414 | mbedtls_ecp_point_free(&outputs[i].Y); | ||
| 415 | } | ||
| 416 | free(outputs); | ||
| 417 | return ESP_FAIL; | ||
| 418 | } | ||
| 419 | |||
| 420 | esp_http_client_set_header(client, "Content-Type", "application/json"); | ||
| 421 | esp_http_client_open(client, strlen(body_str)); | ||
| 422 | esp_http_client_write(client, body_str, strlen(body_str)); | ||
| 423 | free(body_str); | ||
| 424 | |||
| 425 | esp_http_client_fetch_headers(client); | ||
| 426 | int resp_len = esp_http_client_read(client, resp_buf, 8191); | ||
| 427 | int status = esp_http_client_get_status_code(client); | ||
| 428 | esp_http_client_cleanup(client); | ||
| 429 | |||
| 430 | if (status != 200 || resp_len <= 0) { | ||
| 431 | if (resp_len > 0) { | ||
| 432 | resp_buf[resp_len] = '\0'; | ||
| 433 | ESP_LOGE(TAG, "Swap failed: status=%d body=%s", status, resp_buf); | ||
| 434 | } else { | ||
| 435 | ESP_LOGE(TAG, "Swap failed: status=%d len=%d", status, resp_len); | ||
| 436 | } | ||
| 437 | free(resp_buf); | ||
| 438 | for (int i = 0; i < n; i++) { | ||
| 439 | mbedtls_mpi_free(&outputs[i].r); | ||
| 440 | mbedtls_ecp_point_free(&outputs[i].Y); | ||
| 441 | } | ||
| 442 | free(outputs); | ||
| 443 | return ESP_FAIL; | ||
| 444 | } | ||
| 445 | resp_buf[resp_len] = '\0'; | ||
| 446 | |||
| 447 | cJSON *root = cJSON_Parse(resp_buf); | ||
| 448 | free(resp_buf); | ||
| 449 | if (!root) { | ||
| 450 | for (int i = 0; i < n; i++) { | ||
| 451 | mbedtls_mpi_free(&outputs[i].r); | ||
| 452 | mbedtls_ecp_point_free(&outputs[i].Y); | ||
| 453 | } | ||
| 454 | free(outputs); | ||
| 455 | return ESP_FAIL; | ||
| 456 | } | ||
| 457 | |||
| 458 | cJSON *signatures = cJSON_GetObjectItemCaseSensitive(root, "signatures"); | ||
| 459 | if (!signatures || !cJSON_IsArray(signatures)) { | ||
| 460 | ESP_LOGE(TAG, "No signatures in swap response"); | ||
| 461 | cJSON_Delete(root); | ||
| 462 | for (int i = 0; i < n; i++) { | ||
| 463 | mbedtls_mpi_free(&outputs[i].r); | ||
| 464 | mbedtls_ecp_point_free(&outputs[i].Y); | ||
| 465 | } | ||
| 466 | free(outputs); | ||
| 467 | return ESP_FAIL; | ||
| 468 | } | ||
| 469 | |||
| 470 | for (int i = start_index; i < start_index + n; i++) { | ||
| 471 | s_wallet.balance -= s_wallet.proofs[i].amount; | ||
| 472 | } | ||
| 473 | |||
| 474 | int sig_count = cJSON_GetArraySize(signatures); | ||
| 475 | for (int i = 0; i < sig_count && i < n; i++) { | ||
| 476 | cJSON *sig = cJSON_GetArrayItem(signatures, i); | ||
| 477 | cJSON *c_ = cJSON_GetObjectItemCaseSensitive(sig, "C_"); | ||
| 478 | cJSON *amt = cJSON_GetObjectItemCaseSensitive(sig, "amount"); | ||
| 479 | cJSON *id = cJSON_GetObjectItemCaseSensitive(sig, "id"); | ||
| 480 | |||
| 481 | if (!c_ || !cJSON_IsString(c_)) continue; | ||
| 482 | |||
| 483 | uint8_t c_bytes[33]; | ||
| 484 | int c_len = hex_to_bytes(c_->valuestring, c_bytes, 33); | ||
| 485 | |||
| 486 | mbedtls_ecp_point C_; | ||
| 487 | mbedtls_ecp_point_init(&C_); | ||
| 488 | mbedtls_ecp_point_read_binary(&s_grp, &C_, c_bytes, c_len); | ||
| 489 | |||
| 490 | char ks_id[WALLET_KEYSET_ID_LEN] = {0}; | ||
| 491 | if (id && cJSON_IsString(id)) { | ||
| 492 | strncpy(ks_id, id->valuestring, WALLET_KEYSET_ID_LEN - 1); | ||
| 493 | } | ||
| 494 | |||
| 495 | mbedtls_mpi neg_r; | ||
| 496 | mbedtls_mpi_init(&neg_r); | ||
| 497 | mbedtls_mpi_sub_mpi(&neg_r, &s_order, &outputs[i].r); | ||
| 498 | |||
| 499 | mbedtls_ecp_point neg_rG; | ||
| 500 | mbedtls_ecp_point_init(&neg_rG); | ||
| 501 | scalar_mul(&neg_r, &s_grp.G, &neg_rG); | ||
| 502 | |||
| 503 | mbedtls_ecp_point C; | ||
| 504 | mbedtls_ecp_point_init(&C); | ||
| 505 | point_add(&C_, &neg_rG, &C); | ||
| 506 | |||
| 507 | uint8_t c_final[33]; | ||
| 508 | size_t c_final_len; | ||
| 509 | mbedtls_ecp_point_write_binary(&s_grp, &C, MBEDTLS_ECP_PF_COMPRESSED, | ||
| 510 | &c_final_len, c_final, 33); | ||
| 511 | |||
| 512 | if (s_wallet.proof_count < WALLET_MAX_PROOFS) { | ||
| 513 | wallet_proof_t *wp = &s_wallet.proofs[s_wallet.proof_count]; | ||
| 514 | if (amt && cJSON_IsNumber(amt)) { | ||
| 515 | wp->amount = (uint64_t)amt->valuedouble; | ||
| 516 | } | ||
| 517 | strncpy(wp->id, ks_id, WALLET_KEYSET_ID_LEN - 1); | ||
| 518 | bytes_to_hex(outputs[i].secret, 32, wp->secret); | ||
| 519 | bytes_to_hex(c_final, c_final_len, wp->c); | ||
| 520 | s_wallet.balance += wp->amount; | ||
| 521 | s_wallet.proof_count++; | ||
| 522 | } | ||
| 523 | |||
| 524 | mbedtls_mpi_free(&neg_r); | ||
| 525 | mbedtls_ecp_point_free(&C_); | ||
| 526 | mbedtls_ecp_point_free(&neg_rG); | ||
| 527 | mbedtls_ecp_point_free(&C); | ||
| 528 | } | ||
| 529 | |||
| 530 | for (int i = 0; i < n; i++) { | ||
| 531 | int idx = start_index; | ||
| 532 | for (int j = idx; j < s_wallet.proof_count - 1; j++) { | ||
| 533 | memcpy(&s_wallet.proofs[j], &s_wallet.proofs[j + 1], sizeof(wallet_proof_t)); | ||
| 534 | } | ||
| 535 | s_wallet.proof_count--; | ||
| 536 | } | ||
| 537 | |||
| 538 | for (int i = 0; i < n; i++) { | ||
| 539 | mbedtls_mpi_free(&outputs[i].r); | ||
| 540 | mbedtls_ecp_point_free(&outputs[i].Y); | ||
| 541 | } | ||
| 542 | free(outputs); | ||
| 543 | cJSON_Delete(root); | ||
| 544 | |||
| 545 | ESP_LOGI(TAG, "Swap complete: %d proofs swapped, balance=%llu", | ||
| 546 | n, (unsigned long long)s_wallet.balance); | ||
| 547 | wallet_persist_save(); | ||
| 548 | return ESP_OK; | ||
| 549 | } | ||
| 550 | |||
| 551 | esp_err_t wallet_create_token(char *out, size_t out_size, uint64_t amount, | ||
| 552 | const char *mint_url) | ||
| 553 | { | ||
| 554 | if (s_wallet.proof_count == 0 || s_wallet.balance < amount) { | ||
| 555 | ESP_LOGE(TAG, "Insufficient balance: have=%llu need=%llu", | ||
| 556 | (unsigned long long)s_wallet.balance, (unsigned long long)amount); | ||
| 557 | return ESP_FAIL; | ||
| 558 | } | ||
| 559 | |||
| 560 | cJSON *proofs_arr = cJSON_CreateArray(); | ||
| 561 | uint64_t remaining = amount; | ||
| 562 | int indices_to_remove[10]; | ||
| 563 | int remove_count = 0; | ||
| 564 | |||
| 565 | for (int i = 0; i < s_wallet.proof_count && remaining > 0 && remove_count < 10; i++) { | ||
| 566 | if (s_wallet.proofs[i].amount <= remaining) { | ||
| 567 | cJSON *p = cJSON_CreateObject(); | ||
| 568 | cJSON_AddNumberToObject(p, "amount", (double)s_wallet.proofs[i].amount); | ||
| 569 | cJSON_AddStringToObject(p, "id", s_wallet.proofs[i].id); | ||
| 570 | cJSON_AddStringToObject(p, "secret", s_wallet.proofs[i].secret); | ||
| 571 | cJSON_AddStringToObject(p, "C", s_wallet.proofs[i].c); | ||
| 572 | cJSON_AddItemToArray(proofs_arr, p); | ||
| 573 | remaining -= s_wallet.proofs[i].amount; | ||
| 574 | indices_to_remove[remove_count++] = i; | ||
| 575 | } | ||
| 576 | } | ||
| 577 | |||
| 578 | if (remaining > 0) { | ||
| 579 | cJSON_Delete(proofs_arr); | ||
| 580 | ESP_LOGE(TAG, "Cannot make exact amount: %llu remaining", (unsigned long long)remaining); | ||
| 581 | return ESP_FAIL; | ||
| 582 | } | ||
| 583 | |||
| 584 | cJSON *token_obj = cJSON_CreateObject(); | ||
| 585 | cJSON *token_arr = cJSON_CreateArray(); | ||
| 586 | cJSON *mint_proofs = cJSON_CreateObject(); | ||
| 587 | cJSON_AddStringToObject(mint_proofs, "mint", mint_url); | ||
| 588 | cJSON_AddItemToObject(mint_proofs, "proofs", proofs_arr); | ||
| 589 | cJSON_AddItemToArray(token_arr, mint_proofs); | ||
| 590 | cJSON_AddItemToObject(token_obj, "token", token_arr); | ||
| 591 | |||
| 592 | char *json_str = cJSON_PrintUnformatted(token_obj); | ||
| 593 | cJSON_Delete(token_obj); | ||
| 594 | |||
| 595 | size_t b64_len; | ||
| 596 | mbedtls_base64_encode((unsigned char *)out + 6, out_size - 6, &b64_len, | ||
| 597 | (const unsigned char *)json_str, strlen(json_str)); | ||
| 598 | free(json_str); | ||
| 599 | |||
| 600 | memcpy(out, "cashuA", 6); | ||
| 601 | for (size_t i = 0; i < b64_len; i++) { | ||
| 602 | if (out[6 + i] == '+') out[6 + i] = '-'; | ||
| 603 | else if (out[6 + i] == '/') out[6 + i] = '_'; | ||
| 604 | else if (out[6 + i] == '=') { out[6 + i] = '\0'; break; } | ||
| 605 | } | ||
| 606 | out[6 + b64_len] = '\0'; | ||
| 607 | |||
| 608 | for (int i = remove_count - 1; i >= 0; i--) { | ||
| 609 | s_wallet.balance -= s_wallet.proofs[indices_to_remove[i]].amount; | ||
| 610 | for (int j = indices_to_remove[i]; j < s_wallet.proof_count - 1; j++) { | ||
| 611 | memcpy(&s_wallet.proofs[j], &s_wallet.proofs[j + 1], sizeof(wallet_proof_t)); | ||
| 612 | } | ||
| 613 | s_wallet.proof_count--; | ||
| 614 | } | ||
| 615 | |||
| 616 | ESP_LOGI(TAG, "Created token for %llu sats, remaining balance=%llu", | ||
| 617 | (unsigned long long)amount, (unsigned long long)s_wallet.balance); | ||
| 618 | wallet_persist_save(); | ||
| 619 | return ESP_OK; | ||
| 620 | } | ||
| 621 | |||
| 622 | esp_err_t wallet_send(const char *mint_url, uint64_t amount, | ||
| 623 | char *token_out, size_t token_out_size) | ||
| 624 | { | ||
| 625 | return wallet_create_token(token_out, token_out_size, amount, mint_url); | ||
| 626 | } | ||
| 627 | |||
| 628 | void wallet_print_status(void) | ||
| 629 | { | ||
| 630 | ESP_LOGI(TAG, "Wallet: %d proofs, balance=%llu sats, %d keysets", | ||
| 631 | s_wallet.proof_count, | ||
| 632 | (unsigned long long)s_wallet.balance, | ||
| 633 | s_wallet.keyset_count); | ||
| 634 | for (int i = 0; i < s_wallet.proof_count; i++) { | ||
| 635 | ESP_LOGI(TAG, " [%d] amount=%llu id=%s", i, | ||
| 636 | (unsigned long long)s_wallet.proofs[i].amount, | ||
| 637 | s_wallet.proofs[i].id); | ||
| 638 | } | ||
| 639 | } | ||
diff --git a/main/wallet.h b/main/wallet.h new file mode 100644 index 0000000..5089f93 --- /dev/null +++ b/main/wallet.h | |||
| @@ -0,0 +1,53 @@ | |||
| 1 | #ifndef WALLET_H | ||
| 2 | #define WALLET_H | ||
| 3 | |||
| 4 | #include "esp_err.h" | ||
| 5 | #include <stdint.h> | ||
| 6 | #include <stdbool.h> | ||
| 7 | |||
| 8 | #define WALLET_MAX_PROOFS 50 | ||
| 9 | #define WALLET_MAX_KEYSETS 5 | ||
| 10 | #define WALLET_KEYSET_ID_LEN 68 | ||
| 11 | #define WALLET_SECRET_LEN 65 | ||
| 12 | #define WALLET_SIG_LEN 67 | ||
| 13 | |||
| 14 | typedef struct { | ||
| 15 | uint64_t amount; | ||
| 16 | char id[WALLET_KEYSET_ID_LEN]; | ||
| 17 | char secret[WALLET_SECRET_LEN]; | ||
| 18 | char c[WALLET_SIG_LEN]; | ||
| 19 | } wallet_proof_t; | ||
| 20 | |||
| 21 | typedef struct { | ||
| 22 | char id[WALLET_KEYSET_ID_LEN]; | ||
| 23 | char public_key_33[67]; | ||
| 24 | uint64_t amount; | ||
| 25 | int input_fee_ppk; | ||
| 26 | } wallet_keyset_t; | ||
| 27 | |||
| 28 | typedef struct { | ||
| 29 | wallet_proof_t proofs[WALLET_MAX_PROOFS]; | ||
| 30 | int proof_count; | ||
| 31 | wallet_keyset_t keysets[WALLET_MAX_KEYSETS]; | ||
| 32 | int keyset_count; | ||
| 33 | uint64_t balance; | ||
| 34 | } wallet_t; | ||
| 35 | |||
| 36 | esp_err_t wallet_init(void); | ||
| 37 | wallet_t *wallet_get(void); | ||
| 38 | uint64_t wallet_balance(void); | ||
| 39 | |||
| 40 | esp_err_t wallet_add_proofs(const wallet_proof_t *proofs, int count); | ||
| 41 | esp_err_t wallet_remove_proof(int index); | ||
| 42 | void wallet_clear(void); | ||
| 43 | |||
| 44 | esp_err_t wallet_fetch_keysets(const char *mint_url); | ||
| 45 | esp_err_t wallet_swap_proofs(const char *mint_url, int start_index, int count); | ||
| 46 | |||
| 47 | esp_err_t wallet_create_token(char *out, size_t out_size, uint64_t amount, | ||
| 48 | const char *mint_url); | ||
| 49 | esp_err_t wallet_send(const char *mint_url, uint64_t amount, | ||
| 50 | char *token_out, size_t token_out_size); | ||
| 51 | |||
| 52 | void wallet_print_status(void); | ||
| 53 | #endif | ||
diff --git a/main/wallet_persist.c b/main/wallet_persist.c new file mode 100644 index 0000000..45c932f --- /dev/null +++ b/main/wallet_persist.c | |||
| @@ -0,0 +1,147 @@ | |||
| 1 | #include "wallet_persist.h" | ||
| 2 | #include "wallet.h" | ||
| 3 | #include "config.h" | ||
| 4 | #include "esp_log.h" | ||
| 5 | #include "cJSON.h" | ||
| 6 | #include <string.h> | ||
| 7 | #include <stdio.h> | ||
| 8 | #include <unistd.h> | ||
| 9 | |||
| 10 | static const char *TAG = "wallet_persist"; | ||
| 11 | static const char *WALLET_FILE = "/spiffs/wallet.json"; | ||
| 12 | |||
| 13 | esp_err_t wallet_persist_save(void) | ||
| 14 | { | ||
| 15 | const tollgate_config_t *cfg = tollgate_config_get(); | ||
| 16 | wallet_t *w = wallet_get(); | ||
| 17 | |||
| 18 | if (w->balance < cfg->persist_threshold_sats) { | ||
| 19 | if (w->proof_count == 0) { | ||
| 20 | unlink(WALLET_FILE); | ||
| 21 | ESP_LOGI(TAG, "Wallet empty, removed persist file"); | ||
| 22 | } | ||
| 23 | return ESP_OK; | ||
| 24 | } | ||
| 25 | |||
| 26 | cJSON *root = cJSON_CreateObject(); | ||
| 27 | cJSON_AddNumberToObject(root, "balance", (double)w->balance); | ||
| 28 | |||
| 29 | cJSON *proofs = cJSON_CreateArray(); | ||
| 30 | for (int i = 0; i < w->proof_count; i++) { | ||
| 31 | cJSON *p = cJSON_CreateObject(); | ||
| 32 | cJSON_AddNumberToObject(p, "amount", (double)w->proofs[i].amount); | ||
| 33 | cJSON_AddStringToObject(p, "id", w->proofs[i].id); | ||
| 34 | cJSON_AddStringToObject(p, "secret", w->proofs[i].secret); | ||
| 35 | cJSON_AddStringToObject(p, "C", w->proofs[i].c); | ||
| 36 | cJSON_AddItemToArray(proofs, p); | ||
| 37 | } | ||
| 38 | cJSON_AddItemToObject(root, "proofs", proofs); | ||
| 39 | |||
| 40 | cJSON *keysets = cJSON_CreateArray(); | ||
| 41 | for (int i = 0; i < w->keyset_count; i++) { | ||
| 42 | cJSON *ks = cJSON_CreateObject(); | ||
| 43 | cJSON_AddStringToObject(ks, "id", w->keysets[i].id); | ||
| 44 | cJSON_AddItemToArray(keysets, ks); | ||
| 45 | } | ||
| 46 | cJSON_AddItemToObject(root, "keysets", keysets); | ||
| 47 | |||
| 48 | char *json_str = cJSON_PrintUnformatted(root); | ||
| 49 | cJSON_Delete(root); | ||
| 50 | |||
| 51 | FILE *f = fopen(WALLET_FILE, "w"); | ||
| 52 | if (!f) { | ||
| 53 | ESP_LOGE(TAG, "Failed to open %s for writing", WALLET_FILE); | ||
| 54 | cJSON_free(json_str); | ||
| 55 | return ESP_FAIL; | ||
| 56 | } | ||
| 57 | |||
| 58 | size_t written = fwrite(json_str, 1, strlen(json_str), f); | ||
| 59 | fclose(f); | ||
| 60 | cJSON_free(json_str); | ||
| 61 | |||
| 62 | ESP_LOGI(TAG, "Wallet persisted: %d proofs, balance=%llu (%zu bytes)", | ||
| 63 | w->proof_count, (unsigned long long)w->balance, written); | ||
| 64 | return ESP_OK; | ||
| 65 | } | ||
| 66 | |||
| 67 | esp_err_t wallet_persist_load(void) | ||
| 68 | { | ||
| 69 | wallet_t *w = wallet_get(); | ||
| 70 | |||
| 71 | FILE *f = fopen(WALLET_FILE, "r"); | ||
| 72 | if (!f) { | ||
| 73 | ESP_LOGI(TAG, "No persisted wallet found, starting fresh"); | ||
| 74 | return ESP_OK; | ||
| 75 | } | ||
| 76 | |||
| 77 | fseek(f, 0, SEEK_END); | ||
| 78 | long fsize = ftell(f); | ||
| 79 | fseek(f, 0, SEEK_SET); | ||
| 80 | |||
| 81 | if (fsize <= 0 || fsize > 65536) { | ||
| 82 | fclose(f); | ||
| 83 | ESP_LOGW(TAG, "Wallet file size invalid: %ld", fsize); | ||
| 84 | return ESP_FAIL; | ||
| 85 | } | ||
| 86 | |||
| 87 | char *buf = malloc(fsize + 1); | ||
| 88 | if (!buf) { | ||
| 89 | fclose(f); | ||
| 90 | return ESP_ERR_NO_MEM; | ||
| 91 | } | ||
| 92 | |||
| 93 | fread(buf, 1, fsize, f); | ||
| 94 | buf[fsize] = '\0'; | ||
| 95 | fclose(f); | ||
| 96 | |||
| 97 | cJSON *root = cJSON_Parse(buf); | ||
| 98 | free(buf); | ||
| 99 | if (!root) { | ||
| 100 | ESP_LOGE(TAG, "Failed to parse wallet.json"); | ||
| 101 | return ESP_FAIL; | ||
| 102 | } | ||
| 103 | |||
| 104 | cJSON *balance_j = cJSON_GetObjectItemCaseSensitive(root, "balance"); | ||
| 105 | if (balance_j && cJSON_IsNumber(balance_j)) { | ||
| 106 | w->balance = (uint64_t)balance_j->valuedouble; | ||
| 107 | } | ||
| 108 | |||
| 109 | cJSON *proofs = cJSON_GetObjectItemCaseSensitive(root, "proofs"); | ||
| 110 | if (proofs && cJSON_IsArray(proofs)) { | ||
| 111 | int count = cJSON_GetArraySize(proofs); | ||
| 112 | if (count > WALLET_MAX_PROOFS) count = WALLET_MAX_PROOFS; | ||
| 113 | for (int i = 0; i < count; i++) { | ||
| 114 | cJSON *p = cJSON_GetArrayItem(proofs, i); | ||
| 115 | cJSON *amt = cJSON_GetObjectItemCaseSensitive(p, "amount"); | ||
| 116 | cJSON *id = cJSON_GetObjectItemCaseSensitive(p, "id"); | ||
| 117 | cJSON *secret = cJSON_GetObjectItemCaseSensitive(p, "secret"); | ||
| 118 | cJSON *c = cJSON_GetObjectItemCaseSensitive(p, "C"); | ||
| 119 | if (amt) w->proofs[i].amount = (uint64_t)amt->valuedouble; | ||
| 120 | if (id && cJSON_IsString(id)) | ||
| 121 | strncpy(w->proofs[i].id, id->valuestring, WALLET_KEYSET_ID_LEN - 1); | ||
| 122 | if (secret && cJSON_IsString(secret)) | ||
| 123 | strncpy(w->proofs[i].secret, secret->valuestring, WALLET_SECRET_LEN - 1); | ||
| 124 | if (c && cJSON_IsString(c)) | ||
| 125 | strncpy(w->proofs[i].c, c->valuestring, WALLET_SIG_LEN - 1); | ||
| 126 | w->proof_count++; | ||
| 127 | } | ||
| 128 | } | ||
| 129 | |||
| 130 | cJSON *keysets = cJSON_GetObjectItemCaseSensitive(root, "keysets"); | ||
| 131 | if (keysets && cJSON_IsArray(keysets)) { | ||
| 132 | int count = cJSON_GetArraySize(keysets); | ||
| 133 | if (count > WALLET_MAX_KEYSETS) count = WALLET_MAX_KEYSETS; | ||
| 134 | for (int i = 0; i < count; i++) { | ||
| 135 | cJSON *ks = cJSON_GetArrayItem(keysets, i); | ||
| 136 | cJSON *id = cJSON_GetObjectItemCaseSensitive(ks, "id"); | ||
| 137 | if (id && cJSON_IsString(id)) | ||
| 138 | strncpy(w->keysets[i].id, id->valuestring, WALLET_KEYSET_ID_LEN - 1); | ||
| 139 | w->keyset_count++; | ||
| 140 | } | ||
| 141 | } | ||
| 142 | |||
| 143 | cJSON_Delete(root); | ||
| 144 | ESP_LOGI(TAG, "Wallet loaded: %d proofs, %d keysets, balance=%llu", | ||
| 145 | w->proof_count, w->keyset_count, (unsigned long long)w->balance); | ||
| 146 | return ESP_OK; | ||
| 147 | } | ||
diff --git a/main/wallet_persist.h b/main/wallet_persist.h new file mode 100644 index 0000000..4dfcbfc --- /dev/null +++ b/main/wallet_persist.h | |||
| @@ -0,0 +1,9 @@ | |||
| 1 | #ifndef WALLET_PERSIST_H | ||
| 2 | #define WALLET_PERSIST_H | ||
| 3 | |||
| 4 | #include "esp_err.h" | ||
| 5 | |||
| 6 | esp_err_t wallet_persist_save(void); | ||
| 7 | esp_err_t wallet_persist_load(void); | ||
| 8 | |||
| 9 | #endif | ||