From fe6aa9663d4cdabdc6e71db6068f8cd9e3739ffe Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 19 May 2026 13:14:48 +0530 Subject: 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. --- tests/unit/Makefile | 12 ++- tests/unit/stubs/esp_wifi.h | 64 ++++++++++++++ tests/unit/test_beacon_price.c | 132 ++++++++++++++++++++++++++++ tests/unit/test_market.c | 177 ++++++++++++++++++++++++++++++++++++++ tests/unit/test_tollgate_client.c | 1 + 5 files changed, 383 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_beacon_price.c create mode 100644 tests/unit/test_market.c (limited to 'tests/unit') 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 SECP256K1_OBJ := secp256k1.o precomputed_ecmult.o precomputed_ecmult_gen.o -TESTS := 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 +TESTS := 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 .PHONY: all test clean $(TESTS) @@ -63,8 +63,8 @@ test_cashu: test_cashu.c $(REPO_ROOT)/main/cashu.c test_session: test_session.c $(REPO_ROOT)/main/session.c $(REPO_ROOT)/main/cashu.c $(CC) $(CFLAGS) $< $(REPO_ROOT)/main/session.c $(REPO_ROOT)/main/cashu.c -o $@ $(LDFLAGS) -test_tollgate_client: test_tollgate_client.c - $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) +test_tollgate_client: test_tollgate_client.c $(REPO_ROOT)/main/market.c $(REPO_ROOT)/main/beacon_price.c + $(CC) $(CFLAGS) -I $(REPO_ROOT)/main $< $(REPO_ROOT)/main/market.c $(REPO_ROOT)/main/beacon_price.c -o $@ $(LDFLAGS) test_lnurl_pay: test_lnurl_pay.c $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) @@ -87,5 +87,11 @@ test_display: test_display.c test_negentropy_adapter: test_negentropy_adapter.c $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) +test_beacon_price: test_beacon_price.c $(REPO_ROOT)/main/beacon_price.c + $(CC) $(CFLAGS) -I $(REPO_ROOT)/main $< $(REPO_ROOT)/main/beacon_price.c -o $@ $(LDFLAGS) + +test_market: test_market.c $(REPO_ROOT)/main/market.c $(REPO_ROOT)/main/beacon_price.c + $(CC) $(CFLAGS) -I $(REPO_ROOT)/main $< $(REPO_ROOT)/main/market.c $(REPO_ROOT)/main/beacon_price.c -o $@ $(LDFLAGS) + clean: 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 @@ #define STUBS_ESP_WIFI_H #include +#include #include #include "esp_err.h" @@ -37,4 +38,67 @@ static inline esp_err_t esp_wifi_set_config(int ifx, const wifi_config_t *cfg) { static inline esp_err_t esp_wifi_set_mode(uint8_t mode) { (void)mode; return ESP_OK; } static inline esp_err_t esp_wifi_start(void) { return ESP_OK; } +#define WIFI_VENDOR_IE_ELEMENT_ID 0xDD + +typedef enum { + WIFI_VND_IE_TYPE_BEACON, + WIFI_VND_IE_TYPE_PROBE_REQ, + WIFI_VND_IE_TYPE_PROBE_RESP, + WIFI_VND_IE_TYPE_ASSOC_REQ, + WIFI_VND_IE_TYPE_ASSOC_RESP, +} wifi_vendor_ie_type_t; + +typedef enum { + WIFI_VND_IE_ID_0, + WIFI_VND_IE_ID_1, +} wifi_vendor_ie_id_t; + +typedef struct { + uint8_t element_id; + uint8_t length; + uint8_t vendor_oui[3]; + uint8_t vendor_oui_type; + uint8_t payload[0]; +} vendor_ie_data_t; + +typedef 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); + +static 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; } +static 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; } + +#define WIFI_SCAN_TYPE_PASSIVE 0 + +typedef struct { + uint8_t bssid[6]; + uint8_t ssid[33]; + uint8_t primary; + int second; + int8_t rssi; + int authmode; +} wifi_ap_record_t; + +typedef struct { + uint8_t *ssid; + uint8_t *bssid; + uint8_t channel; + bool show_hidden; + int scan_type; + union { + struct { int min; int max; } active; + int passive; + } scan_time; +} wifi_scan_config_t; + +static inline esp_err_t esp_wifi_scan_start(const wifi_scan_config_t *cfg, bool block) { (void)cfg; (void)block; return ESP_OK; } +static inline esp_err_t esp_wifi_scan_get_ap_num(uint16_t *n) { *n = 0; return ESP_OK; } +static 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; } + +#define WIFI_EVENT_SCAN_DONE 3 + +typedef void *esp_event_handler_instance_t; +typedef const char *esp_event_base_t; +#define WIFI_EVENT "WIFI_EVENT" + +static 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; } + #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 @@ +#include "test_framework.h" +#include "../../main/config.h" +#include "../../main/identity.h" +#include +#include +#include + +#include "../../main/beacon_price.h" + +static tollgate_config_t g_test_config; +static tollgate_identity_t g_test_identity; + +const tollgate_config_t *tollgate_config_get(void) { return &g_test_config; } +const tollgate_identity_t *identity_get(void) { return &g_test_identity; } + +int main(void) +{ + printf("=== test_beacon_price ===\n"); + + memset(&g_test_config, 0, sizeof(g_test_config)); + strncpy(g_test_config.mint_url, "https://testnut.cashu.space", sizeof(g_test_config.mint_url) - 1); + strncpy(g_test_config.metric, "milliseconds", sizeof(g_test_config.metric) - 1); + g_test_config.price_per_step = 21; + g_test_config.step_size_ms = 60000; + strncpy(g_test_config.nostr_geohash, "u281w0dfz", sizeof(g_test_config.nostr_geohash) - 1); + + memset(&g_test_identity, 0, sizeof(g_test_identity)); + strncpy(g_test_identity.npub_hex, "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", 64); + g_test_identity.initialized = true; + + printf("\n--- tollgate_price_payload_t size ---\n"); + { + ASSERT_EQ_INT(26, (int)TOLLGATE_IE_PAYLOAD_SIZE, "payload is 26 bytes"); + ASSERT_EQ_INT(32, (int)TOLLGATE_IE_TOTAL_SIZE, "total IE is 32 bytes"); + } + + printf("\n--- beacon_price_hash_mint ---\n"); + { + uint8_t hash[4]; + beacon_price_hash_mint("https://testnut.cashu.space", hash); + + uint8_t expected[32]; + mbedtls_sha256((const unsigned char *)"https://testnut.cashu.space", + strlen("https://testnut.cashu.space"), expected, 0); + ASSERT_MEM_EQ(expected, hash, 4, "mint_hash matches SHA-256 prefix"); + + uint8_t hash2[4]; + beacon_price_hash_mint("https://other.mint.url", hash2); + ASSERT(memcmp(hash, hash2, 4) != 0, "different mint URLs produce different hashes"); + } + + printf("\n--- beacon_price_hash_npub ---\n"); + { + uint8_t hash[4]; + beacon_price_hash_npub("abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", hash); + + uint8_t expected[32]; + mbedtls_sha256((const unsigned char *)"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + 64, expected, 0); + ASSERT_MEM_EQ(expected, hash, 4, "npub_hash matches SHA-256 prefix"); + } + + printf("\n--- beacon_price_build_ie (time metric) ---\n"); + { + tollgate_price_ie_t ie; + beacon_price_build_ie(&ie); + + ASSERT_EQ_INT(0xDD, ie.element_id, "element_id is 0xDD"); + ASSERT_EQ_INT(4 + 26, ie.length, "length is 30 (4 header + 26 payload)"); + ASSERT_EQ_INT(0xC0, ie.vendor_oui[0], "OUI byte 0"); + ASSERT_EQ_INT(0xFF, ie.vendor_oui[1], "OUI byte 1"); + ASSERT_EQ_INT(0xEE, ie.vendor_oui[2], "OUI byte 2"); + ASSERT_EQ_INT(0x01, ie.vendor_oui_type, "OUI type is 0x01"); + + ASSERT_EQ_INT(1, ie.payload.version, "version is 1"); + ASSERT_EQ_INT(0, ie.payload.metric, "metric is 0 (milliseconds)"); + ASSERT_EQ_INT(21, ie.payload.price_per_step, "price is 21"); + ASSERT_EQ_INT(60000, (int)ie.payload.step_size, "step_size is 60000"); + + uint8_t expected_mint_hash[4]; + beacon_price_hash_mint("https://testnut.cashu.space", expected_mint_hash); + ASSERT_MEM_EQ(expected_mint_hash, ie.payload.mint_hash, 4, "mint_hash matches"); + + ASSERT_EQ_INT(9, ie.payload.geohash_len, "geohash_len is 9"); + ASSERT(memcmp(ie.payload.geohash, "u281w0dfz", 9) == 0, "geohash matches"); + } + + printf("\n--- beacon_price_build_ie (bytes metric) ---\n"); + { + strncpy(g_test_config.metric, "bytes", sizeof(g_test_config.metric) - 1); + g_test_config.step_size_bytes = 22020096; + g_test_config.price_per_step = 5; + + tollgate_price_ie_t ie; + beacon_price_build_ie(&ie); + + ASSERT_EQ_INT(1, ie.payload.metric, "metric is 1 (bytes)"); + ASSERT_EQ_INT(5, ie.payload.price_per_step, "price is 5"); + ASSERT_EQ_INT(22020096, (int)ie.payload.step_size, "step_size is 22020096 bytes"); + + strncpy(g_test_config.metric, "milliseconds", sizeof(g_test_config.metric) - 1); + g_test_config.step_size_ms = 60000; + g_test_config.price_per_step = 21; + } + + printf("\n--- roundtrip: build → parse ---\n"); + { + tollgate_price_ie_t ie; + beacon_price_build_ie(&ie); + + vendor_ie_data_t *vnd_ie = (vendor_ie_data_t *)&ie; + + ASSERT(vnd_ie->length >= 4 + (int)TOLLGATE_IE_PAYLOAD_SIZE, "vendor IE length sufficient"); + + const tollgate_price_payload_t *parsed = (const tollgate_price_payload_t *)vnd_ie->payload; + ASSERT_EQ_INT(1, parsed->version, "parsed version"); + ASSERT_EQ_INT(0, parsed->metric, "parsed metric"); + ASSERT_EQ_INT(21, parsed->price_per_step, "parsed price"); + ASSERT_EQ_INT(60000, (int)parsed->step_size, "parsed step_size"); + ASSERT_EQ_INT(9, parsed->geohash_len, "parsed geohash_len"); + } + + printf("\n--- struct packing check ---\n"); + { + tollgate_price_ie_t ie; + memset(&ie, 0, sizeof(ie)); + int expected_size = 2 + 3 + 1 + 26; + ASSERT_EQ_INT(expected_size, (int)sizeof(tollgate_price_ie_t), "no padding in struct"); + } + + TEST_SUMMARY(); +} 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 @@ +#include "test_framework.h" +#include "../../main/beacon_price.h" +#include "../../main/market.h" +#include "../../main/config.h" +#include "../../main/identity.h" +#include +#include +#include + +static tollgate_config_t g_test_config; +static tollgate_identity_t g_test_identity; + +const tollgate_config_t *tollgate_config_get(void) { return &g_test_config; } +const tollgate_identity_t *identity_get(void) { return &g_test_identity; } + +static void build_test_ie(tollgate_price_ie_t *ie, uint16_t price, uint32_t step, uint8_t metric, + const char *geohash, const char *mint_url, const char *npub_hex) +{ + memset(ie, 0, sizeof(*ie)); + ie->element_id = 0xDD; + ie->length = 4 + TOLLGATE_IE_PAYLOAD_SIZE; + ie->vendor_oui[0] = TOLLGATE_OUI_0; + ie->vendor_oui[1] = TOLLGATE_OUI_1; + ie->vendor_oui[2] = TOLLGATE_OUI_2; + ie->vendor_oui_type = TOLLGATE_IE_TYPE; + + ie->payload.version = TOLLGATE_IE_VERSION; + ie->payload.metric = metric; + ie->payload.price_per_step = price; + ie->payload.step_size = step; + + if (mint_url) beacon_price_hash_mint(mint_url, ie->payload.mint_hash); + if (npub_hex) beacon_price_hash_npub(npub_hex, ie->payload.npub_hash); + + uint8_t gh_len = (uint8_t)strnlen(geohash, TOLLGATE_IE_GEOHASH_MAX); + ie->payload.geohash_len = gh_len; + memcpy(ie->payload.geohash, geohash, gh_len); +} + +static void reset_market(void) +{ + market_t *m = (market_t *)market_get(); + memset(m, 0, sizeof(*m)); +} + +int main(void) +{ + printf("=== test_market ===\n"); + + memset(&g_test_config, 0, sizeof(g_test_config)); + g_test_config.market_enabled = true; + g_test_config.market_scan_interval_s = 30; + strncpy(g_test_config.metric, "milliseconds", sizeof(g_test_config.metric) - 1); + + memset(&g_test_identity, 0, sizeof(g_test_identity)); + strncpy(g_test_identity.npub_hex, "0000000000000000000000000000000000000000000000000000000000000001", 64); + g_test_identity.initialized = true; + + printf("\n--- parse vendor IE (valid) ---\n"); + { + reset_market(); + tollgate_price_ie_t ie; + build_test_ie(&ie, 21, 60000, 0, "u281w0dfz", + "https://testnut.cashu.space", + "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"); + + uint8_t bssid[6] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0x01}; + market_parse_vendor_ie(bssid, (vendor_ie_data_t *)&ie, -45); + + const market_t *m = market_get(); + ASSERT_EQ_INT(1, m->count, "one entry added"); + ASSERT(m->entries[0].valid, "entry is valid"); + ASSERT_EQ_INT(21, m->entries[0].price_per_step, "price is 21"); + ASSERT_EQ_INT(60000, (int)m->entries[0].step_size, "step_size is 60000"); + ASSERT_EQ_INT(0, m->entries[0].metric, "metric is 0 (time)"); + ASSERT_EQ_INT(-45, m->entries[0].rssi, "rssi is -45"); + ASSERT(memcmp(m->entries[0].bssid, bssid, 6) == 0, "bssid matches"); + } + + printf("\n--- parse vendor IE (ignore self) ---\n"); + { + reset_market(); + tollgate_price_ie_t ie; + build_test_ie(&ie, 21, 60000, 0, "u281w0dfz", + "https://testnut.cashu.space", + g_test_identity.npub_hex); + + uint8_t bssid[6] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0x02}; + market_parse_vendor_ie(bssid, (vendor_ie_data_t *)&ie, -50); + + const market_t *m = market_get(); + ASSERT_EQ_INT(0, m->count, "self-entry ignored"); + } + + printf("\n--- parse vendor IE (wrong OUI) ---\n"); + { + reset_market(); + tollgate_price_ie_t ie; + build_test_ie(&ie, 21, 60000, 0, "u281w0dfz", + "https://testnut.cashu.space", + "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"); + ie.vendor_oui[0] = 0x00; + + uint8_t bssid[6] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0x03}; + market_parse_vendor_ie(bssid, (vendor_ie_data_t *)&ie, -40); + + const market_t *m = market_get(); + ASSERT_EQ_INT(0, m->count, "wrong OUI rejected"); + } + + printf("\n--- market_find_cheapest ---\n"); + { + reset_market(); + + tollgate_price_ie_t ie1, ie2, ie3; + build_test_ie(&ie1, 21, 60000, 0, "u281w0dfz", + "https://testnut.cashu.space", "aaa...npub1"); + build_test_ie(&ie2, 10, 60000, 0, "u281w0dfz", + "https://testnut.cashu.space", "bbb...npub2"); + build_test_ie(&ie3, 50, 60000, 0, "u281w0dfz", + "https://testnut.cashu.space", "ccc...npub3"); + + uint8_t bssid1[6] = {0x01, 0x01, 0x01, 0x01, 0x01, 0x01}; + uint8_t bssid2[6] = {0x02, 0x02, 0x02, 0x02, 0x02, 0x02}; + uint8_t bssid3[6] = {0x03, 0x03, 0x03, 0x03, 0x03, 0x03}; + + market_parse_vendor_ie(bssid1, (vendor_ie_data_t *)&ie1, -45); + market_parse_vendor_ie(bssid2, (vendor_ie_data_t *)&ie2, -50); + market_parse_vendor_ie(bssid3, (vendor_ie_data_t *)&ie3, -55); + + const market_t *m = market_get(); + ASSERT_EQ_INT(3, m->count, "three entries"); + + strncpy((char *)m->entries[0].ssid, "TollGate-A", 32); + strncpy((char *)m->entries[1].ssid, "TollGate-B", 32); + strncpy((char *)m->entries[2].ssid, "TollGate-C", 32); + + int cheapest = market_find_cheapest(); + ASSERT(cheapest >= 0, "found a cheapest entry"); + ASSERT_EQ_INT(10, m->entries[cheapest].price_per_step, "cheapest is 10 sats"); + } + + printf("\n--- update existing entry ---\n"); + { + reset_market(); + tollgate_price_ie_t ie; + build_test_ie(&ie, 21, 60000, 0, "u281w0dfz", + "https://testnut.cashu.space", "npub1"); + uint8_t bssid[6] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06}; + + market_parse_vendor_ie(bssid, (vendor_ie_data_t *)&ie, -45); + ASSERT_EQ_INT(1, market_get()->count, "first add"); + + build_test_ie(&ie, 15, 60000, 0, "u281w0dfz", + "https://testnut.cashu.space", "npub1"); + market_parse_vendor_ie(bssid, (vendor_ie_data_t *)&ie, -47); + ASSERT_EQ_INT(1, market_get()->count, "update doesn't increase count"); + ASSERT_EQ_INT(15, market_get()->entries[0].price_per_step, "price updated to 15"); + } + + printf("\n--- geohash preserved ---\n"); + { + reset_market(); + tollgate_price_ie_t ie; + build_test_ie(&ie, 21, 60000, 0, "u281w0dfz", + "https://testnut.cashu.space", "npub1"); + uint8_t bssid[6] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; + + market_parse_vendor_ie(bssid, (vendor_ie_data_t *)&ie, -40); + + const market_t *m = market_get(); + ASSERT(m->entries[0].valid, "entry valid"); + ASSERT_EQ_STR("u281w0dfz", m->entries[0].geohash, "geohash is u281w0dfz"); + } + + TEST_SUMMARY(); +} 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) { uint64_t nucula_wallet_balance(void) { return 100; } esp_err_t nucula_wallet_send(uint64_t a, char *b, size_t c) { (void)a; (void)b; (void)c; return ESP_OK; } +const void *identity_get(void) { return NULL; } #include "freertos/FreeRTOS.h" -- cgit v1.2.3