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