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 23:55:05 +0530
committerYour Name <you@example.com>2026-05-16 23:55:05 +0530
commit4c47ae188b288e7d24bd9566ab3e6a6805d9484f (patch)
tree33b74b2090b4f3b7597841734a56a4006a86d73f
parent133e40c82afb4d7659758b1fa57925ac57af4621 (diff)
Phase 3: Nostr identity derivation + wifistr service discovery
- Add identity.c/h: HMAC-SHA512 derivation from nsec → npub, STA/AP MAC, SSID, AP IP - Add nostr_event.c/h: NIP-01 event serialization + Schnorr signing (BIP-340) - Add geohash.c/h: lat/lon to geohash encoding - Add wifistr.c/h: kind 38787 event builder + WebSocket publish to Nostr relays - Update config.c/h: nsec-based identity, Nostr relay/geo config, remove static SSID/IP - Replace custom mbedTLS wallet with nucula library (libsecp256k1) - Remove wallet.c/h, wallet_persist.c/h (replaced by nucula_lib component) - Verified on Board A: derived SSID, captive portal, payment, wallet, wifistr publish
-rw-r--r--.gitmodules3
-rw-r--r--CHECKLIST.md98
-rw-r--r--PLAN.md311
-rw-r--r--components/nucula_lib/CMakeLists.txt17
-rw-r--r--components/nucula_lib/nucula_wallet.cpp199
-rw-r--r--components/nucula_lib/nucula_wallet.h31
l---------components/secp256k11
-rw-r--r--main/CMakeLists.txt9
-rw-r--r--main/config.c88
-rw-r--r--main/config.h14
-rw-r--r--main/geohash.c48
-rw-r--r--main/geohash.h8
-rw-r--r--main/identity.c124
-rw-r--r--main/identity.h29
-rw-r--r--main/nostr_event.c112
-rw-r--r--main/nostr_event.h25
-rw-r--r--main/tollgate_api.c63
-rw-r--r--main/tollgate_main.c31
-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--main/wifistr.c252
-rw-r--r--main/wifistr.h10
m---------nucula_src0
-rw-r--r--sdkconfig45
26 files changed, 1376 insertions, 990 deletions
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..e4b0dbf
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
1[submodule "nucula_src"]
2 path = nucula_src
3 url = https://github.com/zeugmaster/nucula.git
diff --git a/CHECKLIST.md b/CHECKLIST.md
index 3b50c2a..02c8a4c 100644
--- a/CHECKLIST.md
+++ b/CHECKLIST.md
@@ -70,40 +70,69 @@
70- [x] DNS query logging for unauthenticated clients 70- [x] DNS query logging for unauthenticated clients
71- [x] Verified working with GrapheneOS phone (commit `236b61d`) 71- [x] Verified working with GrapheneOS phone (commit `236b61d`)
72 72
73## Phase 3: On-Device Wallet + ESP32-to-ESP32 Payments — IN PROGRESS 73## Phase 3: On-Device Wallet + Nostr Identity + Wifistr — IN PROGRESS
74### Wallet Module (wallet.c/h) 74### nucula Wallet Integration
75- [x] `hash_to_curve()` — SHA256 try-and-increment with Cashu domain separator 75- [x] Add nucula as git submodule (`nucula_src/`)
76- [x] `point_add()`, `scalar_mul()` — mbedTLS secp256k1 primitives 76- [x] Create `components/secp256k1/` (symlink to nucula's libsecp256k1)
77- [x] `random_scalar()` — ESP32 hardware RNG mod curve order 77- [x] Create `components/nucula_lib/` (C++ bridge + C API)
78- [x] Proof storage: `wallet_add_proofs()`, `wallet_remove_proof()`, `wallet_clear()` 78- [x] C bridge: `nucula_wallet.h` (init, receive, send, swap_all, balance, proofs_json)
79- [x] Keyset fetching: `wallet_fetch_keysets()` — GET /v1/keys from mint 79- [x] All wallet operations tested on Board A: pay, swap, send, persistence
80- [x] Full swap: `wallet_swap_proofs()` — generates blinded messages, POST /v1/swap, unblinds signatures 80
81- [x] Token creation: `wallet_create_token()` — encode proofs as `cashuA` token 81### Nostr Identity Derivation (identity.c/h)
82- [x] Wallet API endpoints: `GET /wallet`, `POST /wallet/swap`, `POST /wallet/send` 82- [x] Create `identity.h` — API: `identity_init(nsec_hex)`, derived value accessors
83- [x] Payment flow integration: received proofs added to wallet after session creation 83- [x] Create `identity.c` — HMAC-SHA512 derivation via mbedtls, npub via secp256k1
84- [x] mbedTLS 3.x compatibility (no direct point field access, no point_negate) 84- [x] Derive STA MAC: `tollgate_derive(nsec, "sta-mac", 0)` → 6 bytes, locally administered
85- [x] Unblinding: `C = C_ + (order - r) * G` approach 85- [x] Derive AP MAC: `tollgate_derive(nsec, "ap-mac", 0)` → 6 bytes, locally administered
86- [x] Clean build (0 warnings, 0 errors) 86- [x] Derive SSID: `"TollGate-" + hex(AP_MAC[3:6])`
87 87- [x] Derive AP IP: hash-based from AP MAC bytes
88### Wallet Persistence (wallet_persist.c/h) 88- [x] Compute npub: secp256k1 x-only pubkey from nsec
89- [ ] Implement `wallet_persist_save()` — serialize wallet to `/spiffs/wallet.json` 89- [x] Set MACs via `esp_wifi_set_mac()` in boot sequence
90- [ ] Implement `wallet_persist_load()` — deserialize wallet from `/spiffs/wallet.json` on boot 90
91- [ ] Add `persist_threshold_sats` to config.json and config struct 91### Nostr Event Signing (nostr_event.c/h)
92- [ ] Threshold logic: only persist when `balance >= persist_threshold_sats` 92- [x] Create `nostr_event.h` — NIP-01 event struct + sign/serialize API
93- [ ] Wire `wallet_persist_save()` into wallet mutations (add_proofs, swap, create_token) 93- [x] Create `nostr_event.c` — canonical JSON, SHA-256 ID, Schnorr signature
94- [ ] Wire `wallet_persist_load()` into `wallet_init()` 94- [x] Uses `secp256k1_schnorrsig_sign32()` for BIP-340 signatures
95- [ ] Build and verify clean compile 95
96### Geohash Encoding (geohash.c/h)
97- [x] Create `geohash.h` — `geohash_encode(lat, lon, precision, out)`
98- [x] Create `geohash.c` — standard base-32 geohash encoding
99
100### Wifistr Service Discovery (wifistr.c/h)
101- [x] Create `wifistr.h` — `wifistr_publish()` API
102- [x] Create `wifistr.c` — kind 38787 event builder + WebSocket relay publish
103- [x] Build event with tags: d, ssid, h, security, g, c
104- [x] WebSocket client: raw TCP + TLS (esp_tls.h) + HTTP Upgrade
105- [x] Publish on boot + periodic timer (6h default)
106
107### Config Changes (config.c/h)
108- [x] Add to struct: nsec, npub, nostr_geohash, nostr_relays, nostr_publish_interval_s, sta_mac, ap_mac
109- [x] Remove from JSON parsing: ap_ssid, ap_ip (now derived from nsec)
110- [x] Keep: ap_password, ap_channel, ap_max_conn (hardcoded defaults)
111- [x] Update default config.json template with nsec and Nostr fields
112
113### Boot Sequence Changes (tollgate_main.c)
114- [x] Call `identity_init(nsec)` after config load, before WiFi init
115- [x] Set STA/AP MAC via `esp_wifi_set_mac()` after `esp_wifi_init()`, before `esp_wifi_start()`
116- [x] Remove old `tollgate_config_derive_unique()` call
117- [x] Use derived SSID/IP in AP configuration
118- [x] Start wifistr publish task after services start
119
120### Build System
121- [x] Add identity.c, nostr_event.c, geohash.c, wifistr.c to CMakeLists.txt SRCS
122- [x] Add `secp256k1` to REQUIRES (for identity.c and nostr_event.c)
123- [x] Clean build (0 errors, 0 warnings)
96 124
97### Hardware Testing 125### Hardware Testing
98- [ ] Flash Board A, verify wallet boot (keyset fetch succeeds) 126- [x] Flash Board A, verify wallet boot (keyset fetch succeeds)
99- [ ] Pay Board A with Cashu token, verify proofs stored (GET /wallet) 127- [x] Pay Board A with Cashu token, verify proofs stored (GET /wallet)
100- [ ] Test POST /wallet/swap on Board A 128- [x] Test POST /wallet/swap on Board A
101- [ ] Test POST /wallet/send on Board A, verify token is valid 129- [x] Test POST /wallet/send on Board A, verify token is valid
102- [ ] Verify persistence survives reboot on Board A 130- [x] Flash Board A with new identity derivation, verify derived SSID/MAC/IP
103- [ ] Flash Board B with TollGate firmware 131- [x] Verify captive portal works with new SSID/IP
104- [ ] Load Board B with balance (pay it a token) 132- [x] Verify payment flow still works with identity-derived config
105- [ ] Board B creates send token via POST /wallet/send 133- [x] Verify wifistr event published to relay (damus + nos.lol)
106- [ ] Cross-board payment: Board B token → Board A (laptop relay) 134- [ ] Flash Board B with new firmware (different nsec)
135- [ ] Cross-board payment: Board B token → Board A
107- [ ] Verify both boards show correct balances after cross-board payment 136- [ ] Verify both boards show correct balances after cross-board payment
108 137
109### Tests 25-27 (deferred from Phase 2, need Board B) 138### Tests 25-27 (deferred from Phase 2, need Board B)
@@ -131,8 +160,9 @@
131 160
132## Reminders 161## Reminders
133- Do NOT ask for instructions — proceed independently, skip blocked items, work on unblocked ones 162- 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` 163- Board A: `/dev/ttyACM0`, factory MAC `94:a9:90:2e:37:7c`
135- Board B: `/dev/ttyACM1`, MAC `fc:01:2c:c5:50:50` 164- Board B: `/dev/ttyACM1`, factory MAC `fc:01:2c:c5:50:50`
165- Identity is now derived from nsec in config.json (SSID, IP, MAC all deterministic)
136- testnut.cashu.space auto-pays invoices: `cashu -h https://testnut.cashu.space invoice <amount>` 166- 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` 167- Token generation: `cashu -h https://testnut.cashu.space send --legacy <amount> 2>&1 | grep '^cashuA' | head -1`
138- sudo password: `c03rad0r123` 168- sudo password: `c03rad0r123`
diff --git a/PLAN.md b/PLAN.md
index 2af8a39..0fcecac 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -7,20 +7,23 @@ Build a TollGate firmware for two ESP32 devices, following the [TollGate protoco
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- On-device Cashu wallet uses mbedTLS secp256k1 (hardware RNG, software ECP) 10- On-device Cashu wallet uses nucula library (libsecp256k1)
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- Nostr keypair as root identity — derive AP MAC, SSID, IP from nsec
13 14
14## Technology Stack 15## Technology Stack
15 16
16| Layer | Technology | 17| Layer | Technology |
17|-------|-----------| 18|-------|-----------|
18| Framework | ESP-IDF v5.4.1 (C/C++) | 19| Framework | ESP-IDF v5.4.1 (C/C++) |
19| Cashu wallet | Custom mbedTLS secp256k1 wallet (hash_to_curve, blind signing, swap, send) | 20| Identity | Nostr nsec → HMAC-SHA512 derivation → MAC/SSID/IP; Schnorr signing for Nostr events |
21| Cashu wallet | nucula library (libsecp256k1, NVS persistence) |
22| Service discovery | wifistr (Nostr kind 38787) via WebSocket to relays |
20| HTTP server | `esp_http_server` (port 80 captive portal, port 2121 TollGate API + wallet) | 23| HTTP server | `esp_http_server` (port 80 captive portal, port 2121 TollGate API + wallet) |
21| DNS | Custom UDP task (hijack unauthenticated, forward authenticated) | 24| DNS | Custom UDP task (hijack unauthenticated, forward authenticated) |
22| NAT | lwIP NAPT | 25| NAT | lwIP NAPT |
23| Persistence | SPIFFS (960K partition) with threshold-based write protection | 26| Persistence | NVS (nucula built-in) for wallet; SPIFFS for config.json |
24| Testing | Playwright + curl + nutshell CLI | 27| Testing | Playwright + curl + nutshell CLI |
25| Build | Makefile | 28| Build | Makefile |
26 29
@@ -82,20 +85,52 @@ Build a TollGate firmware for two ESP32 devices, following the [TollGate protoco
82 85
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`). 86**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`).
84 87
85### Phase 3: On-Device Wallet + ESP32-to-ESP32 Payments — IN PROGRESS 88### Phase 3: On-Device Wallet + Nostr Identity + Wifistr — IN PROGRESS
86 89
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. 90**Goal:** On-device Cashu wallet using [nucula](https://github.com/zeugmaster/nucula) library (libsecp256k1). Nostr keypair as root identity — derive AP MAC, SSID, IP deterministically. Publish service via wifistr (Nostr kind 38787).
88 91
89#### Wallet Architecture 92#### Wallet Architecture — nucula Integration
90 93
91- **Crypto**: mbedTLS secp256k1 (software ECP, hardware RNG via `esp_fill_random`) 94**Decision: Use nucula as a git submodule instead of custom mbedTLS wallet.**
92- **Blind signing**: `hash_to_curve()` (SHA256 try-and-increment), `scalar_mul()`, `point_add()` 95
93- **Unblinding**: `C = C_ + (order - r) * G` — avoids needing mint's public key K, avoids point negation 96Why nucula over our custom mbedTLS wallet:
94- **Proof storage**: In-memory array (50 max), persisted to SPIFFS JSON 97- **libsecp256k1** vs mbedTLS ECP: purpose-built C library with precomputed tables, ~10x less stack usage, no stack overflow
95- **Persistence**: SPIFFS `/spiffs/wallet.json`, only written when `balance >= persist_threshold_sats` 98- **Production-quality**: NUT-00 through NUT-13, DLEQ verification, P2PK, deterministic secrets (BIP-39)
96- **Keyset fetch**: GET /v1/keys from mint on boot 99- **No maintenance burden**: upstream at `zeugmaster/nucula`, pull updates via `git submodule update`
97- **Swap**: POST /v1/swap — reissues proofs with new secrets 100- **NVS persistence**: more reliable than SPIFFS, no wear-leveling concerns
98- **Token creation**: Encode proofs as `cashuA` base64url token 101
102Integration structure:
103```
104esp32-tollgate/
105├── components/
106│ ├── nucula_src/ # git submodule → zeugmaster/nucula
107│ ├── secp256k1/ # copied from nucula_src/components/secp256k1/
108│ └── nucula_lib/ # wrapper component
109│ ├── CMakeLists.txt # compiles nucula sources from ../nucula_src/main/
110│ ├── nucula_wallet.h # C API for TollGate
111│ └── nucula_wallet.cpp # C++ bridge → nucula::Wallet
112├── main/
113│ ├── wallet.c # REMOVED
114│ ├── wallet_persist.c # REMOVED
115│ ├── cashu.c # simplified (token decode delegates to nucula)
116│ ├── tollgate_api.c # updated to use nucula_wallet.h
117```
118
119Files compiled from nucula (via `../nucula_src/main/`):
120- `crypto.c` — hash_to_curve, blind_message, unblind, DLEQ verification
121- `wallet.cpp` — full Cashu wallet (swap, receive, send, mint, melt)
122- `cashu_json.cpp` — JSON serialization (cJSON-based)
123- `nut10.cpp` — NUT-10 structured secret parsing
124- `hex.c` — hex encode/decode
125- `http.c` — HTTP client wrapper (uses esp_http_client)
126
127NOT compiled (TollGate doesn't need them):
128- `nucula.cpp` — nucula's own app_main
129- `cashu_cbor.cpp` — CBOR/V4 token support (we only use V3/cashuA)
130- `console.cpp`, `display.cpp`, `nfc.cpp`, `ndef.cpp`, `keypad.c` — hardware UI
131- `bip39.c` — mnemonic generation (we use random secrets)
132- `wifi.c` — nucula's own WiFi manager
133- `crypto_test.c` — test code
99 134
100#### Wallet Endpoints (on :2121) 135#### Wallet Endpoints (on :2121)
101 136
@@ -117,6 +152,124 @@ Config parameter `persist_threshold_sats` (default: 1) controls when wallet stat
117- Rationale: flash has finite write cycles (~100K erase per sector); only persist when e-cash value justifies the wear cost 152- 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 153- SPIFFS wear-leveling spreads writes across the 960K partition
119 154
155#### C API Bridge (`nucula_wallet.h`)
156
157The TollGate firmware is C; nucula is C++. A thin C bridge exposes the wallet operations needed:
158
159```c
160// Initialize wallet with secp256k1 context and mint URL
161esp_err_t nucula_wallet_init(const char *mint_url);
162
163// Receive a cashuA token string into wallet (swap + store proofs)
164esp_err_t nucula_wallet_receive(const char *token_str);
165
166// Create a cashuA token for the given amount
167esp_err_t nucula_wallet_send(uint64_t amount_sat, char *token_out, size_t token_out_size);
168
169// Get current balance in sats
170uint64_t nucula_wallet_balance(void);
171
172// Get proof count
173int nucula_wallet_proof_count(void);
174
175// Get JSON array of proofs (for /wallet endpoint)
176char *nucula_wallet_proofs_json(void);
177
178// Swap all proofs for fresh ones
179esp_err_t nucula_wallet_swap_all(void);
180
181// Print wallet status to log
182void nucula_wallet_print_status(void);
183```
184
185#### Persistence
186
187nucula uses NVS (Non-Volatile Storage) for persistence — proofs stored as JSON blobs in flash, keysets stored individually. This is more reliable than SPIFFS:
188- No filesystem overhead
189- Atomic writes via NVS key-value API
190- Wear leveling handled by NVS internally
191- No `persist_threshold_sats` needed — NVS handles flash wear automatically
192
193#### Nostr Identity Derivation
194
195**Root of trust:** A Nostr private key (`nsec`, 32 bytes hex) stored in `config.json`. All device identifiers are deterministically derived from this single key. Rotating nsec rotates the entire identity (MAC, SSID, IP, Nostr pubkey).
196
197**Derivation function: `tollgate_derive()`**
198
199Simplified HMAC-SHA512 derivation (not full BIP85 — ~50 lines, same security model):
200```
201tollgate_derive(nsec_bytes, label, index) → bytes
202 HMAC-SHA512(key=nsec_bytes, msg=label || uint32_le(index))
203 truncate output to needed length
204```
205
206**Derived values:**
207
208| Value | Derivation | Output |
209|-------|-----------|--------|
210| npub | `secp256k1_ec_pubkey_create(nsec)` → x-only pubkey | 32 bytes hex |
211| STA MAC | `tollgate_derive(nsec, "sta-mac", 0)` | 6 bytes, `byte[0] \|= 0x02` |
212| AP MAC | `tollgate_derive(nsec, "ap-mac", 0)` | 6 bytes, `byte[0] \|= 0x02` |
213| SSID | `"TollGate-" + hex(AP_MAC[3:6])` | last 3 bytes = 6 hex chars |
214| AP IP | `10.(AP_MAC[3]).((AP_MAC[4]^AP_MAC[5])%200+10).1` | hash-based from AP MAC |
215
216**Implementation: `identity.c/h`**
217
218- Uses `mbedtls/md.h` for HMAC-SHA512 (already linked)
219- Uses `secp256k1.h` + `secp256k1_extrakeys.h` from the secp256k1 component
220- Creates its own `secp256k1_context` (SIGN only) — destroyed after init
221- `identity_init(nsec_hex)` called before WiFi start in `app_main()`
222- Sets derived MACs via `esp_wifi_set_mac(WIFI_IF_STA/AP, mac)` after `esp_wifi_init()`
223
224**Boot sequence:**
225```
226nvs_flash_init()
227 → tollgate_config_init() // loads config.json with nsec
228 → identity_init(nsec) // derives npub, MACs, SSID, IP
229 → esp_netif_init()
230 → esp_event_loop_create_default()
231 → wifi_init_sta()
232 → wifi_create_ap_netif() // uses derived AP IP
233 → esp_wifi_init(&cfg)
234 → esp_wifi_set_mac(STA/AP) // sets derived MACs
235 → wifi_configure_ap() // uses derived SSID
236 → esp_wifi_start()
237```
238
239**Config.json format (new):**
240```json
241{
242 "nsec": "hex_64_chars",
243 "wifi_networks": [{"ssid":"...", "password":"..."}],
244 "ap_password": "",
245 "mint_url": "https://testnut.cashu.space",
246 "price_per_step": 21,
247 "step_size_ms": 60000,
248 "nostr_geohash": "u281w0dfz",
249 "nostr_relays": ["wss://relay.damus.io", "wss://nos.lol"],
250 "nostr_publish_interval_s": 21600
251}
252```
253
254Removed from config: `ap_ssid`, `ap_ip`, `ap_channel`, `ap_max_conn` (all derived or hardcoded).
255
256#### Nostr Event Signing (`nostr_event.c/h`)
257
258NIP-01 event serialization and Schnorr signing:
259- Canonical JSON: `[0, pubkey, created_at, kind, tags, content]`
260- Event ID: SHA-256 of canonical JSON serialization
261- Signature: `secp256k1_schnorrsig_sign32()` (BIP-340)
262- Uses own `secp256k1_context` (created on demand, destroyed after use)
263
264#### Wifistr Service Discovery (`wifistr.c/h`)
265
266Publishes TollGate node to Nostr as kind 38787 (wifistr):
267- Tags: `["d", npub]`, `["ssid", ssid]`, `["h", "cashu-testnut"]`, `["security", "open"]`, `["g", geohash]`, `["c", "cashu"]`
268- Content: human-readable description with price info
269- Publishes on boot + periodic timer (default 6 hours)
270- WebSocket client for relay communication (raw TCP + TLS + HTTP Upgrade)
271- Uses `esp_tls.h` for TLS connections to `wss://` relays
272
120#### Test Cases 273#### Test Cases
121 274
122| # | Test | Method | Pass Criteria | Status | 275| # | Test | Method | Pass Criteria | Status |
@@ -133,7 +286,99 @@ Config parameter `persist_threshold_sats` (default: 1) controls when wallet stat
133| 37 | 5 consecutive payments | Loop | All authenticated | TODO | 286| 37 | 5 consecutive payments | Loop | All authenticated | TODO |
134| 38 | Stress: rapid pay/expire | Loop with short sessions | No crash/leak | TODO | 287| 38 | Stress: rapid pay/expire | Loop with short sessions | No crash/leak | TODO |
135 288
136### Phase 4: ESP32-to-OpenWRT TollGate Interop — NOT STARTED 289### Phase 4: Mesh Service Discovery + ESP32-to-OpenWRT Interop — NOT STARTED
290
291**Goal:** Two capabilities: (1) Pre-association price discovery between mesh nodes using Wi-Fi Vendor IE beacons, (2) ESP32-to-OpenWRT TollGate interoperability with Cashu tokens.
292
293#### 4A: Pre-Association Service Discovery via Vendor IE Beacons
294
295**Problem:** In a tollgate mesh network, a client router needs to know an upstream gateway's price before investing in Wi-Fi connection setup/teardown. Standard 802.11u ANQP is not supported by ESP-IDF.
296
297**Solution: Vendor-Specific Information Elements in Beacon/Probe Response frames**
298
299ESP-IDF provides `esp_wifi_set_vendor_ie()` to inject custom data into 802.11 management frames. This allows passive price discovery during normal Wi-Fi scanning — no connection required.
300
301```
302┌─────────────────────────────────────────────────────────────┐
303│ Layer 2 (Pre-Association) │
304│ │
305│ Gateway AP broadcasts price in every Beacon (~100ms) │
306│ Client STA scans, reads price from beacon before connect │
307│ │
308│ ┌─────────────┐ ┌─────────────┐ │
309│ │ Gateway AP │ Beacon ──────────► │ Client STA │ │
310│ │ │ (with price IE) │ │ │
311│ │ Vendor IE: │ │ Scan result │ │
312│ │ OUI:TG │ │ includes │ │
313│ │ price/sats │ │ price data │ │
314│ │ step_ms │ └──────┬──────┘ │
315│ │ mint_url │ │ │
316│ └─────────────┘ Decision: connect? │
317│ │ │
318└──────────────────────────────────────────────┼──────────────┘
319
320 ┌────────────────▼──────────────┐
321 │ Layer 3+ (Connected) │
322 │ POST / with Cashu token │
323 └───────────────────────────────┘
324```
325
326**Beacon IE Payload Format (Vendor-Specific, Element ID 0xDD):**
327
328```
329┌──────────┬────────┬─────────────┬──────────────┬──────────────────┐
330│ element_id│ length │ vendor_oui │ oui_type │ payload │
331│ (0xDD) │ │ (3 bytes) │ (1 byte) │ (variable) │
332├──────────┼────────┼─────────────┼──────────────┼──────────────────┤
333│ 0xDD │ N │ "TG" │ 0x01 (price) │ See below │
334│ │ │ 0x54:0x47 │ │ │
335└──────────┴────────┴─────────────┴──────────────┴──────────────────┘
336
337Price Payload (oui_type 0x01):
338┌─────────────┬─────────────┬──────────────┬───────────────┬────────────┐
339│ version (1B)│ price (2B) │ step_ms (2B) │ fee_ppk (2B) │ hop_count │
340│ = 0x01 │ sat/step │ ms/step │ or 0 │ (1B) │
341├─────────────┼─────────────┼──────────────┼───────────────┼────────────┤
342│ 0x01 │ uint16_le │ uint16_le │ uint16_le │ uint8 │
343└─────────────┴─────────────┴──────────────┴───────────────┴────────────┘
344Total payload: 9 bytes (fits easily in beacon, typical budget ~200 bytes)
345```
346
347**Implementation:**
348
349**AP Side (Gateway — `beacon_price.c/h`):**
350- `beacon_price_start()` — calls `esp_wifi_set_vendor_ie(true, WIFI_VND_IE_TYPE_BEACON, WIFI_VND_IE_ID_0, &ie_data)` and also for `WIFI_VND_IE_TYPE_PROBE_RESP`
351- `beacon_price_update(uint16_t price_sat, uint16_t step_ms, uint16_t fee_ppk, uint8_t hop_count)` — dynamically updates the IE in-place (no reconnect, no user kick; next beacon frame carries new price)
352- Price derived from `tollgate_config_t` fields (`price_per_step`, `step_size_ms`)
353- Can be called on-the-fly when market conditions change (e.g., upstream price changes)
354
355**STA Side (Client — `beacon_scan.c/h`):**
356- `beacon_scan_prices(wifi_ap_record_t *aps, int count, tollgate_price_t *prices, int *price_count)` — given scan results, extract price IEs
357- Uses `esp_wifi_set_vendor_ie_cb()` to register a callback that fires during scan
358- Or parses `vendor_ie_data_t` from scan results if available in `wifi_ap_record_t`
359- Returns array of `{bssid, ssid, price_sat, step_ms, fee_ppk, hop_count}`
360- Client selects cheapest/upstream gateway from scan results before connecting
361
362**Integration with existing config:**
363- OUI: `0x54, 0x47` ("TG" in ASCII) — unique to TollGate
364- oui_type: `0x01` = price advertisement, `0x02` = mesh routing (future)
365- `hop_count`: indicates network depth (0 = directly connected to internet, 1 = one hop away)
366- Price updates are rate-limited to once per 5 seconds to avoid beacon churn
367
368**GL-MT3000 (OpenWrt) Compatibility:**
369- OpenWrt supports vendor IEs via `hostapd_cli -i wlan0 set vendor_elements <hex>` + `hostapd_cli -i wlan0 update_beacon`
370- Client scans via `iw dev wlan0 scan` show vendor elements
371- Requires stock OpenWrt 24 firmware (not GL.iNet default) for mac80211 driver access
372- Same OUI/payload format ensures ESP32 ↔ OpenWrt interop
373
374**Key Benefits:**
375- Zero connection overhead for price discovery
376- Works during normal passive/active scanning (no extra frames)
377- Prices update live without disconnecting clients
378- Supports multi-hop mesh routing via `hop_count`
379- Compatible with both ESP32 and Linux (OpenWrt) platforms
380
381#### 4B: ESP32-to-OpenWRT TollGate Interop
137 382
138**Goal:** ESP32 can pay OpenWRT TollGate using Cashu tokens. Full interoperability with existing OpenWRT-based TollGate infrastructure. 383**Goal:** ESP32 can pay OpenWRT TollGate using Cashu tokens. Full interoperability with existing OpenWRT-based TollGate infrastructure.
139 384
@@ -141,15 +386,31 @@ Config parameter `persist_threshold_sats` (default: 1) controls when wallet stat
141 386
142## Key Technical Notes 387## Key Technical Notes
143 388
144### mbedTLS 3.x Compatibility 389### nucula / libsecp256k1
145- `mbedtls_ecp_point` is opaque — cannot access `.X`, `.Y`, `.Z` directly 390- nucula uses **libsecp256k1** (Bitcoin Core's C library) for all curve operations
146- Use `mbedtls_ecp_muladd`, `mbedtls_ecp_mul`, `mbedtls_ecp_point_read/write_binary` 391- Stack-efficient: precomputed tables in `precomputed_ecmult.c` (compile-time), small runtime stack
147- No point negation needed with `C = C_ + (order - r) * G` unblinding approach 392- No `mbedtls_ecp_mul` → no stack overflow — runs fine on default 32K httpd task
393- ESP-IDF component at `components/secp256k1/` with `ECMULT_WINDOW_SIZE=8`, `ECMULT_GEN_PREC_BITS=4`
394- git submodule at `components/nucula_src/` — pull updates via `git submodule update --remote`
395
396### Token Format
397- TollGate uses **cashuA (V3)** tokens — base64url-encoded JSON
398- nucula's `deserialize_token_v3()` / `serialize_token_v3()` handle encoding
399- cashuB (V4/CBOR) not needed; CBOR dependency excluded from build
400
401### Vendor IE Beacon (Service Discovery)
402- ESP-IDF: `esp_wifi_set_vendor_ie(enable, type, idx, data)` — injects into Beacon/ProbeResp
403- `esp_wifi_set_vendor_ie_cb(cb, ctx)` — receives vendor IEs during scan
404- Element ID 0xDD (Vendor Specific), max ~200 bytes per IE
405- Updates are in-place in RAM; next beacon carries new data (~100ms interval)
406- No client disconnect or AP restart required for updates
407- OUI `0x54:0x47` ("TG") registered for TollGate protocol
148 408
149### Board Configuration 409### Board Configuration
150- Board A: `/dev/ttyACM0`, MAC `94:a9:90:2e:37:7c`, SSID `TollGate-377C`, AP IP `10.55.85.1` 410- Board A: `/dev/ttyACM0`, factory MAC `94:a9:90:2e:37:7c`
151- Board B: `/dev/ttyACM1`, MAC `fc:01:2c:c5:50:50`, unique SSID/IP derived from MAC 411- Board B: `/dev/ttyACM1`, factory MAC `fc:01:2c:c5:50:50`
152- Both boards run identical firmware, unique config derived at boot from factory MAC 412- Both boards run identical firmware; unique identity derived from nsec in config.json
413- SSID, AP IP, STA/AP MAC all derived from nsec via HMAC-SHA512
153 414
154### Test Mint 415### Test Mint
155- `testnut.cashu.space` — auto-pays lightning invoices for testing 416- `testnut.cashu.space` — auto-pays lightning invoices for testing
diff --git a/components/nucula_lib/CMakeLists.txt b/components/nucula_lib/CMakeLists.txt
new file mode 100644
index 0000000..ea1605e
--- /dev/null
+++ b/components/nucula_lib/CMakeLists.txt
@@ -0,0 +1,17 @@
1set(NUCULA_SRC ${CMAKE_CURRENT_SOURCE_DIR}/../../nucula_src/main)
2
3idf_component_register(
4 SRCS "nucula_wallet.cpp"
5 "${NUCULA_SRC}/crypto.c"
6 "${NUCULA_SRC}/wallet.cpp"
7 "${NUCULA_SRC}/cashu_json.cpp"
8 "${NUCULA_SRC}/nut10.cpp"
9 "${NUCULA_SRC}/hex.c"
10 "${NUCULA_SRC}/http.c"
11 INCLUDE_DIRS "."
12 "${NUCULA_SRC}"
13 REQUIRES secp256k1
14 PRIV_REQUIRES log mbedtls nvs_flash esp_http_client json
15)
16
17target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-unused-function)
diff --git a/components/nucula_lib/nucula_wallet.cpp b/components/nucula_lib/nucula_wallet.cpp
new file mode 100644
index 0000000..50583f9
--- /dev/null
+++ b/components/nucula_lib/nucula_wallet.cpp
@@ -0,0 +1,199 @@
1#include "nucula_wallet.h"
2#include "wallet.hpp"
3#include "cashu_json.hpp"
4#include "crypto.h"
5#include "hex.h"
6#include "esp_log.h"
7#include "secp256k1.h"
8#include "cJSON.h"
9#include <cstring>
10#include <string>
11#include <vector>
12
13static const char *TAG = "nucula_wallet";
14
15static secp256k1_context *s_ctx = nullptr;
16static cashu::Wallet *s_wallet = nullptr;
17
18static std::vector<cashu::Proof> &mutable_proofs()
19{
20 return const_cast<std::vector<cashu::Proof> &>(s_wallet->proofs());
21}
22
23esp_err_t nucula_wallet_init(const char *mint_url)
24{
25 if (s_wallet) return ESP_OK;
26
27 s_ctx = secp256k1_context_create(SECP256K1_CONTEXT_SIGN | SECP256K1_CONTEXT_VERIFY);
28 if (!s_ctx) {
29 ESP_LOGE(TAG, "Failed to create secp256k1 context");
30 return ESP_FAIL;
31 }
32
33 s_wallet = new cashu::Wallet(std::string(mint_url), s_ctx, 0);
34 if (!s_wallet) {
35 ESP_LOGE(TAG, "Failed to create wallet");
36 secp256k1_context_destroy(s_ctx);
37 s_ctx = nullptr;
38 return ESP_FAIL;
39 }
40
41 s_wallet->load_from_nvs();
42
43 if (!s_wallet->load_keysets()) {
44 ESP_LOGW(TAG, "Keyset load failed (may be offline)");
45 }
46
47 ESP_LOGI(TAG, "Wallet initialized: balance=%d proofs=%d keysets=%d",
48 s_wallet->balance(), (int)s_wallet->proofs().size(),
49 (int)s_wallet->keysets().size());
50 return ESP_OK;
51}
52
53esp_err_t nucula_wallet_receive(const char *token_str)
54{
55 if (!s_wallet || !token_str) return ESP_FAIL;
56
57 cashu::Token tok;
58 bool decoded = false;
59
60 if (strncmp(token_str, "cashuA", 6) == 0) {
61 decoded = cashu::deserialize_token_v3(token_str, tok);
62 }
63
64 if (!decoded) {
65 ESP_LOGE(TAG, "Failed to decode token");
66 return ESP_FAIL;
67 }
68
69 std::vector<cashu::Proof> proofs_out;
70 if (!s_wallet->receive(tok, proofs_out)) {
71 ESP_LOGE(TAG, "Receive failed");
72 return ESP_FAIL;
73 }
74
75 int total = 0;
76 for (const auto &p : proofs_out) total += p.amount;
77 ESP_LOGI(TAG, "Received %d sat (%d proofs), new balance=%d",
78 total, (int)proofs_out.size(), s_wallet->balance());
79 return ESP_OK;
80}
81
82esp_err_t nucula_wallet_send(uint64_t amount_sat, char *token_out, size_t token_out_size)
83{
84 if (!s_wallet) return ESP_FAIL;
85
86 int amount = (int)amount_sat;
87 std::vector<cashu::Proof> selected, remaining;
88 if (!s_wallet->select_proofs(amount, selected, remaining)) {
89 ESP_LOGE(TAG, "Insufficient balance for %d sat", amount);
90 return ESP_FAIL;
91 }
92
93 std::vector<cashu::Proof> new_proofs, change;
94 if (!s_wallet->swap(selected, (int)amount_sat, new_proofs, change)) {
95 ESP_LOGE(TAG, "Swap for send failed");
96 return ESP_FAIL;
97 }
98
99 cashu::Token token;
100 token.mint = s_wallet->mint_url();
101 token.unit = "sat";
102 for (auto &p : new_proofs) token.proofs.push_back(p);
103
104 std::string encoded = cashu::serialize_token_v3(token);
105 if (encoded.empty()) {
106 ESP_LOGE(TAG, "Token serialization failed");
107 return ESP_FAIL;
108 }
109
110 if (encoded.size() >= token_out_size) {
111 ESP_LOGE(TAG, "Token too large: %zu >= %zu", encoded.size(), token_out_size);
112 return ESP_FAIL;
113 }
114
115 memcpy(token_out, encoded.c_str(), encoded.size() + 1);
116
117 auto &proofs = mutable_proofs();
118 proofs = remaining;
119 for (auto &p : change) proofs.push_back(p);
120 s_wallet->save_proofs();
121
122 ESP_LOGI(TAG, "Sent %llu sat, token=%zu bytes, remaining balance=%d",
123 (unsigned long long)amount_sat, encoded.size(), s_wallet->balance());
124 return ESP_OK;
125}
126
127uint64_t nucula_wallet_balance(void)
128{
129 if (!s_wallet) return 0;
130 return (uint64_t)s_wallet->balance();
131}
132
133int nucula_wallet_proof_count(void)
134{
135 if (!s_wallet) return 0;
136 return (int)s_wallet->proofs().size();
137}
138
139char *nucula_wallet_proofs_json(void)
140{
141 if (!s_wallet) return nullptr;
142
143 const auto &proofs = s_wallet->proofs();
144 cJSON *arr = cJSON_CreateArray();
145 for (const auto &p : proofs) {
146 cJSON *obj = cJSON_CreateObject();
147 cJSON_AddNumberToObject(obj, "amount", p.amount);
148 cJSON_AddStringToObject(obj, "id", p.id.c_str());
149 cJSON_AddItemToArray(arr, obj);
150 }
151 char *json = cJSON_PrintUnformatted(arr);
152 cJSON_Delete(arr);
153 return json;
154}
155
156esp_err_t nucula_wallet_swap_all(void)
157{
158 if (!s_wallet) return ESP_FAIL;
159
160 auto &proofs = mutable_proofs();
161 if (proofs.empty()) {
162 ESP_LOGW(TAG, "No proofs to swap");
163 return ESP_FAIL;
164 }
165
166 int old_balance = s_wallet->balance();
167
168 std::vector<cashu::Proof> inputs = proofs;
169 std::vector<cashu::Proof> new_proofs, change;
170 if (!s_wallet->swap(inputs, -1, new_proofs, change)) {
171 ESP_LOGE(TAG, "Swap failed");
172 return ESP_FAIL;
173 }
174
175 proofs.clear();
176 for (auto &p : new_proofs) proofs.push_back(p);
177 for (auto &p : change) proofs.push_back(p);
178 s_wallet->save_proofs();
179
180 ESP_LOGI(TAG, "Swap complete: %d -> %d sat (%d proofs)",
181 old_balance, s_wallet->balance(), (int)proofs.size());
182 return ESP_OK;
183}
184
185void nucula_wallet_print_status(void)
186{
187 if (!s_wallet) {
188 ESP_LOGI(TAG, "Wallet not initialized");
189 return;
190 }
191 ESP_LOGI(TAG, "Wallet: balance=%d proofs=%d keysets=%d",
192 s_wallet->balance(), (int)s_wallet->proofs().size(),
193 (int)s_wallet->keysets().size());
194 const auto &proofs = s_wallet->proofs();
195 for (size_t i = 0; i < proofs.size(); i++) {
196 ESP_LOGI(TAG, " [%d] amount=%d id=%s", (int)i,
197 proofs[i].amount, proofs[i].id.c_str());
198 }
199}
diff --git a/components/nucula_lib/nucula_wallet.h b/components/nucula_lib/nucula_wallet.h
new file mode 100644
index 0000000..64b7c24
--- /dev/null
+++ b/components/nucula_lib/nucula_wallet.h
@@ -0,0 +1,31 @@
1#ifndef NUCULA_WALLET_H
2#define NUCULA_WALLET_H
3
4#include "esp_err.h"
5#include <stdint.h>
6
7#ifdef __cplusplus
8extern "C" {
9#endif
10
11esp_err_t nucula_wallet_init(const char *mint_url);
12
13esp_err_t nucula_wallet_receive(const char *token_str);
14
15esp_err_t nucula_wallet_send(uint64_t amount_sat, char *token_out, size_t token_out_size);
16
17uint64_t nucula_wallet_balance(void);
18
19int nucula_wallet_proof_count(void);
20
21char *nucula_wallet_proofs_json(void);
22
23esp_err_t nucula_wallet_swap_all(void);
24
25void nucula_wallet_print_status(void);
26
27#ifdef __cplusplus
28}
29#endif
30
31#endif
diff --git a/components/secp256k1 b/components/secp256k1
new file mode 120000
index 0000000..187b270
--- /dev/null
+++ b/components/secp256k1
@@ -0,0 +1 @@
/home/c03rad0r/esp32-tollgate/nucula_src/components/secp256k1 \ No newline at end of file
diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt
index 2eef030..df69283 100644
--- a/main/CMakeLists.txt
+++ b/main/CMakeLists.txt
@@ -6,9 +6,12 @@ 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" 9 "identity.c"
10 "wallet_persist.c" 10 "nostr_event.c"
11 INCLUDE_DIRS "." "${IDF_PATH}/components/lwip/include/apps" 11 "geohash.c"
12 "wifistr.c"
13 INCLUDE_DIRS "."
12 REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server 14 REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server
13 lwip json esp_http_client mbedtls esp-tls log spiffs 15 lwip json esp_http_client mbedtls esp-tls log spiffs
16 nucula_lib secp256k1
14 PRIV_REQUIRES esp-tls) 17 PRIV_REQUIRES esp-tls)
diff --git a/main/config.c b/main/config.c
index 7e8a14c..47d631f 100644
--- a/main/config.c
+++ b/main/config.c
@@ -1,4 +1,5 @@
1#include "config.h" 1#include "config.h"
2#include "identity.h"
2#include "esp_log.h" 3#include "esp_log.h"
3#include "esp_spiffs.h" 4#include "esp_spiffs.h"
4#include "esp_system.h" 5#include "esp_system.h"
@@ -20,6 +21,7 @@ esp_err_t tollgate_config_init(void)
20 g_config.price_per_step = 21; 21 g_config.price_per_step = 21;
21 g_config.step_size_ms = 60000; 22 g_config.step_size_ms = 60000;
22 g_config.persist_threshold_sats = 1; 23 g_config.persist_threshold_sats = 1;
24 g_config.nostr_publish_interval_s = 21600;
23 25
24 esp_vfs_spiffs_conf_t conf = { 26 esp_vfs_spiffs_conf_t conf = {
25 .base_path = "/spiffs", 27 .base_path = "/spiffs",
@@ -37,16 +39,17 @@ esp_err_t tollgate_config_init(void)
37 if (!f) { 39 if (!f) {
38 ESP_LOGW(TAG, "No config.json found, generating default"); 40 ESP_LOGW(TAG, "No config.json found, generating default");
39 const char *default_json = "{" 41 const char *default_json = "{"
42 "\"nsec\":\"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\","
40 "\"wifi_networks\":[" 43 "\"wifi_networks\":["
41 "{\"ssid\":\"EnterSSID-2.4GHz\",\"password\":\"c03rad0r123!\"}" 44 "{\"ssid\":\"EnterSSID-2.4GHz\",\"password\":\"c03rad0r123!\"}"
42 "]," 45 "],"
43 "\"ap_ssid\":\"TollGate\","
44 "\"ap_password\":\"\"," 46 "\"ap_password\":\"\","
45 "\"ap_channel\":1,"
46 "\"mint_url\":\"https://testnut.cashu.space\"," 47 "\"mint_url\":\"https://testnut.cashu.space\","
47 "\"lnurl_url\":\"https://redeem.cashu.me/.well-known/lnurlp/tollgate\","
48 "\"price_per_step\":21," 48 "\"price_per_step\":21,"
49 "\"step_size_ms\":60000" 49 "\"step_size_ms\":60000,"
50 "\"nostr_geohash\":\"u281w0dfz\","
51 "\"nostr_relays\":[\"wss://relay.damus.io\",\"wss://nos.lol\"],"
52 "\"nostr_publish_interval_s\":21600"
50 "}"; 53 "}";
51 f = fopen("/spiffs/config.json", "w"); 54 f = fopen("/spiffs/config.json", "w");
52 if (f) { 55 if (f) {
@@ -80,6 +83,15 @@ esp_err_t tollgate_config_init(void)
80 return ESP_FAIL; 83 return ESP_FAIL;
81 } 84 }
82 85
86 cJSON *nsec = cJSON_GetObjectItem(root, "nsec");
87 if (nsec && cJSON_IsString(nsec)) {
88 strncpy(g_config.nsec, nsec->valuestring, sizeof(g_config.nsec) - 1);
89 } else {
90 ESP_LOGE(TAG, "Missing 'nsec' in config.json");
91 cJSON_Delete(root);
92 return ESP_FAIL;
93 }
94
83 cJSON *networks = cJSON_GetObjectItem(root, "wifi_networks"); 95 cJSON *networks = cJSON_GetObjectItem(root, "wifi_networks");
84 if (networks && cJSON_IsArray(networks)) { 96 if (networks && cJSON_IsArray(networks)) {
85 int count = cJSON_GetArraySize(networks); 97 int count = cJSON_GetArraySize(networks);
@@ -96,16 +108,9 @@ esp_err_t tollgate_config_init(void)
96 } 108 }
97 } 109 }
98 110
99 cJSON *ap_ssid = cJSON_GetObjectItem(root, "ap_ssid");
100 if (ap_ssid) strncpy(g_config.ap_ssid, ap_ssid->valuestring, sizeof(g_config.ap_ssid) - 1);
101 else strncpy(g_config.ap_ssid, "TollGate", sizeof(g_config.ap_ssid) - 1);
102
103 cJSON *ap_pass = cJSON_GetObjectItem(root, "ap_password"); 111 cJSON *ap_pass = cJSON_GetObjectItem(root, "ap_password");
104 if (ap_pass) strncpy(g_config.ap_password, ap_pass->valuestring, sizeof(g_config.ap_password) - 1); 112 if (ap_pass) strncpy(g_config.ap_password, ap_pass->valuestring, sizeof(g_config.ap_password) - 1);
105 113
106 cJSON *ap_ch = cJSON_GetObjectItem(root, "ap_channel");
107 if (ap_ch) g_config.ap_channel = ap_ch->valueint;
108
109 cJSON *mint = cJSON_GetObjectItem(root, "mint_url"); 114 cJSON *mint = cJSON_GetObjectItem(root, "mint_url");
110 if (mint) strncpy(g_config.mint_url, mint->valuestring, sizeof(g_config.mint_url) - 1); 115 if (mint) strncpy(g_config.mint_url, mint->valuestring, sizeof(g_config.mint_url) - 1);
111 116
@@ -121,9 +126,37 @@ esp_err_t tollgate_config_init(void)
121 cJSON *persist = cJSON_GetObjectItem(root, "persist_threshold_sats"); 126 cJSON *persist = cJSON_GetObjectItem(root, "persist_threshold_sats");
122 if (persist) g_config.persist_threshold_sats = (uint64_t)persist->valuedouble; 127 if (persist) g_config.persist_threshold_sats = (uint64_t)persist->valuedouble;
123 128
129 cJSON *geohash = cJSON_GetObjectItem(root, "nostr_geohash");
130 if (geohash) strncpy(g_config.nostr_geohash, geohash->valuestring, sizeof(g_config.nostr_geohash) - 1);
131 else strncpy(g_config.nostr_geohash, "u281w0dfz", sizeof(g_config.nostr_geohash) - 1);
132
133 cJSON *relays = cJSON_GetObjectItem(root, "nostr_relays");
134 if (relays && cJSON_IsArray(relays)) {
135 int rcount = cJSON_GetArraySize(relays);
136 if (rcount > TOLLGATE_MAX_RELAYS) rcount = TOLLGATE_MAX_RELAYS;
137 for (int i = 0; i < rcount; i++) {
138 cJSON *r = cJSON_GetArrayItem(relays, i);
139 if (r && cJSON_IsString(r)) {
140 strncpy(g_config.nostr_relays[i], r->valuestring, sizeof(g_config.nostr_relays[i]) - 1);
141 g_config.nostr_relay_count++;
142 }
143 }
144 }
145
146 cJSON *pub_interval = cJSON_GetObjectItem(root, "nostr_publish_interval_s");
147 if (pub_interval) g_config.nostr_publish_interval_s = pub_interval->valueint;
148
124 cJSON_Delete(root); 149 cJSON_Delete(root);
125 ESP_LOGI(TAG, "Config loaded: AP='%s', %d WiFi networks, price=%d sats/%dms", 150
126 g_config.ap_ssid, g_config.network_count, g_config.price_per_step, g_config.step_size_ms); 151 if (g_config.nostr_relay_count == 0) {
152 strncpy(g_config.nostr_relays[0], "wss://relay.damus.io", sizeof(g_config.nostr_relays[0]) - 1);
153 strncpy(g_config.nostr_relays[1], "wss://nos.lol", sizeof(g_config.nostr_relays[1]) - 1);
154 g_config.nostr_relay_count = 2;
155 }
156
157 ESP_LOGI(TAG, "Config loaded: nsec=%s...%s, %d WiFi networks, price=%d sats/%dms",
158 g_config.nsec, g_config.nsec + 60, g_config.network_count,
159 g_config.price_per_step, g_config.step_size_ms);
127 return ESP_OK; 160 return ESP_OK;
128} 161}
129 162
@@ -151,22 +184,23 @@ esp_err_t tollgate_config_get_next_wifi(wifi_config_t *wifi_config)
151 184
152void tollgate_config_derive_unique(tollgate_config_t *cfg) 185void tollgate_config_derive_unique(tollgate_config_t *cfg)
153{ 186{
154 if (cfg->unique_derived) return; 187 if (cfg->identity_initialized) return;
155
156 uint8_t mac[6];
157 esp_read_mac(mac, ESP_MAC_WIFI_STA);
158 188
159 snprintf(cfg->ap_ssid + strlen(cfg->ap_ssid), 189 const tollgate_identity_t *id = identity_get();
160 TOLLGATE_MAX_AP_SSID_LEN - strlen(cfg->ap_ssid), 190 if (!id || !id->initialized) {
161 "-%02X%02X", mac[4], mac[5]); 191 ESP_LOGE(TAG, "Cannot derive unique config: identity not initialized");
192 return;
193 }
162 194
163 uint8_t b5 = mac[4]; 195 strncpy(cfg->ap_ssid, id->ap_ssid, sizeof(cfg->ap_ssid) - 1);
164 uint8_t b6 = mac[5]; 196 memcpy(cfg->sta_mac, id->sta_mac, 6);
165 uint8_t subnet = (b5 ^ b6) % 200 + 10; 197 memcpy(cfg->ap_mac, id->ap_mac, 6);
166 IP4_ADDR(&cfg->ap_ip, 10, b5, subnet, 1); 198 cfg->ap_ip = id->ap_ip;
167 snprintf(cfg->ap_ip_str, sizeof(cfg->ap_ip_str), IPSTR, IP2STR(&cfg->ap_ip)); 199 strncpy(cfg->ap_ip_str, id->ap_ip_str, sizeof(cfg->ap_ip_str) - 1);
200 strncpy(cfg->npub, id->npub_hex, sizeof(cfg->npub) - 1);
168 201
169 cfg->unique_derived = true; 202 cfg->identity_initialized = true;
170 203
171 ESP_LOGI(TAG, "Unique config: SSID='%s', AP_IP=%s", cfg->ap_ssid, cfg->ap_ip_str); 204 ESP_LOGI(TAG, "Unique config derived from nsec: SSID='%s', AP_IP=%s",
205 cfg->ap_ssid, cfg->ap_ip_str);
172} 206}
diff --git a/main/config.h b/main/config.h
index 2bcd400..8254a62 100644
--- a/main/config.h
+++ b/main/config.h
@@ -10,6 +10,7 @@
10#define TOLLGATE_MAX_MINT_URLS 3 10#define TOLLGATE_MAX_MINT_URLS 3
11#define TOLLGATE_MAX_AP_SSID_LEN 32 11#define TOLLGATE_MAX_AP_SSID_LEN 32
12#define TOLLGATE_MAX_AP_PASS_LEN 64 12#define TOLLGATE_MAX_AP_PASS_LEN 64
13#define TOLLGATE_MAX_RELAYS 4
13 14
14typedef struct { 15typedef struct {
15 char ssid[32]; 16 char ssid[32];
@@ -22,11 +23,17 @@ typedef struct {
22 int current_network; 23 int current_network;
23 int max_retry; 24 int max_retry;
24 25
26 char nsec[65];
27 char npub[65];
28
25 char ap_ssid[TOLLGATE_MAX_AP_SSID_LEN]; 29 char ap_ssid[TOLLGATE_MAX_AP_SSID_LEN];
26 char ap_password[TOLLGATE_MAX_AP_PASS_LEN]; 30 char ap_password[TOLLGATE_MAX_AP_PASS_LEN];
27 uint8_t ap_channel; 31 uint8_t ap_channel;
28 uint8_t ap_max_conn; 32 uint8_t ap_max_conn;
29 33
34 uint8_t sta_mac[6];
35 uint8_t ap_mac[6];
36
30 esp_ip4_addr_t ap_ip; 37 esp_ip4_addr_t ap_ip;
31 char ap_ip_str[16]; 38 char ap_ip_str[16];
32 39
@@ -36,7 +43,12 @@ typedef struct {
36 int step_size_ms; 43 int step_size_ms;
37 uint64_t persist_threshold_sats; 44 uint64_t persist_threshold_sats;
38 45
39 bool unique_derived; 46 char nostr_geohash[16];
47 char nostr_relays[TOLLGATE_MAX_RELAYS][128];
48 int nostr_relay_count;
49 int nostr_publish_interval_s;
50
51 bool identity_initialized;
40} tollgate_config_t; 52} tollgate_config_t;
41 53
42void tollgate_config_derive_unique(tollgate_config_t *cfg); 54void tollgate_config_derive_unique(tollgate_config_t *cfg);
diff --git a/main/geohash.c b/main/geohash.c
new file mode 100644
index 0000000..f649824
--- /dev/null
+++ b/main/geohash.c
@@ -0,0 +1,48 @@
1#include "geohash.h"
2#include <string.h>
3
4static const char BASE32[] = "0123456789bcdefghjkmnpqrstuvwxyz";
5
6void geohash_encode(double lat, double lon, int precision, char *out)
7{
8 double lat_range[2] = { -90.0, 90.0 };
9 double lon_range[2] = { -180.0, 180.0 };
10 uint8_t hash_bytes[16];
11 int bit_count = precision * 5;
12 int byte_count = (bit_count + 7) / 8;
13 memset(hash_bytes, 0, sizeof(hash_bytes));
14
15 for (int i = 0; i < bit_count; i++) {
16 int byte_idx = i / 8;
17 int bit_idx = 7 - (i % 8);
18
19 if (i % 2 == 0) {
20 double mid = (lon_range[0] + lon_range[1]) / 2.0;
21 if (lon >= mid) {
22 hash_bytes[byte_idx] |= (1 << bit_idx);
23 lon_range[0] = mid;
24 } else {
25 lon_range[1] = mid;
26 }
27 } else {
28 double mid = (lat_range[0] + lat_range[1]) / 2.0;
29 if (lat >= mid) {
30 hash_bytes[byte_idx] |= (1 << bit_idx);
31 lat_range[0] = mid;
32 } else {
33 lat_range[1] = mid;
34 }
35 }
36 }
37
38 for (int i = 0; i < precision; i++) {
39 int byte_idx = (i * 5) / 8;
40 int bit_offset = (i * 5) % 8;
41 uint16_t val = (hash_bytes[byte_idx] << 8);
42 if (byte_idx + 1 < (int)sizeof(hash_bytes))
43 val |= hash_bytes[byte_idx + 1];
44 val = (val >> (16 - 5 - bit_offset)) & 0x1F;
45 out[i] = BASE32[val];
46 }
47 out[precision] = '\0';
48}
diff --git a/main/geohash.h b/main/geohash.h
new file mode 100644
index 0000000..f8eb69d
--- /dev/null
+++ b/main/geohash.h
@@ -0,0 +1,8 @@
1#ifndef GEOHASH_H
2#define GEOHASH_H
3
4#include <stddef.h>
5
6void geohash_encode(double lat, double lon, int precision, char *out);
7
8#endif
diff --git a/main/identity.c b/main/identity.c
new file mode 100644
index 0000000..1dab415
--- /dev/null
+++ b/main/identity.c
@@ -0,0 +1,124 @@
1#include "identity.h"
2#include "config.h"
3#include "esp_log.h"
4#include "lwip/ip4_addr.h"
5#include "mbedtls/md.h"
6#include "secp256k1.h"
7#include "secp256k1_extrakeys.h"
8#include <string.h>
9#include <stdio.h>
10#include <stdlib.h>
11
12static const char *TAG = "identity";
13static tollgate_identity_t s_identity;
14
15static int hex_to_bytes(const char *hex, uint8_t *out, size_t out_len)
16{
17 if (strlen(hex) != out_len * 2) return 0;
18 for (size_t i = 0; i < out_len; i++) {
19 unsigned int byte;
20 if (sscanf(hex + i * 2, "%02x", &byte) != 1) return 0;
21 out[i] = (uint8_t)byte;
22 }
23 return 1;
24}
25
26static void bytes_to_hex(const uint8_t *bytes, size_t len, char *hex)
27{
28 for (size_t i = 0; i < len; i++)
29 sprintf(hex + i * 2, "%02x", bytes[i]);
30 hex[len * 2] = '\0';
31}
32
33static void tollgate_derive(const uint8_t nsec[32], const char *label,
34 uint32_t index, uint8_t *out, size_t out_len)
35{
36 size_t label_len = strlen(label);
37 size_t msg_len = label_len + 4;
38 uint8_t *msg = (uint8_t *)malloc(msg_len);
39 memcpy(msg, label, label_len);
40 msg[label_len] = (uint8_t)(index & 0xff);
41 msg[label_len + 1] = (uint8_t)((index >> 8) & 0xff);
42 msg[label_len + 2] = (uint8_t)((index >> 16) & 0xff);
43 msg[label_len + 3] = (uint8_t)((index >> 24) & 0xff);
44
45 uint8_t hmac[64];
46 mbedtls_md_hmac(mbedtls_md_info_from_type(MBEDTLS_MD_SHA512),
47 nsec, 32, msg, msg_len, hmac);
48 free(msg);
49
50 memcpy(out, hmac, out_len);
51}
52
53esp_err_t identity_init(const char *nsec_hex)
54{
55 memset(&s_identity, 0, sizeof(s_identity));
56
57 if (!nsec_hex || strlen(nsec_hex) != 64) {
58 ESP_LOGE(TAG, "Invalid nsec: must be 64 hex chars");
59 return ESP_ERR_INVALID_ARG;
60 }
61
62 strncpy(s_identity.nsec_hex, nsec_hex, sizeof(s_identity.nsec_hex) - 1);
63
64 if (!hex_to_bytes(nsec_hex, s_identity.nsec, 32)) {
65 ESP_LOGE(TAG, "Failed to parse nsec hex");
66 return ESP_ERR_INVALID_ARG;
67 }
68
69 secp256k1_context *ctx = secp256k1_context_create(SECP256K1_CONTEXT_SIGN);
70 if (!ctx) {
71 ESP_LOGE(TAG, "Failed to create secp256k1 context");
72 return ESP_ERR_NO_MEM;
73 }
74
75 secp256k1_pubkey pubkey;
76 if (!secp256k1_ec_pubkey_create(ctx, &pubkey, s_identity.nsec)) {
77 ESP_LOGE(TAG, "Invalid nsec: secp256k1 key creation failed");
78 secp256k1_context_destroy(ctx);
79 return ESP_ERR_INVALID_ARG;
80 }
81
82 secp256k1_xonly_pubkey xonly;
83 secp256k1_xonly_pubkey_from_pubkey(ctx, &xonly, NULL, &pubkey);
84 uint8_t npub_bytes[32];
85 secp256k1_xonly_pubkey_serialize(ctx, npub_bytes, &xonly);
86 bytes_to_hex(npub_bytes, 32, s_identity.npub_hex);
87
88 tollgate_derive(s_identity.nsec, "sta-mac", 0, s_identity.sta_mac, 6);
89 s_identity.sta_mac[0] = (s_identity.sta_mac[0] | 0x02) & 0xFE;
90
91 tollgate_derive(s_identity.nsec, "ap-mac", 0, s_identity.ap_mac, 6);
92 s_identity.ap_mac[0] = (s_identity.ap_mac[0] | 0x02) & 0xFE;
93
94 snprintf(s_identity.ap_ssid, sizeof(s_identity.ap_ssid),
95 "TollGate-%02X%02X%02X",
96 s_identity.ap_mac[3], s_identity.ap_mac[4], s_identity.ap_mac[5]);
97
98 uint8_t b3 = s_identity.ap_mac[3];
99 uint8_t b4 = s_identity.ap_mac[4];
100 uint8_t b5 = s_identity.ap_mac[5];
101 uint8_t subnet = (b4 ^ b5) % 200 + 10;
102 IP4_ADDR(&s_identity.ap_ip, 10, b3, subnet, 1);
103 snprintf(s_identity.ap_ip_str, sizeof(s_identity.ap_ip_str),
104 IPSTR, IP2STR(&s_identity.ap_ip));
105
106 secp256k1_context_destroy(ctx);
107 s_identity.initialized = true;
108
109 ESP_LOGI(TAG, "Identity: npub=%s", s_identity.npub_hex);
110 ESP_LOGI(TAG, " STA MAC: %02X:%02X:%02X:%02X:%02X:%02X",
111 s_identity.sta_mac[0], s_identity.sta_mac[1], s_identity.sta_mac[2],
112 s_identity.sta_mac[3], s_identity.sta_mac[4], s_identity.sta_mac[5]);
113 ESP_LOGI(TAG, " AP MAC: %02X:%02X:%02X:%02X:%02X:%02X",
114 s_identity.ap_mac[0], s_identity.ap_mac[1], s_identity.ap_mac[2],
115 s_identity.ap_mac[3], s_identity.ap_mac[4], s_identity.ap_mac[5]);
116 ESP_LOGI(TAG, " SSID: %s, AP IP: %s", s_identity.ap_ssid, s_identity.ap_ip_str);
117
118 return ESP_OK;
119}
120
121const tollgate_identity_t *identity_get(void)
122{
123 return &s_identity;
124}
diff --git a/main/identity.h b/main/identity.h
new file mode 100644
index 0000000..2990455
--- /dev/null
+++ b/main/identity.h
@@ -0,0 +1,29 @@
1#ifndef IDENTITY_H
2#define IDENTITY_H
3
4#include "esp_err.h"
5#include "esp_wifi.h"
6#include "esp_netif.h"
7#include <stdint.h>
8#include <stdbool.h>
9
10typedef struct {
11 uint8_t nsec[32];
12 char nsec_hex[65];
13 char npub_hex[65];
14
15 uint8_t sta_mac[6];
16 uint8_t ap_mac[6];
17
18 char ap_ssid[32];
19 esp_ip4_addr_t ap_ip;
20 char ap_ip_str[16];
21
22 bool initialized;
23} tollgate_identity_t;
24
25esp_err_t identity_init(const char *nsec_hex);
26
27const tollgate_identity_t *identity_get(void);
28
29#endif
diff --git a/main/nostr_event.c b/main/nostr_event.c
new file mode 100644
index 0000000..b55c47d
--- /dev/null
+++ b/main/nostr_event.c
@@ -0,0 +1,112 @@
1#include "nostr_event.h"
2#include "esp_log.h"
3#include "esp_err.h"
4#include "mbedtls/sha256.h"
5#include "secp256k1.h"
6#include "secp256k1_extrakeys.h"
7#include "secp256k1_schnorrsig.h"
8#include "cJSON.h"
9#include <string.h>
10#include <stdio.h>
11#include <sys/time.h>
12
13static const char *TAG = "nostr_event";
14
15static void bytes_to_hex(const uint8_t *bytes, size_t len, char *hex)
16{
17 for (size_t i = 0; i < len; i++)
18 sprintf(hex + i * 2, "%02x", bytes[i]);
19 hex[len * 2] = '\0';
20}
21
22esp_err_t nostr_event_init(nostr_event_t *event, const char *npub_hex,
23 int kind, const char *tags_json, const char *content)
24{
25 memset(event, 0, sizeof(*event));
26 strncpy(event->pubkey, npub_hex, sizeof(event->pubkey) - 1);
27 event->kind = kind;
28 event->tags_json = tags_json ? tags_json : "[]";
29 event->content = content ? content : "";
30
31 struct timeval tv;
32 gettimeofday(&tv, NULL);
33 event->created_at = (uint64_t)tv.tv_sec;
34
35 cJSON *serial = cJSON_CreateArray();
36 cJSON_AddItemToArray(serial, cJSON_CreateNumber(0));
37 cJSON_AddItemToArray(serial, cJSON_CreateString(event->pubkey));
38 cJSON_AddItemToArray(serial, cJSON_CreateNumber((double)event->created_at));
39 cJSON_AddItemToArray(serial, cJSON_CreateNumber(event->kind));
40 cJSON_AddItemToArray(serial, cJSON_Parse(event->tags_json));
41 cJSON_AddItemToArray(serial, cJSON_CreateString(event->content));
42
43 char *serialized = cJSON_PrintUnformatted(serial);
44 cJSON_Delete(serial);
45
46 uint8_t hash[32];
47 mbedtls_sha256((const unsigned char *)serialized, strlen(serialized),
48 hash, 0);
49 free(serialized);
50
51 bytes_to_hex(hash, 32, event->id);
52 return ESP_OK;
53}
54
55esp_err_t nostr_event_sign(nostr_event_t *event, const uint8_t nsec[32])
56{
57 uint8_t msg[32];
58 for (size_t i = 0; i < 32; i++) {
59 unsigned int byte;
60 sscanf(event->id + i * 2, "%02x", &byte);
61 msg[i] = (uint8_t)byte;
62 }
63
64 secp256k1_context *ctx = secp256k1_context_create(SECP256K1_CONTEXT_SIGN);
65 if (!ctx) {
66 ESP_LOGE(TAG, "Failed to create secp256k1 context");
67 return ESP_ERR_NO_MEM;
68 }
69
70 secp256k1_keypair keypair;
71 if (!secp256k1_keypair_create(ctx, &keypair, nsec)) {
72 ESP_LOGE(TAG, "Invalid nsec for signing");
73 secp256k1_context_destroy(ctx);
74 return ESP_ERR_INVALID_ARG;
75 }
76
77 uint8_t sig[64];
78 if (!secp256k1_schnorrsig_sign32(ctx, sig, msg, &keypair, NULL)) {
79 ESP_LOGE(TAG, "Schnorr signing failed");
80 secp256k1_context_destroy(ctx);
81 return ESP_FAIL;
82 }
83
84 bytes_to_hex(sig, 64, event->sig);
85 secp256k1_context_destroy(ctx);
86 return ESP_OK;
87}
88
89esp_err_t nostr_event_to_json(const nostr_event_t *event, char *buf, size_t buf_len)
90{
91 cJSON *root = cJSON_CreateObject();
92 cJSON_AddStringToObject(root, "id", event->id);
93 cJSON_AddStringToObject(root, "pubkey", event->pubkey);
94 cJSON_AddNumberToObject(root, "created_at", (double)event->created_at);
95 cJSON_AddNumberToObject(root, "kind", event->kind);
96 cJSON_AddItemToObject(root, "tags", cJSON_Parse(event->tags_json));
97 cJSON_AddStringToObject(root, "content", event->content);
98 cJSON_AddStringToObject(root, "sig", event->sig);
99
100 char *json = cJSON_PrintUnformatted(root);
101 cJSON_Delete(root);
102
103 if (!json) return ESP_FAIL;
104 size_t len = strlen(json);
105 if (len >= buf_len) {
106 free(json);
107 return ESP_ERR_NO_MEM;
108 }
109 memcpy(buf, json, len + 1);
110 free(json);
111 return ESP_OK;
112}
diff --git a/main/nostr_event.h b/main/nostr_event.h
new file mode 100644
index 0000000..ce15900
--- /dev/null
+++ b/main/nostr_event.h
@@ -0,0 +1,25 @@
1#ifndef NOSTR_EVENT_H
2#define NOSTR_EVENT_H
3
4#include "esp_err.h"
5#include <stdint.h>
6#include <stddef.h>
7
8typedef struct {
9 char pubkey[65];
10 uint64_t created_at;
11 int kind;
12 const char *tags_json;
13 const char *content;
14 char id[65];
15 char sig[129];
16} nostr_event_t;
17
18esp_err_t nostr_event_init(nostr_event_t *event, const char *npub_hex,
19 int kind, const char *tags_json, const char *content);
20
21esp_err_t nostr_event_sign(nostr_event_t *event, const uint8_t nsec[32]);
22
23esp_err_t nostr_event_to_json(const nostr_event_t *event, char *buf, size_t buf_len);
24
25#endif
diff --git a/main/tollgate_api.c b/main/tollgate_api.c
index e6880e0..72ed726 100644
--- a/main/tollgate_api.c
+++ b/main/tollgate_api.c
@@ -3,7 +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 "nucula_wallet.h"
7#include "esp_log.h" 7#include "esp_log.h"
8#include "cJSON.h" 8#include "cJSON.h"
9#include "lwip/sockets.h" 9#include "lwip/sockets.h"
@@ -194,6 +194,7 @@ static esp_err_t api_post_payment(httpd_req_t *req)
194 return ESP_OK; 194 return ESP_OK;
195 } 195 }
196 esp_err_t err = cashu_decode_token(body, token); 196 esp_err_t err = cashu_decode_token(body, token);
197 char *body_copy = strdup(body);
197 free(body); 198 free(body);
198 199
199 if (err != ESP_OK) { 200 if (err != ESP_OK) {
@@ -319,17 +320,7 @@ static esp_err_t api_post_payment(httpd_req_t *req)
319 cJSON_free(json); 320 cJSON_free(json);
320 cJSON_Delete(session_event); 321 cJSON_Delete(session_event);
321 322
322 { 323 nucula_wallet_receive(body_copy);
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 324
334 free(states); 325 free(states);
335 free(token); 326 free(token);
@@ -381,20 +372,18 @@ static esp_err_t api_get_whoami(httpd_req_t *req)
381 372
382static esp_err_t api_get_wallet(httpd_req_t *req) 373static esp_err_t api_get_wallet(httpd_req_t *req)
383{ 374{
384 wallet_t *w = wallet_get();
385 cJSON *root = cJSON_CreateObject(); 375 cJSON *root = cJSON_CreateObject();
386 cJSON_AddNumberToObject(root, "balance", (double)w->balance); 376 cJSON_AddNumberToObject(root, "balance", (double)nucula_wallet_balance());
387 cJSON_AddNumberToObject(root, "proof_count", w->proof_count); 377 cJSON_AddNumberToObject(root, "proof_count", nucula_wallet_proof_count());
388 cJSON_AddNumberToObject(root, "keyset_count", w->keyset_count); 378
389 379 char *proofs_json = nucula_wallet_proofs_json();
390 cJSON *proofs = cJSON_CreateArray(); 380 if (proofs_json) {
391 for (int i = 0; i < w->proof_count; i++) { 381 cJSON *proofs = cJSON_Parse(proofs_json);
392 cJSON *p = cJSON_CreateObject(); 382 free(proofs_json);
393 cJSON_AddNumberToObject(p, "amount", (double)w->proofs[i].amount); 383 cJSON_AddItemToObject(root, "proofs", proofs);
394 cJSON_AddStringToObject(p, "id", w->proofs[i].id); 384 } else {
395 cJSON_AddItemToArray(proofs, p); 385 cJSON_AddItemToObject(root, "proofs", cJSON_CreateArray());
396 } 386 }
397 cJSON_AddItemToObject(root, "proofs", proofs);
398 387
399 char *json = cJSON_PrintUnformatted(root); 388 char *json = cJSON_PrintUnformatted(root);
400 httpd_resp_set_type(req, "application/json"); 389 httpd_resp_set_type(req, "application/json");
@@ -406,27 +395,16 @@ static esp_err_t api_get_wallet(httpd_req_t *req)
406 395
407static esp_err_t api_post_wallet_swap(httpd_req_t *req) 396static esp_err_t api_post_wallet_swap(httpd_req_t *req)
408{ 397{
409 const tollgate_config_t *cfg = tollgate_config_get(); 398 if (nucula_wallet_balance() == 0) {
410
411 if (wallet_balance() == 0) {
412 httpd_resp_set_status(req, "400 Bad Request"); 399 httpd_resp_set_status(req, "400 Bad Request");
413 httpd_resp_set_type(req, "application/json"); 400 httpd_resp_set_type(req, "application/json");
414 httpd_resp_send(req, "{\"error\":\"no proofs to swap\"}", 27); 401 httpd_resp_send(req, "{\"error\":\"no proofs to swap\"}", 27);
415 return ESP_OK; 402 return ESP_OK;
416 } 403 }
417 404
418 wallet_print_status(); 405 nucula_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 406
428 wallet_t *w = wallet_get(); 407 esp_err_t err = nucula_wallet_swap_all();
429 err = wallet_swap_proofs(cfg->mint_url, 0, w->proof_count);
430 if (err != ESP_OK) { 408 if (err != ESP_OK) {
431 httpd_resp_set_status(req, "502 Bad Gateway"); 409 httpd_resp_set_status(req, "502 Bad Gateway");
432 httpd_resp_set_type(req, "application/json"); 410 httpd_resp_set_type(req, "application/json");
@@ -434,11 +412,11 @@ static esp_err_t api_post_wallet_swap(httpd_req_t *req)
434 return ESP_OK; 412 return ESP_OK;
435 } 413 }
436 414
437 wallet_print_status(); 415 nucula_wallet_print_status();
438 416
439 cJSON *root = cJSON_CreateObject(); 417 cJSON *root = cJSON_CreateObject();
440 cJSON_AddNumberToObject(root, "balance", (double)wallet_balance()); 418 cJSON_AddNumberToObject(root, "balance", (double)nucula_wallet_balance());
441 cJSON_AddNumberToObject(root, "proof_count", wallet_get()->proof_count); 419 cJSON_AddNumberToObject(root, "proof_count", nucula_wallet_proof_count());
442 char *json = cJSON_PrintUnformatted(root); 420 char *json = cJSON_PrintUnformatted(root);
443 httpd_resp_set_type(req, "application/json"); 421 httpd_resp_set_type(req, "application/json");
444 httpd_resp_send(req, json, strlen(json)); 422 httpd_resp_send(req, json, strlen(json));
@@ -472,9 +450,8 @@ static esp_err_t api_post_wallet_send(httpd_req_t *req)
472 return ESP_OK; 450 return ESP_OK;
473 } 451 }
474 452
475 const tollgate_config_t *cfg = tollgate_config_get();
476 char token[4096]; 453 char token[4096];
477 esp_err_t err = wallet_send(cfg->mint_url, amount, token, sizeof(token)); 454 esp_err_t err = nucula_wallet_send(amount, token, sizeof(token));
478 if (err != ESP_OK) { 455 if (err != ESP_OK) {
479 httpd_resp_set_status(req, "402 Payment Required"); 456 httpd_resp_set_status(req, "402 Payment Required");
480 httpd_resp_set_type(req, "text/plain"); 457 httpd_resp_set_type(req, "text/plain");
diff --git a/main/tollgate_main.c b/main/tollgate_main.c
index d4b29bc..7fa1be1 100644
--- a/main/tollgate_main.c
+++ b/main/tollgate_main.c
@@ -11,12 +11,14 @@
11#include "lwip/dns.h" 11#include "lwip/dns.h"
12#include "dhcpserver/dhcpserver.h" 12#include "dhcpserver/dhcpserver.h"
13#include "config.h" 13#include "config.h"
14#include "identity.h"
14#include "dns_server.h" 15#include "dns_server.h"
15#include "captive_portal.h" 16#include "captive_portal.h"
16#include "firewall.h" 17#include "firewall.h"
17#include "session.h" 18#include "session.h"
18#include "tollgate_api.h" 19#include "tollgate_api.h"
19#include "wallet.h" 20#include "nucula_wallet.h"
21#include "wifistr.h"
20 22
21#define MAX_STA_RETRY 5 23#define MAX_STA_RETRY 5
22static const char *TAG = "tollgate_main"; 24static const char *TAG = "tollgate_main";
@@ -92,8 +94,16 @@ static void ip_event_handler(void *arg, esp_event_base_t event_base,
92static void wallet_init_task(void *pvParameters) 94static void wallet_init_task(void *pvParameters)
93{ 95{
94 const tollgate_config_t *cfg = tollgate_config_get(); 96 const tollgate_config_t *cfg = tollgate_config_get();
95 wallet_init(); 97 nucula_wallet_init(cfg->mint_url);
96 wallet_fetch_keysets(cfg->mint_url); 98 vTaskDelete(NULL);
99}
100
101static void publish_wifistr_task(void *pvParameters)
102{
103 vTaskDelay(pdMS_TO_TICKS(5000));
104 wifistr_publish();
105 const tollgate_config_t *cfg = tollgate_config_get();
106 wifistr_start_periodic(cfg->nostr_publish_interval_s);
97 vTaskDelete(NULL); 107 vTaskDelete(NULL);
98} 108}
99 109
@@ -123,6 +133,8 @@ static void start_services(void)
123 captive_portal_start(cfg->ap_ip_str); 133 captive_portal_start(cfg->ap_ip_str);
124 tollgate_api_start(); 134 tollgate_api_start();
125 135
136 xTaskCreate(publish_wifistr_task, "wifistr_init", 16384, NULL, 3, NULL);
137
126 s_services_running = true; 138 s_services_running = true;
127 if (s_services_mutex) xSemaphoreGive(s_services_mutex); 139 if (s_services_mutex) xSemaphoreGive(s_services_mutex);
128 ESP_LOGI(TAG, "=== TollGate services started ==="); 140 ESP_LOGI(TAG, "=== TollGate services started ===");
@@ -214,7 +226,11 @@ void app_main(void)
214 ESP_ERROR_CHECK(ret); 226 ESP_ERROR_CHECK(ret);
215 227
216 ESP_ERROR_CHECK(tollgate_config_init()); 228 ESP_ERROR_CHECK(tollgate_config_init());
229
230 ESP_ERROR_CHECK(identity_init(tollgate_config_get()->nsec));
231
217 tollgate_config_derive_unique((tollgate_config_t *)tollgate_config_get()); 232 tollgate_config_derive_unique((tollgate_config_t *)tollgate_config_get());
233
218 ESP_ERROR_CHECK(esp_netif_init()); 234 ESP_ERROR_CHECK(esp_netif_init());
219 ESP_ERROR_CHECK(esp_event_loop_create_default()); 235 ESP_ERROR_CHECK(esp_event_loop_create_default());
220 236
@@ -227,6 +243,11 @@ void app_main(void)
227 wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); 243 wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
228 ESP_ERROR_CHECK(esp_wifi_init(&cfg)); 244 ESP_ERROR_CHECK(esp_wifi_init(&cfg));
229 245
246 const tollgate_config_t *tcfg = tollgate_config_get();
247 ESP_ERROR_CHECK(esp_wifi_set_mac(WIFI_IF_STA, tcfg->sta_mac));
248 ESP_ERROR_CHECK(esp_wifi_set_mac(WIFI_IF_AP, tcfg->ap_mac));
249 ESP_LOGI(TAG, "MACs set from identity");
250
230 ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, 251 ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID,
231 &wifi_event_handler, NULL, NULL)); 252 &wifi_event_handler, NULL, NULL));
232 ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, 253 ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP,
@@ -241,8 +262,8 @@ void app_main(void)
241 wifi_config_t sta_config; 262 wifi_config_t sta_config;
242 if (tollgate_config_get_wifi(&sta_config) == ESP_OK) { 263 if (tollgate_config_get_wifi(&sta_config) == ESP_OK) {
243 ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &sta_config)); 264 ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &sta_config));
244 const tollgate_config_t *tcfg = tollgate_config_get(); 265 const tollgate_config_t *tcfg2 = tollgate_config_get();
245 ESP_LOGI(TAG, "STA configured for SSID: %s", tcfg->networks[tcfg->current_network].ssid); 266 ESP_LOGI(TAG, "STA configured for SSID: %s", tcfg2->networks[tcfg2->current_network].ssid);
246 } 267 }
247 268
248 ESP_ERROR_CHECK(esp_wifi_start()); 269 ESP_ERROR_CHECK(esp_wifi_start());
diff --git a/main/wallet.c b/main/wallet.c
deleted file mode 100644
index 3f65220..0000000
--- a/main/wallet.c
+++ /dev/null
@@ -1,639 +0,0 @@
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
deleted file mode 100644
index 5089f93..0000000
--- a/main/wallet.h
+++ /dev/null
@@ -1,53 +0,0 @@
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
deleted file mode 100644
index 45c932f..0000000
--- a/main/wallet_persist.c
+++ /dev/null
@@ -1,147 +0,0 @@
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
deleted file mode 100644
index 4dfcbfc..0000000
--- a/main/wallet_persist.h
+++ /dev/null
@@ -1,9 +0,0 @@
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/main/wifistr.c b/main/wifistr.c
new file mode 100644
index 0000000..bf03b4d
--- /dev/null
+++ b/main/wifistr.c
@@ -0,0 +1,252 @@
1#include "wifistr.h"
2#include "identity.h"
3#include "nostr_event.h"
4#include "config.h"
5#include "esp_log.h"
6#include "esp_tls.h"
7#include "esp_crt_bundle.h"
8#include "cJSON.h"
9#include "freertos/task.h"
10#include "freertos/timers.h"
11#include <string.h>
12#include <stdio.h>
13#include <stdlib.h>
14
15static const char *TAG = "wifistr";
16static TimerHandle_t s_publish_timer = NULL;
17
18static esp_err_t ws_send_to_relay(const char *relay_url, const char *event_json)
19{
20 char host[128] = {0};
21 int port = 443;
22 char path[128] = "/";
23
24 if (strncmp(relay_url, "wss://", 6) != 0) {
25 ESP_LOGW(TAG, "Unsupported relay URL: %s", relay_url);
26 return ESP_ERR_INVALID_ARG;
27 }
28
29 const char *url_start = relay_url + 6;
30 const char *path_ptr = strchr(url_start, '/');
31 if (path_ptr) {
32 size_t host_len = path_ptr - url_start;
33 if (host_len >= sizeof(host)) host_len = sizeof(host) - 1;
34 memcpy(host, url_start, host_len);
35 host[host_len] = '\0';
36 strncpy(path, path_ptr, sizeof(path) - 1);
37 } else {
38 strncpy(host, url_start, sizeof(host) - 1);
39 }
40
41 char *colon = strchr(host, ':');
42 if (colon) {
43 *colon = '\0';
44 port = atoi(colon + 1);
45 }
46
47 ESP_LOGI(TAG, "Connecting to %s:%d%s", host, port, path);
48
49 esp_tls_cfg_t tls_cfg = {
50 .crt_bundle_attach = esp_crt_bundle_attach,
51 };
52
53 esp_tls_t *tls = esp_tls_init();
54 if (!tls) {
55 ESP_LOGE(TAG, "Failed to allocate TLS handle");
56 return ESP_ERR_NO_MEM;
57 }
58
59 int ret = esp_tls_conn_new_sync(host, strlen(host), port, &tls_cfg, tls);
60 if (ret < 0) {
61 ESP_LOGE(TAG, "TLS connect failed to %s", host);
62 esp_tls_conn_destroy(tls);
63 return ESP_FAIL;
64 }
65
66 char upgrade[512];
67 snprintf(upgrade, sizeof(upgrade),
68 "GET %s HTTP/1.1\r\n"
69 "Host: %s\r\n"
70 "Upgrade: websocket\r\n"
71 "Connection: Upgrade\r\n"
72 "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
73 "Sec-WebSocket-Version: 13\r\n"
74 "\r\n",
75 path, host);
76
77 int written = esp_tls_conn_write(tls, (const unsigned char *)upgrade, strlen(upgrade));
78 if (written < 0) {
79 ESP_LOGE(TAG, "Failed to send upgrade request");
80 esp_tls_conn_destroy(tls);
81 return ESP_FAIL;
82 }
83
84 char resp[1024];
85 int rlen = esp_tls_conn_read(tls, (unsigned char *)resp, sizeof(resp) - 1);
86 if (rlen <= 0 || !strstr(resp, "101")) {
87 ESP_LOGE(TAG, "WebSocket upgrade failed (read %d bytes)", rlen);
88 esp_tls_conn_destroy(tls);
89 return ESP_FAIL;
90 }
91
92 cJSON *arr = cJSON_CreateArray();
93 cJSON_AddItemToArray(arr, cJSON_CreateString("EVENT"));
94 cJSON_AddItemToArray(arr, cJSON_Parse(event_json));
95 char *msg = cJSON_PrintUnformatted(arr);
96 cJSON_Delete(arr);
97
98 size_t msg_len = strlen(msg);
99 uint8_t ws_header[10];
100 int header_len = 0;
101 ws_header[0] = 0x81;
102 if (msg_len <= 125) {
103 ws_header[1] = (uint8_t)msg_len;
104 header_len = 2;
105 } else if (msg_len <= 65535) {
106 ws_header[1] = 126;
107 ws_header[2] = (uint8_t)((msg_len >> 8) & 0xff);
108 ws_header[3] = (uint8_t)(msg_len & 0xff);
109 header_len = 4;
110 } else {
111 ws_header[1] = 127;
112 for (int i = 0; i < 8; i++)
113 ws_header[2 + i] = (uint8_t)((msg_len >> (56 - i * 8)) & 0xff);
114 header_len = 10;
115 }
116
117 esp_tls_conn_write(tls, ws_header, header_len);
118 esp_tls_conn_write(tls, (const unsigned char *)msg, msg_len);
119
120 free(msg);
121
122 uint8_t resp_buf[256];
123 int resp_len = esp_tls_conn_read(tls, resp_buf, sizeof(resp_buf) - 1);
124 if (resp_len > 0) {
125 resp_buf[resp_len] = '\0';
126 int mask_len = (resp_buf[1] & 0x80) ? 4 : 0;
127 int payload_offset = 2 + mask_len;
128 if (resp_len > payload_offset) {
129 ESP_LOGI(TAG, "Relay response: %.*s", resp_len - payload_offset,
130 (char *)resp_buf + payload_offset);
131 }
132 }
133
134 uint8_t close_frame[2] = {0x88, 0x00};
135 esp_tls_conn_write(tls, close_frame, 2);
136 esp_tls_conn_destroy(tls);
137
138 ESP_LOGI(TAG, "Published to %s", host);
139 return ESP_OK;
140}
141
142static char *build_wifistr_event(void)
143{
144 const tollgate_identity_t *id = identity_get();
145 if (!id || !id->initialized) {
146 ESP_LOGE(TAG, "Identity not initialized");
147 return NULL;
148 }
149
150 const tollgate_config_t *cfg = tollgate_config_get();
151
152 cJSON *tags = cJSON_CreateArray();
153
154 cJSON *d_tag = cJSON_CreateArray();
155 cJSON_AddItemToArray(d_tag, cJSON_CreateString("d"));
156 cJSON_AddItemToArray(d_tag, cJSON_CreateString(id->npub_hex));
157 cJSON_AddItemToArray(tags, d_tag);
158
159 cJSON *ssid_tag = cJSON_CreateArray();
160 cJSON_AddItemToArray(ssid_tag, cJSON_CreateString("ssid"));
161 cJSON_AddItemToArray(ssid_tag, cJSON_CreateString(id->ap_ssid));
162 cJSON_AddItemToArray(tags, ssid_tag);
163
164 cJSON *h_tag = cJSON_CreateArray();
165 cJSON_AddItemToArray(h_tag, cJSON_CreateString("h"));
166 cJSON_AddItemToArray(h_tag, cJSON_CreateString("cashu-testnut"));
167 cJSON_AddItemToArray(tags, h_tag);
168
169 cJSON *sec_tag = cJSON_CreateArray();
170 cJSON_AddItemToArray(sec_tag, cJSON_CreateString("security"));
171 cJSON_AddItemToArray(sec_tag, cJSON_CreateString("open"));
172 cJSON_AddItemToArray(tags, sec_tag);
173
174 cJSON *g_tag = cJSON_CreateArray();
175 cJSON_AddItemToArray(g_tag, cJSON_CreateString("g"));
176 cJSON_AddItemToArray(g_tag, cJSON_CreateString(cfg->nostr_geohash));
177 cJSON_AddItemToArray(tags, g_tag);
178
179 cJSON *c_tag = cJSON_CreateArray();
180 cJSON_AddItemToArray(c_tag, cJSON_CreateString("c"));
181 cJSON_AddItemToArray(c_tag, cJSON_CreateString("cashu"));
182 cJSON_AddItemToArray(tags, c_tag);
183
184 char content[512];
185 snprintf(content, sizeof(content),
186 "TollGate WiFi hotspot: %s | Price: %d sats/%dms | Mint: %s",
187 id->ap_ssid, cfg->price_per_step, cfg->step_size_ms, cfg->mint_url);
188
189 char *tags_str = cJSON_PrintUnformatted(tags);
190 cJSON_Delete(tags);
191
192 nostr_event_t event;
193 nostr_event_init(&event, id->npub_hex, 38787, tags_str, content);
194 nostr_event_sign(&event, id->nsec);
195 free(tags_str);
196
197 char *event_json = malloc(2048);
198 if (!event_json) return NULL;
199
200 esp_err_t ret = nostr_event_to_json(&event, event_json, 2048);
201 if (ret != ESP_OK) {
202 free(event_json);
203 return NULL;
204 }
205
206 return event_json;
207}
208
209esp_err_t wifistr_publish(void)
210{
211 char *event_json = build_wifistr_event();
212 if (!event_json) {
213 ESP_LOGE(TAG, "Failed to build wifistr event");
214 return ESP_FAIL;
215 }
216
217 ESP_LOGI(TAG, "Wifistr event: %s", event_json);
218
219 const tollgate_config_t *cfg = tollgate_config_get();
220 esp_err_t last_err = ESP_FAIL;
221
222 for (int i = 0; i < cfg->nostr_relay_count; i++) {
223 esp_err_t err = ws_send_to_relay(cfg->nostr_relays[i], event_json);
224 if (err == ESP_OK) last_err = ESP_OK;
225 vTaskDelay(pdMS_TO_TICKS(500));
226 }
227
228 free(event_json);
229 return last_err;
230}
231
232static void publish_task(void *pvParameters)
233{
234 wifistr_publish();
235 vTaskDelete(NULL);
236}
237
238static void timer_callback(TimerHandle_t timer)
239{
240 xTaskCreate(publish_task, "wifistr_pub", 16384, NULL, 3, NULL);
241}
242
243void wifistr_start_periodic(int interval_s)
244{
245 if (s_publish_timer) return;
246 s_publish_timer = xTimerCreate("wifistr", pdMS_TO_TICKS(interval_s * 1000),
247 pdTRUE, NULL, timer_callback);
248 if (s_publish_timer) {
249 xTimerStart(s_publish_timer, 0);
250 ESP_LOGI(TAG, "Periodic publish every %ds", interval_s);
251 }
252}
diff --git a/main/wifistr.h b/main/wifistr.h
new file mode 100644
index 0000000..843b6be
--- /dev/null
+++ b/main/wifistr.h
@@ -0,0 +1,10 @@
1#ifndef WIFISTR_H
2#define WIFISTR_H
3
4#include "esp_err.h"
5
6esp_err_t wifistr_publish(void);
7
8void wifistr_start_periodic(int interval_s);
9
10#endif
diff --git a/nucula_src b/nucula_src
new file mode 160000
Subproject 0ecd83c404455b0885b35e15eb1d7f5447bc489
diff --git a/sdkconfig b/sdkconfig
index 0b024cd..53590c2 100644
--- a/sdkconfig
+++ b/sdkconfig
@@ -961,8 +961,8 @@ CONFIG_ESP32S3_UNIVERSAL_MAC_ADDRESSES=4
961# 961#
962# Sleep Config 962# Sleep Config
963# 963#
964# CONFIG_ESP_SLEEP_POWER_DOWN_FLASH is not set
965CONFIG_ESP_SLEEP_FLASH_LEAKAGE_WORKAROUND=y 964CONFIG_ESP_SLEEP_FLASH_LEAKAGE_WORKAROUND=y
965CONFIG_ESP_SLEEP_PSRAM_LEAKAGE_WORKAROUND=y
966CONFIG_ESP_SLEEP_MSPI_NEED_ALL_IO_PU=y 966CONFIG_ESP_SLEEP_MSPI_NEED_ALL_IO_PU=y
967CONFIG_ESP_SLEEP_RTC_BUS_ISO_WORKAROUND=y 967CONFIG_ESP_SLEEP_RTC_BUS_ISO_WORKAROUND=y
968CONFIG_ESP_SLEEP_GPIO_RESET_WORKAROUND=y 968CONFIG_ESP_SLEEP_GPIO_RESET_WORKAROUND=y
@@ -1071,7 +1071,36 @@ CONFIG_PM_RESTORE_CACHE_TAGMEM_AFTER_LIGHT_SLEEP=y
1071# 1071#
1072# ESP PSRAM 1072# ESP PSRAM
1073# 1073#
1074# CONFIG_SPIRAM is not set 1074CONFIG_SPIRAM=y
1075
1076#
1077# SPI RAM config
1078#
1079# CONFIG_SPIRAM_MODE_QUAD is not set
1080CONFIG_SPIRAM_MODE_OCT=y
1081CONFIG_SPIRAM_TYPE_AUTO=y
1082# CONFIG_SPIRAM_TYPE_ESPPSRAM64 is not set
1083CONFIG_SPIRAM_CLK_IO=30
1084CONFIG_SPIRAM_CS_IO=26
1085# CONFIG_SPIRAM_XIP_FROM_PSRAM is not set
1086# CONFIG_SPIRAM_FETCH_INSTRUCTIONS is not set
1087# CONFIG_SPIRAM_RODATA is not set
1088CONFIG_SPIRAM_SPEED_80M=y
1089# CONFIG_SPIRAM_SPEED_40M is not set
1090CONFIG_SPIRAM_SPEED=80
1091# CONFIG_SPIRAM_ECC_ENABLE is not set
1092CONFIG_SPIRAM_BOOT_INIT=y
1093# CONFIG_SPIRAM_IGNORE_NOTFOUND is not set
1094# CONFIG_SPIRAM_USE_MEMMAP is not set
1095# CONFIG_SPIRAM_USE_CAPS_ALLOC is not set
1096CONFIG_SPIRAM_USE_MALLOC=y
1097CONFIG_SPIRAM_MEMTEST=y
1098CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=16384
1099# CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP is not set
1100CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=32768
1101# CONFIG_SPIRAM_ALLOW_BSS_SEG_EXTERNAL_MEMORY is not set
1102# CONFIG_SPIRAM_ALLOW_NOINIT_SEG_EXTERNAL_MEMORY is not set
1103# end of SPI RAM config
1075# end of ESP PSRAM 1104# end of ESP PSRAM
1076 1105
1077# 1106#
@@ -1331,6 +1360,7 @@ CONFIG_FATFS_CODEPAGE=437
1331CONFIG_FATFS_FS_LOCK=0 1360CONFIG_FATFS_FS_LOCK=0
1332CONFIG_FATFS_TIMEOUT_MS=10000 1361CONFIG_FATFS_TIMEOUT_MS=10000
1333CONFIG_FATFS_PER_FILE_CACHE=y 1362CONFIG_FATFS_PER_FILE_CACHE=y
1363CONFIG_FATFS_ALLOC_PREFER_EXTRAM=y
1334# CONFIG_FATFS_USE_FASTSEEK is not set 1364# CONFIG_FATFS_USE_FASTSEEK is not set
1335CONFIG_FATFS_USE_STRFUNC_NONE=y 1365CONFIG_FATFS_USE_STRFUNC_NONE=y
1336# CONFIG_FATFS_USE_STRFUNC_WITHOUT_CRLF_CONV is not set 1366# CONFIG_FATFS_USE_STRFUNC_WITHOUT_CRLF_CONV is not set
@@ -1400,6 +1430,7 @@ CONFIG_FREERTOS_SYSTICK_USES_SYSTIMER=y
1400# 1430#
1401# Extra 1431# Extra
1402# 1432#
1433CONFIG_FREERTOS_TASK_CREATE_ALLOW_EXT_MEM=y
1403# end of Extra 1434# end of Extra
1404 1435
1405CONFIG_FREERTOS_PORT=y 1436CONFIG_FREERTOS_PORT=y
@@ -1645,6 +1676,7 @@ CONFIG_LWIP_HOOK_DNS_EXT_RESOLVE_NONE=y
1645# mbedTLS 1676# mbedTLS
1646# 1677#
1647CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC=y 1678CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC=y
1679# CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC is not set
1648# CONFIG_MBEDTLS_DEFAULT_MEM_ALLOC is not set 1680# CONFIG_MBEDTLS_DEFAULT_MEM_ALLOC is not set
1649# CONFIG_MBEDTLS_CUSTOM_MEM_ALLOC is not set 1681# CONFIG_MBEDTLS_CUSTOM_MEM_ALLOC is not set
1650CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN=y 1682CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN=y
@@ -1809,12 +1841,15 @@ CONFIG_NEWLIB_TIME_SYSCALL_USE_RTC_HRT=y
1809# CONFIG_NEWLIB_TIME_SYSCALL_USE_NONE is not set 1841# CONFIG_NEWLIB_TIME_SYSCALL_USE_NONE is not set
1810# end of Newlib 1842# end of Newlib
1811 1843
1844CONFIG_STDATOMIC_S32C1I_SPIRAM_WORKAROUND=y
1845
1812# 1846#
1813# NVS 1847# NVS
1814# 1848#
1815# CONFIG_NVS_ENCRYPTION is not set 1849# CONFIG_NVS_ENCRYPTION is not set
1816# CONFIG_NVS_ASSERT_ERROR_CHECK is not set 1850# CONFIG_NVS_ASSERT_ERROR_CHECK is not set
1817# CONFIG_NVS_LEGACY_DUP_KEYS_COMPATIBILITY is not set 1851# CONFIG_NVS_LEGACY_DUP_KEYS_COMPATIBILITY is not set
1852# CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM is not set
1818# end of NVS 1853# end of NVS
1819 1854
1820# 1855#
@@ -2114,7 +2149,6 @@ CONFIG_POST_EVENTS_FROM_IRAM_ISR=y
2114CONFIG_GDBSTUB_SUPPORT_TASKS=y 2149CONFIG_GDBSTUB_SUPPORT_TASKS=y
2115CONFIG_GDBSTUB_MAX_TASKS=32 2150CONFIG_GDBSTUB_MAX_TASKS=32
2116# CONFIG_OTA_ALLOW_HTTP is not set 2151# CONFIG_OTA_ALLOW_HTTP is not set
2117# CONFIG_ESP_SYSTEM_PD_FLASH is not set
2118CONFIG_ESP32S3_DEEP_SLEEP_WAKEUP_DELAY=2000 2152CONFIG_ESP32S3_DEEP_SLEEP_WAKEUP_DELAY=2000
2119CONFIG_ESP_SLEEP_DEEP_SLEEP_WAKEUP_DELAY=2000 2153CONFIG_ESP_SLEEP_DEEP_SLEEP_WAKEUP_DELAY=2000
2120CONFIG_ESP32S3_RTC_CLK_SRC_INT_RC=y 2154CONFIG_ESP32S3_RTC_CLK_SRC_INT_RC=y
@@ -2130,7 +2164,9 @@ CONFIG_ESP32_PHY_MAX_TX_POWER=20
2130# CONFIG_ESP32_REDUCE_PHY_TX_POWER is not set 2164# CONFIG_ESP32_REDUCE_PHY_TX_POWER is not set
2131CONFIG_ESP_SYSTEM_PM_POWER_DOWN_CPU=y 2165CONFIG_ESP_SYSTEM_PM_POWER_DOWN_CPU=y
2132CONFIG_PM_POWER_DOWN_TAGMEM_IN_LIGHT_SLEEP=y 2166CONFIG_PM_POWER_DOWN_TAGMEM_IN_LIGHT_SLEEP=y
2133# CONFIG_ESP32S3_SPIRAM_SUPPORT is not set 2167CONFIG_ESP32S3_SPIRAM_SUPPORT=y
2168CONFIG_DEFAULT_PSRAM_CLK_IO=30
2169CONFIG_DEFAULT_PSRAM_CS_IO=26
2134# CONFIG_ESP32S3_DEFAULT_CPU_FREQ_80 is not set 2170# CONFIG_ESP32S3_DEFAULT_CPU_FREQ_80 is not set
2135CONFIG_ESP32S3_DEFAULT_CPU_FREQ_160=y 2171CONFIG_ESP32S3_DEFAULT_CPU_FREQ_160=y
2136# CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240 is not set 2172# CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240 is not set
@@ -2216,6 +2252,7 @@ CONFIG_TIMER_TASK_PRIORITY=1
2216CONFIG_TIMER_TASK_STACK_DEPTH=2048 2252CONFIG_TIMER_TASK_STACK_DEPTH=2048
2217CONFIG_TIMER_QUEUE_LENGTH=10 2253CONFIG_TIMER_QUEUE_LENGTH=10
2218# CONFIG_ENABLE_STATIC_TASK_CLEAN_UP_HOOK is not set 2254# CONFIG_ENABLE_STATIC_TASK_CLEAN_UP_HOOK is not set
2255CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY=y
2219# CONFIG_HAL_ASSERTION_SILIENT is not set 2256# CONFIG_HAL_ASSERTION_SILIENT is not set
2220# CONFIG_L2_TO_L3_COPY is not set 2257# CONFIG_L2_TO_L3_COPY is not set
2221CONFIG_ESP_GRATUITOUS_ARP=y 2258CONFIG_ESP_GRATUITOUS_ARP=y