upleb.uk

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

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYour Name <you@example.com>2026-05-16 15:32:55 +0530
committerYour Name <you@example.com>2026-05-16 15:32:55 +0530
commit133e40c82afb4d7659758b1fa57925ac57af4621 (patch)
tree130f43e668f42f8fcc8ef55808ba89b6db40f615
parent8f0aeb7d7b8216f1fc906cf855e5be9e90ecc0a8 (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)
-rw-r--r--CHECKLIST.md84
-rw-r--r--PLAN.md96
-rw-r--r--main/CMakeLists.txt2
-rw-r--r--main/cashu.h2
-rw-r--r--main/config.c4
-rw-r--r--main/config.h1
-rw-r--r--main/tollgate_api.c134
-rw-r--r--main/tollgate_main.c11
-rw-r--r--main/wallet.c639
-rw-r--r--main/wallet.h53
-rw-r--r--main/wallet_persist.c147
-rw-r--r--main/wallet_persist.h9
-rw-r--r--sdkconfig.defaults8
13 files changed, 1147 insertions, 43 deletions
diff --git a/CHECKLIST.md b/CHECKLIST.md
index d5711b4..3b50c2a 100644
--- a/CHECKLIST.md
+++ b/CHECKLIST.md
@@ -21,7 +21,7 @@
21- [x] Fix ping tests (use `-I wlp59s0`) 21- [x] Fix ping tests (use `-I wlp59s0`)
22- [x] Tests 1-14: ALL PASSING 22- [x] Tests 1-14: ALL PASSING
23 23
24## Phase 2: E-Cash Payments — IN PROGRESS (commit `3f46bb8` + uncommitted fixes) 24## Phase 2: E-Cash Payments — COMPLETE
25### Code Written 25### Code Written
26- [x] Implement cashu.c/h (Cashu token parse, base64url, checkstate, mint validation) 26- [x] Implement cashu.c/h (Cashu token parse, base64url, checkstate, mint validation)
27- [x] Implement session.c/h (time-based allotment, expiry, secret tracking, MAC tracking) 27- [x] Implement session.c/h (time-based allotment, expiry, secret tracking, MAC tracking)
@@ -45,10 +45,8 @@
45 45
46### Infrastructure 46### Infrastructure
47- [x] Upstream gateway on enx00e04c633a90 (192.168.2.0/24, metric 101, default route) 47- [x] Upstream gateway on enx00e04c633a90 (192.168.2.0/24, metric 101, default route)
48- [x] OpenWRT TollGate on enx00e04c683d2d (10.47.41.0/24, metric 20100, never-default)
49- [x] WiFi wlp59s0 free for ESP32 TollGate connection 48- [x] WiFi wlp59s0 free for ESP32 TollGate connection
50- [x] NetworkManager profile "TollGate-ESP32" created (manual 192.168.4.2/24, autoconnect=no) 49- [x] Mint URL verified: `testnut.cashu.space` works (auto-pays invoices)
51- [x] Mint URL verified: `testnut.cashu.space` works; `nofee.testnut.cashu.space` and `nofees.testnut.cashu.space` both broken
52 50
53### Tests Passing 51### Tests Passing
54- [x] Test 15: Advertisement valid (kind=10021 with price_per_step) — PASSING 52- [x] Test 15: Advertisement valid (kind=10021 with price_per_step) — PASSING
@@ -63,11 +61,6 @@
63- [x] Test: /whoami returns ip=X.X.X.X mac=XX:XX:XX:XX:XX:XX — PASSING 61- [x] Test: /whoami returns ip=X.X.X.X mac=XX:XX:XX:XX:XX:XX — PASSING
64- [x] Test: Portal has payment form (Cashu token input + Pay button) — PASSING 62- [x] Test: Portal has payment form (Cashu token input + Pay button) — PASSING
65 63
66### Tests Not Yet Run (deferred to Phase 3 — will use Board B as second client)
67- [ ] Test 25: Two clients pay independently (laptop + Board B)
68- [ ] Test 26: Client isolation (only payer gets internet)
69- [ ] Test 27: Full e2e: portal → pay → browse
70
71### Captive Portal Detection Fix 64### Captive Portal Detection Fix
72- [x] Added DoT reject server on port 853 (TCP RST forces DNS fallback to port 53) 65- [x] Added DoT reject server on port 853 (TCP RST forces DNS fallback to port 53)
73- [x] DNS hijack now returns NXDOMAIN for ALL non-A query types (prevents DNS leaks) 66- [x] DNS hijack now returns NXDOMAIN for ALL non-A query types (prevents DNS leaks)
@@ -75,16 +68,73 @@
75- [x] Explicit 302 redirect handlers for all captive detection URIs (/generate_204, /hotspot-detect.html, etc.) 68- [x] Explicit 302 redirect handlers for all captive detection URIs (/generate_204, /hotspot-detect.html, etc.)
76- [x] HTTP request logging for captive detection endpoints 69- [x] HTTP request logging for captive detection endpoints
77- [x] DNS query logging for unauthenticated clients 70- [x] DNS query logging for unauthenticated clients
78- [ ] **Needs verification with actual GrapheneOS phone** 71- [x] Verified working with GrapheneOS phone (commit `236b61d`)
79 72
80## Phase 3: nucula Wallet + ESP32-to-ESP32 Payments — NOT STARTED 73## Phase 3: On-Device Wallet + ESP32-to-ESP32 Payments — IN PROGRESS
81- [ ] Extract nucula wallet into components/cashu_wallet/ 74### Wallet Module (wallet.c/h)
82- [ ] Replace simple melt with Wallet::receive() 75- [x] `hash_to_curve()` — SHA256 try-and-increment with Cashu domain separator
83- [ ] Implement payout.c/h (background melt-to-LN) 76- [x] `point_add()`, `scalar_mul()` — mbedTLS secp256k1 primitives
84- [ ] Implement upstream_client.c/h (reseller mode) 77- [x] `random_scalar()` — ESP32 hardware RNG mod curve order
85- [ ] ESP32-to-ESP32 payments (ESP32 generates/proves tokens to pay another ESP32 TollGate) 78- [x] Proof storage: `wallet_add_proofs()`, `wallet_remove_proof()`, `wallet_clear()`
86- [ ] Tests 28-38 79- [x] Keyset fetching: `wallet_fetch_keysets()` — GET /v1/keys from mint
80- [x] Full swap: `wallet_swap_proofs()` — generates blinded messages, POST /v1/swap, unblinds signatures
81- [x] Token creation: `wallet_create_token()` — encode proofs as `cashuA` token
82- [x] Wallet API endpoints: `GET /wallet`, `POST /wallet/swap`, `POST /wallet/send`
83- [x] Payment flow integration: received proofs added to wallet after session creation
84- [x] mbedTLS 3.x compatibility (no direct point field access, no point_negate)
85- [x] Unblinding: `C = C_ + (order - r) * G` approach
86- [x] Clean build (0 warnings, 0 errors)
87
88### Wallet Persistence (wallet_persist.c/h)
89- [ ] Implement `wallet_persist_save()` — serialize wallet to `/spiffs/wallet.json`
90- [ ] Implement `wallet_persist_load()` — deserialize wallet from `/spiffs/wallet.json` on boot
91- [ ] Add `persist_threshold_sats` to config.json and config struct
92- [ ] Threshold logic: only persist when `balance >= persist_threshold_sats`
93- [ ] Wire `wallet_persist_save()` into wallet mutations (add_proofs, swap, create_token)
94- [ ] Wire `wallet_persist_load()` into `wallet_init()`
95- [ ] Build and verify clean compile
96
97### Hardware Testing
98- [ ] Flash Board A, verify wallet boot (keyset fetch succeeds)
99- [ ] Pay Board A with Cashu token, verify proofs stored (GET /wallet)
100- [ ] Test POST /wallet/swap on Board A
101- [ ] Test POST /wallet/send on Board A, verify token is valid
102- [ ] Verify persistence survives reboot on Board A
103- [ ] Flash Board B with TollGate firmware
104- [ ] Load Board B with balance (pay it a token)
105- [ ] Board B creates send token via POST /wallet/send
106- [ ] Cross-board payment: Board B token → Board A (laptop relay)
107- [ ] Verify both boards show correct balances after cross-board payment
108
109### Tests 25-27 (deferred from Phase 2, need Board B)
110- [ ] Test 25: Two clients pay independently (laptop + Board B)
111- [ ] Test 26: Client isolation (only payer gets internet)
112- [ ] Test 27: Full e2e: portal → pay → browse
113
114### Tests 28-38 (Phase 3 specific)
115- [ ] Test 28: Wallet boot (keysets loaded)
116- [ ] Test 29: Receive via wallet (balance incremented)
117- [ ] Test 30: Wallet swap (same balance, new proofs)
118- [ ] Test 31: Wallet send (valid cashuA token)
119- [ ] Test 32: Persistence survives reboot
120- [ ] Test 33: Cross-board payment
121- [ ] Test 34: 5 consecutive payments
122- [ ] Test 35: Stress: rapid pay/expire
123
124### Automated Tests
125- [ ] Write tests/phase3.mjs (wallet endpoint tests + cross-board)
126- [ ] All Phase 3 tests passing
87 127
88## Phase 4: ESP32-to-OpenWRT TollGate Interop — NOT STARTED 128## Phase 4: ESP32-to-OpenWRT TollGate Interop — NOT STARTED
89- [ ] ESP32 pays OpenWRT TollGate using Cashu tokens 129- [ ] ESP32 pays OpenWRT TollGate using Cashu tokens
90- [ ] Interoperability testing with existing OpenWRT TollGate on enx00e04c683d2d 130- [ ] Interoperability testing with existing OpenWRT TollGate on enx00e04c683d2d
131
132## Reminders
133- Do NOT ask for instructions — proceed independently, skip blocked items, work on unblocked ones
134- Board A: `/dev/ttyACM0`, MAC `94:a9:90:2e:37:7c`, SSID `TollGate-377C`, AP IP `10.55.85.1`
135- Board B: `/dev/ttyACM1`, MAC `fc:01:2c:c5:50:50`
136- testnut.cashu.space auto-pays invoices: `cashu -h https://testnut.cashu.space invoice <amount>`
137- Token generation: `cashu -h https://testnut.cashu.space send --legacy <amount> 2>&1 | grep '^cashuA' | head -1`
138- sudo password: `c03rad0r123`
139- Commit + push whenever tests pass
140- 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 @@
2 2
3## Overview 3## Overview
4 4
5Build 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. 5Build 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.
6 6
7## Architecture Decision: C/C++ (ESP-IDF) 7## Architecture Decision: C/C++ (ESP-IDF)
8 8
9- Existing working captive portal is in C (ESP-IDF) 9- Existing working captive portal is in C (ESP-IDF)
10- Nucula Cashu wallet is in C/C++ (ESP-IDF) 10- On-device Cashu wallet uses mbedTLS secp256k1 (hardware RNG, software ECP)
11- ESP-IDF is already installed at `~/esp/esp-idf` 11- ESP-IDF is already installed at `~/esp/esp-idf`
12- No Rust/ESP32 toolchain installed 12- No Rust/ESP32 toolchain installed
13 13
@@ -16,11 +16,12 @@ Build a TollGate firmware for two ESP32 devices, following the [TollGate protoco
16| Layer | Technology | 16| Layer | Technology |
17|-------|-----------| 17|-------|-----------|
18| Framework | ESP-IDF v5.4.1 (C/C++) | 18| Framework | ESP-IDF v5.4.1 (C/C++) |
19| Cashu wallet | nucula `Wallet` class (Phase 3) | 19| Cashu wallet | Custom mbedTLS secp256k1 wallet (hash_to_curve, blind signing, swap, send) |
20| HTTP server | `esp_http_server` (port 80 captive portal, port 2121 TollGate API) | 20| HTTP server | `esp_http_server` (port 80 captive portal, port 2121 TollGate API + wallet) |
21| DNS | Custom UDP task (hijack unauthenticated, forward authenticated) | 21| DNS | Custom UDP task (hijack unauthenticated, forward authenticated) |
22| NAT | lwIP NAPT | 22| NAT | lwIP NAPT |
23| Testing | Playwright + curl + pyserial | 23| Persistence | SPIFFS (960K partition) with threshold-based write protection |
24| Testing | Playwright + curl + nutshell CLI |
24| Build | Makefile | 25| Build | Makefile |
25 26
26## Four-Phase Plan 27## Four-Phase Plan
@@ -52,7 +53,7 @@ Build a TollGate firmware for two ESP32 devices, following the [TollGate protoco
52| 13 | Reset auth | GET /reset_authentication | 200 | PASS | 53| 13 | Reset auth | GET /reset_authentication | 200 | PASS |
53| 14 | Internet blocked after reset | ping 8.8.8.8 | Fails | PASS | 54| 14 | Internet blocked after reset | ping 8.8.8.8 | Fails | PASS |
54 55
55### Phase 2: E-Cash Payments — IN PROGRESS 56### Phase 2: E-Cash Payments — COMPLETE
56 57
57**Goal:** Replace free access with Cashu payment. ESP32 parses token, checks proof state via mint API, grants time-based session. 58**Goal:** Replace free access with Cashu payment. ESP32 parses token, checks proof state via mint API, grants time-based session.
58 59
@@ -79,29 +80,78 @@ Build a TollGate firmware for two ESP32 devices, following the [TollGate protoco
79| 26 | Client isolation | Only payer gets internet | Non-payer blocked | Phase 3 | 80| 26 | Client isolation | Only payer gets internet | Non-payer blocked | Phase 3 |
80| 27 | Full e2e: portal→pay→browse | Playwright | Complete flow | Phase 3 | 81| 27 | Full e2e: portal→pay→browse | Playwright | Complete flow | Phase 3 |
81 82
82**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. 83**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`).
83 84
84### Phase 3: nucula Wallet + ESP32-to-ESP32 Payments — NOT STARTED 85### Phase 3: On-Device Wallet + ESP32-to-ESP32 Payments — IN PROGRESS
85 86
86**Goal:** Integrate nucula's full Cashu wallet. ESP32 holds balance, can be a reseller. ESP32-to-ESP32 direct payments. 87**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.
87 88
88**11 Additional Test Cases:** 89#### Wallet Architecture
89| # | Test | Method | Pass Criteria | 90
90|---|------|--------|---------------| 91- **Crypto**: mbedTLS secp256k1 (software ECP, hardware RNG via `esp_fill_random`)
91| 28 | Wallet boot | Serial | Keysets loaded | 92- **Blind signing**: `hash_to_curve()` (SHA256 try-and-increment), `scalar_mul()`, `point_add()`
92| 29 | Receive via wallet | POST :2121/ | Balance incremented | 93- **Unblinding**: `C = C_ + (order - r) * G` — avoids needing mint's public key K, avoids point negation
93| 30 | Balance persists | Reboot | Same balance | 94- **Proof storage**: In-memory array (50 max), persisted to SPIFFS JSON
94| 31 | Payout routine | Wait + serial | Tokens melted to LN | 95- **Persistence**: SPIFFS `/spiffs/wallet.json`, only written when `balance >= persist_threshold_sats`
95| 32 | Reseller discover | Serial | Upstream TollGate found | 96- **Keyset fetch**: GET /v1/keys from mint on boot
96| 33 | Reseller pay | Serial + API | Token POSTed upstream | 97- **Swap**: POST /v1/swap — reissues proofs with new secrets
97| 34 | Multi-hop internet | Ping from laptop | laptop→A→B→internet | 98- **Token creation**: Encode proofs as `cashuA` base64url token
98| 35 | P2PK receive | Post P2PK token | Auto-signed, accepted | 99
99| 36 | DLEQ verified | Post token with DLEQ | Verified, accepted | 100#### Wallet Endpoints (on :2121)
100| 37 | 5 consecutive payments | Loop | All authenticated | 101
101| 38 | Stress: rapid pay/expire | Loop with short sessions | No crash/leak | 102| Method | Path | Description |
103|--------|------|-------------|
104| GET | /wallet | Balance, proof count, keyset count |
105| POST | /wallet/swap | Swap all proofs for fresh ones via mint |
106| POST | /wallet/send | Create cashuA token for given amount (body = sat count) |
107
108#### Payment Integration
109
110Received payment proofs are automatically added to wallet after session creation in `tollgate_api.c`.
111
112#### Persistence Threshold
113
114Config parameter `persist_threshold_sats` (default: 1) controls when wallet state is written to flash:
115- `balance >= persist_threshold_sats` → write wallet.json
116- `balance < threshold` → skip write (or delete existing file)
117- Rationale: flash has finite write cycles (~100K erase per sector); only persist when e-cash value justifies the wear cost
118- SPIFFS wear-leveling spreads writes across the 960K partition
119
120#### Test Cases
121
122| # | Test | Method | Pass Criteria | Status |
123|---|------|--------|---------------|--------|
124| 28 | Wallet boot | Serial | Keysets loaded | TODO |
125| 29 | Receive via wallet | POST :2121/ | Balance incremented | TODO |
126| 30 | Wallet swap | POST /wallet/swap | Same balance, new proofs | TODO |
127| 31 | Wallet send | POST /wallet/send | Valid cashuA token returned | TODO |
128| 32 | Persistence survives reboot | Reboot + GET /wallet | Same balance | TODO |
129| 33 | Cross-board payment | B sends → A receives | A balance increases | TODO |
130| 34 | Two clients pay independently | Two POSTs | Both authenticated | TODO |
131| 35 | Client isolation | Only payer gets internet | Non-payer blocked | TODO |
132| 36 | Full e2e: portal→pay→browse | Playwright | Complete flow | TODO |
133| 37 | 5 consecutive payments | Loop | All authenticated | TODO |
134| 38 | Stress: rapid pay/expire | Loop with short sessions | No crash/leak | TODO |
102 135
103### Phase 4: ESP32-to-OpenWRT TollGate Interop — NOT STARTED 136### Phase 4: ESP32-to-OpenWRT TollGate Interop — NOT STARTED
104 137
105**Goal:** ESP32 can pay OpenWRT TollGate using Cashu tokens. Full interoperability with existing OpenWRT-based TollGate infrastructure. 138**Goal:** ESP32 can pay OpenWRT TollGate using Cashu tokens. Full interoperability with existing OpenWRT-based TollGate infrastructure.
106 139
107## Total: 38 Tests across 4 phases 140## Total: 38 Tests across 4 phases
141
142## Key Technical Notes
143
144### mbedTLS 3.x Compatibility
145- `mbedtls_ecp_point` is opaque — cannot access `.X`, `.Y`, `.Z` directly
146- Use `mbedtls_ecp_muladd`, `mbedtls_ecp_mul`, `mbedtls_ecp_point_read/write_binary`
147- No point negation needed with `C = C_ + (order - r) * G` unblinding approach
148
149### Board Configuration
150- Board A: `/dev/ttyACM0`, MAC `94:a9:90:2e:37:7c`, SSID `TollGate-377C`, AP IP `10.55.85.1`
151- Board B: `/dev/ttyACM1`, MAC `fc:01:2c:c5:50:50`, unique SSID/IP derived from MAC
152- Both boards run identical firmware, unique config derived at boot from factory MAC
153
154### Test Mint
155- `testnut.cashu.space` — auto-pays lightning invoices for testing
156- `cashu -h https://testnut.cashu.space invoice <amount>` → auto-paid
157- `cashu -h https://testnut.cashu.space send --legacy <amount>` → 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"
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
13typedef struct { 13typedef 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
382static 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
407static 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
450static 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
366static const httpd_uri_t uri_discovery = { .uri = "/", .method = HTTP_GET, .handler = api_get_discovery }; 490static const httpd_uri_t uri_discovery = { .uri = "/", .method = HTTP_GET, .handler = api_get_discovery };
367static const httpd_uri_t uri_payment = { .uri = "/", .method = HTTP_POST, .handler = api_post_payment }; 491static const httpd_uri_t uri_payment = { .uri = "/", .method = HTTP_POST, .handler = api_post_payment };
368static const httpd_uri_t uri_usage = { .uri = "/usage", .method = HTTP_GET, .handler = api_get_usage }; 492static const httpd_uri_t uri_usage = { .uri = "/usage", .method = HTTP_GET, .handler = api_get_usage };
369static const httpd_uri_t uri_whoami = { .uri = "/whoami", .method = HTTP_GET, .handler = api_get_whoami }; 493static const httpd_uri_t uri_whoami = { .uri = "/whoami", .method = HTTP_GET, .handler = api_get_whoami };
494static const httpd_uri_t uri_wallet = { .uri = "/wallet", .method = HTTP_GET, .handler = api_get_wallet };
495static const httpd_uri_t uri_wallet_swap = { .uri = "/wallet/swap", .method = HTTP_POST, .handler = api_post_wallet_swap };
496static const httpd_uri_t uri_wallet_send = { .uri = "/wallet/send", .method = HTTP_POST, .handler = api_post_wallet_send };
370 497
371esp_err_t tollgate_api_start(void) 498esp_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
21static const char *TAG = "tollgate_main"; 22static 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
92static 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
91static void start_services(void) 100static 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
20static const char *TAG = "wallet";
21static wallet_t s_wallet;
22
23static const char DOMAIN_SEPARATOR[] = "Secp256k1_HashToCurve_Cashu_";
24
25static mbedtls_ecp_group s_grp;
26static mbedtls_mpi s_order;
27static bool s_grp_loaded = false;
28
29static 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
44static void random_bytes(uint8_t *buf, size_t len)
45{
46 esp_fill_random(buf, len);
47}
48
49static 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
64static 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
112static 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
126static 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
136static 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
148static 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
156esp_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
166wallet_t *wallet_get(void)
167{
168 return &s_wallet;
169}
170
171uint64_t wallet_balance(void)
172{
173 return s_wallet.balance;
174}
175
176esp_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
194esp_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
207void wallet_clear(void)
208{
209 s_wallet.balance = 0;
210 s_wallet.proof_count = 0;
211 wallet_persist_save();
212}
213
214esp_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
283esp_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
551esp_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
622esp_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
628void 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
14typedef 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
21typedef 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
28typedef 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
36esp_err_t wallet_init(void);
37wallet_t *wallet_get(void);
38uint64_t wallet_balance(void);
39
40esp_err_t wallet_add_proofs(const wallet_proof_t *proofs, int count);
41esp_err_t wallet_remove_proof(int index);
42void wallet_clear(void);
43
44esp_err_t wallet_fetch_keysets(const char *mint_url);
45esp_err_t wallet_swap_proofs(const char *mint_url, int start_index, int count);
46
47esp_err_t wallet_create_token(char *out, size_t out_size, uint64_t amount,
48 const char *mint_url);
49esp_err_t wallet_send(const char *mint_url, uint64_t amount,
50 char *token_out, size_t token_out_size);
51
52void 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
10static const char *TAG = "wallet_persist";
11static const char *WALLET_FILE = "/spiffs/wallet.json";
12
13esp_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
67esp_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
6esp_err_t wallet_persist_save(void);
7esp_err_t wallet_persist_load(void);
8
9#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"
33 33
34# mbedTLS (needed for HTTPS to mint) 34# mbedTLS (needed for HTTPS to mint)
35CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y 35CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
36
37# PSRAM (ESP32-S3 has 8MB)
38CONFIG_SPIRAM=y
39CONFIG_SPIRAM_MODE_OCT=y
40CONFIG_SPIRAM_SPEED_80M=y
41CONFIG_SPIRAM_USE_MALLOC=y
42CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=16384
43CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=32768