upleb.uk

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

summaryrefslogtreecommitdiff
path: root/components/nucula_lib/nucula_wallet.cpp
diff options
context:
space:
mode:
authorYour Name <you@example.com>2026-05-19 13:21:25 +0530
committerYour Name <you@example.com>2026-05-19 13:31:08 +0530
commiteeba74a4a1c011e85e33dea4252b381e35a64ea4 (patch)
tree14862e7d300511e28e214c743fd2f699bc54c5b8 /components/nucula_lib/nucula_wallet.cpp
parentb0d9d494f00ee77f9efc22d1ef2ea3c94b23ddbd (diff)
feat: multi-mint wallet with health tracking, WPA auto-detect, display gating
Squash merge of feature/multi-mint-support (21 commits): Multi-mint wallet: - Accept payments from 4 mints: minibits, coinos, 21mint, lnvoltz - Periodic health probing (300s interval, 3 recovery threshold) - Multi-wallet init with nucula_wallet_init_multi() - /mints and /wallet API endpoints WPA auto-detect: - wifi_auth_mode config field (default WPA2, supports WPA3) - Runtime mapping to wifi_auth_mode_t in STA config Display gating: - display_enabled config field (default true) - Guards display_init/display_update per-board Bug fixes: - 3s delay before service start prevents lwip mem_free assertion - Real npub in discovery (identity_get()->npub_hex) - Health probe interval 300s (production value) - Duplicate services_start_task call removed - UTF-8 arrow replaced with ASCII in log message Tests: 61+14 unit tests passing, firmware builds clean
Diffstat (limited to 'components/nucula_lib/nucula_wallet.cpp')
-rw-r--r--components/nucula_lib/nucula_wallet.cpp260
1 files changed, 180 insertions, 80 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}