From 133e40c82afb4d7659758b1fa57925ac57af4621 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 16 May 2026 15:32:55 +0530 Subject: 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) --- CHECKLIST.md | 84 +++++-- PLAN.md | 96 ++++++-- main/CMakeLists.txt | 2 + main/cashu.h | 2 +- main/config.c | 4 + main/config.h | 1 + main/tollgate_api.c | 134 ++++++++++- main/tollgate_main.c | 11 + main/wallet.c | 639 ++++++++++++++++++++++++++++++++++++++++++++++++++ main/wallet.h | 53 +++++ main/wallet_persist.c | 147 ++++++++++++ main/wallet_persist.h | 9 + sdkconfig.defaults | 8 + 13 files changed, 1147 insertions(+), 43 deletions(-) create mode 100644 main/wallet.c create mode 100644 main/wallet.h create mode 100644 main/wallet_persist.c create mode 100644 main/wallet_persist.h diff --git a/CHECKLIST.md b/CHECKLIST.md index d5711b4..3b50c2a 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -21,7 +21,7 @@ - [x] Fix ping tests (use `-I wlp59s0`) - [x] Tests 1-14: ALL PASSING -## Phase 2: E-Cash Payments — IN PROGRESS (commit `3f46bb8` + uncommitted fixes) +## Phase 2: E-Cash Payments — COMPLETE ### Code Written - [x] Implement cashu.c/h (Cashu token parse, base64url, checkstate, mint validation) - [x] Implement session.c/h (time-based allotment, expiry, secret tracking, MAC tracking) @@ -45,10 +45,8 @@ ### Infrastructure - [x] Upstream gateway on enx00e04c633a90 (192.168.2.0/24, metric 101, default route) -- [x] OpenWRT TollGate on enx00e04c683d2d (10.47.41.0/24, metric 20100, never-default) - [x] WiFi wlp59s0 free for ESP32 TollGate connection -- [x] NetworkManager profile "TollGate-ESP32" created (manual 192.168.4.2/24, autoconnect=no) -- [x] Mint URL verified: `testnut.cashu.space` works; `nofee.testnut.cashu.space` and `nofees.testnut.cashu.space` both broken +- [x] Mint URL verified: `testnut.cashu.space` works (auto-pays invoices) ### Tests Passing - [x] Test 15: Advertisement valid (kind=10021 with price_per_step) — PASSING @@ -63,11 +61,6 @@ - [x] Test: /whoami returns ip=X.X.X.X mac=XX:XX:XX:XX:XX:XX — PASSING - [x] Test: Portal has payment form (Cashu token input + Pay button) — PASSING -### Tests Not Yet Run (deferred to Phase 3 — will use Board B as second client) -- [ ] Test 25: Two clients pay independently (laptop + Board B) -- [ ] Test 26: Client isolation (only payer gets internet) -- [ ] Test 27: Full e2e: portal → pay → browse - ### Captive Portal Detection Fix - [x] Added DoT reject server on port 853 (TCP RST forces DNS fallback to port 53) - [x] DNS hijack now returns NXDOMAIN for ALL non-A query types (prevents DNS leaks) @@ -75,16 +68,73 @@ - [x] Explicit 302 redirect handlers for all captive detection URIs (/generate_204, /hotspot-detect.html, etc.) - [x] HTTP request logging for captive detection endpoints - [x] DNS query logging for unauthenticated clients -- [ ] **Needs verification with actual GrapheneOS phone** +- [x] Verified working with GrapheneOS phone (commit `236b61d`) -## Phase 3: nucula Wallet + ESP32-to-ESP32 Payments — NOT STARTED -- [ ] Extract nucula wallet into components/cashu_wallet/ -- [ ] Replace simple melt with Wallet::receive() -- [ ] Implement payout.c/h (background melt-to-LN) -- [ ] Implement upstream_client.c/h (reseller mode) -- [ ] ESP32-to-ESP32 payments (ESP32 generates/proves tokens to pay another ESP32 TollGate) -- [ ] Tests 28-38 +## Phase 3: On-Device Wallet + ESP32-to-ESP32 Payments — IN PROGRESS +### Wallet Module (wallet.c/h) +- [x] `hash_to_curve()` — SHA256 try-and-increment with Cashu domain separator +- [x] `point_add()`, `scalar_mul()` — mbedTLS secp256k1 primitives +- [x] `random_scalar()` — ESP32 hardware RNG mod curve order +- [x] Proof storage: `wallet_add_proofs()`, `wallet_remove_proof()`, `wallet_clear()` +- [x] Keyset fetching: `wallet_fetch_keysets()` — GET /v1/keys from mint +- [x] Full swap: `wallet_swap_proofs()` — generates blinded messages, POST /v1/swap, unblinds signatures +- [x] Token creation: `wallet_create_token()` — encode proofs as `cashuA` token +- [x] Wallet API endpoints: `GET /wallet`, `POST /wallet/swap`, `POST /wallet/send` +- [x] Payment flow integration: received proofs added to wallet after session creation +- [x] mbedTLS 3.x compatibility (no direct point field access, no point_negate) +- [x] Unblinding: `C = C_ + (order - r) * G` approach +- [x] Clean build (0 warnings, 0 errors) + +### Wallet Persistence (wallet_persist.c/h) +- [ ] Implement `wallet_persist_save()` — serialize wallet to `/spiffs/wallet.json` +- [ ] Implement `wallet_persist_load()` — deserialize wallet from `/spiffs/wallet.json` on boot +- [ ] Add `persist_threshold_sats` to config.json and config struct +- [ ] Threshold logic: only persist when `balance >= persist_threshold_sats` +- [ ] Wire `wallet_persist_save()` into wallet mutations (add_proofs, swap, create_token) +- [ ] Wire `wallet_persist_load()` into `wallet_init()` +- [ ] Build and verify clean compile + +### Hardware Testing +- [ ] Flash Board A, verify wallet boot (keyset fetch succeeds) +- [ ] Pay Board A with Cashu token, verify proofs stored (GET /wallet) +- [ ] Test POST /wallet/swap on Board A +- [ ] Test POST /wallet/send on Board A, verify token is valid +- [ ] Verify persistence survives reboot on Board A +- [ ] Flash Board B with TollGate firmware +- [ ] Load Board B with balance (pay it a token) +- [ ] Board B creates send token via POST /wallet/send +- [ ] Cross-board payment: Board B token → Board A (laptop relay) +- [ ] Verify both boards show correct balances after cross-board payment + +### Tests 25-27 (deferred from Phase 2, need Board B) +- [ ] Test 25: Two clients pay independently (laptop + Board B) +- [ ] Test 26: Client isolation (only payer gets internet) +- [ ] Test 27: Full e2e: portal → pay → browse + +### Tests 28-38 (Phase 3 specific) +- [ ] Test 28: Wallet boot (keysets loaded) +- [ ] Test 29: Receive via wallet (balance incremented) +- [ ] Test 30: Wallet swap (same balance, new proofs) +- [ ] Test 31: Wallet send (valid cashuA token) +- [ ] Test 32: Persistence survives reboot +- [ ] Test 33: Cross-board payment +- [ ] Test 34: 5 consecutive payments +- [ ] Test 35: Stress: rapid pay/expire + +### Automated Tests +- [ ] Write tests/phase3.mjs (wallet endpoint tests + cross-board) +- [ ] All Phase 3 tests passing ## Phase 4: ESP32-to-OpenWRT TollGate Interop — NOT STARTED - [ ] ESP32 pays OpenWRT TollGate using Cashu tokens - [ ] Interoperability testing with existing OpenWRT TollGate on enx00e04c683d2d + +## Reminders +- Do NOT ask for instructions — proceed independently, skip blocked items, work on unblocked ones +- Board A: `/dev/ttyACM0`, MAC `94:a9:90:2e:37:7c`, SSID `TollGate-377C`, AP IP `10.55.85.1` +- Board B: `/dev/ttyACM1`, MAC `fc:01:2c:c5:50:50` +- testnut.cashu.space auto-pays invoices: `cashu -h https://testnut.cashu.space invoice ` +- Token generation: `cashu -h https://testnut.cashu.space send --legacy 2>&1 | grep '^cashuA' | head -1` +- sudo password: `c03rad0r123` +- Commit + push whenever tests pass +- Proceed to Phase 4 after completing Phase 3 diff --git a/PLAN.md b/PLAN.md index d43344b..2af8a39 100644 --- a/PLAN.md +++ b/PLAN.md @@ -2,12 +2,12 @@ ## Overview -Build a TollGate firmware for two ESP32 devices, following the [TollGate protocol spec](https://github.com/OpenTollGate/tollgate) (TIP-01, TIP-02, HTTP-01/02/03). The implementation uses ESP-IDF (C/C++) and integrates the nucula Cashu wallet. +Build a TollGate firmware for two ESP32 devices, following the [TollGate protocol spec](https://github.com/OpenTollGate/tollgate) (TIP-01, TIP-02, HTTP-01/02/03). The implementation uses ESP-IDF (C/C++) with an on-device Cashu wallet using mbedTLS secp256k1. ## Architecture Decision: C/C++ (ESP-IDF) - Existing working captive portal is in C (ESP-IDF) -- Nucula Cashu wallet is in C/C++ (ESP-IDF) +- On-device Cashu wallet uses mbedTLS secp256k1 (hardware RNG, software ECP) - ESP-IDF is already installed at `~/esp/esp-idf` - No Rust/ESP32 toolchain installed @@ -16,11 +16,12 @@ Build a TollGate firmware for two ESP32 devices, following the [TollGate protoco | Layer | Technology | |-------|-----------| | Framework | ESP-IDF v5.4.1 (C/C++) | -| Cashu wallet | nucula `Wallet` class (Phase 3) | -| HTTP server | `esp_http_server` (port 80 captive portal, port 2121 TollGate API) | +| Cashu wallet | Custom mbedTLS secp256k1 wallet (hash_to_curve, blind signing, swap, send) | +| HTTP server | `esp_http_server` (port 80 captive portal, port 2121 TollGate API + wallet) | | DNS | Custom UDP task (hijack unauthenticated, forward authenticated) | | NAT | lwIP NAPT | -| Testing | Playwright + curl + pyserial | +| Persistence | SPIFFS (960K partition) with threshold-based write protection | +| Testing | Playwright + curl + nutshell CLI | | Build | Makefile | ## Four-Phase Plan @@ -52,7 +53,7 @@ Build a TollGate firmware for two ESP32 devices, following the [TollGate protoco | 13 | Reset auth | GET /reset_authentication | 200 | PASS | | 14 | Internet blocked after reset | ping 8.8.8.8 | Fails | PASS | -### Phase 2: E-Cash Payments — IN PROGRESS +### Phase 2: E-Cash Payments — COMPLETE **Goal:** Replace free access with Cashu payment. ESP32 parses token, checks proof state via mint API, grants time-based session. @@ -79,29 +80,78 @@ Build a TollGate firmware for two ESP32 devices, following the [TollGate protoco | 26 | Client isolation | Only payer gets internet | Non-payer blocked | Phase 3 | | 27 | Full e2e: portal→pay→browse | Playwright | Complete flow | Phase 3 | -**Captive Portal Fix:** Added DoT reject server on port 853 (TCP RST forces DNS-over-TLS fallback to plain DNS), DNS hijack returns NXDOMAIN for all non-A query types, explicit 302 redirect handlers for all captive detection URIs. Needs verification with actual GrapheneOS phone. +**Captive Portal Detection:** DoT reject server on port 853, NXDOMAIN for non-A queries, 302 redirects for captive URIs. Verified working on GrapheneOS (commit `236b61d`). -### Phase 3: nucula Wallet + ESP32-to-ESP32 Payments — NOT STARTED +### Phase 3: On-Device Wallet + ESP32-to-ESP32 Payments — IN PROGRESS -**Goal:** Integrate nucula's full Cashu wallet. ESP32 holds balance, can be a reseller. ESP32-to-ESP32 direct payments. +**Goal:** On-device Cashu wallet using mbedTLS secp256k1. ESP32 holds balance, can swap proofs, create tokens for P2P payments. Proof persistence via SPIFFS with threshold-based write protection. -**11 Additional Test Cases:** -| # | Test | Method | Pass Criteria | -|---|------|--------|---------------| -| 28 | Wallet boot | Serial | Keysets loaded | -| 29 | Receive via wallet | POST :2121/ | Balance incremented | -| 30 | Balance persists | Reboot | Same balance | -| 31 | Payout routine | Wait + serial | Tokens melted to LN | -| 32 | Reseller discover | Serial | Upstream TollGate found | -| 33 | Reseller pay | Serial + API | Token POSTed upstream | -| 34 | Multi-hop internet | Ping from laptop | laptop→A→B→internet | -| 35 | P2PK receive | Post P2PK token | Auto-signed, accepted | -| 36 | DLEQ verified | Post token with DLEQ | Verified, accepted | -| 37 | 5 consecutive payments | Loop | All authenticated | -| 38 | Stress: rapid pay/expire | Loop with short sessions | No crash/leak | +#### Wallet Architecture + +- **Crypto**: mbedTLS secp256k1 (software ECP, hardware RNG via `esp_fill_random`) +- **Blind signing**: `hash_to_curve()` (SHA256 try-and-increment), `scalar_mul()`, `point_add()` +- **Unblinding**: `C = C_ + (order - r) * G` — avoids needing mint's public key K, avoids point negation +- **Proof storage**: In-memory array (50 max), persisted to SPIFFS JSON +- **Persistence**: SPIFFS `/spiffs/wallet.json`, only written when `balance >= persist_threshold_sats` +- **Keyset fetch**: GET /v1/keys from mint on boot +- **Swap**: POST /v1/swap — reissues proofs with new secrets +- **Token creation**: Encode proofs as `cashuA` base64url token + +#### Wallet Endpoints (on :2121) + +| Method | Path | Description | +|--------|------|-------------| +| GET | /wallet | Balance, proof count, keyset count | +| POST | /wallet/swap | Swap all proofs for fresh ones via mint | +| POST | /wallet/send | Create cashuA token for given amount (body = sat count) | + +#### Payment Integration + +Received payment proofs are automatically added to wallet after session creation in `tollgate_api.c`. + +#### Persistence Threshold + +Config parameter `persist_threshold_sats` (default: 1) controls when wallet state is written to flash: +- `balance >= persist_threshold_sats` → write wallet.json +- `balance < threshold` → skip write (or delete existing file) +- Rationale: flash has finite write cycles (~100K erase per sector); only persist when e-cash value justifies the wear cost +- SPIFFS wear-leveling spreads writes across the 960K partition + +#### Test Cases + +| # | Test | Method | Pass Criteria | Status | +|---|------|--------|---------------|--------| +| 28 | Wallet boot | Serial | Keysets loaded | TODO | +| 29 | Receive via wallet | POST :2121/ | Balance incremented | TODO | +| 30 | Wallet swap | POST /wallet/swap | Same balance, new proofs | TODO | +| 31 | Wallet send | POST /wallet/send | Valid cashuA token returned | TODO | +| 32 | Persistence survives reboot | Reboot + GET /wallet | Same balance | TODO | +| 33 | Cross-board payment | B sends → A receives | A balance increases | TODO | +| 34 | Two clients pay independently | Two POSTs | Both authenticated | TODO | +| 35 | Client isolation | Only payer gets internet | Non-payer blocked | TODO | +| 36 | Full e2e: portal→pay→browse | Playwright | Complete flow | TODO | +| 37 | 5 consecutive payments | Loop | All authenticated | TODO | +| 38 | Stress: rapid pay/expire | Loop with short sessions | No crash/leak | TODO | ### Phase 4: ESP32-to-OpenWRT TollGate Interop — NOT STARTED **Goal:** ESP32 can pay OpenWRT TollGate using Cashu tokens. Full interoperability with existing OpenWRT-based TollGate infrastructure. ## Total: 38 Tests across 4 phases + +## Key Technical Notes + +### mbedTLS 3.x Compatibility +- `mbedtls_ecp_point` is opaque — cannot access `.X`, `.Y`, `.Z` directly +- Use `mbedtls_ecp_muladd`, `mbedtls_ecp_mul`, `mbedtls_ecp_point_read/write_binary` +- No point negation needed with `C = C_ + (order - r) * G` unblinding approach + +### Board Configuration +- Board A: `/dev/ttyACM0`, MAC `94:a9:90:2e:37:7c`, SSID `TollGate-377C`, AP IP `10.55.85.1` +- Board B: `/dev/ttyACM1`, MAC `fc:01:2c:c5:50:50`, unique SSID/IP derived from MAC +- Both boards run identical firmware, unique config derived at boot from factory MAC + +### Test Mint +- `testnut.cashu.space` — auto-pays lightning invoices for testing +- `cashu -h https://testnut.cashu.space invoice ` → auto-paid +- `cashu -h https://testnut.cashu.space send --legacy ` → generates cashuA token 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" "cashu.c" "session.c" "tollgate_api.c" + "wallet.c" + "wallet_persist.c" INCLUDE_DIRS "." "${IDF_PATH}/components/lwip/include/apps" REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server 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 @@ #define CASHU_MAX_PROOFS 10 #define CASHU_MAX_SECRET_LEN 128 -#define CASHU_MAX_ID_LEN 16 +#define CASHU_MAX_ID_LEN 68 #define CASHU_MAX_C_LEN 128 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) g_config.ap_max_conn = 4; g_config.price_per_step = 21; g_config.step_size_ms = 60000; + g_config.persist_threshold_sats = 1; esp_vfs_spiffs_conf_t conf = { .base_path = "/spiffs", @@ -117,6 +118,9 @@ esp_err_t tollgate_config_init(void) cJSON *step = cJSON_GetObjectItem(root, "step_size_ms"); if (step) g_config.step_size_ms = step->valueint; + cJSON *persist = cJSON_GetObjectItem(root, "persist_threshold_sats"); + if (persist) g_config.persist_threshold_sats = (uint64_t)persist->valuedouble; + cJSON_Delete(root); ESP_LOGI(TAG, "Config loaded: AP='%s', %d WiFi networks, price=%d sats/%dms", 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 { char lnurl_url[256]; int price_per_step; int step_size_ms; + uint64_t persist_threshold_sats; bool unique_derived; } 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 @@ #include "config.h" #include "session.h" #include "firewall.h" +#include "wallet.h" #include "esp_log.h" #include "cJSON.h" #include "lwip/sockets.h" @@ -298,9 +299,9 @@ static esp_err_t api_post_payment(httpd_req_t *req) secrets[i] = token->proofs[i].secret; } session_t *session = session_create(client_ip, allotment, secrets, secret_count); - free(states); - free(token); if (!session) { + free(states); + free(token); cJSON *notice = create_notice("error", "session-error", "Failed to create session"); char *json = cJSON_PrintUnformatted(notice); httpd_resp_set_status(req, "503 Service Unavailable"); @@ -317,6 +318,21 @@ static esp_err_t api_post_payment(httpd_req_t *req) httpd_resp_send(req, json, strlen(json)); cJSON_free(json); cJSON_Delete(session_event); + + { + wallet_proof_t wproofs[CASHU_MAX_PROOFS]; + int wcount = token->proof_count > CASHU_MAX_PROOFS ? CASHU_MAX_PROOFS : token->proof_count; + for (int i = 0; i < wcount; i++) { + wproofs[i].amount = token->proofs[i].amount; + strncpy(wproofs[i].id, token->proofs[i].id, WALLET_KEYSET_ID_LEN - 1); + strncpy(wproofs[i].secret, token->proofs[i].secret, WALLET_SECRET_LEN - 1); + strncpy(wproofs[i].c, token->proofs[i].c, WALLET_SIG_LEN - 1); + } + wallet_add_proofs(wproofs, wcount); + } + + free(states); + free(token); return ESP_OK; } @@ -363,10 +379,121 @@ static esp_err_t api_get_whoami(httpd_req_t *req) return ESP_OK; } +static esp_err_t api_get_wallet(httpd_req_t *req) +{ + wallet_t *w = wallet_get(); + cJSON *root = cJSON_CreateObject(); + cJSON_AddNumberToObject(root, "balance", (double)w->balance); + cJSON_AddNumberToObject(root, "proof_count", w->proof_count); + cJSON_AddNumberToObject(root, "keyset_count", w->keyset_count); + + cJSON *proofs = cJSON_CreateArray(); + for (int i = 0; i < w->proof_count; i++) { + cJSON *p = cJSON_CreateObject(); + cJSON_AddNumberToObject(p, "amount", (double)w->proofs[i].amount); + cJSON_AddStringToObject(p, "id", w->proofs[i].id); + cJSON_AddItemToArray(proofs, p); + } + cJSON_AddItemToObject(root, "proofs", proofs); + + char *json = cJSON_PrintUnformatted(root); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json, strlen(json)); + cJSON_free(json); + cJSON_Delete(root); + return ESP_OK; +} + +static esp_err_t api_post_wallet_swap(httpd_req_t *req) +{ + const tollgate_config_t *cfg = tollgate_config_get(); + + if (wallet_balance() == 0) { + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, "{\"error\":\"no proofs to swap\"}", 27); + return ESP_OK; + } + + wallet_print_status(); + + esp_err_t err = wallet_fetch_keysets(cfg->mint_url); + if (err != ESP_OK) { + httpd_resp_set_status(req, "502 Bad Gateway"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, "{\"error\":\"keyset fetch failed\"}", 29); + return ESP_OK; + } + + wallet_t *w = wallet_get(); + err = wallet_swap_proofs(cfg->mint_url, 0, w->proof_count); + if (err != ESP_OK) { + httpd_resp_set_status(req, "502 Bad Gateway"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, "{\"error\":\"swap failed\"}", 21); + return ESP_OK; + } + + wallet_print_status(); + + cJSON *root = cJSON_CreateObject(); + cJSON_AddNumberToObject(root, "balance", (double)wallet_balance()); + cJSON_AddNumberToObject(root, "proof_count", wallet_get()->proof_count); + char *json = cJSON_PrintUnformatted(root); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json, strlen(json)); + cJSON_free(json); + cJSON_Delete(root); + return ESP_OK; +} + +static esp_err_t api_post_wallet_send(httpd_req_t *req) +{ + int content_len = req->content_len; + if (content_len <= 0 || content_len > 32) { + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_send(req, "invalid amount", 14); + return ESP_OK; + } + + char body[32]; + int total = 0; + while (total < content_len) { + int r = httpd_req_recv(req, body + total, content_len - total); + if (r <= 0) { httpd_resp_send_500(req); return ESP_OK; } + total += r; + } + body[total] = '\0'; + + uint64_t amount = strtoull(body, NULL, 10); + if (amount == 0) { + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_send(req, "invalid amount", 14); + return ESP_OK; + } + + const tollgate_config_t *cfg = tollgate_config_get(); + char token[4096]; + esp_err_t err = wallet_send(cfg->mint_url, amount, token, sizeof(token)); + if (err != ESP_OK) { + httpd_resp_set_status(req, "402 Payment Required"); + httpd_resp_set_type(req, "text/plain"); + httpd_resp_send(req, "insufficient balance", 20); + return ESP_OK; + } + + httpd_resp_set_type(req, "text/plain"); + httpd_resp_send(req, token, strlen(token)); + return ESP_OK; +} + static const httpd_uri_t uri_discovery = { .uri = "/", .method = HTTP_GET, .handler = api_get_discovery }; static const httpd_uri_t uri_payment = { .uri = "/", .method = HTTP_POST, .handler = api_post_payment }; static const httpd_uri_t uri_usage = { .uri = "/usage", .method = HTTP_GET, .handler = api_get_usage }; static const httpd_uri_t uri_whoami = { .uri = "/whoami", .method = HTTP_GET, .handler = api_get_whoami }; +static const httpd_uri_t uri_wallet = { .uri = "/wallet", .method = HTTP_GET, .handler = api_get_wallet }; +static const httpd_uri_t uri_wallet_swap = { .uri = "/wallet/swap", .method = HTTP_POST, .handler = api_post_wallet_swap }; +static const httpd_uri_t uri_wallet_send = { .uri = "/wallet/send", .method = HTTP_POST, .handler = api_post_wallet_send }; esp_err_t tollgate_api_start(void) { @@ -388,6 +515,9 @@ esp_err_t tollgate_api_start(void) httpd_register_uri_handler(s_api_server, &uri_payment); httpd_register_uri_handler(s_api_server, &uri_usage); httpd_register_uri_handler(s_api_server, &uri_whoami); + httpd_register_uri_handler(s_api_server, &uri_wallet); + httpd_register_uri_handler(s_api_server, &uri_wallet_swap); + httpd_register_uri_handler(s_api_server, &uri_wallet_send); ESP_LOGI(TAG, "TollGate API started on port 2121"); 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 @@ #include "firewall.h" #include "session.h" #include "tollgate_api.h" +#include "wallet.h" #define MAX_STA_RETRY 5 static const char *TAG = "tollgate_main"; @@ -88,6 +89,14 @@ static void ip_event_handler(void *arg, esp_event_base_t event_base, } } +static void wallet_init_task(void *pvParameters) +{ + const tollgate_config_t *cfg = tollgate_config_get(); + wallet_init(); + wallet_fetch_keysets(cfg->mint_url); + vTaskDelete(NULL); +} + static void start_services(void) { if (s_services_mutex) xSemaphoreTake(s_services_mutex, portMAX_DELAY); @@ -107,6 +116,8 @@ static void start_services(void) firewall_init(ap_ip_info.ip); session_manager_init(); + xTaskCreate(wallet_init_task, "wallet_init", 32768, NULL, 5, NULL); + const tollgate_config_t *cfg = tollgate_config_get(); dns_server_start(ap_ip_info.ip, upstream_dns); 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 @@ +#include "wallet.h" +#include "wallet_persist.h" +#include "config.h" +#include "esp_log.h" +#include "esp_random.h" +#include "esp_http_client.h" +#include "esp_crt_bundle.h" +#include "cJSON.h" +#include "mbedtls/ecp.h" +#include "mbedtls/bignum.h" +#include "mbedtls/sha256.h" +#include "mbedtls/base64.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/semphr.h" +#include "esp_heap_caps.h" +#include +#include + +static const char *TAG = "wallet"; +static wallet_t s_wallet; + +static const char DOMAIN_SEPARATOR[] = "Secp256k1_HashToCurve_Cashu_"; + +static mbedtls_ecp_group s_grp; +static mbedtls_mpi s_order; +static bool s_grp_loaded = false; + +static esp_err_t init_ecp_group(void) +{ + if (s_grp_loaded) return ESP_OK; + mbedtls_ecp_group_init(&s_grp); + mbedtls_mpi_init(&s_order); + int ret = mbedtls_ecp_group_load(&s_grp, MBEDTLS_ECP_DP_SECP256K1); + if (ret != 0) { + ESP_LOGE(TAG, "Failed to load secp256k1 group: -0x%x", -ret); + return ESP_FAIL; + } + mbedtls_mpi_copy(&s_order, &s_grp.N); + s_grp_loaded = true; + return ESP_OK; +} + +static void random_bytes(uint8_t *buf, size_t len) +{ + esp_fill_random(buf, len); +} + +static esp_err_t random_scalar(mbedtls_mpi *r) +{ + uint8_t buf[32]; + random_bytes(buf, 32); + mbedtls_mpi_init(r); + int ret = mbedtls_mpi_read_binary(r, buf, 32); + if (ret != 0) return ESP_FAIL; + ret = mbedtls_mpi_mod_mpi(r, r, &s_order); + if (ret != 0) return ESP_FAIL; + if (mbedtls_mpi_cmp_int(r, 1) < 0) { + mbedtls_mpi_add_int(r, r, 1); + } + return ESP_OK; +} + +static esp_err_t hash_to_curve(const uint8_t *msg, size_t msg_len, mbedtls_ecp_point *Y) +{ + uint8_t msg_hash[32]; + size_t ds_len = strlen(DOMAIN_SEPARATOR); + uint8_t *hash_input = malloc(ds_len + msg_len); + if (!hash_input) return ESP_FAIL; + memcpy(hash_input, DOMAIN_SEPARATOR, ds_len); + memcpy(hash_input + ds_len, msg, msg_len); + mbedtls_sha256(hash_input, ds_len + msg_len, msg_hash, 0); + free(hash_input); + + mbedtls_ecp_point_init(Y); + for (uint32_t counter = 0; counter < 256; counter++) { + uint8_t counter_bytes[4]; + counter_bytes[0] = counter & 0xFF; + counter_bytes[1] = (counter >> 8) & 0xFF; + counter_bytes[2] = (counter >> 16) & 0xFF; + counter_bytes[3] = (counter >> 24) & 0xFF; + + uint8_t to_hash[32 + 4 + 1]; + memcpy(to_hash, msg_hash, 32); + memcpy(to_hash + 32, counter_bytes, 4); + + uint8_t point_hash[32]; + mbedtls_sha256(to_hash, 36, point_hash, 0); + + uint8_t compressed[33]; + compressed[0] = 0x02; + memcpy(compressed + 1, point_hash, 32); + + int ret = mbedtls_ecp_point_read_binary(&s_grp, Y, compressed, 33); + if (ret == 0) { + ret = mbedtls_ecp_check_pubkey(&s_grp, Y); + if (ret == 0) return ESP_OK; + } + + compressed[0] = 0x03; + ret = mbedtls_ecp_point_read_binary(&s_grp, Y, compressed, 33); + if (ret == 0) { + ret = mbedtls_ecp_check_pubkey(&s_grp, Y); + if (ret == 0) return ESP_OK; + } + } + + ESP_LOGE(TAG, "hash_to_curve failed after 256 attempts"); + return ESP_FAIL; +} + +static esp_err_t point_add(const mbedtls_ecp_point *A, const mbedtls_ecp_point *B, + mbedtls_ecp_point *R) +{ + mbedtls_mpi one; + mbedtls_mpi_init(&one); + mbedtls_mpi_lset(&one, 1); + int ret = mbedtls_ecp_muladd(&s_grp, R, &one, A, &one, B); + if (ret != 0) { + ESP_LOGE(TAG, "point_add failed: -0x%x", -ret); + } + mbedtls_mpi_free(&one); + return (ret == 0) ? ESP_OK : ESP_FAIL; +} + +static esp_err_t scalar_mul(const mbedtls_mpi *m, const mbedtls_ecp_point *P, + mbedtls_ecp_point *R) +{ + int ret = mbedtls_ecp_mul(&s_grp, R, m, P, NULL, NULL); + if (ret != 0) { + ESP_LOGE(TAG, "scalar_mul failed: -0x%x", -ret); + } + return (ret == 0) ? ESP_OK : ESP_FAIL; +} + +static int hex_to_bytes(const char *hex, uint8_t *bytes, size_t bytes_len) +{ + size_t hex_len = strlen(hex); + if (hex_len / 2 > bytes_len) return -1; + for (size_t i = 0; i < hex_len / 2; i++) { + unsigned int b; + sscanf(hex + i * 2, "%02x", &b); + bytes[i] = (uint8_t)b; + } + return hex_len / 2; +} + +static void bytes_to_hex(const uint8_t *bytes, size_t len, char *hex) +{ + for (size_t i = 0; i < len; i++) { + sprintf(hex + i * 2, "%02x", bytes[i]); + } + hex[len * 2] = '\0'; +} + +esp_err_t wallet_init(void) +{ + memset(&s_wallet, 0, sizeof(s_wallet)); + esp_err_t err = init_ecp_group(); + if (err != ESP_OK) return err; + wallet_persist_load(); + ESP_LOGI(TAG, "Wallet initialized (secp256k1 loaded)"); + return ESP_OK; +} + +wallet_t *wallet_get(void) +{ + return &s_wallet; +} + +uint64_t wallet_balance(void) +{ + return s_wallet.balance; +} + +esp_err_t wallet_add_proofs(const wallet_proof_t *proofs, int count) +{ + for (int i = 0; i < count; i++) { + if (s_wallet.proof_count >= WALLET_MAX_PROOFS) { + ESP_LOGW(TAG, "Wallet full, cannot add more proofs"); + return ESP_ERR_NO_MEM; + } + memcpy(&s_wallet.proofs[s_wallet.proof_count], &proofs[i], sizeof(wallet_proof_t)); + s_wallet.balance += proofs[i].amount; + s_wallet.proof_count++; + ESP_LOGI(TAG, "Added proof: amount=%llu, total_balance=%llu", + (unsigned long long)proofs[i].amount, + (unsigned long long)s_wallet.balance); + } + wallet_persist_save(); + return ESP_OK; +} + +esp_err_t wallet_remove_proof(int index) +{ + if (index < 0 || index >= s_wallet.proof_count) return ESP_ERR_INVALID_ARG; + s_wallet.balance -= s_wallet.proofs[index].amount; + for (int i = index; i < s_wallet.proof_count - 1; i++) { + memcpy(&s_wallet.proofs[i], &s_wallet.proofs[i + 1], sizeof(wallet_proof_t)); + } + memset(&s_wallet.proofs[s_wallet.proof_count - 1], 0, sizeof(wallet_proof_t)); + s_wallet.proof_count--; + wallet_persist_save(); + return ESP_OK; +} + +void wallet_clear(void) +{ + s_wallet.balance = 0; + s_wallet.proof_count = 0; + wallet_persist_save(); +} + +esp_err_t wallet_fetch_keysets(const char *mint_url) +{ + char url[512]; + snprintf(url, sizeof(url), "%s/v1/keysets", mint_url); + + char *resp_buf = malloc(8192); + if (!resp_buf) return ESP_ERR_NO_MEM; + + esp_http_client_config_t config = { + .url = url, + .method = HTTP_METHOD_GET, + .timeout_ms = 10000, + .crt_bundle_attach = esp_crt_bundle_attach, + }; + esp_http_client_handle_t client = esp_http_client_init(&config); + if (!client) { free(resp_buf); return ESP_FAIL; } + + esp_err_t err = esp_http_client_open(client, 0); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Keyset fetch open failed: %s", esp_err_to_name(err)); + esp_http_client_cleanup(client); + free(resp_buf); + return err; + } + + int content_length = esp_http_client_fetch_headers(client); + int status = esp_http_client_get_status_code(client); + ESP_LOGI(TAG, "Keyset fetch: status=%d content_length=%d", status, content_length); + + int resp_len = esp_http_client_read(client, resp_buf, 8191); + ESP_LOGI(TAG, "Keyset fetch: read %d bytes", resp_len); + esp_http_client_cleanup(client); + + if (status != 200 || resp_len <= 0) { + ESP_LOGE(TAG, "Keyset fetch failed: status=%d len=%d", status, resp_len); + free(resp_buf); + return ESP_FAIL; + } + resp_buf[resp_len] = '\0'; + + cJSON *root = cJSON_Parse(resp_buf); + free(resp_buf); + if (!root) return ESP_FAIL; + + cJSON *keysets = cJSON_GetObjectItemCaseSensitive(root, "keysets"); + if (!keysets || !cJSON_IsArray(keysets)) { + cJSON_Delete(root); + return ESP_FAIL; + } + + s_wallet.keyset_count = 0; + int n = cJSON_GetArraySize(keysets); + for (int i = 0; i < n && i < WALLET_MAX_KEYSETS; i++) { + cJSON *ks = cJSON_GetArrayItem(keysets, i); + cJSON *id = cJSON_GetObjectItemCaseSensitive(ks, "id"); + if (id && cJSON_IsString(id)) { + strncpy(s_wallet.keysets[s_wallet.keyset_count].id, id->valuestring, + WALLET_KEYSET_ID_LEN - 1); + cJSON *fee = cJSON_GetObjectItemCaseSensitive(ks, "input_fee_ppk"); + s_wallet.keysets[s_wallet.keyset_count].input_fee_ppk = fee ? fee->valueint : 0; + s_wallet.keyset_count++; + } + } + + cJSON_Delete(root); + ESP_LOGI(TAG, "Fetched %d keysets from %s", s_wallet.keyset_count, mint_url); + return ESP_OK; +} + +esp_err_t wallet_swap_proofs(const char *mint_url, int start_index, int count) +{ + ESP_LOGI(TAG, "wallet_swap_proofs called: start=%d count=%d keysets=%d proofs=%d", + start_index, count, s_wallet.keyset_count, s_wallet.proof_count); + + if (s_wallet.keyset_count == 0) { + ESP_LOGE(TAG, "No keysets loaded, fetch first"); + return ESP_FAIL; + } + if (start_index < 0 || start_index + count > s_wallet.proof_count) { + return ESP_ERR_INVALID_ARG; + } + + wallet_proof_t *old_proofs = &s_wallet.proofs[start_index]; + int n = count; + + uint64_t total_input = 0; + for (int i = 0; i < n; i++) total_input += old_proofs[i].amount; + + int fee_ppk = s_wallet.keysets[0].input_fee_ppk; + uint64_t fee_sats = (total_input * fee_ppk + 999) / 1000; + uint64_t total_output = total_input - fee_sats; + ESP_LOGI(TAG, "Swap: total_input=%llu fee_ppk=%d fee=%llu total_output=%llu", + (unsigned long long)total_input, fee_ppk, + (unsigned long long)fee_sats, (unsigned long long)total_output); + + cJSON *inputs = cJSON_CreateArray(); + for (int i = 0; i < n; i++) { + cJSON *p = cJSON_CreateObject(); + cJSON_AddNumberToObject(p, "amount", (double)old_proofs[i].amount); + cJSON_AddStringToObject(p, "id", old_proofs[i].id); + cJSON_AddStringToObject(p, "secret", old_proofs[i].secret); + cJSON_AddStringToObject(p, "C", old_proofs[i].c); + cJSON_AddItemToArray(inputs, p); + } + + typedef struct { + uint8_t secret[32]; + mbedtls_mpi r; + mbedtls_ecp_point Y; + } swap_output_t; + + swap_output_t *outputs = heap_caps_malloc(n * sizeof(swap_output_t), MALLOC_CAP_SPIRAM); + if (!outputs) { cJSON_Delete(inputs); return ESP_ERR_NO_MEM; } + + cJSON *blinded_msgs = cJSON_CreateArray(); + for (int i = 0; i < n; i++) { + random_bytes(outputs[i].secret, 32); + mbedtls_ecp_point_init(&outputs[i].Y); + esp_err_t htc_ret = hash_to_curve(outputs[i].secret, 32, &outputs[i].Y); + if (htc_ret != ESP_OK) { + ESP_LOGE(TAG, "hash_to_curve failed for output %d", i); + } + mbedtls_mpi_init(&outputs[i].r); + random_scalar(&outputs[i].r); + + mbedtls_ecp_point rG, B_; + mbedtls_ecp_point_init(&rG); + mbedtls_ecp_point_init(&B_); + + esp_err_t sm_ret = scalar_mul(&outputs[i].r, &s_grp.G, &rG); + if (sm_ret != ESP_OK) { + ESP_LOGE(TAG, "scalar_mul failed for output %d", i); + } + esp_err_t pa_ret = point_add(&outputs[i].Y, &rG, &B_); + if (pa_ret != ESP_OK) { + ESP_LOGE(TAG, "point_add failed for output %d", i); + } + + uint8_t b_bytes[33]; + size_t olen = 0; + int wret = mbedtls_ecp_point_write_binary(&s_grp, &B_, MBEDTLS_ECP_PF_COMPRESSED, &olen, b_bytes, 33); + if (wret != 0 || olen == 0) { + ESP_LOGE(TAG, "Blinded point write failed: ret=-0x%x olen=%zu", -wret, olen); + olen = 1; + b_bytes[0] = 0x00; + } + char b_hex[67]; + bytes_to_hex(b_bytes, olen, b_hex); + + uint64_t out_amount = old_proofs[i].amount; + if (i == n - 1) { + uint64_t running = 0; + for (int j = 0; j < n - 1; j++) running += old_proofs[j].amount; + out_amount = total_output - running; + } + + cJSON *bm = cJSON_CreateObject(); + cJSON_AddNumberToObject(bm, "amount", (double)out_amount); + cJSON_AddStringToObject(bm, "id", s_wallet.keysets[0].id); + cJSON_AddStringToObject(bm, "B_", b_hex); + cJSON_AddItemToArray(blinded_msgs, bm); + + mbedtls_ecp_point_free(&rG); + mbedtls_ecp_point_free(&B_); + } + + cJSON *body = cJSON_CreateObject(); + cJSON_AddItemToObject(body, "inputs", inputs); + cJSON_AddItemToObject(body, "outputs", blinded_msgs); + char *body_str = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + + ESP_LOGI(TAG, "Swap request body (%zu bytes): %s", strlen(body_str), body_str); + + char url[512]; + snprintf(url, sizeof(url), "%s/v1/swap", mint_url); + + char *resp_buf = malloc(8192); + if (!resp_buf) { + free(body_str); + for (int i = 0; i < n; i++) { + mbedtls_mpi_free(&outputs[i].r); + mbedtls_ecp_point_free(&outputs[i].Y); + } + free(outputs); + return ESP_ERR_NO_MEM; + } + + esp_http_client_config_t config = { + .url = url, + .method = HTTP_METHOD_POST, + .timeout_ms = 15000, + .crt_bundle_attach = esp_crt_bundle_attach, + }; + esp_http_client_handle_t client = esp_http_client_init(&config); + if (!client) { + free(body_str); + free(resp_buf); + for (int i = 0; i < n; i++) { + mbedtls_mpi_free(&outputs[i].r); + mbedtls_ecp_point_free(&outputs[i].Y); + } + free(outputs); + return ESP_FAIL; + } + + esp_http_client_set_header(client, "Content-Type", "application/json"); + esp_http_client_open(client, strlen(body_str)); + esp_http_client_write(client, body_str, strlen(body_str)); + free(body_str); + + esp_http_client_fetch_headers(client); + int resp_len = esp_http_client_read(client, resp_buf, 8191); + int status = esp_http_client_get_status_code(client); + esp_http_client_cleanup(client); + + if (status != 200 || resp_len <= 0) { + if (resp_len > 0) { + resp_buf[resp_len] = '\0'; + ESP_LOGE(TAG, "Swap failed: status=%d body=%s", status, resp_buf); + } else { + ESP_LOGE(TAG, "Swap failed: status=%d len=%d", status, resp_len); + } + free(resp_buf); + for (int i = 0; i < n; i++) { + mbedtls_mpi_free(&outputs[i].r); + mbedtls_ecp_point_free(&outputs[i].Y); + } + free(outputs); + return ESP_FAIL; + } + resp_buf[resp_len] = '\0'; + + cJSON *root = cJSON_Parse(resp_buf); + free(resp_buf); + if (!root) { + for (int i = 0; i < n; i++) { + mbedtls_mpi_free(&outputs[i].r); + mbedtls_ecp_point_free(&outputs[i].Y); + } + free(outputs); + return ESP_FAIL; + } + + cJSON *signatures = cJSON_GetObjectItemCaseSensitive(root, "signatures"); + if (!signatures || !cJSON_IsArray(signatures)) { + ESP_LOGE(TAG, "No signatures in swap response"); + cJSON_Delete(root); + for (int i = 0; i < n; i++) { + mbedtls_mpi_free(&outputs[i].r); + mbedtls_ecp_point_free(&outputs[i].Y); + } + free(outputs); + return ESP_FAIL; + } + + for (int i = start_index; i < start_index + n; i++) { + s_wallet.balance -= s_wallet.proofs[i].amount; + } + + int sig_count = cJSON_GetArraySize(signatures); + for (int i = 0; i < sig_count && i < n; i++) { + cJSON *sig = cJSON_GetArrayItem(signatures, i); + cJSON *c_ = cJSON_GetObjectItemCaseSensitive(sig, "C_"); + cJSON *amt = cJSON_GetObjectItemCaseSensitive(sig, "amount"); + cJSON *id = cJSON_GetObjectItemCaseSensitive(sig, "id"); + + if (!c_ || !cJSON_IsString(c_)) continue; + + uint8_t c_bytes[33]; + int c_len = hex_to_bytes(c_->valuestring, c_bytes, 33); + + mbedtls_ecp_point C_; + mbedtls_ecp_point_init(&C_); + mbedtls_ecp_point_read_binary(&s_grp, &C_, c_bytes, c_len); + + char ks_id[WALLET_KEYSET_ID_LEN] = {0}; + if (id && cJSON_IsString(id)) { + strncpy(ks_id, id->valuestring, WALLET_KEYSET_ID_LEN - 1); + } + + mbedtls_mpi neg_r; + mbedtls_mpi_init(&neg_r); + mbedtls_mpi_sub_mpi(&neg_r, &s_order, &outputs[i].r); + + mbedtls_ecp_point neg_rG; + mbedtls_ecp_point_init(&neg_rG); + scalar_mul(&neg_r, &s_grp.G, &neg_rG); + + mbedtls_ecp_point C; + mbedtls_ecp_point_init(&C); + point_add(&C_, &neg_rG, &C); + + uint8_t c_final[33]; + size_t c_final_len; + mbedtls_ecp_point_write_binary(&s_grp, &C, MBEDTLS_ECP_PF_COMPRESSED, + &c_final_len, c_final, 33); + + if (s_wallet.proof_count < WALLET_MAX_PROOFS) { + wallet_proof_t *wp = &s_wallet.proofs[s_wallet.proof_count]; + if (amt && cJSON_IsNumber(amt)) { + wp->amount = (uint64_t)amt->valuedouble; + } + strncpy(wp->id, ks_id, WALLET_KEYSET_ID_LEN - 1); + bytes_to_hex(outputs[i].secret, 32, wp->secret); + bytes_to_hex(c_final, c_final_len, wp->c); + s_wallet.balance += wp->amount; + s_wallet.proof_count++; + } + + mbedtls_mpi_free(&neg_r); + mbedtls_ecp_point_free(&C_); + mbedtls_ecp_point_free(&neg_rG); + mbedtls_ecp_point_free(&C); + } + + for (int i = 0; i < n; i++) { + int idx = start_index; + for (int j = idx; j < s_wallet.proof_count - 1; j++) { + memcpy(&s_wallet.proofs[j], &s_wallet.proofs[j + 1], sizeof(wallet_proof_t)); + } + s_wallet.proof_count--; + } + + for (int i = 0; i < n; i++) { + mbedtls_mpi_free(&outputs[i].r); + mbedtls_ecp_point_free(&outputs[i].Y); + } + free(outputs); + cJSON_Delete(root); + + ESP_LOGI(TAG, "Swap complete: %d proofs swapped, balance=%llu", + n, (unsigned long long)s_wallet.balance); + wallet_persist_save(); + return ESP_OK; +} + +esp_err_t wallet_create_token(char *out, size_t out_size, uint64_t amount, + const char *mint_url) +{ + if (s_wallet.proof_count == 0 || s_wallet.balance < amount) { + ESP_LOGE(TAG, "Insufficient balance: have=%llu need=%llu", + (unsigned long long)s_wallet.balance, (unsigned long long)amount); + return ESP_FAIL; + } + + cJSON *proofs_arr = cJSON_CreateArray(); + uint64_t remaining = amount; + int indices_to_remove[10]; + int remove_count = 0; + + for (int i = 0; i < s_wallet.proof_count && remaining > 0 && remove_count < 10; i++) { + if (s_wallet.proofs[i].amount <= remaining) { + cJSON *p = cJSON_CreateObject(); + cJSON_AddNumberToObject(p, "amount", (double)s_wallet.proofs[i].amount); + cJSON_AddStringToObject(p, "id", s_wallet.proofs[i].id); + cJSON_AddStringToObject(p, "secret", s_wallet.proofs[i].secret); + cJSON_AddStringToObject(p, "C", s_wallet.proofs[i].c); + cJSON_AddItemToArray(proofs_arr, p); + remaining -= s_wallet.proofs[i].amount; + indices_to_remove[remove_count++] = i; + } + } + + if (remaining > 0) { + cJSON_Delete(proofs_arr); + ESP_LOGE(TAG, "Cannot make exact amount: %llu remaining", (unsigned long long)remaining); + return ESP_FAIL; + } + + cJSON *token_obj = cJSON_CreateObject(); + cJSON *token_arr = cJSON_CreateArray(); + cJSON *mint_proofs = cJSON_CreateObject(); + cJSON_AddStringToObject(mint_proofs, "mint", mint_url); + cJSON_AddItemToObject(mint_proofs, "proofs", proofs_arr); + cJSON_AddItemToArray(token_arr, mint_proofs); + cJSON_AddItemToObject(token_obj, "token", token_arr); + + char *json_str = cJSON_PrintUnformatted(token_obj); + cJSON_Delete(token_obj); + + size_t b64_len; + mbedtls_base64_encode((unsigned char *)out + 6, out_size - 6, &b64_len, + (const unsigned char *)json_str, strlen(json_str)); + free(json_str); + + memcpy(out, "cashuA", 6); + for (size_t i = 0; i < b64_len; i++) { + if (out[6 + i] == '+') out[6 + i] = '-'; + else if (out[6 + i] == '/') out[6 + i] = '_'; + else if (out[6 + i] == '=') { out[6 + i] = '\0'; break; } + } + out[6 + b64_len] = '\0'; + + for (int i = remove_count - 1; i >= 0; i--) { + s_wallet.balance -= s_wallet.proofs[indices_to_remove[i]].amount; + for (int j = indices_to_remove[i]; j < s_wallet.proof_count - 1; j++) { + memcpy(&s_wallet.proofs[j], &s_wallet.proofs[j + 1], sizeof(wallet_proof_t)); + } + s_wallet.proof_count--; + } + + ESP_LOGI(TAG, "Created token for %llu sats, remaining balance=%llu", + (unsigned long long)amount, (unsigned long long)s_wallet.balance); + wallet_persist_save(); + return ESP_OK; +} + +esp_err_t wallet_send(const char *mint_url, uint64_t amount, + char *token_out, size_t token_out_size) +{ + return wallet_create_token(token_out, token_out_size, amount, mint_url); +} + +void wallet_print_status(void) +{ + ESP_LOGI(TAG, "Wallet: %d proofs, balance=%llu sats, %d keysets", + s_wallet.proof_count, + (unsigned long long)s_wallet.balance, + s_wallet.keyset_count); + for (int i = 0; i < s_wallet.proof_count; i++) { + ESP_LOGI(TAG, " [%d] amount=%llu id=%s", i, + (unsigned long long)s_wallet.proofs[i].amount, + s_wallet.proofs[i].id); + } +} 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 @@ +#ifndef WALLET_H +#define WALLET_H + +#include "esp_err.h" +#include +#include + +#define WALLET_MAX_PROOFS 50 +#define WALLET_MAX_KEYSETS 5 +#define WALLET_KEYSET_ID_LEN 68 +#define WALLET_SECRET_LEN 65 +#define WALLET_SIG_LEN 67 + +typedef struct { + uint64_t amount; + char id[WALLET_KEYSET_ID_LEN]; + char secret[WALLET_SECRET_LEN]; + char c[WALLET_SIG_LEN]; +} wallet_proof_t; + +typedef struct { + char id[WALLET_KEYSET_ID_LEN]; + char public_key_33[67]; + uint64_t amount; + int input_fee_ppk; +} wallet_keyset_t; + +typedef struct { + wallet_proof_t proofs[WALLET_MAX_PROOFS]; + int proof_count; + wallet_keyset_t keysets[WALLET_MAX_KEYSETS]; + int keyset_count; + uint64_t balance; +} wallet_t; + +esp_err_t wallet_init(void); +wallet_t *wallet_get(void); +uint64_t wallet_balance(void); + +esp_err_t wallet_add_proofs(const wallet_proof_t *proofs, int count); +esp_err_t wallet_remove_proof(int index); +void wallet_clear(void); + +esp_err_t wallet_fetch_keysets(const char *mint_url); +esp_err_t wallet_swap_proofs(const char *mint_url, int start_index, int count); + +esp_err_t wallet_create_token(char *out, size_t out_size, uint64_t amount, + const char *mint_url); +esp_err_t wallet_send(const char *mint_url, uint64_t amount, + char *token_out, size_t token_out_size); + +void wallet_print_status(void); +#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 @@ +#include "wallet_persist.h" +#include "wallet.h" +#include "config.h" +#include "esp_log.h" +#include "cJSON.h" +#include +#include +#include + +static const char *TAG = "wallet_persist"; +static const char *WALLET_FILE = "/spiffs/wallet.json"; + +esp_err_t wallet_persist_save(void) +{ + const tollgate_config_t *cfg = tollgate_config_get(); + wallet_t *w = wallet_get(); + + if (w->balance < cfg->persist_threshold_sats) { + if (w->proof_count == 0) { + unlink(WALLET_FILE); + ESP_LOGI(TAG, "Wallet empty, removed persist file"); + } + return ESP_OK; + } + + cJSON *root = cJSON_CreateObject(); + cJSON_AddNumberToObject(root, "balance", (double)w->balance); + + cJSON *proofs = cJSON_CreateArray(); + for (int i = 0; i < w->proof_count; i++) { + cJSON *p = cJSON_CreateObject(); + cJSON_AddNumberToObject(p, "amount", (double)w->proofs[i].amount); + cJSON_AddStringToObject(p, "id", w->proofs[i].id); + cJSON_AddStringToObject(p, "secret", w->proofs[i].secret); + cJSON_AddStringToObject(p, "C", w->proofs[i].c); + cJSON_AddItemToArray(proofs, p); + } + cJSON_AddItemToObject(root, "proofs", proofs); + + cJSON *keysets = cJSON_CreateArray(); + for (int i = 0; i < w->keyset_count; i++) { + cJSON *ks = cJSON_CreateObject(); + cJSON_AddStringToObject(ks, "id", w->keysets[i].id); + cJSON_AddItemToArray(keysets, ks); + } + cJSON_AddItemToObject(root, "keysets", keysets); + + char *json_str = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + + FILE *f = fopen(WALLET_FILE, "w"); + if (!f) { + ESP_LOGE(TAG, "Failed to open %s for writing", WALLET_FILE); + cJSON_free(json_str); + return ESP_FAIL; + } + + size_t written = fwrite(json_str, 1, strlen(json_str), f); + fclose(f); + cJSON_free(json_str); + + ESP_LOGI(TAG, "Wallet persisted: %d proofs, balance=%llu (%zu bytes)", + w->proof_count, (unsigned long long)w->balance, written); + return ESP_OK; +} + +esp_err_t wallet_persist_load(void) +{ + wallet_t *w = wallet_get(); + + FILE *f = fopen(WALLET_FILE, "r"); + if (!f) { + ESP_LOGI(TAG, "No persisted wallet found, starting fresh"); + return ESP_OK; + } + + fseek(f, 0, SEEK_END); + long fsize = ftell(f); + fseek(f, 0, SEEK_SET); + + if (fsize <= 0 || fsize > 65536) { + fclose(f); + ESP_LOGW(TAG, "Wallet file size invalid: %ld", fsize); + return ESP_FAIL; + } + + char *buf = malloc(fsize + 1); + if (!buf) { + fclose(f); + return ESP_ERR_NO_MEM; + } + + fread(buf, 1, fsize, f); + buf[fsize] = '\0'; + fclose(f); + + cJSON *root = cJSON_Parse(buf); + free(buf); + if (!root) { + ESP_LOGE(TAG, "Failed to parse wallet.json"); + return ESP_FAIL; + } + + cJSON *balance_j = cJSON_GetObjectItemCaseSensitive(root, "balance"); + if (balance_j && cJSON_IsNumber(balance_j)) { + w->balance = (uint64_t)balance_j->valuedouble; + } + + cJSON *proofs = cJSON_GetObjectItemCaseSensitive(root, "proofs"); + if (proofs && cJSON_IsArray(proofs)) { + int count = cJSON_GetArraySize(proofs); + if (count > WALLET_MAX_PROOFS) count = WALLET_MAX_PROOFS; + for (int i = 0; i < count; i++) { + cJSON *p = cJSON_GetArrayItem(proofs, i); + cJSON *amt = cJSON_GetObjectItemCaseSensitive(p, "amount"); + cJSON *id = cJSON_GetObjectItemCaseSensitive(p, "id"); + cJSON *secret = cJSON_GetObjectItemCaseSensitive(p, "secret"); + cJSON *c = cJSON_GetObjectItemCaseSensitive(p, "C"); + if (amt) w->proofs[i].amount = (uint64_t)amt->valuedouble; + if (id && cJSON_IsString(id)) + strncpy(w->proofs[i].id, id->valuestring, WALLET_KEYSET_ID_LEN - 1); + if (secret && cJSON_IsString(secret)) + strncpy(w->proofs[i].secret, secret->valuestring, WALLET_SECRET_LEN - 1); + if (c && cJSON_IsString(c)) + strncpy(w->proofs[i].c, c->valuestring, WALLET_SIG_LEN - 1); + w->proof_count++; + } + } + + cJSON *keysets = cJSON_GetObjectItemCaseSensitive(root, "keysets"); + if (keysets && cJSON_IsArray(keysets)) { + int count = cJSON_GetArraySize(keysets); + if (count > WALLET_MAX_KEYSETS) count = WALLET_MAX_KEYSETS; + for (int i = 0; i < count; i++) { + cJSON *ks = cJSON_GetArrayItem(keysets, i); + cJSON *id = cJSON_GetObjectItemCaseSensitive(ks, "id"); + if (id && cJSON_IsString(id)) + strncpy(w->keysets[i].id, id->valuestring, WALLET_KEYSET_ID_LEN - 1); + w->keyset_count++; + } + } + + cJSON_Delete(root); + ESP_LOGI(TAG, "Wallet loaded: %d proofs, %d keysets, balance=%llu", + w->proof_count, w->keyset_count, (unsigned long long)w->balance); + return ESP_OK; +} 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 @@ +#ifndef WALLET_PERSIST_H +#define WALLET_PERSIST_H + +#include "esp_err.h" + +esp_err_t wallet_persist_save(void); +esp_err_t wallet_persist_load(void); + +#endif diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 5a80e87..f13a2e9 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -33,3 +33,11 @@ CONFIG_PARTITION_TABLE_FILENAME="partitions.csv" # mbedTLS (needed for HTTPS to mint) CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y + +# PSRAM (ESP32-S3 has 8MB) +CONFIG_SPIRAM=y +CONFIG_SPIRAM_MODE_OCT=y +CONFIG_SPIRAM_SPEED_80M=y +CONFIG_SPIRAM_USE_MALLOC=y +CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=16384 +CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=32768 -- cgit v1.2.3