From 8b6db0f9d5b9d0834bebe4242be458d0983e9aa8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 18 May 2026 14:11:45 +0530 Subject: docs: multi-mint support design document --- docs/MULTI_MINT_DESIGN.md | 468 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 468 insertions(+) create mode 100644 docs/MULTI_MINT_DESIGN.md (limited to 'docs') diff --git a/docs/MULTI_MINT_DESIGN.md b/docs/MULTI_MINT_DESIGN.md new file mode 100644 index 0000000..3a30112 --- /dev/null +++ b/docs/MULTI_MINT_DESIGN.md @@ -0,0 +1,468 @@ +# Multi-Mint Support — Design Document + +**Branch**: `feature/multi-mint-support` +**Date**: 2026-05-18 +**Status**: Implementation Phase + +--- + +## 1. Overview + +Extend the ESP32 TollGate firmware to accept Cashu ecash payments from **multiple mints** instead of a single hardcoded mint URL. The system must: + +- Accept tokens from any of 4 configured mints +- Track mint reachability via periodic health probes +- Only accept payments from mints that are currently reachable (successful swap) +- Expose all reachable mints in the discovery endpoint and captive portal +- Manage per-mint wallets with independent keysets and proof storage + +### Supported Mints + +| Mint | URL | +|------|-----| +| Minibits | `https://mint.minibits.cash/Bitcoin` | +| CoinOS | `https://mint.coinos.io` | +| 21mint | `https://21mint.me` | +| LNVoltz | `https://mint.lnvoltz.com` | + +All verified reachable via `GET /v1/info` (HTTP 200). + +--- + +## 2. Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ config.json │ +│ "accepted_mints": ["url1", "url2", "url3", "url4"] │ +└──────────────────────┬──────────────────────────────┘ + │ + ┌────────────┼────────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌───────────────┐ + │ Config │ │ Health │ │ Multi-Wallet │ + │ Layer │ │ Tracker │ │ (Nucula) │ + │ │ │ │ │ │ + │ accepted_ │ │ probe │ │ Wallet[0] → │ + │ mints[] │ │ every │ │ mint A │ + │ │ │ 5min │ │ Wallet[1] → │ + │ │ │ │ │ mint B │ + │ │ │ recovery │ │ ... │ + │ │ │ thresh=3 │ │ │ + └─────┬─────┘ └────┬─────┘ └───────┬───────┘ + │ │ │ + ▼ ▼ ▼ + ┌─────────────────────────────────────────────────┐ + │ cashu_is_mint_accepted() │ + │ in config AND reachable → accept │ + └────────────────────┬────────────────────────────┘ + │ + ┌─────────────┼──────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌───────────┐ ┌───────────┐ + │Discovery │ │ Captive │ │ Payment │ + │ Endpoint │ │ Portal │ │ Handler │ + │ │ │ │ │ │ + │ 1 tag │ │ mint list │ │ find right│ + │ per │ │ with │ │ wallet, │ + │ reachable│ │ indicators│ │ receive() │ + │ mint │ │ │ │ │ + └──────────┘ └───────────┘ └───────────┘ +``` + +--- + +## 3. Phase Details + +### Phase 1: Config Layer — Multi-Mint Array + +**Files**: `main/config.h`, `main/config.c` + +**Changes**: + +- Increase `TOLLGATE_MAX_MINT_URLS` from `3` to `8` +- Add to `tollgate_config_t`: + ```c + char accepted_mints[TOLLGATE_MAX_MINT_URLS][256]; + int accepted_mint_count; + ``` +- Keep existing `mint_url[256]` for backward compatibility +- Parse new `"accepted_mints"` JSON array from config.json +- If `"accepted_mints"` absent, populate from `"mint_url"` (backward compat) +- Update default config.json generation to include `"accepted_mints"` + +**Config.json format** (new): +```json +{ + "nsec": "...", + "accepted_mints": [ + "https://mint.minibits.cash/Bitcoin", + "https://mint.coinos.io", + "https://21mint.me", + "https://mint.lnvoltz.com" + ], + "mint_url": "https://mint.minibits.cash/Bitcoin" +} +``` + +The `"mint_url"` field is kept as fallback / primary mint identifier. + +--- + +### Phase 2: Mint Acceptance — Multi-Mint Check + +**Files**: `main/cashu.c`, `main/cashu.h` + +Replace single-mint check in `cashu_is_mint_accepted()`: + +```c +bool cashu_is_mint_accepted(const char *mint_url) { + if (!mint_url || mint_url[0] == '\0') return false; + const tollgate_config_t *cfg = tollgate_config_get(); + for (int i = 0; i < cfg->accepted_mint_count; i++) { + if (strstr(mint_url, cfg->accepted_mints[i]) != NULL) + return true; + } + return false; +} +``` + +This is the config-only check. Phase 4 adds health gating. + +--- + +### Phase 3: Mint Health Tracker + +**New files**: `main/mint_health.h`, `main/mint_health.c` + +**Data structures**: + +```c +#define MINT_HEALTH_MAX 8 +#define MINT_HEALTH_PROBE_INTERVAL_S 300 +#define MINT_HEALTH_PROBE_TIMEOUT_MS 15000 +#define MINT_HEALTH_RECOVERY_THRESHOLD 3 + +typedef struct { + char url[256]; + bool reachable; + uint8_t consecutive_successes; + int64_t last_probe_ms; + int last_http_status; +} mint_status_t; + +typedef void (*mint_health_changed_cb)(void); +``` + +**Public API**: + +```c +esp_err_t mint_health_init(const char urls[][256], int count); +void mint_health_start(void); +void mint_health_stop(void); +const mint_status_t *mint_health_get_all(int *out_count); +bool mint_health_is_reachable(const char *url); +void mint_health_mark_unreachable(const char *url); +void mint_health_register_callback(mint_health_changed_cb cb); +``` + +**Probing logic** (FreeRTOS task): + +| Parameter | Value | Rationale | +|-----------|-------|-----------| +| Endpoint | `GET {url}/v1/info` | Lightweight, no auth required | +| Timeout | 15 seconds | ESP32 resource-constrained, 30s too long | +| Interval | 5 minutes (`vTaskDelay`) | Matches Go reference | +| Failure | Immediate | Single failed probe → unreachable | +| Recovery | 3 consecutive successes | 15 min sustained health (matches Go) | +| Initial | Success → reachable immediately | Set `consecutive_successes = threshold` | + +**Thread safety**: Single FreeRTOS mutex protecting the status array. Callbacks dispatched after releasing the mutex. + +**Reference**: Modeled after Go `MintHealthTracker` in `tollgate-module-basic-go/src/merchant/mint_health_tracker.go`. + +--- + +### Phase 4: Health-Aware Acceptance + +**Files**: `main/cashu.c` + +Update `cashu_is_mint_accepted()` to gate on health: + +```c +bool cashu_is_mint_accepted(const char *mint_url) { + if (!mint_url || mint_url[0] == '\0') return false; + const tollgate_config_t *cfg = tollgate_config_get(); + for (int i = 0; i < cfg->accepted_mint_count; i++) { + if (strstr(mint_url, cfg->accepted_mints[i]) != NULL) + return mint_health_is_reachable(mint_url); + } + return false; +} +``` + +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. + +--- + +### Phase 5: Multi-Mint Discovery Endpoint + +**File**: `main/tollgate_api.c` + +Replace single `price_per_step` tag in `api_get_discovery()` with one per reachable mint: + +```c +int count; +const mint_status_t *mints = mint_health_get_all(&count); +for (int i = 0; i < count; i++) { + if (!mints[i].reachable) continue; + cJSON *price_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(price_tag, cJSON_CreateString("price_per_step")); + cJSON_AddItemToArray(price_tag, cJSON_CreateString("cashu")); + cJSON_AddItemToArray(price_tag, cJSON_CreateString(price_str)); + cJSON_AddItemToArray(price_tag, cJSON_CreateString("sat")); + cJSON_AddItemToArray(price_tag, cJSON_CreateString(mints[i].url)); + cJSON_AddItemToArray(price_tag, cJSON_CreateString("1")); + cJSON_AddItemToArray(tags, price_tag); +} +``` + +If no mints are reachable, include a single tag with the primary `mint_url` as fallback (degraded mode signal). + +--- + +### Phase 6: Multi-Mint Captive Portal UI + +**File**: `main/captive_portal.c` + +**Changes**: + +1. Replace `__MINT_URL__` template placeholder with `__MINT_LIST__` +2. Generate HTML list of reachable mints with green dot indicators +3. Unreachable mints shown greyed out (informative but not selectable) +4. New API endpoint `GET /api/mints` → JSON array of mint status + +**Portal mint list HTML**: +```html +
+
SUPPORTED MINTS
+
+ + mint.minibits.cash/Bitcoin +
+
+ + mint.coinos.io +
+
+``` + +**Auto-refresh**: JS polls `GET /api/mints` every 30s to update indicators. + +--- + +### Phase 7: Multi-Mint Wallet (Nucula) + +**Files**: `components/nucula_lib/nucula_wallet.h`, `components/nucula_lib/nucula_wallet.cpp` + +**Approach**: Multi-wallet — one `cashu::Wallet` instance per mint. + +**Why multi-wallet vs refactoring Wallet class**: +- Each mint has its own keysets, proofs, NVS slot — natural isolation +- No risk of cross-mint proof confusion +- `cashu::Wallet` class unchanged — zero regression risk +- NVS slot allocation already supported: `Wallet(url, ctx, nvs_slot)` +- `MAX_MINTS = 3` constant already defined in `wallet.hpp` + +**Internal structure**: +```cpp +static const int MAX_WALLETS = 4; +static cashu::Wallet *s_wallets[MAX_WALLETS]; +static int s_wallet_count = 0; +``` + +**API changes**: + +| Old | New | Behavior | +|-----|-----|----------| +| `nucula_wallet_init(url)` | `nucula_wallet_init_multi(urls, count)` | Create wallet per mint | +| `nucula_wallet_init(url)` | Keep as compat wrapper | Creates single-wallet array | +| `nucula_wallet_receive(token)` | Same | Decode mint from token, route to correct wallet | +| `nucula_wallet_balance()` | Same | Sum across all wallets | +| `nucula_wallet_send(amount, ...)` | Same | Select wallet with sufficient balance | +| `nucula_wallet_swap_all()` | Same | Swap all wallets | +| `nucula_wallet_proof_count()` | Same | Sum across all wallets | + +**Token routing in `receive()`**: +1. Decode token to extract `mint_url` from the token JSON +2. Find matching wallet by URL +3. Call `wallet->receive(token, proofs_out)` on that wallet +4. If no matching wallet found, try first wallet as fallback + +**NVS slot mapping**: + +| Mint index | NVS slot | NVS keys | +|-----------|----------|----------| +| 0 | 0 | `url_0`, `proofs_0`, `kn_0`, `k_0_0`..`k_0_9` | +| 1 | 1 | `url_1`, `proofs_1`, `kn_1`, `k_1_0`..`k_1_9` | +| 2 | 2 | `url_2`, `proofs_2`, `kn_2`, `k_2_0`..`k_2_9` | +| 3 | 3 | `url_3`, `proofs_3`, `kn_3`, `k_3_0`..`k_3_9` | + +--- + +### Phase 8: Service Startup Integration + +**File**: `main/tollgate_main.c` + +**Changes to `start_services()`**: + +``` +1. firewall_init() +2. session_manager_init() +3. mint_health_init(cfg->accepted_mints, cfg->accepted_mint_count) +4. mint_health_start() ← async probing begins +5. nucula_wallet_init_multi(cfg->accepted_mints, cfg->accepted_mint_count) +6. lightning_payout_init() +7. dns_server_start() +8. captive_portal_start() +9. tollgate_api_start() +10. wifistr_publish() +11. cvm_server_start() +``` + +**Health callback**: When reachable set changes, trigger wifistr re-publish to update Nostr kind 38787 event with current mint list. + +--- + +## 4. Data Flow + +### Payment Flow (Multi-Mint) + +``` +Client POST cashuA token + │ + ▼ +api_post_payment() + ├── cashu_decode_token() → extract mint_url from token + ├── cashu_is_mint_accepted(mint_url) + │ ├── Check in cfg->accepted_mints[] → config match + │ └── Check mint_health_is_reachable(mint_url) → health gate + ├── cashu_check_proof_states(mint_url, token) → POST {mint_url}/v1/checkstate + ├── session_create(client_ip, allotment) + └── nucula_wallet_receive(token_str) + ├── Decode token → extract mint_url + ├── Find wallet for that mint_url + └── wallet->receive(token, proofs_out) +``` + +### Health Probe Flow + +``` +mint_health_task (FreeRTOS, 5min interval) + │ + for each mint in accepted_mints[]: + │ + ├── GET {url}/v1/info (15s timeout) + │ + ├── Success? + │ ├── YES → consecutive_successes++ + │ │ if >= RECOVERY_THRESHOLD → mark reachable + │ └── NO → mark unreachable, reset consecutive_successes = 0 + │ + └── Reachable set changed? → fire callback +``` + +--- + +## 5. Error Handling + +| Scenario | Behavior | +|----------|----------| +| No internet at boot | No mints reachable, no tokens accepted until probe succeeds | +| All mints unreachable | Discovery shows primary mint (degraded), portal shows "Checking mints..." | +| Mint goes down mid-operation | `cashu_check_proof_states` fails → 502 Bad Gateway to client | +| Wallet init fails for one mint | Skip that mint, log error, continue with others | +| NVS full for multi-wallet | Fallback to single wallet, log warning | +| Probe timeout | Treat as unreachable (same as connection refused) | + +--- + +## 6. Memory Budget + +| Component | Estimated RAM | Notes | +|-----------|--------------|-------| +| `mint_status_t[8]` | ~2 KB | 256-byte URLs + metadata | +| Health probe task stack | 8 KB | HTTP client needs stack | +| `cashu::Wallet` per mint | ~4 KB each | Keysets + proofs in NVS, not RAM | +| 4 wallets total | ~16 KB | Within ESP32-S3 512KB SRAM budget | +| Health task TLS | ~40 KB | esp_http_client TLS buffer | +| **Total new overhead** | **~66 KB** | Acceptable with 512KB SRAM + 8MB PSRAM | + +--- + +## 7. Testing Strategy + +### Unit Tests (host, `tests/unit/`) + +| Test File | Covers | +|-----------|--------| +| `test_cashu.c` | Multi-mint acceptance (config-only) | +| `test_mint_health.c` | Health state machine, recovery, callbacks | +| `test_config.c` | Config parsing of `accepted_mints` array | + +### Integration Tests (device) + +1. Flash to Board A, verify discovery shows multiple mints +2. Send token from each mint, verify accepted +3. Block one mint at firewall level, verify becomes unreachable +4. Verify recovery after unblocking + +### E2E Tests (Playwright) + +1. Captive portal shows mint list with indicators +2. Pay with token from mint A → success +3. Pay with token from unreachable mint → error shown in portal + +--- + +## 8. Risks and Mitigations + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| TLS memory pressure with 4 wallets | Medium | High | Each wallet shares single TLS context; only probe makes concurrent HTTP | +| NVS key namespace collision | Low | High | Use distinct `nvs_slot` per wallet (0-3) | +| Keyset loading OOM on multiple mints | Medium | Medium | Cap keysets per wallet at `MAX_KEYSETS=10` | +| Health probe blocks other tasks | Low | Medium | Dedicated FreeRTOS task, low priority | +| Backward compatibility break | Low | High | `mint_url` field still works as fallback | + +--- + +## 9. Backward Compatibility + +- Existing `config.json` with only `"mint_url"` → works (populates `accepted_mints[0]` from it) +- Existing SPIFFS images → no change needed +- NVS data → compatible (single wallet stays at slot 0) +- API endpoints → same paths, discovery just has more tags +- Captive portal → same UI flow, more mints shown + +--- + +## 10. Implementation Checklist + +- [ ] Phase 1: Config layer (`config.h`, `config.c`) +- [ ] Phase 2: Multi-mint acceptance (`cashu.c`) +- [ ] Phase 3: Mint health tracker (`mint_health.h`, `mint_health.c`) +- [ ] Phase 4: Health-aware acceptance integration +- [ ] Phase 5: Multi-mint discovery endpoint (`tollgate_api.c`) +- [ ] Phase 6: Multi-mint captive portal UI (`captive_portal.c`) +- [ ] Phase 7: Multi-mint wallet (`nucula_wallet.h`, `nucula_wallet.cpp`) +- [ ] Phase 8: Service startup integration (`tollgate_main.c`) +- [ ] Unit tests: `test_mint_health.c` +- [ ] Unit tests: update `test_cashu.c` for multi-mint +- [ ] Build verification (no compiler errors/warnings) +- [ ] Flash Board A and verify multi-mint discovery +- [ ] Flash Board B and verify multi-mint discovery +- [ ] Payment test with token from each supported mint +- [ ] Health probe test (verify reachable/unreachable transitions) +- [ ] Captive portal multi-mint display verification +- [ ] Commit and merge to master -- cgit v1.2.3