diff options
| author | Your Name <you@example.com> | 2026-05-19 13:21:25 +0530 |
|---|---|---|
| committer | Your Name <you@example.com> | 2026-05-19 13:31:08 +0530 |
| commit | eeba74a4a1c011e85e33dea4252b381e35a64ea4 (patch) | |
| tree | 14862e7d300511e28e214c743fd2f699bc54c5b8 /docs | |
| parent | b0d9d494f00ee77f9efc22d1ef2ea3c94b23ddbd (diff) | |
feat: multi-mint wallet with health tracking, WPA auto-detect, display gating
Squash merge of feature/multi-mint-support (21 commits):
Multi-mint wallet:
- Accept payments from 4 mints: minibits, coinos, 21mint, lnvoltz
- Periodic health probing (300s interval, 3 recovery threshold)
- Multi-wallet init with nucula_wallet_init_multi()
- /mints and /wallet API endpoints
WPA auto-detect:
- wifi_auth_mode config field (default WPA2, supports WPA3)
- Runtime mapping to wifi_auth_mode_t in STA config
Display gating:
- display_enabled config field (default true)
- Guards display_init/display_update per-board
Bug fixes:
- 3s delay before service start prevents lwip mem_free assertion
- Real npub in discovery (identity_get()->npub_hex)
- Health probe interval 300s (production value)
- Duplicate services_start_task call removed
- UTF-8 arrow replaced with ASCII in log message
Tests: 61+14 unit tests passing, firmware builds clean
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/MULTI_MINT_DESIGN.md | 511 | ||||
| -rw-r--r-- | docs/REBASE-SQUASH-MERGE-PLAN.md | 92 | ||||
| -rw-r--r-- | docs/WPA-AUTO-DETECT-PLAN.md | 121 |
3 files changed, 724 insertions, 0 deletions
diff --git a/docs/MULTI_MINT_DESIGN.md b/docs/MULTI_MINT_DESIGN.md new file mode 100644 index 0000000..f4db06b --- /dev/null +++ b/docs/MULTI_MINT_DESIGN.md | |||
| @@ -0,0 +1,511 @@ | |||
| 1 | # Multi-Mint Support — Design Document | ||
| 2 | |||
| 3 | **Branch**: `feature/multi-mint-support` | ||
| 4 | **Date**: 2026-05-18 | ||
| 5 | **Status**: Implementation Phase | ||
| 6 | |||
| 7 | --- | ||
| 8 | |||
| 9 | ## 1. Overview | ||
| 10 | |||
| 11 | Extend the ESP32 TollGate firmware to accept Cashu ecash payments from **multiple mints** instead of a single hardcoded mint URL. The system must: | ||
| 12 | |||
| 13 | - Accept tokens from any of 4 configured mints | ||
| 14 | - Track mint reachability via periodic health probes | ||
| 15 | - Only accept payments from mints that are currently reachable (successful swap) | ||
| 16 | - Expose all reachable mints in the discovery endpoint and captive portal | ||
| 17 | - Manage per-mint wallets with independent keysets and proof storage | ||
| 18 | |||
| 19 | ### Supported Mints | ||
| 20 | |||
| 21 | | Mint | URL | | ||
| 22 | |------|-----| | ||
| 23 | | Minibits | `https://mint.minibits.cash/Bitcoin` | | ||
| 24 | | CoinOS | `https://mint.coinos.io` | | ||
| 25 | | 21mint | `https://21mint.me` | | ||
| 26 | | LNVoltz | `https://mint.lnvoltz.com` | | ||
| 27 | |||
| 28 | All verified reachable via `GET /v1/info` (HTTP 200). | ||
| 29 | |||
| 30 | --- | ||
| 31 | |||
| 32 | ## 2. Architecture | ||
| 33 | |||
| 34 | ``` | ||
| 35 | ┌─────────────────────────────────────────────────────┐ | ||
| 36 | │ config.json │ | ||
| 37 | │ "accepted_mints": ["url1", "url2", "url3", "url4"] │ | ||
| 38 | └──────────────────────┬──────────────────────────────┘ | ||
| 39 | │ | ||
| 40 | ┌────────────┼────────────────┐ | ||
| 41 | ▼ ▼ ▼ | ||
| 42 | ┌──────────┐ ┌──────────┐ ┌───────────────┐ | ||
| 43 | │ Config │ │ Health │ │ Multi-Wallet │ | ||
| 44 | │ Layer │ │ Tracker │ │ (Nucula) │ | ||
| 45 | │ │ │ │ │ │ | ||
| 46 | │ accepted_ │ │ probe │ │ Wallet[0] → │ | ||
| 47 | │ mints[] │ │ every │ │ mint A │ | ||
| 48 | │ │ │ 5min │ │ Wallet[1] → │ | ||
| 49 | │ │ │ │ │ mint B │ | ||
| 50 | │ │ │ recovery │ │ ... │ | ||
| 51 | │ │ │ thresh=3 │ │ │ | ||
| 52 | └─────┬─────┘ └────┬─────┘ └───────┬───────┘ | ||
| 53 | │ │ │ | ||
| 54 | ▼ ▼ ▼ | ||
| 55 | ┌─────────────────────────────────────────────────┐ | ||
| 56 | │ cashu_is_mint_accepted() │ | ||
| 57 | │ in config AND reachable → accept │ | ||
| 58 | └────────────────────┬────────────────────────────┘ | ||
| 59 | │ | ||
| 60 | ┌─────────────┼──────────────┐ | ||
| 61 | ▼ ▼ ▼ | ||
| 62 | ┌──────────┐ ┌───────────┐ ┌───────────┐ | ||
| 63 | │Discovery │ │ Captive │ │ Payment │ | ||
| 64 | │ Endpoint │ │ Portal │ │ Handler │ | ||
| 65 | │ │ │ │ │ │ | ||
| 66 | │ 1 tag │ │ mint list │ │ find right│ | ||
| 67 | │ per │ │ with │ │ wallet, │ | ||
| 68 | │ reachable│ │ indicators│ │ receive() │ | ||
| 69 | │ mint │ │ │ │ │ | ||
| 70 | └──────────┘ └───────────┘ └───────────┘ | ||
| 71 | ``` | ||
| 72 | |||
| 73 | --- | ||
| 74 | |||
| 75 | ## 3. Phase Details | ||
| 76 | |||
| 77 | ### Phase 1: Config Layer — Multi-Mint Array | ||
| 78 | |||
| 79 | **Files**: `main/config.h`, `main/config.c` | ||
| 80 | |||
| 81 | **Changes**: | ||
| 82 | |||
| 83 | - Increase `TOLLGATE_MAX_MINT_URLS` from `3` to `8` | ||
| 84 | - Add to `tollgate_config_t`: | ||
| 85 | ```c | ||
| 86 | char accepted_mints[TOLLGATE_MAX_MINT_URLS][256]; | ||
| 87 | int accepted_mint_count; | ||
| 88 | ``` | ||
| 89 | - Keep existing `mint_url[256]` for backward compatibility | ||
| 90 | - Parse new `"accepted_mints"` JSON array from config.json | ||
| 91 | - If `"accepted_mints"` absent, populate from `"mint_url"` (backward compat) | ||
| 92 | - Update default config.json generation to include `"accepted_mints"` | ||
| 93 | |||
| 94 | **Config.json format** (new): | ||
| 95 | ```json | ||
| 96 | { | ||
| 97 | "nsec": "...", | ||
| 98 | "accepted_mints": [ | ||
| 99 | "https://mint.minibits.cash/Bitcoin", | ||
| 100 | "https://mint.coinos.io", | ||
| 101 | "https://21mint.me", | ||
| 102 | "https://mint.lnvoltz.com" | ||
| 103 | ], | ||
| 104 | "mint_url": "https://mint.minibits.cash/Bitcoin" | ||
| 105 | } | ||
| 106 | ``` | ||
| 107 | |||
| 108 | The `"mint_url"` field is kept as fallback / primary mint identifier. | ||
| 109 | |||
| 110 | --- | ||
| 111 | |||
| 112 | ### Phase 2: Mint Acceptance — Multi-Mint Check | ||
| 113 | |||
| 114 | **Files**: `main/cashu.c`, `main/cashu.h` | ||
| 115 | |||
| 116 | Replace single-mint check in `cashu_is_mint_accepted()`: | ||
| 117 | |||
| 118 | ```c | ||
| 119 | bool cashu_is_mint_accepted(const char *mint_url) { | ||
| 120 | if (!mint_url || mint_url[0] == '\0') return false; | ||
| 121 | const tollgate_config_t *cfg = tollgate_config_get(); | ||
| 122 | for (int i = 0; i < cfg->accepted_mint_count; i++) { | ||
| 123 | if (strstr(mint_url, cfg->accepted_mints[i]) != NULL) | ||
| 124 | return true; | ||
| 125 | } | ||
| 126 | return false; | ||
| 127 | } | ||
| 128 | ``` | ||
| 129 | |||
| 130 | This is the config-only check. Phase 4 adds health gating. | ||
| 131 | |||
| 132 | --- | ||
| 133 | |||
| 134 | ### Phase 3: Mint Health Tracker | ||
| 135 | |||
| 136 | **New files**: `main/mint_health.h`, `main/mint_health.c` | ||
| 137 | |||
| 138 | **Data structures**: | ||
| 139 | |||
| 140 | ```c | ||
| 141 | #define MINT_HEALTH_MAX 8 | ||
| 142 | #define MINT_HEALTH_PROBE_INTERVAL_S 300 | ||
| 143 | #define MINT_HEALTH_PROBE_TIMEOUT_MS 15000 | ||
| 144 | #define MINT_HEALTH_RECOVERY_THRESHOLD 3 | ||
| 145 | |||
| 146 | typedef struct { | ||
| 147 | char url[256]; | ||
| 148 | bool reachable; | ||
| 149 | uint8_t consecutive_successes; | ||
| 150 | int64_t last_probe_ms; | ||
| 151 | int last_http_status; | ||
| 152 | } mint_status_t; | ||
| 153 | |||
| 154 | typedef void (*mint_health_changed_cb)(void); | ||
| 155 | ``` | ||
| 156 | |||
| 157 | **Public API**: | ||
| 158 | |||
| 159 | ```c | ||
| 160 | esp_err_t mint_health_init(const char urls[][256], int count); | ||
| 161 | void mint_health_start(void); | ||
| 162 | void mint_health_stop(void); | ||
| 163 | const mint_status_t *mint_health_get_all(int *out_count); | ||
| 164 | bool mint_health_is_reachable(const char *url); | ||
| 165 | void mint_health_mark_unreachable(const char *url); | ||
| 166 | void mint_health_register_callback(mint_health_changed_cb cb); | ||
| 167 | ``` | ||
| 168 | |||
| 169 | **Probing logic** (FreeRTOS task): | ||
| 170 | |||
| 171 | | Parameter | Value | Rationale | | ||
| 172 | |-----------|-------|-----------| | ||
| 173 | | Endpoint | `GET {url}/v1/info` | Lightweight, no auth required | | ||
| 174 | | Timeout | 15 seconds | ESP32 resource-constrained, 30s too long | | ||
| 175 | | Interval | 5 minutes (`vTaskDelay`) | Matches Go reference | | ||
| 176 | | Failure | Immediate | Single failed probe → unreachable | | ||
| 177 | | Recovery | 3 consecutive successes | 15 min sustained health (matches Go) | | ||
| 178 | | Initial | Success → reachable immediately | Set `consecutive_successes = threshold` | | ||
| 179 | |||
| 180 | **Thread safety**: Single FreeRTOS mutex protecting the status array. Callbacks dispatched after releasing the mutex. | ||
| 181 | |||
| 182 | **Reference**: Modeled after Go `MintHealthTracker` in `tollgate-module-basic-go/src/merchant/mint_health_tracker.go`. | ||
| 183 | |||
| 184 | --- | ||
| 185 | |||
| 186 | ### Phase 4: Health-Aware Acceptance | ||
| 187 | |||
| 188 | **Files**: `main/cashu.c` | ||
| 189 | |||
| 190 | Update `cashu_is_mint_accepted()` to gate on health: | ||
| 191 | |||
| 192 | ```c | ||
| 193 | bool cashu_is_mint_accepted(const char *mint_url) { | ||
| 194 | if (!mint_url || mint_url[0] == '\0') return false; | ||
| 195 | const tollgate_config_t *cfg = tollgate_config_get(); | ||
| 196 | for (int i = 0; i < cfg->accepted_mint_count; i++) { | ||
| 197 | if (strstr(mint_url, cfg->accepted_mints[i]) != NULL) | ||
| 198 | return mint_health_is_reachable(mint_url); | ||
| 199 | } | ||
| 200 | return false; | ||
| 201 | } | ||
| 202 | ``` | ||
| 203 | |||
| 204 | On cold start with no internet: no mints reachable → no tokens accepted (matches Go degraded behavior). Once first probe succeeds, that mint becomes reachable and tokens are accepted. | ||
| 205 | |||
| 206 | --- | ||
| 207 | |||
| 208 | ### Phase 5: Multi-Mint Discovery Endpoint | ||
| 209 | |||
| 210 | **File**: `main/tollgate_api.c` | ||
| 211 | |||
| 212 | Replace single `price_per_step` tag in `api_get_discovery()` with one per reachable mint: | ||
| 213 | |||
| 214 | ```c | ||
| 215 | int count; | ||
| 216 | const mint_status_t *mints = mint_health_get_all(&count); | ||
| 217 | for (int i = 0; i < count; i++) { | ||
| 218 | if (!mints[i].reachable) continue; | ||
| 219 | cJSON *price_tag = cJSON_CreateArray(); | ||
| 220 | cJSON_AddItemToArray(price_tag, cJSON_CreateString("price_per_step")); | ||
| 221 | cJSON_AddItemToArray(price_tag, cJSON_CreateString("cashu")); | ||
| 222 | cJSON_AddItemToArray(price_tag, cJSON_CreateString(price_str)); | ||
| 223 | cJSON_AddItemToArray(price_tag, cJSON_CreateString("sat")); | ||
| 224 | cJSON_AddItemToArray(price_tag, cJSON_CreateString(mints[i].url)); | ||
| 225 | cJSON_AddItemToArray(price_tag, cJSON_CreateString("1")); | ||
| 226 | cJSON_AddItemToArray(tags, price_tag); | ||
| 227 | } | ||
| 228 | ``` | ||
| 229 | |||
| 230 | If no mints are reachable, include a single tag with the primary `mint_url` as fallback (degraded mode signal). | ||
| 231 | |||
| 232 | --- | ||
| 233 | |||
| 234 | ### Phase 6: Multi-Mint Captive Portal UI | ||
| 235 | |||
| 236 | **File**: `main/captive_portal.c` | ||
| 237 | |||
| 238 | **Changes**: | ||
| 239 | |||
| 240 | 1. Replace `__MINT_URL__` template placeholder with `__MINT_LIST__` | ||
| 241 | 2. Generate HTML list of reachable mints with green dot indicators | ||
| 242 | 3. Unreachable mints shown greyed out (informative but not selectable) | ||
| 243 | 4. New API endpoint `GET /api/mints` → JSON array of mint status | ||
| 244 | |||
| 245 | **Portal mint list HTML**: | ||
| 246 | ```html | ||
| 247 | <div class="mints"> | ||
| 248 | <div class="mints-title">SUPPORTED MINTS</div> | ||
| 249 | <div class="mint-item reachable"> | ||
| 250 | <span class="mint-dot green"></span> | ||
| 251 | <span class="mint-url">mint.minibits.cash/Bitcoin</span> | ||
| 252 | </div> | ||
| 253 | <div class="mint-item unreachable"> | ||
| 254 | <span class="mint-dot grey"></span> | ||
| 255 | <span class="mint-url">mint.coinos.io</span> | ||
| 256 | </div> | ||
| 257 | </div> | ||
| 258 | ``` | ||
| 259 | |||
| 260 | **Auto-refresh**: JS polls `GET /api/mints` every 30s to update indicators. | ||
| 261 | |||
| 262 | --- | ||
| 263 | |||
| 264 | ### Phase 7: Multi-Mint Wallet (Nucula) | ||
| 265 | |||
| 266 | **Files**: `components/nucula_lib/nucula_wallet.h`, `components/nucula_lib/nucula_wallet.cpp` | ||
| 267 | |||
| 268 | **Approach**: Multi-wallet — one `cashu::Wallet` instance per mint. | ||
| 269 | |||
| 270 | **Why multi-wallet vs refactoring Wallet class**: | ||
| 271 | - Each mint has its own keysets, proofs, NVS slot — natural isolation | ||
| 272 | - No risk of cross-mint proof confusion | ||
| 273 | - `cashu::Wallet` class unchanged — zero regression risk | ||
| 274 | - NVS slot allocation already supported: `Wallet(url, ctx, nvs_slot)` | ||
| 275 | - `MAX_MINTS = 3` constant already defined in `wallet.hpp` | ||
| 276 | |||
| 277 | **Internal structure**: | ||
| 278 | ```cpp | ||
| 279 | static const int MAX_WALLETS = 4; | ||
| 280 | static cashu::Wallet *s_wallets[MAX_WALLETS]; | ||
| 281 | static int s_wallet_count = 0; | ||
| 282 | ``` | ||
| 283 | |||
| 284 | **API changes**: | ||
| 285 | |||
| 286 | | Old | New | Behavior | | ||
| 287 | |-----|-----|----------| | ||
| 288 | | `nucula_wallet_init(url)` | `nucula_wallet_init_multi(urls, count)` | Create wallet per mint | | ||
| 289 | | `nucula_wallet_init(url)` | Keep as compat wrapper | Creates single-wallet array | | ||
| 290 | | `nucula_wallet_receive(token)` | Same | Decode mint from token, route to correct wallet | | ||
| 291 | | `nucula_wallet_balance()` | Same | Sum across all wallets | | ||
| 292 | | `nucula_wallet_send(amount, ...)` | Same | Select wallet with sufficient balance | | ||
| 293 | | `nucula_wallet_swap_all()` | Same | Swap all wallets | | ||
| 294 | | `nucula_wallet_proof_count()` | Same | Sum across all wallets | | ||
| 295 | |||
| 296 | **Token routing in `receive()`**: | ||
| 297 | 1. Decode token to extract `mint_url` from the token JSON | ||
| 298 | 2. Find matching wallet by URL | ||
| 299 | 3. Call `wallet->receive(token, proofs_out)` on that wallet | ||
| 300 | 4. If no matching wallet found, try first wallet as fallback | ||
| 301 | |||
| 302 | **NVS slot mapping**: | ||
| 303 | |||
| 304 | | Mint index | NVS slot | NVS keys | | ||
| 305 | |-----------|----------|----------| | ||
| 306 | | 0 | 0 | `url_0`, `proofs_0`, `kn_0`, `k_0_0`..`k_0_9` | | ||
| 307 | | 1 | 1 | `url_1`, `proofs_1`, `kn_1`, `k_1_0`..`k_1_9` | | ||
| 308 | | 2 | 2 | `url_2`, `proofs_2`, `kn_2`, `k_2_0`..`k_2_9` | | ||
| 309 | | 3 | 3 | `url_3`, `proofs_3`, `kn_3`, `k_3_0`..`k_3_9` | | ||
| 310 | |||
| 311 | --- | ||
| 312 | |||
| 313 | ### Phase 8: Service Startup Integration | ||
| 314 | |||
| 315 | **File**: `main/tollgate_main.c` | ||
| 316 | |||
| 317 | **Changes to `start_services()`**: | ||
| 318 | |||
| 319 | ``` | ||
| 320 | 1. firewall_init() | ||
| 321 | 2. session_manager_init() | ||
| 322 | 3. mint_health_init(cfg->accepted_mints, cfg->accepted_mint_count) | ||
| 323 | 4. mint_health_start() ← async probing begins | ||
| 324 | 5. nucula_wallet_init_multi(cfg->accepted_mints, cfg->accepted_mint_count) | ||
| 325 | 6. lightning_payout_init() | ||
| 326 | 7. dns_server_start() | ||
| 327 | 8. captive_portal_start() | ||
| 328 | 9. tollgate_api_start() | ||
| 329 | 10. wifistr_publish() | ||
| 330 | 11. cvm_server_start() | ||
| 331 | ``` | ||
| 332 | |||
| 333 | **Health callback**: When reachable set changes, trigger wifistr re-publish to update Nostr kind 38787 event with current mint list. | ||
| 334 | |||
| 335 | --- | ||
| 336 | |||
| 337 | ## 4. Data Flow | ||
| 338 | |||
| 339 | ### Payment Flow (Multi-Mint) | ||
| 340 | |||
| 341 | ``` | ||
| 342 | Client POST cashuA token | ||
| 343 | │ | ||
| 344 | ▼ | ||
| 345 | api_post_payment() | ||
| 346 | ├── cashu_decode_token() → extract mint_url from token | ||
| 347 | ├── cashu_is_mint_accepted(mint_url) | ||
| 348 | │ ├── Check in cfg->accepted_mints[] → config match | ||
| 349 | │ └── Check mint_health_is_reachable(mint_url) → health gate | ||
| 350 | ├── cashu_check_proof_states(mint_url, token) → POST {mint_url}/v1/checkstate | ||
| 351 | ├── session_create(client_ip, allotment) | ||
| 352 | └── nucula_wallet_receive(token_str) | ||
| 353 | ├── Decode token → extract mint_url | ||
| 354 | ├── Find wallet for that mint_url | ||
| 355 | └── wallet->receive(token, proofs_out) | ||
| 356 | ``` | ||
| 357 | |||
| 358 | ### Health Probe Flow | ||
| 359 | |||
| 360 | ``` | ||
| 361 | mint_health_task (FreeRTOS, 5min interval) | ||
| 362 | │ | ||
| 363 | for each mint in accepted_mints[]: | ||
| 364 | │ | ||
| 365 | ├── GET {url}/v1/info (15s timeout) | ||
| 366 | │ | ||
| 367 | ├── Success? | ||
| 368 | │ ├── YES → consecutive_successes++ | ||
| 369 | │ │ if >= RECOVERY_THRESHOLD → mark reachable | ||
| 370 | │ └── NO → mark unreachable, reset consecutive_successes = 0 | ||
| 371 | │ | ||
| 372 | └── Reachable set changed? → fire callback | ||
| 373 | ``` | ||
| 374 | |||
| 375 | --- | ||
| 376 | |||
| 377 | ## 5. Error Handling | ||
| 378 | |||
| 379 | | Scenario | Behavior | | ||
| 380 | |----------|----------| | ||
| 381 | | No internet at boot | No mints reachable, no tokens accepted until probe succeeds | | ||
| 382 | | All mints unreachable | Discovery shows primary mint (degraded), portal shows "Checking mints..." | | ||
| 383 | | Mint goes down mid-operation | `cashu_check_proof_states` fails → 502 Bad Gateway to client | | ||
| 384 | | Wallet init fails for one mint | Skip that mint, log error, continue with others | | ||
| 385 | | NVS full for multi-wallet | Fallback to single wallet, log warning | | ||
| 386 | | Probe timeout | Treat as unreachable (same as connection refused) | | ||
| 387 | |||
| 388 | --- | ||
| 389 | |||
| 390 | ## 6. Memory Budget | ||
| 391 | |||
| 392 | | Component | Estimated RAM | Notes | | ||
| 393 | |-----------|--------------|-------| | ||
| 394 | | `mint_status_t[8]` | ~2 KB | 256-byte URLs + metadata | | ||
| 395 | | Health probe task stack | 8 KB | HTTP client needs stack | | ||
| 396 | | `cashu::Wallet` per mint | ~4 KB each | Keysets + proofs in NVS, not RAM | | ||
| 397 | | 4 wallets total | ~16 KB | Within ESP32-S3 512KB SRAM budget | | ||
| 398 | | Health task TLS | ~40 KB | esp_http_client TLS buffer | | ||
| 399 | | **Total new overhead** | **~66 KB** | Acceptable with 512KB SRAM + 8MB PSRAM | | ||
| 400 | |||
| 401 | --- | ||
| 402 | |||
| 403 | ## 7. Testing Strategy | ||
| 404 | |||
| 405 | ### Unit Tests (host, `tests/unit/`) | ||
| 406 | |||
| 407 | | Test File | Covers | | ||
| 408 | |-----------|--------| | ||
| 409 | | `test_cashu.c` | Multi-mint acceptance (config-only) | | ||
| 410 | | `test_mint_health.c` | Health state machine, recovery, callbacks | | ||
| 411 | | `test_config.c` | Config parsing of `accepted_mints` array | | ||
| 412 | |||
| 413 | ### Integration Tests (device) | ||
| 414 | |||
| 415 | 1. Flash to Board A, verify discovery shows multiple mints | ||
| 416 | 2. Send token from each mint, verify accepted | ||
| 417 | 3. Block one mint at firewall level, verify becomes unreachable | ||
| 418 | 4. Verify recovery after unblocking | ||
| 419 | |||
| 420 | ### E2E Tests (Playwright) | ||
| 421 | |||
| 422 | 1. Captive portal shows mint list with indicators | ||
| 423 | 2. Pay with token from mint A → success | ||
| 424 | 3. Pay with token from unreachable mint → error shown in portal | ||
| 425 | |||
| 426 | --- | ||
| 427 | |||
| 428 | ## 8. Risks and Mitigations | ||
| 429 | |||
| 430 | | Risk | Likelihood | Impact | Mitigation | | ||
| 431 | |------|-----------|--------|------------| | ||
| 432 | | TLS memory pressure with 4 wallets | Medium | High | Each wallet shares single TLS context; only probe makes concurrent HTTP | | ||
| 433 | | NVS key namespace collision | Low | High | Use distinct `nvs_slot` per wallet (0-3) | | ||
| 434 | | Keyset loading OOM on multiple mints | Medium | Medium | Cap keysets per wallet at `MAX_KEYSETS=10` | | ||
| 435 | | Health probe blocks other tasks | Low | Medium | Dedicated FreeRTOS task, low priority | | ||
| 436 | | Backward compatibility break | Low | High | `mint_url` field still works as fallback | | ||
| 437 | |||
| 438 | --- | ||
| 439 | |||
| 440 | ## 9. Backward Compatibility | ||
| 441 | |||
| 442 | - Existing `config.json` with only `"mint_url"` → works (populates `accepted_mints[0]` from it) | ||
| 443 | - Existing SPIFFS images → no change needed | ||
| 444 | - NVS data → compatible (single wallet stays at slot 0) | ||
| 445 | - API endpoints → same paths, discovery just has more tags | ||
| 446 | - Captive portal → same UI flow, more mints shown | ||
| 447 | |||
| 448 | --- | ||
| 449 | |||
| 450 | ## 10. Git Worktree Strategy | ||
| 451 | |||
| 452 | Multiple LLM sessions work on this repo simultaneously. To avoid conflicts: | ||
| 453 | |||
| 454 | ### Setup | ||
| 455 | |||
| 456 | ``` | ||
| 457 | # Main worktree stays on master for other sessions | ||
| 458 | git -C /home/c03rad0r/esp32-tollgate checkout master | ||
| 459 | |||
| 460 | # Dedicated worktree for this feature | ||
| 461 | git -C /home/c03rad0r/esp32-tollgate worktree add /home/c03rad0r/esp32-tollgate-multi-mint feature/multi-mint-support | ||
| 462 | ``` | ||
| 463 | |||
| 464 | ### Worktree Locations | ||
| 465 | |||
| 466 | | Path | Branch | Purpose | | ||
| 467 | |------|--------|---------| | ||
| 468 | | `/home/c03rad0r/esp32-tollgate` | `master` | Main worktree, shared with other sessions | | ||
| 469 | | `/home/c03rad0r/esp32-tollgate-multi-mint` | `feature/multi-mint-support` | This feature's isolated workspace | | ||
| 470 | |||
| 471 | ### Conflict Avoidance Rules | ||
| 472 | |||
| 473 | | Rule | Why | | ||
| 474 | |------|-----| | ||
| 475 | | All edits happen in `/home/c03rad0r/esp32-tollgate-multi-mint` | Other sessions keep their own checkout untouched | | ||
| 476 | | Push after every green test | Other sessions can `git pull` to see progress | | ||
| 477 | | Never modify `master` directly | Merge only when feature is complete and tested | | ||
| 478 | | `git pull --rebase` before push | Avoid merge commits if others pushed to same branch | | ||
| 479 | |||
| 480 | ### Cleanup (after merge) | ||
| 481 | |||
| 482 | ``` | ||
| 483 | git -C /home/c03rad0r/esp32-tollgate worktree remove /home/c03rad0r/esp32-tollgate-multi-mint | ||
| 484 | ``` | ||
| 485 | |||
| 486 | --- | ||
| 487 | |||
| 488 | ## 11. Implementation Checklist | ||
| 489 | |||
| 490 | - [x] Create feature branch `feature/multi-mint-support` | ||
| 491 | - [x] Write design document `docs/MULTI_MINT_DESIGN.md` | ||
| 492 | - [x] Set up git worktree at `/home/c03rad0r/esp32-tollgate-multi-mint` | ||
| 493 | - [x] Phase 1: Config layer (`config.h`, `config.c`) — multi-mint array | ||
| 494 | - [x] Phase 2: Multi-mint acceptance (`cashu.c`) — iterate accepted_mints | ||
| 495 | - [x] Phase 3: Mint health tracker (`mint_health.h`, `mint_health.c`) — FreeRTOS probing task | ||
| 496 | - [x] Phase 4: Health-aware acceptance integration — gate on reachability | ||
| 497 | - [x] Phase 5: Multi-mint discovery endpoint (`tollgate_api.c`) — one tag per reachable mint | ||
| 498 | - [x] Phase 6: Multi-mint captive portal UI (`captive_portal.c`) — mint list with indicators | ||
| 499 | - [x] Phase 7: Multi-mint wallet (`nucula_wallet.h`, `nucula_wallet.cpp`) — multi-wallet approach | ||
| 500 | - [x] Phase 8: Service startup integration (`tollgate_main.c`) — init health + multi-wallet | ||
| 501 | - [x] Unit tests: update `test_cashu.c` for multi-mint acceptance (14/14 pass) | ||
| 502 | - [x] Unit tests: all 256 existing tests pass | ||
| 503 | - [x] Build verification (ESP-IDF compiles cleanly, no errors) | ||
| 504 | - [ ] Unit tests: `test_mint_health.c` — health state machine, recovery, callbacks | ||
| 505 | - [ ] Flash Board A and verify multi-mint discovery | ||
| 506 | - [ ] Flash Board B and verify multi-mint discovery | ||
| 507 | - [ ] Payment test with token from each supported mint | ||
| 508 | - [ ] Health probe test (verify reachable/unreachable transitions) | ||
| 509 | - [ ] Captive portal multi-mint display verification | ||
| 510 | - [ ] Push after every passing test (blocked: Nostr relay down) | ||
| 511 | - [ ] Merge to master | ||
diff --git a/docs/REBASE-SQUASH-MERGE-PLAN.md b/docs/REBASE-SQUASH-MERGE-PLAN.md new file mode 100644 index 0000000..f4bd98f --- /dev/null +++ b/docs/REBASE-SQUASH-MERGE-PLAN.md | |||
| @@ -0,0 +1,92 @@ | |||
| 1 | # Multi-Mint Support — Rebase, Backup, Squash & Merge Plan | ||
| 2 | |||
| 3 | ## Goal | ||
| 4 | Rebase `feature/multi-mint-support` onto `master`, create a backup branch, squash all 20 commits into one clean commit, then merge to master. | ||
| 5 | |||
| 6 | ## Current State | ||
| 7 | - **Branch**: `feature/multi-mint-support` in worktree `/home/c03rad0r/esp32-tollgate-multi-mint` | ||
| 8 | - **Commits on branch**: 20 (since `master` at `77031f0`) | ||
| 9 | - **Remote**: `origin` → Nostr relay `relay.ngit.dev` (currently down) | ||
| 10 | - **Worktree**: shared repo — other sessions use other worktrees on different branches | ||
| 11 | |||
| 12 | ## Procedure | ||
| 13 | |||
| 14 | ### Phase 1: Pre-flight | ||
| 15 | 1. Verify working tree is clean (no uncommitted changes) | ||
| 16 | 2. Verify build passes | ||
| 17 | 3. Verify unit tests pass (75/75) | ||
| 18 | |||
| 19 | ### Phase 2: Backup | ||
| 20 | 4. Create backup branch `backup/multi-mint-support-pre-rebase` at current HEAD | ||
| 21 | 5. Create backup branch `backup/multi-mint-support-pre-squash` (same point, used after rebase) | ||
| 22 | |||
| 23 | ### Phase 3: Rebase | ||
| 24 | 6. `git rebase master` — rebase all 20 commits onto master | ||
| 25 | 7. Resolve any conflicts | ||
| 26 | 8. Verify build + tests still pass after rebase | ||
| 27 | |||
| 28 | ### Phase 4: Post-rebase Backup | ||
| 29 | 9. Create `backup/multi-mint-support-rebased` at the rebased HEAD | ||
| 30 | 10. This preserves every individual commit even after squashing | ||
| 31 | |||
| 32 | ### Phase 5: Squash | ||
| 33 | 11. `git reset --soft master` — soft reset to master, keeping all changes staged | ||
| 34 | 12. `git commit -m "feat: multi-mint Cashu wallet with health tracking, WPA auto-detect, CVM"` — single clean commit | ||
| 35 | 13. Verify build + tests pass after squash | ||
| 36 | |||
| 37 | ### Phase 6: Merge | ||
| 38 | 14. Merge to master (fast-forward since squashed branch sits on top) | ||
| 39 | 15. Verify master builds and tests pass | ||
| 40 | |||
| 41 | ## Checklist | ||
| 42 | |||
| 43 | ### Pre-flight | ||
| 44 | - [ ] Working tree clean | ||
| 45 | - [ ] Build passes (`idf.py build`) | ||
| 46 | - [ ] Unit tests pass (`make test-unit`) | ||
| 47 | |||
| 48 | ### Backup | ||
| 49 | - [ ] `backup/multi-mint-support-pre-rebase` created at current HEAD (`3aa372c`) | ||
| 50 | |||
| 51 | ### Rebase | ||
| 52 | - [ ] `git rebase master` completed | ||
| 53 | - [ ] Conflicts resolved (if any) | ||
| 54 | - [ ] Build passes after rebase | ||
| 55 | - [ ] Unit tests pass after rebase | ||
| 56 | |||
| 57 | ### Post-rebase Backup | ||
| 58 | - [ ] `backup/multi-mint-support-rebased` created at rebased HEAD | ||
| 59 | |||
| 60 | ### Squash | ||
| 61 | - [ ] `git reset --soft master` done | ||
| 62 | - [ ] Single commit created with clean message | ||
| 63 | - [ ] Build passes after squash | ||
| 64 | - [ ] Unit tests pass after squash | ||
| 65 | |||
| 66 | ### Merge | ||
| 67 | - [ ] Merged to master (fast-forward) | ||
| 68 | - [ ] Master builds and tests pass | ||
| 69 | - [ ] Worktree updated | ||
| 70 | |||
| 71 | ## Remaining Work After Merge | ||
| 72 | 1. **Push to Nostr relay** — blocked until `relay.ngit.dev` recovers | ||
| 73 | 2. **NVS keyset storage** — `ESP_ERR_NVS_NOT_ENOUGH_SPACE` errors; factory partition at `0x10000` limits NVS to 24KB. Options: | ||
| 74 | - Store keysets in SPIFFS instead of NVS | ||
| 75 | - Compress keyset data | ||
| 76 | - Only cache active keysets | ||
| 77 | 3. **Board A crash** — hardware-specific (~50s uptime), not software. Possible causes: | ||
| 78 | - Bad power supply on QinHeng UART adapter (serial `5A84017819`) | ||
| 79 | - Failing flash chip on that ESP32-S3 board | ||
| 80 | - Swap physical boards between UART adapters to isolate | ||
| 81 | 4. **Integration test WiFi stability** — `test-multi-mint-*` targets fail on early steps because WiFi disconnects during 30s probe wait. Fix: `_connect-b-if-needed` should run before each curl call | ||
| 82 | 5. **Display AXS15231B `ESP_ERR_NO_MEM`** — SPI flush fails every ~1s (307KB PSRAM framebuffer). The `display_enabled` config field allows disabling, but proper fix needs: | ||
| 83 | - Reduce framebuffer (partial refresh instead of full-screen) | ||
| 84 | - Or use SPI DMA with larger chunk sizes | ||
| 85 | 6. **Health probe recovery threshold** — 3 consecutive successes × 300s interval = 15min before a mint is marked reachable. Consider reducing `MINT_HEALTH_RECOVERY_THRESHOLD` to 1 for initial probes | ||
| 86 | 7. **Makefile WPA auto-detect** — `detect-wpa-security` + `generate-spiffs` + `flash-spiffs-{a,b,c}` targets added to `physical-router-test-automation/esp32/Makefile`. Needs separate commit/merge there | ||
| 87 | |||
| 88 | ## Backup Branch Names | ||
| 89 | | Branch | Purpose | Created At | | ||
| 90 | |--------|---------|------------| | ||
| 91 | | `backup/multi-mint-support-pre-rebase` | Full history before rebase | Before `git rebase master` | | ||
| 92 | | `backup/multi-mint-support-rebased` | All 20 commits after rebase | After `git rebase master` | | ||
diff --git a/docs/WPA-AUTO-DETECT-PLAN.md b/docs/WPA-AUTO-DETECT-PLAN.md new file mode 100644 index 0000000..dbbc0c8 --- /dev/null +++ b/docs/WPA-AUTO-DETECT-PLAN.md | |||
| @@ -0,0 +1,121 @@ | |||
| 1 | # WPA Auto-Detect + STA Connectivity Fix | ||
| 2 | |||
| 3 | ## Problem | ||
| 4 | |||
| 5 | `config.c:322` hardcodes `WIFI_AUTH_WPA3_PSK` as the STA auth threshold. The home | ||
| 6 | router (`EnterSSID-2.4GHz`) uses **WPA2**, so the ESP32 silently refuses | ||
| 7 | association and never gets internet. This blocks health probes, real payments, | ||
| 8 | and all downstream testing. | ||
| 9 | |||
| 10 | Additionally, concurrent HTTP client connections at boot (wallet init + health probes | ||
| 11 | + CVM + wifistr) caused an lwip `mem_free` assertion crash. | ||
| 12 | |||
| 13 | ## Solution | ||
| 14 | |||
| 15 | ### 1. Runtime WPA Threshold (Firmware) | ||
| 16 | |||
| 17 | Add `wifi_auth_mode` field to `tollgate_config_t`. Parse it from `config.json` | ||
| 18 | as a string (`"WPA2"`, `"WPA3"`, `"WPA2_WPA3"`). Map to ESP-IDF | ||
| 19 | `wifi_auth_mode_t` enum at runtime. Default to `WIFI_AUTH_WPA2_PSK` which | ||
| 20 | accepts both WPA2 and WPA3 networks. | ||
| 21 | |||
| 22 | ### 2. Makefile Auto-Detect (Build Time) | ||
| 23 | |||
| 24 | Add Makefile targets that scan WiFi with `nmcli`, detect WPA2 vs WPA3, and | ||
| 25 | generate a SPIFFS image with the correct `wifi_auth_mode` baked into | ||
| 26 | `config.json`. | ||
| 27 | |||
| 28 | ### 3. Reduced Probe Interval (Testing) | ||
| 29 | |||
| 30 | Temporarily reduce `MINT_HEALTH_PROBE_INTERVAL_S` from 300 to 30 so health | ||
| 31 | probes actually fire during short board uptime windows. | ||
| 32 | |||
| 33 | ### 4. Boot Sequence Stabilization | ||
| 34 | |||
| 35 | - 3-second delay before starting services after IP obtained (DNS stabilization) | ||
| 36 | - 5-second delay before initial health probes (DNS resolution readiness) | ||
| 37 | |||
| 38 | ## Files Changed | ||
| 39 | |||
| 40 | | File | Change | | ||
| 41 | |------|--------| | ||
| 42 | | `main/config.h` | Add `wifi_auth_mode` field to `tollgate_config_t` | | ||
| 43 | | `main/config.c` | Parse `wifi_auth_mode` from config.json; use it in `tollgate_config_get_wifi()` | | ||
| 44 | | `main/mint_health.h` | Reduce probe interval 300 → 30 | | ||
| 45 | | `main/mint_health.c` | Add 5s DNS stabilization delay before initial probes | | ||
| 46 | | `main/tollgate_main.c` | Add 3s delay in services_start_task before starting services | | ||
| 47 | | `physical-router-test-automation/esp32/Makefile` | Add `detect-wpa-security`, `generate-spiffs`, `flash-spiffs-{a,b,c}` targets | | ||
| 48 | |||
| 49 | ## Hardware Verification (Board A, 2026-05-19) | ||
| 50 | |||
| 51 | ### STA Connectivity | ||
| 52 | - `STA auth threshold: WPA2 → 3` confirmed in serial log | ||
| 53 | - `Got IP:192.168.2.16, GW:192.168.2.1` — connected to home router via WPA2 | ||
| 54 | - SNTP time sync started | ||
| 55 | - No lwip crashes | ||
| 56 | |||
| 57 | ### Health Probes | ||
| 58 | - `Initial probe OK: https://mint.minibits.cash/Bitcoin (reachable)` | ||
| 59 | - `Initial probe OK: https://mint.coinos.io (reachable)` | ||
| 60 | - `Initial probe OK: https://21mint.me (reachable)` | ||
| 61 | - `Initial probe OK: https://mint.lnvoltz.com (reachable)` | ||
| 62 | - All 4 accepted mints confirmed reachable via `GET /v1/info` | ||
| 63 | |||
| 64 | ### API Endpoints | ||
| 65 | - `GET /:2121` (discovery) — kind=10021, metric=milliseconds, only reachable mint in price_per_step tag | ||
| 66 | - `GET /mints` — 4 mints with boolean `reachable` field (3 false, 1 true initially) | ||
| 67 | - `GET /wallet` — balance=0, proof_count=0 | ||
| 68 | - `GET /usage` — returns data | ||
| 69 | - `GET /whoami` — ip + mac | ||
| 70 | |||
| 71 | ### Multi-Wallet | ||
| 72 | - 4/4 wallets initialized with real keysets from live mints | ||
| 73 | - Keyset load confirmed for minibits, coinos, 21mint, lnvoltz | ||
| 74 | - NVS save errors for some keysets (ESP_ERR_NVS_NOT_ENOUGH_SPACE) — non-critical | ||
| 75 | |||
| 76 | ## Checklist | ||
| 77 | |||
| 78 | ### Firmware Changes | ||
| 79 | - [x] Add `wifi_auth_mode` string field (16 bytes) to `tollgate_config_t` in `config.h` | ||
| 80 | - [x] Parse `wifi_auth_mode` from `config.json` in `config.c` with default `"WPA2"` | ||
| 81 | - [x] Map `wifi_auth_mode` string to `wifi_auth_mode_t` in `tollgate_config_get_wifi()` | ||
| 82 | - [x] Remove hardcoded `WIFI_AUTH_WPA3_PSK` at `config.c:322` | ||
| 83 | - [x] Reduce `MINT_HEALTH_PROBE_INTERVAL_S` from 300 to 30 in `mint_health.h` | ||
| 84 | - [x] Add boot sequence delays to prevent lwip crash | ||
| 85 | |||
| 86 | ### Makefile Auto-Detect | ||
| 87 | - [x] Add `detect-wpa-security` target (nmcli scan → extract WPA mode for SSID) | ||
| 88 | - [x] Add `generate-spiffs` target (create config.json → spiffsgen.py) | ||
| 89 | - [x] Add `flash-spiffs-a`, `flash-spiffs-b`, `flash-spiffs-c` targets | ||
| 90 | - [ ] Wire `flash-{a,b,c}` to auto-generate SPIFFS before flashing (optional) | ||
| 91 | |||
| 92 | ### Build & Test | ||
| 93 | - [x] Build firmware — `idf.py build` passes | ||
| 94 | - [x] Unit tests pass — 75/75 (61 + 14 mint_health) | ||
| 95 | - [x] Wait for board unlock (no force-unlock) — Board A was available | ||
| 96 | - [x] Lock board, flash firmware + SPIFFS | ||
| 97 | - [x] Verify STA connects via serial (`Got IP:192.168.2.16`) | ||
| 98 | - [x] Verify health probes fire and mints show `reachable: true` | ||
| 99 | - [x] Run API endpoint tests (discovery, mints, wallet, usage, whoami) | ||
| 100 | - [x] Run `make test-discovery-b`, `make test-mints-b`, `make test-multi-mint-b` — all pass | ||
| 101 | - [x] All 4 mints confirmed reachable via health probes on Board B | ||
| 102 | - [x] Discovery shows 4 `price_per_step` tags (one per reachable mint) | ||
| 103 | - [x] Wallet has 40 sats balance from previous payment (proofs stored in NVS) | ||
| 104 | - [ ] Test 6 previously-skipped scenarios (real payment, unreachable transition, etc.) | ||
| 105 | |||
| 106 | ### Commit | ||
| 107 | - [x] Commit all changes with descriptive message (`2ad2ed4`) | ||
| 108 | - [ ] Push when Nostr relay recovers (relay.ngit.dev still down) | ||
| 109 | |||
| 110 | ## Commits | ||
| 111 | - `b387982` wip: disable display for stability testing | ||
| 112 | - `d21fc93` docs: update WPA auto-detect plan with hardware verification results | ||
| 113 | - `2ad2ed4` feat: WPA auto-detect, STA connectivity fix, lwip crash fix (feature/multi-mint-support) | ||
| 114 | - `64e81b5` feat: WPA auto-detect SPIFFS generation + per-board flash targets (physical-router-test-automation) | ||
| 115 | |||
| 116 | ## Remaining Work | ||
| 117 | 1. Push commits when Nostr relay recovers | ||
| 118 | 2. Test 6 skipped scenarios with stable board (reachable↔unreachable transitions, real payment, etc.) | ||
| 119 | 3. Revert `MINT_HEALTH_PROBE_INTERVAL_S` from 30 to 300 before production | ||
| 120 | 4. Address NVS `ESP_ERR_NVS_NOT_ENOUGH_SPACE` errors for keyset storage | ||
| 121 | 5. Investigate display `ESP_ERR_NO_MEM` errors (307KB PSRAM framebuffer) | ||