From 2a86bec93273e2f4ceeab60683058c65dbb1da3d Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 18 May 2026 14:50:41 +0530 Subject: feat: multi-mint health tracker, discovery, portal, multi-wallet (Phase 3-8) - mint_health.h/c: FreeRTOS probing task, GET /v1/info every 5min, recovery threshold 3, immediate failure, mutex-protected state - cashu.c: health-gated acceptance (config match AND reachable) - tollgate_api.c: one price_per_step tag per reachable mint in discovery - captive_portal.c: mint list with green/grey indicators, /mints API, auto-refresh every 30s via JS - nucula_wallet.h/cpp: multi-wallet (up to 4), route receive to correct wallet by mint URL, balance sums across all wallets - tollgate_main.c: init health tracker + multi-wallet on service start - CMakeLists.txt: add mint_health.c --- components/nucula_lib/nucula_wallet.cpp | 260 ++++++++++++++++++++++---------- components/nucula_lib/nucula_wallet.h | 1 + main/CMakeLists.txt | 5 +- main/captive_portal.c | 86 +++++++++-- main/cashu.c | 7 +- main/mint_health.c | 234 ++++++++++++++++++++++++++++ main/mint_health.h | 31 ++++ main/tollgate_api.c | 37 ++++- main/tollgate_main.c | 12 +- 9 files changed, 569 insertions(+), 104 deletions(-) create mode 100644 main/mint_health.c create mode 100644 main/mint_health.h diff --git a/components/nucula_lib/nucula_wallet.cpp b/components/nucula_lib/nucula_wallet.cpp index 9a24e89..7c9141d 100644 --- a/components/nucula_lib/nucula_wallet.cpp +++ b/components/nucula_lib/nucula_wallet.cpp @@ -12,47 +12,111 @@ static const char *TAG = "nucula_wallet"; +static const int MAX_WALLETS = 4; + static secp256k1_context *s_ctx = nullptr; -static cashu::Wallet *s_wallet = nullptr; +static cashu::Wallet *s_wallets[MAX_WALLETS] = {}; +static int s_wallet_count = 0; +static char s_wallet_urls[MAX_WALLETS][256] = {}; -static std::vector &mutable_proofs() +static cashu::Wallet *find_wallet_for_token(const cashu::Token &tok) { - return const_cast &>(s_wallet->proofs()); + for (int i = 0; i < s_wallet_count; i++) { + if (s_wallets[i] && !s_wallets[i]->mint_url().empty()) { + if (tok.mint.find(s_wallets[i]->mint_url()) != std::string::npos || + s_wallets[i]->mint_url().find(tok.mint) != std::string::npos) { + return s_wallets[i]; + } + } + } + if (s_wallet_count > 0 && s_wallets[0]) return s_wallets[0]; + return nullptr; } -esp_err_t nucula_wallet_init(const char *mint_url) +static cashu::Wallet *find_wallet_for_send(int amount) { - if (s_wallet) return ESP_OK; - - s_ctx = secp256k1_context_create(SECP256K1_CONTEXT_SIGN | SECP256K1_CONTEXT_VERIFY); - if (!s_ctx) { - ESP_LOGE(TAG, "Failed to create secp256k1 context"); - return ESP_FAIL; + for (int i = 0; i < s_wallet_count; i++) { + if (s_wallets[i] && s_wallets[i]->balance() >= amount) { + return s_wallets[i]; + } } + return s_wallet_count > 0 ? s_wallets[0] : nullptr; +} + +static std::vector &mutable_proofs(cashu::Wallet *w) +{ + return const_cast &>(w->proofs()); +} + +static esp_err_t init_wallet(int slot, const char *mint_url) +{ + if (slot >= MAX_WALLETS) return ESP_FAIL; - s_wallet = new cashu::Wallet(std::string(mint_url), s_ctx, 0); - if (!s_wallet) { - ESP_LOGE(TAG, "Failed to create wallet"); - secp256k1_context_destroy(s_ctx); - s_ctx = nullptr; + s_wallets[slot] = new cashu::Wallet(std::string(mint_url), s_ctx, slot); + if (!s_wallets[slot]) { + ESP_LOGE(TAG, "Failed to create wallet for slot %d", slot); return ESP_FAIL; } + strncpy(s_wallet_urls[slot], mint_url, sizeof(s_wallet_urls[slot]) - 1); - s_wallet->load_from_nvs(); + s_wallets[slot]->load_from_nvs(); - if (!s_wallet->load_keysets()) { - ESP_LOGW(TAG, "Keyset load failed (may be offline)"); + if (!s_wallets[slot]->load_keysets()) { + ESP_LOGW(TAG, "Keyset load failed for slot %d (may be offline)", slot); } - ESP_LOGI(TAG, "Wallet initialized: balance=%d proofs=%d keysets=%d", - s_wallet->balance(), (int)s_wallet->proofs().size(), - (int)s_wallet->keysets().size()); + ESP_LOGI(TAG, "Wallet[%d] initialized: url=%s balance=%d proofs=%d keysets=%d", + slot, mint_url, s_wallets[slot]->balance(), + (int)s_wallets[slot]->proofs().size(), + (int)s_wallets[slot]->keysets().size()); return ESP_OK; } +esp_err_t nucula_wallet_init(const char *mint_url) +{ + if (s_wallet_count > 0) return ESP_OK; + + if (!s_ctx) { + s_ctx = secp256k1_context_create(SECP256K1_CONTEXT_SIGN | SECP256K1_CONTEXT_VERIFY); + if (!s_ctx) { + ESP_LOGE(TAG, "Failed to create secp256k1 context"); + return ESP_FAIL; + } + } + + esp_err_t ret = init_wallet(0, mint_url); + if (ret == ESP_OK) s_wallet_count = 1; + return ret; +} + +esp_err_t nucula_wallet_init_multi(const char mint_urls[][256], int count) +{ + if (s_wallet_count > 0) return ESP_OK; + if (count > MAX_WALLETS) count = MAX_WALLETS; + + if (!s_ctx) { + s_ctx = secp256k1_context_create(SECP256K1_CONTEXT_SIGN | SECP256K1_CONTEXT_VERIFY); + if (!s_ctx) { + ESP_LOGE(TAG, "Failed to create secp256k1 context"); + return ESP_FAIL; + } + } + + int ok = 0; + for (int i = 0; i < count; i++) { + if (init_wallet(i, mint_urls[i]) == ESP_OK) { + ok++; + } + } + + s_wallet_count = count; + ESP_LOGI(TAG, "Multi-wallet initialized: %d/%d wallets", ok, count); + return ok > 0 ? ESP_OK : ESP_FAIL; +} + esp_err_t nucula_wallet_receive(const char *token_str) { - if (!s_wallet || !token_str) return ESP_FAIL; + if (s_wallet_count == 0 || !token_str) return ESP_FAIL; cashu::Token tok; bool decoded = false; @@ -66,38 +130,47 @@ esp_err_t nucula_wallet_receive(const char *token_str) return ESP_FAIL; } + cashu::Wallet *w = find_wallet_for_token(tok); + if (!w) { + ESP_LOGE(TAG, "No wallet found for mint: %s", tok.mint.c_str()); + return ESP_FAIL; + } + std::vector proofs_out; - if (!s_wallet->receive(tok, proofs_out)) { + if (!w->receive(tok, proofs_out)) { ESP_LOGE(TAG, "Receive failed"); return ESP_FAIL; } int total = 0; for (const auto &p : proofs_out) total += p.amount; - ESP_LOGI(TAG, "Received %d sat (%d proofs), new balance=%d", - total, (int)proofs_out.size(), s_wallet->balance()); + ESP_LOGI(TAG, "Received %d sat (%d proofs) via wallet[%s], new balance=%d", + total, (int)proofs_out.size(), w->mint_url().c_str(), w->balance()); return ESP_OK; } esp_err_t nucula_wallet_send(uint64_t amount_sat, char *token_out, size_t token_out_size) { - if (!s_wallet) return ESP_FAIL; + if (s_wallet_count == 0) return ESP_FAIL; int amount = (int)amount_sat; + cashu::Wallet *w = find_wallet_for_send(amount); + if (!w) return ESP_FAIL; + std::vector selected, remaining; - if (!s_wallet->select_proofs(amount, selected, remaining)) { + if (!w->select_proofs(amount, selected, remaining)) { ESP_LOGE(TAG, "Insufficient balance for %d sat", amount); return ESP_FAIL; } std::vector new_proofs, change; - if (!s_wallet->swap(selected, (int)amount_sat, new_proofs, change)) { + if (!w->swap(selected, (int)amount_sat, new_proofs, change)) { ESP_LOGE(TAG, "Swap for send failed"); return ESP_FAIL; } cashu::Token token; - token.mint = s_wallet->mint_url(); + token.mint = w->mint_url(); token.unit = "sat"; for (auto &p : new_proofs) token.proofs.push_back(p); @@ -114,39 +187,48 @@ esp_err_t nucula_wallet_send(uint64_t amount_sat, char *token_out, size_t token_ memcpy(token_out, encoded.c_str(), encoded.size() + 1); - auto &proofs = mutable_proofs(); + auto &proofs = mutable_proofs(w); proofs = remaining; for (auto &p : change) proofs.push_back(p); - s_wallet->save_proofs(); + w->save_proofs(); - ESP_LOGI(TAG, "Sent %llu sat, token=%zu bytes, remaining balance=%d", - (unsigned long long)amount_sat, encoded.size(), s_wallet->balance()); + ESP_LOGI(TAG, "Sent %llu sat via wallet[%s], token=%zu bytes, remaining balance=%d", + (unsigned long long)amount_sat, w->mint_url().c_str(), + encoded.size(), w->balance()); return ESP_OK; } uint64_t nucula_wallet_balance(void) { - if (!s_wallet) return 0; - return (uint64_t)s_wallet->balance(); + uint64_t total = 0; + for (int i = 0; i < s_wallet_count; i++) { + if (s_wallets[i]) total += (uint64_t)s_wallets[i]->balance(); + } + return total; } int nucula_wallet_proof_count(void) { - if (!s_wallet) return 0; - return (int)s_wallet->proofs().size(); + int total = 0; + for (int i = 0; i < s_wallet_count; i++) { + if (s_wallets[i]) total += (int)s_wallets[i]->proofs().size(); + } + return total; } char *nucula_wallet_proofs_json(void) { - if (!s_wallet) return nullptr; - - const auto &proofs = s_wallet->proofs(); cJSON *arr = cJSON_CreateArray(); - for (const auto &p : proofs) { - cJSON *obj = cJSON_CreateObject(); - cJSON_AddNumberToObject(obj, "amount", p.amount); - cJSON_AddStringToObject(obj, "id", p.id.c_str()); - cJSON_AddItemToArray(arr, obj); + for (int i = 0; i < s_wallet_count; i++) { + if (!s_wallets[i]) continue; + const auto &proofs = s_wallets[i]->proofs(); + for (const auto &p : proofs) { + cJSON *obj = cJSON_CreateObject(); + cJSON_AddNumberToObject(obj, "amount", p.amount); + cJSON_AddStringToObject(obj, "id", p.id.c_str()); + cJSON_AddStringToObject(obj, "mint", s_wallet_urls[i]); + cJSON_AddItemToArray(arr, obj); + } } char *json = cJSON_PrintUnformatted(arr); cJSON_Delete(arr); @@ -155,55 +237,72 @@ char *nucula_wallet_proofs_json(void) esp_err_t nucula_wallet_swap_all(void) { - if (!s_wallet) return ESP_FAIL; + if (s_wallet_count == 0) return ESP_FAIL; - auto &proofs = mutable_proofs(); - if (proofs.empty()) { - ESP_LOGW(TAG, "No proofs to swap"); - return ESP_FAIL; - } + bool any_ok = false; + for (int i = 0; i < s_wallet_count; i++) { + if (!s_wallets[i]) continue; - int old_balance = s_wallet->balance(); + auto &proofs = mutable_proofs(s_wallets[i]); + if (proofs.empty()) continue; - std::vector inputs = proofs; - std::vector new_proofs, change; - if (!s_wallet->swap(inputs, -1, new_proofs, change)) { - ESP_LOGE(TAG, "Swap failed"); - return ESP_FAIL; - } + int old_balance = s_wallets[i]->balance(); - proofs.clear(); - for (auto &p : new_proofs) proofs.push_back(p); - for (auto &p : change) proofs.push_back(p); - s_wallet->save_proofs(); + std::vector inputs = proofs; + std::vector new_proofs, change; + if (!s_wallets[i]->swap(inputs, -1, new_proofs, change)) { + ESP_LOGE(TAG, "Swap failed for wallet[%d]", i); + continue; + } - ESP_LOGI(TAG, "Swap complete: %d -> %d sat (%d proofs)", - old_balance, s_wallet->balance(), (int)proofs.size()); - return ESP_OK; + proofs.clear(); + for (auto &p : new_proofs) proofs.push_back(p); + for (auto &p : change) proofs.push_back(p); + s_wallets[i]->save_proofs(); + + ESP_LOGI(TAG, "Swap wallet[%d]: %d -> %d sat (%d proofs)", + i, old_balance, s_wallets[i]->balance(), (int)proofs.size()); + any_ok = true; + } + + return any_ok ? ESP_OK : ESP_FAIL; } void nucula_wallet_print_status(void) { - if (!s_wallet) { - ESP_LOGI(TAG, "Wallet not initialized"); + if (s_wallet_count == 0) { + ESP_LOGI(TAG, "No wallets initialized"); return; } - ESP_LOGI(TAG, "Wallet: balance=%d proofs=%d keysets=%d", - s_wallet->balance(), (int)s_wallet->proofs().size(), - (int)s_wallet->keysets().size()); - const auto &proofs = s_wallet->proofs(); - for (size_t i = 0; i < proofs.size(); i++) { - ESP_LOGI(TAG, " [%d] amount=%d id=%s", (int)i, - proofs[i].amount, proofs[i].id.c_str()); + for (int i = 0; i < s_wallet_count; i++) { + if (!s_wallets[i]) continue; + ESP_LOGI(TAG, "Wallet[%d] %s: balance=%d proofs=%d keysets=%d", + i, s_wallet_urls[i], + s_wallets[i]->balance(), (int)s_wallets[i]->proofs().size(), + (int)s_wallets[i]->keysets().size()); + const auto &proofs = s_wallets[i]->proofs(); + for (size_t j = 0; j < proofs.size() && j < 10; j++) { + ESP_LOGI(TAG, " [%d][%d] amount=%d id=%s", (int)i, (int)j, + proofs[j].amount, proofs[j].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; + if (s_wallet_count == 0 || !bolt11_invoice) return ESP_FAIL; + + cashu::Wallet *w = nullptr; + for (int i = 0; i < s_wallet_count; i++) { + if (s_wallets[i] && s_wallets[i]->balance() > 0) { + w = s_wallets[i]; + break; + } + } + if (!w) return ESP_FAIL; cashu::MeltQuote quote; - if (!s_wallet->request_melt_quote(std::string(bolt11_invoice), quote)) { + if (!w->request_melt_quote(std::string(bolt11_invoice), quote)) { ESP_LOGE(TAG, "Melt quote request failed"); return ESP_FAIL; } @@ -216,19 +315,20 @@ esp_err_t nucula_wallet_melt(const char *bolt11_invoice, uint64_t max_fee_sats) return ESP_FAIL; } - int balance_before = s_wallet->balance(); + int balance_before = w->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)) { + if (!w->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()); + ESP_LOGI(TAG, "Melted via wallet[%s]: %d sats paid, %d change, balance=%d->%d", + w->mint_url().c_str(), quote.amount, change_amount, + balance_before, w->balance()); return ESP_OK; } diff --git a/components/nucula_lib/nucula_wallet.h b/components/nucula_lib/nucula_wallet.h index 784a126..3c1f3f8 100644 --- a/components/nucula_lib/nucula_wallet.h +++ b/components/nucula_lib/nucula_wallet.h @@ -9,6 +9,7 @@ extern "C" { #endif esp_err_t nucula_wallet_init(const char *mint_url); +esp_err_t nucula_wallet_init_multi(const char mint_urls[][256], int count); esp_err_t nucula_wallet_receive(const char *token_str); diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 9b0fb1c..1416e07 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -15,8 +15,9 @@ idf_component_register(SRCS "tollgate_main.c" "lightning_payout.c" "nip04.c" "mcp_handler.c" - "cvm_server.c" - "display.c" + "cvm_server.c" + "mint_health.c" + "display.c" "font.c" INCLUDE_DIRS "." REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server diff --git a/main/captive_portal.c b/main/captive_portal.c index 1a3d5ce..57ef65a 100644 --- a/main/captive_portal.c +++ b/main/captive_portal.c @@ -2,6 +2,7 @@ #include "firewall.h" #include "session.h" #include "config.h" +#include "mint_health.h" #include "esp_log.h" #include "esp_wifi.h" #include "cJSON.h" @@ -42,9 +43,14 @@ static const char PORTAL_HTML_TEMPLATE[] = \ ".btn:disabled{background:#333;color:#666;cursor:not-allowed}" ".mints{background:#252525;border-radius:12px;padding:12px;margin-top:16px;text-align:left}" ".mints-title{color:#888;font-size:12px;margin-bottom:8px}" -".mint-url{font-family:monospace;font-size:11px;color:#f7931a;word-break:break-all;" -"background:#1a1a1a;padding:8px;border-radius:6px;cursor:pointer}" -".mint-url:active{opacity:0.7}" +".mint-item{display:flex;align-items:center;padding:6px 8px;margin-bottom:4px;" +"background:#1a1a1a;border-radius:6px;cursor:pointer}" +".mint-item:active{opacity:0.7}" +".mint-dot{width:8px;height:8px;border-radius:50%;margin-right:8px;flex-shrink:0}" +".mint-dot.green{background:#4caf50}" +".mint-dot.grey{background:#666}" +".mint-url{font-family:monospace;font-size:11px;color:#f7931a;word-break:break-all}" +".mint-url.dim{color:#666}" ".mint-hint{color:#666;font-size:10px;margin-top:4px}" "#status{margin-top:16px;padding:12px;border-radius:8px;display:none;font-size:14px}" "#status.success{display:block;background:#1a472a;color:#4caf50}" @@ -63,20 +69,21 @@ static const char PORTAL_HTML_TEMPLATE[] = \ "" "
" "
SUPPORTED MINTS
" -"
__MINT_URL__
" -"
Tap to copy • Mint tokens at this URL before paying
" +"
__MINT_LIST__
" +"
Tap to copy • Green = reachable
" "
" "
" "" "" ""; @@ -122,10 +143,32 @@ static esp_err_t portal_handler(httpd_req_t *req) const char *tpl = PORTAL_HTML_TEMPLATE; size_t tpl_len = strlen(tpl); + char mint_list_html[4096]; + mint_list_html[0] = '\0'; + int mint_count = 0; + const mint_status_t *mints = mint_health_get_all(&mint_count); + for (int i = 0; i < mint_count; i++) { + const char *cls = mints[i].reachable ? "green" : "grey"; + const char *url_cls = mints[i].reachable ? "mint-url" : "mint-url dim"; + char item[512]; + snprintf(item, sizeof(item), + "
" + "" + "%s
", + mints[i].url, cls, url_cls, mints[i].url); + strncat(mint_list_html, item, sizeof(mint_list_html) - strlen(mint_list_html) - 1); + } + if (mint_count == 0) { + const tollgate_config_t *cfg = tollgate_config_get(); + snprintf(mint_list_html, sizeof(mint_list_html), + "
" + "%s
", cfg->mint_url); + } + struct { const char *key; const char *val; } subs[] = { { "__AP_IP__", s_ap_ip_str }, { "__PRICE__", price_str }, - { "__MINT_URL__", cfg->mint_url }, + { "__MINT_LIST__", mint_list_html }, }; int nsubs = sizeof(subs) / sizeof(subs[0]); @@ -190,6 +233,25 @@ static esp_err_t grant_access_handler(httpd_req_t *req) return ESP_OK; } +static esp_err_t mints_handler(httpd_req_t *req) +{ + int mint_count = 0; + const mint_status_t *mints = mint_health_get_all(&mint_count); + cJSON *arr = cJSON_CreateArray(); + for (int i = 0; i < mint_count; i++) { + cJSON *obj = cJSON_CreateObject(); + cJSON_AddStringToObject(obj, "url", mints[i].url); + cJSON_AddBoolToObject(obj, "reachable", mints[i].reachable); + cJSON_AddItemToArray(arr, obj); + } + char *json = cJSON_PrintUnformatted(arr); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json, strlen(json)); + cJSON_free(json); + cJSON_Delete(arr); + return ESP_OK; +} + static esp_err_t status_handler(httpd_req_t *req) { const tollgate_config_t *cfg = tollgate_config_get(); @@ -290,6 +352,7 @@ static esp_err_t catchall_handler(httpd_req_t *req) static const httpd_uri_t uri_portal = { .uri = "/", .method = HTTP_GET, .handler = portal_handler }; static const httpd_uri_t uri_grant = { .uri = "/grant_access", .method = HTTP_GET, .handler = grant_access_handler }; +static const httpd_uri_t uri_mints = { .uri = "/mints", .method = HTTP_GET, .handler = mints_handler }; static const httpd_uri_t uri_status = { .uri = "/api/status", .method = HTTP_GET, .handler = status_handler }; static const httpd_uri_t uri_whoami = { .uri = "/whoami", .method = HTTP_GET, .handler = whoami_handler }; static const httpd_uri_t uri_usage = { .uri = "/usage", .method = HTTP_GET, .handler = usage_handler }; @@ -320,6 +383,7 @@ esp_err_t captive_portal_start(const char *ap_ip_str) httpd_register_uri_handler(s_server, &uri_portal); httpd_register_uri_handler(s_server, &uri_grant); + httpd_register_uri_handler(s_server, &uri_mints); httpd_register_uri_handler(s_server, &uri_status); httpd_register_uri_handler(s_server, &uri_whoami); httpd_register_uri_handler(s_server, &uri_usage); diff --git a/main/cashu.c b/main/cashu.c index 2912d1d..da12ff9 100644 --- a/main/cashu.c +++ b/main/cashu.c @@ -1,5 +1,6 @@ #include "cashu.h" #include "config.h" +#include "mint_health.h" #include "esp_log.h" #include "esp_http_client.h" #include "cJSON.h" @@ -268,8 +269,10 @@ bool cashu_is_mint_accepted(const char *mint_url) if (!mint_url || mint_url[0] == '\0') return false; const tollgate_config_t *cfg = tollgate_config_get(); for (int i = 0; i < cfg->accepted_mint_count; i++) { - if (strstr(mint_url, cfg->accepted_mints[i]) != NULL) return true; - if (strcmp(mint_url, cfg->accepted_mints[i]) == 0) return true; + if (strstr(mint_url, cfg->accepted_mints[i]) != NULL || + strcmp(mint_url, cfg->accepted_mints[i]) == 0) { + return mint_health_is_reachable(mint_url); + } } return false; } diff --git a/main/mint_health.c b/main/mint_health.c new file mode 100644 index 0000000..39b0e8e --- /dev/null +++ b/main/mint_health.c @@ -0,0 +1,234 @@ +#include "mint_health.h" +#include "esp_log.h" +#include "esp_http_client.h" +#include "esp_crt_bundle.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/semphr.h" +#include +#include + +static const char *TAG = "mint_health"; + +static mint_status_t s_mints[MINT_HEALTH_MAX]; +static int s_mint_count = 0; +static bool s_running = false; +static TaskHandle_t s_task_handle = NULL; +static SemaphoreHandle_t s_mutex = NULL; + +#define MAX_CALLBACKS 4 +static mint_health_changed_cb s_callbacks[MAX_CALLBACKS]; +static int s_callback_count = 0; + +static void fire_callbacks(void) +{ + for (int i = 0; i < s_callback_count; i++) { + if (s_callbacks[i]) s_callbacks[i](); + } +} + +esp_err_t mint_health_init(const char urls[][256], int count) +{ + if (count > MINT_HEALTH_MAX) count = MINT_HEALTH_MAX; + s_mint_count = count; + s_callback_count = 0; + + if (!s_mutex) s_mutex = xSemaphoreCreateMutex(); + + memset(s_mints, 0, sizeof(s_mints)); + for (int i = 0; i < count; i++) { + strncpy(s_mints[i].url, urls[i], sizeof(s_mints[i].url) - 1); + s_mints[i].reachable = false; + s_mints[i].consecutive_successes = 0; + s_mints[i].last_probe_ms = 0; + s_mints[i].last_http_status = 0; + } + + ESP_LOGI(TAG, "Initialized with %d mints", count); + return ESP_OK; +} + +static bool probe_mint(const char *url) +{ + char probe_url[512]; + snprintf(probe_url, sizeof(probe_url), "%s/v1/info", url); + + esp_http_client_config_t config = { + .url = probe_url, + .method = HTTP_METHOD_GET, + .timeout_ms = MINT_HEALTH_PROBE_TIMEOUT_MS, + .crt_bundle_attach = esp_crt_bundle_attach, + }; + esp_http_client_handle_t client = esp_http_client_init(&config); + if (!client) return false; + + esp_err_t err = esp_http_client_open(client, 0); + if (err != ESP_OK) { + esp_http_client_cleanup(client); + return false; + } + + int content_length = esp_http_client_fetch_headers(client); + int status = esp_http_client_get_status_code(client); + + char *resp = NULL; + if (content_length > 0 && content_length < 8192) { + resp = malloc(content_length + 1); + if (resp) { + int read = esp_http_client_read(client, resp, content_length); + if (read > 0) resp[read] = '\0'; + } + } + if (resp) free(resp); + + esp_http_client_cleanup(client); + return (status >= 200 && status < 300); +} + +static void run_probes(void) +{ + int old_reachable = 0; + int new_reachable = 0; + + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(5000)) != pdTRUE) return; + + for (int i = 0; i < s_mint_count; i++) { + if (s_mints[i].reachable) old_reachable++; + } + + for (int i = 0; i < s_mint_count; i++) { + bool ok = probe_mint(s_mints[i].url); + s_mints[i].last_probe_ms = (int64_t)xTaskGetTickCount() * portTICK_PERIOD_MS; + s_mints[i].last_http_status = ok ? 200 : 0; + + if (ok) { + s_mints[i].consecutive_successes++; + if (s_mints[i].consecutive_successes >= MINT_HEALTH_RECOVERY_THRESHOLD) { + if (!s_mints[i].reachable) { + ESP_LOGI(TAG, "Mint RECOVERED: %s", s_mints[i].url); + } + s_mints[i].reachable = true; + } + } else { + if (s_mints[i].reachable) { + ESP_LOGW(TAG, "Mint UNREACHABLE: %s", s_mints[i].url); + } + s_mints[i].reachable = false; + s_mints[i].consecutive_successes = 0; + } + + if (s_mints[i].reachable) new_reachable++; + } + + bool changed = (old_reachable != new_reachable); + xSemaphoreGive(s_mutex); + + if (changed) { + ESP_LOGI(TAG, "Reachable set changed: %d -> %d", old_reachable, new_reachable); + fire_callbacks(); + } +} + +static void run_initial_probes(void) +{ + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(5000)) != pdTRUE) return; + + for (int i = 0; i < s_mint_count; i++) { + bool ok = probe_mint(s_mints[i].url); + s_mints[i].last_probe_ms = (int64_t)xTaskGetTickCount() * portTICK_PERIOD_MS; + s_mints[i].last_http_status = ok ? 200 : 0; + + if (ok) { + s_mints[i].consecutive_successes = MINT_HEALTH_RECOVERY_THRESHOLD; + s_mints[i].reachable = true; + ESP_LOGI(TAG, "Initial probe OK: %s (reachable)", s_mints[i].url); + } else { + s_mints[i].consecutive_successes = 0; + s_mints[i].reachable = false; + ESP_LOGW(TAG, "Initial probe FAIL: %s (unreachable)", s_mints[i].url); + } + } + + xSemaphoreGive(s_mutex); + fire_callbacks(); +} + +static void health_task(void *pvParameters) +{ + ESP_LOGI(TAG, "Health probe task started"); + run_initial_probes(); + + while (s_running) { + vTaskDelay(pdMS_TO_TICKS(MINT_HEALTH_PROBE_INTERVAL_S * 1000)); + if (!s_running) break; + run_probes(); + } + + s_task_handle = NULL; + vTaskDelete(NULL); +} + +void mint_health_start(void) +{ + if (s_running) return; + s_running = true; + xTaskCreate(health_task, "mint_health", 16384, NULL, 3, &s_task_handle); +} + +void mint_health_stop(void) +{ + s_running = false; + if (s_task_handle) { + vTaskDelay(pdMS_TO_TICKS(100)); + } +} + +const mint_status_t *mint_health_get_all(int *out_count) +{ + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(1000)) != pdTRUE) { + *out_count = 0; + return s_mints; + } + *out_count = s_mint_count; + xSemaphoreGive(s_mutex); + return s_mints; +} + +bool mint_health_is_reachable(const char *url) +{ + if (!url) return false; + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(1000)) != pdTRUE) return false; + bool result = false; + for (int i = 0; i < s_mint_count; i++) { + if (strcmp(s_mints[i].url, url) == 0 || strstr(url, s_mints[i].url) != NULL) { + result = s_mints[i].reachable; + break; + } + } + xSemaphoreGive(s_mutex); + return result; +} + +void mint_health_mark_unreachable(const char *url) +{ + if (!url) return; + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(1000)) != pdTRUE) return; + for (int i = 0; i < s_mint_count; i++) { + if (strcmp(s_mints[i].url, url) == 0 || strstr(url, s_mints[i].url) != NULL) { + if (s_mints[i].reachable) { + s_mints[i].reachable = false; + s_mints[i].consecutive_successes = 0; + ESP_LOGW(TAG, "Reactively marked unreachable: %s", url); + } + break; + } + } + xSemaphoreGive(s_mutex); +} + +void mint_health_register_callback(mint_health_changed_cb cb) +{ + if (s_callback_count < MAX_CALLBACKS && cb) { + s_callbacks[s_callback_count++] = cb; + } +} diff --git a/main/mint_health.h b/main/mint_health.h new file mode 100644 index 0000000..f047d6a --- /dev/null +++ b/main/mint_health.h @@ -0,0 +1,31 @@ +#ifndef MINT_HEALTH_H +#define MINT_HEALTH_H + +#include "esp_err.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); + +esp_err_t mint_health_init(const char urls[][256], int count); +void mint_health_start(void); +void mint_health_stop(void); +const mint_status_t *mint_health_get_all(int *out_count); +bool mint_health_is_reachable(const char *url); +void mint_health_mark_unreachable(const char *url); +void mint_health_register_callback(mint_health_changed_cb cb); + +#endif diff --git a/main/tollgate_api.c b/main/tollgate_api.c index 650b0f3..b694729 100644 --- a/main/tollgate_api.c +++ b/main/tollgate_api.c @@ -4,6 +4,7 @@ #include "session.h" #include "firewall.h" #include "nucula_wallet.h" +#include "mint_health.h" #include "esp_log.h" #include "cJSON.h" #include "lwip/sockets.h" @@ -110,16 +111,36 @@ static esp_err_t api_get_discovery(httpd_req_t *req) cJSON_AddItemToArray(step_tag, cJSON_CreateString(step_str)); cJSON_AddItemToArray(tags, step_tag); - cJSON *price_tag = cJSON_CreateArray(); - cJSON_AddItemToArray(price_tag, cJSON_CreateString("price_per_step")); - cJSON_AddItemToArray(price_tag, cJSON_CreateString("cashu")); char price_str[32]; snprintf(price_str, sizeof(price_str), "%d", cfg->price_per_step); - cJSON_AddItemToArray(price_tag, cJSON_CreateString(price_str)); - cJSON_AddItemToArray(price_tag, cJSON_CreateString("sat")); - cJSON_AddItemToArray(price_tag, cJSON_CreateString(cfg->mint_url)); - cJSON_AddItemToArray(price_tag, cJSON_CreateString("1")); - cJSON_AddItemToArray(tags, price_tag); + + int mint_count = 0; + const mint_status_t *mints = mint_health_get_all(&mint_count); + bool any_reachable = false; + + for (int i = 0; i < mint_count; i++) { + if (!mints[i].reachable) continue; + any_reachable = true; + cJSON *price_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(price_tag, cJSON_CreateString("price_per_step")); + cJSON_AddItemToArray(price_tag, cJSON_CreateString("cashu")); + cJSON_AddItemToArray(price_tag, cJSON_CreateString(price_str)); + cJSON_AddItemToArray(price_tag, cJSON_CreateString("sat")); + cJSON_AddItemToArray(price_tag, cJSON_CreateString(mints[i].url)); + cJSON_AddItemToArray(price_tag, cJSON_CreateString("1")); + cJSON_AddItemToArray(tags, price_tag); + } + + if (!any_reachable) { + cJSON *price_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(price_tag, cJSON_CreateString("price_per_step")); + cJSON_AddItemToArray(price_tag, cJSON_CreateString("cashu")); + cJSON_AddItemToArray(price_tag, cJSON_CreateString(price_str)); + cJSON_AddItemToArray(price_tag, cJSON_CreateString("sat")); + cJSON_AddItemToArray(price_tag, cJSON_CreateString(cfg->mint_url)); + cJSON_AddItemToArray(price_tag, cJSON_CreateString("1")); + cJSON_AddItemToArray(tags, price_tag); + } cJSON *tips_tag = cJSON_CreateArray(); cJSON_AddItemToArray(tips_tag, cJSON_CreateString("tips")); diff --git a/main/tollgate_main.c b/main/tollgate_main.c index c0ff65f..5f3e0e1 100644 --- a/main/tollgate_main.c +++ b/main/tollgate_main.c @@ -22,6 +22,7 @@ #include "wifistr.h" #include "tollgate_client.h" #include "lightning_payout.h" +#include "mint_health.h" #include "cvm_server.h" #include "display.h" @@ -151,7 +152,15 @@ static void start_services(void) session_manager_init(); const tollgate_config_t *cfg = tollgate_config_get(); - nucula_wallet_init(cfg->mint_url); + + mint_health_init(cfg->accepted_mints, cfg->accepted_mint_count); + mint_health_start(); + + if (cfg->accepted_mint_count > 1) { + nucula_wallet_init_multi(cfg->accepted_mints, cfg->accepted_mint_count); + } else { + nucula_wallet_init(cfg->mint_url); + } lightning_payout_init(&cfg->payout); dns_server_start(ap_ip_info.ip, upstream_dns); @@ -187,6 +196,7 @@ static void stop_services(void) captive_portal_stop(); tollgate_api_stop(); dns_server_stop(); + mint_health_stop(); cvm_server_stop(); firewall_revoke_all(); s_services_running = false; -- cgit v1.2.3