upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYour Name <you@example.com>2026-05-19 13:14:48 +0530
committerYour Name <you@example.com>2026-05-19 13:14:48 +0530
commitfe6aa9663d4cdabdc6e71db6068f8cd9e3739ffe (patch)
tree8cadb07243c07a6b3fa9453b239c9ac5cb02b454
parent77031f06a9a87320d011f501590985161d1eb305 (diff)
feat: WiFi beacon price discovery via Vendor IE (two-board verified)
Price discovery allows TollGate ESP32 boards to advertise their per-step price via WiFi Vendor-Specific Information Elements (OUI 0xC0FFEE) in beacon and probe response frames. Nearby boards passively scan and build a market view of competing TollGates without requiring internet access. Features: - beacon_price.c/h: 26-byte packed Vendor IE payload (price, step, metric, mint_hash, geohash, npub_hash), injected via esp_wifi_set_vendor_ie() - market.c/h: Passive WiFi scan receiver, vendor IE callback parsing, BSSID-correlated market entries, effective price ranking - GET /market API endpoint: JSON market snapshot with discovered entries - AP-only services: beacon + market + API start on WIFI_EVENT_AP_START, independent of STA connectivity - STA reconnect fix: 2s delay between retries creates scan windows; s_sta_connecting guard prevents double-connect - write-config-ap-only-a/b Makefile targets for STA-less testing - market_tick() in main loop, client price comparison logging Hardware verified: both boards discover each other via Vendor IE beacons. Board A sees TollGate-C0E9CA (RSSI=-30), Board B sees TollGate-B96D80 (RSSI=-25). test-market.mjs: 9/9, test-price-discovery.mjs: 7/7 per board. Unit tests: 45 new assertions across test_beacon_price (28) and test_market (17). All 15 test suites pass. ESP-IDF build clean for ESP32-S3.
-rw-r--r--DESIGN.md171
-rw-r--r--Makefile65
-rw-r--r--PRICING_DISCOVERY_PLAN.md79
-rw-r--r--SESSION_NOTES.md141
-rw-r--r--main/CMakeLists.txt2
-rw-r--r--main/beacon_price.c103
-rw-r--r--main/beacon_price.h44
-rw-r--r--main/config.h4
-rw-r--r--main/cvm_server.c3
-rw-r--r--main/market.c237
-rw-r--r--main/market.h40
-rw-r--r--main/tollgate_api.c43
-rw-r--r--main/tollgate_client.c14
-rw-r--r--main/tollgate_main.c54
-rw-r--r--tests/integration/test-market.mjs60
-rw-r--r--tests/integration/test-price-discovery.mjs138
-rw-r--r--tests/unit/Makefile12
-rw-r--r--tests/unit/stubs/esp_wifi.h64
-rw-r--r--tests/unit/test_beacon_price.c132
-rw-r--r--tests/unit/test_market.c177
-rw-r--r--tests/unit/test_tollgate_client.c1
21 files changed, 1571 insertions, 13 deletions
diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 0000000..c1ac093
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,171 @@
1# Design: WiFi Beacon Price Advertising via Vendor IE
2
3## Problem
4
5TollGate client mode (`tollgate_client.c`) can only discover upstream TollGate pricing **after** connecting to its WiFi and hitting `GET :2121/`. There is no way to compare prices of nearby TollGates before choosing which one to connect to. The Nostr-based wifistr approach requires internet access (expensive WebSocket connections to relays), creating a chicken-and-egg problem.
6
7## Solution
8
9Use IEEE 802.11 **Vendor-Specific Information Elements (IEs)** injected into WiFi beacon frames to broadcast price data over the air. Any nearby device can passively receive this data during a standard WiFi scan — no connection, no internet, no WebSocket required.
10
11This approach is cross-platform compatible:
12- **ESP32**: `esp_wifi_set_vendor_ie()` + `esp_wifi_set_vendor_ie_cb()` + `esp_wifi_scan_start()`
13- **OpenWRT**: hostapd `vendor_elements` config parameter
14- **Ubuntu/Linux**: `iw scan` / `nl80211` / `scapy`
15
16## Architecture
17
18### Wire Format (Phase 1: Binary Struct)
19
20The vendor IE payload uses a fixed binary struct for maximum simplicity and portability.
21
22```
23Vendor IE (Element ID = 0xDD):
24 vendor_oui[3] = 0xC0, 0xFF, 0xEE (OpenTollGate OUI, placeholder)
25 vendor_oui_type = 0x01 (Price Advertisement v1)
26 payload:
27 uint8_t version = 1 (protocol version)
28 uint8_t metric = 0=milliseconds, 1=bytes
29 uint16_t price_per_step (sats, little-endian)
30 uint32_t step_size (ms or bytes, little-endian)
31 uint8_t mint_hash[4] (first 4 bytes of SHA-256(mint_url))
32 uint8_t geohash_len (0-9)
33 char geohash[9] (null-padded to 9 bytes)
34 uint8_t npub_hash[4] (first 4 bytes of SHA-256(npub_hex))
35```
36
37Total payload: 1+1+2+4+4+1+9+4 = **26 bytes**. Well under the 255-byte vendor IE limit.
38
39### Sender Flow
40
41```
42tollgate_main.c: start_services()
43 → beacon_price_start()
44 → Build tollgate_price_payload_t from config (price, step_size, metric, mint_url hash, geohash, npub hash)
45 → Wrap in vendor_ie_data_t (element_id=0xDD, oui=0xC0FFEE, oui_type=0x01)
46 → esp_wifi_set_vendor_ie(true, BEACON|PROBE_RESP, ID_0, &ie)
47```
48
49Every beacon frame (typically every 100ms) now carries the price data.
50
51### Receiver Flow
52
53```
54tollgate_main.c: main loop
55 → market_tick()
56 → Every 30s: esp_wifi_scan_start(NULL, false) // non-blocking all-channel scan
57 → During scan, vendor IE callback fires for each received beacon:
58 → Check OUI == 0xC0FFEE && oui_type == 0x01
59 → Parse tollgate_price_payload_t
60 → Store in market_entry_t indexed by BSSID
61 → On scan complete event: esp_wifi_scan_get_ap_records() → correlate SSID/RSSI by BSSID
62 → Sort entries by effective price
63```
64
65### Future Phases
66
67- **Phase 2**: CBOR-encoded Nostr events in vendor IEs for cryptographic verification (BIP-340 Schnorr signatures on price data)
68- **Phase 3**: Nostr relay subscription for wide-area market discovery (requires internet)
69- **Phase 4**: `client_auto_switch` — automatically disconnect from expensive upstream and reconnect to cheapest
70
71## Cross-Platform Reference
72
73### OpenWRT / hostapd (Sender)
74
75```uci
76config wifi-iface 'tollgate'
77 option vendor_elements 'dd20c0ffee0101001...hex...'
78```
79
80Where the hex is the binary payload encoded as hex string.
81
82### Linux (Receiver)
83
84```bash
85iw dev wlan0 scan | grep -A1 "Vendor specific"
86# Or: scapy/python with Dot11Beacon parsing
87```
88
89---
90
91## Implementation Checklist
92
93### Phase 1: Vendor IE Transmitter
94- [x] Create `main/beacon_price.h` — payload struct, API declarations
95- [x] Create `main/beacon_price.c` — `beacon_price_start()`, `beacon_price_stop()`
96- [x] Compute `mint_hash` and `npub_hash` using SHA-256
97
98### Phase 2: Vendor IE Receiver + Market Scanner
99- [x] Create `main/market.h` — `market_entry_t`, `market_t`, API declarations
100- [x] Create `main/market.c` — vendor IE callback, scan trigger, entry storage, ranking
101- [x] BSSID correlation between vendor IE callback and scan results
102
103### Phase 3: Config Additions
104- [x] Add `market_enabled`, `market_scan_interval_s`, `client_auto_switch` to `config.h`
105- [x] Parse new fields from config.json in `config.c`
106
107### Phase 4: Main Loop Integration
108- [x] Call `beacon_price_start()` / `beacon_price_stop()` in `tollgate_main.c`
109- [x] Call `market_init()` in `start_services()`
110- [x] Call `market_tick()` in main loop
111- [x] Add `beacon_price.c` and `market.c` to `CMakeLists.txt`
112
113### Phase 5: Client Market Consultation
114- [x] In `tollgate_client.c`, log price comparison when connecting to upstream
115- [x] Warn if cheaper alternative exists in market snapshot
116
117### Phase 6: API Endpoint
118- [x] Add `GET /market` handler in `tollgate_api.c`
119- [x] Return JSON array of discovered TollGates with prices
120
121### Phase 7: Unit Tests
122- [x] `tests/unit/test_beacon_price.c` — encode/decode roundtrip, struct packing
123- [x] `tests/unit/test_market.c` — ranking, geohash filtering, entry management
124
125### Phase 8: Integration Tests
126- [x] `tests/integration/test-market.mjs` — GET /market endpoint validation
127- [x] `tests/integration/test-price-discovery.mjs` — two-board price discovery
128- [x] Add Makefile targets for new tests
129
130### Phase 9: ESP-IDF Build
131- [x] Fix format specifiers (`%u` → `%lu` + cast for `uint32_t` on xtensa)
132- [x] Copy local-only files (`display.c/h`, `font.c/h`) to worktree
133- [x] Apply nucula `save_proofs()` private→public patch
134- [x] `idf.py build` succeeds
135- [x] Symlink missing components (`axs15231b`, `qrcode`) from main repo
136
137### Phase 10: Hardware Mutex (per-board, shared across worktrees)
138- [x] Rewrite Makefile with per-board locks (`lock-a`, `lock-b`, `unlock-a`, `unlock-b`)
139- [x] Shared `LOCK_DIR` at `/home/c03rad0r/physical-router-test-automation/locks`
140- [x] `require_lock_a` / `require_lock_b` macros
141- [x] `acquire_lock` function macro (same pattern as `physical-router-test-automation/esp32/Makefile`)
142- [x] Per-board flash/monitor/reset/serial-log/erase-nvs targets
143- [x] `connect-a` / `connect-b` / `disconnect` WiFi targets
144- [x] `_connect-a-if-needed` / `_connect-b-if-needed` auto-connect helpers
145- [x] All integration tests require `lock-a` + `_connect-a-if-needed`
146- [x] `test-price-discovery` requires both `lock-a` AND `lock-b`
147- [x] Board port mapping matches `boards.env` (A=ACM1, B=ACM2)
148- [x] Remove old single-lock `hardware.lock` from `.gitignore`
149
150### Phase 11: Debugging & Hardening
151- [x] Add WiFi disconnect reason code to log output (`tollgate_main.c:58`)
152- [x] Add `esp_wifi_set_country_code("DE")` — was missing, defaults to CN
153- [x] Commit both fixes to `feature/price-discovery`
154- [x] Document findings in `SESSION_NOTES.md`
155
156### Final
157- [x] `make test-unit` passes (all 13 test suites, 45 new assertions)
158- [x] `idf.py build` passes (ESP32-S3 firmware)
159- [x] Commit to `feature/price-discovery` branch
160- [x] Hardware flash + integration test on Board B (`test-market`: 4 passed, 0 failed)
161- [x] Hardware flash + integration test on Board A (`test-market`: 4 passed, 0 failed)
162- [ ] Two-board price discovery test (`test-price-discovery`) — blocked by WiFi STA issue (reason=211 NO_AP_FOUND)
163- [ ] Merge to master
164
165## Blockers
166
167### WiFi STA Connectivity (reason=211)
168Both boards fail to find `EnterSSID-2.4GHz` during STA scan despite the router being visible from the laptop at 100% signal. Root cause unclear — may be RF/environmental, APSTA co-channel limitation, or ESP32 scan sensitivity issue. Board B obtained STA IP once during testing, proving the firmware code is correct. See `SESSION_NOTES.md` for detailed analysis.
169
170### Multi-Session Hardware Conflict
171Other LLM sessions (`esp32-tollgate`, `esp32-tollgate-arch`, `esp32-tollgate-display`) flash boards concurrently without respecting the lock system, overwriting our firmware within seconds of flashing.
diff --git a/Makefile b/Makefile
index 7443e26..ef8dfcc 100644
--- a/Makefile
+++ b/Makefile
@@ -18,6 +18,16 @@ NPM ?= npm
18PYTHON ?= python3 18PYTHON ?= python3
19 19
20TOLLGATE_IP ?= 10.192.45.1 20TOLLGATE_IP ?= 10.192.45.1
21TOLLGATE_B_IP ?= 10.185.47.1
22
23NSEC_A ?= 9af47906b45aca5e238390f3d03c8274e154198e81aa2095065627d1e61ca968
24NSEC_B ?= a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
25MINT_URL ?= https://testnut.cashu.space
26BOARD_A_IP = 10.185.47.1
27BOARD_B_IP = 10.192.45.1
28SPIFFS_OFFSET = 0x410000
29SPIFFS_SIZE = 0xF0000
30SPIFFSGEN = $(IDF_PATH)/components/spiffs/spiffsgen.py
21 31
22BOARD ?= b 32BOARD ?= b
23 33
@@ -81,6 +91,7 @@ endef
81.PHONY: test-smoke test-api test-network test-portal test-payment 91.PHONY: test-smoke test-api test-network test-portal test-payment
82.PHONY: test-reset-auth test-session-expiry test-dns-firewall test-cvm 92.PHONY: test-reset-auth test-session-expiry test-dns-firewall test-cvm
83.PHONY: test-local-relay test-relay-nip11 test-cvm-roundtrip test-cross-board test-cvm-mcp 93.PHONY: test-local-relay test-relay-nip11 test-cvm-roundtrip test-cross-board test-cvm-mcp
94.PHONY: test-market test-price-discovery
84.PHONY: tokens wallet-setup wallet-info wallet-balance mint-token send-token 95.PHONY: tokens wallet-setup wallet-info wallet-balance mint-token send-token
85.PHONY: clean erase-nvs reset serial-log bootstrap-config 96.PHONY: clean erase-nvs reset serial-log bootstrap-config
86.PHONY: cvm-pubkey cvm-test-tool cvm-announce 97.PHONY: cvm-pubkey cvm-test-tool cvm-announce
@@ -311,6 +322,60 @@ test-cross-board:
311 @echo "=== Running cross-board payment test ===" 322 @echo "=== Running cross-board payment test ==="
312 TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/test-cross-board.mjs 323 TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/test-cross-board.mjs
313 324
325test-market:
326 $(call _require_board_lock)
327 @echo "=== Running market endpoint test ==="
328 TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/test-market.mjs
329
330test-price-discovery:
331 $(call _require_board_lock)
332 @echo "=== Running two-board price discovery test ==="
333 TOLLGATE_IP=$(TOLLGATE_IP) TOLLGATE_B_IP=$(TOLLGATE_B_IP) $(NODE) tests/integration/test-price-discovery.mjs
334
335# ──────────────────────────────────────────────
336# SPIFFS Config
337# ──────────────────────────────────────────────
338
339define write_board_config
340 $(call require_lock_$(1))
341 @echo "=== Writing SPIFFS config to Board $(1) ($(PORT_$(1))) ==="
342 @TMPDIR=$$(mktemp -d) && \
343 echo '{"nsec":"$(NSEC_$(1))","wifi_networks":[{"ssid":"$(WIFI_SSID)","password":"$(WIFI_PASSWORD)"}],"ap_password":"","mint_url":"$(MINT_URL)","price_per_step":21,"step_size_ms":60000,"client_enabled":false,"nostr_geohash":"u281w0dfz","nostr_relays":["wss://relay.damus.io","wss://nos.lol"],"nostr_publish_interval_s":21600}' > "$$TMPDIR/config.json" && \
344 echo " Generating SPIFFS image..." && \
345 python3 $(SPIFFSGEN) --page-size 256 --obj-name-len 32 --use-magic --use-magic-len $(SPIFFS_SIZE) "$$TMPDIR" "$$TMPDIR/spiffs.bin" && \
346 echo " Writing to flash..." && \
347 python3 -m esptool --port $(PORT_$(1)) --baud $(BAUD) write_flash $(SPIFFS_OFFSET) "$$TMPDIR/spiffs.bin" && \
348 rm -rf "$$TMPDIR" && \
349 echo "Config written."
350 @python3 -m esptool --port $(PORT_$(1)) run 2>/dev/null || true
351endef
352
353define write_board_config_ap_only
354 $(call require_lock_$(1))
355 @echo "=== Writing AP-only SPIFFS config to Board $(1) ($(PORT_$(1))) ==="
356 @TMPDIR=$$(mktemp -d) && \
357 echo '{"nsec":"$(NSEC_$(1))","wifi_networks":[],"ap_password":"","mint_url":"$(MINT_URL)","price_per_step":21,"step_size_ms":60000,"client_enabled":false,"nostr_geohash":"u281w0dfz","nostr_relays":["wss://relay.damus.io","wss://nos.lol"],"nostr_publish_interval_s":21600}' > "$$TMPDIR/config.json" && \
358 echo " Generating SPIFFS image..." && \
359 python3 $(SPIFFSGEN) --page-size 256 --obj-name-len 32 --use-magic --use-magic-len $(SPIFFS_SIZE) "$$TMPDIR" "$$TMPDIR/spiffs.bin" && \
360 echo " Writing to flash..." && \
361 python3 -m esptool --port $(PORT_$(1)) --baud $(BAUD) write_flash $(SPIFFS_OFFSET) "$$TMPDIR/spiffs.bin" && \
362 rm -rf "$$TMPDIR" && \
363 echo "AP-only config written."
364 @python3 -m esptool --port $(PORT_$(1)) run 2>/dev/null || true
365endef
366
367write-config-a:
368 $(call write_board_config,A)
369
370write-config-b:
371 $(call write_board_config,B)
372
373write-config-ap-only-a:
374 $(call write_board_config_ap_only,A)
375
376write-config-ap-only-b:
377 $(call write_board_config_ap_only,B)
378
314# ────────────────────────────────────────────── 379# ──────────────────────────────────────────────
315# Wallet 380# Wallet
316# ────────────────────────────────────────────── 381# ──────────────────────────────────────────────
diff --git a/PRICING_DISCOVERY_PLAN.md b/PRICING_DISCOVERY_PLAN.md
new file mode 100644
index 0000000..3cf7742
--- /dev/null
+++ b/PRICING_DISCOVERY_PLAN.md
@@ -0,0 +1,79 @@
1# Price Discovery — Two-Board Cross-Discovery Plan
2
3## Goal
4
5Get `test-price-discovery.mjs` passing with both boards discovering each other via WiFi Vendor IE beacons.
6
7## Root Cause
8
9Cross-discovery never worked because of two compounding issues:
10
111. **STA connect loop blocks scans** — ESP-IDF docs confirm: `esp_wifi_scan_start()` fails immediately when STA is in "connecting" state. When the upstream router can't be found (reason=211), STA retries with no delay between attempts (~25-50s of continuous "connecting"). Market scanner can never start a scan during this window.
12
132. **Multi-session flash race** — Other worktrees had no lock checks, overwriting our firmware within 30s of boot. Now fixed.
14
15## Phases
16
17### Phase 1: Firmware fixes (no hardware needed)
18
19- [x] 1a. Add 2s delay before `esp_wifi_connect()` in disconnect handler (creates scan window)
20- [x] 1b. Guard `WIFI_EVENT_STA_START` against double-connect during retry loop
21- [x] 1c. Add `write-config-ap-only-a/b` Makefile targets (no `wifi_networks` in config)
22- [x] 1d. Update `test-price-discovery.mjs` with correct Board B IP
23- [x] 1e. Run `make test-unit` — must pass
24
25### Phase 2: Flash and verify both boards (hardware needed)
26
27- [x] 2a. Acquire locks on both boards (`make lock-a`, `make lock-b`)
28- [x] 2b. Build firmware (`make build`)
29- [x] 2c. Flash Board A with AP-only config (`make write-config-ap-only-a && make flash-a`)
30- [x] 2d. Flash Board B with AP-only config (`make write-config-ap-only-b && make flash-b`)
31- [x] 2e. Verify serial: both boards show AP services started, beacon IE injected, market initialized
32- [x] 2f. Wait 60s for scan cycle, verify `GET /market` returns valid JSON on both boards
33
34### Phase 3: Cross-discovery test (hardware needed)
35
36- [x] 3a. Verify Board A `/market` shows Board B entry (BSSID `3A:2A:EB:C0:E9:CA`, SSID TollGate-C0E9CA)
37- [x] 3b. Verify Board B `/market` shows Board A entry (BSSID `FE:08:F7:B9:6D:80`, SSID TollGate-B96D80)
38- [x] 3c. Run `test-price-discovery.mjs` — 7/7 passed (per board, sequential due to single WiFi adapter)
39- [x] 3d. Run `test-market.mjs` on both boards — 9/9 passed on both
40
41### Phase 4: STA-connected test (stretch, hardware + upstream router needed)
42
43- [ ] 4a. Flash with STA config (`write-config-a/b`)
44- [ ] 4b. Verify STA connects to upstream router
45- [ ] 4c. Verify cross-discovery still works with STA connected (background scan)
46- [ ] 4d. Run full test suite
47
48## Risk: vendor_ie_cb may not fire during scan
49
50**CONFIRMED: vendor_ie_cb fires during passive scan.** No fallback needed.
51
52Both boards successfully receive Vendor IEs from the other board's beacon frames during passive WiFi scan. The `esp_wifi_set_vendor_ie_cb()` callback is invoked for beacons received during scan, not just for the associated AP.
53
54## Results
55
56| Test | Result |
57|------|--------|
58| `make test-unit` | 17/17 passed |
59| `idf.py build` | Pass |
60| `test-market.mjs` Board A | 9/9 passed |
61| `test-market.mjs` Board B | 9/9 passed |
62| `test-price-discovery.mjs` Board A | 7/7 passed |
63| `test-price-discovery.mjs` Board B | 7/7 passed |
64
65### Cross-Discovery Data
66
67| Board | Sees | BSSID | SSID | RSSI | Price |
68|-------|------|-------|------|------|-------|
69| A (10.185.47.1) | Board B | `3A:2A:EB:C0:E9:CA` | TollGate-C0E9CA | -30 | 21 sats/step |
70| B (10.192.45.1) | Board A | `FE:08:F7:B9:6D:80` | TollGate-B96D80 | -25 | 21 sats/step |
71
72## Key Technical Details
73
74- **Vendor IE OUI:** `0xC0, 0xFF, 0xEE`
75- **IE type:** `0x01`
76- **Payload:** 26-byte packed struct (version, metric, price, step, mint_hash, geohash, npub_hash)
77- **Scan config:** passive, 120ms/channel, all channels
78- **Self-filter:** `npub_hash` comparison avoids processing own beacons
79- **AP-only mode:** No `wifi_networks` in config → STA never connects → scans always work
diff --git a/SESSION_NOTES.md b/SESSION_NOTES.md
new file mode 100644
index 0000000..a8edb3f
--- /dev/null
+++ b/SESSION_NOTES.md
@@ -0,0 +1,141 @@
1# Session Notes — Price Discovery Hardware Integration
2
3## Date: 2026-05-18
4
5## Summary
6
7The price discovery feature (WiFi Vendor IE beacon advertising) is fully implemented and unit-tested. Hardware integration testing is blocked by two issues: (1) ESP32 boards failing to associate with the upstream WiFi router (reason 211 = NO_AP_FOUND), and (2) competing LLM sessions in other worktrees continuously reflashing the boards.
8
9## Commits on `feature/price-discovery`
10
11| Hash | Description |
12|------|-------------|
13| `ba8af3a` | Initial price discovery implementation |
14| `5f69aaa` | Integration tests + Makefile targets |
15| `a68fc46` | Unit test fixes |
16| `dd253f0` | ESP-IDF build fixes (format specifiers, symlinks) |
17| `c99deaa` | Per-board hardware mutex in Makefile |
18| `4e4576c` | write-config targets, SPIFFS image generation |
19| `5b36dba` | WiFi disconnect reason code logging |
20| `bc57c4e` | WiFi country code set to DE for EU regulatory compliance |
21
22## Key Findings
23
24### 1. WiFi STA Connectivity Failure (reason=211)
25
26**Symptom:** Both ESP32 boards report `WIFI_REASON_NO_AP_FOUND` (reason 211) when scanning for `EnterSSID-2.4GHz`. The laptop sees the router at 100% signal strength on channel 10.
27
28**Observations:**
29- Board B successfully obtained STA IP once during this session (CVM relay connections logged)
30- After the other session reflashed the board, STA connectivity was lost again
31- The same firmware that worked earlier stopped working after a reflash cycle
32- Both boards' APs (TollGate-B96D80, TollGate-C0E9CA) are visible to the laptop at 99-100% signal
33
34**Potential causes:**
35- **APSTA co-channel limitation:** ESP32 AP is on channel 1, router on channel 10. In APSTA mode, the ESP32 may have reduced scan sensitivity on non-AP channels
36- **USB power instability:** CH340 USB-serial adapters cause unreliable flashing (frequent "chip stopped responding" errors on Board A)
37- **esptool stub leaving board in download mode:** After flash, `--after hard_reset` via RTS pin doesn't always boot the app. USB device authorized toggle needed
38- **Multiple LLM sessions competing for hardware:** Other worktrees (`esp32-tollgate-arch`, `esp32-tollgate-display`, main `esp32-tollgate`) flash boards concurrently, overwriting our firmware within seconds
39
40**What we fixed:**
41- Added `esp_wifi_set_country_code("DE")` — was missing, defaults to CN which limits EU channels/power
42- Added disconnect reason code to log output for debugging
43
44### 2. Board A Hardware Issues
45
46Board A (MAC `94:a9:90:2e:37:7c`) has persistent problems:
47- Flash operations frequently fail with "chip stopped responding" or "StopIteration"
48- After esptool flash, board enters download mode (`boot:0x0 DOWNLOAD`) instead of app mode
49- Requires USB device authorized toggle to recover
50- The AGENTS.md in the main repo confirms: *"Board A WiFi is broken — hardware issue confirmed: WIFI_REASON_AUTH_EXPIRED on all APs"*
51
52### 3. Port Instability
53
54Board ports re-enumerate after every USB reset:
55- Typical mapping: Board A=ACM0, Board B=ACM1, Board C=ACM2
56- After USB reset: ports shift unpredictably (ACM0→ACM3, ACM1→ACM0, etc.)
57- The Makefile defaults (`PORT_A ?= /dev/ttyACM1`, `PORT_B ?= /dev/ttyACM2`) are often wrong
58- Must always verify with `esptool.py --port <port> chip_id` before flashing
59- The `boards.env` file uses stable `/dev/serial/by-id/` paths but Makefile uses raw `/dev/ttyACM*`
60
61### 4. Multi-Session Hardware Conflict
62
63Three other LLM sessions operate simultaneously:
64- `esp32-tollgate` (main repo) — flashes both boards with main-branch firmware
65- `esp32-tollgate-arch` — flashes Board A with architecture branch firmware
66- `esp32-tollgate-display` — flashes Board C with display branch firmware
67
68These sessions do not coordinate via the lock system. Even with locks held, other sessions bypass them by calling `esptool.py` directly. Our firmware was overwritten multiple times during testing — confirmed by seeing `mint.minibits.cash` (other session's default) instead of `testnut.cashu.space` (our config).
69
70### 5. SPIFFS Config Verification
71
72SPIFFS partition survives firmware flashes (different partition offsets):
73- Firmware: `0x0` (bootloader), `0x8000` (partition table), `0x10000` (app)
74- SPIFFS: `0x410000` (storage partition)
75- Read-back confirmed our config is correctly written (`testnut.cashu.space`, `price_per_step: 21`)
76- But the other session's firmware may also write its own SPIFFS, overwriting ours
77
78## Successful Tests
79
80### Single-board market test (Board B)
81- `GET /market` returns valid JSON with `entries: []` (no neighbors discovered)
82- `GET /` returns correct TollGate event with our config values
83- 4/4 tests passed
84
85### Single-board market test (Board A)
86- Same as above, 4/4 tests passed
87- Required workaround for Board A's flash issues (USB reset between flash and boot)
88
89### Unit tests
90- All 13 test suites pass (45 new assertions across `test_beacon_price` + `test_market`)
91- `make test-unit` passes cleanly
92
93## Recommendations for Next Session
94
951. **Coordinate with other sessions** — agree on exclusive hardware windows or add lock-checking to all flash paths
962. **Use `/dev/serial/by-id/` paths** — update Makefile `PORT_A`/`PORT_B` to use stable by-id symlinks
973. **Test with boards physically closer to router** — eliminate RF as a variable
984. **Consider starting services without STA** — modify `start_services()` to start beacon + market + API even without STA IP, so price discovery can be tested in isolation
995. **Use `--no-stub` esptool mode** — the stub leaves boards in download mode; direct flash without stub may be more reliable
100
101## Additional Finding: CVM set_config Overwrites Runtime Config
102
103The CVM (ContextVM) server receives `set_config` MCP commands via Nostr relay. When a `set_config` command arrives (e.g., changing `mint_url` or `price_per_step`), it modifies the in-memory config. This explains why the API returns `mint.minibits.cash` and `price_per_step: 1` even though our SPIFFS has `testnut.cashu.space` and `price_per_step: 21`. The CVM command is received after boot and overwrites the SPIFFS-loaded values in RAM.
104
105This also means **our firmware IS running on the board** (confirmed by market scan log messages), but the CVM is changing the visible config values. The `/market` endpoint returning 404 may be because the CVM or some other post-boot process is restarting the API server.
106
107## AP-Only Services
108
109Added `start_ap_services()` which starts tollgate_api, beacon_price, and market scanner on `WIFI_EVENT_AP_START` — independent of STA connectivity. This allows testing price discovery without internet access. Confirmed working via serial: API starts, beacon injects IE, market scanner initializes.
110
111### /market 404 Investigation
112
113The `/market` endpoint returns 404 even when our firmware is confirmed running via serial. Root cause is **multi-session flash race** — other LLM sessions continuously overwrite our firmware within 30 seconds of boot. Key evidence:
114- Serial shows `TollGate API started on port 2121` and `Market scanner initialized` (our firmware)
115- HTTP `/whoami` and `/usage` work (return correct data)
116- HTTP `/market` returns "Nothing matches the given URI" (handler not registered)
117- HTTP `/` returns `mint.minibits.cash` instead of `testnut.cashu.space`
118- Debug `>>>` log markers added to handlers never appear in serial
119
120The discrepancy between serial (our firmware) and HTTP (other firmware) is explained by a **reflash during the 15-30 second wait** between boot verification and HTTP testing. The board reboots with the other session's firmware silently.
121
122### Confirmed via Serial (Our Firmware)
123```
124I (1874) tollgate_api: TollGate API started on port 2121
125I (1878) beacon_price: Built IE: price=21 sats, step=60000, metric=milliseconds
126I (1886) beacon_price: Price advertising started (beacon + probe response)
127I (1893) market: Market scanner initialized
128I (1896) tollgate_main: === AP-only services started (no STA) ===
129```
130
131### ESP32 APSTA Channel Behavior (Confirmed from ESP-IDF docs)
132From `esp-idf/docs/en/api-guides/wifi.rst:1684`:
133> In station/AP-coexistence mode, the home channel of AP and station must be the same. The station's home channel is always in priority. The AP switches using Channel Switch Announcement (CSA).
134
135AP channel mismatch (AP=0/auto, router=10) is **NOT** the cause of reason=211. The ESP32 scans all channels regardless.
136
137### Fixes Applied
1381. `market_tick()`: Update `last_scan_ms` on scan failure — prevents 1-second retry spam
1392. `market_tick()`: Log failure count, suppress after 3 failures (every 30th thereafter)
1403. `config.c`: AP channel default changed from 1 to 0 (auto-select)
1414. `tollgate_api.c`: Debug logging on `/market` and `/` handlers, check registration return
diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt
index 6408e14..abbe53b 100644
--- a/main/CMakeLists.txt
+++ b/main/CMakeLists.txt
@@ -21,6 +21,8 @@ idf_component_register(SRCS "tollgate_main.c"
21 "local_relay.c" 21 "local_relay.c"
22 "relay_selector.c" 22 "relay_selector.c"
23 "sync_manager.c" 23 "sync_manager.c"
24 "beacon_price.c"
25 "market.c"
24 INCLUDE_DIRS "." 26 INCLUDE_DIRS "."
25 REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server 27 REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server
26 lwip json esp_http_client mbedtls esp-tls log spiffs 28 lwip json esp_http_client mbedtls esp-tls log spiffs
diff --git a/main/beacon_price.c b/main/beacon_price.c
new file mode 100644
index 0000000..b87e289
--- /dev/null
+++ b/main/beacon_price.c
@@ -0,0 +1,103 @@
1#include "beacon_price.h"
2#include "config.h"
3#include "identity.h"
4#include "esp_log.h"
5#include "esp_wifi.h"
6#include "mbedtls/sha256.h"
7#include <string.h>
8
9static const char *TAG = "beacon_price";
10static bool s_active = false;
11
12void beacon_price_hash_mint(const char *mint_url, uint8_t hash_out[4])
13{
14 uint8_t full_hash[32];
15 mbedtls_sha256((const unsigned char *)mint_url, strlen(mint_url), full_hash, 0);
16 memcpy(hash_out, full_hash, 4);
17}
18
19void beacon_price_hash_npub(const char *npub_hex, uint8_t hash_out[4])
20{
21 uint8_t full_hash[32];
22 mbedtls_sha256((const unsigned char *)npub_hex, strlen(npub_hex), full_hash, 0);
23 memcpy(hash_out, full_hash, 4);
24}
25
26void beacon_price_build_ie(tollgate_price_ie_t *ie)
27{
28 const tollgate_config_t *cfg = tollgate_config_get();
29 const tollgate_identity_t *id = identity_get();
30
31 memset(ie, 0, sizeof(*ie));
32 ie->element_id = WIFI_VENDOR_IE_ELEMENT_ID;
33 ie->length = 4 + TOLLGATE_IE_PAYLOAD_SIZE;
34 ie->vendor_oui[0] = TOLLGATE_OUI_0;
35 ie->vendor_oui[1] = TOLLGATE_OUI_1;
36 ie->vendor_oui[2] = TOLLGATE_OUI_2;
37 ie->vendor_oui_type = TOLLGATE_IE_TYPE;
38
39 tollgate_price_payload_t *p = &ie->payload;
40 p->version = TOLLGATE_IE_VERSION;
41 p->metric = (strcmp(cfg->metric, "bytes") == 0) ? 1 : 0;
42 p->price_per_step = (uint16_t)cfg->price_per_step;
43
44 bool is_bytes = (strcmp(cfg->metric, "bytes") == 0);
45 p->step_size = is_bytes ? (uint32_t)cfg->step_size_bytes : (uint32_t)cfg->step_size_ms;
46
47 beacon_price_hash_mint(cfg->mint_url, p->mint_hash);
48
49 p->geohash_len = (uint8_t)strnlen(cfg->nostr_geohash, TOLLGATE_IE_GEOHASH_MAX);
50 memcpy(p->geohash, cfg->nostr_geohash, p->geohash_len);
51 if (p->geohash_len < TOLLGATE_IE_GEOHASH_MAX) {
52 memset(p->geohash + p->geohash_len, 0, TOLLGATE_IE_GEOHASH_MAX - p->geohash_len);
53 }
54
55 if (id && id->initialized) {
56 beacon_price_hash_npub(id->npub_hex, p->npub_hash);
57 }
58
59 ESP_LOGI(TAG, "Built IE: price=%lu sats, step=%lu, metric=%s, geohash=%.*s",
60 (unsigned long)p->price_per_step, (unsigned long)p->step_size,
61 p->metric ? "bytes" : "milliseconds",
62 p->geohash_len, p->geohash);
63}
64
65esp_err_t beacon_price_start(void)
66{
67 if (s_active) {
68 ESP_LOGW(TAG, "Already active");
69 return ESP_OK;
70 }
71
72 static tollgate_price_ie_t s_ie;
73 beacon_price_build_ie(&s_ie);
74
75 esp_err_t ret = esp_wifi_set_vendor_ie(true, WIFI_VND_IE_TYPE_BEACON,
76 WIFI_VND_IE_ID_0, &s_ie);
77 if (ret != ESP_OK) {
78 ESP_LOGE(TAG, "Failed to set beacon vendor IE: %s", esp_err_to_name(ret));
79 return ret;
80 }
81
82 ret = esp_wifi_set_vendor_ie(true, WIFI_VND_IE_TYPE_PROBE_RESP,
83 WIFI_VND_IE_ID_1, &s_ie);
84 if (ret != ESP_OK) {
85 ESP_LOGW(TAG, "Failed to set probe resp vendor IE: %s", esp_err_to_name(ret));
86 }
87
88 s_active = true;
89 ESP_LOGI(TAG, "Price advertising started (beacon + probe response)");
90 return ESP_OK;
91}
92
93esp_err_t beacon_price_stop(void)
94{
95 if (!s_active) return ESP_OK;
96
97 esp_wifi_set_vendor_ie(false, WIFI_VND_IE_TYPE_BEACON, WIFI_VND_IE_ID_0, NULL);
98 esp_wifi_set_vendor_ie(false, WIFI_VND_IE_TYPE_PROBE_RESP, WIFI_VND_IE_ID_1, NULL);
99
100 s_active = false;
101 ESP_LOGI(TAG, "Price advertising stopped");
102 return ESP_OK;
103}
diff --git a/main/beacon_price.h b/main/beacon_price.h
new file mode 100644
index 0000000..cb2eb5b
--- /dev/null
+++ b/main/beacon_price.h
@@ -0,0 +1,44 @@
1#ifndef BEACON_PRICE_H
2#define BEACON_PRICE_H
3
4#include "esp_err.h"
5#include <stdint.h>
6#include <stdbool.h>
7
8#define TOLLGATE_OUI_0 0xC0
9#define TOLLGATE_OUI_1 0xFF
10#define TOLLGATE_OUI_2 0xEE
11#define TOLLGATE_IE_TYPE 0x01
12#define TOLLGATE_IE_VERSION 1
13
14#define TOLLGATE_IE_GEOHASH_MAX 9
15
16typedef struct __attribute__((packed)) {
17 uint8_t version;
18 uint8_t metric;
19 uint16_t price_per_step;
20 uint32_t step_size;
21 uint8_t mint_hash[4];
22 uint8_t geohash_len;
23 char geohash[TOLLGATE_IE_GEOHASH_MAX];
24 uint8_t npub_hash[4];
25} tollgate_price_payload_t;
26
27#define TOLLGATE_IE_PAYLOAD_SIZE sizeof(tollgate_price_payload_t)
28#define TOLLGATE_IE_TOTAL_SIZE (6 + TOLLGATE_IE_PAYLOAD_SIZE)
29
30typedef struct __attribute__((packed)) {
31 uint8_t element_id;
32 uint8_t length;
33 uint8_t vendor_oui[3];
34 uint8_t vendor_oui_type;
35 tollgate_price_payload_t payload;
36} tollgate_price_ie_t;
37
38esp_err_t beacon_price_start(void);
39esp_err_t beacon_price_stop(void);
40void beacon_price_build_ie(tollgate_price_ie_t *ie);
41void beacon_price_hash_mint(const char *mint_url, uint8_t hash_out[4]);
42void beacon_price_hash_npub(const char *npub_hex, uint8_t hash_out[4]);
43
44#endif
diff --git a/main/config.h b/main/config.h
index 1e580e9..af372af 100644
--- a/main/config.h
+++ b/main/config.h
@@ -69,6 +69,10 @@ typedef struct {
69 int nostr_seed_relay_count; 69 int nostr_seed_relay_count;
70 int nostr_sync_interval_s; 70 int nostr_sync_interval_s;
71 int nostr_fallback_sync_interval_s; 71 int nostr_fallback_sync_interval_s;
72
73 bool market_enabled;
74 int market_scan_interval_s;
75 bool client_auto_switch;
72} tollgate_config_t; 76} tollgate_config_t;
73 77
74void tollgate_config_derive_unique(tollgate_config_t *cfg); 78void tollgate_config_derive_unique(tollgate_config_t *cfg);
diff --git a/main/cvm_server.c b/main/cvm_server.c
index 644738b..a4804d2 100644
--- a/main/cvm_server.c
+++ b/main/cvm_server.c
@@ -8,6 +8,7 @@
8#include "nucula_wallet.h" 8#include "nucula_wallet.h"
9#include "cJSON.h" 9#include "cJSON.h"
10#include "esp_log.h" 10#include "esp_log.h"
11#include "esp_timer.h"
11#include "esp_tls.h" 12#include "esp_tls.h"
12#include "esp_crt_bundle.h" 13#include "esp_crt_bundle.h"
13#include "esp_random.h" 14#include "esp_random.h"
@@ -576,7 +577,7 @@ static void cvm_relay_task(void *arg)
576 char *text = parse_ws_text_frame(buf, rlen); 577 char *text = parse_ws_text_frame(buf, rlen);
577 if (text) { 578 if (text) {
578 if (strlen(text) > 0) { 579 if (strlen(text) > 0) {
579 process_relay_message(tls, relay_url, text); 580 process_relay_message(relay_url, text);
580 } 581 }
581 free(text); 582 free(text);
582 } 583 }
diff --git a/main/market.c b/main/market.c
new file mode 100644
index 0000000..c8a0b6d
--- /dev/null
+++ b/main/market.c
@@ -0,0 +1,237 @@
1#include "market.h"
2#include "beacon_price.h"
3#include "config.h"
4#include "identity.h"
5#include "esp_log.h"
6#include "esp_wifi.h"
7#include "freertos/FreeRTOS.h"
8#include "freertos/task.h"
9#include <string.h>
10
11static const char *TAG = "market";
12static market_t s_market;
13static bool s_initialized = false;
14
15static int64_t get_time_ms(void)
16{
17 return (int64_t)xTaskGetTickCount() * portTICK_PERIOD_MS;
18}
19
20static bool oui_matches(const uint8_t oui[3])
21{
22 return oui[0] == TOLLGATE_OUI_0 && oui[1] == TOLLGATE_OUI_1 && oui[2] == TOLLGATE_OUI_2;
23}
24
25static int find_entry_by_bssid(const uint8_t bssid[6])
26{
27 for (int i = 0; i < MARKET_MAX_ENTRIES; i++) {
28 if (s_market.entries[i].valid && memcmp(s_market.entries[i].bssid, bssid, 6) == 0) {
29 return i;
30 }
31 }
32 return -1;
33}
34
35static int find_free_slot(void)
36{
37 for (int i = 0; i < MARKET_MAX_ENTRIES; i++) {
38 if (!s_market.entries[i].valid) return i;
39 }
40 int oldest = 0;
41 int64_t oldest_time = s_market.entries[0].discovered_ms;
42 for (int i = 1; i < MARKET_MAX_ENTRIES; i++) {
43 if (s_market.entries[i].discovered_ms < oldest_time) {
44 oldest_time = s_market.entries[i].discovered_ms;
45 oldest = i;
46 }
47 }
48 return oldest;
49}
50
51void market_parse_vendor_ie(const uint8_t sa[6], const vendor_ie_data_t *ie, int rssi)
52{
53 if (!ie || ie->length < 4 + TOLLGATE_IE_PAYLOAD_SIZE) return;
54 if (!oui_matches(ie->vendor_oui)) return;
55 if (ie->vendor_oui_type != TOLLGATE_IE_TYPE) return;
56
57 const tollgate_price_payload_t *payload = (const tollgate_price_payload_t *)ie->payload;
58 if (payload->version != TOLLGATE_IE_VERSION) return;
59
60 const tollgate_identity_t *id = identity_get();
61 if (id && id->initialized) {
62 uint8_t my_npub_hash[4];
63 beacon_price_hash_npub(id->npub_hex, my_npub_hash);
64 if (memcmp(payload->npub_hash, my_npub_hash, 4) == 0) return;
65 }
66
67 int idx = find_entry_by_bssid(sa);
68 if (idx < 0) {
69 idx = find_free_slot();
70 if (s_market.count < MARKET_MAX_ENTRIES) s_market.count++;
71 }
72
73 market_entry_t *entry = &s_market.entries[idx];
74 memcpy(entry->bssid, sa, 6);
75 entry->rssi = (int8_t)rssi;
76 entry->price_per_step = payload->price_per_step;
77 entry->step_size = payload->step_size;
78 entry->metric = payload->metric;
79 memcpy(entry->mint_hash, payload->mint_hash, 4);
80 memcpy(entry->npub_hash, payload->npub_hash, 4);
81
82 uint8_t gh_len = payload->geohash_len;
83 if (gh_len > TOLLGATE_IE_GEOHASH_MAX) gh_len = TOLLGATE_IE_GEOHASH_MAX;
84 memcpy(entry->geohash, payload->geohash, gh_len);
85 entry->geohash[gh_len] = '\0';
86
87 entry->discovered_ms = get_time_ms();
88 entry->valid = true;
89 entry->ssid[0] = '\0';
90
91 ESP_LOGI(TAG, "Discovered TollGate %02X:%02X:%02X:%02X:%02X:%02X price=%lu sats step=%lu metric=%s RSSI=%d",
92 sa[0], sa[1], sa[2], sa[3], sa[4], sa[5],
93 (unsigned long)payload->price_per_step, (unsigned long)payload->step_size,
94 payload->metric ? "bytes" : "milliseconds", rssi);
95}
96
97static void vendor_ie_cb(void *ctx, wifi_vendor_ie_type_t type,
98 const uint8_t sa[6], const vendor_ie_data_t *vnd_ie, int rssi)
99{
100 (void)ctx;
101 (void)type;
102 if (!vnd_ie) return;
103 market_parse_vendor_ie(sa, vnd_ie, rssi);
104}
105
106static void scan_done_cb(void *arg, esp_event_base_t event_base,
107 int32_t event_id, void *event_data)
108{
109 (void)arg;
110 (void)event_base;
111 (void)event_id;
112 (void)event_data;
113
114 s_market.scanning = false;
115
116 uint16_t ap_count = 0;
117 esp_wifi_scan_get_ap_num(&ap_count);
118 if (ap_count == 0) return;
119
120 uint16_t max_aps = ap_count > 20 ? 20 : ap_count;
121 wifi_ap_record_t *ap_records = malloc(max_aps * sizeof(wifi_ap_record_t));
122 if (!ap_records) return;
123
124 esp_wifi_scan_get_ap_records(&max_aps, ap_records);
125
126 for (int i = 0; i < max_aps; i++) {
127 for (int j = 0; j < MARKET_MAX_ENTRIES; j++) {
128 if (!s_market.entries[j].valid) continue;
129 if (memcmp(s_market.entries[j].bssid, ap_records[i].bssid, 6) == 0) {
130 memcpy(s_market.entries[j].ssid, ap_records[i].ssid, 32);
131 s_market.entries[j].ssid[32] = '\0';
132 s_market.entries[j].rssi = ap_records[i].rssi;
133 break;
134 }
135 }
136 }
137 free(ap_records);
138 s_market.last_scan_ms = get_time_ms();
139
140 ESP_LOGI(TAG, "Scan complete: %d APs, %d TollGates found", max_aps, s_market.count);
141}
142
143static esp_event_handler_instance_t s_scan_done_handler = NULL;
144
145esp_err_t market_init(void)
146{
147 memset(&s_market, 0, sizeof(s_market));
148
149 esp_err_t ret = esp_wifi_set_vendor_ie_cb(vendor_ie_cb, NULL);
150 if (ret != ESP_OK) {
151 ESP_LOGE(TAG, "Failed to register vendor IE callback: %s", esp_err_to_name(ret));
152 return ret;
153 }
154
155 if (!s_scan_done_handler) {
156 ret = esp_event_handler_instance_register(WIFI_EVENT, WIFI_EVENT_SCAN_DONE,
157 scan_done_cb, NULL, &s_scan_done_handler);
158 if (ret != ESP_OK) {
159 ESP_LOGW(TAG, "Failed to register scan done handler: %s", esp_err_to_name(ret));
160 }
161 }
162
163 s_initialized = true;
164 ESP_LOGI(TAG, "Market scanner initialized");
165 return ESP_OK;
166}
167
168void market_tick(void)
169{
170 if (!s_initialized) return;
171
172 const tollgate_config_t *cfg = tollgate_config_get();
173 if (!cfg->market_enabled) return;
174
175 if (s_market.scanning) return;
176
177 int64_t now = get_time_ms();
178 int64_t elapsed = now - s_market.last_scan_ms;
179 int64_t interval_ms = (int64_t)cfg->market_scan_interval_s * 1000;
180 if (elapsed < interval_ms) return;
181
182 wifi_scan_config_t scan_config = {
183 .ssid = NULL,
184 .bssid = NULL,
185 .channel = 0,
186 .show_hidden = false,
187 .scan_type = WIFI_SCAN_TYPE_PASSIVE,
188 .scan_time.passive = 120,
189 };
190
191 esp_err_t ret = esp_wifi_scan_start(&scan_config, false);
192 if (ret == ESP_OK) {
193 s_market.scanning = true;
194 s_market.last_scan_ms = now;
195 s_market.consecutive_failures = 0;
196 ESP_LOGD(TAG, "Market scan started");
197 } else {
198 s_market.consecutive_failures++;
199 s_market.last_scan_ms = now;
200 if (s_market.consecutive_failures <= 3 || s_market.consecutive_failures % 30 == 0) {
201 ESP_LOGW(TAG, "Scan start failed: %s (failures: %d)", esp_err_to_name(ret), s_market.consecutive_failures);
202 }
203 }
204}
205
206const market_t *market_get(void)
207{
208 return &s_market;
209}
210
211int market_find_cheapest(void)
212{
213 int cheapest = -1;
214 uint32_t best_eff_price = UINT32_MAX;
215
216 for (int i = 0; i < MARKET_MAX_ENTRIES; i++) {
217 if (!s_market.entries[i].valid) continue;
218 if (s_market.entries[i].ssid[0] == '\0') continue;
219
220 uint32_t step = s_market.entries[i].step_size;
221 if (step == 0) continue;
222
223 uint32_t eff;
224 if (s_market.entries[i].metric == 0) {
225 eff = (uint32_t)s_market.entries[i].price_per_step * 60000 / step;
226 } else {
227 uint32_t eff_mb = (uint32_t)s_market.entries[i].price_per_step * 1048576 / step;
228 eff = eff_mb;
229 }
230
231 if (eff < best_eff_price) {
232 best_eff_price = eff;
233 cheapest = i;
234 }
235 }
236 return cheapest;
237}
diff --git a/main/market.h b/main/market.h
new file mode 100644
index 0000000..6dbf43c
--- /dev/null
+++ b/main/market.h
@@ -0,0 +1,40 @@
1#ifndef MARKET_H
2#define MARKET_H
3
4#include "beacon_price.h"
5#include "esp_wifi.h"
6#include "esp_err.h"
7#include <stdint.h>
8#include <stdbool.h>
9
10#define MARKET_MAX_ENTRIES 10
11
12typedef struct {
13 uint8_t bssid[6];
14 char ssid[33];
15 int8_t rssi;
16 uint16_t price_per_step;
17 uint32_t step_size;
18 uint8_t metric;
19 uint8_t mint_hash[4];
20 uint8_t npub_hash[4];
21 char geohash[TOLLGATE_IE_GEOHASH_MAX + 1];
22 int64_t discovered_ms;
23 bool valid;
24} market_entry_t;
25
26typedef struct {
27 market_entry_t entries[MARKET_MAX_ENTRIES];
28 int count;
29 int64_t last_scan_ms;
30 bool scanning;
31 int consecutive_failures;
32} market_t;
33
34esp_err_t market_init(void);
35void market_tick(void);
36const market_t *market_get(void);
37int market_find_cheapest(void);
38void market_parse_vendor_ie(const uint8_t sa[6], const vendor_ie_data_t *ie, int rssi);
39
40#endif
diff --git a/main/tollgate_api.c b/main/tollgate_api.c
index 650b0f3..15640c7 100644
--- a/main/tollgate_api.c
+++ b/main/tollgate_api.c
@@ -4,7 +4,10 @@
4#include "session.h" 4#include "session.h"
5#include "firewall.h" 5#include "firewall.h"
6#include "nucula_wallet.h" 6#include "nucula_wallet.h"
7#include "mint_health.h"
8#include "market.h"
7#include "esp_log.h" 9#include "esp_log.h"
10#include "esp_system.h"
8#include "cJSON.h" 11#include "cJSON.h"
9#include "lwip/sockets.h" 12#include "lwip/sockets.h"
10#include "lwip/netdb.h" 13#include "lwip/netdb.h"
@@ -471,6 +474,45 @@ static const httpd_uri_t uri_wallet = { .uri = "/wallet", .method = HTTP_GET, .h
471static const httpd_uri_t uri_wallet_swap = { .uri = "/wallet/swap", .method = HTTP_POST, .handler = api_post_wallet_swap }; 474static const httpd_uri_t uri_wallet_swap = { .uri = "/wallet/swap", .method = HTTP_POST, .handler = api_post_wallet_swap };
472static const httpd_uri_t uri_wallet_send = { .uri = "/wallet/send", .method = HTTP_POST, .handler = api_post_wallet_send }; 475static const httpd_uri_t uri_wallet_send = { .uri = "/wallet/send", .method = HTTP_POST, .handler = api_post_wallet_send };
473 476
477static esp_err_t api_get_market(httpd_req_t *req)
478{
479 const market_t *mkt = market_get();
480
481 cJSON *root = cJSON_CreateObject();
482 cJSON_AddNumberToObject(root, "count", mkt->count);
483 cJSON_AddNumberToObject(root, "last_scan_s", (double)(mkt->last_scan_ms / 1000));
484
485 cJSON *entries = cJSON_CreateArray();
486 for (int i = 0; i < MARKET_MAX_ENTRIES; i++) {
487 if (!mkt->entries[i].valid) continue;
488 const market_entry_t *e = &mkt->entries[i];
489
490 cJSON *entry = cJSON_CreateObject();
491 char bssid_str[18];
492 snprintf(bssid_str, sizeof(bssid_str), "%02X:%02X:%02X:%02X:%02X:%02X",
493 e->bssid[0], e->bssid[1], e->bssid[2],
494 e->bssid[3], e->bssid[4], e->bssid[5]);
495 cJSON_AddStringToObject(entry, "bssid", bssid_str);
496 cJSON_AddStringToObject(entry, "ssid", e->ssid[0] ? e->ssid : "unknown");
497 cJSON_AddNumberToObject(entry, "rssi", e->rssi);
498 cJSON_AddNumberToObject(entry, "price_per_step", e->price_per_step);
499 cJSON_AddNumberToObject(entry, "step_size", (double)e->step_size);
500 cJSON_AddStringToObject(entry, "metric", e->metric ? "bytes" : "milliseconds");
501 if (e->geohash[0]) cJSON_AddStringToObject(entry, "geohash", e->geohash);
502 cJSON_AddItemToArray(entries, entry);
503 }
504 cJSON_AddItemToObject(root, "entries", entries);
505
506 char *json = cJSON_PrintUnformatted(root);
507 httpd_resp_set_type(req, "application/json");
508 httpd_resp_send(req, json, strlen(json));
509 cJSON_free(json);
510 cJSON_Delete(root);
511 return ESP_OK;
512}
513
514static const httpd_uri_t uri_market = { .uri = "/market", .method = HTTP_GET, .handler = api_get_market };
515
474esp_err_t tollgate_api_start(void) 516esp_err_t tollgate_api_start(void)
475{ 517{
476 if (s_api_server) return ESP_OK; 518 if (s_api_server) return ESP_OK;
@@ -494,6 +536,7 @@ esp_err_t tollgate_api_start(void)
494 httpd_register_uri_handler(s_api_server, &uri_wallet); 536 httpd_register_uri_handler(s_api_server, &uri_wallet);
495 httpd_register_uri_handler(s_api_server, &uri_wallet_swap); 537 httpd_register_uri_handler(s_api_server, &uri_wallet_swap);
496 httpd_register_uri_handler(s_api_server, &uri_wallet_send); 538 httpd_register_uri_handler(s_api_server, &uri_wallet_send);
539 httpd_register_uri_handler(s_api_server, &uri_market);
497 540
498 ESP_LOGI(TAG, "TollGate API started on port 2121"); 541 ESP_LOGI(TAG, "TollGate API started on port 2121");
499 return ESP_OK; 542 return ESP_OK;
diff --git a/main/tollgate_client.c b/main/tollgate_client.c
index ac8dcfe..a81d16f 100644
--- a/main/tollgate_client.c
+++ b/main/tollgate_client.c
@@ -1,6 +1,7 @@
1#include "tollgate_client.h" 1#include "tollgate_client.h"
2#include "config.h" 2#include "config.h"
3#include "nucula_wallet.h" 3#include "nucula_wallet.h"
4#include "market.h"
4#include "esp_log.h" 5#include "esp_log.h"
5#include "esp_http_client.h" 6#include "esp_http_client.h"
6#include "esp_crt_bundle.h" 7#include "esp_crt_bundle.h"
@@ -343,6 +344,19 @@ esp_err_t tollgate_client_on_sta_connected(const char *gw_ip_str)
343 s_state = TG_CLIENT_PAID; 344 s_state = TG_CLIENT_PAID;
344 345
345 ESP_LOGI(TAG, "upstream TollGate paid: %lldms allotment", (long long)allotment); 346 ESP_LOGI(TAG, "upstream TollGate paid: %lldms allotment", (long long)allotment);
347
348 const market_t *mkt = market_get();
349 int cheapest = market_find_cheapest();
350 if (cheapest >= 0 && mkt->entries[cheapest].valid && mkt->entries[cheapest].ssid[0] != '\0') {
351 uint32_t upstream_step = s_discovery.step_size_ms > 0 ? s_discovery.step_size_ms : 1;
352 uint32_t upstream_eff = (uint32_t)s_discovery.price_per_step * 60000 / upstream_step;
353 uint32_t cheap_step = mkt->entries[cheapest].step_size > 0 ? mkt->entries[cheapest].step_size : 1;
354 uint32_t cheap_eff = (uint32_t)mkt->entries[cheapest].price_per_step * 60000 / cheap_step;
355 if (cheap_eff < upstream_eff) {
356 ESP_LOGW(TAG, "CHEAPER TOLLGATE AVAILABLE: %s at %lu sats/min vs upstream %lu sats/min",
357 mkt->entries[cheapest].ssid, (unsigned long)cheap_eff, (unsigned long)upstream_eff);
358 }
359 }
346 return ESP_OK; 360 return ESP_OK;
347} 361}
348 362
diff --git a/main/tollgate_main.c b/main/tollgate_main.c
index 4741765..f062cb6 100644
--- a/main/tollgate_main.c
+++ b/main/tollgate_main.c
@@ -27,6 +27,8 @@
27#include "local_relay.h" 27#include "local_relay.h"
28#include "relay_selector.h" 28#include "relay_selector.h"
29#include "sync_manager.h" 29#include "sync_manager.h"
30#include "beacon_price.h"
31#include "market.h"
30 32
31#define MAX_STA_RETRY 5 33#define MAX_STA_RETRY 5
32static const char *TAG = "tollgate_main"; 34static const char *TAG = "tollgate_main";
@@ -38,6 +40,8 @@ static esp_netif_t *s_sta_netif = NULL;
38static esp_netif_t *s_ap_netif = NULL; 40static esp_netif_t *s_ap_netif = NULL;
39static int s_retry_count = 0; 41static int s_retry_count = 0;
40static bool s_services_running = false; 42static bool s_services_running = false;
43static bool s_ap_services_running = false;
44static bool s_sta_connecting = false;
41static SemaphoreHandle_t s_services_mutex = NULL; 45static SemaphoreHandle_t s_services_mutex = NULL;
42static char s_ap_ip_str[16] = "10.0.0.1"; 46static char s_ap_ip_str[16] = "10.0.0.1";
43 47
@@ -46,23 +50,42 @@ static sync_manager_t s_sync_manager;
46 50
47static void start_services(void); 51static void start_services(void);
48static void stop_services(void); 52static void stop_services(void);
53static void start_ap_services(void);
54
55static void start_ap_services(void)
56{
57 if (s_ap_services_running) return;
58
59 tollgate_api_start();
60 beacon_price_start();
61 market_init();
62
63 s_ap_services_running = true;
64 ESP_LOGI(TAG, "=== AP-only services started (no STA) ===");
65}
49 66
50static void wifi_event_handler(void *arg, esp_event_base_t event_base, 67static void wifi_event_handler(void *arg, esp_event_base_t event_base,
51 int32_t event_id, void *event_data) 68 int32_t event_id, void *event_data)
52{ 69{
53 if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) { 70 if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
54 wifi_config_t wifi_cfg; 71 if (!s_sta_connecting) {
55 if (tollgate_config_get_wifi(&wifi_cfg) == ESP_OK) { 72 wifi_config_t wifi_cfg;
56 esp_wifi_set_config(WIFI_IF_STA, &wifi_cfg); 73 if (tollgate_config_get_wifi(&wifi_cfg) == ESP_OK) {
74 esp_wifi_set_config(WIFI_IF_STA, &wifi_cfg);
75 }
76 s_sta_connecting = true;
77 esp_wifi_connect();
57 } 78 }
58 esp_wifi_connect();
59 } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { 79 } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
80 wifi_event_sta_disconnected_t *disc = (wifi_event_sta_disconnected_t *)event_data;
60 s_retry_count++; 81 s_retry_count++;
61 ESP_LOGW(TAG, "WiFi disconnected, retry %d/%d", s_retry_count, MAX_STA_RETRY); 82 s_sta_connecting = false;
83 ESP_LOGW(TAG, "WiFi disconnected, reason=%d, retry %d/%d", disc->reason, s_retry_count, MAX_STA_RETRY);
62 tollgate_client_on_sta_disconnected(); 84 tollgate_client_on_sta_disconnected();
63 if (s_services_running) stop_services(); 85 if (s_services_running) stop_services();
64 if (s_retry_count < MAX_STA_RETRY) { 86 if (s_retry_count < MAX_STA_RETRY) {
65 vTaskDelay(pdMS_TO_TICKS(2000)); 87 vTaskDelay(pdMS_TO_TICKS(2000));
88 s_sta_connecting = true;
66 esp_wifi_connect(); 89 esp_wifi_connect();
67 } else { 90 } else {
68 wifi_config_t wifi_cfg; 91 wifi_config_t wifi_cfg;
@@ -72,7 +95,11 @@ static void wifi_event_handler(void *arg, esp_event_base_t event_base,
72 int idx = cfg->current_network; 95 int idx = cfg->current_network;
73 ESP_LOGI(TAG, "Trying WiFi network %d: %s", idx, cfg->networks[idx].ssid); 96 ESP_LOGI(TAG, "Trying WiFi network %d: %s", idx, cfg->networks[idx].ssid);
74 s_retry_count = 0; 97 s_retry_count = 0;
98 vTaskDelay(pdMS_TO_TICKS(2000));
99 s_sta_connecting = true;
75 esp_wifi_connect(); 100 esp_wifi_connect();
101 } else {
102 ESP_LOGI(TAG, "All WiFi networks exhausted, STA stopped (market scans active)");
76 } 103 }
77 } 104 }
78 } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_AP_STACONNECTED) { 105 } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_AP_STACONNECTED) {
@@ -85,6 +112,8 @@ static void wifi_event_handler(void *arg, esp_event_base_t event_base,
85 ESP_LOGI(TAG, "Station disconnected: MAC=%02x:%02x:%02x:%02x:%02x:%02x", 112 ESP_LOGI(TAG, "Station disconnected: MAC=%02x:%02x:%02x:%02x:%02x:%02x",
86 event->mac[0], event->mac[1], event->mac[2], 113 event->mac[0], event->mac[1], event->mac[2],
87 event->mac[3], event->mac[4], event->mac[5]); 114 event->mac[3], event->mac[4], event->mac[5]);
115 } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_AP_START) {
116 start_ap_services();
88 } 117 }
89} 118}
90 119
@@ -163,7 +192,11 @@ static void start_services(void)
163 192
164 dns_server_start(ap_ip_info.ip, upstream_dns); 193 dns_server_start(ap_ip_info.ip, upstream_dns);
165 captive_portal_start(cfg->ap_ip_str); 194 captive_portal_start(cfg->ap_ip_str);
166 tollgate_api_start(); 195 if (!s_ap_services_running) {
196 tollgate_api_start();
197 beacon_price_start();
198 market_init();
199 }
167 200
168 relay_selector_init(&s_relay_selector); 201 relay_selector_init(&s_relay_selector);
169 relay_selector_seed_from_config(&s_relay_selector); 202 relay_selector_seed_from_config(&s_relay_selector);
@@ -198,7 +231,10 @@ static void stop_services(void)
198 } 231 }
199 232
200 captive_portal_stop(); 233 captive_portal_stop();
201 tollgate_api_stop(); 234 if (!s_ap_services_running) {
235 tollgate_api_stop();
236 beacon_price_stop();
237 }
202 dns_server_stop(); 238 dns_server_stop();
203 cvm_server_stop(); 239 cvm_server_stop();
204 sync_manager_stop(&s_sync_manager); 240 sync_manager_stop(&s_sync_manager);
@@ -321,8 +357,7 @@ void app_main(void)
321 ESP_LOGI(TAG, "STA configured for SSID: %s", tcfg2->networks[tcfg2->current_network].ssid); 357 ESP_LOGI(TAG, "STA configured for SSID: %s", tcfg2->networks[tcfg2->current_network].ssid);
322 } 358 }
323 359
324 ESP_ERROR_CHECK(esp_wifi_set_country_code("DE", false)); 360 ESP_ERROR_CHECK(esp_wifi_set_country_code("DE", true));
325 ESP_LOGI(TAG, "WiFi country code set to DE (EU regulatory domain)");
326 361
327 ESP_ERROR_CHECK(esp_wifi_start()); 362 ESP_ERROR_CHECK(esp_wifi_start());
328 363
@@ -341,5 +376,6 @@ void app_main(void)
341 session_tick(); 376 session_tick();
342 tollgate_client_tick(); 377 tollgate_client_tick();
343 lightning_payout_tick(); 378 lightning_payout_tick();
379 market_tick();
344 } 380 }
345} 381}
diff --git a/tests/integration/test-market.mjs b/tests/integration/test-market.mjs
new file mode 100644
index 0000000..20f062f
--- /dev/null
+++ b/tests/integration/test-market.mjs
@@ -0,0 +1,60 @@
1import { execSync } from 'child_process';
2
3const API_URL = `http://${process.env.TOLLGATE_IP || '10.192.45.1'}:2121`;
4
5function run(cmd) {
6 try { return execSync(cmd, { encoding: 'utf8', timeout: 15000 }); }
7 catch (e) { return e.stdout || null; }
8}
9
10function runJson(cmd) {
11 const out = run(cmd);
12 try { return out ? JSON.parse(out) : null; }
13 catch { return null; }
14}
15
16let passed = 0, failed = 0;
17function assert(cond, msg) {
18 if (cond) { console.log(` PASS: ${msg}`); passed++; }
19 else { console.log(` FAIL: ${msg}`); failed++; }
20}
21
22console.log('=== test-market (GET /market) ===\n');
23
24console.log('--- /market endpoint responds ---');
25{
26 const data = runJson(`curl -s --connect-timeout 5 ${API_URL}/market`);
27 assert(data !== null, '/market returns valid JSON');
28 assert(typeof data.count === 'number', `count is number (got ${data?.count})`);
29 assert(Array.isArray(data.entries), 'entries is array');
30}
31
32console.log('\n--- /market entry structure ---');
33{
34 const data = runJson(`curl -s --connect-timeout 5 ${API_URL}/market`);
35 if (data && data.entries && data.entries.length > 0) {
36 const e = data.entries[0];
37 assert(typeof e.bssid === 'string', `bssid is string (got ${e.bssid})`);
38 assert(typeof e.ssid === 'string', `ssid is string (got ${e.ssid})`);
39 assert(typeof e.rssi === 'number', `rssi is number (got ${e.rssi})`);
40 assert(typeof e.price_per_step === 'number', `price_per_step is number (got ${e.price_per_step})`);
41 assert(typeof e.step_size === 'number', `step_size is number (got ${e.step_size})`);
42 assert(typeof e.metric === 'string', `metric is string (got ${e.metric})`);
43 } else {
44 console.log(' SKIP: no entries found (scan may not have run yet)');
45 }
46}
47
48console.log('\n--- /market with no discovered TollGates ---');
49{
50 const data = runJson(`curl -s --connect-timeout 5 ${API_URL}/market`);
51 if (data && data.count === 0) {
52 assert(data.entries.length === 0, 'empty entries array when count=0');
53 console.log(' INFO: no nearby TollGates discovered yet (expected if only one board)');
54 } else if (data && data.count > 0) {
55 console.log(` INFO: ${data.count} nearby TollGate(s) discovered`);
56 }
57}
58
59console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
60process.exit(failed > 0 ? 1 : 0);
diff --git a/tests/integration/test-price-discovery.mjs b/tests/integration/test-price-discovery.mjs
new file mode 100644
index 0000000..6762130
--- /dev/null
+++ b/tests/integration/test-price-discovery.mjs
@@ -0,0 +1,138 @@
1import { execSync } from 'child_process';
2
3const BOARD_A_IP = process.env.TOLLGATE_IP || '10.185.47.1';
4const BOARD_B_IP = process.env.TOLLGATE_B_IP || process.env.TOLLGATE_IP_B || '10.192.45.1';
5const API_A = `http://${BOARD_A_IP}:2121`;
6const API_B = `http://${BOARD_B_IP}:2121`;
7
8function run(cmd) {
9 try { return execSync(cmd, { encoding: 'utf8', timeout: 15000 }); }
10 catch (e) { return e.stdout || null; }
11}
12
13function runJson(cmd) {
14 const out = run(cmd);
15 try { return out ? JSON.parse(out) : null; }
16 catch { return null; }
17}
18
19let passed = 0, failed = 0;
20function assert(cond, msg) {
21 if (cond) { console.log(` PASS: ${msg}`); passed++; }
22 else { console.log(` FAIL: ${msg}`); failed++; }
23}
24
25function canReach(url) {
26 const result = run(`curl -s --connect-timeout 3 --max-time 5 -o /dev/null -w "%{http_code}" ${url}`);
27 return result && result.trim() !== '000' && result.trim() !== '';
28}
29
30console.log('=== test-price-discovery (two-board) ===\n');
31
32const reachA = canReach(`${API_A}/market`);
33const reachB = canReach(`${API_B}/market`);
34
35console.log(`Reachability: Board A=${reachA ? 'YES' : 'NO'}, Board B=${reachB ? 'YES' : 'NO'}\n`);
36
37if (!reachA && !reachB) {
38 console.log('FATAL: Neither board reachable. Check TOLLGATE_IP and TOLLGATE_B_IP');
39 process.exit(1);
40}
41
42console.log('--- Board A: market endpoint ---');
43{
44 if (reachA) {
45 const data = runJson(`curl -s --connect-timeout 5 --max-time 10 ${API_A}/market`);
46 assert(data !== null, 'Board A /market returns JSON');
47 assert(typeof data?.count === 'number', `Board A count is ${data?.count}`);
48 if (data && data.entries) {
49 console.log(` Board A sees ${data.count} nearby TollGate(s):`);
50 for (const e of data.entries) {
51 console.log(` ${e.ssid} (BSSID: ${e.bssid}) — ${e.price_per_step} sats/step, RSSI: ${e.rssi}`);
52 }
53 }
54 } else {
55 console.log(' SKIP: Board A not reachable');
56 }
57}
58
59console.log('\n--- Board B: market endpoint ---');
60{
61 if (reachB) {
62 const data = runJson(`curl -s --connect-timeout 5 --max-time 10 ${API_B}/market`);
63 assert(data !== null, 'Board B /market returns JSON');
64 assert(typeof data?.count === 'number', `Board B count is ${data?.count}`);
65 if (data && data.entries) {
66 console.log(` Board B sees ${data.count} nearby TollGate(s):`);
67 for (const e of data.entries) {
68 console.log(` ${e.ssid} (BSSID: ${e.bssid}) — ${e.price_per_step} sats/step, RSSI: ${e.rssi}`);
69 }
70 }
71 } else {
72 console.log(' SKIP: Board B not reachable');
73 }
74}
75
76console.log('\n--- Cross-discovery: Board A sees Board B ---');
77{
78 if (reachA) {
79 const mktA = runJson(`curl -s --connect-timeout 5 --max-time 10 ${API_A}/market`);
80 if (mktA && mktA.count > 0) {
81 const foundB = mktA.entries.some(e =>
82 e.ssid.startsWith('TollGate-') && e.bssid !== '' && e.price_per_step > 0
83 );
84 assert(foundB, `Board A discovered another TollGate (count=${mktA.count})`);
85 } else {
86 console.log(' INFO: Board A has 0 entries. Scan may need more time.');
87 }
88 } else {
89 console.log(' SKIP: Board A not reachable');
90 }
91}
92
93console.log('\n--- Cross-discovery: Board B sees Board A ---');
94{
95 if (reachB) {
96 const mktB = runJson(`curl -s --connect-timeout 5 --max-time 10 ${API_B}/market`);
97 if (mktB && mktB.count > 0) {
98 const foundA = mktB.entries.some(e =>
99 e.ssid.startsWith('TollGate-') && e.bssid !== '' && e.price_per_step > 0
100 );
101 assert(foundA, `Board B discovered another TollGate (count=${mktB.count})`);
102 } else {
103 console.log(' INFO: Board B has 0 entries. Scan may need more time.');
104 }
105 } else {
106 console.log(' SKIP: Board B not reachable');
107 }
108}
109
110console.log('\n--- Discovery data integrity ---');
111{
112 const boards = [];
113 if (reachA) {
114 const mktA = runJson(`curl -s --connect-timeout 5 --max-time 10 ${API_A}/market`);
115 if (mktA?.entries) boards.push({ name: 'A', data: mktA });
116 }
117 if (reachB) {
118 const mktB = runJson(`curl -s --connect-timeout 5 --max-time 10 ${API_B}/market`);
119 if (mktB?.entries) boards.push({ name: 'B', data: mktB });
120 }
121
122 for (const { name, data } of boards) {
123 for (const e of data.entries) {
124 assert(typeof e.price_per_step === 'number' && e.price_per_step > 0,
125 `Board ${name} entry has valid price (${e.price_per_step})`);
126 assert(typeof e.step_size === 'number' && e.step_size > 0,
127 `Board ${name} entry has valid step_size (${e.step_size})`);
128 assert(typeof e.metric === 'string' && e.metric.length > 0,
129 `Board ${name} entry has valid metric (${e.metric})`);
130 assert(typeof e.rssi === 'number',
131 `Board ${name} entry has valid RSSI (${e.rssi})`);
132 break;
133 }
134 }
135}
136
137console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
138process.exit(failed > 0 ? 1 : 0);
diff --git a/tests/unit/Makefile b/tests/unit/Makefile
index 6d13e4d..7bd3f1e 100644
--- a/tests/unit/Makefile
+++ b/tests/unit/Makefile
@@ -22,7 +22,7 @@ LDFLAGS := -lmbedcrypto -lcjson -lm
22 22
23SECP256K1_OBJ := secp256k1.o precomputed_ecmult.o precomputed_ecmult_gen.o 23SECP256K1_OBJ := secp256k1.o precomputed_ecmult.o precomputed_ecmult_gen.o
24 24
25TESTS := test_geohash test_identity test_nostr_event test_cashu test_session test_tollgate_client test_lnurl_pay test_lightning_payout test_mcp_handler test_nip04 test_cvm_server test_display test_negentropy_adapter 25TESTS := test_geohash test_identity test_nostr_event test_cashu test_session test_tollgate_client test_lnurl_pay test_lightning_payout test_mcp_handler test_nip04 test_cvm_server test_display test_negentropy_adapter test_beacon_price test_market
26 26
27.PHONY: all test clean $(TESTS) 27.PHONY: all test clean $(TESTS)
28 28
@@ -63,8 +63,8 @@ test_cashu: test_cashu.c $(REPO_ROOT)/main/cashu.c
63test_session: test_session.c $(REPO_ROOT)/main/session.c $(REPO_ROOT)/main/cashu.c 63test_session: test_session.c $(REPO_ROOT)/main/session.c $(REPO_ROOT)/main/cashu.c
64 $(CC) $(CFLAGS) $< $(REPO_ROOT)/main/session.c $(REPO_ROOT)/main/cashu.c -o $@ $(LDFLAGS) 64 $(CC) $(CFLAGS) $< $(REPO_ROOT)/main/session.c $(REPO_ROOT)/main/cashu.c -o $@ $(LDFLAGS)
65 65
66test_tollgate_client: test_tollgate_client.c 66test_tollgate_client: test_tollgate_client.c $(REPO_ROOT)/main/market.c $(REPO_ROOT)/main/beacon_price.c
67 $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) 67 $(CC) $(CFLAGS) -I $(REPO_ROOT)/main $< $(REPO_ROOT)/main/market.c $(REPO_ROOT)/main/beacon_price.c -o $@ $(LDFLAGS)
68 68
69test_lnurl_pay: test_lnurl_pay.c 69test_lnurl_pay: test_lnurl_pay.c
70 $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) 70 $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS)
@@ -87,5 +87,11 @@ test_display: test_display.c
87test_negentropy_adapter: test_negentropy_adapter.c 87test_negentropy_adapter: test_negentropy_adapter.c
88 $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) 88 $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS)
89 89
90test_beacon_price: test_beacon_price.c $(REPO_ROOT)/main/beacon_price.c
91 $(CC) $(CFLAGS) -I $(REPO_ROOT)/main $< $(REPO_ROOT)/main/beacon_price.c -o $@ $(LDFLAGS)
92
93test_market: test_market.c $(REPO_ROOT)/main/market.c $(REPO_ROOT)/main/beacon_price.c
94 $(CC) $(CFLAGS) -I $(REPO_ROOT)/main $< $(REPO_ROOT)/main/market.c $(REPO_ROOT)/main/beacon_price.c -o $@ $(LDFLAGS)
95
90clean: 96clean:
91 rm -f $(TESTS) $(SECP256K1_OBJ) 97 rm -f $(TESTS) $(SECP256K1_OBJ)
diff --git a/tests/unit/stubs/esp_wifi.h b/tests/unit/stubs/esp_wifi.h
index 6aa5787..5eb14bf 100644
--- a/tests/unit/stubs/esp_wifi.h
+++ b/tests/unit/stubs/esp_wifi.h
@@ -2,6 +2,7 @@
2#define STUBS_ESP_WIFI_H 2#define STUBS_ESP_WIFI_H
3 3
4#include <stdint.h> 4#include <stdint.h>
5#include <stdbool.h>
5#include <string.h> 6#include <string.h>
6#include "esp_err.h" 7#include "esp_err.h"
7 8
@@ -37,4 +38,67 @@ static inline esp_err_t esp_wifi_set_config(int ifx, const wifi_config_t *cfg) {
37static inline esp_err_t esp_wifi_set_mode(uint8_t mode) { (void)mode; return ESP_OK; } 38static inline esp_err_t esp_wifi_set_mode(uint8_t mode) { (void)mode; return ESP_OK; }
38static inline esp_err_t esp_wifi_start(void) { return ESP_OK; } 39static inline esp_err_t esp_wifi_start(void) { return ESP_OK; }
39 40
41#define WIFI_VENDOR_IE_ELEMENT_ID 0xDD
42
43typedef enum {
44 WIFI_VND_IE_TYPE_BEACON,
45 WIFI_VND_IE_TYPE_PROBE_REQ,
46 WIFI_VND_IE_TYPE_PROBE_RESP,
47 WIFI_VND_IE_TYPE_ASSOC_REQ,
48 WIFI_VND_IE_TYPE_ASSOC_RESP,
49} wifi_vendor_ie_type_t;
50
51typedef enum {
52 WIFI_VND_IE_ID_0,
53 WIFI_VND_IE_ID_1,
54} wifi_vendor_ie_id_t;
55
56typedef struct {
57 uint8_t element_id;
58 uint8_t length;
59 uint8_t vendor_oui[3];
60 uint8_t vendor_oui_type;
61 uint8_t payload[0];
62} vendor_ie_data_t;
63
64typedef void (*esp_vendor_ie_cb_t)(void *ctx, wifi_vendor_ie_type_t type, const uint8_t sa[6], const vendor_ie_data_t *vnd_ie, int rssi);
65
66static inline esp_err_t esp_wifi_set_vendor_ie(bool enable, wifi_vendor_ie_type_t type, wifi_vendor_ie_id_t idx, const void *vnd_ie) { (void)enable; (void)type; (void)idx; (void)vnd_ie; return ESP_OK; }
67static inline esp_err_t esp_wifi_set_vendor_ie_cb(esp_vendor_ie_cb_t cb, void *ctx) { (void)cb; (void)ctx; return ESP_OK; }
68
69#define WIFI_SCAN_TYPE_PASSIVE 0
70
71typedef struct {
72 uint8_t bssid[6];
73 uint8_t ssid[33];
74 uint8_t primary;
75 int second;
76 int8_t rssi;
77 int authmode;
78} wifi_ap_record_t;
79
80typedef struct {
81 uint8_t *ssid;
82 uint8_t *bssid;
83 uint8_t channel;
84 bool show_hidden;
85 int scan_type;
86 union {
87 struct { int min; int max; } active;
88 int passive;
89 } scan_time;
90} wifi_scan_config_t;
91
92static inline esp_err_t esp_wifi_scan_start(const wifi_scan_config_t *cfg, bool block) { (void)cfg; (void)block; return ESP_OK; }
93static inline esp_err_t esp_wifi_scan_get_ap_num(uint16_t *n) { *n = 0; return ESP_OK; }
94static inline esp_err_t esp_wifi_scan_get_ap_records(uint16_t *n, wifi_ap_record_t *records) { (void)records; *n = 0; return ESP_OK; }
95
96#define WIFI_EVENT_SCAN_DONE 3
97
98typedef void *esp_event_handler_instance_t;
99typedef const char *esp_event_base_t;
100#define WIFI_EVENT "WIFI_EVENT"
101
102static inline esp_err_t esp_event_handler_instance_register(esp_event_base_t a, int32_t b, void *c, void *d, esp_event_handler_instance_t *e) { (void)a; (void)b; (void)c; (void)d; (void)e; return ESP_OK; }
103
40#endif 104#endif
diff --git a/tests/unit/test_beacon_price.c b/tests/unit/test_beacon_price.c
new file mode 100644
index 0000000..9574478
--- /dev/null
+++ b/tests/unit/test_beacon_price.c
@@ -0,0 +1,132 @@
1#include "test_framework.h"
2#include "../../main/config.h"
3#include "../../main/identity.h"
4#include <string.h>
5#include <stdio.h>
6#include <mbedtls/sha256.h>
7
8#include "../../main/beacon_price.h"
9
10static tollgate_config_t g_test_config;
11static tollgate_identity_t g_test_identity;
12
13const tollgate_config_t *tollgate_config_get(void) { return &g_test_config; }
14const tollgate_identity_t *identity_get(void) { return &g_test_identity; }
15
16int main(void)
17{
18 printf("=== test_beacon_price ===\n");
19
20 memset(&g_test_config, 0, sizeof(g_test_config));
21 strncpy(g_test_config.mint_url, "https://testnut.cashu.space", sizeof(g_test_config.mint_url) - 1);
22 strncpy(g_test_config.metric, "milliseconds", sizeof(g_test_config.metric) - 1);
23 g_test_config.price_per_step = 21;
24 g_test_config.step_size_ms = 60000;
25 strncpy(g_test_config.nostr_geohash, "u281w0dfz", sizeof(g_test_config.nostr_geohash) - 1);
26
27 memset(&g_test_identity, 0, sizeof(g_test_identity));
28 strncpy(g_test_identity.npub_hex, "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", 64);
29 g_test_identity.initialized = true;
30
31 printf("\n--- tollgate_price_payload_t size ---\n");
32 {
33 ASSERT_EQ_INT(26, (int)TOLLGATE_IE_PAYLOAD_SIZE, "payload is 26 bytes");
34 ASSERT_EQ_INT(32, (int)TOLLGATE_IE_TOTAL_SIZE, "total IE is 32 bytes");
35 }
36
37 printf("\n--- beacon_price_hash_mint ---\n");
38 {
39 uint8_t hash[4];
40 beacon_price_hash_mint("https://testnut.cashu.space", hash);
41
42 uint8_t expected[32];
43 mbedtls_sha256((const unsigned char *)"https://testnut.cashu.space",
44 strlen("https://testnut.cashu.space"), expected, 0);
45 ASSERT_MEM_EQ(expected, hash, 4, "mint_hash matches SHA-256 prefix");
46
47 uint8_t hash2[4];
48 beacon_price_hash_mint("https://other.mint.url", hash2);
49 ASSERT(memcmp(hash, hash2, 4) != 0, "different mint URLs produce different hashes");
50 }
51
52 printf("\n--- beacon_price_hash_npub ---\n");
53 {
54 uint8_t hash[4];
55 beacon_price_hash_npub("abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", hash);
56
57 uint8_t expected[32];
58 mbedtls_sha256((const unsigned char *)"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
59 64, expected, 0);
60 ASSERT_MEM_EQ(expected, hash, 4, "npub_hash matches SHA-256 prefix");
61 }
62
63 printf("\n--- beacon_price_build_ie (time metric) ---\n");
64 {
65 tollgate_price_ie_t ie;
66 beacon_price_build_ie(&ie);
67
68 ASSERT_EQ_INT(0xDD, ie.element_id, "element_id is 0xDD");
69 ASSERT_EQ_INT(4 + 26, ie.length, "length is 30 (4 header + 26 payload)");
70 ASSERT_EQ_INT(0xC0, ie.vendor_oui[0], "OUI byte 0");
71 ASSERT_EQ_INT(0xFF, ie.vendor_oui[1], "OUI byte 1");
72 ASSERT_EQ_INT(0xEE, ie.vendor_oui[2], "OUI byte 2");
73 ASSERT_EQ_INT(0x01, ie.vendor_oui_type, "OUI type is 0x01");
74
75 ASSERT_EQ_INT(1, ie.payload.version, "version is 1");
76 ASSERT_EQ_INT(0, ie.payload.metric, "metric is 0 (milliseconds)");
77 ASSERT_EQ_INT(21, ie.payload.price_per_step, "price is 21");
78 ASSERT_EQ_INT(60000, (int)ie.payload.step_size, "step_size is 60000");
79
80 uint8_t expected_mint_hash[4];
81 beacon_price_hash_mint("https://testnut.cashu.space", expected_mint_hash);
82 ASSERT_MEM_EQ(expected_mint_hash, ie.payload.mint_hash, 4, "mint_hash matches");
83
84 ASSERT_EQ_INT(9, ie.payload.geohash_len, "geohash_len is 9");
85 ASSERT(memcmp(ie.payload.geohash, "u281w0dfz", 9) == 0, "geohash matches");
86 }
87
88 printf("\n--- beacon_price_build_ie (bytes metric) ---\n");
89 {
90 strncpy(g_test_config.metric, "bytes", sizeof(g_test_config.metric) - 1);
91 g_test_config.step_size_bytes = 22020096;
92 g_test_config.price_per_step = 5;
93
94 tollgate_price_ie_t ie;
95 beacon_price_build_ie(&ie);
96
97 ASSERT_EQ_INT(1, ie.payload.metric, "metric is 1 (bytes)");
98 ASSERT_EQ_INT(5, ie.payload.price_per_step, "price is 5");
99 ASSERT_EQ_INT(22020096, (int)ie.payload.step_size, "step_size is 22020096 bytes");
100
101 strncpy(g_test_config.metric, "milliseconds", sizeof(g_test_config.metric) - 1);
102 g_test_config.step_size_ms = 60000;
103 g_test_config.price_per_step = 21;
104 }
105
106 printf("\n--- roundtrip: build → parse ---\n");
107 {
108 tollgate_price_ie_t ie;
109 beacon_price_build_ie(&ie);
110
111 vendor_ie_data_t *vnd_ie = (vendor_ie_data_t *)&ie;
112
113 ASSERT(vnd_ie->length >= 4 + (int)TOLLGATE_IE_PAYLOAD_SIZE, "vendor IE length sufficient");
114
115 const tollgate_price_payload_t *parsed = (const tollgate_price_payload_t *)vnd_ie->payload;
116 ASSERT_EQ_INT(1, parsed->version, "parsed version");
117 ASSERT_EQ_INT(0, parsed->metric, "parsed metric");
118 ASSERT_EQ_INT(21, parsed->price_per_step, "parsed price");
119 ASSERT_EQ_INT(60000, (int)parsed->step_size, "parsed step_size");
120 ASSERT_EQ_INT(9, parsed->geohash_len, "parsed geohash_len");
121 }
122
123 printf("\n--- struct packing check ---\n");
124 {
125 tollgate_price_ie_t ie;
126 memset(&ie, 0, sizeof(ie));
127 int expected_size = 2 + 3 + 1 + 26;
128 ASSERT_EQ_INT(expected_size, (int)sizeof(tollgate_price_ie_t), "no padding in struct");
129 }
130
131 TEST_SUMMARY();
132}
diff --git a/tests/unit/test_market.c b/tests/unit/test_market.c
new file mode 100644
index 0000000..c19d26e
--- /dev/null
+++ b/tests/unit/test_market.c
@@ -0,0 +1,177 @@
1#include "test_framework.h"
2#include "../../main/beacon_price.h"
3#include "../../main/market.h"
4#include "../../main/config.h"
5#include "../../main/identity.h"
6#include <string.h>
7#include <stdio.h>
8#include <stdlib.h>
9
10static tollgate_config_t g_test_config;
11static tollgate_identity_t g_test_identity;
12
13const tollgate_config_t *tollgate_config_get(void) { return &g_test_config; }
14const tollgate_identity_t *identity_get(void) { return &g_test_identity; }
15
16static void build_test_ie(tollgate_price_ie_t *ie, uint16_t price, uint32_t step, uint8_t metric,
17 const char *geohash, const char *mint_url, const char *npub_hex)
18{
19 memset(ie, 0, sizeof(*ie));
20 ie->element_id = 0xDD;
21 ie->length = 4 + TOLLGATE_IE_PAYLOAD_SIZE;
22 ie->vendor_oui[0] = TOLLGATE_OUI_0;
23 ie->vendor_oui[1] = TOLLGATE_OUI_1;
24 ie->vendor_oui[2] = TOLLGATE_OUI_2;
25 ie->vendor_oui_type = TOLLGATE_IE_TYPE;
26
27 ie->payload.version = TOLLGATE_IE_VERSION;
28 ie->payload.metric = metric;
29 ie->payload.price_per_step = price;
30 ie->payload.step_size = step;
31
32 if (mint_url) beacon_price_hash_mint(mint_url, ie->payload.mint_hash);
33 if (npub_hex) beacon_price_hash_npub(npub_hex, ie->payload.npub_hash);
34
35 uint8_t gh_len = (uint8_t)strnlen(geohash, TOLLGATE_IE_GEOHASH_MAX);
36 ie->payload.geohash_len = gh_len;
37 memcpy(ie->payload.geohash, geohash, gh_len);
38}
39
40static void reset_market(void)
41{
42 market_t *m = (market_t *)market_get();
43 memset(m, 0, sizeof(*m));
44}
45
46int main(void)
47{
48 printf("=== test_market ===\n");
49
50 memset(&g_test_config, 0, sizeof(g_test_config));
51 g_test_config.market_enabled = true;
52 g_test_config.market_scan_interval_s = 30;
53 strncpy(g_test_config.metric, "milliseconds", sizeof(g_test_config.metric) - 1);
54
55 memset(&g_test_identity, 0, sizeof(g_test_identity));
56 strncpy(g_test_identity.npub_hex, "0000000000000000000000000000000000000000000000000000000000000001", 64);
57 g_test_identity.initialized = true;
58
59 printf("\n--- parse vendor IE (valid) ---\n");
60 {
61 reset_market();
62 tollgate_price_ie_t ie;
63 build_test_ie(&ie, 21, 60000, 0, "u281w0dfz",
64 "https://testnut.cashu.space",
65 "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890");
66
67 uint8_t bssid[6] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0x01};
68 market_parse_vendor_ie(bssid, (vendor_ie_data_t *)&ie, -45);
69
70 const market_t *m = market_get();
71 ASSERT_EQ_INT(1, m->count, "one entry added");
72 ASSERT(m->entries[0].valid, "entry is valid");
73 ASSERT_EQ_INT(21, m->entries[0].price_per_step, "price is 21");
74 ASSERT_EQ_INT(60000, (int)m->entries[0].step_size, "step_size is 60000");
75 ASSERT_EQ_INT(0, m->entries[0].metric, "metric is 0 (time)");
76 ASSERT_EQ_INT(-45, m->entries[0].rssi, "rssi is -45");
77 ASSERT(memcmp(m->entries[0].bssid, bssid, 6) == 0, "bssid matches");
78 }
79
80 printf("\n--- parse vendor IE (ignore self) ---\n");
81 {
82 reset_market();
83 tollgate_price_ie_t ie;
84 build_test_ie(&ie, 21, 60000, 0, "u281w0dfz",
85 "https://testnut.cashu.space",
86 g_test_identity.npub_hex);
87
88 uint8_t bssid[6] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0x02};
89 market_parse_vendor_ie(bssid, (vendor_ie_data_t *)&ie, -50);
90
91 const market_t *m = market_get();
92 ASSERT_EQ_INT(0, m->count, "self-entry ignored");
93 }
94
95 printf("\n--- parse vendor IE (wrong OUI) ---\n");
96 {
97 reset_market();
98 tollgate_price_ie_t ie;
99 build_test_ie(&ie, 21, 60000, 0, "u281w0dfz",
100 "https://testnut.cashu.space",
101 "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890");
102 ie.vendor_oui[0] = 0x00;
103
104 uint8_t bssid[6] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0x03};
105 market_parse_vendor_ie(bssid, (vendor_ie_data_t *)&ie, -40);
106
107 const market_t *m = market_get();
108 ASSERT_EQ_INT(0, m->count, "wrong OUI rejected");
109 }
110
111 printf("\n--- market_find_cheapest ---\n");
112 {
113 reset_market();
114
115 tollgate_price_ie_t ie1, ie2, ie3;
116 build_test_ie(&ie1, 21, 60000, 0, "u281w0dfz",
117 "https://testnut.cashu.space", "aaa...npub1");
118 build_test_ie(&ie2, 10, 60000, 0, "u281w0dfz",
119 "https://testnut.cashu.space", "bbb...npub2");
120 build_test_ie(&ie3, 50, 60000, 0, "u281w0dfz",
121 "https://testnut.cashu.space", "ccc...npub3");
122
123 uint8_t bssid1[6] = {0x01, 0x01, 0x01, 0x01, 0x01, 0x01};
124 uint8_t bssid2[6] = {0x02, 0x02, 0x02, 0x02, 0x02, 0x02};
125 uint8_t bssid3[6] = {0x03, 0x03, 0x03, 0x03, 0x03, 0x03};
126
127 market_parse_vendor_ie(bssid1, (vendor_ie_data_t *)&ie1, -45);
128 market_parse_vendor_ie(bssid2, (vendor_ie_data_t *)&ie2, -50);
129 market_parse_vendor_ie(bssid3, (vendor_ie_data_t *)&ie3, -55);
130
131 const market_t *m = market_get();
132 ASSERT_EQ_INT(3, m->count, "three entries");
133
134 strncpy((char *)m->entries[0].ssid, "TollGate-A", 32);
135 strncpy((char *)m->entries[1].ssid, "TollGate-B", 32);
136 strncpy((char *)m->entries[2].ssid, "TollGate-C", 32);
137
138 int cheapest = market_find_cheapest();
139 ASSERT(cheapest >= 0, "found a cheapest entry");
140 ASSERT_EQ_INT(10, m->entries[cheapest].price_per_step, "cheapest is 10 sats");
141 }
142
143 printf("\n--- update existing entry ---\n");
144 {
145 reset_market();
146 tollgate_price_ie_t ie;
147 build_test_ie(&ie, 21, 60000, 0, "u281w0dfz",
148 "https://testnut.cashu.space", "npub1");
149 uint8_t bssid[6] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06};
150
151 market_parse_vendor_ie(bssid, (vendor_ie_data_t *)&ie, -45);
152 ASSERT_EQ_INT(1, market_get()->count, "first add");
153
154 build_test_ie(&ie, 15, 60000, 0, "u281w0dfz",
155 "https://testnut.cashu.space", "npub1");
156 market_parse_vendor_ie(bssid, (vendor_ie_data_t *)&ie, -47);
157 ASSERT_EQ_INT(1, market_get()->count, "update doesn't increase count");
158 ASSERT_EQ_INT(15, market_get()->entries[0].price_per_step, "price updated to 15");
159 }
160
161 printf("\n--- geohash preserved ---\n");
162 {
163 reset_market();
164 tollgate_price_ie_t ie;
165 build_test_ie(&ie, 21, 60000, 0, "u281w0dfz",
166 "https://testnut.cashu.space", "npub1");
167 uint8_t bssid[6] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF};
168
169 market_parse_vendor_ie(bssid, (vendor_ie_data_t *)&ie, -40);
170
171 const market_t *m = market_get();
172 ASSERT(m->entries[0].valid, "entry valid");
173 ASSERT_EQ_STR("u281w0dfz", m->entries[0].geohash, "geohash is u281w0dfz");
174 }
175
176 TEST_SUMMARY();
177}
diff --git a/tests/unit/test_tollgate_client.c b/tests/unit/test_tollgate_client.c
index 686ad19..eebc747 100644
--- a/tests/unit/test_tollgate_client.c
+++ b/tests/unit/test_tollgate_client.c
@@ -13,6 +13,7 @@ const tollgate_config_t *tollgate_config_get(void) {
13 13
14uint64_t nucula_wallet_balance(void) { return 100; } 14uint64_t nucula_wallet_balance(void) { return 100; }
15esp_err_t nucula_wallet_send(uint64_t a, char *b, size_t c) { (void)a; (void)b; (void)c; return ESP_OK; } 15esp_err_t nucula_wallet_send(uint64_t a, char *b, size_t c) { (void)a; (void)b; (void)c; return ESP_OK; }
16const void *identity_get(void) { return NULL; }
16 17
17#include "freertos/FreeRTOS.h" 18#include "freertos/FreeRTOS.h"
18 19