upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYour Name <you@example.com>2026-05-18 14:50:41 +0530
committerYour Name <you@example.com>2026-05-18 14:50:41 +0530
commit2a86bec93273e2f4ceeab60683058c65dbb1da3d (patch)
tree56e66acc4eaa0b5a947bb78d28e494a0adc857e2
parent0c3f08ac7cf8e970369ec137153107ca8edc3326 (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.cpp260
-rw-r--r--components/nucula_lib/nucula_wallet.h1
-rw-r--r--main/CMakeLists.txt5
-rw-r--r--main/captive_portal.c86
-rw-r--r--main/cashu.c7
-rw-r--r--main/mint_health.c234
-rw-r--r--main/mint_health.h31
-rw-r--r--main/tollgate_api.c37
-rw-r--r--main/tollgate_main.c12
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
13static const char *TAG = "nucula_wallet"; 13static const char *TAG = "nucula_wallet";
14 14
15static const int MAX_WALLETS = 4;
16
15static secp256k1_context *s_ctx = nullptr; 17static secp256k1_context *s_ctx = nullptr;
16static cashu::Wallet *s_wallet = nullptr; 18static cashu::Wallet *s_wallets[MAX_WALLETS] = {};
19static int s_wallet_count = 0;
20static char s_wallet_urls[MAX_WALLETS][256] = {};
17 21
18static std::vector<cashu::Proof> &mutable_proofs() 22static 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
23esp_err_t nucula_wallet_init(const char *mint_url) 36static 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
46static std::vector<cashu::Proof> &mutable_proofs(cashu::Wallet *w)
47{
48 return const_cast<std::vector<cashu::Proof> &>(w->proofs());
49}
50
51static 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
75esp_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
92esp_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
53esp_err_t nucula_wallet_receive(const char *token_str) 117esp_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
82esp_err_t nucula_wallet_send(uint64_t amount_sat, char *token_out, size_t token_out_size) 152esp_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
127uint64_t nucula_wallet_balance(void) 201uint64_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
133int nucula_wallet_proof_count(void) 210int 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
139char *nucula_wallet_proofs_json(void) 219char *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
156esp_err_t nucula_wallet_swap_all(void) 238esp_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
185void nucula_wallet_print_status(void) 271void 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
201esp_err_t nucula_wallet_melt(const char *bolt11_invoice, uint64_t max_fee_sats) 291esp_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
11esp_err_t nucula_wallet_init(const char *mint_url); 11esp_err_t nucula_wallet_init(const char *mint_url);
12esp_err_t nucula_wallet_init_multi(const char mint_urls[][256], int count);
12 13
13esp_err_t nucula_wallet_receive(const char *token_str); 14esp_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 &bull; Mint tokens at this URL before paying</div>" 73"<div class='mint-hint'>Tap to copy &bull; 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
236static 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
193static esp_err_t status_handler(httpd_req_t *req) 255static 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
291static const httpd_uri_t uri_portal = { .uri = "/", .method = HTTP_GET, .handler = portal_handler }; 353static const httpd_uri_t uri_portal = { .uri = "/", .method = HTTP_GET, .handler = portal_handler };
292static const httpd_uri_t uri_grant = { .uri = "/grant_access", .method = HTTP_GET, .handler = grant_access_handler }; 354static const httpd_uri_t uri_grant = { .uri = "/grant_access", .method = HTTP_GET, .handler = grant_access_handler };
355static const httpd_uri_t uri_mints = { .uri = "/mints", .method = HTTP_GET, .handler = mints_handler };
293static const httpd_uri_t uri_status = { .uri = "/api/status", .method = HTTP_GET, .handler = status_handler }; 356static const httpd_uri_t uri_status = { .uri = "/api/status", .method = HTTP_GET, .handler = status_handler };
294static const httpd_uri_t uri_whoami = { .uri = "/whoami", .method = HTTP_GET, .handler = whoami_handler }; 357static const httpd_uri_t uri_whoami = { .uri = "/whoami", .method = HTTP_GET, .handler = whoami_handler };
295static const httpd_uri_t uri_usage = { .uri = "/usage", .method = HTTP_GET, .handler = usage_handler }; 358static 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
11static const char *TAG = "mint_health";
12
13static mint_status_t s_mints[MINT_HEALTH_MAX];
14static int s_mint_count = 0;
15static bool s_running = false;
16static TaskHandle_t s_task_handle = NULL;
17static SemaphoreHandle_t s_mutex = NULL;
18
19#define MAX_CALLBACKS 4
20static mint_health_changed_cb s_callbacks[MAX_CALLBACKS];
21static int s_callback_count = 0;
22
23static 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
30esp_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
51static 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
88static 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
132static 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
156static 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
171void 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
178void mint_health_stop(void)
179{
180 s_running = false;
181 if (s_task_handle) {
182 vTaskDelay(pdMS_TO_TICKS(100));
183 }
184}
185
186const 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
197bool 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
212void 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
229void 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
13typedef 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
21typedef void (*mint_health_changed_cb)(void);
22
23esp_err_t mint_health_init(const char urls[][256], int count);
24void mint_health_start(void);
25void mint_health_stop(void);
26const mint_status_t *mint_health_get_all(int *out_count);
27bool mint_health_is_reachable(const char *url);
28void mint_health_mark_unreachable(const char *url);
29void 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;