From eeba74a4a1c011e85e33dea4252b381e35a64ea4 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 19 May 2026 13:21:25 +0530 Subject: feat: multi-mint wallet with health tracking, WPA auto-detect, display gating Squash merge of feature/multi-mint-support (21 commits): Multi-mint wallet: - Accept payments from 4 mints: minibits, coinos, 21mint, lnvoltz - Periodic health probing (300s interval, 3 recovery threshold) - Multi-wallet init with nucula_wallet_init_multi() - /mints and /wallet API endpoints WPA auto-detect: - wifi_auth_mode config field (default WPA2, supports WPA3) - Runtime mapping to wifi_auth_mode_t in STA config Display gating: - display_enabled config field (default true) - Guards display_init/display_update per-board Bug fixes: - 3s delay before service start prevents lwip mem_free assertion - Real npub in discovery (identity_get()->npub_hex) - Health probe interval 300s (production value) - Duplicate services_start_task call removed - UTF-8 arrow replaced with ASCII in log message Tests: 61+14 unit tests passing, firmware builds clean --- tests/unit/Makefile | 13 +-- tests/unit/stubs/freertos/FreeRTOS.h | 2 +- tests/unit/stubs/freertos/semphr.h | 7 ++ tests/unit/stubs/mint_health.h | 44 ++++++++ tests/unit/test_cashu.c | 18 +++- tests/unit/test_geohash | Bin 20776 -> 20784 bytes tests/unit/test_identity | Bin 297880 -> 297888 bytes tests/unit/test_mint_health.c | 194 +++++++++++++++++++++++++++++++++++ 8 files changed, 268 insertions(+), 10 deletions(-) create mode 100644 tests/unit/stubs/freertos/semphr.h create mode 100644 tests/unit/stubs/mint_health.h create mode 100644 tests/unit/test_mint_health.c (limited to 'tests/unit') diff --git a/tests/unit/Makefile b/tests/unit/Makefile index 7bd3f1e..a06807f 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 test_beacon_price test_market +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 test_mint_health .PHONY: all test clean $(TESTS) @@ -58,10 +58,10 @@ test_nostr_event: test_nostr_event.c $(REPO_ROOT)/main/nostr_event.c $(REPO_ROOT $(CC) $(CFLAGS) -I $(SECP256K1_PRIV_INC) $< $(REPO_ROOT)/main/nostr_event.c $(REPO_ROOT)/main/identity.c $(SECP256K1_OBJ) -o $@ $(LDFLAGS) test_cashu: test_cashu.c $(REPO_ROOT)/main/cashu.c - $(CC) $(CFLAGS) $< $(REPO_ROOT)/main/cashu.c -o $@ $(LDFLAGS) + $(CC) $(CFLAGS) -include stubs/mint_health.h $< $(REPO_ROOT)/main/cashu.c -o $@ $(LDFLAGS) 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) + $(CC) $(CFLAGS) -include stubs/mint_health.h $< $(REPO_ROOT)/main/session.c $(REPO_ROOT)/main/cashu.c -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) @@ -81,11 +81,8 @@ test_nip04: test_nip04.c $(REPO_ROOT)/main/nip04.c $(SECP256K1_OBJ) test_cvm_server: test_cvm_server.c $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) -test_display: test_display.c - $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) - -test_negentropy_adapter: test_negentropy_adapter.c - $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) +test_mint_health: test_mint_health.c $(REPO_ROOT)/main/mint_health.c + $(CC) -Wall -Wextra -Wno-unused-parameter -Wno-unused-function -Wno-sign-compare -std=gnu17 -g -O0 -DTEST_HOST -include stubs/esp_err.h -I $(REPO_ROOT)/main -I stubs -I $(SECP256K1_INC) -I $(SECP256K1_CFG) -I /usr/include/cjson $< $(REPO_ROOT)/main/mint_health.c -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) diff --git a/tests/unit/stubs/freertos/FreeRTOS.h b/tests/unit/stubs/freertos/FreeRTOS.h index 41426c8..2d2b967 100644 --- a/tests/unit/stubs/freertos/FreeRTOS.h +++ b/tests/unit/stubs/freertos/FreeRTOS.h @@ -7,7 +7,7 @@ static inline uint32_t xTaskGetTickCount(void) { return 0; } static inline void vTaskDelay(uint32_t ticks) { (void)ticks; } #define pdMS_TO_TICKS(ms) ((ms) / 10) #define portTICK_PERIOD_MS 10 -#define configTICK_RATE_HZ 100 #define portMAX_DELAY 0xFFFFFFFF +#define pdTRUE 1 #endif diff --git a/tests/unit/stubs/freertos/semphr.h b/tests/unit/stubs/freertos/semphr.h new file mode 100644 index 0000000..0389b11 --- /dev/null +++ b/tests/unit/stubs/freertos/semphr.h @@ -0,0 +1,7 @@ +#ifndef STUBS_FREERTOS_SEMPHR_H +#define STUBS_FREERTOS_SEMPHR_H + +#include "FreeRTOS.h" +#include "task.h" + +#endif diff --git a/tests/unit/stubs/mint_health.h b/tests/unit/stubs/mint_health.h new file mode 100644 index 0000000..7248042 --- /dev/null +++ b/tests/unit/stubs/mint_health.h @@ -0,0 +1,44 @@ +#ifndef MINT_HEALTH_H +#define MINT_HEALTH_H + +#include +#include + +#define MINT_HEALTH_MAX 8 +#define MINT_HEALTH_PROBE_INTERVAL_S 300 +#define MINT_HEALTH_PROBE_TIMEOUT_MS 15000 +#define MINT_HEALTH_RECOVERY_THRESHOLD 3 + +typedef struct { + char url[256]; + bool reachable; + uint8_t consecutive_successes; + int64_t last_probe_ms; + int last_http_status; +} mint_status_t; + +typedef void (*mint_health_changed_cb)(void); + +static inline bool mint_health_is_reachable(const char *url) { + (void)url; + return true; +} + +static inline void mint_health_mark_unreachable(const char *url) { + (void)url; +} + +static inline esp_err_t mint_health_init(const char urls[][256], int count) { + (void)urls; (void)count; return 0; +} + +static inline void mint_health_start(void) {} +static inline void mint_health_stop(void) {} +static inline const mint_status_t *mint_health_get_all(int *out_count) { + *out_count = 0; return NULL; +} +static inline void mint_health_register_callback(mint_health_changed_cb cb) { + (void)cb; +} + +#endif diff --git a/tests/unit/test_cashu.c b/tests/unit/test_cashu.c index cec8e08..021d1b7 100644 --- a/tests/unit/test_cashu.c +++ b/tests/unit/test_cashu.c @@ -20,6 +20,18 @@ int main(void) g_test_config.price_per_step = 21; g_test_config.step_size_ms = 60000; + const char *mints[] = { + "https://testnut.cashu.space", + "https://mint.minibits.cash/Bitcoin", + "https://mint.coinos.io", + "https://21mint.me", + }; + for (int i = 0; i < 4; i++) { + strncpy(g_test_config.accepted_mints[i], mints[i], + sizeof(g_test_config.accepted_mints[i]) - 1); + } + g_test_config.accepted_mint_count = 4; + printf("\n--- cashu_calculate_allotment_ms ---\n"); uint64_t a1 = cashu_calculate_allotment_ms(21, 21, 60000); ASSERT_EQ_INT(60000, (int)a1, "21 sats at 21 sats/min = 60000ms"); @@ -33,10 +45,14 @@ int main(void) uint64_t a4 = cashu_calculate_allotment_ms(100, 10, 30000); ASSERT_EQ_INT(300000, (int)a4, "100 sats at 10 sats/30s = 300000ms"); - printf("\n--- cashu_is_mint_accepted ---\n"); + printf("\n--- cashu_is_mint_accepted (multi-mint) ---\n"); ASSERT(cashu_is_mint_accepted("https://testnut.cashu.space"), "testnut.cashu.space accepted"); + ASSERT(cashu_is_mint_accepted("https://mint.minibits.cash/Bitcoin"), "minibits accepted"); + ASSERT(cashu_is_mint_accepted("https://mint.coinos.io"), "coinos accepted"); + ASSERT(cashu_is_mint_accepted("https://21mint.me"), "21mint accepted"); ASSERT(!cashu_is_mint_accepted("https://evil.mint.example.com"), "evil mint rejected"); ASSERT(!cashu_is_mint_accepted(""), "empty string rejected"); + ASSERT(!cashu_is_mint_accepted(NULL), "NULL rejected"); printf("\n--- cashu_decode_token with garbage ---\n"); cashu_token_t token; diff --git a/tests/unit/test_geohash b/tests/unit/test_geohash index dc5045f..46d0e6f 100755 Binary files a/tests/unit/test_geohash and b/tests/unit/test_geohash differ diff --git a/tests/unit/test_identity b/tests/unit/test_identity index 277bb49..d0ff402 100755 Binary files a/tests/unit/test_identity and b/tests/unit/test_identity differ diff --git a/tests/unit/test_mint_health.c b/tests/unit/test_mint_health.c new file mode 100644 index 0000000..d170d55 --- /dev/null +++ b/tests/unit/test_mint_health.c @@ -0,0 +1,194 @@ +#include +#include +#include +#include "mint_health.h" + +static int test_count = 0; +static int pass_count = 0; + +#define TEST(name) do { \ + test_count++; \ + printf(" TEST: %s ... ", name); \ +} while(0) + +#define PASS() do { \ + pass_count++; \ + printf("PASS\n"); \ +} while(0) + +#define FAIL(msg) do { \ + printf("FAIL: %s\n", msg); \ +} while(0) + +#define ASSERT_EQ(a, b, msg) do { \ + if ((a) != (b)) { FAIL(msg); return; } \ +} while(0) + +#define ASSERT_TRUE(a, msg) do { \ + if (!(a)) { FAIL(msg); return; } \ +} while(0) + +#define ASSERT_FALSE(a, msg) do { \ + if ((a)) { FAIL(msg); return; } \ +} while(0) + +static void test_init_basic(void) { + TEST("init with 4 mints"); + const char urls[4][256] = { + "https://mint.minibits.cash/Bitcoin", + "https://mint.coinos.io", + "https://21mint.me", + "https://mint.lnvoltz.com" + }; + esp_err_t err = mint_health_init(urls, 4); + ASSERT_EQ(err, 0, "init should return ESP_OK"); + PASS(); +} + +static void test_get_all(void) { + TEST("get_all returns correct count"); + int count = 0; + const mint_status_t *mints = mint_health_get_all(&count); + ASSERT_EQ(count, 4, "should have 4 mints"); + ASSERT_TRUE(mints != NULL, "mints should not be NULL"); + PASS(); +} + +static void test_initial_state_unreachable(void) { + TEST("initial state: all mints unreachable (no probes run)"); + const char *expected_urls[] = { + "https://mint.minibits.cash/Bitcoin", + "https://mint.coinos.io", + "https://21mint.me", + "https://mint.lnvoltz.com" + }; + int count = 0; + const mint_status_t *mints = mint_health_get_all(&count); + ASSERT_EQ(count, 4, "should have 4 mints"); + for (int i = 0; i < count; i++) { + ASSERT_FALSE(mints[i].reachable, "initial mint should be unreachable"); + ASSERT_EQ(mints[i].consecutive_successes, 0, "initial successes should be 0"); + ASSERT_TRUE(strcmp(mints[i].url, expected_urls[i]) == 0, "URL mismatch"); + } + PASS(); +} + +static void test_is_reachable_before_probes(void) { + TEST("is_reachable returns false before probes"); + bool r = mint_health_is_reachable("https://mint.minibits.cash/Bitcoin"); + ASSERT_FALSE(r, "should be unreachable before probes"); + PASS(); +} + +static void test_is_reachable_null(void) { + TEST("is_reachable returns false for NULL"); + bool r = mint_health_is_reachable(NULL); + ASSERT_FALSE(r, "NULL should return false"); + PASS(); +} + +static void test_is_reachable_unknown_url(void) { + TEST("is_reachable returns false for unknown URL"); + bool r = mint_health_is_reachable("https://unknown.mint.example.com"); + ASSERT_FALSE(r, "unknown URL should return false"); + PASS(); +} + +static void test_mark_unreachable(void) { + TEST("mark_unreachable on already-unreachable mint"); + mint_health_mark_unreachable("https://mint.coinos.io"); + bool r = mint_health_is_reachable("https://mint.coinos.io"); + ASSERT_FALSE(r, "should still be unreachable"); + PASS(); +} + +static void test_mark_unreachable_null(void) { + TEST("mark_unreachable with NULL does not crash"); + mint_health_mark_unreachable(NULL); + PASS(); +} + +static void test_init_overflow(void) { + TEST("init with more than MAX mints truncates"); + const char urls[MINT_HEALTH_MAX + 2][256]; + for (int i = 0; i < MINT_HEALTH_MAX + 2; i++) { + snprintf((char *)urls[i], 256, "https://mint%d.example.com", i); + } + esp_err_t err = mint_health_init(urls, MINT_HEALTH_MAX + 2); + ASSERT_EQ(err, 0, "init should succeed"); + + int count = 0; + mint_health_get_all(&count); + ASSERT_EQ(count, MINT_HEALTH_MAX, "should be truncated to MAX"); + PASS(); +} + +static void test_init_empty(void) { + TEST("init with 0 mints"); + esp_err_t err = mint_health_init(NULL, 0); + ASSERT_EQ(err, 0, "init with 0 should succeed"); + + int count = -1; + mint_health_get_all(&count); + ASSERT_EQ(count, 0, "should have 0 mints"); + PASS(); +} + +static void dummy_cb(void) { } + +static void test_register_callback(void) { + TEST("register_callback does not crash"); + mint_health_register_callback(dummy_cb); + PASS(); +} + +static void test_register_callback_null(void) { + TEST("register_callback NULL does not crash"); + mint_health_register_callback(NULL); + PASS(); +} + +static void test_reinit_resets_state(void) { + TEST("re-init resets state"); + const char urls[2][256] = { + "https://mint-a.example.com", + "https://mint-b.example.com" + }; + mint_health_init(urls, 2); + + int count = 0; + const mint_status_t *mints = mint_health_get_all(&count); + ASSERT_EQ(count, 2, "should have 2 mints"); + ASSERT_TRUE(strcmp(mints[0].url, "https://mint-a.example.com") == 0, "first URL"); + ASSERT_TRUE(strcmp(mints[1].url, "https://mint-b.example.com") == 0, "second URL"); + PASS(); +} + +static void test_start_stop(void) { + TEST("start/stop do not crash (task stubbed)"); + mint_health_start(); + mint_health_stop(); + PASS(); +} + +int main(void) { + printf("\n=== Mint Health Unit Tests ===\n\n"); + + test_init_basic(); + test_get_all(); + test_initial_state_unreachable(); + test_is_reachable_before_probes(); + test_is_reachable_null(); + test_is_reachable_unknown_url(); + test_mark_unreachable(); + test_mark_unreachable_null(); + test_init_overflow(); + test_init_empty(); + test_register_callback(); + test_register_callback_null(); + test_reinit_resets_state(); + test_start_stop(); + + printf("\n=== Results: %d passed, %d failed ===\n\n", pass_count, test_count - pass_count); + return (pass_count == test_count) ? 0 : 1; +} -- cgit v1.2.3