From cb4bd7d7c10cadcb43f82c09b13ffed744e541f7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 17 May 2026 04:37:15 +0530 Subject: Phase 5: Lightning auto-payout with LNURL-pay and NUT-05 melt - New lnurl_pay.c/h: LNURL-pay protocol (GET .well-known/lnurlp + callback) - New lightning_payout.c/h: threshold-based auto-payout with multi-recipient split - Extended nucula_wallet bridge with nucula_wallet_melt() (NUT-05) - Config: payout section with multi-mint, multi-recipient, fee_tolerance - Default: enabled, TollGate@coinos.io, min_payout=128, min_balance=64 - 18 new unit tests (all passing), 134 total --- CHECKLIST.md | 34 +++---- components/nucula_lib/nucula_wallet.cpp | 35 +++++++ components/nucula_lib/nucula_wallet.h | 2 + main/CMakeLists.txt | 2 + main/config.c | 74 +++++++++++++++ main/config.h | 4 + main/lightning_payout.c | 93 +++++++++++++++++++ main/lightning_payout.h | 37 ++++++++ main/lnurl_pay.c | 156 ++++++++++++++++++++++++++++++++ main/lnurl_pay.h | 14 +++ main/tollgate_main.c | 4 + tests/unit/Makefile | 10 +- tests/unit/stubs/nucula_wallet.h | 1 + tests/unit/test_geohash | Bin 20744 -> 20776 bytes tests/unit/test_identity | Bin 296504 -> 296728 bytes tests/unit/test_lightning_payout | Bin 0 -> 20552 bytes tests/unit/test_lightning_payout.c | 97 ++++++++++++++++++++ tests/unit/test_lnurl_pay | Bin 0 -> 21304 bytes tests/unit/test_lnurl_pay.c | 125 +++++++++++++++++++++++++ tests/unit/test_tollgate_client | Bin 0 -> 51904 bytes 20 files changed, 669 insertions(+), 19 deletions(-) create mode 100644 main/lightning_payout.c create mode 100644 main/lightning_payout.h create mode 100644 main/lnurl_pay.c create mode 100644 main/lnurl_pay.h create mode 100755 tests/unit/test_lightning_payout create mode 100644 tests/unit/test_lightning_payout.c create mode 100755 tests/unit/test_lnurl_pay create mode 100644 tests/unit/test_lnurl_pay.c create mode 100755 tests/unit/test_tollgate_client diff --git a/CHECKLIST.md b/CHECKLIST.md index dd21b0c..5cedd30 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -192,29 +192,29 @@ ## Phase 4: ESP32 TollGate Client Detection + Auto-Payment — IN PROGRESS ### tollgate_client.c/h (New) -- [ ] Create `tollgate_client.h` — types: `tollgate_discovery_t`, `tollgate_client_state_t` enum (IDLE/DETECTING/NEEDS_PAY/PAYING/PAID/RENEWING) -- [ ] Create `tollgate_client.c` — detection, payment, monitoring, state machine -- [ ] `tollgate_client_detect(gw_ip)` — HTTP GET `http://{gw}:2121/`, parse kind=10021, extract price tags -- [ ] `tollgate_client_pay(gw_ip, amount_sats)` — `nucula_wallet_send()` → POST to upstream → parse kind=1022/21023 -- [ ] `tollgate_client_on_sta_connected()` — extract gw from DHCP, detect, pay (blocking) -- [ ] `tollgate_client_tick()` — GET `/usage`, renew at 20% remaining -- [ ] `tollgate_client_on_sta_disconnected()` — reset state -- [ ] `tollgate_client_get_usage(gw_ip)` — GET `/usage` → parse remaining/total +- [x] Create `tollgate_client.h` — types: `tollgate_discovery_t`, `tollgate_client_state_t` enum (IDLE/DETECTING/NEEDS_PAY/PAYING/PAID/RENEWING) +- [x] Create `tollgate_client.c` — detection, payment, monitoring, state machine +- [x] `tollgate_client_detect(gw_ip)` — HTTP GET `http://{gw}:2121/`, parse kind=10021, extract price tags +- [x] `tollgate_client_pay(gw_ip, amount_sats)` — `nucula_wallet_send()` → POST to upstream → parse kind=1022/21023 +- [x] `tollgate_client_on_sta_connected()` — extract gw from DHCP, detect, pay (blocking) +- [x] `tollgate_client_tick()` — GET `/usage`, renew at 20% remaining +- [x] `tollgate_client_on_sta_disconnected()` — reset state +- [x] `tollgate_client_get_usage(gw_ip)` — GET `/usage` → parse remaining/total ### Config Changes -- [ ] Add to `config.h`: `client_enabled`, `client_steps_to_buy`, `client_renewal_threshold_pct`, `client_retry_interval_ms` -- [ ] Parse new fields in `config.c` +- [x] Add to `config.h`: `client_enabled`, `client_steps_to_buy`, `client_renewal_threshold_pct`, `client_retry_interval_ms` +- [x] Parse new fields in `config.c` ### Integration (tollgate_main.c) -- [ ] Make wallet init synchronous (call `nucula_wallet_init()` directly, not as task) -- [ ] Add `tollgate_client_on_sta_connected()` in `ip_event_handler` (blocking, before `start_services()`) -- [ ] Add `tollgate_client_on_sta_disconnected()` in `wifi_event_handler` -- [ ] Add `tollgate_client_tick()` in main loop -- [ ] Update `main/CMakeLists.txt` — add `tollgate_client.c` +- [x] Make wallet init synchronous (call `nucula_wallet_init()` directly, not as task) +- [x] Add `tollgate_client_on_sta_connected()` in `ip_event_handler` (blocking, before `start_services()`) +- [x] Add `tollgate_client_on_sta_disconnected()` in `wifi_event_handler` +- [x] Add `tollgate_client_tick()` in main loop +- [x] Update `main/CMakeLists.txt` — add `tollgate_client.c` ### Unit Tests -- [ ] `tests/unit/test_tollgate_client.c` — discovery parsing, price extraction, state machine, renewal threshold -- [ ] All unit tests passing +- [x] `tests/unit/test_tollgate_client.c` — discovery parsing, price extraction, state machine, renewal threshold +- [x] All unit tests passing (30 new, 116 total) — committed at `78dd599` ### Integration Tests - [ ] ESP32→OpenWRT auto-payment (Scenario 4) diff --git a/components/nucula_lib/nucula_wallet.cpp b/components/nucula_lib/nucula_wallet.cpp index 50583f9..9a24e89 100644 --- a/components/nucula_lib/nucula_wallet.cpp +++ b/components/nucula_lib/nucula_wallet.cpp @@ -197,3 +197,38 @@ void nucula_wallet_print_status(void) proofs[i].amount, proofs[i].id.c_str()); } } + +esp_err_t nucula_wallet_melt(const char *bolt11_invoice, uint64_t max_fee_sats) +{ + if (!s_wallet || !bolt11_invoice) return ESP_FAIL; + + cashu::MeltQuote quote; + if (!s_wallet->request_melt_quote(std::string(bolt11_invoice), quote)) { + ESP_LOGE(TAG, "Melt quote request failed"); + return ESP_FAIL; + } + + uint64_t total_cost = (uint64_t)quote.amount + (uint64_t)quote.fee_reserve; + if (total_cost > max_fee_sats) { + ESP_LOGE(TAG, "Melt cost %llu exceeds max %llu (amount=%d fee=%d)", + (unsigned long long)total_cost, (unsigned long long)max_fee_sats, + quote.amount, quote.fee_reserve); + return ESP_FAIL; + } + + int balance_before = s_wallet->balance(); + if (balance_before < quote.amount) { + ESP_LOGE(TAG, "Insufficient balance: %d < %d", balance_before, quote.amount); + return ESP_FAIL; + } + + int change_amount = 0; + if (!s_wallet->melt_tokens(quote, change_amount)) { + ESP_LOGE(TAG, "Melt tokens failed"); + return ESP_FAIL; + } + + ESP_LOGI(TAG, "Melted: %d sats paid, %d change, balance=%d->%d", + quote.amount, change_amount, balance_before, s_wallet->balance()); + return ESP_OK; +} diff --git a/components/nucula_lib/nucula_wallet.h b/components/nucula_lib/nucula_wallet.h index 64b7c24..784a126 100644 --- a/components/nucula_lib/nucula_wallet.h +++ b/components/nucula_lib/nucula_wallet.h @@ -22,6 +22,8 @@ char *nucula_wallet_proofs_json(void); esp_err_t nucula_wallet_swap_all(void); +esp_err_t nucula_wallet_melt(const char *bolt11_invoice, uint64_t max_fee_sats); + void nucula_wallet_print_status(void); #ifdef __cplusplus diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index be4d564..c2aaeb2 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -11,6 +11,8 @@ idf_component_register(SRCS "tollgate_main.c" "geohash.c" "wifistr.c" "tollgate_client.c" + "lnurl_pay.c" + "lightning_payout.c" INCLUDE_DIRS "." REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server lwip json esp_http_client mbedtls esp-tls log spiffs diff --git a/main/config.c b/main/config.c index c074410..9257397 100644 --- a/main/config.c +++ b/main/config.c @@ -26,6 +26,11 @@ esp_err_t tollgate_config_init(void) g_config.client_steps_to_buy = 1; g_config.client_renewal_threshold_pct = 20; g_config.client_retry_interval_ms = 30000; + g_config.payout.enabled = true; + g_config.payout.fee_tolerance_pct = 10; + g_config.payout.check_interval_s = 60; + g_config.payout.recipient_count = 0; + g_config.payout.mint_count = 0; esp_vfs_spiffs_conf_t conf = { .base_path = "/spiffs", @@ -166,6 +171,75 @@ esp_err_t tollgate_config_init(void) cJSON *client_retry = cJSON_GetObjectItem(root, "client_retry_interval_ms"); if (client_retry) g_config.client_retry_interval_ms = client_retry->valueint; + cJSON *payout = cJSON_GetObjectItem(root, "payout"); + if (payout && cJSON_IsObject(payout)) { + cJSON *p_en = cJSON_GetObjectItem(payout, "enabled"); + if (p_en && cJSON_IsBool(p_en)) g_config.payout.enabled = cJSON_IsTrue(p_en); + + cJSON *p_fee = cJSON_GetObjectItem(payout, "fee_tolerance_pct"); + if (p_fee) g_config.payout.fee_tolerance_pct = (uint64_t)p_fee->valuedouble; + + cJSON *p_interval = cJSON_GetObjectItem(payout, "check_interval_s"); + if (p_interval) g_config.payout.check_interval_s = p_interval->valueint; + + cJSON *recipients = cJSON_GetObjectItem(payout, "recipients"); + if (recipients && cJSON_IsArray(recipients)) { + int rcount = cJSON_GetArraySize(recipients); + if (rcount > PAYOUT_MAX_RECIPIENTS) rcount = PAYOUT_MAX_RECIPIENTS; + for (int i = 0; i < rcount; i++) { + cJSON *r = cJSON_GetArrayItem(recipients, i); + cJSON *addr = cJSON_GetObjectItem(r, "lightning_address"); + cJSON *factor = cJSON_GetObjectItem(r, "factor"); + if (addr && cJSON_IsString(addr)) { + strncpy(g_config.payout.recipients[i].lightning_address, addr->valuestring, + sizeof(g_config.payout.recipients[i].lightning_address) - 1); + } + if (factor && cJSON_IsNumber(factor)) { + g_config.payout.recipients[i].factor = factor->valuedouble; + } + } + g_config.payout.recipient_count = rcount; + } + + cJSON *mints = cJSON_GetObjectItem(payout, "mints"); + if (mints && cJSON_IsArray(mints)) { + int mcount = cJSON_GetArraySize(mints); + if (mcount > PAYOUT_MAX_MINTS) mcount = PAYOUT_MAX_MINTS; + for (int i = 0; i < mcount; i++) { + cJSON *m = cJSON_GetArrayItem(mints, i); + cJSON *murl = cJSON_GetObjectItem(m, "url"); + cJSON *mbal = cJSON_GetObjectItem(m, "min_balance"); + cJSON *mpay = cJSON_GetObjectItem(m, "min_payout_amount"); + if (murl && cJSON_IsString(murl)) { + strncpy(g_config.payout.mints[i].url, murl->valuestring, + sizeof(g_config.payout.mints[i].url) - 1); + } + if (mbal && cJSON_IsNumber(mbal)) { + g_config.payout.mints[i].min_balance = (uint64_t)mbal->valuedouble; + } + if (mpay && cJSON_IsNumber(mpay)) { + g_config.payout.mints[i].min_payout_amount = (uint64_t)mpay->valuedouble; + } + } + g_config.payout.mint_count = mcount; + } + } + + if (g_config.payout.mint_count == 0 && g_config.mint_url[0] != '\0') { + strncpy(g_config.payout.mints[0].url, g_config.mint_url, + sizeof(g_config.payout.mints[0].url) - 1); + g_config.payout.mints[0].min_balance = 64; + g_config.payout.mints[0].min_payout_amount = 128; + g_config.payout.mint_count = 1; + } + + if (g_config.payout.recipient_count == 0) { + strncpy(g_config.payout.recipients[0].lightning_address, "TollGate@coinos.io", + sizeof(g_config.payout.recipients[0].lightning_address) - 1); + g_config.payout.recipients[0].factor = 1.0; + g_config.payout.recipient_count = 1; + } + cJSON_Delete(root); if (g_config.nostr_relay_count == 0) { diff --git a/main/config.h b/main/config.h index 4c6116e..de9f856 100644 --- a/main/config.h +++ b/main/config.h @@ -6,6 +6,8 @@ #include "esp_netif.h" #include +#include "lightning_payout.h" + #define TOLLGATE_MAX_WIFI_NETWORKS 5 #define TOLLGATE_MAX_MINT_URLS 3 #define TOLLGATE_MAX_AP_SSID_LEN 32 @@ -54,6 +56,8 @@ typedef struct { int client_steps_to_buy; int client_renewal_threshold_pct; int client_retry_interval_ms; + + payout_config_t payout; } tollgate_config_t; void tollgate_config_derive_unique(tollgate_config_t *cfg); diff --git a/main/lightning_payout.c b/main/lightning_payout.c new file mode 100644 index 0000000..42593a9 --- /dev/null +++ b/main/lightning_payout.c @@ -0,0 +1,93 @@ +#include "lightning_payout.h" +#include "lnurl_pay.h" +#include "nucula_wallet.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include +#include + +static const char *TAG = "ln_payout"; + +static payout_config_t s_config; +static int64_t s_last_check_ms = 0; + +static int64_t get_time_ms(void) { + return (int64_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); +} + +esp_err_t lightning_payout_init(const payout_config_t *config) +{ + if (!config) return ESP_FAIL; + memcpy(&s_config, config, sizeof(s_config)); + s_last_check_ms = get_time_ms(); + ESP_LOGI(TAG, "Payout initialized: %d mints, %d recipients, interval=%ds", + s_config.mint_count, s_config.recipient_count, s_config.check_interval_s); + return ESP_OK; +} + +static esp_err_t payout_one(const char *lightning_address, uint64_t amount_sats, uint64_t fee_tolerance_pct) +{ + if (amount_sats == 0) return ESP_OK; + + char bolt11[LNURL_MAX_BOLT11_LEN]; + esp_err_t err = lnurl_get_invoice(lightning_address, amount_sats, bolt11, sizeof(bolt11)); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to get invoice for %s (%llu sats)", lightning_address, (unsigned long long)amount_sats); + return err; + } + + uint64_t max_cost = amount_sats + (amount_sats * fee_tolerance_pct / 100); + err = nucula_wallet_melt(bolt11, max_cost); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Melt failed for %s", lightning_address); + return err; + } + + ESP_LOGI(TAG, "Payout: %llu sats -> %s", (unsigned long long)amount_sats, lightning_address); + return ESP_OK; +} + +void lightning_payout_tick(void) +{ + if (!s_config.enabled) return; + if (s_config.recipient_count == 0) return; + + int64_t now = get_time_ms(); + int64_t elapsed = now - s_last_check_ms; + int64_t interval_ms = (int64_t)s_config.check_interval_s * 1000; + if (elapsed < interval_ms) return; + + s_last_check_ms = now; + + uint64_t balance = nucula_wallet_balance(); + + for (int m = 0; m < s_config.mint_count; m++) { + const payout_mint_config_t *mc = &s_config.mints[m]; + + if (balance < mc->min_payout_amount) { + ESP_LOGI(TAG, "Balance %llu < min_payout %llu for %s, skipping", + (unsigned long long)balance, (unsigned long long)mc->min_payout_amount, mc->url); + continue; + } + + uint64_t payout_pool = balance - mc->min_balance; + if (payout_pool == 0) continue; + + ESP_LOGI(TAG, "Payout pool: %llu sats (balance=%llu - reserve=%llu)", + (unsigned long long)payout_pool, (unsigned long long)balance, + (unsigned long long)mc->min_balance); + + for (int r = 0; r < s_config.recipient_count; r++) { + const payout_recipient_t *recip = &s_config.recipients[r]; + if (recip->factor <= 0.0 || recip->lightning_address[0] == '\0') continue; + + uint64_t share = (uint64_t)round((double)payout_pool * recip->factor); + if (share == 0) continue; + + payout_one(recip->lightning_address, share, s_config.fee_tolerance_pct); + } + + balance = nucula_wallet_balance(); + } +} diff --git a/main/lightning_payout.h b/main/lightning_payout.h new file mode 100644 index 0000000..d353902 --- /dev/null +++ b/main/lightning_payout.h @@ -0,0 +1,37 @@ +#ifndef LIGHTNING_PAYOUT_H +#define LIGHTNING_PAYOUT_H + +#include "esp_err.h" +#include +#include + +#define PAYOUT_MAX_RECIPIENTS 4 +#define PAYOUT_MAX_MINTS 3 +#define PAYOUT_MAX_ADDR_LEN 128 + +typedef struct { + char lightning_address[PAYOUT_MAX_ADDR_LEN]; + double factor; +} payout_recipient_t; + +typedef struct { + char url[256]; + uint64_t min_balance; + uint64_t min_payout_amount; +} payout_mint_config_t; + +typedef struct { + bool enabled; + payout_mint_config_t mints[PAYOUT_MAX_MINTS]; + int mint_count; + payout_recipient_t recipients[PAYOUT_MAX_RECIPIENTS]; + int recipient_count; + uint64_t fee_tolerance_pct; + int check_interval_s; +} payout_config_t; + +esp_err_t lightning_payout_init(const payout_config_t *config); + +void lightning_payout_tick(void); + +#endif diff --git a/main/lnurl_pay.c b/main/lnurl_pay.c new file mode 100644 index 0000000..bf3a932 --- /dev/null +++ b/main/lnurl_pay.c @@ -0,0 +1,156 @@ +#include "lnurl_pay.h" +#include "esp_log.h" +#include "esp_http_client.h" +#include "esp_crt_bundle.h" +#include "cJSON.h" +#include +#include +#include + +static const char *TAG = "lnurl_pay"; + +static esp_err_t http_get_json(const char *url, char *resp_buf, size_t resp_buf_size, int *status_out) +{ + esp_http_client_config_t config = { + .url = url, + .method = HTTP_METHOD_GET, + .timeout_ms = 15000, + .crt_bundle_attach = esp_crt_bundle_attach, + }; + esp_http_client_handle_t client = esp_http_client_init(&config); + if (!client) return ESP_FAIL; + + esp_err_t err = esp_http_client_open(client, 0); + if (err != ESP_OK) { + esp_http_client_cleanup(client); + return ESP_FAIL; + } + + int content_length = esp_http_client_fetch_headers(client); + (void)content_length; + int status = esp_http_client_get_status_code(client); + if (status_out) *status_out = status; + + int resp_len = esp_http_client_read(client, resp_buf, resp_buf_size - 1); + esp_http_client_cleanup(client); + + if (resp_len < 0) return ESP_FAIL; + resp_buf[resp_len] = '\0'; + return ESP_OK; +} + +esp_err_t lnurl_get_invoice(const char *lightning_address, uint64_t amount_sats, + char *bolt11_out, size_t bolt11_out_size) +{ + if (!lightning_address || !bolt11_out) return ESP_FAIL; + + const char *at = strchr(lightning_address, '@'); + if (!at) { + ESP_LOGE(TAG, "Invalid lightning address: missing '@'"); + return ESP_FAIL; + } + + size_t user_len = at - lightning_address; + char username[64]; + if (user_len >= sizeof(username)) return ESP_FAIL; + memcpy(username, lightning_address, user_len); + username[user_len] = '\0'; + + const char *domain = at + 1; + + char url[512]; + snprintf(url, sizeof(url), "https://%s/.well-known/lnurlp/%s", domain, username); + + ESP_LOGI(TAG, "LNURL-pay step 1: GET %s", url); + + char *resp_buf = malloc(4096); + if (!resp_buf) return ESP_ERR_NO_MEM; + + int status = 0; + esp_err_t err = http_get_json(url, resp_buf, 4096, &status); + if (err != ESP_OK || status != 200) { + ESP_LOGE(TAG, "LNURL-pay step 1 failed: status=%d err=%s", status, esp_err_to_name(err)); + free(resp_buf); + return ESP_FAIL; + } + + cJSON *root = cJSON_Parse(resp_buf); + if (!root) { + ESP_LOGE(TAG, "LNURL-pay step 1: invalid JSON"); + free(resp_buf); + return ESP_FAIL; + } + + cJSON *callback = cJSON_GetObjectItemCaseSensitive(root, "callback"); + if (!callback || !cJSON_IsString(callback)) { + ESP_LOGE(TAG, "LNURL-pay step 1: missing callback"); + cJSON_Delete(root); + free(resp_buf); + return ESP_FAIL; + } + + char callback_url[512]; + strncpy(callback_url, callback->valuestring, sizeof(callback_url) - 1); + + cJSON *min_sendable = cJSON_GetObjectItemCaseSensitive(root, "minSendable"); + cJSON *max_sendable = cJSON_GetObjectItemCaseSensitive(root, "maxSendable"); + + uint64_t amount_msat = amount_sats * 1000; + if (min_sendable && cJSON_IsNumber(min_sendable) && amount_msat < (uint64_t)min_sendable->valuedouble) { + ESP_LOGE(TAG, "Amount %llumsat below min %g", (unsigned long long)amount_msat, min_sendable->valuedouble); + cJSON_Delete(root); + free(resp_buf); + return ESP_FAIL; + } + if (max_sendable && cJSON_IsNumber(max_sendable) && amount_msat > (uint64_t)max_sendable->valuedouble) { + ESP_LOGE(TAG, "Amount %llumsat above max %g", (unsigned long long)amount_msat, max_sendable->valuedouble); + cJSON_Delete(root); + free(resp_buf); + return ESP_FAIL; + } + + cJSON_Delete(root); + + char callback_with_amount[768]; + snprintf(callback_with_amount, sizeof(callback_with_amount), "%s%samount=%llu", + callback_url, strchr(callback_url, '?') ? "&" : "?", + (unsigned long long)amount_msat); + + free(resp_buf); + + ESP_LOGI(TAG, "LNURL-pay step 2: GET %s", callback_with_amount); + + resp_buf = malloc(4096); + if (!resp_buf) return ESP_ERR_NO_MEM; + + err = http_get_json(callback_with_amount, resp_buf, 4096, &status); + if (err != ESP_OK || status != 200) { + ESP_LOGE(TAG, "LNURL-pay step 2 failed: status=%d", status); + free(resp_buf); + return ESP_FAIL; + } + + root = cJSON_Parse(resp_buf); + free(resp_buf); + if (!root) return ESP_FAIL; + + cJSON *pr = cJSON_GetObjectItemCaseSensitive(root, "pr"); + if (!pr || !cJSON_IsString(pr)) { + ESP_LOGE(TAG, "LNURL-pay step 2: missing 'pr' (bolt11)"); + cJSON_Delete(root); + return ESP_FAIL; + } + + size_t pr_len = strlen(pr->valuestring); + if (pr_len >= bolt11_out_size) { + ESP_LOGE(TAG, "BOLT11 too long: %zu >= %zu", pr_len, bolt11_out_size); + cJSON_Delete(root); + return ESP_FAIL; + } + + memcpy(bolt11_out, pr->valuestring, pr_len + 1); + cJSON_Delete(root); + + ESP_LOGI(TAG, "Got BOLT11 invoice (%zu bytes) for %llu sats", pr_len, (unsigned long long)amount_sats); + return ESP_OK; +} diff --git a/main/lnurl_pay.h b/main/lnurl_pay.h new file mode 100644 index 0000000..8969e38 --- /dev/null +++ b/main/lnurl_pay.h @@ -0,0 +1,14 @@ +#ifndef LNURL_PAY_H +#define LNURL_PAY_H + +#include "esp_err.h" +#include +#include + +#define LNURL_MAX_BOLT11_LEN 2048 +#define LNURL_MAX_ADDR_LEN 128 + +esp_err_t lnurl_get_invoice(const char *lightning_address, uint64_t amount_sats, + char *bolt11_out, size_t bolt11_out_size); + +#endif diff --git a/main/tollgate_main.c b/main/tollgate_main.c index d4dcf0d..3f83923 100644 --- a/main/tollgate_main.c +++ b/main/tollgate_main.c @@ -20,6 +20,7 @@ #include "nucula_wallet.h" #include "wifistr.h" #include "tollgate_client.h" +#include "lightning_payout.h" #define MAX_STA_RETRY 5 static const char *TAG = "tollgate_main"; @@ -89,6 +90,8 @@ static void ip_event_handler(void *arg, esp_event_base_t event_base, const tollgate_config_t *cfg = tollgate_config_get(); nucula_wallet_init(cfg->mint_url); + lightning_payout_init(&cfg->payout); + char gw_ip_str[16]; snprintf(gw_ip_str, sizeof(gw_ip_str), IPSTR, IP2STR(&event->ip_info.gw)); tollgate_client_on_sta_connected(gw_ip_str); @@ -282,5 +285,6 @@ void app_main(void) vTaskDelay(pdMS_TO_TICKS(1000)); session_tick(); tollgate_client_tick(); + lightning_payout_tick(); } } diff --git a/tests/unit/Makefile b/tests/unit/Makefile index e4ea388..f31172b 100644 --- a/tests/unit/Makefile +++ b/tests/unit/Makefile @@ -17,11 +17,11 @@ CFLAGS := -Wall -Wextra -Wno-unused-parameter -Wno-unused-function -Wno-sign-com -I $(SECP256K1_CFG) \ -I /usr/include/cjson -LDFLAGS := -lmbedcrypto -lcjson +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 +TESTS := test_geohash test_identity test_nostr_event test_cashu test_session test_tollgate_client test_lnurl_pay test_lightning_payout .PHONY: all test clean $(TESTS) @@ -65,5 +65,11 @@ test_session: test_session.c $(REPO_ROOT)/main/session.c test_tollgate_client: test_tollgate_client.c $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) +test_lnurl_pay: test_lnurl_pay.c + $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) + +test_lightning_payout: test_lightning_payout.c + $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) + clean: rm -f $(TESTS) $(SECP256K1_OBJ) diff --git a/tests/unit/stubs/nucula_wallet.h b/tests/unit/stubs/nucula_wallet.h index 260ec35..399b3b5 100644 --- a/tests/unit/stubs/nucula_wallet.h +++ b/tests/unit/stubs/nucula_wallet.h @@ -12,6 +12,7 @@ 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); +esp_err_t nucula_wallet_melt(const char *bolt11_invoice, uint64_t max_fee_sats); void nucula_wallet_print_status(void); #endif diff --git a/tests/unit/test_geohash b/tests/unit/test_geohash index db87d33..dc5045f 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 c89de17..7ad1485 100755 Binary files a/tests/unit/test_identity and b/tests/unit/test_identity differ diff --git a/tests/unit/test_lightning_payout b/tests/unit/test_lightning_payout new file mode 100755 index 0000000..b10888c Binary files /dev/null and b/tests/unit/test_lightning_payout differ diff --git a/tests/unit/test_lightning_payout.c b/tests/unit/test_lightning_payout.c new file mode 100644 index 0000000..8501eb9 --- /dev/null +++ b/tests/unit/test_lightning_payout.c @@ -0,0 +1,97 @@ +#include "test_framework.h" +#include "../../main/lightning_payout.h" +#include "../../main/config.h" +#include +#include +#include + +static void test_payout_calculation(void) +{ + printf("\n--- Payout pool calculation ---\n"); + { + uint64_t balance = 500; + uint64_t min_balance = 64; + uint64_t min_payout_amount = 128; + + ASSERT(balance >= min_payout_amount, "500 >= 128 triggers payout"); + + uint64_t pool = balance - min_balance; + ASSERT_EQ_INT(436, (int)pool, "pool = 500 - 64 = 436"); + } + + printf("\n--- Payout below threshold ---\n"); + { + uint64_t balance = 100; + uint64_t min_payout_amount = 128; + + ASSERT(balance < min_payout_amount, "100 < 128, no payout"); + } + + printf("\n--- Multi-recipient split ---\n"); + { + uint64_t pool = 436; + double factors[] = {0.79, 0.21}; + const char *names[] = {"owner", "developer"}; + + uint64_t total = 0; + for (int i = 0; i < 2; i++) { + uint64_t share = (uint64_t)round((double)pool * factors[i]); + printf(" %s: factor=%.2f share=%llu\n", names[i], factors[i], (unsigned long long)share); + total += share; + } + ASSERT_EQ_INT(436, (int)total, "79/21 split sums to pool"); + } + + printf("\n--- Single recipient 100%% ---\n"); + { + uint64_t pool = 436; + double factor = 1.0; + uint64_t share = (uint64_t)round((double)pool * factor); + ASSERT_EQ_INT(436, (int)share, "1.0 factor = full pool"); + } + + printf("\n--- Fee tolerance calculation ---\n"); + { + uint64_t share = 344; + uint64_t fee_pct = 10; + uint64_t max_cost = share + (share * fee_pct / 100); + ASSERT_EQ_INT(378, (int)max_cost, "344 + 10% = 378"); + } + + printf("\n--- Zero pool (balance == reserve) ---\n"); + { + uint64_t balance = 64; + uint64_t min_balance = 64; + uint64_t pool = balance - min_balance; + ASSERT_EQ_INT(0, (int)pool, "no payout when balance == reserve"); + } + + printf("\n--- Payout config defaults ---\n"); + { + payout_config_t cfg; + memset(&cfg, 0, sizeof(cfg)); + cfg.enabled = true; + cfg.mint_count = 1; + strncpy(cfg.mints[0].url, "https://testnut.cashu.space", sizeof(cfg.mints[0].url) - 1); + cfg.mints[0].min_balance = 64; + cfg.mints[0].min_payout_amount = 128; + cfg.recipient_count = 1; + strncpy(cfg.recipients[0].lightning_address, "TollGate@coinos.io", + sizeof(cfg.recipients[0].lightning_address) - 1); + cfg.recipients[0].factor = 1.0; + cfg.fee_tolerance_pct = 10; + cfg.check_interval_s = 60; + + ASSERT(cfg.enabled, "payout enabled"); + ASSERT_EQ_INT(1, cfg.mint_count, "1 mint"); + ASSERT_EQ_INT(1, cfg.recipient_count, "1 recipient"); + ASSERT_EQ_STR("TollGate@coinos.io", cfg.recipients[0].lightning_address, "default LNURL"); + } +} + +int main(void) +{ + printf("=== test_lightning_payout ===\n"); + test_payout_calculation(); + TEST_SUMMARY(); +} diff --git a/tests/unit/test_lnurl_pay b/tests/unit/test_lnurl_pay new file mode 100755 index 0000000..1f16293 Binary files /dev/null and b/tests/unit/test_lnurl_pay differ diff --git a/tests/unit/test_lnurl_pay.c b/tests/unit/test_lnurl_pay.c new file mode 100644 index 0000000..d630b9c --- /dev/null +++ b/tests/unit/test_lnurl_pay.c @@ -0,0 +1,125 @@ +#include "test_framework.h" +#include +#include +#include +#include +#include +#include + +static const char *SAMPLE_LNURLP_RESPONSE = + "{\"callback\":\"https://coinos.io/lnurlp/callback/abc123\"," + "\"maxSendable\":1000000000," + "\"minSendable\":1000," + "\"metadata\":\"[[\\\"text/identifier\\\",\\\"TollGate@coinos.io\\\"]]\"," + "\"tag\":\"payRequest\"}"; + +static const char *SAMPLE_CALLBACK_RESPONSE = + "{\"pr\":\"lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan7s4g2e65tr58a50k2jdhz3l8eqc5x5z5f9e3u70fxnh00x09ml0q CONSTANTS\"}"; + +static const char *SAMPLE_MISSING_AT = "no-at-sign.com"; +static const char *SAMPLE_MISSING_CALLBACK = "{\"tag\":\"payRequest\"}"; +static const char *SAMPLE_MISSING_PR = "{\"status\":\"ERROR\"}"; + +static bool test_parse_lnurlp_response(void) +{ + cJSON *root = cJSON_Parse(SAMPLE_LNURLP_RESPONSE); + if (!root) return false; + + cJSON *callback = cJSON_GetObjectItemCaseSensitive(root, "callback"); + bool ok = (callback && cJSON_IsString(callback) && + strcmp(callback->valuestring, "https://coinos.io/lnurlp/callback/abc123") == 0); + + cJSON *min_sendable = cJSON_GetObjectItemCaseSensitive(root, "minSendable"); + ok = ok && (min_sendable && cJSON_IsNumber(min_sendable) && min_sendable->valuedouble == 1000); + + cJSON *max_sendable = cJSON_GetObjectItemCaseSensitive(root, "maxSendable"); + ok = ok && (max_sendable && cJSON_IsNumber(max_sendable)); + + cJSON_Delete(root); + return ok; +} + +static bool test_parse_callback_response(void) +{ + cJSON *root = cJSON_Parse(SAMPLE_CALLBACK_RESPONSE); + if (!root) return false; + + cJSON *pr = cJSON_GetObjectItemCaseSensitive(root, "pr"); + bool ok = (pr && cJSON_IsString(pr) && strncmp(pr->valuestring, "lnbc", 4) == 0); + + cJSON_Delete(root); + return ok; +} + +static bool test_lightning_address_parse(void) +{ + const char *addr = "TollGate@coinos.io"; + const char *at = strchr(addr, '@'); + if (!at) return false; + + size_t user_len = at - addr; + if (user_len != 8) return false; + + char username[64]; + memcpy(username, addr, user_len); + username[user_len] = '\0'; + + if (strcmp(username, "TollGate") != 0) return false; + if (strcmp(at + 1, "coinos.io") != 0) return false; + + return true; +} + +static bool test_amount_validation(void) +{ + uint64_t min_msat = 1000; + uint64_t max_msat = 1000000000; + uint64_t amount_msat = 21 * 1000; + + bool ok = (amount_msat >= min_msat && amount_msat <= max_msat); + ok = ok && !(0 >= min_msat); + ok = ok && !(2000000000ULL <= max_msat); + return ok; +} + +int main(void) +{ + printf("=== test_lnurl_pay ===\n"); + + printf("\n--- LNURL-pay response parsing ---\n"); + ASSERT(test_parse_lnurlp_response(), "parse lnurlp response: callback + min/max"); + + printf("\n--- Callback response parsing ---\n"); + ASSERT(test_parse_callback_response(), "parse callback response: extract bolt11 'pr'"); + + printf("\n--- Lightning address parsing ---\n"); + ASSERT(test_lightning_address_parse(), "split 'TollGate@coinos.io' into user + domain"); + + printf("\n--- Amount validation ---\n"); + ASSERT(test_amount_validation(), "21 sats (21000 msat) within [1000, 1000000000]"); + + printf("\n--- Missing @ in address ---\n"); + { + const char *addr = "no-at-sign.com"; + const char *at = strchr(addr, '@'); + ASSERT(at == NULL, "no @ returns NULL"); + } + + printf("\n--- Missing callback in response ---\n"); + { + cJSON *root = cJSON_Parse(SAMPLE_MISSING_CALLBACK); + cJSON *cb = cJSON_GetObjectItemCaseSensitive(root, "callback"); + ASSERT(!cb, "missing callback detected"); + cJSON_Delete(root); + } + + printf("\n--- Missing pr in callback response ---\n"); + { + cJSON *root = cJSON_Parse(SAMPLE_MISSING_PR); + cJSON *pr = cJSON_GetObjectItemCaseSensitive(root, "pr"); + ASSERT(!pr, "missing pr detected"); + cJSON_Delete(root); + } + + TEST_SUMMARY(); +} diff --git a/tests/unit/test_tollgate_client b/tests/unit/test_tollgate_client new file mode 100755 index 0000000..33b272e Binary files /dev/null and b/tests/unit/test_tollgate_client differ -- cgit v1.2.3