diff options
| author | Your Name <you@example.com> | 2026-05-18 14:50:41 +0530 |
|---|---|---|
| committer | Your Name <you@example.com> | 2026-05-18 14:50:41 +0530 |
| commit | 2a86bec93273e2f4ceeab60683058c65dbb1da3d (patch) | |
| tree | 56e66acc4eaa0b5a947bb78d28e494a0adc857e2 | |
| parent | 0c3f08ac7cf8e970369ec137153107ca8edc3326 (diff) | |
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
| -rw-r--r-- | components/nucula_lib/nucula_wallet.cpp | 260 | ||||
| -rw-r--r-- | components/nucula_lib/nucula_wallet.h | 1 | ||||
| -rw-r--r-- | main/CMakeLists.txt | 5 | ||||
| -rw-r--r-- | main/captive_portal.c | 86 | ||||
| -rw-r--r-- | main/cashu.c | 7 | ||||
| -rw-r--r-- | main/mint_health.c | 234 | ||||
| -rw-r--r-- | main/mint_health.h | 31 | ||||
| -rw-r--r-- | main/tollgate_api.c | 37 | ||||
| -rw-r--r-- | main/tollgate_main.c | 12 |
9 files changed, 569 insertions, 104 deletions
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 @@ | |||
| 12 | 12 | ||
| 13 | static const char *TAG = "nucula_wallet"; | 13 | static const char *TAG = "nucula_wallet"; |
| 14 | 14 | ||
| 15 | static const int MAX_WALLETS = 4; | ||
| 16 | |||
| 15 | static secp256k1_context *s_ctx = nullptr; | 17 | static secp256k1_context *s_ctx = nullptr; |
| 16 | static cashu::Wallet *s_wallet = nullptr; | 18 | static cashu::Wallet *s_wallets[MAX_WALLETS] = {}; |
| 19 | static int s_wallet_count = 0; | ||
| 20 | static char s_wallet_urls[MAX_WALLETS][256] = {}; | ||
| 17 | 21 | ||
| 18 | static std::vector<cashu::Proof> &mutable_proofs() | 22 | static cashu::Wallet *find_wallet_for_token(const cashu::Token &tok) |
| 19 | { | 23 | { |
| 20 | return const_cast<std::vector<cashu::Proof> &>(s_wallet->proofs()); | 24 | for (int i = 0; i < s_wallet_count; i++) { |
| 25 | if (s_wallets[i] && !s_wallets[i]->mint_url().empty()) { | ||
| 26 | if (tok.mint.find(s_wallets[i]->mint_url()) != std::string::npos || | ||
| 27 | s_wallets[i]->mint_url().find(tok.mint) != std::string::npos) { | ||
| 28 | return s_wallets[i]; | ||
| 29 | } | ||
| 30 | } | ||
| 31 | } | ||
| 32 | if (s_wallet_count > 0 && s_wallets[0]) return s_wallets[0]; | ||
| 33 | return nullptr; | ||
| 21 | } | 34 | } |
| 22 | 35 | ||
| 23 | esp_err_t nucula_wallet_init(const char *mint_url) | 36 | static cashu::Wallet *find_wallet_for_send(int amount) |
| 24 | { | 37 | { |
| 25 | if (s_wallet) return ESP_OK; | 38 | for (int i = 0; i < s_wallet_count; i++) { |
| 26 | 39 | if (s_wallets[i] && s_wallets[i]->balance() >= amount) { | |
| 27 | s_ctx = secp256k1_context_create(SECP256K1_CONTEXT_SIGN | SECP256K1_CONTEXT_VERIFY); | 40 | return s_wallets[i]; |
| 28 | if (!s_ctx) { | 41 | } |
| 29 | ESP_LOGE(TAG, "Failed to create secp256k1 context"); | ||
| 30 | return ESP_FAIL; | ||
| 31 | } | 42 | } |
| 43 | return s_wallet_count > 0 ? s_wallets[0] : nullptr; | ||
| 44 | } | ||
| 45 | |||
| 46 | static std::vector<cashu::Proof> &mutable_proofs(cashu::Wallet *w) | ||
| 47 | { | ||
| 48 | return const_cast<std::vector<cashu::Proof> &>(w->proofs()); | ||
| 49 | } | ||
| 50 | |||
| 51 | static esp_err_t init_wallet(int slot, const char *mint_url) | ||
| 52 | { | ||
| 53 | if (slot >= MAX_WALLETS) return ESP_FAIL; | ||
| 32 | 54 | ||
| 33 | s_wallet = new cashu::Wallet(std::string(mint_url), s_ctx, 0); | 55 | s_wallets[slot] = new cashu::Wallet(std::string(mint_url), s_ctx, slot); |
| 34 | if (!s_wallet) { | 56 | if (!s_wallets[slot]) { |
| 35 | ESP_LOGE(TAG, "Failed to create wallet"); | 57 | ESP_LOGE(TAG, "Failed to create wallet for slot %d", slot); |
| 36 | secp256k1_context_destroy(s_ctx); | ||
| 37 | s_ctx = nullptr; | ||
| 38 | return ESP_FAIL; | 58 | return ESP_FAIL; |
| 39 | } | 59 | } |
| 60 | strncpy(s_wallet_urls[slot], mint_url, sizeof(s_wallet_urls[slot]) - 1); | ||
| 40 | 61 | ||
| 41 | s_wallet->load_from_nvs(); | 62 | s_wallets[slot]->load_from_nvs(); |
| 42 | 63 | ||
| 43 | if (!s_wallet->load_keysets()) { | 64 | if (!s_wallets[slot]->load_keysets()) { |
| 44 | ESP_LOGW(TAG, "Keyset load failed (may be offline)"); | 65 | ESP_LOGW(TAG, "Keyset load failed for slot %d (may be offline)", slot); |
| 45 | } | 66 | } |
| 46 | 67 | ||
| 47 | ESP_LOGI(TAG, "Wallet initialized: balance=%d proofs=%d keysets=%d", | 68 | ESP_LOGI(TAG, "Wallet[%d] initialized: url=%s balance=%d proofs=%d keysets=%d", |
| 48 | s_wallet->balance(), (int)s_wallet->proofs().size(), | 69 | slot, mint_url, s_wallets[slot]->balance(), |
| 49 | (int)s_wallet->keysets().size()); | 70 | (int)s_wallets[slot]->proofs().size(), |
| 71 | (int)s_wallets[slot]->keysets().size()); | ||
| 50 | return ESP_OK; | 72 | return ESP_OK; |
| 51 | } | 73 | } |
| 52 | 74 | ||
| 75 | esp_err_t nucula_wallet_init(const char *mint_url) | ||
| 76 | { | ||
| 77 | if (s_wallet_count > 0) return ESP_OK; | ||
| 78 | |||
| 79 | if (!s_ctx) { | ||
| 80 | s_ctx = secp256k1_context_create(SECP256K1_CONTEXT_SIGN | SECP256K1_CONTEXT_VERIFY); | ||
| 81 | if (!s_ctx) { | ||
| 82 | ESP_LOGE(TAG, "Failed to create secp256k1 context"); | ||
| 83 | return ESP_FAIL; | ||
| 84 | } | ||
| 85 | } | ||
| 86 | |||
| 87 | esp_err_t ret = init_wallet(0, mint_url); | ||
| 88 | if (ret == ESP_OK) s_wallet_count = 1; | ||
| 89 | return ret; | ||
| 90 | } | ||
| 91 | |||
| 92 | esp_err_t nucula_wallet_init_multi(const char mint_urls[][256], int count) | ||
| 93 | { | ||
| 94 | if (s_wallet_count > 0) return ESP_OK; | ||
| 95 | if (count > MAX_WALLETS) count = MAX_WALLETS; | ||
| 96 | |||
| 97 | if (!s_ctx) { | ||
| 98 | s_ctx = secp256k1_context_create(SECP256K1_CONTEXT_SIGN | SECP256K1_CONTEXT_VERIFY); | ||
| 99 | if (!s_ctx) { | ||
| 100 | ESP_LOGE(TAG, "Failed to create secp256k1 context"); | ||
| 101 | return ESP_FAIL; | ||
| 102 | } | ||
| 103 | } | ||
| 104 | |||
| 105 | int ok = 0; | ||
| 106 | for (int i = 0; i < count; i++) { | ||
| 107 | if (init_wallet(i, mint_urls[i]) == ESP_OK) { | ||
| 108 | ok++; | ||
| 109 | } | ||
| 110 | } | ||
| 111 | |||
| 112 | s_wallet_count = count; | ||
| 113 | ESP_LOGI(TAG, "Multi-wallet initialized: %d/%d wallets", ok, count); | ||
| 114 | return ok > 0 ? ESP_OK : ESP_FAIL; | ||
| 115 | } | ||
| 116 | |||
| 53 | esp_err_t nucula_wallet_receive(const char *token_str) | 117 | esp_err_t nucula_wallet_receive(const char *token_str) |
| 54 | { | 118 | { |
| 55 | if (!s_wallet || !token_str) return ESP_FAIL; | 119 | if (s_wallet_count == 0 || !token_str) return ESP_FAIL; |
| 56 | 120 | ||
| 57 | cashu::Token tok; | 121 | cashu::Token tok; |
| 58 | bool decoded = false; | 122 | bool decoded = false; |
| @@ -66,38 +130,47 @@ esp_err_t nucula_wallet_receive(const char *token_str) | |||
| 66 | return ESP_FAIL; | 130 | return ESP_FAIL; |
| 67 | } | 131 | } |
| 68 | 132 | ||
| 133 | cashu::Wallet *w = find_wallet_for_token(tok); | ||
| 134 | if (!w) { | ||
| 135 | ESP_LOGE(TAG, "No wallet found for mint: %s", tok.mint.c_str()); | ||
| 136 | return ESP_FAIL; | ||
| 137 | } | ||
| 138 | |||
| 69 | std::vector<cashu::Proof> proofs_out; | 139 | std::vector<cashu::Proof> proofs_out; |
| 70 | if (!s_wallet->receive(tok, proofs_out)) { | 140 | if (!w->receive(tok, proofs_out)) { |
| 71 | ESP_LOGE(TAG, "Receive failed"); | 141 | ESP_LOGE(TAG, "Receive failed"); |
| 72 | return ESP_FAIL; | 142 | return ESP_FAIL; |
| 73 | } | 143 | } |
| 74 | 144 | ||
| 75 | int total = 0; | 145 | int total = 0; |
| 76 | for (const auto &p : proofs_out) total += p.amount; | 146 | for (const auto &p : proofs_out) total += p.amount; |
| 77 | ESP_LOGI(TAG, "Received %d sat (%d proofs), new balance=%d", | 147 | ESP_LOGI(TAG, "Received %d sat (%d proofs) via wallet[%s], new balance=%d", |
| 78 | total, (int)proofs_out.size(), s_wallet->balance()); | 148 | total, (int)proofs_out.size(), w->mint_url().c_str(), w->balance()); |
| 79 | return ESP_OK; | 149 | return ESP_OK; |
| 80 | } | 150 | } |
| 81 | 151 | ||
| 82 | esp_err_t nucula_wallet_send(uint64_t amount_sat, char *token_out, size_t token_out_size) | 152 | esp_err_t nucula_wallet_send(uint64_t amount_sat, char *token_out, size_t token_out_size) |
| 83 | { | 153 | { |
| 84 | if (!s_wallet) return ESP_FAIL; | 154 | if (s_wallet_count == 0) return ESP_FAIL; |
| 85 | 155 | ||
| 86 | int amount = (int)amount_sat; | 156 | int amount = (int)amount_sat; |
| 157 | cashu::Wallet *w = find_wallet_for_send(amount); | ||
| 158 | if (!w) return ESP_FAIL; | ||
| 159 | |||
| 87 | std::vector<cashu::Proof> selected, remaining; | 160 | std::vector<cashu::Proof> selected, remaining; |
| 88 | if (!s_wallet->select_proofs(amount, selected, remaining)) { | 161 | if (!w->select_proofs(amount, selected, remaining)) { |
| 89 | ESP_LOGE(TAG, "Insufficient balance for %d sat", amount); | 162 | ESP_LOGE(TAG, "Insufficient balance for %d sat", amount); |
| 90 | return ESP_FAIL; | 163 | return ESP_FAIL; |
| 91 | } | 164 | } |
| 92 | 165 | ||
| 93 | std::vector<cashu::Proof> new_proofs, change; | 166 | std::vector<cashu::Proof> new_proofs, change; |
| 94 | if (!s_wallet->swap(selected, (int)amount_sat, new_proofs, change)) { | 167 | if (!w->swap(selected, (int)amount_sat, new_proofs, change)) { |
| 95 | ESP_LOGE(TAG, "Swap for send failed"); | 168 | ESP_LOGE(TAG, "Swap for send failed"); |
| 96 | return ESP_FAIL; | 169 | return ESP_FAIL; |
| 97 | } | 170 | } |
| 98 | 171 | ||
| 99 | cashu::Token token; | 172 | cashu::Token token; |
| 100 | token.mint = s_wallet->mint_url(); | 173 | token.mint = w->mint_url(); |
| 101 | token.unit = "sat"; | 174 | token.unit = "sat"; |
| 102 | for (auto &p : new_proofs) token.proofs.push_back(p); | 175 | for (auto &p : new_proofs) token.proofs.push_back(p); |
| 103 | 176 | ||
| @@ -114,39 +187,48 @@ esp_err_t nucula_wallet_send(uint64_t amount_sat, char *token_out, size_t token_ | |||
| 114 | 187 | ||
| 115 | memcpy(token_out, encoded.c_str(), encoded.size() + 1); | 188 | memcpy(token_out, encoded.c_str(), encoded.size() + 1); |
| 116 | 189 | ||
| 117 | auto &proofs = mutable_proofs(); | 190 | auto &proofs = mutable_proofs(w); |
| 118 | proofs = remaining; | 191 | proofs = remaining; |
| 119 | for (auto &p : change) proofs.push_back(p); | 192 | for (auto &p : change) proofs.push_back(p); |
| 120 | s_wallet->save_proofs(); | 193 | w->save_proofs(); |
| 121 | 194 | ||
| 122 | ESP_LOGI(TAG, "Sent %llu sat, token=%zu bytes, remaining balance=%d", | 195 | ESP_LOGI(TAG, "Sent %llu sat via wallet[%s], token=%zu bytes, remaining balance=%d", |
| 123 | (unsigned long long)amount_sat, encoded.size(), s_wallet->balance()); | 196 | (unsigned long long)amount_sat, w->mint_url().c_str(), |
| 197 | encoded.size(), w->balance()); | ||
| 124 | return ESP_OK; | 198 | return ESP_OK; |
| 125 | } | 199 | } |
| 126 | 200 | ||
| 127 | uint64_t nucula_wallet_balance(void) | 201 | uint64_t nucula_wallet_balance(void) |
| 128 | { | 202 | { |
| 129 | if (!s_wallet) return 0; | 203 | uint64_t total = 0; |
| 130 | return (uint64_t)s_wallet->balance(); | 204 | for (int i = 0; i < s_wallet_count; i++) { |
| 205 | if (s_wallets[i]) total += (uint64_t)s_wallets[i]->balance(); | ||
| 206 | } | ||
| 207 | return total; | ||
| 131 | } | 208 | } |
| 132 | 209 | ||
| 133 | int nucula_wallet_proof_count(void) | 210 | int nucula_wallet_proof_count(void) |
| 134 | { | 211 | { |
| 135 | if (!s_wallet) return 0; | 212 | int total = 0; |
| 136 | return (int)s_wallet->proofs().size(); | 213 | for (int i = 0; i < s_wallet_count; i++) { |
| 214 | if (s_wallets[i]) total += (int)s_wallets[i]->proofs().size(); | ||
| 215 | } | ||
| 216 | return total; | ||
| 137 | } | 217 | } |
| 138 | 218 | ||
| 139 | char *nucula_wallet_proofs_json(void) | 219 | char *nucula_wallet_proofs_json(void) |
| 140 | { | 220 | { |
| 141 | if (!s_wallet) return nullptr; | ||
| 142 | |||
| 143 | const auto &proofs = s_wallet->proofs(); | ||
| 144 | cJSON *arr = cJSON_CreateArray(); | 221 | cJSON *arr = cJSON_CreateArray(); |
| 145 | for (const auto &p : proofs) { | 222 | for (int i = 0; i < s_wallet_count; i++) { |
| 146 | cJSON *obj = cJSON_CreateObject(); | 223 | if (!s_wallets[i]) continue; |
| 147 | cJSON_AddNumberToObject(obj, "amount", p.amount); | 224 | const auto &proofs = s_wallets[i]->proofs(); |
| 148 | cJSON_AddStringToObject(obj, "id", p.id.c_str()); | 225 | for (const auto &p : proofs) { |
| 149 | cJSON_AddItemToArray(arr, obj); | 226 | cJSON *obj = cJSON_CreateObject(); |
| 227 | cJSON_AddNumberToObject(obj, "amount", p.amount); | ||
| 228 | cJSON_AddStringToObject(obj, "id", p.id.c_str()); | ||
| 229 | cJSON_AddStringToObject(obj, "mint", s_wallet_urls[i]); | ||
| 230 | cJSON_AddItemToArray(arr, obj); | ||
| 231 | } | ||
| 150 | } | 232 | } |
| 151 | char *json = cJSON_PrintUnformatted(arr); | 233 | char *json = cJSON_PrintUnformatted(arr); |
| 152 | cJSON_Delete(arr); | 234 | cJSON_Delete(arr); |
| @@ -155,55 +237,72 @@ char *nucula_wallet_proofs_json(void) | |||
| 155 | 237 | ||
| 156 | esp_err_t nucula_wallet_swap_all(void) | 238 | esp_err_t nucula_wallet_swap_all(void) |
| 157 | { | 239 | { |
| 158 | if (!s_wallet) return ESP_FAIL; | 240 | if (s_wallet_count == 0) return ESP_FAIL; |
| 159 | 241 | ||
| 160 | auto &proofs = mutable_proofs(); | 242 | bool any_ok = false; |
| 161 | if (proofs.empty()) { | 243 | for (int i = 0; i < s_wallet_count; i++) { |
| 162 | ESP_LOGW(TAG, "No proofs to swap"); | 244 | if (!s_wallets[i]) continue; |
| 163 | return ESP_FAIL; | ||
| 164 | } | ||
| 165 | 245 | ||
| 166 | int old_balance = s_wallet->balance(); | 246 | auto &proofs = mutable_proofs(s_wallets[i]); |
| 247 | if (proofs.empty()) continue; | ||
| 167 | 248 | ||
| 168 | std::vector<cashu::Proof> inputs = proofs; | 249 | int old_balance = s_wallets[i]->balance(); |
| 169 | std::vector<cashu::Proof> new_proofs, change; | ||
| 170 | if (!s_wallet->swap(inputs, -1, new_proofs, change)) { | ||
| 171 | ESP_LOGE(TAG, "Swap failed"); | ||
| 172 | return ESP_FAIL; | ||
| 173 | } | ||
| 174 | 250 | ||
| 175 | proofs.clear(); | 251 | std::vector<cashu::Proof> inputs = proofs; |
| 176 | for (auto &p : new_proofs) proofs.push_back(p); | 252 | std::vector<cashu::Proof> new_proofs, change; |
| 177 | for (auto &p : change) proofs.push_back(p); | 253 | if (!s_wallets[i]->swap(inputs, -1, new_proofs, change)) { |
| 178 | s_wallet->save_proofs(); | 254 | ESP_LOGE(TAG, "Swap failed for wallet[%d]", i); |
| 255 | continue; | ||
| 256 | } | ||
| 179 | 257 | ||
| 180 | ESP_LOGI(TAG, "Swap complete: %d -> %d sat (%d proofs)", | 258 | proofs.clear(); |
| 181 | old_balance, s_wallet->balance(), (int)proofs.size()); | 259 | for (auto &p : new_proofs) proofs.push_back(p); |
| 182 | return ESP_OK; | 260 | for (auto &p : change) proofs.push_back(p); |
| 261 | s_wallets[i]->save_proofs(); | ||
| 262 | |||
| 263 | ESP_LOGI(TAG, "Swap wallet[%d]: %d -> %d sat (%d proofs)", | ||
| 264 | i, old_balance, s_wallets[i]->balance(), (int)proofs.size()); | ||
| 265 | any_ok = true; | ||
| 266 | } | ||
| 267 | |||
| 268 | return any_ok ? ESP_OK : ESP_FAIL; | ||
| 183 | } | 269 | } |
| 184 | 270 | ||
| 185 | void nucula_wallet_print_status(void) | 271 | void nucula_wallet_print_status(void) |
| 186 | { | 272 | { |
| 187 | if (!s_wallet) { | 273 | if (s_wallet_count == 0) { |
| 188 | ESP_LOGI(TAG, "Wallet not initialized"); | 274 | ESP_LOGI(TAG, "No wallets initialized"); |
| 189 | return; | 275 | return; |
| 190 | } | 276 | } |
| 191 | ESP_LOGI(TAG, "Wallet: balance=%d proofs=%d keysets=%d", | 277 | for (int i = 0; i < s_wallet_count; i++) { |
| 192 | s_wallet->balance(), (int)s_wallet->proofs().size(), | 278 | if (!s_wallets[i]) continue; |
| 193 | (int)s_wallet->keysets().size()); | 279 | ESP_LOGI(TAG, "Wallet[%d] %s: balance=%d proofs=%d keysets=%d", |
| 194 | const auto &proofs = s_wallet->proofs(); | 280 | i, s_wallet_urls[i], |
| 195 | for (size_t i = 0; i < proofs.size(); i++) { | 281 | s_wallets[i]->balance(), (int)s_wallets[i]->proofs().size(), |
| 196 | ESP_LOGI(TAG, " [%d] amount=%d id=%s", (int)i, | 282 | (int)s_wallets[i]->keysets().size()); |
| 197 | proofs[i].amount, proofs[i].id.c_str()); | 283 | const auto &proofs = s_wallets[i]->proofs(); |
| 284 | for (size_t j = 0; j < proofs.size() && j < 10; j++) { | ||
| 285 | ESP_LOGI(TAG, " [%d][%d] amount=%d id=%s", (int)i, (int)j, | ||
| 286 | proofs[j].amount, proofs[j].id.c_str()); | ||
| 287 | } | ||
| 198 | } | 288 | } |
| 199 | } | 289 | } |
| 200 | 290 | ||
| 201 | esp_err_t nucula_wallet_melt(const char *bolt11_invoice, uint64_t max_fee_sats) | 291 | esp_err_t nucula_wallet_melt(const char *bolt11_invoice, uint64_t max_fee_sats) |
| 202 | { | 292 | { |
| 203 | if (!s_wallet || !bolt11_invoice) return ESP_FAIL; | 293 | if (s_wallet_count == 0 || !bolt11_invoice) return ESP_FAIL; |
| 294 | |||
| 295 | cashu::Wallet *w = nullptr; | ||
| 296 | for (int i = 0; i < s_wallet_count; i++) { | ||
| 297 | if (s_wallets[i] && s_wallets[i]->balance() > 0) { | ||
| 298 | w = s_wallets[i]; | ||
| 299 | break; | ||
| 300 | } | ||
| 301 | } | ||
| 302 | if (!w) return ESP_FAIL; | ||
| 204 | 303 | ||
| 205 | cashu::MeltQuote quote; | 304 | cashu::MeltQuote quote; |
| 206 | if (!s_wallet->request_melt_quote(std::string(bolt11_invoice), quote)) { | 305 | if (!w->request_melt_quote(std::string(bolt11_invoice), quote)) { |
| 207 | ESP_LOGE(TAG, "Melt quote request failed"); | 306 | ESP_LOGE(TAG, "Melt quote request failed"); |
| 208 | return ESP_FAIL; | 307 | return ESP_FAIL; |
| 209 | } | 308 | } |
| @@ -216,19 +315,20 @@ esp_err_t nucula_wallet_melt(const char *bolt11_invoice, uint64_t max_fee_sats) | |||
| 216 | return ESP_FAIL; | 315 | return ESP_FAIL; |
| 217 | } | 316 | } |
| 218 | 317 | ||
| 219 | int balance_before = s_wallet->balance(); | 318 | int balance_before = w->balance(); |
| 220 | if (balance_before < quote.amount) { | 319 | if (balance_before < quote.amount) { |
| 221 | ESP_LOGE(TAG, "Insufficient balance: %d < %d", balance_before, quote.amount); | 320 | ESP_LOGE(TAG, "Insufficient balance: %d < %d", balance_before, quote.amount); |
| 222 | return ESP_FAIL; | 321 | return ESP_FAIL; |
| 223 | } | 322 | } |
| 224 | 323 | ||
| 225 | int change_amount = 0; | 324 | int change_amount = 0; |
| 226 | if (!s_wallet->melt_tokens(quote, change_amount)) { | 325 | if (!w->melt_tokens(quote, change_amount)) { |
| 227 | ESP_LOGE(TAG, "Melt tokens failed"); | 326 | ESP_LOGE(TAG, "Melt tokens failed"); |
| 228 | return ESP_FAIL; | 327 | return ESP_FAIL; |
| 229 | } | 328 | } |
| 230 | 329 | ||
| 231 | ESP_LOGI(TAG, "Melted: %d sats paid, %d change, balance=%d->%d", | 330 | ESP_LOGI(TAG, "Melted via wallet[%s]: %d sats paid, %d change, balance=%d->%d", |
| 232 | quote.amount, change_amount, balance_before, s_wallet->balance()); | 331 | w->mint_url().c_str(), quote.amount, change_amount, |
| 332 | balance_before, w->balance()); | ||
| 233 | return ESP_OK; | 333 | return ESP_OK; |
| 234 | } | 334 | } |
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" { | |||
| 9 | #endif | 9 | #endif |
| 10 | 10 | ||
| 11 | esp_err_t nucula_wallet_init(const char *mint_url); | 11 | esp_err_t nucula_wallet_init(const char *mint_url); |
| 12 | esp_err_t nucula_wallet_init_multi(const char mint_urls[][256], int count); | ||
| 12 | 13 | ||
| 13 | esp_err_t nucula_wallet_receive(const char *token_str); | 14 | esp_err_t nucula_wallet_receive(const char *token_str); |
| 14 | 15 | ||
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" | |||
| 15 | "lightning_payout.c" | 15 | "lightning_payout.c" |
| 16 | "nip04.c" | 16 | "nip04.c" |
| 17 | "mcp_handler.c" | 17 | "mcp_handler.c" |
| 18 | "cvm_server.c" | 18 | "cvm_server.c" |
| 19 | "display.c" | 19 | "mint_health.c" |
| 20 | "display.c" | ||
| 20 | "font.c" | 21 | "font.c" |
| 21 | INCLUDE_DIRS "." | 22 | INCLUDE_DIRS "." |
| 22 | REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server | 23 | 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 @@ | |||
| 2 | #include "firewall.h" | 2 | #include "firewall.h" |
| 3 | #include "session.h" | 3 | #include "session.h" |
| 4 | #include "config.h" | 4 | #include "config.h" |
| 5 | #include "mint_health.h" | ||
| 5 | #include "esp_log.h" | 6 | #include "esp_log.h" |
| 6 | #include "esp_wifi.h" | 7 | #include "esp_wifi.h" |
| 7 | #include "cJSON.h" | 8 | #include "cJSON.h" |
| @@ -42,9 +43,14 @@ static const char PORTAL_HTML_TEMPLATE[] = \ | |||
| 42 | ".btn:disabled{background:#333;color:#666;cursor:not-allowed}" | 43 | ".btn:disabled{background:#333;color:#666;cursor:not-allowed}" |
| 43 | ".mints{background:#252525;border-radius:12px;padding:12px;margin-top:16px;text-align:left}" | 44 | ".mints{background:#252525;border-radius:12px;padding:12px;margin-top:16px;text-align:left}" |
| 44 | ".mints-title{color:#888;font-size:12px;margin-bottom:8px}" | 45 | ".mints-title{color:#888;font-size:12px;margin-bottom:8px}" |
| 45 | ".mint-url{font-family:monospace;font-size:11px;color:#f7931a;word-break:break-all;" | 46 | ".mint-item{display:flex;align-items:center;padding:6px 8px;margin-bottom:4px;" |
| 46 | "background:#1a1a1a;padding:8px;border-radius:6px;cursor:pointer}" | 47 | "background:#1a1a1a;border-radius:6px;cursor:pointer}" |
| 47 | ".mint-url:active{opacity:0.7}" | 48 | ".mint-item:active{opacity:0.7}" |
| 49 | ".mint-dot{width:8px;height:8px;border-radius:50%;margin-right:8px;flex-shrink:0}" | ||
| 50 | ".mint-dot.green{background:#4caf50}" | ||
| 51 | ".mint-dot.grey{background:#666}" | ||
| 52 | ".mint-url{font-family:monospace;font-size:11px;color:#f7931a;word-break:break-all}" | ||
| 53 | ".mint-url.dim{color:#666}" | ||
| 48 | ".mint-hint{color:#666;font-size:10px;margin-top:4px}" | 54 | ".mint-hint{color:#666;font-size:10px;margin-top:4px}" |
| 49 | "#status{margin-top:16px;padding:12px;border-radius:8px;display:none;font-size:14px}" | 55 | "#status{margin-top:16px;padding:12px;border-radius:8px;display:none;font-size:14px}" |
| 50 | "#status.success{display:block;background:#1a472a;color:#4caf50}" | 56 | "#status.success{display:block;background:#1a472a;color:#4caf50}" |
| @@ -63,20 +69,21 @@ static const char PORTAL_HTML_TEMPLATE[] = \ | |||
| 63 | "<button class='btn' id='payBtn' onclick='payToken()'>Pay & Connect</button>" | 69 | "<button class='btn' id='payBtn' onclick='payToken()'>Pay & Connect</button>" |
| 64 | "<div class='mints'>" | 70 | "<div class='mints'>" |
| 65 | "<div class='mints-title'>SUPPORTED MINTS</div>" | 71 | "<div class='mints-title'>SUPPORTED MINTS</div>" |
| 66 | "<div class='mint-url' id='mintUrl' onclick='copyMint()'>__MINT_URL__</div>" | 72 | "<div id='mintList'>__MINT_LIST__</div>" |
| 67 | "<div class='mint-hint'>Tap to copy • Mint tokens at this URL before paying</div>" | 73 | "<div class='mint-hint'>Tap to copy • Green = reachable</div>" |
| 68 | "</div>" | 74 | "</div>" |
| 69 | "<div id='status'></div>" | 75 | "<div id='status'></div>" |
| 70 | "</div>" | 76 | "</div>" |
| 71 | "<script>" | 77 | "<script>" |
| 72 | "const mintUrlEl=document.getElementById('mintUrl');" | 78 | "const mintListEl=document.getElementById('mintList');" |
| 73 | "const mintUrl=mintUrlEl.textContent;" | ||
| 74 | "const statusEl=document.getElementById('status');" | 79 | "const statusEl=document.getElementById('status');" |
| 75 | "const payBtn=document.getElementById('payBtn');" | 80 | "const payBtn=document.getElementById('payBtn');" |
| 76 | "const tokenInput=document.getElementById('tokenInput');" | 81 | "const tokenInput=document.getElementById('tokenInput');" |
| 77 | "function copyMint(){" | 82 | "function copyMint(url){" |
| 78 | "if(navigator.clipboard){navigator.clipboard.writeText(mintUrl);" | 83 | "if(navigator.clipboard){navigator.clipboard.writeText(url);" |
| 79 | "mintUrlEl.textContent='Copied!';setTimeout(()=>{mintUrlEl.textContent=mintUrl;},1000);}" | 84 | "const el=event.currentTarget;const u=el.querySelector('.mint-url');" |
| 85 | "const orig=u.textContent;u.textContent='Copied!';" | ||
| 86 | "setTimeout(()=>{u.textContent=orig;},1000);}" | ||
| 80 | "}" | 87 | "}" |
| 81 | "function showStatus(msg,type){statusEl.textContent=msg;statusEl.className=type;}" | 88 | "function showStatus(msg,type){statusEl.textContent=msg;statusEl.className=type;}" |
| 82 | "function payToken(){" | 89 | "function payToken(){" |
| @@ -93,6 +100,20 @@ static const char PORTAL_HTML_TEMPLATE[] = \ | |||
| 93 | "else if(d.kind===21023){showStatus('Error: '+(d.content||'Unknown error'),'error');payBtn.disabled=false;}" | 100 | "else if(d.kind===21023){showStatus('Error: '+(d.content||'Unknown error'),'error');payBtn.disabled=false;}" |
| 94 | "}).catch(e=>{showStatus(e.message||'Connection error','error');payBtn.disabled=false;});" | 101 | "}).catch(e=>{showStatus(e.message||'Connection error','error');payBtn.disabled=false;});" |
| 95 | "}" | 102 | "}" |
| 103 | "function refreshMints(){" | ||
| 104 | "fetch('http://__AP_IP__:2121/mints').then(r=>r.json()).then(data=>{" | ||
| 105 | "let html='';" | ||
| 106 | "for(const m of data){" | ||
| 107 | "const cls=m.reachable?'green':'grey';" | ||
| 108 | "const urlCls=m.reachable?'mint-url':'mint-url dim';" | ||
| 109 | "html+='<div class=\"mint-item\" onclick=\"copyMint(\\''+m.url+'\\')\">';" | ||
| 110 | "html+='<span class=\"mint-dot '+cls+'\"></span>';" | ||
| 111 | "html+='<span class=\"'+urlCls+'\">'+m.url+'</span></div>';" | ||
| 112 | "}" | ||
| 113 | "if(html)mintListEl.innerHTML=html;" | ||
| 114 | "}).catch(()=>{});" | ||
| 115 | "}" | ||
| 116 | "setInterval(refreshMints,30000);" | ||
| 96 | "</script>" | 117 | "</script>" |
| 97 | "</body></html>"; | 118 | "</body></html>"; |
| 98 | 119 | ||
| @@ -122,10 +143,32 @@ static esp_err_t portal_handler(httpd_req_t *req) | |||
| 122 | const char *tpl = PORTAL_HTML_TEMPLATE; | 143 | const char *tpl = PORTAL_HTML_TEMPLATE; |
| 123 | size_t tpl_len = strlen(tpl); | 144 | size_t tpl_len = strlen(tpl); |
| 124 | 145 | ||
| 146 | char mint_list_html[4096]; | ||
| 147 | mint_list_html[0] = '\0'; | ||
| 148 | int mint_count = 0; | ||
| 149 | const mint_status_t *mints = mint_health_get_all(&mint_count); | ||
| 150 | for (int i = 0; i < mint_count; i++) { | ||
| 151 | const char *cls = mints[i].reachable ? "green" : "grey"; | ||
| 152 | const char *url_cls = mints[i].reachable ? "mint-url" : "mint-url dim"; | ||
| 153 | char item[512]; | ||
| 154 | snprintf(item, sizeof(item), | ||
| 155 | "<div class='mint-item' onclick='copyMint(\"%s\")'>" | ||
| 156 | "<span class='mint-dot %s'></span>" | ||
| 157 | "<span class='%s'>%s</span></div>", | ||
| 158 | mints[i].url, cls, url_cls, mints[i].url); | ||
| 159 | strncat(mint_list_html, item, sizeof(mint_list_html) - strlen(mint_list_html) - 1); | ||
| 160 | } | ||
| 161 | if (mint_count == 0) { | ||
| 162 | const tollgate_config_t *cfg = tollgate_config_get(); | ||
| 163 | snprintf(mint_list_html, sizeof(mint_list_html), | ||
| 164 | "<div class='mint-item'><span class='mint-dot grey'></span>" | ||
| 165 | "<span class='mint-url dim'>%s</span></div>", cfg->mint_url); | ||
| 166 | } | ||
| 167 | |||
| 125 | struct { const char *key; const char *val; } subs[] = { | 168 | struct { const char *key; const char *val; } subs[] = { |
| 126 | { "__AP_IP__", s_ap_ip_str }, | 169 | { "__AP_IP__", s_ap_ip_str }, |
| 127 | { "__PRICE__", price_str }, | 170 | { "__PRICE__", price_str }, |
| 128 | { "__MINT_URL__", cfg->mint_url }, | 171 | { "__MINT_LIST__", mint_list_html }, |
| 129 | }; | 172 | }; |
| 130 | int nsubs = sizeof(subs) / sizeof(subs[0]); | 173 | int nsubs = sizeof(subs) / sizeof(subs[0]); |
| 131 | 174 | ||
| @@ -190,6 +233,25 @@ static esp_err_t grant_access_handler(httpd_req_t *req) | |||
| 190 | return ESP_OK; | 233 | return ESP_OK; |
| 191 | } | 234 | } |
| 192 | 235 | ||
| 236 | static esp_err_t mints_handler(httpd_req_t *req) | ||
| 237 | { | ||
| 238 | int mint_count = 0; | ||
| 239 | const mint_status_t *mints = mint_health_get_all(&mint_count); | ||
| 240 | cJSON *arr = cJSON_CreateArray(); | ||
| 241 | for (int i = 0; i < mint_count; i++) { | ||
| 242 | cJSON *obj = cJSON_CreateObject(); | ||
| 243 | cJSON_AddStringToObject(obj, "url", mints[i].url); | ||
| 244 | cJSON_AddBoolToObject(obj, "reachable", mints[i].reachable); | ||
| 245 | cJSON_AddItemToArray(arr, obj); | ||
| 246 | } | ||
| 247 | char *json = cJSON_PrintUnformatted(arr); | ||
| 248 | httpd_resp_set_type(req, "application/json"); | ||
| 249 | httpd_resp_send(req, json, strlen(json)); | ||
| 250 | cJSON_free(json); | ||
| 251 | cJSON_Delete(arr); | ||
| 252 | return ESP_OK; | ||
| 253 | } | ||
| 254 | |||
| 193 | static esp_err_t status_handler(httpd_req_t *req) | 255 | static esp_err_t status_handler(httpd_req_t *req) |
| 194 | { | 256 | { |
| 195 | const tollgate_config_t *cfg = tollgate_config_get(); | 257 | const tollgate_config_t *cfg = tollgate_config_get(); |
| @@ -290,6 +352,7 @@ static esp_err_t catchall_handler(httpd_req_t *req) | |||
| 290 | 352 | ||
| 291 | static const httpd_uri_t uri_portal = { .uri = "/", .method = HTTP_GET, .handler = portal_handler }; | 353 | static const httpd_uri_t uri_portal = { .uri = "/", .method = HTTP_GET, .handler = portal_handler }; |
| 292 | static const httpd_uri_t uri_grant = { .uri = "/grant_access", .method = HTTP_GET, .handler = grant_access_handler }; | 354 | static const httpd_uri_t uri_grant = { .uri = "/grant_access", .method = HTTP_GET, .handler = grant_access_handler }; |
| 355 | static const httpd_uri_t uri_mints = { .uri = "/mints", .method = HTTP_GET, .handler = mints_handler }; | ||
| 293 | static const httpd_uri_t uri_status = { .uri = "/api/status", .method = HTTP_GET, .handler = status_handler }; | 356 | static const httpd_uri_t uri_status = { .uri = "/api/status", .method = HTTP_GET, .handler = status_handler }; |
| 294 | static const httpd_uri_t uri_whoami = { .uri = "/whoami", .method = HTTP_GET, .handler = whoami_handler }; | 357 | static const httpd_uri_t uri_whoami = { .uri = "/whoami", .method = HTTP_GET, .handler = whoami_handler }; |
| 295 | static const httpd_uri_t uri_usage = { .uri = "/usage", .method = HTTP_GET, .handler = usage_handler }; | 358 | 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) | |||
| 320 | 383 | ||
| 321 | httpd_register_uri_handler(s_server, &uri_portal); | 384 | httpd_register_uri_handler(s_server, &uri_portal); |
| 322 | httpd_register_uri_handler(s_server, &uri_grant); | 385 | httpd_register_uri_handler(s_server, &uri_grant); |
| 386 | httpd_register_uri_handler(s_server, &uri_mints); | ||
| 323 | httpd_register_uri_handler(s_server, &uri_status); | 387 | httpd_register_uri_handler(s_server, &uri_status); |
| 324 | httpd_register_uri_handler(s_server, &uri_whoami); | 388 | httpd_register_uri_handler(s_server, &uri_whoami); |
| 325 | httpd_register_uri_handler(s_server, &uri_usage); | 389 | 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 @@ | |||
| 1 | #include "cashu.h" | 1 | #include "cashu.h" |
| 2 | #include "config.h" | 2 | #include "config.h" |
| 3 | #include "mint_health.h" | ||
| 3 | #include "esp_log.h" | 4 | #include "esp_log.h" |
| 4 | #include "esp_http_client.h" | 5 | #include "esp_http_client.h" |
| 5 | #include "cJSON.h" | 6 | #include "cJSON.h" |
| @@ -268,8 +269,10 @@ bool cashu_is_mint_accepted(const char *mint_url) | |||
| 268 | if (!mint_url || mint_url[0] == '\0') return false; | 269 | if (!mint_url || mint_url[0] == '\0') return false; |
| 269 | const tollgate_config_t *cfg = tollgate_config_get(); | 270 | const tollgate_config_t *cfg = tollgate_config_get(); |
| 270 | for (int i = 0; i < cfg->accepted_mint_count; i++) { | 271 | for (int i = 0; i < cfg->accepted_mint_count; i++) { |
| 271 | if (strstr(mint_url, cfg->accepted_mints[i]) != NULL) return true; | 272 | if (strstr(mint_url, cfg->accepted_mints[i]) != NULL || |
| 272 | if (strcmp(mint_url, cfg->accepted_mints[i]) == 0) return true; | 273 | strcmp(mint_url, cfg->accepted_mints[i]) == 0) { |
| 274 | return mint_health_is_reachable(mint_url); | ||
| 275 | } | ||
| 273 | } | 276 | } |
| 274 | return false; | 277 | return false; |
| 275 | } | 278 | } |
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 @@ | |||
| 1 | #include "mint_health.h" | ||
| 2 | #include "esp_log.h" | ||
| 3 | #include "esp_http_client.h" | ||
| 4 | #include "esp_crt_bundle.h" | ||
| 5 | #include "freertos/FreeRTOS.h" | ||
| 6 | #include "freertos/task.h" | ||
| 7 | #include "freertos/semphr.h" | ||
| 8 | #include <string.h> | ||
| 9 | #include <stdlib.h> | ||
| 10 | |||
| 11 | static const char *TAG = "mint_health"; | ||
| 12 | |||
| 13 | static mint_status_t s_mints[MINT_HEALTH_MAX]; | ||
| 14 | static int s_mint_count = 0; | ||
| 15 | static bool s_running = false; | ||
| 16 | static TaskHandle_t s_task_handle = NULL; | ||
| 17 | static SemaphoreHandle_t s_mutex = NULL; | ||
| 18 | |||
| 19 | #define MAX_CALLBACKS 4 | ||
| 20 | static mint_health_changed_cb s_callbacks[MAX_CALLBACKS]; | ||
| 21 | static int s_callback_count = 0; | ||
| 22 | |||
| 23 | static void fire_callbacks(void) | ||
| 24 | { | ||
| 25 | for (int i = 0; i < s_callback_count; i++) { | ||
| 26 | if (s_callbacks[i]) s_callbacks[i](); | ||
| 27 | } | ||
| 28 | } | ||
| 29 | |||
| 30 | esp_err_t mint_health_init(const char urls[][256], int count) | ||
| 31 | { | ||
| 32 | if (count > MINT_HEALTH_MAX) count = MINT_HEALTH_MAX; | ||
| 33 | s_mint_count = count; | ||
| 34 | s_callback_count = 0; | ||
| 35 | |||
| 36 | if (!s_mutex) s_mutex = xSemaphoreCreateMutex(); | ||
| 37 | |||
| 38 | memset(s_mints, 0, sizeof(s_mints)); | ||
| 39 | for (int i = 0; i < count; i++) { | ||
| 40 | strncpy(s_mints[i].url, urls[i], sizeof(s_mints[i].url) - 1); | ||
| 41 | s_mints[i].reachable = false; | ||
| 42 | s_mints[i].consecutive_successes = 0; | ||
| 43 | s_mints[i].last_probe_ms = 0; | ||
| 44 | s_mints[i].last_http_status = 0; | ||
| 45 | } | ||
| 46 | |||
| 47 | ESP_LOGI(TAG, "Initialized with %d mints", count); | ||
| 48 | return ESP_OK; | ||
| 49 | } | ||
| 50 | |||
| 51 | static bool probe_mint(const char *url) | ||
| 52 | { | ||
| 53 | char probe_url[512]; | ||
| 54 | snprintf(probe_url, sizeof(probe_url), "%s/v1/info", url); | ||
| 55 | |||
| 56 | esp_http_client_config_t config = { | ||
| 57 | .url = probe_url, | ||
| 58 | .method = HTTP_METHOD_GET, | ||
| 59 | .timeout_ms = MINT_HEALTH_PROBE_TIMEOUT_MS, | ||
| 60 | .crt_bundle_attach = esp_crt_bundle_attach, | ||
| 61 | }; | ||
| 62 | esp_http_client_handle_t client = esp_http_client_init(&config); | ||
| 63 | if (!client) return false; | ||
| 64 | |||
| 65 | esp_err_t err = esp_http_client_open(client, 0); | ||
| 66 | if (err != ESP_OK) { | ||
| 67 | esp_http_client_cleanup(client); | ||
| 68 | return false; | ||
| 69 | } | ||
| 70 | |||
| 71 | int content_length = esp_http_client_fetch_headers(client); | ||
| 72 | int status = esp_http_client_get_status_code(client); | ||
| 73 | |||
| 74 | char *resp = NULL; | ||
| 75 | if (content_length > 0 && content_length < 8192) { | ||
| 76 | resp = malloc(content_length + 1); | ||
| 77 | if (resp) { | ||
| 78 | int read = esp_http_client_read(client, resp, content_length); | ||
| 79 | if (read > 0) resp[read] = '\0'; | ||
| 80 | } | ||
| 81 | } | ||
| 82 | if (resp) free(resp); | ||
| 83 | |||
| 84 | esp_http_client_cleanup(client); | ||
| 85 | return (status >= 200 && status < 300); | ||
| 86 | } | ||
| 87 | |||
| 88 | static void run_probes(void) | ||
| 89 | { | ||
| 90 | int old_reachable = 0; | ||
| 91 | int new_reachable = 0; | ||
| 92 | |||
| 93 | if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(5000)) != pdTRUE) return; | ||
| 94 | |||
| 95 | for (int i = 0; i < s_mint_count; i++) { | ||
| 96 | if (s_mints[i].reachable) old_reachable++; | ||
| 97 | } | ||
| 98 | |||
| 99 | for (int i = 0; i < s_mint_count; i++) { | ||
| 100 | bool ok = probe_mint(s_mints[i].url); | ||
| 101 | s_mints[i].last_probe_ms = (int64_t)xTaskGetTickCount() * portTICK_PERIOD_MS; | ||
| 102 | s_mints[i].last_http_status = ok ? 200 : 0; | ||
| 103 | |||
| 104 | if (ok) { | ||
| 105 | s_mints[i].consecutive_successes++; | ||
| 106 | if (s_mints[i].consecutive_successes >= MINT_HEALTH_RECOVERY_THRESHOLD) { | ||
| 107 | if (!s_mints[i].reachable) { | ||
| 108 | ESP_LOGI(TAG, "Mint RECOVERED: %s", s_mints[i].url); | ||
| 109 | } | ||
| 110 | s_mints[i].reachable = true; | ||
| 111 | } | ||
| 112 | } else { | ||
| 113 | if (s_mints[i].reachable) { | ||
| 114 | ESP_LOGW(TAG, "Mint UNREACHABLE: %s", s_mints[i].url); | ||
| 115 | } | ||
| 116 | s_mints[i].reachable = false; | ||
| 117 | s_mints[i].consecutive_successes = 0; | ||
| 118 | } | ||
| 119 | |||
| 120 | if (s_mints[i].reachable) new_reachable++; | ||
| 121 | } | ||
| 122 | |||
| 123 | bool changed = (old_reachable != new_reachable); | ||
| 124 | xSemaphoreGive(s_mutex); | ||
| 125 | |||
| 126 | if (changed) { | ||
| 127 | ESP_LOGI(TAG, "Reachable set changed: %d -> %d", old_reachable, new_reachable); | ||
| 128 | fire_callbacks(); | ||
| 129 | } | ||
| 130 | } | ||
| 131 | |||
| 132 | static void run_initial_probes(void) | ||
| 133 | { | ||
| 134 | if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(5000)) != pdTRUE) return; | ||
| 135 | |||
| 136 | for (int i = 0; i < s_mint_count; i++) { | ||
| 137 | bool ok = probe_mint(s_mints[i].url); | ||
| 138 | s_mints[i].last_probe_ms = (int64_t)xTaskGetTickCount() * portTICK_PERIOD_MS; | ||
| 139 | s_mints[i].last_http_status = ok ? 200 : 0; | ||
| 140 | |||
| 141 | if (ok) { | ||
| 142 | s_mints[i].consecutive_successes = MINT_HEALTH_RECOVERY_THRESHOLD; | ||
| 143 | s_mints[i].reachable = true; | ||
| 144 | ESP_LOGI(TAG, "Initial probe OK: %s (reachable)", s_mints[i].url); | ||
| 145 | } else { | ||
| 146 | s_mints[i].consecutive_successes = 0; | ||
| 147 | s_mints[i].reachable = false; | ||
| 148 | ESP_LOGW(TAG, "Initial probe FAIL: %s (unreachable)", s_mints[i].url); | ||
| 149 | } | ||
| 150 | } | ||
| 151 | |||
| 152 | xSemaphoreGive(s_mutex); | ||
| 153 | fire_callbacks(); | ||
| 154 | } | ||
| 155 | |||
| 156 | static void health_task(void *pvParameters) | ||
| 157 | { | ||
| 158 | ESP_LOGI(TAG, "Health probe task started"); | ||
| 159 | run_initial_probes(); | ||
| 160 | |||
| 161 | while (s_running) { | ||
| 162 | vTaskDelay(pdMS_TO_TICKS(MINT_HEALTH_PROBE_INTERVAL_S * 1000)); | ||
| 163 | if (!s_running) break; | ||
| 164 | run_probes(); | ||
| 165 | } | ||
| 166 | |||
| 167 | s_task_handle = NULL; | ||
| 168 | vTaskDelete(NULL); | ||
| 169 | } | ||
| 170 | |||
| 171 | void mint_health_start(void) | ||
| 172 | { | ||
| 173 | if (s_running) return; | ||
| 174 | s_running = true; | ||
| 175 | xTaskCreate(health_task, "mint_health", 16384, NULL, 3, &s_task_handle); | ||
| 176 | } | ||
| 177 | |||
| 178 | void mint_health_stop(void) | ||
| 179 | { | ||
| 180 | s_running = false; | ||
| 181 | if (s_task_handle) { | ||
| 182 | vTaskDelay(pdMS_TO_TICKS(100)); | ||
| 183 | } | ||
| 184 | } | ||
| 185 | |||
| 186 | const mint_status_t *mint_health_get_all(int *out_count) | ||
| 187 | { | ||
| 188 | if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(1000)) != pdTRUE) { | ||
| 189 | *out_count = 0; | ||
| 190 | return s_mints; | ||
| 191 | } | ||
| 192 | *out_count = s_mint_count; | ||
| 193 | xSemaphoreGive(s_mutex); | ||
| 194 | return s_mints; | ||
| 195 | } | ||
| 196 | |||
| 197 | bool mint_health_is_reachable(const char *url) | ||
| 198 | { | ||
| 199 | if (!url) return false; | ||
| 200 | if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(1000)) != pdTRUE) return false; | ||
| 201 | bool result = false; | ||
| 202 | for (int i = 0; i < s_mint_count; i++) { | ||
| 203 | if (strcmp(s_mints[i].url, url) == 0 || strstr(url, s_mints[i].url) != NULL) { | ||
| 204 | result = s_mints[i].reachable; | ||
| 205 | break; | ||
| 206 | } | ||
| 207 | } | ||
| 208 | xSemaphoreGive(s_mutex); | ||
| 209 | return result; | ||
| 210 | } | ||
| 211 | |||
| 212 | void mint_health_mark_unreachable(const char *url) | ||
| 213 | { | ||
| 214 | if (!url) return; | ||
| 215 | if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(1000)) != pdTRUE) return; | ||
| 216 | for (int i = 0; i < s_mint_count; i++) { | ||
| 217 | if (strcmp(s_mints[i].url, url) == 0 || strstr(url, s_mints[i].url) != NULL) { | ||
| 218 | if (s_mints[i].reachable) { | ||
| 219 | s_mints[i].reachable = false; | ||
| 220 | s_mints[i].consecutive_successes = 0; | ||
| 221 | ESP_LOGW(TAG, "Reactively marked unreachable: %s", url); | ||
| 222 | } | ||
| 223 | break; | ||
| 224 | } | ||
| 225 | } | ||
| 226 | xSemaphoreGive(s_mutex); | ||
| 227 | } | ||
| 228 | |||
| 229 | void mint_health_register_callback(mint_health_changed_cb cb) | ||
| 230 | { | ||
| 231 | if (s_callback_count < MAX_CALLBACKS && cb) { | ||
| 232 | s_callbacks[s_callback_count++] = cb; | ||
| 233 | } | ||
| 234 | } | ||
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 @@ | |||
| 1 | #ifndef MINT_HEALTH_H | ||
| 2 | #define MINT_HEALTH_H | ||
| 3 | |||
| 4 | #include "esp_err.h" | ||
| 5 | #include <stdint.h> | ||
| 6 | #include <stdbool.h> | ||
| 7 | |||
| 8 | #define MINT_HEALTH_MAX 8 | ||
| 9 | #define MINT_HEALTH_PROBE_INTERVAL_S 300 | ||
| 10 | #define MINT_HEALTH_PROBE_TIMEOUT_MS 15000 | ||
| 11 | #define MINT_HEALTH_RECOVERY_THRESHOLD 3 | ||
| 12 | |||
| 13 | typedef struct { | ||
| 14 | char url[256]; | ||
| 15 | bool reachable; | ||
| 16 | uint8_t consecutive_successes; | ||
| 17 | int64_t last_probe_ms; | ||
| 18 | int last_http_status; | ||
| 19 | } mint_status_t; | ||
| 20 | |||
| 21 | typedef void (*mint_health_changed_cb)(void); | ||
| 22 | |||
| 23 | esp_err_t mint_health_init(const char urls[][256], int count); | ||
| 24 | void mint_health_start(void); | ||
| 25 | void mint_health_stop(void); | ||
| 26 | const mint_status_t *mint_health_get_all(int *out_count); | ||
| 27 | bool mint_health_is_reachable(const char *url); | ||
| 28 | void mint_health_mark_unreachable(const char *url); | ||
| 29 | void mint_health_register_callback(mint_health_changed_cb cb); | ||
| 30 | |||
| 31 | #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 @@ | |||
| 4 | #include "session.h" | 4 | #include "session.h" |
| 5 | #include "firewall.h" | 5 | #include "firewall.h" |
| 6 | #include "nucula_wallet.h" | 6 | #include "nucula_wallet.h" |
| 7 | #include "mint_health.h" | ||
| 7 | #include "esp_log.h" | 8 | #include "esp_log.h" |
| 8 | #include "cJSON.h" | 9 | #include "cJSON.h" |
| 9 | #include "lwip/sockets.h" | 10 | #include "lwip/sockets.h" |
| @@ -110,16 +111,36 @@ static esp_err_t api_get_discovery(httpd_req_t *req) | |||
| 110 | cJSON_AddItemToArray(step_tag, cJSON_CreateString(step_str)); | 111 | cJSON_AddItemToArray(step_tag, cJSON_CreateString(step_str)); |
| 111 | cJSON_AddItemToArray(tags, step_tag); | 112 | cJSON_AddItemToArray(tags, step_tag); |
| 112 | 113 | ||
| 113 | cJSON *price_tag = cJSON_CreateArray(); | ||
| 114 | cJSON_AddItemToArray(price_tag, cJSON_CreateString("price_per_step")); | ||
| 115 | cJSON_AddItemToArray(price_tag, cJSON_CreateString("cashu")); | ||
| 116 | char price_str[32]; | 114 | char price_str[32]; |
| 117 | snprintf(price_str, sizeof(price_str), "%d", cfg->price_per_step); | 115 | snprintf(price_str, sizeof(price_str), "%d", cfg->price_per_step); |
| 118 | cJSON_AddItemToArray(price_tag, cJSON_CreateString(price_str)); | 116 | |
| 119 | cJSON_AddItemToArray(price_tag, cJSON_CreateString("sat")); | 117 | int mint_count = 0; |
| 120 | cJSON_AddItemToArray(price_tag, cJSON_CreateString(cfg->mint_url)); | 118 | const mint_status_t *mints = mint_health_get_all(&mint_count); |
| 121 | cJSON_AddItemToArray(price_tag, cJSON_CreateString("1")); | 119 | bool any_reachable = false; |
| 122 | cJSON_AddItemToArray(tags, price_tag); | 120 | |
| 121 | for (int i = 0; i < mint_count; i++) { | ||
| 122 | if (!mints[i].reachable) continue; | ||
| 123 | any_reachable = true; | ||
| 124 | cJSON *price_tag = cJSON_CreateArray(); | ||
| 125 | cJSON_AddItemToArray(price_tag, cJSON_CreateString("price_per_step")); | ||
| 126 | cJSON_AddItemToArray(price_tag, cJSON_CreateString("cashu")); | ||
| 127 | cJSON_AddItemToArray(price_tag, cJSON_CreateString(price_str)); | ||
| 128 | cJSON_AddItemToArray(price_tag, cJSON_CreateString("sat")); | ||
| 129 | cJSON_AddItemToArray(price_tag, cJSON_CreateString(mints[i].url)); | ||
| 130 | cJSON_AddItemToArray(price_tag, cJSON_CreateString("1")); | ||
| 131 | cJSON_AddItemToArray(tags, price_tag); | ||
| 132 | } | ||
| 133 | |||
| 134 | if (!any_reachable) { | ||
| 135 | cJSON *price_tag = cJSON_CreateArray(); | ||
| 136 | cJSON_AddItemToArray(price_tag, cJSON_CreateString("price_per_step")); | ||
| 137 | cJSON_AddItemToArray(price_tag, cJSON_CreateString("cashu")); | ||
| 138 | cJSON_AddItemToArray(price_tag, cJSON_CreateString(price_str)); | ||
| 139 | cJSON_AddItemToArray(price_tag, cJSON_CreateString("sat")); | ||
| 140 | cJSON_AddItemToArray(price_tag, cJSON_CreateString(cfg->mint_url)); | ||
| 141 | cJSON_AddItemToArray(price_tag, cJSON_CreateString("1")); | ||
| 142 | cJSON_AddItemToArray(tags, price_tag); | ||
| 143 | } | ||
| 123 | 144 | ||
| 124 | cJSON *tips_tag = cJSON_CreateArray(); | 145 | cJSON *tips_tag = cJSON_CreateArray(); |
| 125 | cJSON_AddItemToArray(tips_tag, cJSON_CreateString("tips")); | 146 | 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 @@ | |||
| 22 | #include "wifistr.h" | 22 | #include "wifistr.h" |
| 23 | #include "tollgate_client.h" | 23 | #include "tollgate_client.h" |
| 24 | #include "lightning_payout.h" | 24 | #include "lightning_payout.h" |
| 25 | #include "mint_health.h" | ||
| 25 | #include "cvm_server.h" | 26 | #include "cvm_server.h" |
| 26 | #include "display.h" | 27 | #include "display.h" |
| 27 | 28 | ||
| @@ -151,7 +152,15 @@ static void start_services(void) | |||
| 151 | session_manager_init(); | 152 | session_manager_init(); |
| 152 | 153 | ||
| 153 | const tollgate_config_t *cfg = tollgate_config_get(); | 154 | const tollgate_config_t *cfg = tollgate_config_get(); |
| 154 | nucula_wallet_init(cfg->mint_url); | 155 | |
| 156 | mint_health_init(cfg->accepted_mints, cfg->accepted_mint_count); | ||
| 157 | mint_health_start(); | ||
| 158 | |||
| 159 | if (cfg->accepted_mint_count > 1) { | ||
| 160 | nucula_wallet_init_multi(cfg->accepted_mints, cfg->accepted_mint_count); | ||
| 161 | } else { | ||
| 162 | nucula_wallet_init(cfg->mint_url); | ||
| 163 | } | ||
| 155 | lightning_payout_init(&cfg->payout); | 164 | lightning_payout_init(&cfg->payout); |
| 156 | 165 | ||
| 157 | dns_server_start(ap_ip_info.ip, upstream_dns); | 166 | dns_server_start(ap_ip_info.ip, upstream_dns); |
| @@ -187,6 +196,7 @@ static void stop_services(void) | |||
| 187 | captive_portal_stop(); | 196 | captive_portal_stop(); |
| 188 | tollgate_api_stop(); | 197 | tollgate_api_stop(); |
| 189 | dns_server_stop(); | 198 | dns_server_stop(); |
| 199 | mint_health_stop(); | ||
| 190 | cvm_server_stop(); | 200 | cvm_server_stop(); |
| 191 | firewall_revoke_all(); | 201 | firewall_revoke_all(); |
| 192 | s_services_running = false; | 202 | s_services_running = false; |