From 78dd599277b8e8b2ddc39a4ae710ec91d737272e Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 17 May 2026 04:21:39 +0530 Subject: Phase 4: TollGate client detection + auto-payment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New tollgate_client.c/h: detect upstream TollGate (kind=10021), auto-pay via nucula wallet, session monitoring with 20% renewal - State machine: IDLE→DETECTING→NEEDS_PAY→PAYING→PAID→RENEWING - Blocking: upstream payment before local services start - Synchronous wallet init (was async task) - Client config: enabled, steps_to_buy, renewal_threshold_pct - Updated PLAN.md with Phases 4-7 (client, payout, bytes, CVM) - Updated CHECKLIST.md with all new phase items - 30 new unit tests (all passing), 116 total --- tests/unit/Makefile | 5 +- tests/unit/stubs/esp_err.h | 1 + tests/unit/stubs/nucula_wallet.h | 17 ++++ tests/unit/test_tollgate_client.c | 186 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 tests/unit/stubs/nucula_wallet.h create mode 100644 tests/unit/test_tollgate_client.c (limited to 'tests') diff --git a/tests/unit/Makefile b/tests/unit/Makefile index ab41175..e4ea388 100644 --- a/tests/unit/Makefile +++ b/tests/unit/Makefile @@ -21,7 +21,7 @@ LDFLAGS := -lmbedcrypto -lcjson SECP256K1_OBJ := secp256k1.o precomputed_ecmult.o precomputed_ecmult_gen.o -TESTS := test_geohash test_identity test_nostr_event test_cashu test_session +TESTS := test_geohash test_identity test_nostr_event test_cashu test_session test_tollgate_client .PHONY: all test clean $(TESTS) @@ -62,5 +62,8 @@ test_cashu: test_cashu.c $(REPO_ROOT)/main/cashu.c test_session: test_session.c $(REPO_ROOT)/main/session.c $(CC) $(CFLAGS) $< $(REPO_ROOT)/main/session.c -o $@ $(LDFLAGS) +test_tollgate_client: test_tollgate_client.c + $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) + clean: rm -f $(TESTS) $(SECP256K1_OBJ) diff --git a/tests/unit/stubs/esp_err.h b/tests/unit/stubs/esp_err.h index 9bedb72..2a8e216 100644 --- a/tests/unit/stubs/esp_err.h +++ b/tests/unit/stubs/esp_err.h @@ -12,6 +12,7 @@ typedef int esp_err_t; #define ESP_ERR_INVALID_ARG 0x102 #define ESP_ERR_NO_MEM 0x101 #define ESP_ERR_NOT_FOUND 0x104 +#define ESP_ERR_INVALID_STATE 0x103 static inline const char *esp_err_to_name(esp_err_t err) { (void)err; return "ESP_OK"; } diff --git a/tests/unit/stubs/nucula_wallet.h b/tests/unit/stubs/nucula_wallet.h new file mode 100644 index 0000000..260ec35 --- /dev/null +++ b/tests/unit/stubs/nucula_wallet.h @@ -0,0 +1,17 @@ +#ifndef STUBS_NUCULA_WALLET_H +#define STUBS_NUCULA_WALLET_H + +#include "esp_err.h" +#include +#include + +esp_err_t nucula_wallet_init(const char *mint_url); +esp_err_t nucula_wallet_receive(const char *token_str); +esp_err_t nucula_wallet_send(uint64_t amount_sat, char *token_out, size_t token_out_size); +uint64_t nucula_wallet_balance(void); +int nucula_wallet_proof_count(void); +char *nucula_wallet_proofs_json(void); +esp_err_t nucula_wallet_swap_all(void); +void nucula_wallet_print_status(void); + +#endif diff --git a/tests/unit/test_tollgate_client.c b/tests/unit/test_tollgate_client.c new file mode 100644 index 0000000..686ad19 --- /dev/null +++ b/tests/unit/test_tollgate_client.c @@ -0,0 +1,186 @@ +#include "test_framework.h" +#include "../../main/config.h" +#include +#include +#include +#include + +static tollgate_config_t g_test_config; + +const tollgate_config_t *tollgate_config_get(void) { + return &g_test_config; +} + +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; } + +#include "freertos/FreeRTOS.h" + +#include "../../main/tollgate_client.c" + +static void reset_state(void) { + s_state = TG_CLIENT_IDLE; + memset(&s_discovery, 0, sizeof(s_discovery)); + memset(s_gw_ip, 0, sizeof(s_gw_ip)); + s_allotment_ms = 0; + s_remaining_ms = -1; + s_last_pay_time_ms = 0; + s_retry_count = 0; +} + +int main(void) +{ + printf("=== test_tollgate_client ===\n"); + + memset(&g_test_config, 0, sizeof(g_test_config)); + g_test_config.client_enabled = true; + g_test_config.client_steps_to_buy = 1; + g_test_config.client_renewal_threshold_pct = 20; + g_test_config.client_retry_interval_ms = 30000; + + printf("\n--- parse_discovery_response (valid kind=10021) ---\n"); + { + const char *json = "{\"kind\":10021,\"pubkey\":\"abcdef\",\"tags\":[" + "[\"metric\",\"milliseconds\"]," + "[\"step_size\",\"60000\"]," + "[\"price_per_step\",\"cashu\",\"21\",\"sat\",\"https://testnut.cashu.space\",\"1\"]," + "[\"tips\",\"1\",\"2\",\"5\"]" + "],\"content\":\"\"}"; + + tollgate_discovery_t disc; + bool ok = parse_discovery_response(json, &disc); + ASSERT(ok, "valid discovery parsed"); + ASSERT(disc.is_tollgate, "is_tollgate=true"); + ASSERT_EQ_INT(21, disc.price_per_step, "price_per_step=21"); + ASSERT_EQ_INT(60000, disc.step_size_ms, "step_size_ms=60000"); + ASSERT_EQ_STR("milliseconds", disc.metric, "metric=milliseconds"); + ASSERT_EQ_STR("https://testnut.cashu.space", disc.mint_url, "mint_url"); + } + + printf("\n--- parse_discovery_response (wrong kind) ---\n"); + { + const char *json = "{\"kind\":1,\"tags\":[]}"; + tollgate_discovery_t disc; + bool ok = parse_discovery_response(json, &disc); + ASSERT(!ok, "wrong kind rejected"); + } + + printf("\n--- parse_discovery_response (no tags) ---\n"); + { + const char *json = "{\"kind\":10021,\"content\":\"\"}"; + tollgate_discovery_t disc = {0}; + bool ok = parse_discovery_response(json, &disc); + ASSERT(ok, "no tags still parses"); + ASSERT(disc.is_tollgate, "is_tollgate=true even without tags"); + ASSERT_EQ_INT(0, disc.price_per_step, "price=0 when no tags"); + } + + printf("\n--- parse_discovery_response (garbage) ---\n"); + { + tollgate_discovery_t disc; + bool ok = parse_discovery_response("not json", &disc); + ASSERT(!ok, "garbage rejected"); + } + + printf("\n--- parse_session_response (valid kind=1022) ---\n"); + { + const char *json = "{\"kind\":1022,\"pubkey\":\"abcdef\",\"tags\":[" + "[\"p\",\"unknown\"]," + "[\"device-identifier\",\"mac\",\"10.0.0.2\"]," + "[\"allotment\",\"60000\"]," + "[\"metric\",\"milliseconds\"]" + "],\"content\":\"\"}"; + + int64_t allotment = 0; + bool ok = parse_session_response(json, &allotment); + ASSERT(ok, "valid session parsed"); + ASSERT_EQ_INT(60000, (int)allotment, "allotment=60000"); + } + + printf("\n--- parse_session_response (error kind=21023) ---\n"); + { + const char *json = "{\"kind\":21023,\"tags\":[[\"level\",\"error\"],[\"code\",\"payment-error-token-spent\"]],\"content\":\"Token spent\"}"; + int64_t allotment = 999; + bool ok = parse_session_response(json, &allotment); + ASSERT(!ok, "error kind rejected"); + ASSERT_EQ_INT(999, (int)allotment, "allotment unchanged on error"); + } + + printf("\n--- parse_usage_response ---\n"); + { + int64_t remaining = 0, total = 0; + bool ok = parse_usage_response("30000/60000", &remaining, &total); + ASSERT(ok, "valid usage parsed"); + ASSERT_EQ_INT(30000, (int)remaining, "remaining=30000"); + ASSERT_EQ_INT(60000, (int)total, "total=60000"); + } + + printf("\n--- parse_usage_response (-1/-1) ---\n"); + { + int64_t remaining = 0, total = 0; + bool ok = parse_usage_response("-1/-1", &remaining, &total); + ASSERT(ok, "no-session usage parsed"); + ASSERT_EQ_INT(-1, (int)remaining, "remaining=-1"); + ASSERT_EQ_INT(-1, (int)total, "total=-1"); + } + + printf("\n--- parse_usage_response (garbage) ---\n"); + { + int64_t remaining = 0, total = 0; + bool ok = parse_usage_response("garbage", &remaining, &total); + ASSERT(!ok, "no slash rejected"); + } + + printf("\n--- renewal threshold calculation ---\n"); + { + reset_state(); + s_state = TG_CLIENT_PAID; + s_allotment_ms = 60000; + s_remaining_ms = 10000; + strncpy(s_gw_ip, "10.0.0.1", sizeof(s_gw_ip) - 1); + + int remaining_pct = (int)((s_remaining_ms * 100) / s_allotment_ms); + ASSERT(remaining_pct <= 20, "10/60 = 16% <= 20% triggers renewal"); + } + + printf("\n--- renewal threshold no-renew (above 20%%) ---\n"); + { + reset_state(); + s_allotment_ms = 60000; + s_remaining_ms = 50000; + int remaining_pct = (int)((s_remaining_ms * 100) / s_allotment_ms); + ASSERT(remaining_pct > 20, "50/60 = 83% > 20% no renewal"); + } + + printf("\n--- state machine: init ---\n"); + { + reset_state(); + tollgate_client_init(); + ASSERT_EQ_INT(TG_CLIENT_IDLE, (int)tollgate_client_get_state(), "init sets IDLE"); + } + + printf("\n--- config: client_enabled=false ---\n"); + { + reset_state(); + g_test_config.client_enabled = false; + esp_err_t ret = tollgate_client_on_sta_connected("10.0.0.1"); + ASSERT_EQ_INT(ESP_OK, (int)ret, "returns OK when disabled"); + ASSERT_EQ_INT(TG_CLIENT_IDLE, (int)tollgate_client_get_state(), "stays IDLE when disabled"); + g_test_config.client_enabled = true; + } + + printf("\n--- state machine: disconnect resets ---\n"); + { + reset_state(); + s_state = TG_CLIENT_PAID; + strncpy(s_gw_ip, "10.0.0.1", sizeof(s_gw_ip) - 1); + s_allotment_ms = 60000; + s_remaining_ms = 30000; + tollgate_client_on_sta_disconnected(); + ASSERT_EQ_INT(TG_CLIENT_IDLE, (int)tollgate_client_get_state(), "disconnect resets to IDLE"); + ASSERT_EQ_INT(-1, (int)tollgate_client_get_remaining_ms(), "remaining reset to -1"); + ASSERT_EQ_INT(0, (int)tollgate_client_get_allotment_ms(), "allotment reset to 0"); + } + + TEST_SUMMARY(); +} -- cgit v1.2.3