From 4c47ae188b288e7d24bd9566ab3e6a6805d9484f Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 16 May 2026 23:55:05 +0530 Subject: Phase 3: Nostr identity derivation + wifistr service discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitmodules | 3 + CHECKLIST.md | 98 +++-- PLAN.md | 311 ++++++++++++++-- components/nucula_lib/CMakeLists.txt | 17 + components/nucula_lib/nucula_wallet.cpp | 199 ++++++++++ components/nucula_lib/nucula_wallet.h | 31 ++ components/secp256k1 | 1 + main/CMakeLists.txt | 9 +- main/config.c | 88 +++-- main/config.h | 14 +- main/geohash.c | 48 +++ main/geohash.h | 8 + main/identity.c | 124 +++++++ main/identity.h | 29 ++ main/nostr_event.c | 112 ++++++ main/nostr_event.h | 25 ++ main/tollgate_api.c | 63 +--- main/tollgate_main.c | 31 +- main/wallet.c | 639 -------------------------------- main/wallet.h | 53 --- main/wallet_persist.c | 147 -------- main/wallet_persist.h | 9 - main/wifistr.c | 252 +++++++++++++ main/wifistr.h | 10 + nucula_src | 1 + sdkconfig | 45 ++- 26 files changed, 1377 insertions(+), 990 deletions(-) create mode 100644 .gitmodules create mode 100644 components/nucula_lib/CMakeLists.txt create mode 100644 components/nucula_lib/nucula_wallet.cpp create mode 100644 components/nucula_lib/nucula_wallet.h create mode 120000 components/secp256k1 create mode 100644 main/geohash.c create mode 100644 main/geohash.h create mode 100644 main/identity.c create mode 100644 main/identity.h create mode 100644 main/nostr_event.c create mode 100644 main/nostr_event.h delete mode 100644 main/wallet.c delete mode 100644 main/wallet.h delete mode 100644 main/wallet_persist.c delete mode 100644 main/wallet_persist.h create mode 100644 main/wifistr.c create mode 100644 main/wifistr.h create mode 160000 nucula_src diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..e4b0dbf --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "nucula_src"] + path = nucula_src + 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 @@ - [x] DNS query logging for unauthenticated clients - [x] Verified working with GrapheneOS phone (commit `236b61d`) -## Phase 3: On-Device Wallet + ESP32-to-ESP32 Payments — IN PROGRESS -### Wallet Module (wallet.c/h) -- [x] `hash_to_curve()` — SHA256 try-and-increment with Cashu domain separator -- [x] `point_add()`, `scalar_mul()` — mbedTLS secp256k1 primitives -- [x] `random_scalar()` — ESP32 hardware RNG mod curve order -- [x] Proof storage: `wallet_add_proofs()`, `wallet_remove_proof()`, `wallet_clear()` -- [x] Keyset fetching: `wallet_fetch_keysets()` — GET /v1/keys from mint -- [x] Full swap: `wallet_swap_proofs()` — generates blinded messages, POST /v1/swap, unblinds signatures -- [x] Token creation: `wallet_create_token()` — encode proofs as `cashuA` token -- [x] Wallet API endpoints: `GET /wallet`, `POST /wallet/swap`, `POST /wallet/send` -- [x] Payment flow integration: received proofs added to wallet after session creation -- [x] mbedTLS 3.x compatibility (no direct point field access, no point_negate) -- [x] Unblinding: `C = C_ + (order - r) * G` approach -- [x] Clean build (0 warnings, 0 errors) - -### Wallet Persistence (wallet_persist.c/h) -- [ ] Implement `wallet_persist_save()` — serialize wallet to `/spiffs/wallet.json` -- [ ] Implement `wallet_persist_load()` — deserialize wallet from `/spiffs/wallet.json` on boot -- [ ] Add `persist_threshold_sats` to config.json and config struct -- [ ] Threshold logic: only persist when `balance >= persist_threshold_sats` -- [ ] Wire `wallet_persist_save()` into wallet mutations (add_proofs, swap, create_token) -- [ ] Wire `wallet_persist_load()` into `wallet_init()` -- [ ] Build and verify clean compile +## Phase 3: On-Device Wallet + Nostr Identity + Wifistr — IN PROGRESS +### nucula Wallet Integration +- [x] Add nucula as git submodule (`nucula_src/`) +- [x] Create `components/secp256k1/` (symlink to nucula's libsecp256k1) +- [x] Create `components/nucula_lib/` (C++ bridge + C API) +- [x] C bridge: `nucula_wallet.h` (init, receive, send, swap_all, balance, proofs_json) +- [x] All wallet operations tested on Board A: pay, swap, send, persistence + +### Nostr Identity Derivation (identity.c/h) +- [x] Create `identity.h` — API: `identity_init(nsec_hex)`, derived value accessors +- [x] Create `identity.c` — HMAC-SHA512 derivation via mbedtls, npub via secp256k1 +- [x] Derive STA MAC: `tollgate_derive(nsec, "sta-mac", 0)` → 6 bytes, locally administered +- [x] Derive AP MAC: `tollgate_derive(nsec, "ap-mac", 0)` → 6 bytes, locally administered +- [x] Derive SSID: `"TollGate-" + hex(AP_MAC[3:6])` +- [x] Derive AP IP: hash-based from AP MAC bytes +- [x] Compute npub: secp256k1 x-only pubkey from nsec +- [x] Set MACs via `esp_wifi_set_mac()` in boot sequence + +### Nostr Event Signing (nostr_event.c/h) +- [x] Create `nostr_event.h` — NIP-01 event struct + sign/serialize API +- [x] Create `nostr_event.c` — canonical JSON, SHA-256 ID, Schnorr signature +- [x] Uses `secp256k1_schnorrsig_sign32()` for BIP-340 signatures + +### Geohash Encoding (geohash.c/h) +- [x] Create `geohash.h` — `geohash_encode(lat, lon, precision, out)` +- [x] Create `geohash.c` — standard base-32 geohash encoding + +### Wifistr Service Discovery (wifistr.c/h) +- [x] Create `wifistr.h` — `wifistr_publish()` API +- [x] Create `wifistr.c` — kind 38787 event builder + WebSocket relay publish +- [x] Build event with tags: d, ssid, h, security, g, c +- [x] WebSocket client: raw TCP + TLS (esp_tls.h) + HTTP Upgrade +- [x] Publish on boot + periodic timer (6h default) + +### Config Changes (config.c/h) +- [x] Add to struct: nsec, npub, nostr_geohash, nostr_relays, nostr_publish_interval_s, sta_mac, ap_mac +- [x] Remove from JSON parsing: ap_ssid, ap_ip (now derived from nsec) +- [x] Keep: ap_password, ap_channel, ap_max_conn (hardcoded defaults) +- [x] Update default config.json template with nsec and Nostr fields + +### Boot Sequence Changes (tollgate_main.c) +- [x] Call `identity_init(nsec)` after config load, before WiFi init +- [x] Set STA/AP MAC via `esp_wifi_set_mac()` after `esp_wifi_init()`, before `esp_wifi_start()` +- [x] Remove old `tollgate_config_derive_unique()` call +- [x] Use derived SSID/IP in AP configuration +- [x] Start wifistr publish task after services start + +### Build System +- [x] Add identity.c, nostr_event.c, geohash.c, wifistr.c to CMakeLists.txt SRCS +- [x] Add `secp256k1` to REQUIRES (for identity.c and nostr_event.c) +- [x] Clean build (0 errors, 0 warnings) ### Hardware Testing -- [ ] Flash Board A, verify wallet boot (keyset fetch succeeds) -- [ ] Pay Board A with Cashu token, verify proofs stored (GET /wallet) -- [ ] Test POST /wallet/swap on Board A -- [ ] Test POST /wallet/send on Board A, verify token is valid -- [ ] Verify persistence survives reboot on Board A -- [ ] Flash Board B with TollGate firmware -- [ ] Load Board B with balance (pay it a token) -- [ ] Board B creates send token via POST /wallet/send -- [ ] Cross-board payment: Board B token → Board A (laptop relay) +- [x] Flash Board A, verify wallet boot (keyset fetch succeeds) +- [x] Pay Board A with Cashu token, verify proofs stored (GET /wallet) +- [x] Test POST /wallet/swap on Board A +- [x] Test POST /wallet/send on Board A, verify token is valid +- [x] Flash Board A with new identity derivation, verify derived SSID/MAC/IP +- [x] Verify captive portal works with new SSID/IP +- [x] Verify payment flow still works with identity-derived config +- [x] Verify wifistr event published to relay (damus + nos.lol) +- [ ] Flash Board B with new firmware (different nsec) +- [ ] Cross-board payment: Board B token → Board A - [ ] Verify both boards show correct balances after cross-board payment ### Tests 25-27 (deferred from Phase 2, need Board B) @@ -131,8 +160,9 @@ ## Reminders - Do NOT ask for instructions — proceed independently, skip blocked items, work on unblocked ones -- Board A: `/dev/ttyACM0`, MAC `94:a9:90:2e:37:7c`, SSID `TollGate-377C`, AP IP `10.55.85.1` -- Board B: `/dev/ttyACM1`, MAC `fc:01:2c:c5:50:50` +- Board A: `/dev/ttyACM0`, factory MAC `94:a9:90:2e:37:7c` +- Board B: `/dev/ttyACM1`, factory MAC `fc:01:2c:c5:50:50` +- Identity is now derived from nsec in config.json (SSID, IP, MAC all deterministic) - testnut.cashu.space auto-pays invoices: `cashu -h https://testnut.cashu.space invoice ` - Token generation: `cashu -h https://testnut.cashu.space send --legacy 2>&1 | grep '^cashuA' | head -1` - sudo password: `c03rad0r123` 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 ## Architecture Decision: C/C++ (ESP-IDF) - Existing working captive portal is in C (ESP-IDF) -- On-device Cashu wallet uses mbedTLS secp256k1 (hardware RNG, software ECP) +- On-device Cashu wallet uses nucula library (libsecp256k1) - ESP-IDF is already installed at `~/esp/esp-idf` - No Rust/ESP32 toolchain installed +- Nostr keypair as root identity — derive AP MAC, SSID, IP from nsec ## Technology Stack | Layer | Technology | |-------|-----------| | Framework | ESP-IDF v5.4.1 (C/C++) | -| Cashu wallet | Custom mbedTLS secp256k1 wallet (hash_to_curve, blind signing, swap, send) | +| Identity | Nostr nsec → HMAC-SHA512 derivation → MAC/SSID/IP; Schnorr signing for Nostr events | +| Cashu wallet | nucula library (libsecp256k1, NVS persistence) | +| Service discovery | wifistr (Nostr kind 38787) via WebSocket to relays | | HTTP server | `esp_http_server` (port 80 captive portal, port 2121 TollGate API + wallet) | | DNS | Custom UDP task (hijack unauthenticated, forward authenticated) | | NAT | lwIP NAPT | -| Persistence | SPIFFS (960K partition) with threshold-based write protection | +| Persistence | NVS (nucula built-in) for wallet; SPIFFS for config.json | | Testing | Playwright + curl + nutshell CLI | | Build | Makefile | @@ -82,20 +85,52 @@ Build a TollGate firmware for two ESP32 devices, following the [TollGate protoco **Captive Portal Detection:** DoT reject server on port 853, NXDOMAIN for non-A queries, 302 redirects for captive URIs. Verified working on GrapheneOS (commit `236b61d`). -### Phase 3: On-Device Wallet + ESP32-to-ESP32 Payments — IN PROGRESS - -**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. - -#### Wallet Architecture - -- **Crypto**: mbedTLS secp256k1 (software ECP, hardware RNG via `esp_fill_random`) -- **Blind signing**: `hash_to_curve()` (SHA256 try-and-increment), `scalar_mul()`, `point_add()` -- **Unblinding**: `C = C_ + (order - r) * G` — avoids needing mint's public key K, avoids point negation -- **Proof storage**: In-memory array (50 max), persisted to SPIFFS JSON -- **Persistence**: SPIFFS `/spiffs/wallet.json`, only written when `balance >= persist_threshold_sats` -- **Keyset fetch**: GET /v1/keys from mint on boot -- **Swap**: POST /v1/swap — reissues proofs with new secrets -- **Token creation**: Encode proofs as `cashuA` base64url token +### Phase 3: On-Device Wallet + Nostr Identity + Wifistr — IN PROGRESS + +**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). + +#### Wallet Architecture — nucula Integration + +**Decision: Use nucula as a git submodule instead of custom mbedTLS wallet.** + +Why nucula over our custom mbedTLS wallet: +- **libsecp256k1** vs mbedTLS ECP: purpose-built C library with precomputed tables, ~10x less stack usage, no stack overflow +- **Production-quality**: NUT-00 through NUT-13, DLEQ verification, P2PK, deterministic secrets (BIP-39) +- **No maintenance burden**: upstream at `zeugmaster/nucula`, pull updates via `git submodule update` +- **NVS persistence**: more reliable than SPIFFS, no wear-leveling concerns + +Integration structure: +``` +esp32-tollgate/ +├── components/ +│ ├── nucula_src/ # git submodule → zeugmaster/nucula +│ ├── secp256k1/ # copied from nucula_src/components/secp256k1/ +│ └── nucula_lib/ # wrapper component +│ ├── CMakeLists.txt # compiles nucula sources from ../nucula_src/main/ +│ ├── nucula_wallet.h # C API for TollGate +│ └── nucula_wallet.cpp # C++ bridge → nucula::Wallet +├── main/ +│ ├── wallet.c # REMOVED +│ ├── wallet_persist.c # REMOVED +│ ├── cashu.c # simplified (token decode delegates to nucula) +│ ├── tollgate_api.c # updated to use nucula_wallet.h +``` + +Files compiled from nucula (via `../nucula_src/main/`): +- `crypto.c` — hash_to_curve, blind_message, unblind, DLEQ verification +- `wallet.cpp` — full Cashu wallet (swap, receive, send, mint, melt) +- `cashu_json.cpp` — JSON serialization (cJSON-based) +- `nut10.cpp` — NUT-10 structured secret parsing +- `hex.c` — hex encode/decode +- `http.c` — HTTP client wrapper (uses esp_http_client) + +NOT compiled (TollGate doesn't need them): +- `nucula.cpp` — nucula's own app_main +- `cashu_cbor.cpp` — CBOR/V4 token support (we only use V3/cashuA) +- `console.cpp`, `display.cpp`, `nfc.cpp`, `ndef.cpp`, `keypad.c` — hardware UI +- `bip39.c` — mnemonic generation (we use random secrets) +- `wifi.c` — nucula's own WiFi manager +- `crypto_test.c` — test code #### Wallet Endpoints (on :2121) @@ -117,6 +152,124 @@ Config parameter `persist_threshold_sats` (default: 1) controls when wallet stat - Rationale: flash has finite write cycles (~100K erase per sector); only persist when e-cash value justifies the wear cost - SPIFFS wear-leveling spreads writes across the 960K partition +#### C API Bridge (`nucula_wallet.h`) + +The TollGate firmware is C; nucula is C++. A thin C bridge exposes the wallet operations needed: + +```c +// Initialize wallet with secp256k1 context and mint URL +esp_err_t nucula_wallet_init(const char *mint_url); + +// Receive a cashuA token string into wallet (swap + store proofs) +esp_err_t nucula_wallet_receive(const char *token_str); + +// Create a cashuA token for the given amount +esp_err_t nucula_wallet_send(uint64_t amount_sat, char *token_out, size_t token_out_size); + +// Get current balance in sats +uint64_t nucula_wallet_balance(void); + +// Get proof count +int nucula_wallet_proof_count(void); + +// Get JSON array of proofs (for /wallet endpoint) +char *nucula_wallet_proofs_json(void); + +// Swap all proofs for fresh ones +esp_err_t nucula_wallet_swap_all(void); + +// Print wallet status to log +void nucula_wallet_print_status(void); +``` + +#### Persistence + +nucula uses NVS (Non-Volatile Storage) for persistence — proofs stored as JSON blobs in flash, keysets stored individually. This is more reliable than SPIFFS: +- No filesystem overhead +- Atomic writes via NVS key-value API +- Wear leveling handled by NVS internally +- No `persist_threshold_sats` needed — NVS handles flash wear automatically + +#### Nostr Identity Derivation + +**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). + +**Derivation function: `tollgate_derive()`** + +Simplified HMAC-SHA512 derivation (not full BIP85 — ~50 lines, same security model): +``` +tollgate_derive(nsec_bytes, label, index) → bytes + HMAC-SHA512(key=nsec_bytes, msg=label || uint32_le(index)) + truncate output to needed length +``` + +**Derived values:** + +| Value | Derivation | Output | +|-------|-----------|--------| +| npub | `secp256k1_ec_pubkey_create(nsec)` → x-only pubkey | 32 bytes hex | +| STA MAC | `tollgate_derive(nsec, "sta-mac", 0)` | 6 bytes, `byte[0] \|= 0x02` | +| AP MAC | `tollgate_derive(nsec, "ap-mac", 0)` | 6 bytes, `byte[0] \|= 0x02` | +| SSID | `"TollGate-" + hex(AP_MAC[3:6])` | last 3 bytes = 6 hex chars | +| AP IP | `10.(AP_MAC[3]).((AP_MAC[4]^AP_MAC[5])%200+10).1` | hash-based from AP MAC | + +**Implementation: `identity.c/h`** + +- Uses `mbedtls/md.h` for HMAC-SHA512 (already linked) +- Uses `secp256k1.h` + `secp256k1_extrakeys.h` from the secp256k1 component +- Creates its own `secp256k1_context` (SIGN only) — destroyed after init +- `identity_init(nsec_hex)` called before WiFi start in `app_main()` +- Sets derived MACs via `esp_wifi_set_mac(WIFI_IF_STA/AP, mac)` after `esp_wifi_init()` + +**Boot sequence:** +``` +nvs_flash_init() + → tollgate_config_init() // loads config.json with nsec + → identity_init(nsec) // derives npub, MACs, SSID, IP + → esp_netif_init() + → esp_event_loop_create_default() + → wifi_init_sta() + → wifi_create_ap_netif() // uses derived AP IP + → esp_wifi_init(&cfg) + → esp_wifi_set_mac(STA/AP) // sets derived MACs + → wifi_configure_ap() // uses derived SSID + → esp_wifi_start() +``` + +**Config.json format (new):** +```json +{ + "nsec": "hex_64_chars", + "wifi_networks": [{"ssid":"...", "password":"..."}], + "ap_password": "", + "mint_url": "https://testnut.cashu.space", + "price_per_step": 21, + "step_size_ms": 60000, + "nostr_geohash": "u281w0dfz", + "nostr_relays": ["wss://relay.damus.io", "wss://nos.lol"], + "nostr_publish_interval_s": 21600 +} +``` + +Removed from config: `ap_ssid`, `ap_ip`, `ap_channel`, `ap_max_conn` (all derived or hardcoded). + +#### Nostr Event Signing (`nostr_event.c/h`) + +NIP-01 event serialization and Schnorr signing: +- Canonical JSON: `[0, pubkey, created_at, kind, tags, content]` +- Event ID: SHA-256 of canonical JSON serialization +- Signature: `secp256k1_schnorrsig_sign32()` (BIP-340) +- Uses own `secp256k1_context` (created on demand, destroyed after use) + +#### Wifistr Service Discovery (`wifistr.c/h`) + +Publishes TollGate node to Nostr as kind 38787 (wifistr): +- Tags: `["d", npub]`, `["ssid", ssid]`, `["h", "cashu-testnut"]`, `["security", "open"]`, `["g", geohash]`, `["c", "cashu"]` +- Content: human-readable description with price info +- Publishes on boot + periodic timer (default 6 hours) +- WebSocket client for relay communication (raw TCP + TLS + HTTP Upgrade) +- Uses `esp_tls.h` for TLS connections to `wss://` relays + #### Test Cases | # | Test | Method | Pass Criteria | Status | @@ -133,7 +286,99 @@ Config parameter `persist_threshold_sats` (default: 1) controls when wallet stat | 37 | 5 consecutive payments | Loop | All authenticated | TODO | | 38 | Stress: rapid pay/expire | Loop with short sessions | No crash/leak | TODO | -### Phase 4: ESP32-to-OpenWRT TollGate Interop — NOT STARTED +### Phase 4: Mesh Service Discovery + ESP32-to-OpenWRT Interop — NOT STARTED + +**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. + +#### 4A: Pre-Association Service Discovery via Vendor IE Beacons + +**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. + +**Solution: Vendor-Specific Information Elements in Beacon/Probe Response frames** + +ESP-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. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Layer 2 (Pre-Association) │ +│ │ +│ Gateway AP broadcasts price in every Beacon (~100ms) │ +│ Client STA scans, reads price from beacon before connect │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ │ +│ │ Gateway AP │ Beacon ──────────► │ Client STA │ │ +│ │ │ (with price IE) │ │ │ +│ │ Vendor IE: │ │ Scan result │ │ +│ │ OUI:TG │ │ includes │ │ +│ │ price/sats │ │ price data │ │ +│ │ step_ms │ └──────┬──────┘ │ +│ │ mint_url │ │ │ +│ └─────────────┘ Decision: connect? │ +│ │ │ +└──────────────────────────────────────────────┼──────────────┘ + │ + ┌────────────────▼──────────────┐ + │ Layer 3+ (Connected) │ + │ POST / with Cashu token │ + └───────────────────────────────┘ +``` + +**Beacon IE Payload Format (Vendor-Specific, Element ID 0xDD):** + +``` +┌──────────┬────────┬─────────────┬──────────────┬──────────────────┐ +│ element_id│ length │ vendor_oui │ oui_type │ payload │ +│ (0xDD) │ │ (3 bytes) │ (1 byte) │ (variable) │ +├──────────┼────────┼─────────────┼──────────────┼──────────────────┤ +│ 0xDD │ N │ "TG" │ 0x01 (price) │ See below │ +│ │ │ 0x54:0x47 │ │ │ +└──────────┴────────┴─────────────┴──────────────┴──────────────────┘ + +Price Payload (oui_type 0x01): +┌─────────────┬─────────────┬──────────────┬───────────────┬────────────┐ +│ version (1B)│ price (2B) │ step_ms (2B) │ fee_ppk (2B) │ hop_count │ +│ = 0x01 │ sat/step │ ms/step │ or 0 │ (1B) │ +├─────────────┼─────────────┼──────────────┼───────────────┼────────────┤ +│ 0x01 │ uint16_le │ uint16_le │ uint16_le │ uint8 │ +└─────────────┴─────────────┴──────────────┴───────────────┴────────────┘ +Total payload: 9 bytes (fits easily in beacon, typical budget ~200 bytes) +``` + +**Implementation:** + +**AP Side (Gateway — `beacon_price.c/h`):** +- `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` +- `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) +- Price derived from `tollgate_config_t` fields (`price_per_step`, `step_size_ms`) +- Can be called on-the-fly when market conditions change (e.g., upstream price changes) + +**STA Side (Client — `beacon_scan.c/h`):** +- `beacon_scan_prices(wifi_ap_record_t *aps, int count, tollgate_price_t *prices, int *price_count)` — given scan results, extract price IEs +- Uses `esp_wifi_set_vendor_ie_cb()` to register a callback that fires during scan +- Or parses `vendor_ie_data_t` from scan results if available in `wifi_ap_record_t` +- Returns array of `{bssid, ssid, price_sat, step_ms, fee_ppk, hop_count}` +- Client selects cheapest/upstream gateway from scan results before connecting + +**Integration with existing config:** +- OUI: `0x54, 0x47` ("TG" in ASCII) — unique to TollGate +- oui_type: `0x01` = price advertisement, `0x02` = mesh routing (future) +- `hop_count`: indicates network depth (0 = directly connected to internet, 1 = one hop away) +- Price updates are rate-limited to once per 5 seconds to avoid beacon churn + +**GL-MT3000 (OpenWrt) Compatibility:** +- OpenWrt supports vendor IEs via `hostapd_cli -i wlan0 set vendor_elements ` + `hostapd_cli -i wlan0 update_beacon` +- Client scans via `iw dev wlan0 scan` show vendor elements +- Requires stock OpenWrt 24 firmware (not GL.iNet default) for mac80211 driver access +- Same OUI/payload format ensures ESP32 ↔ OpenWrt interop + +**Key Benefits:** +- Zero connection overhead for price discovery +- Works during normal passive/active scanning (no extra frames) +- Prices update live without disconnecting clients +- Supports multi-hop mesh routing via `hop_count` +- Compatible with both ESP32 and Linux (OpenWrt) platforms + +#### 4B: ESP32-to-OpenWRT TollGate Interop **Goal:** ESP32 can pay OpenWRT TollGate using Cashu tokens. Full interoperability with existing OpenWRT-based TollGate infrastructure. @@ -141,15 +386,31 @@ Config parameter `persist_threshold_sats` (default: 1) controls when wallet stat ## Key Technical Notes -### mbedTLS 3.x Compatibility -- `mbedtls_ecp_point` is opaque — cannot access `.X`, `.Y`, `.Z` directly -- Use `mbedtls_ecp_muladd`, `mbedtls_ecp_mul`, `mbedtls_ecp_point_read/write_binary` -- No point negation needed with `C = C_ + (order - r) * G` unblinding approach +### nucula / libsecp256k1 +- nucula uses **libsecp256k1** (Bitcoin Core's C library) for all curve operations +- Stack-efficient: precomputed tables in `precomputed_ecmult.c` (compile-time), small runtime stack +- No `mbedtls_ecp_mul` → no stack overflow — runs fine on default 32K httpd task +- ESP-IDF component at `components/secp256k1/` with `ECMULT_WINDOW_SIZE=8`, `ECMULT_GEN_PREC_BITS=4` +- git submodule at `components/nucula_src/` — pull updates via `git submodule update --remote` + +### Token Format +- TollGate uses **cashuA (V3)** tokens — base64url-encoded JSON +- nucula's `deserialize_token_v3()` / `serialize_token_v3()` handle encoding +- cashuB (V4/CBOR) not needed; CBOR dependency excluded from build + +### Vendor IE Beacon (Service Discovery) +- ESP-IDF: `esp_wifi_set_vendor_ie(enable, type, idx, data)` — injects into Beacon/ProbeResp +- `esp_wifi_set_vendor_ie_cb(cb, ctx)` — receives vendor IEs during scan +- Element ID 0xDD (Vendor Specific), max ~200 bytes per IE +- Updates are in-place in RAM; next beacon carries new data (~100ms interval) +- No client disconnect or AP restart required for updates +- OUI `0x54:0x47` ("TG") registered for TollGate protocol ### Board Configuration -- Board A: `/dev/ttyACM0`, MAC `94:a9:90:2e:37:7c`, SSID `TollGate-377C`, AP IP `10.55.85.1` -- Board B: `/dev/ttyACM1`, MAC `fc:01:2c:c5:50:50`, unique SSID/IP derived from MAC -- Both boards run identical firmware, unique config derived at boot from factory MAC +- Board A: `/dev/ttyACM0`, factory MAC `94:a9:90:2e:37:7c` +- Board B: `/dev/ttyACM1`, factory MAC `fc:01:2c:c5:50:50` +- Both boards run identical firmware; unique identity derived from nsec in config.json +- SSID, AP IP, STA/AP MAC all derived from nsec via HMAC-SHA512 ### Test Mint - `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 @@ +set(NUCULA_SRC ${CMAKE_CURRENT_SOURCE_DIR}/../../nucula_src/main) + +idf_component_register( + SRCS "nucula_wallet.cpp" + "${NUCULA_SRC}/crypto.c" + "${NUCULA_SRC}/wallet.cpp" + "${NUCULA_SRC}/cashu_json.cpp" + "${NUCULA_SRC}/nut10.cpp" + "${NUCULA_SRC}/hex.c" + "${NUCULA_SRC}/http.c" + INCLUDE_DIRS "." + "${NUCULA_SRC}" + REQUIRES secp256k1 + PRIV_REQUIRES log mbedtls nvs_flash esp_http_client json +) + +target_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 @@ +#include "nucula_wallet.h" +#include "wallet.hpp" +#include "cashu_json.hpp" +#include "crypto.h" +#include "hex.h" +#include "esp_log.h" +#include "secp256k1.h" +#include "cJSON.h" +#include +#include +#include + +static const char *TAG = "nucula_wallet"; + +static secp256k1_context *s_ctx = nullptr; +static cashu::Wallet *s_wallet = nullptr; + +static std::vector &mutable_proofs() +{ + return const_cast &>(s_wallet->proofs()); +} + +esp_err_t nucula_wallet_init(const char *mint_url) +{ + if (s_wallet) return ESP_OK; + + s_ctx = secp256k1_context_create(SECP256K1_CONTEXT_SIGN | SECP256K1_CONTEXT_VERIFY); + if (!s_ctx) { + ESP_LOGE(TAG, "Failed to create secp256k1 context"); + return ESP_FAIL; + } + + s_wallet = new cashu::Wallet(std::string(mint_url), s_ctx, 0); + if (!s_wallet) { + ESP_LOGE(TAG, "Failed to create wallet"); + secp256k1_context_destroy(s_ctx); + s_ctx = nullptr; + return ESP_FAIL; + } + + s_wallet->load_from_nvs(); + + if (!s_wallet->load_keysets()) { + ESP_LOGW(TAG, "Keyset load failed (may be offline)"); + } + + ESP_LOGI(TAG, "Wallet initialized: balance=%d proofs=%d keysets=%d", + s_wallet->balance(), (int)s_wallet->proofs().size(), + (int)s_wallet->keysets().size()); + return ESP_OK; +} + +esp_err_t nucula_wallet_receive(const char *token_str) +{ + if (!s_wallet || !token_str) return ESP_FAIL; + + cashu::Token tok; + bool decoded = false; + + if (strncmp(token_str, "cashuA", 6) == 0) { + decoded = cashu::deserialize_token_v3(token_str, tok); + } + + if (!decoded) { + ESP_LOGE(TAG, "Failed to decode token"); + return ESP_FAIL; + } + + std::vector proofs_out; + if (!s_wallet->receive(tok, proofs_out)) { + ESP_LOGE(TAG, "Receive failed"); + return ESP_FAIL; + } + + int total = 0; + for (const auto &p : proofs_out) total += p.amount; + ESP_LOGI(TAG, "Received %d sat (%d proofs), new balance=%d", + total, (int)proofs_out.size(), s_wallet->balance()); + return ESP_OK; +} + +esp_err_t nucula_wallet_send(uint64_t amount_sat, char *token_out, size_t token_out_size) +{ + if (!s_wallet) return ESP_FAIL; + + int amount = (int)amount_sat; + std::vector selected, remaining; + if (!s_wallet->select_proofs(amount, selected, remaining)) { + ESP_LOGE(TAG, "Insufficient balance for %d sat", amount); + return ESP_FAIL; + } + + std::vector new_proofs, change; + if (!s_wallet->swap(selected, (int)amount_sat, new_proofs, change)) { + ESP_LOGE(TAG, "Swap for send failed"); + return ESP_FAIL; + } + + cashu::Token token; + token.mint = s_wallet->mint_url(); + token.unit = "sat"; + for (auto &p : new_proofs) token.proofs.push_back(p); + + std::string encoded = cashu::serialize_token_v3(token); + if (encoded.empty()) { + ESP_LOGE(TAG, "Token serialization failed"); + return ESP_FAIL; + } + + if (encoded.size() >= token_out_size) { + ESP_LOGE(TAG, "Token too large: %zu >= %zu", encoded.size(), token_out_size); + return ESP_FAIL; + } + + memcpy(token_out, encoded.c_str(), encoded.size() + 1); + + auto &proofs = mutable_proofs(); + proofs = remaining; + for (auto &p : change) proofs.push_back(p); + s_wallet->save_proofs(); + + ESP_LOGI(TAG, "Sent %llu sat, token=%zu bytes, remaining balance=%d", + (unsigned long long)amount_sat, encoded.size(), s_wallet->balance()); + return ESP_OK; +} + +uint64_t nucula_wallet_balance(void) +{ + if (!s_wallet) return 0; + return (uint64_t)s_wallet->balance(); +} + +int nucula_wallet_proof_count(void) +{ + if (!s_wallet) return 0; + return (int)s_wallet->proofs().size(); +} + +char *nucula_wallet_proofs_json(void) +{ + if (!s_wallet) return nullptr; + + const auto &proofs = s_wallet->proofs(); + cJSON *arr = cJSON_CreateArray(); + for (const auto &p : proofs) { + cJSON *obj = cJSON_CreateObject(); + cJSON_AddNumberToObject(obj, "amount", p.amount); + cJSON_AddStringToObject(obj, "id", p.id.c_str()); + cJSON_AddItemToArray(arr, obj); + } + char *json = cJSON_PrintUnformatted(arr); + cJSON_Delete(arr); + return json; +} + +esp_err_t nucula_wallet_swap_all(void) +{ + if (!s_wallet) return ESP_FAIL; + + auto &proofs = mutable_proofs(); + if (proofs.empty()) { + ESP_LOGW(TAG, "No proofs to swap"); + return ESP_FAIL; + } + + int old_balance = s_wallet->balance(); + + std::vector inputs = proofs; + std::vector new_proofs, change; + if (!s_wallet->swap(inputs, -1, new_proofs, change)) { + ESP_LOGE(TAG, "Swap failed"); + return ESP_FAIL; + } + + proofs.clear(); + for (auto &p : new_proofs) proofs.push_back(p); + for (auto &p : change) proofs.push_back(p); + s_wallet->save_proofs(); + + ESP_LOGI(TAG, "Swap complete: %d -> %d sat (%d proofs)", + old_balance, s_wallet->balance(), (int)proofs.size()); + return ESP_OK; +} + +void nucula_wallet_print_status(void) +{ + if (!s_wallet) { + ESP_LOGI(TAG, "Wallet not initialized"); + return; + } + ESP_LOGI(TAG, "Wallet: balance=%d proofs=%d keysets=%d", + s_wallet->balance(), (int)s_wallet->proofs().size(), + (int)s_wallet->keysets().size()); + const auto &proofs = s_wallet->proofs(); + for (size_t i = 0; i < proofs.size(); i++) { + ESP_LOGI(TAG, " [%d] amount=%d id=%s", (int)i, + proofs[i].amount, proofs[i].id.c_str()); + } +} 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 @@ +#ifndef NUCULA_WALLET_H +#define NUCULA_WALLET_H + +#include "esp_err.h" +#include + +#ifdef __cplusplus +extern "C" { +#endif + +esp_err_t nucula_wallet_init(const char *mint_url); + +esp_err_t nucula_wallet_receive(const char *token_str); + +esp_err_t nucula_wallet_send(uint64_t amount_sat, char *token_out, size_t token_out_size); + +uint64_t nucula_wallet_balance(void); + +int nucula_wallet_proof_count(void); + +char *nucula_wallet_proofs_json(void); + +esp_err_t nucula_wallet_swap_all(void); + +void nucula_wallet_print_status(void); + +#ifdef __cplusplus +} +#endif + +#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" "cashu.c" "session.c" "tollgate_api.c" - "wallet.c" - "wallet_persist.c" - INCLUDE_DIRS "." "${IDF_PATH}/components/lwip/include/apps" + "identity.c" + "nostr_event.c" + "geohash.c" + "wifistr.c" + INCLUDE_DIRS "." REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server lwip json esp_http_client mbedtls esp-tls log spiffs + nucula_lib secp256k1 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 @@ #include "config.h" +#include "identity.h" #include "esp_log.h" #include "esp_spiffs.h" #include "esp_system.h" @@ -20,6 +21,7 @@ esp_err_t tollgate_config_init(void) g_config.price_per_step = 21; g_config.step_size_ms = 60000; g_config.persist_threshold_sats = 1; + g_config.nostr_publish_interval_s = 21600; esp_vfs_spiffs_conf_t conf = { .base_path = "/spiffs", @@ -37,16 +39,17 @@ esp_err_t tollgate_config_init(void) if (!f) { ESP_LOGW(TAG, "No config.json found, generating default"); const char *default_json = "{" + "\"nsec\":\"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\"," "\"wifi_networks\":[" "{\"ssid\":\"EnterSSID-2.4GHz\",\"password\":\"c03rad0r123!\"}" "]," - "\"ap_ssid\":\"TollGate\"," "\"ap_password\":\"\"," - "\"ap_channel\":1," "\"mint_url\":\"https://testnut.cashu.space\"," - "\"lnurl_url\":\"https://redeem.cashu.me/.well-known/lnurlp/tollgate\"," "\"price_per_step\":21," - "\"step_size_ms\":60000" + "\"step_size_ms\":60000," + "\"nostr_geohash\":\"u281w0dfz\"," + "\"nostr_relays\":[\"wss://relay.damus.io\",\"wss://nos.lol\"]," + "\"nostr_publish_interval_s\":21600" "}"; f = fopen("/spiffs/config.json", "w"); if (f) { @@ -80,6 +83,15 @@ esp_err_t tollgate_config_init(void) return ESP_FAIL; } + cJSON *nsec = cJSON_GetObjectItem(root, "nsec"); + if (nsec && cJSON_IsString(nsec)) { + strncpy(g_config.nsec, nsec->valuestring, sizeof(g_config.nsec) - 1); + } else { + ESP_LOGE(TAG, "Missing 'nsec' in config.json"); + cJSON_Delete(root); + return ESP_FAIL; + } + cJSON *networks = cJSON_GetObjectItem(root, "wifi_networks"); if (networks && cJSON_IsArray(networks)) { int count = cJSON_GetArraySize(networks); @@ -96,16 +108,9 @@ esp_err_t tollgate_config_init(void) } } - cJSON *ap_ssid = cJSON_GetObjectItem(root, "ap_ssid"); - if (ap_ssid) strncpy(g_config.ap_ssid, ap_ssid->valuestring, sizeof(g_config.ap_ssid) - 1); - else strncpy(g_config.ap_ssid, "TollGate", sizeof(g_config.ap_ssid) - 1); - cJSON *ap_pass = cJSON_GetObjectItem(root, "ap_password"); if (ap_pass) strncpy(g_config.ap_password, ap_pass->valuestring, sizeof(g_config.ap_password) - 1); - cJSON *ap_ch = cJSON_GetObjectItem(root, "ap_channel"); - if (ap_ch) g_config.ap_channel = ap_ch->valueint; - cJSON *mint = cJSON_GetObjectItem(root, "mint_url"); if (mint) strncpy(g_config.mint_url, mint->valuestring, sizeof(g_config.mint_url) - 1); @@ -121,9 +126,37 @@ esp_err_t tollgate_config_init(void) cJSON *persist = cJSON_GetObjectItem(root, "persist_threshold_sats"); if (persist) g_config.persist_threshold_sats = (uint64_t)persist->valuedouble; + cJSON *geohash = cJSON_GetObjectItem(root, "nostr_geohash"); + if (geohash) strncpy(g_config.nostr_geohash, geohash->valuestring, sizeof(g_config.nostr_geohash) - 1); + else strncpy(g_config.nostr_geohash, "u281w0dfz", sizeof(g_config.nostr_geohash) - 1); + + cJSON *relays = cJSON_GetObjectItem(root, "nostr_relays"); + if (relays && cJSON_IsArray(relays)) { + int rcount = cJSON_GetArraySize(relays); + if (rcount > TOLLGATE_MAX_RELAYS) rcount = TOLLGATE_MAX_RELAYS; + for (int i = 0; i < rcount; i++) { + cJSON *r = cJSON_GetArrayItem(relays, i); + if (r && cJSON_IsString(r)) { + strncpy(g_config.nostr_relays[i], r->valuestring, sizeof(g_config.nostr_relays[i]) - 1); + g_config.nostr_relay_count++; + } + } + } + + cJSON *pub_interval = cJSON_GetObjectItem(root, "nostr_publish_interval_s"); + if (pub_interval) g_config.nostr_publish_interval_s = pub_interval->valueint; + cJSON_Delete(root); - ESP_LOGI(TAG, "Config loaded: AP='%s', %d WiFi networks, price=%d sats/%dms", - g_config.ap_ssid, g_config.network_count, g_config.price_per_step, g_config.step_size_ms); + + if (g_config.nostr_relay_count == 0) { + strncpy(g_config.nostr_relays[0], "wss://relay.damus.io", sizeof(g_config.nostr_relays[0]) - 1); + strncpy(g_config.nostr_relays[1], "wss://nos.lol", sizeof(g_config.nostr_relays[1]) - 1); + g_config.nostr_relay_count = 2; + } + + ESP_LOGI(TAG, "Config loaded: nsec=%s...%s, %d WiFi networks, price=%d sats/%dms", + g_config.nsec, g_config.nsec + 60, g_config.network_count, + g_config.price_per_step, g_config.step_size_ms); return ESP_OK; } @@ -151,22 +184,23 @@ esp_err_t tollgate_config_get_next_wifi(wifi_config_t *wifi_config) void tollgate_config_derive_unique(tollgate_config_t *cfg) { - if (cfg->unique_derived) return; - - uint8_t mac[6]; - esp_read_mac(mac, ESP_MAC_WIFI_STA); + if (cfg->identity_initialized) return; - snprintf(cfg->ap_ssid + strlen(cfg->ap_ssid), - TOLLGATE_MAX_AP_SSID_LEN - strlen(cfg->ap_ssid), - "-%02X%02X", mac[4], mac[5]); + const tollgate_identity_t *id = identity_get(); + if (!id || !id->initialized) { + ESP_LOGE(TAG, "Cannot derive unique config: identity not initialized"); + return; + } - uint8_t b5 = mac[4]; - uint8_t b6 = mac[5]; - uint8_t subnet = (b5 ^ b6) % 200 + 10; - IP4_ADDR(&cfg->ap_ip, 10, b5, subnet, 1); - snprintf(cfg->ap_ip_str, sizeof(cfg->ap_ip_str), IPSTR, IP2STR(&cfg->ap_ip)); + strncpy(cfg->ap_ssid, id->ap_ssid, sizeof(cfg->ap_ssid) - 1); + memcpy(cfg->sta_mac, id->sta_mac, 6); + memcpy(cfg->ap_mac, id->ap_mac, 6); + cfg->ap_ip = id->ap_ip; + strncpy(cfg->ap_ip_str, id->ap_ip_str, sizeof(cfg->ap_ip_str) - 1); + strncpy(cfg->npub, id->npub_hex, sizeof(cfg->npub) - 1); - cfg->unique_derived = true; + cfg->identity_initialized = true; - ESP_LOGI(TAG, "Unique config: SSID='%s', AP_IP=%s", cfg->ap_ssid, cfg->ap_ip_str); + ESP_LOGI(TAG, "Unique config derived from nsec: SSID='%s', AP_IP=%s", + cfg->ap_ssid, cfg->ap_ip_str); } 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 @@ #define TOLLGATE_MAX_MINT_URLS 3 #define TOLLGATE_MAX_AP_SSID_LEN 32 #define TOLLGATE_MAX_AP_PASS_LEN 64 +#define TOLLGATE_MAX_RELAYS 4 typedef struct { char ssid[32]; @@ -22,11 +23,17 @@ typedef struct { int current_network; int max_retry; + char nsec[65]; + char npub[65]; + char ap_ssid[TOLLGATE_MAX_AP_SSID_LEN]; char ap_password[TOLLGATE_MAX_AP_PASS_LEN]; uint8_t ap_channel; uint8_t ap_max_conn; + uint8_t sta_mac[6]; + uint8_t ap_mac[6]; + esp_ip4_addr_t ap_ip; char ap_ip_str[16]; @@ -36,7 +43,12 @@ typedef struct { int step_size_ms; uint64_t persist_threshold_sats; - bool unique_derived; + char nostr_geohash[16]; + char nostr_relays[TOLLGATE_MAX_RELAYS][128]; + int nostr_relay_count; + int nostr_publish_interval_s; + + bool identity_initialized; } tollgate_config_t; void 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 @@ +#include "geohash.h" +#include + +static const char BASE32[] = "0123456789bcdefghjkmnpqrstuvwxyz"; + +void geohash_encode(double lat, double lon, int precision, char *out) +{ + double lat_range[2] = { -90.0, 90.0 }; + double lon_range[2] = { -180.0, 180.0 }; + uint8_t hash_bytes[16]; + int bit_count = precision * 5; + int byte_count = (bit_count + 7) / 8; + memset(hash_bytes, 0, sizeof(hash_bytes)); + + for (int i = 0; i < bit_count; i++) { + int byte_idx = i / 8; + int bit_idx = 7 - (i % 8); + + if (i % 2 == 0) { + double mid = (lon_range[0] + lon_range[1]) / 2.0; + if (lon >= mid) { + hash_bytes[byte_idx] |= (1 << bit_idx); + lon_range[0] = mid; + } else { + lon_range[1] = mid; + } + } else { + double mid = (lat_range[0] + lat_range[1]) / 2.0; + if (lat >= mid) { + hash_bytes[byte_idx] |= (1 << bit_idx); + lat_range[0] = mid; + } else { + lat_range[1] = mid; + } + } + } + + for (int i = 0; i < precision; i++) { + int byte_idx = (i * 5) / 8; + int bit_offset = (i * 5) % 8; + uint16_t val = (hash_bytes[byte_idx] << 8); + if (byte_idx + 1 < (int)sizeof(hash_bytes)) + val |= hash_bytes[byte_idx + 1]; + val = (val >> (16 - 5 - bit_offset)) & 0x1F; + out[i] = BASE32[val]; + } + out[precision] = '\0'; +} 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 @@ +#ifndef GEOHASH_H +#define GEOHASH_H + +#include + +void geohash_encode(double lat, double lon, int precision, char *out); + +#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 @@ +#include "identity.h" +#include "config.h" +#include "esp_log.h" +#include "lwip/ip4_addr.h" +#include "mbedtls/md.h" +#include "secp256k1.h" +#include "secp256k1_extrakeys.h" +#include +#include +#include + +static const char *TAG = "identity"; +static tollgate_identity_t s_identity; + +static int hex_to_bytes(const char *hex, uint8_t *out, size_t out_len) +{ + if (strlen(hex) != out_len * 2) return 0; + for (size_t i = 0; i < out_len; i++) { + unsigned int byte; + if (sscanf(hex + i * 2, "%02x", &byte) != 1) return 0; + out[i] = (uint8_t)byte; + } + return 1; +} + +static void bytes_to_hex(const uint8_t *bytes, size_t len, char *hex) +{ + for (size_t i = 0; i < len; i++) + sprintf(hex + i * 2, "%02x", bytes[i]); + hex[len * 2] = '\0'; +} + +static void tollgate_derive(const uint8_t nsec[32], const char *label, + uint32_t index, uint8_t *out, size_t out_len) +{ + size_t label_len = strlen(label); + size_t msg_len = label_len + 4; + uint8_t *msg = (uint8_t *)malloc(msg_len); + memcpy(msg, label, label_len); + msg[label_len] = (uint8_t)(index & 0xff); + msg[label_len + 1] = (uint8_t)((index >> 8) & 0xff); + msg[label_len + 2] = (uint8_t)((index >> 16) & 0xff); + msg[label_len + 3] = (uint8_t)((index >> 24) & 0xff); + + uint8_t hmac[64]; + mbedtls_md_hmac(mbedtls_md_info_from_type(MBEDTLS_MD_SHA512), + nsec, 32, msg, msg_len, hmac); + free(msg); + + memcpy(out, hmac, out_len); +} + +esp_err_t identity_init(const char *nsec_hex) +{ + memset(&s_identity, 0, sizeof(s_identity)); + + if (!nsec_hex || strlen(nsec_hex) != 64) { + ESP_LOGE(TAG, "Invalid nsec: must be 64 hex chars"); + return ESP_ERR_INVALID_ARG; + } + + strncpy(s_identity.nsec_hex, nsec_hex, sizeof(s_identity.nsec_hex) - 1); + + if (!hex_to_bytes(nsec_hex, s_identity.nsec, 32)) { + ESP_LOGE(TAG, "Failed to parse nsec hex"); + return ESP_ERR_INVALID_ARG; + } + + secp256k1_context *ctx = secp256k1_context_create(SECP256K1_CONTEXT_SIGN); + if (!ctx) { + ESP_LOGE(TAG, "Failed to create secp256k1 context"); + return ESP_ERR_NO_MEM; + } + + secp256k1_pubkey pubkey; + if (!secp256k1_ec_pubkey_create(ctx, &pubkey, s_identity.nsec)) { + ESP_LOGE(TAG, "Invalid nsec: secp256k1 key creation failed"); + secp256k1_context_destroy(ctx); + return ESP_ERR_INVALID_ARG; + } + + secp256k1_xonly_pubkey xonly; + secp256k1_xonly_pubkey_from_pubkey(ctx, &xonly, NULL, &pubkey); + uint8_t npub_bytes[32]; + secp256k1_xonly_pubkey_serialize(ctx, npub_bytes, &xonly); + bytes_to_hex(npub_bytes, 32, s_identity.npub_hex); + + tollgate_derive(s_identity.nsec, "sta-mac", 0, s_identity.sta_mac, 6); + s_identity.sta_mac[0] = (s_identity.sta_mac[0] | 0x02) & 0xFE; + + tollgate_derive(s_identity.nsec, "ap-mac", 0, s_identity.ap_mac, 6); + s_identity.ap_mac[0] = (s_identity.ap_mac[0] | 0x02) & 0xFE; + + snprintf(s_identity.ap_ssid, sizeof(s_identity.ap_ssid), + "TollGate-%02X%02X%02X", + s_identity.ap_mac[3], s_identity.ap_mac[4], s_identity.ap_mac[5]); + + uint8_t b3 = s_identity.ap_mac[3]; + uint8_t b4 = s_identity.ap_mac[4]; + uint8_t b5 = s_identity.ap_mac[5]; + uint8_t subnet = (b4 ^ b5) % 200 + 10; + IP4_ADDR(&s_identity.ap_ip, 10, b3, subnet, 1); + snprintf(s_identity.ap_ip_str, sizeof(s_identity.ap_ip_str), + IPSTR, IP2STR(&s_identity.ap_ip)); + + secp256k1_context_destroy(ctx); + s_identity.initialized = true; + + ESP_LOGI(TAG, "Identity: npub=%s", s_identity.npub_hex); + ESP_LOGI(TAG, " STA MAC: %02X:%02X:%02X:%02X:%02X:%02X", + s_identity.sta_mac[0], s_identity.sta_mac[1], s_identity.sta_mac[2], + s_identity.sta_mac[3], s_identity.sta_mac[4], s_identity.sta_mac[5]); + ESP_LOGI(TAG, " AP MAC: %02X:%02X:%02X:%02X:%02X:%02X", + s_identity.ap_mac[0], s_identity.ap_mac[1], s_identity.ap_mac[2], + s_identity.ap_mac[3], s_identity.ap_mac[4], s_identity.ap_mac[5]); + ESP_LOGI(TAG, " SSID: %s, AP IP: %s", s_identity.ap_ssid, s_identity.ap_ip_str); + + return ESP_OK; +} + +const tollgate_identity_t *identity_get(void) +{ + return &s_identity; +} 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 @@ +#ifndef IDENTITY_H +#define IDENTITY_H + +#include "esp_err.h" +#include "esp_wifi.h" +#include "esp_netif.h" +#include +#include + +typedef struct { + uint8_t nsec[32]; + char nsec_hex[65]; + char npub_hex[65]; + + uint8_t sta_mac[6]; + uint8_t ap_mac[6]; + + char ap_ssid[32]; + esp_ip4_addr_t ap_ip; + char ap_ip_str[16]; + + bool initialized; +} tollgate_identity_t; + +esp_err_t identity_init(const char *nsec_hex); + +const tollgate_identity_t *identity_get(void); + +#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 @@ +#include "nostr_event.h" +#include "esp_log.h" +#include "esp_err.h" +#include "mbedtls/sha256.h" +#include "secp256k1.h" +#include "secp256k1_extrakeys.h" +#include "secp256k1_schnorrsig.h" +#include "cJSON.h" +#include +#include +#include + +static const char *TAG = "nostr_event"; + +static void bytes_to_hex(const uint8_t *bytes, size_t len, char *hex) +{ + for (size_t i = 0; i < len; i++) + sprintf(hex + i * 2, "%02x", bytes[i]); + hex[len * 2] = '\0'; +} + +esp_err_t nostr_event_init(nostr_event_t *event, const char *npub_hex, + int kind, const char *tags_json, const char *content) +{ + memset(event, 0, sizeof(*event)); + strncpy(event->pubkey, npub_hex, sizeof(event->pubkey) - 1); + event->kind = kind; + event->tags_json = tags_json ? tags_json : "[]"; + event->content = content ? content : ""; + + struct timeval tv; + gettimeofday(&tv, NULL); + event->created_at = (uint64_t)tv.tv_sec; + + cJSON *serial = cJSON_CreateArray(); + cJSON_AddItemToArray(serial, cJSON_CreateNumber(0)); + cJSON_AddItemToArray(serial, cJSON_CreateString(event->pubkey)); + cJSON_AddItemToArray(serial, cJSON_CreateNumber((double)event->created_at)); + cJSON_AddItemToArray(serial, cJSON_CreateNumber(event->kind)); + cJSON_AddItemToArray(serial, cJSON_Parse(event->tags_json)); + cJSON_AddItemToArray(serial, cJSON_CreateString(event->content)); + + char *serialized = cJSON_PrintUnformatted(serial); + cJSON_Delete(serial); + + uint8_t hash[32]; + mbedtls_sha256((const unsigned char *)serialized, strlen(serialized), + hash, 0); + free(serialized); + + bytes_to_hex(hash, 32, event->id); + return ESP_OK; +} + +esp_err_t nostr_event_sign(nostr_event_t *event, const uint8_t nsec[32]) +{ + uint8_t msg[32]; + for (size_t i = 0; i < 32; i++) { + unsigned int byte; + sscanf(event->id + i * 2, "%02x", &byte); + msg[i] = (uint8_t)byte; + } + + secp256k1_context *ctx = secp256k1_context_create(SECP256K1_CONTEXT_SIGN); + if (!ctx) { + ESP_LOGE(TAG, "Failed to create secp256k1 context"); + return ESP_ERR_NO_MEM; + } + + secp256k1_keypair keypair; + if (!secp256k1_keypair_create(ctx, &keypair, nsec)) { + ESP_LOGE(TAG, "Invalid nsec for signing"); + secp256k1_context_destroy(ctx); + return ESP_ERR_INVALID_ARG; + } + + uint8_t sig[64]; + if (!secp256k1_schnorrsig_sign32(ctx, sig, msg, &keypair, NULL)) { + ESP_LOGE(TAG, "Schnorr signing failed"); + secp256k1_context_destroy(ctx); + return ESP_FAIL; + } + + bytes_to_hex(sig, 64, event->sig); + secp256k1_context_destroy(ctx); + return ESP_OK; +} + +esp_err_t nostr_event_to_json(const nostr_event_t *event, char *buf, size_t buf_len) +{ + cJSON *root = cJSON_CreateObject(); + cJSON_AddStringToObject(root, "id", event->id); + cJSON_AddStringToObject(root, "pubkey", event->pubkey); + cJSON_AddNumberToObject(root, "created_at", (double)event->created_at); + cJSON_AddNumberToObject(root, "kind", event->kind); + cJSON_AddItemToObject(root, "tags", cJSON_Parse(event->tags_json)); + cJSON_AddStringToObject(root, "content", event->content); + cJSON_AddStringToObject(root, "sig", event->sig); + + char *json = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + + if (!json) return ESP_FAIL; + size_t len = strlen(json); + if (len >= buf_len) { + free(json); + return ESP_ERR_NO_MEM; + } + memcpy(buf, json, len + 1); + free(json); + return ESP_OK; +} 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 @@ +#ifndef NOSTR_EVENT_H +#define NOSTR_EVENT_H + +#include "esp_err.h" +#include +#include + +typedef struct { + char pubkey[65]; + uint64_t created_at; + int kind; + const char *tags_json; + const char *content; + char id[65]; + char sig[129]; +} nostr_event_t; + +esp_err_t nostr_event_init(nostr_event_t *event, const char *npub_hex, + int kind, const char *tags_json, const char *content); + +esp_err_t nostr_event_sign(nostr_event_t *event, const uint8_t nsec[32]); + +esp_err_t nostr_event_to_json(const nostr_event_t *event, char *buf, size_t buf_len); + +#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 @@ #include "config.h" #include "session.h" #include "firewall.h" -#include "wallet.h" +#include "nucula_wallet.h" #include "esp_log.h" #include "cJSON.h" #include "lwip/sockets.h" @@ -194,6 +194,7 @@ static esp_err_t api_post_payment(httpd_req_t *req) return ESP_OK; } esp_err_t err = cashu_decode_token(body, token); + char *body_copy = strdup(body); free(body); if (err != ESP_OK) { @@ -319,17 +320,7 @@ static esp_err_t api_post_payment(httpd_req_t *req) cJSON_free(json); cJSON_Delete(session_event); - { - wallet_proof_t wproofs[CASHU_MAX_PROOFS]; - int wcount = token->proof_count > CASHU_MAX_PROOFS ? CASHU_MAX_PROOFS : token->proof_count; - for (int i = 0; i < wcount; i++) { - wproofs[i].amount = token->proofs[i].amount; - strncpy(wproofs[i].id, token->proofs[i].id, WALLET_KEYSET_ID_LEN - 1); - strncpy(wproofs[i].secret, token->proofs[i].secret, WALLET_SECRET_LEN - 1); - strncpy(wproofs[i].c, token->proofs[i].c, WALLET_SIG_LEN - 1); - } - wallet_add_proofs(wproofs, wcount); - } + nucula_wallet_receive(body_copy); free(states); free(token); @@ -381,20 +372,18 @@ static esp_err_t api_get_whoami(httpd_req_t *req) static esp_err_t api_get_wallet(httpd_req_t *req) { - wallet_t *w = wallet_get(); cJSON *root = cJSON_CreateObject(); - cJSON_AddNumberToObject(root, "balance", (double)w->balance); - cJSON_AddNumberToObject(root, "proof_count", w->proof_count); - cJSON_AddNumberToObject(root, "keyset_count", w->keyset_count); - - cJSON *proofs = cJSON_CreateArray(); - for (int i = 0; i < w->proof_count; i++) { - cJSON *p = cJSON_CreateObject(); - cJSON_AddNumberToObject(p, "amount", (double)w->proofs[i].amount); - cJSON_AddStringToObject(p, "id", w->proofs[i].id); - cJSON_AddItemToArray(proofs, p); + cJSON_AddNumberToObject(root, "balance", (double)nucula_wallet_balance()); + cJSON_AddNumberToObject(root, "proof_count", nucula_wallet_proof_count()); + + char *proofs_json = nucula_wallet_proofs_json(); + if (proofs_json) { + cJSON *proofs = cJSON_Parse(proofs_json); + free(proofs_json); + cJSON_AddItemToObject(root, "proofs", proofs); + } else { + cJSON_AddItemToObject(root, "proofs", cJSON_CreateArray()); } - cJSON_AddItemToObject(root, "proofs", proofs); char *json = cJSON_PrintUnformatted(root); httpd_resp_set_type(req, "application/json"); @@ -406,27 +395,16 @@ static esp_err_t api_get_wallet(httpd_req_t *req) static esp_err_t api_post_wallet_swap(httpd_req_t *req) { - const tollgate_config_t *cfg = tollgate_config_get(); - - if (wallet_balance() == 0) { + if (nucula_wallet_balance() == 0) { httpd_resp_set_status(req, "400 Bad Request"); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, "{\"error\":\"no proofs to swap\"}", 27); return ESP_OK; } - wallet_print_status(); - - esp_err_t err = wallet_fetch_keysets(cfg->mint_url); - if (err != ESP_OK) { - httpd_resp_set_status(req, "502 Bad Gateway"); - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, "{\"error\":\"keyset fetch failed\"}", 29); - return ESP_OK; - } + nucula_wallet_print_status(); - wallet_t *w = wallet_get(); - err = wallet_swap_proofs(cfg->mint_url, 0, w->proof_count); + esp_err_t err = nucula_wallet_swap_all(); if (err != ESP_OK) { httpd_resp_set_status(req, "502 Bad Gateway"); httpd_resp_set_type(req, "application/json"); @@ -434,11 +412,11 @@ static esp_err_t api_post_wallet_swap(httpd_req_t *req) return ESP_OK; } - wallet_print_status(); + nucula_wallet_print_status(); cJSON *root = cJSON_CreateObject(); - cJSON_AddNumberToObject(root, "balance", (double)wallet_balance()); - cJSON_AddNumberToObject(root, "proof_count", wallet_get()->proof_count); + cJSON_AddNumberToObject(root, "balance", (double)nucula_wallet_balance()); + cJSON_AddNumberToObject(root, "proof_count", nucula_wallet_proof_count()); char *json = cJSON_PrintUnformatted(root); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, json, strlen(json)); @@ -472,9 +450,8 @@ static esp_err_t api_post_wallet_send(httpd_req_t *req) return ESP_OK; } - const tollgate_config_t *cfg = tollgate_config_get(); char token[4096]; - esp_err_t err = wallet_send(cfg->mint_url, amount, token, sizeof(token)); + esp_err_t err = nucula_wallet_send(amount, token, sizeof(token)); if (err != ESP_OK) { httpd_resp_set_status(req, "402 Payment Required"); 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 @@ #include "lwip/dns.h" #include "dhcpserver/dhcpserver.h" #include "config.h" +#include "identity.h" #include "dns_server.h" #include "captive_portal.h" #include "firewall.h" #include "session.h" #include "tollgate_api.h" -#include "wallet.h" +#include "nucula_wallet.h" +#include "wifistr.h" #define MAX_STA_RETRY 5 static const char *TAG = "tollgate_main"; @@ -92,8 +94,16 @@ static void ip_event_handler(void *arg, esp_event_base_t event_base, static void wallet_init_task(void *pvParameters) { const tollgate_config_t *cfg = tollgate_config_get(); - wallet_init(); - wallet_fetch_keysets(cfg->mint_url); + nucula_wallet_init(cfg->mint_url); + vTaskDelete(NULL); +} + +static void publish_wifistr_task(void *pvParameters) +{ + vTaskDelay(pdMS_TO_TICKS(5000)); + wifistr_publish(); + const tollgate_config_t *cfg = tollgate_config_get(); + wifistr_start_periodic(cfg->nostr_publish_interval_s); vTaskDelete(NULL); } @@ -123,6 +133,8 @@ static void start_services(void) captive_portal_start(cfg->ap_ip_str); tollgate_api_start(); + xTaskCreate(publish_wifistr_task, "wifistr_init", 16384, NULL, 3, NULL); + s_services_running = true; if (s_services_mutex) xSemaphoreGive(s_services_mutex); ESP_LOGI(TAG, "=== TollGate services started ==="); @@ -214,7 +226,11 @@ void app_main(void) ESP_ERROR_CHECK(ret); ESP_ERROR_CHECK(tollgate_config_init()); + + ESP_ERROR_CHECK(identity_init(tollgate_config_get()->nsec)); + tollgate_config_derive_unique((tollgate_config_t *)tollgate_config_get()); + ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default()); @@ -227,6 +243,11 @@ void app_main(void) wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + const tollgate_config_t *tcfg = tollgate_config_get(); + ESP_ERROR_CHECK(esp_wifi_set_mac(WIFI_IF_STA, tcfg->sta_mac)); + ESP_ERROR_CHECK(esp_wifi_set_mac(WIFI_IF_AP, tcfg->ap_mac)); + ESP_LOGI(TAG, "MACs set from identity"); + ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL, NULL)); ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, @@ -241,8 +262,8 @@ void app_main(void) wifi_config_t sta_config; if (tollgate_config_get_wifi(&sta_config) == ESP_OK) { ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &sta_config)); - const tollgate_config_t *tcfg = tollgate_config_get(); - ESP_LOGI(TAG, "STA configured for SSID: %s", tcfg->networks[tcfg->current_network].ssid); + const tollgate_config_t *tcfg2 = tollgate_config_get(); + ESP_LOGI(TAG, "STA configured for SSID: %s", tcfg2->networks[tcfg2->current_network].ssid); } 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 @@ -#include "wallet.h" -#include "wallet_persist.h" -#include "config.h" -#include "esp_log.h" -#include "esp_random.h" -#include "esp_http_client.h" -#include "esp_crt_bundle.h" -#include "cJSON.h" -#include "mbedtls/ecp.h" -#include "mbedtls/bignum.h" -#include "mbedtls/sha256.h" -#include "mbedtls/base64.h" -#include "freertos/FreeRTOS.h" -#include "freertos/task.h" -#include "freertos/semphr.h" -#include "esp_heap_caps.h" -#include -#include - -static const char *TAG = "wallet"; -static wallet_t s_wallet; - -static const char DOMAIN_SEPARATOR[] = "Secp256k1_HashToCurve_Cashu_"; - -static mbedtls_ecp_group s_grp; -static mbedtls_mpi s_order; -static bool s_grp_loaded = false; - -static esp_err_t init_ecp_group(void) -{ - if (s_grp_loaded) return ESP_OK; - mbedtls_ecp_group_init(&s_grp); - mbedtls_mpi_init(&s_order); - int ret = mbedtls_ecp_group_load(&s_grp, MBEDTLS_ECP_DP_SECP256K1); - if (ret != 0) { - ESP_LOGE(TAG, "Failed to load secp256k1 group: -0x%x", -ret); - return ESP_FAIL; - } - mbedtls_mpi_copy(&s_order, &s_grp.N); - s_grp_loaded = true; - return ESP_OK; -} - -static void random_bytes(uint8_t *buf, size_t len) -{ - esp_fill_random(buf, len); -} - -static esp_err_t random_scalar(mbedtls_mpi *r) -{ - uint8_t buf[32]; - random_bytes(buf, 32); - mbedtls_mpi_init(r); - int ret = mbedtls_mpi_read_binary(r, buf, 32); - if (ret != 0) return ESP_FAIL; - ret = mbedtls_mpi_mod_mpi(r, r, &s_order); - if (ret != 0) return ESP_FAIL; - if (mbedtls_mpi_cmp_int(r, 1) < 0) { - mbedtls_mpi_add_int(r, r, 1); - } - return ESP_OK; -} - -static esp_err_t hash_to_curve(const uint8_t *msg, size_t msg_len, mbedtls_ecp_point *Y) -{ - uint8_t msg_hash[32]; - size_t ds_len = strlen(DOMAIN_SEPARATOR); - uint8_t *hash_input = malloc(ds_len + msg_len); - if (!hash_input) return ESP_FAIL; - memcpy(hash_input, DOMAIN_SEPARATOR, ds_len); - memcpy(hash_input + ds_len, msg, msg_len); - mbedtls_sha256(hash_input, ds_len + msg_len, msg_hash, 0); - free(hash_input); - - mbedtls_ecp_point_init(Y); - for (uint32_t counter = 0; counter < 256; counter++) { - uint8_t counter_bytes[4]; - counter_bytes[0] = counter & 0xFF; - counter_bytes[1] = (counter >> 8) & 0xFF; - counter_bytes[2] = (counter >> 16) & 0xFF; - counter_bytes[3] = (counter >> 24) & 0xFF; - - uint8_t to_hash[32 + 4 + 1]; - memcpy(to_hash, msg_hash, 32); - memcpy(to_hash + 32, counter_bytes, 4); - - uint8_t point_hash[32]; - mbedtls_sha256(to_hash, 36, point_hash, 0); - - uint8_t compressed[33]; - compressed[0] = 0x02; - memcpy(compressed + 1, point_hash, 32); - - int ret = mbedtls_ecp_point_read_binary(&s_grp, Y, compressed, 33); - if (ret == 0) { - ret = mbedtls_ecp_check_pubkey(&s_grp, Y); - if (ret == 0) return ESP_OK; - } - - compressed[0] = 0x03; - ret = mbedtls_ecp_point_read_binary(&s_grp, Y, compressed, 33); - if (ret == 0) { - ret = mbedtls_ecp_check_pubkey(&s_grp, Y); - if (ret == 0) return ESP_OK; - } - } - - ESP_LOGE(TAG, "hash_to_curve failed after 256 attempts"); - return ESP_FAIL; -} - -static esp_err_t point_add(const mbedtls_ecp_point *A, const mbedtls_ecp_point *B, - mbedtls_ecp_point *R) -{ - mbedtls_mpi one; - mbedtls_mpi_init(&one); - mbedtls_mpi_lset(&one, 1); - int ret = mbedtls_ecp_muladd(&s_grp, R, &one, A, &one, B); - if (ret != 0) { - ESP_LOGE(TAG, "point_add failed: -0x%x", -ret); - } - mbedtls_mpi_free(&one); - return (ret == 0) ? ESP_OK : ESP_FAIL; -} - -static esp_err_t scalar_mul(const mbedtls_mpi *m, const mbedtls_ecp_point *P, - mbedtls_ecp_point *R) -{ - int ret = mbedtls_ecp_mul(&s_grp, R, m, P, NULL, NULL); - if (ret != 0) { - ESP_LOGE(TAG, "scalar_mul failed: -0x%x", -ret); - } - return (ret == 0) ? ESP_OK : ESP_FAIL; -} - -static int hex_to_bytes(const char *hex, uint8_t *bytes, size_t bytes_len) -{ - size_t hex_len = strlen(hex); - if (hex_len / 2 > bytes_len) return -1; - for (size_t i = 0; i < hex_len / 2; i++) { - unsigned int b; - sscanf(hex + i * 2, "%02x", &b); - bytes[i] = (uint8_t)b; - } - return hex_len / 2; -} - -static void bytes_to_hex(const uint8_t *bytes, size_t len, char *hex) -{ - for (size_t i = 0; i < len; i++) { - sprintf(hex + i * 2, "%02x", bytes[i]); - } - hex[len * 2] = '\0'; -} - -esp_err_t wallet_init(void) -{ - memset(&s_wallet, 0, sizeof(s_wallet)); - esp_err_t err = init_ecp_group(); - if (err != ESP_OK) return err; - wallet_persist_load(); - ESP_LOGI(TAG, "Wallet initialized (secp256k1 loaded)"); - return ESP_OK; -} - -wallet_t *wallet_get(void) -{ - return &s_wallet; -} - -uint64_t wallet_balance(void) -{ - return s_wallet.balance; -} - -esp_err_t wallet_add_proofs(const wallet_proof_t *proofs, int count) -{ - for (int i = 0; i < count; i++) { - if (s_wallet.proof_count >= WALLET_MAX_PROOFS) { - ESP_LOGW(TAG, "Wallet full, cannot add more proofs"); - return ESP_ERR_NO_MEM; - } - memcpy(&s_wallet.proofs[s_wallet.proof_count], &proofs[i], sizeof(wallet_proof_t)); - s_wallet.balance += proofs[i].amount; - s_wallet.proof_count++; - ESP_LOGI(TAG, "Added proof: amount=%llu, total_balance=%llu", - (unsigned long long)proofs[i].amount, - (unsigned long long)s_wallet.balance); - } - wallet_persist_save(); - return ESP_OK; -} - -esp_err_t wallet_remove_proof(int index) -{ - if (index < 0 || index >= s_wallet.proof_count) return ESP_ERR_INVALID_ARG; - s_wallet.balance -= s_wallet.proofs[index].amount; - for (int i = index; i < s_wallet.proof_count - 1; i++) { - memcpy(&s_wallet.proofs[i], &s_wallet.proofs[i + 1], sizeof(wallet_proof_t)); - } - memset(&s_wallet.proofs[s_wallet.proof_count - 1], 0, sizeof(wallet_proof_t)); - s_wallet.proof_count--; - wallet_persist_save(); - return ESP_OK; -} - -void wallet_clear(void) -{ - s_wallet.balance = 0; - s_wallet.proof_count = 0; - wallet_persist_save(); -} - -esp_err_t wallet_fetch_keysets(const char *mint_url) -{ - char url[512]; - snprintf(url, sizeof(url), "%s/v1/keysets", mint_url); - - char *resp_buf = malloc(8192); - if (!resp_buf) return ESP_ERR_NO_MEM; - - esp_http_client_config_t config = { - .url = url, - .method = HTTP_METHOD_GET, - .timeout_ms = 10000, - .crt_bundle_attach = esp_crt_bundle_attach, - }; - esp_http_client_handle_t client = esp_http_client_init(&config); - if (!client) { free(resp_buf); return ESP_FAIL; } - - esp_err_t err = esp_http_client_open(client, 0); - if (err != ESP_OK) { - ESP_LOGE(TAG, "Keyset fetch open failed: %s", esp_err_to_name(err)); - esp_http_client_cleanup(client); - free(resp_buf); - return err; - } - - int content_length = esp_http_client_fetch_headers(client); - int status = esp_http_client_get_status_code(client); - ESP_LOGI(TAG, "Keyset fetch: status=%d content_length=%d", status, content_length); - - int resp_len = esp_http_client_read(client, resp_buf, 8191); - ESP_LOGI(TAG, "Keyset fetch: read %d bytes", resp_len); - esp_http_client_cleanup(client); - - if (status != 200 || resp_len <= 0) { - ESP_LOGE(TAG, "Keyset fetch failed: status=%d len=%d", status, resp_len); - free(resp_buf); - return ESP_FAIL; - } - resp_buf[resp_len] = '\0'; - - cJSON *root = cJSON_Parse(resp_buf); - free(resp_buf); - if (!root) return ESP_FAIL; - - cJSON *keysets = cJSON_GetObjectItemCaseSensitive(root, "keysets"); - if (!keysets || !cJSON_IsArray(keysets)) { - cJSON_Delete(root); - return ESP_FAIL; - } - - s_wallet.keyset_count = 0; - int n = cJSON_GetArraySize(keysets); - for (int i = 0; i < n && i < WALLET_MAX_KEYSETS; i++) { - cJSON *ks = cJSON_GetArrayItem(keysets, i); - cJSON *id = cJSON_GetObjectItemCaseSensitive(ks, "id"); - if (id && cJSON_IsString(id)) { - strncpy(s_wallet.keysets[s_wallet.keyset_count].id, id->valuestring, - WALLET_KEYSET_ID_LEN - 1); - cJSON *fee = cJSON_GetObjectItemCaseSensitive(ks, "input_fee_ppk"); - s_wallet.keysets[s_wallet.keyset_count].input_fee_ppk = fee ? fee->valueint : 0; - s_wallet.keyset_count++; - } - } - - cJSON_Delete(root); - ESP_LOGI(TAG, "Fetched %d keysets from %s", s_wallet.keyset_count, mint_url); - return ESP_OK; -} - -esp_err_t wallet_swap_proofs(const char *mint_url, int start_index, int count) -{ - ESP_LOGI(TAG, "wallet_swap_proofs called: start=%d count=%d keysets=%d proofs=%d", - start_index, count, s_wallet.keyset_count, s_wallet.proof_count); - - if (s_wallet.keyset_count == 0) { - ESP_LOGE(TAG, "No keysets loaded, fetch first"); - return ESP_FAIL; - } - if (start_index < 0 || start_index + count > s_wallet.proof_count) { - return ESP_ERR_INVALID_ARG; - } - - wallet_proof_t *old_proofs = &s_wallet.proofs[start_index]; - int n = count; - - uint64_t total_input = 0; - for (int i = 0; i < n; i++) total_input += old_proofs[i].amount; - - int fee_ppk = s_wallet.keysets[0].input_fee_ppk; - uint64_t fee_sats = (total_input * fee_ppk + 999) / 1000; - uint64_t total_output = total_input - fee_sats; - ESP_LOGI(TAG, "Swap: total_input=%llu fee_ppk=%d fee=%llu total_output=%llu", - (unsigned long long)total_input, fee_ppk, - (unsigned long long)fee_sats, (unsigned long long)total_output); - - cJSON *inputs = cJSON_CreateArray(); - for (int i = 0; i < n; i++) { - cJSON *p = cJSON_CreateObject(); - cJSON_AddNumberToObject(p, "amount", (double)old_proofs[i].amount); - cJSON_AddStringToObject(p, "id", old_proofs[i].id); - cJSON_AddStringToObject(p, "secret", old_proofs[i].secret); - cJSON_AddStringToObject(p, "C", old_proofs[i].c); - cJSON_AddItemToArray(inputs, p); - } - - typedef struct { - uint8_t secret[32]; - mbedtls_mpi r; - mbedtls_ecp_point Y; - } swap_output_t; - - swap_output_t *outputs = heap_caps_malloc(n * sizeof(swap_output_t), MALLOC_CAP_SPIRAM); - if (!outputs) { cJSON_Delete(inputs); return ESP_ERR_NO_MEM; } - - cJSON *blinded_msgs = cJSON_CreateArray(); - for (int i = 0; i < n; i++) { - random_bytes(outputs[i].secret, 32); - mbedtls_ecp_point_init(&outputs[i].Y); - esp_err_t htc_ret = hash_to_curve(outputs[i].secret, 32, &outputs[i].Y); - if (htc_ret != ESP_OK) { - ESP_LOGE(TAG, "hash_to_curve failed for output %d", i); - } - mbedtls_mpi_init(&outputs[i].r); - random_scalar(&outputs[i].r); - - mbedtls_ecp_point rG, B_; - mbedtls_ecp_point_init(&rG); - mbedtls_ecp_point_init(&B_); - - esp_err_t sm_ret = scalar_mul(&outputs[i].r, &s_grp.G, &rG); - if (sm_ret != ESP_OK) { - ESP_LOGE(TAG, "scalar_mul failed for output %d", i); - } - esp_err_t pa_ret = point_add(&outputs[i].Y, &rG, &B_); - if (pa_ret != ESP_OK) { - ESP_LOGE(TAG, "point_add failed for output %d", i); - } - - uint8_t b_bytes[33]; - size_t olen = 0; - int wret = mbedtls_ecp_point_write_binary(&s_grp, &B_, MBEDTLS_ECP_PF_COMPRESSED, &olen, b_bytes, 33); - if (wret != 0 || olen == 0) { - ESP_LOGE(TAG, "Blinded point write failed: ret=-0x%x olen=%zu", -wret, olen); - olen = 1; - b_bytes[0] = 0x00; - } - char b_hex[67]; - bytes_to_hex(b_bytes, olen, b_hex); - - uint64_t out_amount = old_proofs[i].amount; - if (i == n - 1) { - uint64_t running = 0; - for (int j = 0; j < n - 1; j++) running += old_proofs[j].amount; - out_amount = total_output - running; - } - - cJSON *bm = cJSON_CreateObject(); - cJSON_AddNumberToObject(bm, "amount", (double)out_amount); - cJSON_AddStringToObject(bm, "id", s_wallet.keysets[0].id); - cJSON_AddStringToObject(bm, "B_", b_hex); - cJSON_AddItemToArray(blinded_msgs, bm); - - mbedtls_ecp_point_free(&rG); - mbedtls_ecp_point_free(&B_); - } - - cJSON *body = cJSON_CreateObject(); - cJSON_AddItemToObject(body, "inputs", inputs); - cJSON_AddItemToObject(body, "outputs", blinded_msgs); - char *body_str = cJSON_PrintUnformatted(body); - cJSON_Delete(body); - - ESP_LOGI(TAG, "Swap request body (%zu bytes): %s", strlen(body_str), body_str); - - char url[512]; - snprintf(url, sizeof(url), "%s/v1/swap", mint_url); - - char *resp_buf = malloc(8192); - if (!resp_buf) { - free(body_str); - for (int i = 0; i < n; i++) { - mbedtls_mpi_free(&outputs[i].r); - mbedtls_ecp_point_free(&outputs[i].Y); - } - free(outputs); - return ESP_ERR_NO_MEM; - } - - esp_http_client_config_t config = { - .url = url, - .method = HTTP_METHOD_POST, - .timeout_ms = 15000, - .crt_bundle_attach = esp_crt_bundle_attach, - }; - esp_http_client_handle_t client = esp_http_client_init(&config); - if (!client) { - free(body_str); - free(resp_buf); - for (int i = 0; i < n; i++) { - mbedtls_mpi_free(&outputs[i].r); - mbedtls_ecp_point_free(&outputs[i].Y); - } - free(outputs); - return ESP_FAIL; - } - - esp_http_client_set_header(client, "Content-Type", "application/json"); - esp_http_client_open(client, strlen(body_str)); - esp_http_client_write(client, body_str, strlen(body_str)); - free(body_str); - - esp_http_client_fetch_headers(client); - int resp_len = esp_http_client_read(client, resp_buf, 8191); - int status = esp_http_client_get_status_code(client); - esp_http_client_cleanup(client); - - if (status != 200 || resp_len <= 0) { - if (resp_len > 0) { - resp_buf[resp_len] = '\0'; - ESP_LOGE(TAG, "Swap failed: status=%d body=%s", status, resp_buf); - } else { - ESP_LOGE(TAG, "Swap failed: status=%d len=%d", status, resp_len); - } - free(resp_buf); - for (int i = 0; i < n; i++) { - mbedtls_mpi_free(&outputs[i].r); - mbedtls_ecp_point_free(&outputs[i].Y); - } - free(outputs); - return ESP_FAIL; - } - resp_buf[resp_len] = '\0'; - - cJSON *root = cJSON_Parse(resp_buf); - free(resp_buf); - if (!root) { - for (int i = 0; i < n; i++) { - mbedtls_mpi_free(&outputs[i].r); - mbedtls_ecp_point_free(&outputs[i].Y); - } - free(outputs); - return ESP_FAIL; - } - - cJSON *signatures = cJSON_GetObjectItemCaseSensitive(root, "signatures"); - if (!signatures || !cJSON_IsArray(signatures)) { - ESP_LOGE(TAG, "No signatures in swap response"); - cJSON_Delete(root); - for (int i = 0; i < n; i++) { - mbedtls_mpi_free(&outputs[i].r); - mbedtls_ecp_point_free(&outputs[i].Y); - } - free(outputs); - return ESP_FAIL; - } - - for (int i = start_index; i < start_index + n; i++) { - s_wallet.balance -= s_wallet.proofs[i].amount; - } - - int sig_count = cJSON_GetArraySize(signatures); - for (int i = 0; i < sig_count && i < n; i++) { - cJSON *sig = cJSON_GetArrayItem(signatures, i); - cJSON *c_ = cJSON_GetObjectItemCaseSensitive(sig, "C_"); - cJSON *amt = cJSON_GetObjectItemCaseSensitive(sig, "amount"); - cJSON *id = cJSON_GetObjectItemCaseSensitive(sig, "id"); - - if (!c_ || !cJSON_IsString(c_)) continue; - - uint8_t c_bytes[33]; - int c_len = hex_to_bytes(c_->valuestring, c_bytes, 33); - - mbedtls_ecp_point C_; - mbedtls_ecp_point_init(&C_); - mbedtls_ecp_point_read_binary(&s_grp, &C_, c_bytes, c_len); - - char ks_id[WALLET_KEYSET_ID_LEN] = {0}; - if (id && cJSON_IsString(id)) { - strncpy(ks_id, id->valuestring, WALLET_KEYSET_ID_LEN - 1); - } - - mbedtls_mpi neg_r; - mbedtls_mpi_init(&neg_r); - mbedtls_mpi_sub_mpi(&neg_r, &s_order, &outputs[i].r); - - mbedtls_ecp_point neg_rG; - mbedtls_ecp_point_init(&neg_rG); - scalar_mul(&neg_r, &s_grp.G, &neg_rG); - - mbedtls_ecp_point C; - mbedtls_ecp_point_init(&C); - point_add(&C_, &neg_rG, &C); - - uint8_t c_final[33]; - size_t c_final_len; - mbedtls_ecp_point_write_binary(&s_grp, &C, MBEDTLS_ECP_PF_COMPRESSED, - &c_final_len, c_final, 33); - - if (s_wallet.proof_count < WALLET_MAX_PROOFS) { - wallet_proof_t *wp = &s_wallet.proofs[s_wallet.proof_count]; - if (amt && cJSON_IsNumber(amt)) { - wp->amount = (uint64_t)amt->valuedouble; - } - strncpy(wp->id, ks_id, WALLET_KEYSET_ID_LEN - 1); - bytes_to_hex(outputs[i].secret, 32, wp->secret); - bytes_to_hex(c_final, c_final_len, wp->c); - s_wallet.balance += wp->amount; - s_wallet.proof_count++; - } - - mbedtls_mpi_free(&neg_r); - mbedtls_ecp_point_free(&C_); - mbedtls_ecp_point_free(&neg_rG); - mbedtls_ecp_point_free(&C); - } - - for (int i = 0; i < n; i++) { - int idx = start_index; - for (int j = idx; j < s_wallet.proof_count - 1; j++) { - memcpy(&s_wallet.proofs[j], &s_wallet.proofs[j + 1], sizeof(wallet_proof_t)); - } - s_wallet.proof_count--; - } - - for (int i = 0; i < n; i++) { - mbedtls_mpi_free(&outputs[i].r); - mbedtls_ecp_point_free(&outputs[i].Y); - } - free(outputs); - cJSON_Delete(root); - - ESP_LOGI(TAG, "Swap complete: %d proofs swapped, balance=%llu", - n, (unsigned long long)s_wallet.balance); - wallet_persist_save(); - return ESP_OK; -} - -esp_err_t wallet_create_token(char *out, size_t out_size, uint64_t amount, - const char *mint_url) -{ - if (s_wallet.proof_count == 0 || s_wallet.balance < amount) { - ESP_LOGE(TAG, "Insufficient balance: have=%llu need=%llu", - (unsigned long long)s_wallet.balance, (unsigned long long)amount); - return ESP_FAIL; - } - - cJSON *proofs_arr = cJSON_CreateArray(); - uint64_t remaining = amount; - int indices_to_remove[10]; - int remove_count = 0; - - for (int i = 0; i < s_wallet.proof_count && remaining > 0 && remove_count < 10; i++) { - if (s_wallet.proofs[i].amount <= remaining) { - cJSON *p = cJSON_CreateObject(); - cJSON_AddNumberToObject(p, "amount", (double)s_wallet.proofs[i].amount); - cJSON_AddStringToObject(p, "id", s_wallet.proofs[i].id); - cJSON_AddStringToObject(p, "secret", s_wallet.proofs[i].secret); - cJSON_AddStringToObject(p, "C", s_wallet.proofs[i].c); - cJSON_AddItemToArray(proofs_arr, p); - remaining -= s_wallet.proofs[i].amount; - indices_to_remove[remove_count++] = i; - } - } - - if (remaining > 0) { - cJSON_Delete(proofs_arr); - ESP_LOGE(TAG, "Cannot make exact amount: %llu remaining", (unsigned long long)remaining); - return ESP_FAIL; - } - - cJSON *token_obj = cJSON_CreateObject(); - cJSON *token_arr = cJSON_CreateArray(); - cJSON *mint_proofs = cJSON_CreateObject(); - cJSON_AddStringToObject(mint_proofs, "mint", mint_url); - cJSON_AddItemToObject(mint_proofs, "proofs", proofs_arr); - cJSON_AddItemToArray(token_arr, mint_proofs); - cJSON_AddItemToObject(token_obj, "token", token_arr); - - char *json_str = cJSON_PrintUnformatted(token_obj); - cJSON_Delete(token_obj); - - size_t b64_len; - mbedtls_base64_encode((unsigned char *)out + 6, out_size - 6, &b64_len, - (const unsigned char *)json_str, strlen(json_str)); - free(json_str); - - memcpy(out, "cashuA", 6); - for (size_t i = 0; i < b64_len; i++) { - if (out[6 + i] == '+') out[6 + i] = '-'; - else if (out[6 + i] == '/') out[6 + i] = '_'; - else if (out[6 + i] == '=') { out[6 + i] = '\0'; break; } - } - out[6 + b64_len] = '\0'; - - for (int i = remove_count - 1; i >= 0; i--) { - s_wallet.balance -= s_wallet.proofs[indices_to_remove[i]].amount; - for (int j = indices_to_remove[i]; j < s_wallet.proof_count - 1; j++) { - memcpy(&s_wallet.proofs[j], &s_wallet.proofs[j + 1], sizeof(wallet_proof_t)); - } - s_wallet.proof_count--; - } - - ESP_LOGI(TAG, "Created token for %llu sats, remaining balance=%llu", - (unsigned long long)amount, (unsigned long long)s_wallet.balance); - wallet_persist_save(); - return ESP_OK; -} - -esp_err_t wallet_send(const char *mint_url, uint64_t amount, - char *token_out, size_t token_out_size) -{ - return wallet_create_token(token_out, token_out_size, amount, mint_url); -} - -void wallet_print_status(void) -{ - ESP_LOGI(TAG, "Wallet: %d proofs, balance=%llu sats, %d keysets", - s_wallet.proof_count, - (unsigned long long)s_wallet.balance, - s_wallet.keyset_count); - for (int i = 0; i < s_wallet.proof_count; i++) { - ESP_LOGI(TAG, " [%d] amount=%llu id=%s", i, - (unsigned long long)s_wallet.proofs[i].amount, - s_wallet.proofs[i].id); - } -} diff --git a/main/wallet.h b/main/wallet.h deleted file mode 100644 index 5089f93..0000000 --- a/main/wallet.h +++ /dev/null @@ -1,53 +0,0 @@ -#ifndef WALLET_H -#define WALLET_H - -#include "esp_err.h" -#include -#include - -#define WALLET_MAX_PROOFS 50 -#define WALLET_MAX_KEYSETS 5 -#define WALLET_KEYSET_ID_LEN 68 -#define WALLET_SECRET_LEN 65 -#define WALLET_SIG_LEN 67 - -typedef struct { - uint64_t amount; - char id[WALLET_KEYSET_ID_LEN]; - char secret[WALLET_SECRET_LEN]; - char c[WALLET_SIG_LEN]; -} wallet_proof_t; - -typedef struct { - char id[WALLET_KEYSET_ID_LEN]; - char public_key_33[67]; - uint64_t amount; - int input_fee_ppk; -} wallet_keyset_t; - -typedef struct { - wallet_proof_t proofs[WALLET_MAX_PROOFS]; - int proof_count; - wallet_keyset_t keysets[WALLET_MAX_KEYSETS]; - int keyset_count; - uint64_t balance; -} wallet_t; - -esp_err_t wallet_init(void); -wallet_t *wallet_get(void); -uint64_t wallet_balance(void); - -esp_err_t wallet_add_proofs(const wallet_proof_t *proofs, int count); -esp_err_t wallet_remove_proof(int index); -void wallet_clear(void); - -esp_err_t wallet_fetch_keysets(const char *mint_url); -esp_err_t wallet_swap_proofs(const char *mint_url, int start_index, int count); - -esp_err_t wallet_create_token(char *out, size_t out_size, uint64_t amount, - const char *mint_url); -esp_err_t wallet_send(const char *mint_url, uint64_t amount, - char *token_out, size_t token_out_size); - -void wallet_print_status(void); -#endif diff --git a/main/wallet_persist.c b/main/wallet_persist.c deleted file mode 100644 index 45c932f..0000000 --- a/main/wallet_persist.c +++ /dev/null @@ -1,147 +0,0 @@ -#include "wallet_persist.h" -#include "wallet.h" -#include "config.h" -#include "esp_log.h" -#include "cJSON.h" -#include -#include -#include - -static const char *TAG = "wallet_persist"; -static const char *WALLET_FILE = "/spiffs/wallet.json"; - -esp_err_t wallet_persist_save(void) -{ - const tollgate_config_t *cfg = tollgate_config_get(); - wallet_t *w = wallet_get(); - - if (w->balance < cfg->persist_threshold_sats) { - if (w->proof_count == 0) { - unlink(WALLET_FILE); - ESP_LOGI(TAG, "Wallet empty, removed persist file"); - } - return ESP_OK; - } - - cJSON *root = cJSON_CreateObject(); - cJSON_AddNumberToObject(root, "balance", (double)w->balance); - - cJSON *proofs = cJSON_CreateArray(); - for (int i = 0; i < w->proof_count; i++) { - cJSON *p = cJSON_CreateObject(); - cJSON_AddNumberToObject(p, "amount", (double)w->proofs[i].amount); - cJSON_AddStringToObject(p, "id", w->proofs[i].id); - cJSON_AddStringToObject(p, "secret", w->proofs[i].secret); - cJSON_AddStringToObject(p, "C", w->proofs[i].c); - cJSON_AddItemToArray(proofs, p); - } - cJSON_AddItemToObject(root, "proofs", proofs); - - cJSON *keysets = cJSON_CreateArray(); - for (int i = 0; i < w->keyset_count; i++) { - cJSON *ks = cJSON_CreateObject(); - cJSON_AddStringToObject(ks, "id", w->keysets[i].id); - cJSON_AddItemToArray(keysets, ks); - } - cJSON_AddItemToObject(root, "keysets", keysets); - - char *json_str = cJSON_PrintUnformatted(root); - cJSON_Delete(root); - - FILE *f = fopen(WALLET_FILE, "w"); - if (!f) { - ESP_LOGE(TAG, "Failed to open %s for writing", WALLET_FILE); - cJSON_free(json_str); - return ESP_FAIL; - } - - size_t written = fwrite(json_str, 1, strlen(json_str), f); - fclose(f); - cJSON_free(json_str); - - ESP_LOGI(TAG, "Wallet persisted: %d proofs, balance=%llu (%zu bytes)", - w->proof_count, (unsigned long long)w->balance, written); - return ESP_OK; -} - -esp_err_t wallet_persist_load(void) -{ - wallet_t *w = wallet_get(); - - FILE *f = fopen(WALLET_FILE, "r"); - if (!f) { - ESP_LOGI(TAG, "No persisted wallet found, starting fresh"); - return ESP_OK; - } - - fseek(f, 0, SEEK_END); - long fsize = ftell(f); - fseek(f, 0, SEEK_SET); - - if (fsize <= 0 || fsize > 65536) { - fclose(f); - ESP_LOGW(TAG, "Wallet file size invalid: %ld", fsize); - return ESP_FAIL; - } - - char *buf = malloc(fsize + 1); - if (!buf) { - fclose(f); - return ESP_ERR_NO_MEM; - } - - fread(buf, 1, fsize, f); - buf[fsize] = '\0'; - fclose(f); - - cJSON *root = cJSON_Parse(buf); - free(buf); - if (!root) { - ESP_LOGE(TAG, "Failed to parse wallet.json"); - return ESP_FAIL; - } - - cJSON *balance_j = cJSON_GetObjectItemCaseSensitive(root, "balance"); - if (balance_j && cJSON_IsNumber(balance_j)) { - w->balance = (uint64_t)balance_j->valuedouble; - } - - cJSON *proofs = cJSON_GetObjectItemCaseSensitive(root, "proofs"); - if (proofs && cJSON_IsArray(proofs)) { - int count = cJSON_GetArraySize(proofs); - if (count > WALLET_MAX_PROOFS) count = WALLET_MAX_PROOFS; - for (int i = 0; i < count; i++) { - cJSON *p = cJSON_GetArrayItem(proofs, i); - cJSON *amt = cJSON_GetObjectItemCaseSensitive(p, "amount"); - cJSON *id = cJSON_GetObjectItemCaseSensitive(p, "id"); - cJSON *secret = cJSON_GetObjectItemCaseSensitive(p, "secret"); - cJSON *c = cJSON_GetObjectItemCaseSensitive(p, "C"); - if (amt) w->proofs[i].amount = (uint64_t)amt->valuedouble; - if (id && cJSON_IsString(id)) - strncpy(w->proofs[i].id, id->valuestring, WALLET_KEYSET_ID_LEN - 1); - if (secret && cJSON_IsString(secret)) - strncpy(w->proofs[i].secret, secret->valuestring, WALLET_SECRET_LEN - 1); - if (c && cJSON_IsString(c)) - strncpy(w->proofs[i].c, c->valuestring, WALLET_SIG_LEN - 1); - w->proof_count++; - } - } - - cJSON *keysets = cJSON_GetObjectItemCaseSensitive(root, "keysets"); - if (keysets && cJSON_IsArray(keysets)) { - int count = cJSON_GetArraySize(keysets); - if (count > WALLET_MAX_KEYSETS) count = WALLET_MAX_KEYSETS; - for (int i = 0; i < count; i++) { - cJSON *ks = cJSON_GetArrayItem(keysets, i); - cJSON *id = cJSON_GetObjectItemCaseSensitive(ks, "id"); - if (id && cJSON_IsString(id)) - strncpy(w->keysets[i].id, id->valuestring, WALLET_KEYSET_ID_LEN - 1); - w->keyset_count++; - } - } - - cJSON_Delete(root); - ESP_LOGI(TAG, "Wallet loaded: %d proofs, %d keysets, balance=%llu", - w->proof_count, w->keyset_count, (unsigned long long)w->balance); - return ESP_OK; -} diff --git a/main/wallet_persist.h b/main/wallet_persist.h deleted file mode 100644 index 4dfcbfc..0000000 --- a/main/wallet_persist.h +++ /dev/null @@ -1,9 +0,0 @@ -#ifndef WALLET_PERSIST_H -#define WALLET_PERSIST_H - -#include "esp_err.h" - -esp_err_t wallet_persist_save(void); -esp_err_t wallet_persist_load(void); - -#endif diff --git a/main/wifistr.c b/main/wifistr.c new file mode 100644 index 0000000..bf03b4d --- /dev/null +++ b/main/wifistr.c @@ -0,0 +1,252 @@ +#include "wifistr.h" +#include "identity.h" +#include "nostr_event.h" +#include "config.h" +#include "esp_log.h" +#include "esp_tls.h" +#include "esp_crt_bundle.h" +#include "cJSON.h" +#include "freertos/task.h" +#include "freertos/timers.h" +#include +#include +#include + +static const char *TAG = "wifistr"; +static TimerHandle_t s_publish_timer = NULL; + +static esp_err_t ws_send_to_relay(const char *relay_url, const char *event_json) +{ + char host[128] = {0}; + int port = 443; + char path[128] = "/"; + + if (strncmp(relay_url, "wss://", 6) != 0) { + ESP_LOGW(TAG, "Unsupported relay URL: %s", relay_url); + return ESP_ERR_INVALID_ARG; + } + + const char *url_start = relay_url + 6; + const char *path_ptr = strchr(url_start, '/'); + if (path_ptr) { + size_t host_len = path_ptr - url_start; + if (host_len >= sizeof(host)) host_len = sizeof(host) - 1; + memcpy(host, url_start, host_len); + host[host_len] = '\0'; + strncpy(path, path_ptr, sizeof(path) - 1); + } else { + strncpy(host, url_start, sizeof(host) - 1); + } + + char *colon = strchr(host, ':'); + if (colon) { + *colon = '\0'; + port = atoi(colon + 1); + } + + ESP_LOGI(TAG, "Connecting to %s:%d%s", host, port, path); + + esp_tls_cfg_t tls_cfg = { + .crt_bundle_attach = esp_crt_bundle_attach, + }; + + esp_tls_t *tls = esp_tls_init(); + if (!tls) { + ESP_LOGE(TAG, "Failed to allocate TLS handle"); + return ESP_ERR_NO_MEM; + } + + int ret = esp_tls_conn_new_sync(host, strlen(host), port, &tls_cfg, tls); + if (ret < 0) { + ESP_LOGE(TAG, "TLS connect failed to %s", host); + esp_tls_conn_destroy(tls); + return ESP_FAIL; + } + + char upgrade[512]; + snprintf(upgrade, sizeof(upgrade), + "GET %s HTTP/1.1\r\n" + "Host: %s\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + "Sec-WebSocket-Version: 13\r\n" + "\r\n", + path, host); + + int written = esp_tls_conn_write(tls, (const unsigned char *)upgrade, strlen(upgrade)); + if (written < 0) { + ESP_LOGE(TAG, "Failed to send upgrade request"); + esp_tls_conn_destroy(tls); + return ESP_FAIL; + } + + char resp[1024]; + int rlen = esp_tls_conn_read(tls, (unsigned char *)resp, sizeof(resp) - 1); + if (rlen <= 0 || !strstr(resp, "101")) { + ESP_LOGE(TAG, "WebSocket upgrade failed (read %d bytes)", rlen); + esp_tls_conn_destroy(tls); + return ESP_FAIL; + } + + cJSON *arr = cJSON_CreateArray(); + cJSON_AddItemToArray(arr, cJSON_CreateString("EVENT")); + cJSON_AddItemToArray(arr, cJSON_Parse(event_json)); + char *msg = cJSON_PrintUnformatted(arr); + cJSON_Delete(arr); + + size_t msg_len = strlen(msg); + uint8_t ws_header[10]; + int header_len = 0; + ws_header[0] = 0x81; + if (msg_len <= 125) { + ws_header[1] = (uint8_t)msg_len; + header_len = 2; + } else if (msg_len <= 65535) { + ws_header[1] = 126; + ws_header[2] = (uint8_t)((msg_len >> 8) & 0xff); + ws_header[3] = (uint8_t)(msg_len & 0xff); + header_len = 4; + } else { + ws_header[1] = 127; + for (int i = 0; i < 8; i++) + ws_header[2 + i] = (uint8_t)((msg_len >> (56 - i * 8)) & 0xff); + header_len = 10; + } + + esp_tls_conn_write(tls, ws_header, header_len); + esp_tls_conn_write(tls, (const unsigned char *)msg, msg_len); + + free(msg); + + uint8_t resp_buf[256]; + int resp_len = esp_tls_conn_read(tls, resp_buf, sizeof(resp_buf) - 1); + if (resp_len > 0) { + resp_buf[resp_len] = '\0'; + int mask_len = (resp_buf[1] & 0x80) ? 4 : 0; + int payload_offset = 2 + mask_len; + if (resp_len > payload_offset) { + ESP_LOGI(TAG, "Relay response: %.*s", resp_len - payload_offset, + (char *)resp_buf + payload_offset); + } + } + + uint8_t close_frame[2] = {0x88, 0x00}; + esp_tls_conn_write(tls, close_frame, 2); + esp_tls_conn_destroy(tls); + + ESP_LOGI(TAG, "Published to %s", host); + return ESP_OK; +} + +static char *build_wifistr_event(void) +{ + const tollgate_identity_t *id = identity_get(); + if (!id || !id->initialized) { + ESP_LOGE(TAG, "Identity not initialized"); + return NULL; + } + + const tollgate_config_t *cfg = tollgate_config_get(); + + cJSON *tags = cJSON_CreateArray(); + + cJSON *d_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(d_tag, cJSON_CreateString("d")); + cJSON_AddItemToArray(d_tag, cJSON_CreateString(id->npub_hex)); + cJSON_AddItemToArray(tags, d_tag); + + cJSON *ssid_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(ssid_tag, cJSON_CreateString("ssid")); + cJSON_AddItemToArray(ssid_tag, cJSON_CreateString(id->ap_ssid)); + cJSON_AddItemToArray(tags, ssid_tag); + + cJSON *h_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(h_tag, cJSON_CreateString("h")); + cJSON_AddItemToArray(h_tag, cJSON_CreateString("cashu-testnut")); + cJSON_AddItemToArray(tags, h_tag); + + cJSON *sec_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(sec_tag, cJSON_CreateString("security")); + cJSON_AddItemToArray(sec_tag, cJSON_CreateString("open")); + cJSON_AddItemToArray(tags, sec_tag); + + cJSON *g_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(g_tag, cJSON_CreateString("g")); + cJSON_AddItemToArray(g_tag, cJSON_CreateString(cfg->nostr_geohash)); + cJSON_AddItemToArray(tags, g_tag); + + cJSON *c_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(c_tag, cJSON_CreateString("c")); + cJSON_AddItemToArray(c_tag, cJSON_CreateString("cashu")); + cJSON_AddItemToArray(tags, c_tag); + + char content[512]; + snprintf(content, sizeof(content), + "TollGate WiFi hotspot: %s | Price: %d sats/%dms | Mint: %s", + id->ap_ssid, cfg->price_per_step, cfg->step_size_ms, cfg->mint_url); + + char *tags_str = cJSON_PrintUnformatted(tags); + cJSON_Delete(tags); + + nostr_event_t event; + nostr_event_init(&event, id->npub_hex, 38787, tags_str, content); + nostr_event_sign(&event, id->nsec); + free(tags_str); + + char *event_json = malloc(2048); + if (!event_json) return NULL; + + esp_err_t ret = nostr_event_to_json(&event, event_json, 2048); + if (ret != ESP_OK) { + free(event_json); + return NULL; + } + + return event_json; +} + +esp_err_t wifistr_publish(void) +{ + char *event_json = build_wifistr_event(); + if (!event_json) { + ESP_LOGE(TAG, "Failed to build wifistr event"); + return ESP_FAIL; + } + + ESP_LOGI(TAG, "Wifistr event: %s", event_json); + + const tollgate_config_t *cfg = tollgate_config_get(); + esp_err_t last_err = ESP_FAIL; + + for (int i = 0; i < cfg->nostr_relay_count; i++) { + esp_err_t err = ws_send_to_relay(cfg->nostr_relays[i], event_json); + if (err == ESP_OK) last_err = ESP_OK; + vTaskDelay(pdMS_TO_TICKS(500)); + } + + free(event_json); + return last_err; +} + +static void publish_task(void *pvParameters) +{ + wifistr_publish(); + vTaskDelete(NULL); +} + +static void timer_callback(TimerHandle_t timer) +{ + xTaskCreate(publish_task, "wifistr_pub", 16384, NULL, 3, NULL); +} + +void wifistr_start_periodic(int interval_s) +{ + if (s_publish_timer) return; + s_publish_timer = xTimerCreate("wifistr", pdMS_TO_TICKS(interval_s * 1000), + pdTRUE, NULL, timer_callback); + if (s_publish_timer) { + xTimerStart(s_publish_timer, 0); + ESP_LOGI(TAG, "Periodic publish every %ds", interval_s); + } +} 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 @@ +#ifndef WIFISTR_H +#define WIFISTR_H + +#include "esp_err.h" + +esp_err_t wifistr_publish(void); + +void wifistr_start_periodic(int interval_s); + +#endif diff --git a/nucula_src b/nucula_src new file mode 160000 index 0000000..0ecd83c --- /dev/null +++ b/nucula_src @@ -0,0 +1 @@ +Subproject commit 0ecd83c404455b0885b35e15eb1d7f5447bc4892 diff --git a/sdkconfig b/sdkconfig index 0b024cd..53590c2 100644 --- a/sdkconfig +++ b/sdkconfig @@ -961,8 +961,8 @@ CONFIG_ESP32S3_UNIVERSAL_MAC_ADDRESSES=4 # # Sleep Config # -# CONFIG_ESP_SLEEP_POWER_DOWN_FLASH is not set CONFIG_ESP_SLEEP_FLASH_LEAKAGE_WORKAROUND=y +CONFIG_ESP_SLEEP_PSRAM_LEAKAGE_WORKAROUND=y CONFIG_ESP_SLEEP_MSPI_NEED_ALL_IO_PU=y CONFIG_ESP_SLEEP_RTC_BUS_ISO_WORKAROUND=y CONFIG_ESP_SLEEP_GPIO_RESET_WORKAROUND=y @@ -1071,7 +1071,36 @@ CONFIG_PM_RESTORE_CACHE_TAGMEM_AFTER_LIGHT_SLEEP=y # # ESP PSRAM # -# CONFIG_SPIRAM is not set +CONFIG_SPIRAM=y + +# +# SPI RAM config +# +# CONFIG_SPIRAM_MODE_QUAD is not set +CONFIG_SPIRAM_MODE_OCT=y +CONFIG_SPIRAM_TYPE_AUTO=y +# CONFIG_SPIRAM_TYPE_ESPPSRAM64 is not set +CONFIG_SPIRAM_CLK_IO=30 +CONFIG_SPIRAM_CS_IO=26 +# CONFIG_SPIRAM_XIP_FROM_PSRAM is not set +# CONFIG_SPIRAM_FETCH_INSTRUCTIONS is not set +# CONFIG_SPIRAM_RODATA is not set +CONFIG_SPIRAM_SPEED_80M=y +# CONFIG_SPIRAM_SPEED_40M is not set +CONFIG_SPIRAM_SPEED=80 +# CONFIG_SPIRAM_ECC_ENABLE is not set +CONFIG_SPIRAM_BOOT_INIT=y +# CONFIG_SPIRAM_IGNORE_NOTFOUND is not set +# CONFIG_SPIRAM_USE_MEMMAP is not set +# CONFIG_SPIRAM_USE_CAPS_ALLOC is not set +CONFIG_SPIRAM_USE_MALLOC=y +CONFIG_SPIRAM_MEMTEST=y +CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=16384 +# CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP is not set +CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=32768 +# CONFIG_SPIRAM_ALLOW_BSS_SEG_EXTERNAL_MEMORY is not set +# CONFIG_SPIRAM_ALLOW_NOINIT_SEG_EXTERNAL_MEMORY is not set +# end of SPI RAM config # end of ESP PSRAM # @@ -1331,6 +1360,7 @@ CONFIG_FATFS_CODEPAGE=437 CONFIG_FATFS_FS_LOCK=0 CONFIG_FATFS_TIMEOUT_MS=10000 CONFIG_FATFS_PER_FILE_CACHE=y +CONFIG_FATFS_ALLOC_PREFER_EXTRAM=y # CONFIG_FATFS_USE_FASTSEEK is not set CONFIG_FATFS_USE_STRFUNC_NONE=y # CONFIG_FATFS_USE_STRFUNC_WITHOUT_CRLF_CONV is not set @@ -1400,6 +1430,7 @@ CONFIG_FREERTOS_SYSTICK_USES_SYSTIMER=y # # Extra # +CONFIG_FREERTOS_TASK_CREATE_ALLOW_EXT_MEM=y # end of Extra CONFIG_FREERTOS_PORT=y @@ -1645,6 +1676,7 @@ CONFIG_LWIP_HOOK_DNS_EXT_RESOLVE_NONE=y # mbedTLS # CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC=y +# CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC is not set # CONFIG_MBEDTLS_DEFAULT_MEM_ALLOC is not set # CONFIG_MBEDTLS_CUSTOM_MEM_ALLOC is not set CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN=y @@ -1809,12 +1841,15 @@ CONFIG_NEWLIB_TIME_SYSCALL_USE_RTC_HRT=y # CONFIG_NEWLIB_TIME_SYSCALL_USE_NONE is not set # end of Newlib +CONFIG_STDATOMIC_S32C1I_SPIRAM_WORKAROUND=y + # # NVS # # CONFIG_NVS_ENCRYPTION is not set # CONFIG_NVS_ASSERT_ERROR_CHECK is not set # CONFIG_NVS_LEGACY_DUP_KEYS_COMPATIBILITY is not set +# CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM is not set # end of NVS # @@ -2114,7 +2149,6 @@ CONFIG_POST_EVENTS_FROM_IRAM_ISR=y CONFIG_GDBSTUB_SUPPORT_TASKS=y CONFIG_GDBSTUB_MAX_TASKS=32 # CONFIG_OTA_ALLOW_HTTP is not set -# CONFIG_ESP_SYSTEM_PD_FLASH is not set CONFIG_ESP32S3_DEEP_SLEEP_WAKEUP_DELAY=2000 CONFIG_ESP_SLEEP_DEEP_SLEEP_WAKEUP_DELAY=2000 CONFIG_ESP32S3_RTC_CLK_SRC_INT_RC=y @@ -2130,7 +2164,9 @@ CONFIG_ESP32_PHY_MAX_TX_POWER=20 # CONFIG_ESP32_REDUCE_PHY_TX_POWER is not set CONFIG_ESP_SYSTEM_PM_POWER_DOWN_CPU=y CONFIG_PM_POWER_DOWN_TAGMEM_IN_LIGHT_SLEEP=y -# CONFIG_ESP32S3_SPIRAM_SUPPORT is not set +CONFIG_ESP32S3_SPIRAM_SUPPORT=y +CONFIG_DEFAULT_PSRAM_CLK_IO=30 +CONFIG_DEFAULT_PSRAM_CS_IO=26 # CONFIG_ESP32S3_DEFAULT_CPU_FREQ_80 is not set CONFIG_ESP32S3_DEFAULT_CPU_FREQ_160=y # CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240 is not set @@ -2216,6 +2252,7 @@ CONFIG_TIMER_TASK_PRIORITY=1 CONFIG_TIMER_TASK_STACK_DEPTH=2048 CONFIG_TIMER_QUEUE_LENGTH=10 # CONFIG_ENABLE_STATIC_TASK_CLEAN_UP_HOOK is not set +CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY=y # CONFIG_HAL_ASSERTION_SILIENT is not set # CONFIG_L2_TO_L3_COPY is not set CONFIG_ESP_GRATUITOUS_ARP=y -- cgit v1.2.3