@routstr/sdk 0.3.7 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/client/index.d.mts +411 -0
  2. package/dist/client/index.d.ts +411 -0
  3. package/dist/client/index.js +4938 -0
  4. package/dist/client/index.js.map +1 -0
  5. package/dist/client/index.mjs +4932 -0
  6. package/dist/client/index.mjs.map +1 -0
  7. package/dist/discovery/index.d.mts +247 -0
  8. package/dist/discovery/index.d.ts +247 -0
  9. package/dist/discovery/index.js +894 -0
  10. package/dist/discovery/index.js.map +1 -0
  11. package/dist/discovery/index.mjs +891 -0
  12. package/dist/discovery/index.mjs.map +1 -0
  13. package/dist/index.d.mts +195 -0
  14. package/dist/index.d.ts +195 -0
  15. package/dist/index.js +6797 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/index.mjs +6746 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/interfaces-C-DYd9Jy.d.ts +176 -0
  20. package/dist/interfaces-Csn8Uq04.d.mts +176 -0
  21. package/dist/interfaces-Cv1k2EUK.d.mts +118 -0
  22. package/dist/interfaces-iL7CWeG5.d.ts +118 -0
  23. package/dist/storage/index.d.mts +113 -0
  24. package/dist/storage/index.d.ts +113 -0
  25. package/dist/storage/index.js +2019 -0
  26. package/dist/storage/index.js.map +1 -0
  27. package/dist/storage/index.mjs +1995 -0
  28. package/dist/storage/index.mjs.map +1 -0
  29. package/dist/store-58VcEUoA.d.ts +172 -0
  30. package/dist/store-C6dfj1cc.d.mts +172 -0
  31. package/dist/types-_21yYFZG.d.mts +234 -0
  32. package/dist/types-_21yYFZG.d.ts +234 -0
  33. package/dist/wallet/index.d.mts +249 -0
  34. package/dist/wallet/index.d.ts +249 -0
  35. package/dist/wallet/index.js +1383 -0
  36. package/dist/wallet/index.js.map +1 -0
  37. package/dist/wallet/index.mjs +1380 -0
  38. package/dist/wallet/index.mjs.map +1 -0
  39. package/package.json +3 -2
@@ -0,0 +1,1383 @@
1
+ 'use strict';
2
+
3
+ var cashuTs = require('@cashu/cashu-ts');
4
+
5
+ // core/types.ts
6
+ function makeConsoleLogger(prefix) {
7
+ const fmt = (args) => prefix ? [prefix, ...args] : args;
8
+ return {
9
+ log: (...args) => console.log(...fmt(args)),
10
+ warn: (...args) => console.warn(...fmt(args)),
11
+ error: (...args) => console.error(...fmt(args)),
12
+ debug: (...args) => console.log(...fmt(args)),
13
+ child: (p) => makeConsoleLogger(prefix ? `${prefix}:${p}` : p)
14
+ };
15
+ }
16
+ var consoleLogger = makeConsoleLogger();
17
+
18
+ // core/errors.ts
19
+ var InsufficientBalanceError = class extends Error {
20
+ constructor(required, available, maxMintBalance = 0, maxMintUrl = "", customMessage) {
21
+ super(
22
+ customMessage ?? `Insufficient balance: need ${required} sats, have ${available} sats available. ` + (maxMintBalance > 0 ? `Largest mint balance: ${maxMintBalance} sats from ${maxMintUrl}` : "")
23
+ );
24
+ this.required = required;
25
+ this.available = available;
26
+ this.maxMintBalance = maxMintBalance;
27
+ this.maxMintUrl = maxMintUrl;
28
+ this.name = "InsufficientBalanceError";
29
+ }
30
+ required;
31
+ available;
32
+ maxMintBalance;
33
+ maxMintUrl;
34
+ };
35
+
36
+ // wallet/AuditLogger.ts
37
+ var AuditLogger = class _AuditLogger {
38
+ static instance = null;
39
+ static getInstance() {
40
+ if (!_AuditLogger.instance) {
41
+ _AuditLogger.instance = new _AuditLogger();
42
+ }
43
+ return _AuditLogger.instance;
44
+ }
45
+ async log(entry) {
46
+ const fullEntry = {
47
+ ...entry,
48
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
49
+ };
50
+ const logLine = JSON.stringify(fullEntry) + "\n";
51
+ if (typeof window === "undefined") {
52
+ try {
53
+ const fs = await import('fs');
54
+ const path = await import('path');
55
+ const logPath = path.join(process.cwd(), "audit.log");
56
+ fs.appendFileSync(logPath, logLine);
57
+ } catch (error) {
58
+ console.error("[AuditLogger] Failed to write to file:", error);
59
+ }
60
+ } else {
61
+ console.log("[AUDIT]", logLine.trim());
62
+ }
63
+ }
64
+ async logBalanceSnapshot(action, amounts, options) {
65
+ await this.log({
66
+ action,
67
+ totalBalance: amounts.totalBalance,
68
+ providerBalances: amounts.providerBalances,
69
+ mintBalances: amounts.mintBalances,
70
+ amount: options?.amount,
71
+ mintUrl: options?.mintUrl,
72
+ baseUrl: options?.baseUrl,
73
+ status: options?.status ?? "success",
74
+ details: options?.details
75
+ });
76
+ }
77
+ };
78
+ var auditLogger = AuditLogger.getInstance();
79
+
80
+ // wallet/tokenUtils.ts
81
+ function isNetworkErrorMessage(message) {
82
+ return message.includes("NetworkError when attempting to fetch resource") || message.includes("Failed to fetch") || message.includes("Load failed") || message.includes("ERR_TLS_CERT_ALTNAME_INVALID") || message.includes("ERR_TLS_CERT_NOT_YET_VALID") || message.includes("ERR_TLS_CERT_EXPIRED") || message.includes("UNABLE_TO_VERIFY_LEAF_SIGNATURE") || message.includes("SELF_SIGNED_CERT_IN_CHAIN");
83
+ }
84
+ function getBalanceInSats(balance, unit) {
85
+ return unit === "msat" ? balance / 1e3 : balance;
86
+ }
87
+ function selectMintWithBalance(balances, units, amount, excludeMints = []) {
88
+ for (const mintUrl in balances) {
89
+ if (excludeMints.includes(mintUrl)) {
90
+ continue;
91
+ }
92
+ const balanceInSats = getBalanceInSats(balances[mintUrl], units[mintUrl]);
93
+ if (balanceInSats >= amount) {
94
+ return { selectedMintUrl: mintUrl, selectedMintBalance: balanceInSats };
95
+ }
96
+ }
97
+ return { selectedMintUrl: null, selectedMintBalance: 0 };
98
+ }
99
+ var CashuSpender = class {
100
+ constructor(walletAdapter, storageAdapter, _providerRegistry, balanceManager, logger) {
101
+ this.walletAdapter = walletAdapter;
102
+ this.storageAdapter = storageAdapter;
103
+ this._providerRegistry = _providerRegistry;
104
+ this.balanceManager = balanceManager;
105
+ this.logger = (logger ?? consoleLogger).child("CashuSpender");
106
+ }
107
+ walletAdapter;
108
+ storageAdapter;
109
+ _providerRegistry;
110
+ balanceManager;
111
+ _isBusy = false;
112
+ debugLevel = "WARN";
113
+ logger;
114
+ async receiveToken(token) {
115
+ try {
116
+ const result = await this.walletAdapter.receiveToken(token);
117
+ return result;
118
+ } catch (error) {
119
+ const errorMessage = error instanceof Error ? error.message : String(error);
120
+ if (errorMessage.includes("Failed to fetch mint")) {
121
+ const cachedTokens = this.storageAdapter.getCachedReceiveTokens();
122
+ const existingIndex = cachedTokens.findIndex((t) => t.token === token);
123
+ if (existingIndex === -1) {
124
+ const { amount: amount2, unit: unit2 } = this._decodeTokenAmount(token);
125
+ this.storageAdapter.setCachedReceiveTokens([
126
+ ...cachedTokens,
127
+ {
128
+ token,
129
+ amount: amount2,
130
+ unit: unit2,
131
+ createdAt: Date.now()
132
+ }
133
+ ]);
134
+ }
135
+ }
136
+ const { amount, unit } = this._decodeTokenAmount(token);
137
+ return { success: false, amount, unit, message: errorMessage };
138
+ }
139
+ }
140
+ _decodeTokenAmount(token) {
141
+ try {
142
+ const decoded = cashuTs.getDecodedToken(token);
143
+ const amount = decoded.proofs.reduce(
144
+ (acc, proof) => acc + proof.amount,
145
+ 0
146
+ );
147
+ const unit = decoded.unit || "sat";
148
+ return { amount, unit };
149
+ } catch {
150
+ return { amount: 0, unit: "sat" };
151
+ }
152
+ }
153
+ async _getBalanceState() {
154
+ if (this.balanceManager) {
155
+ return this.balanceManager.getBalanceState();
156
+ }
157
+ const mintBalances = await this.walletAdapter.getBalances();
158
+ const units = this.walletAdapter.getMintUnits();
159
+ let totalMintBalance = 0;
160
+ const normalizedMintBalances = {};
161
+ for (const url in mintBalances) {
162
+ const balance = mintBalances[url];
163
+ const unit = units[url];
164
+ const balanceInSats = getBalanceInSats(balance, unit);
165
+ normalizedMintBalances[url] = balanceInSats;
166
+ totalMintBalance += balanceInSats;
167
+ }
168
+ const providerBalances = {};
169
+ let totalProviderBalance = 0;
170
+ const apiKeys = this.storageAdapter.getAllApiKeys();
171
+ for (const apiKey of apiKeys) {
172
+ if (!providerBalances[apiKey.baseUrl]) {
173
+ providerBalances[apiKey.baseUrl] = 0;
174
+ }
175
+ providerBalances[apiKey.baseUrl] += apiKey.balance;
176
+ totalProviderBalance += apiKey.balance;
177
+ }
178
+ return {
179
+ totalBalance: totalMintBalance + totalProviderBalance,
180
+ providerBalances,
181
+ mintBalances: normalizedMintBalances
182
+ };
183
+ }
184
+ async _logTransaction(action, options) {
185
+ const balanceState = await this._getBalanceState();
186
+ await auditLogger.logBalanceSnapshot(action, balanceState, options);
187
+ }
188
+ /**
189
+ * Check if the spender is currently in a critical operation
190
+ */
191
+ get isBusy() {
192
+ return this._isBusy;
193
+ }
194
+ getDebugLevel() {
195
+ return this.debugLevel;
196
+ }
197
+ setDebugLevel(level) {
198
+ this.debugLevel = level;
199
+ }
200
+ _log(level, ...args) {
201
+ const levelPriority = {
202
+ DEBUG: 0,
203
+ WARN: 1,
204
+ ERROR: 2
205
+ };
206
+ if (levelPriority[level] >= levelPriority[this.debugLevel]) {
207
+ switch (level) {
208
+ case "DEBUG":
209
+ this.logger.log(...args);
210
+ break;
211
+ case "WARN":
212
+ this.logger.warn(...args);
213
+ break;
214
+ case "ERROR":
215
+ this.logger.error(...args);
216
+ break;
217
+ }
218
+ }
219
+ }
220
+ /**
221
+ * Spend Cashu tokens with automatic mint selection and retry logic
222
+ * Throws errors on failure instead of returning failed SpendResult
223
+ */
224
+ async spend(options) {
225
+ const {
226
+ mintUrl,
227
+ amount,
228
+ baseUrl,
229
+ reuseToken = false,
230
+ p2pkPubkey,
231
+ excludeMints = [],
232
+ retryCount = 0
233
+ } = options;
234
+ this._isBusy = true;
235
+ try {
236
+ const result = await this._spendInternal({
237
+ mintUrl,
238
+ amount,
239
+ baseUrl,
240
+ reuseToken,
241
+ p2pkPubkey,
242
+ excludeMints,
243
+ retryCount
244
+ });
245
+ if (result.status === "failed" || !result.token) {
246
+ const errorMsg = result.error || `Insufficient balance. Need ${amount} sats.`;
247
+ if (this._isNetworkError(errorMsg)) {
248
+ throw new Error(
249
+ `Your mint ${mintUrl} is unreachable or is blocking your IP. Please try again later or switch mints.`
250
+ );
251
+ }
252
+ if (result.errorDetails) {
253
+ throw new InsufficientBalanceError(
254
+ result.errorDetails.required,
255
+ result.errorDetails.available,
256
+ result.errorDetails.maxMintBalance,
257
+ result.errorDetails.maxMintUrl
258
+ );
259
+ }
260
+ throw new Error(errorMsg);
261
+ }
262
+ return result;
263
+ } finally {
264
+ this._isBusy = false;
265
+ }
266
+ }
267
+ /**
268
+ * Check if error message indicates a network error
269
+ */
270
+ _isNetworkError(message) {
271
+ return isNetworkErrorMessage(message) || message.includes("Your mint") && message.includes("unreachable");
272
+ }
273
+ /**
274
+ * Internal spending logic
275
+ */
276
+ async _spendInternal(options) {
277
+ let {
278
+ mintUrl,
279
+ amount,
280
+ baseUrl,
281
+ reuseToken,
282
+ p2pkPubkey,
283
+ excludeMints,
284
+ retryCount
285
+ } = options;
286
+ this._log(
287
+ "DEBUG",
288
+ `[CashuSpender] _spendInternal: amount=${amount}, mintUrl=${mintUrl}, baseUrl=${baseUrl}, reuseToken=${reuseToken}`
289
+ );
290
+ let adjustedAmount = Math.ceil(amount);
291
+ if (!adjustedAmount || isNaN(adjustedAmount)) {
292
+ this._log(
293
+ "ERROR",
294
+ `[CashuSpender] _spendInternal: Invalid amount: ${amount}`
295
+ );
296
+ return {
297
+ token: null,
298
+ status: "failed",
299
+ balance: 0,
300
+ error: "Please enter a valid amount"
301
+ };
302
+ }
303
+ if (reuseToken && baseUrl) {
304
+ this._log(
305
+ "DEBUG",
306
+ `[CashuSpender] _spendInternal: Attempting to reuse token for ${baseUrl}`
307
+ );
308
+ const existingResult = await this._tryReuseToken(
309
+ baseUrl,
310
+ adjustedAmount,
311
+ mintUrl
312
+ );
313
+ if (existingResult) {
314
+ this._log(
315
+ "DEBUG",
316
+ `[CashuSpender] _spendInternal: Successfully reused token, balance: ${existingResult.balance}`
317
+ );
318
+ return existingResult;
319
+ }
320
+ this._log(
321
+ "DEBUG",
322
+ `[CashuSpender] _spendInternal: Could not reuse token, will create new token`
323
+ );
324
+ }
325
+ const balanceState = await this._getBalanceState();
326
+ const totalAvailableBalance = balanceState.totalBalance;
327
+ this._log(
328
+ "DEBUG",
329
+ `[CashuSpender] _spendInternal: totalAvailableBalance=${totalAvailableBalance}, adjustedAmount=${adjustedAmount}`
330
+ );
331
+ if (totalAvailableBalance < adjustedAmount) {
332
+ this._log(
333
+ "ERROR",
334
+ `[CashuSpender] _spendInternal: Insufficient balance, have=${totalAvailableBalance}, need=${adjustedAmount}`
335
+ );
336
+ return this._createInsufficientBalanceError(
337
+ adjustedAmount,
338
+ balanceState.mintBalances,
339
+ totalAvailableBalance
340
+ );
341
+ }
342
+ let token = null;
343
+ let selectedMintUrl;
344
+ let spentAmount = adjustedAmount;
345
+ if (this.balanceManager) {
346
+ const tokenResult = await this.balanceManager.createProviderToken({
347
+ mintUrl,
348
+ baseUrl,
349
+ amount: adjustedAmount,
350
+ p2pkPubkey,
351
+ excludeMints,
352
+ retryCount
353
+ });
354
+ if (!tokenResult.success || !tokenResult.token) {
355
+ if ((tokenResult.error || "").includes("Insufficient balance")) {
356
+ return this._createInsufficientBalanceError(
357
+ adjustedAmount,
358
+ balanceState.mintBalances,
359
+ totalAvailableBalance
360
+ );
361
+ }
362
+ return {
363
+ token: null,
364
+ status: "failed",
365
+ balance: 0,
366
+ error: tokenResult.error || "Failed to create token"
367
+ };
368
+ }
369
+ token = tokenResult.token;
370
+ selectedMintUrl = tokenResult.selectedMintUrl;
371
+ spentAmount = tokenResult.amountSpent || adjustedAmount;
372
+ } else {
373
+ try {
374
+ token = await this.walletAdapter.sendToken(
375
+ mintUrl,
376
+ adjustedAmount,
377
+ p2pkPubkey
378
+ );
379
+ selectedMintUrl = mintUrl;
380
+ } catch (error) {
381
+ const errorMsg = error instanceof Error ? error.message : String(error);
382
+ return {
383
+ token: null,
384
+ status: "failed",
385
+ balance: 0,
386
+ error: `Error generating token: ${errorMsg}`
387
+ };
388
+ }
389
+ }
390
+ if (token) {
391
+ this._log(
392
+ "DEBUG",
393
+ `[CashuSpender] _spendInternal: Successfully spent ${spentAmount}, returning token with balance=${spentAmount}`
394
+ );
395
+ }
396
+ this._logTransaction("spend", {
397
+ amount: spentAmount,
398
+ mintUrl: selectedMintUrl || mintUrl,
399
+ baseUrl,
400
+ status: "success"
401
+ });
402
+ this._log(
403
+ "DEBUG",
404
+ `[CashuSpender] _spendInternal: Successfully spent ${spentAmount}, returning token with balance=${spentAmount}`
405
+ );
406
+ const units = this.walletAdapter.getMintUnits();
407
+ return {
408
+ token,
409
+ status: "success",
410
+ balance: spentAmount,
411
+ unit: (selectedMintUrl ? units[selectedMintUrl] : units[mintUrl]) || "sat"
412
+ };
413
+ }
414
+ /**
415
+ * Try to reuse an existing API key
416
+ */
417
+ async _tryReuseToken(baseUrl, amount, mintUrl) {
418
+ const apiKeyEntry = this.storageAdapter.getApiKey(baseUrl);
419
+ if (!apiKeyEntry) return null;
420
+ const apiKeyDistribution = this.storageAdapter.getApiKeyDistribution();
421
+ const balanceForBaseUrl = apiKeyDistribution.find((b) => b.baseUrl === baseUrl)?.amount || 0;
422
+ this._log("DEBUG", "Reusing API key", balanceForBaseUrl, amount);
423
+ if (balanceForBaseUrl > amount) {
424
+ const units = this.walletAdapter.getMintUnits();
425
+ const unit = units[mintUrl] || "sat";
426
+ return {
427
+ token: apiKeyEntry.key,
428
+ status: "success",
429
+ balance: balanceForBaseUrl,
430
+ unit
431
+ };
432
+ }
433
+ if (this.balanceManager) {
434
+ const topUpAmount = Math.ceil(amount * 1.2 - balanceForBaseUrl);
435
+ const topUpResult = await this.balanceManager.topUp({
436
+ mintUrl,
437
+ baseUrl,
438
+ amount: topUpAmount,
439
+ token: apiKeyEntry.key
440
+ });
441
+ this._log("DEBUG", "TOPUP ", topUpResult);
442
+ if (topUpResult.success && topUpResult.toppedUpAmount) {
443
+ const newBalance = balanceForBaseUrl + topUpResult.toppedUpAmount;
444
+ const units = this.walletAdapter.getMintUnits();
445
+ const unit = units[mintUrl] || "sat";
446
+ this._logTransaction("topup", {
447
+ amount: topUpResult.toppedUpAmount,
448
+ mintUrl,
449
+ baseUrl,
450
+ status: "success"
451
+ });
452
+ return {
453
+ token: apiKeyEntry.key,
454
+ status: "success",
455
+ balance: newBalance,
456
+ unit
457
+ };
458
+ }
459
+ const providerBalance = await this._getProviderTokenBalance(
460
+ baseUrl,
461
+ apiKeyEntry.key
462
+ );
463
+ this._log("DEBUG", providerBalance);
464
+ if (providerBalance <= 0) {
465
+ this.storageAdapter.removeApiKey(baseUrl);
466
+ }
467
+ }
468
+ return null;
469
+ }
470
+ /**
471
+ * Refund all xcashu tokens from storage by calling the provider's refund endpoint.
472
+ * The xcashu token acts as an API key to claim the refund, and the response contains
473
+ * the actual refunded Cashu token which is then received into the wallet.
474
+ * @param mintUrl - The mint URL for receiving tokens
475
+ * @param excludeBaseUrls - Base URLs to exclude from refund (optional)
476
+ * @returns Results for each xcashu token refund attempt
477
+ */
478
+ async refundXcashuTokens(mintUrl, excludeBaseUrls) {
479
+ const results = [];
480
+ const xcashuTokens = this.storageAdapter.getXcashuTokens();
481
+ const excludedUrls = new Set(excludeBaseUrls || []);
482
+ for (const [baseUrl, tokens] of Object.entries(xcashuTokens)) {
483
+ if (excludedUrls.has(baseUrl)) continue;
484
+ for (const xcashuToken of tokens) {
485
+ try {
486
+ if (!this.balanceManager) {
487
+ throw new Error("BalanceManager not available for xcashu refund");
488
+ }
489
+ const fetchResult = await this.balanceManager.fetchRefundToken(
490
+ baseUrl,
491
+ xcashuToken.token,
492
+ true
493
+ );
494
+ if (!fetchResult.success || !fetchResult.token) {
495
+ throw new Error(
496
+ fetchResult.error || "Failed to fetch refund token from provider"
497
+ );
498
+ }
499
+ const receiveResult = await this.receiveToken(fetchResult.token);
500
+ if (receiveResult.success) {
501
+ this.storageAdapter.removeXcashuToken(baseUrl, xcashuToken.token);
502
+ results.push({
503
+ baseUrl,
504
+ token: xcashuToken.token,
505
+ success: true
506
+ });
507
+ this._log(
508
+ "DEBUG",
509
+ `[CashuSpender] refundXcashuTokens: Successfully refunded xcashu token for ${baseUrl}, amount=${receiveResult.amount}`
510
+ );
511
+ } else {
512
+ const currentTryCount = xcashuToken.tryCount ?? 0;
513
+ const newTryCount = currentTryCount + 1;
514
+ this.storageAdapter.updateXcashuTokenTryCount(
515
+ xcashuToken.token,
516
+ newTryCount
517
+ );
518
+ results.push({
519
+ baseUrl,
520
+ token: xcashuToken.token,
521
+ success: false,
522
+ error: receiveResult.message ?? "Refund failed"
523
+ });
524
+ this._log(
525
+ "DEBUG",
526
+ `[CashuSpender] refundXcashuTokens: Failed to receive refund token for ${baseUrl}, incremented tryCount to ${newTryCount}: ${receiveResult.message}`
527
+ );
528
+ }
529
+ } catch (error) {
530
+ const currentTryCount = xcashuToken.tryCount ?? 0;
531
+ const newTryCount = currentTryCount + 1;
532
+ this.storageAdapter.updateXcashuTokenTryCount(
533
+ xcashuToken.token,
534
+ newTryCount
535
+ );
536
+ const errorMessage = error instanceof Error ? error.message : String(error);
537
+ results.push({
538
+ baseUrl,
539
+ token: xcashuToken.token,
540
+ success: false,
541
+ error: errorMessage
542
+ });
543
+ this._log(
544
+ "ERROR",
545
+ `[CashuSpender] refundXcashuTokens: Exception during refund for ${baseUrl}: ${errorMessage}, incremented tryCount to ${newTryCount}`
546
+ );
547
+ }
548
+ }
549
+ }
550
+ return results;
551
+ }
552
+ /**
553
+ * Refund specific providers without retrying spend
554
+ */
555
+ async refundProviders(mintUrl, forceRefund) {
556
+ const results = [];
557
+ const apiKeyDistribution = this.storageAdapter.getApiKeyDistribution();
558
+ for (const apiKeyEntry of apiKeyDistribution) {
559
+ const apiKeyEntryFull = this.storageAdapter.getApiKey(
560
+ apiKeyEntry.baseUrl
561
+ );
562
+ if (apiKeyEntryFull && this.balanceManager) {
563
+ try {
564
+ const balanceResult = await this.balanceManager.getTokenBalance(
565
+ apiKeyEntryFull.key,
566
+ apiKeyEntry.baseUrl
567
+ );
568
+ if (balanceResult.isInvalidApiKey) {
569
+ this.logger.warn(
570
+ `refundProviders: ${apiKeyEntry.baseUrl} returned invalid API key; removing local key and treating as success`
571
+ );
572
+ this.storageAdapter.removeApiKey(apiKeyEntry.baseUrl);
573
+ results.push({
574
+ baseUrl: apiKeyEntry.baseUrl,
575
+ success: true
576
+ });
577
+ continue;
578
+ }
579
+ if (balanceResult.amount >= 0 && !balanceResult.balanceUnknown) {
580
+ const balanceSat = balanceResult.unit === "msat" ? Math.floor(balanceResult.amount / 1e3) : balanceResult.amount;
581
+ this.storageAdapter.updateApiKeyBalance(
582
+ apiKeyEntry.baseUrl,
583
+ balanceSat
584
+ );
585
+ } else {
586
+ this.logger.warn(
587
+ `refundProviders: balance refresh for ${apiKeyEntry.baseUrl} returned negative amount; keeping stale local balance=${apiKeyEntryFull.balance}`
588
+ );
589
+ }
590
+ } catch (error) {
591
+ this.logger.warn(
592
+ `refundProviders: balance refresh threw for ${apiKeyEntry.baseUrl}; proceeding with stale local balance`,
593
+ error
594
+ );
595
+ }
596
+ const refreshedEntry = this.storageAdapter.getApiKey(
597
+ apiKeyEntry.baseUrl
598
+ );
599
+ if (!refreshedEntry) {
600
+ continue;
601
+ }
602
+ const refundResult = await this.balanceManager.refundApiKey({
603
+ mintUrl,
604
+ baseUrl: apiKeyEntry.baseUrl,
605
+ apiKey: refreshedEntry.key,
606
+ forceRefund
607
+ });
608
+ if (refundResult.success) {
609
+ this.storageAdapter.removeApiKey(apiKeyEntry.baseUrl);
610
+ } else {
611
+ const currentEntry = this.storageAdapter.getApiKey(
612
+ apiKeyEntry.baseUrl
613
+ );
614
+ this.logger.warn(
615
+ `refundProviders: refund failed for ${apiKeyEntry.baseUrl}; currentEntry=${Boolean(currentEntry)} balance=${currentEntry?.balance ?? "none"}. Touching lastUsed to rate-limit retries.`
616
+ );
617
+ if (currentEntry) {
618
+ this.storageAdapter.updateApiKeyBalance(
619
+ apiKeyEntry.baseUrl,
620
+ currentEntry.balance
621
+ );
622
+ }
623
+ }
624
+ results.push({
625
+ baseUrl: apiKeyEntry.baseUrl,
626
+ success: refundResult.success
627
+ });
628
+ } else {
629
+ this.logger.warn(
630
+ `refundProviders: cannot refund ${apiKeyEntry.baseUrl}; apiKeyEntryFull=${Boolean(apiKeyEntryFull)} balanceManager=${Boolean(this.balanceManager)}`
631
+ );
632
+ results.push({
633
+ baseUrl: apiKeyEntry.baseUrl,
634
+ success: false
635
+ });
636
+ }
637
+ }
638
+ return results;
639
+ }
640
+ /**
641
+ * Create an insufficient balance error result
642
+ */
643
+ _createInsufficientBalanceError(required, normalizedBalances, availableBalance) {
644
+ let maxBalance = 0;
645
+ let maxMintUrl = "";
646
+ for (const mintUrl in normalizedBalances) {
647
+ const balanceInSats = normalizedBalances[mintUrl];
648
+ if (balanceInSats > maxBalance) {
649
+ maxBalance = balanceInSats;
650
+ maxMintUrl = mintUrl;
651
+ }
652
+ }
653
+ const error = new InsufficientBalanceError(
654
+ required,
655
+ availableBalance ?? maxBalance,
656
+ maxBalance,
657
+ maxMintUrl
658
+ );
659
+ return {
660
+ token: null,
661
+ status: "failed",
662
+ balance: 0,
663
+ error: error.message,
664
+ errorDetails: {
665
+ required,
666
+ available: availableBalance ?? maxBalance,
667
+ maxMintBalance: maxBalance,
668
+ maxMintUrl
669
+ }
670
+ };
671
+ }
672
+ async _getProviderTokenBalance(baseUrl, token) {
673
+ try {
674
+ const response = await fetch(`${baseUrl}v1/wallet/info`, {
675
+ headers: {
676
+ Authorization: `Bearer ${token}`
677
+ }
678
+ });
679
+ if (response.ok) {
680
+ const data = await response.json();
681
+ return data.balance / 1e3;
682
+ }
683
+ } catch {
684
+ return 0;
685
+ }
686
+ return 0;
687
+ }
688
+ };
689
+
690
+ // wallet/BalanceManager.ts
691
+ var BalanceManager = class _BalanceManager {
692
+ constructor(walletAdapter, storageAdapter, providerRegistry, cashuSpender, logger) {
693
+ this.walletAdapter = walletAdapter;
694
+ this.storageAdapter = storageAdapter;
695
+ this.providerRegistry = providerRegistry;
696
+ this.logger = (logger ?? consoleLogger).child("BalanceManager");
697
+ if (cashuSpender) {
698
+ this.cashuSpender = cashuSpender;
699
+ } else {
700
+ this.cashuSpender = new CashuSpender(
701
+ walletAdapter,
702
+ storageAdapter,
703
+ providerRegistry,
704
+ this,
705
+ this.logger
706
+ );
707
+ }
708
+ }
709
+ walletAdapter;
710
+ storageAdapter;
711
+ providerRegistry;
712
+ cashuSpender;
713
+ /** In-memory guard for per-provider wallet mutations (topup / refund) */
714
+ providerWalletOps = /* @__PURE__ */ new Map();
715
+ /** Cooldown (ms) between opposite operations on the same provider */
716
+ static PROVIDER_WALLET_COOLDOWN_MS = 1e4;
717
+ logger;
718
+ /**
719
+ * Check whether a wallet operation (topup/refund) may run for a provider.
720
+ * Returns the reason when blocked.
721
+ */
722
+ _canRunProviderWalletOperation(baseUrl, type) {
723
+ const existing = this.providerWalletOps.get(baseUrl);
724
+ if (!existing) {
725
+ return { allowed: true };
726
+ }
727
+ if (existing.type === type) {
728
+ return { allowed: true };
729
+ }
730
+ if (!existing.endTime) {
731
+ return {
732
+ allowed: false,
733
+ reason: `Provider wallet operation locked; ${existing.type} in progress`
734
+ };
735
+ }
736
+ const elapsed = Date.now() - existing.endTime;
737
+ if (elapsed < _BalanceManager.PROVIDER_WALLET_COOLDOWN_MS) {
738
+ return {
739
+ allowed: false,
740
+ reason: `Provider wallet operation locked; recent ${existing.type} completed ${Math.round(elapsed / 1e3)}s ago`
741
+ };
742
+ }
743
+ this.providerWalletOps.delete(baseUrl);
744
+ return { allowed: true };
745
+ }
746
+ _beginProviderWalletOperation(baseUrl, type) {
747
+ this.providerWalletOps.set(baseUrl, { type, startTime: Date.now() });
748
+ }
749
+ _endProviderWalletOperation(baseUrl, type) {
750
+ const existing = this.providerWalletOps.get(baseUrl);
751
+ if (existing && existing.type === type) {
752
+ existing.endTime = Date.now();
753
+ }
754
+ }
755
+ async getBalanceState() {
756
+ const mintBalances = await this.walletAdapter.getBalances();
757
+ const units = this.walletAdapter.getMintUnits();
758
+ let totalMintBalance = 0;
759
+ const normalizedMintBalances = {};
760
+ for (const url in mintBalances) {
761
+ const balance = mintBalances[url];
762
+ const unit = units[url];
763
+ const balanceInSats = getBalanceInSats(balance, unit);
764
+ normalizedMintBalances[url] = balanceInSats;
765
+ totalMintBalance += balanceInSats;
766
+ }
767
+ const providerBalances = {};
768
+ let totalProviderBalance = 0;
769
+ const apiKeys = this.storageAdapter.getAllApiKeys();
770
+ for (const apiKey of apiKeys) {
771
+ if (!providerBalances[apiKey.baseUrl]) {
772
+ providerBalances[apiKey.baseUrl] = 0;
773
+ }
774
+ providerBalances[apiKey.baseUrl] += apiKey.balance;
775
+ totalProviderBalance += apiKey.balance;
776
+ }
777
+ return {
778
+ totalBalance: totalMintBalance + totalProviderBalance,
779
+ providerBalances,
780
+ mintBalances: normalizedMintBalances
781
+ };
782
+ }
783
+ /**
784
+ * Refund API key balance - convert remaining API key balance to cashu token
785
+ * @param options - Refund options including forceRefund flag
786
+ * @returns Refund result
787
+ */
788
+ async refundApiKey(options) {
789
+ const { mintUrl, baseUrl, apiKey, forceRefund } = options;
790
+ const guard = this._canRunProviderWalletOperation(baseUrl, "refund");
791
+ if (!guard.allowed) {
792
+ this.logger.log(`Skipping refund for ${baseUrl} - ${guard.reason}`);
793
+ return { success: false, message: guard.reason };
794
+ }
795
+ this._beginProviderWalletOperation(baseUrl, "refund");
796
+ try {
797
+ return await this._refundApiKeyImpl({ mintUrl, baseUrl, apiKey, forceRefund });
798
+ } finally {
799
+ this._endProviderWalletOperation(baseUrl, "refund");
800
+ }
801
+ }
802
+ async _refundApiKeyImpl(options) {
803
+ const { mintUrl, baseUrl, apiKey, forceRefund } = options;
804
+ if (!apiKey) {
805
+ this.logger.warn(`refundApiKey: aborting for ${baseUrl} - no API key`);
806
+ return { success: false, message: "No API key to refund" };
807
+ }
808
+ if (!forceRefund) {
809
+ const apiKeyEntry = this.storageAdapter.getApiKey(baseUrl);
810
+ if (apiKeyEntry?.lastUsed) {
811
+ const fiveMinutesAgo = Date.now() - 5 * 60 * 1e3;
812
+ if (apiKeyEntry.lastUsed > fiveMinutesAgo) {
813
+ this.logger.log(
814
+ `refundApiKey: skipping ${baseUrl} - used ${Math.round((Date.now() - apiKeyEntry.lastUsed) / 1e3)}s ago`
815
+ );
816
+ return {
817
+ success: false,
818
+ message: "API key was used recently, skipping refund"
819
+ };
820
+ }
821
+ }
822
+ }
823
+ let fetchResult;
824
+ try {
825
+ fetchResult = await this.fetchRefundToken(baseUrl, apiKey);
826
+ if (fetchResult.error === "No balance to refund") {
827
+ this.logger.log(`refundApiKey: provider says no balance for ${baseUrl}; removing API key`);
828
+ this.storageAdapter.removeApiKey(baseUrl);
829
+ return { success: true, message: "No balance to refund, key cleaned up" };
830
+ }
831
+ if (!fetchResult.success) {
832
+ this.logger.warn(
833
+ `refundApiKey: fetch failed for ${baseUrl}: ${fetchResult.error || "API key refund failed"}`
834
+ );
835
+ return {
836
+ success: false,
837
+ message: fetchResult.error || "API key refund failed",
838
+ requestId: fetchResult.requestId
839
+ };
840
+ }
841
+ if (!fetchResult.token) {
842
+ this.logger.warn(`refundApiKey: no token received for ${baseUrl}`);
843
+ return {
844
+ success: false,
845
+ message: "No token received from API key refund",
846
+ requestId: fetchResult.requestId
847
+ };
848
+ }
849
+ const receiveResult = await this.cashuSpender.receiveToken(
850
+ fetchResult.token
851
+ );
852
+ const totalAmountMsat = receiveResult.unit === "msat" ? receiveResult.amount : receiveResult.amount * 1e3;
853
+ if (receiveResult.success) {
854
+ this.storageAdapter.removeApiKey(baseUrl);
855
+ } else {
856
+ this.logger.warn(
857
+ `refundApiKey: receive failed for ${baseUrl}; keeping API key. message=${receiveResult.message ?? "none"}`
858
+ );
859
+ }
860
+ return {
861
+ success: receiveResult.success,
862
+ refundedAmount: totalAmountMsat,
863
+ message: receiveResult.message,
864
+ requestId: fetchResult.requestId
865
+ };
866
+ } catch (error) {
867
+ this.logger.error("API key refund error", error);
868
+ return this._handleRefundError(error, mintUrl, fetchResult?.requestId);
869
+ }
870
+ }
871
+ /**
872
+ * Fetch refund token from provider API using API key (or xcashu token) authentication
873
+ */
874
+ async fetchRefundToken(baseUrl, apiKeyOrToken, xCashu = false) {
875
+ if (!baseUrl) {
876
+ return {
877
+ success: false,
878
+ error: "No base URL configured"
879
+ };
880
+ }
881
+ const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
882
+ const url = `${normalizedBaseUrl}v1/wallet/refund`;
883
+ const controller = new AbortController();
884
+ const timeoutId = setTimeout(() => {
885
+ controller.abort();
886
+ }, 6e4);
887
+ try {
888
+ const headers = {
889
+ "Content-Type": "application/json"
890
+ };
891
+ if (xCashu) {
892
+ headers["X-Cashu"] = apiKeyOrToken;
893
+ } else {
894
+ headers["Authorization"] = `Bearer ${apiKeyOrToken}`;
895
+ }
896
+ const response = await fetch(url, {
897
+ method: "POST",
898
+ headers,
899
+ signal: controller.signal
900
+ });
901
+ clearTimeout(timeoutId);
902
+ const requestId = response.headers.get("x-routstr-request-id") || void 0;
903
+ if (!response.ok) {
904
+ const errorData = await response.json().catch(() => ({}));
905
+ this.logger.warn(
906
+ `fetchRefundToken: non-ok response for ${url} status=${response.status} statusText=${response.statusText}`,
907
+ errorData
908
+ );
909
+ return {
910
+ success: false,
911
+ requestId,
912
+ error: `API key refund failed: ${errorData?.detail || response.statusText}`
913
+ };
914
+ }
915
+ const data = await response.json();
916
+ return {
917
+ success: true,
918
+ token: data.token,
919
+ requestId
920
+ };
921
+ } catch (error) {
922
+ clearTimeout(timeoutId);
923
+ this.logger.error("fetchRefundToken fetch error", error);
924
+ if (error instanceof Error) {
925
+ if (error.name === "AbortError") {
926
+ return {
927
+ success: false,
928
+ error: "Request timed out after 1 minute"
929
+ };
930
+ }
931
+ return {
932
+ success: false,
933
+ error: error.message
934
+ };
935
+ }
936
+ return {
937
+ success: false,
938
+ error: "Unknown error occurred during API key refund request"
939
+ };
940
+ }
941
+ }
942
+ /**
943
+ * Top up API key balance with a cashu token
944
+ */
945
+ async topUp(options) {
946
+ const { mintUrl, baseUrl, amount, token: providedToken } = options;
947
+ const guard = this._canRunProviderWalletOperation(baseUrl, "topup");
948
+ if (!guard.allowed) {
949
+ this.logger.log(`Skipping topup for ${baseUrl} - ${guard.reason}`);
950
+ return { success: false, message: guard.reason };
951
+ }
952
+ this._beginProviderWalletOperation(baseUrl, "topup");
953
+ try {
954
+ return await this._topUpImpl({ mintUrl, baseUrl, amount, token: providedToken });
955
+ } finally {
956
+ this._endProviderWalletOperation(baseUrl, "topup");
957
+ }
958
+ }
959
+ async _topUpImpl(options) {
960
+ const { mintUrl, baseUrl, amount, token: providedToken } = options;
961
+ if (!amount || amount <= 0) {
962
+ return { success: false, message: "Invalid top up amount" };
963
+ }
964
+ const apiKeyEntry = providedToken ? null : this.storageAdapter.getApiKey(baseUrl);
965
+ const apiKey = providedToken || apiKeyEntry?.key;
966
+ if (!apiKey) {
967
+ return { success: false, message: "No API key available for top up" };
968
+ }
969
+ let cashuToken = null;
970
+ let requestId;
971
+ try {
972
+ const tokenResult = await this.createProviderToken({
973
+ mintUrl,
974
+ baseUrl,
975
+ amount
976
+ });
977
+ if (!tokenResult.success || !tokenResult.token) {
978
+ return {
979
+ success: false,
980
+ message: tokenResult.error || "Unable to create top up token"
981
+ };
982
+ }
983
+ cashuToken = tokenResult.token;
984
+ const topUpResult = await this._postTopUp(baseUrl, apiKey, cashuToken);
985
+ requestId = topUpResult.requestId;
986
+ this.logger.log("topUpResult:", topUpResult);
987
+ if (!topUpResult.success) {
988
+ await this._recoverFailedTopUp(cashuToken);
989
+ return {
990
+ success: false,
991
+ message: topUpResult.error || "Top up failed",
992
+ requestId,
993
+ recoveredToken: true
994
+ };
995
+ }
996
+ return {
997
+ success: true,
998
+ toppedUpAmount: amount,
999
+ requestId
1000
+ };
1001
+ } catch (error) {
1002
+ this.logger.log(`topup error for ${baseUrl}: ${error}`);
1003
+ if (cashuToken) {
1004
+ await this._recoverFailedTopUp(cashuToken);
1005
+ }
1006
+ return this._handleTopUpError(error, mintUrl, requestId);
1007
+ }
1008
+ }
1009
+ async createProviderToken(options) {
1010
+ const {
1011
+ mintUrl,
1012
+ baseUrl,
1013
+ amount,
1014
+ retryCount = 0,
1015
+ excludeMints = [],
1016
+ p2pkPubkey
1017
+ } = options;
1018
+ const adjustedAmount = Math.ceil(amount);
1019
+ this.logger.log(`createProviderToken: baseUrl=${baseUrl} mintUrl=${mintUrl} amount=${amount} adjustedAmount=${adjustedAmount} retryCount=${retryCount}`);
1020
+ if (!adjustedAmount || isNaN(adjustedAmount)) {
1021
+ this.logger.error(`createProviderToken: invalid amount=${amount}`);
1022
+ return { success: false, error: "Invalid top up amount" };
1023
+ }
1024
+ const balanceState = await this.getBalanceState();
1025
+ const balances = await this.walletAdapter.getBalances();
1026
+ const units = this.walletAdapter.getMintUnits();
1027
+ const totalMintBalance = Object.values(balanceState.mintBalances).reduce(
1028
+ (sum, value) => sum + value,
1029
+ 0
1030
+ );
1031
+ const targetProviderBalance = balanceState.providerBalances[baseUrl] || 0;
1032
+ const refundableProviderBalance = Object.entries(
1033
+ balanceState.providerBalances
1034
+ ).filter(([providerBaseUrl]) => providerBaseUrl !== baseUrl).reduce((sum, [, value]) => sum + value, 0);
1035
+ if (totalMintBalance + targetProviderBalance < adjustedAmount && totalMintBalance + targetProviderBalance + refundableProviderBalance >= adjustedAmount && retryCount < 2) {
1036
+ await this._refundOtherProvidersForTopUp(baseUrl, mintUrl, retryCount);
1037
+ return this.createProviderToken({
1038
+ ...options,
1039
+ retryCount: retryCount + 1
1040
+ });
1041
+ }
1042
+ if (totalMintBalance + targetProviderBalance < adjustedAmount) {
1043
+ const error = new InsufficientBalanceError(
1044
+ adjustedAmount,
1045
+ totalMintBalance + targetProviderBalance,
1046
+ totalMintBalance,
1047
+ Object.entries(balanceState.mintBalances).reduce(
1048
+ (max, [url, balance]) => balance > max.balance ? { url, balance } : max,
1049
+ { url: "", balance: 0 }
1050
+ ).url
1051
+ );
1052
+ this.logger.error(`createProviderToken: insufficient balance required=${adjustedAmount} available=${totalMintBalance + targetProviderBalance} totalMint=${totalMintBalance} targetProvider=${targetProviderBalance}`);
1053
+ return { success: false, error: error.message };
1054
+ }
1055
+ const providerMints = baseUrl && this.providerRegistry ? this.providerRegistry.getProviderMints(baseUrl) : [];
1056
+ let requiredAmount = adjustedAmount;
1057
+ const supportedMintsOnly = providerMints.length > 0;
1058
+ let candidates = this._selectCandidateMints({
1059
+ balances,
1060
+ units,
1061
+ amount: requiredAmount,
1062
+ preferredMintUrl: mintUrl,
1063
+ excludeMints,
1064
+ allowedMints: supportedMintsOnly ? providerMints : void 0
1065
+ });
1066
+ if (candidates.length === 0 && supportedMintsOnly) {
1067
+ requiredAmount += 2;
1068
+ candidates = this._selectCandidateMints({
1069
+ balances,
1070
+ units,
1071
+ amount: requiredAmount,
1072
+ preferredMintUrl: mintUrl,
1073
+ excludeMints
1074
+ });
1075
+ }
1076
+ if (candidates.length === 0) {
1077
+ let maxBalance = 0;
1078
+ let maxMintUrl = "";
1079
+ for (const mintUrl2 in balances) {
1080
+ const balance = balances[mintUrl2];
1081
+ const unit = units[mintUrl2];
1082
+ const balanceInSats = getBalanceInSats(balance, unit);
1083
+ if (balanceInSats > maxBalance) {
1084
+ maxBalance = balanceInSats;
1085
+ maxMintUrl = mintUrl2;
1086
+ }
1087
+ }
1088
+ this.logger.error(`createProviderToken: no candidate mints required=${requiredAmount} totalMint=${totalMintBalance} maxBalance=${maxBalance} maxMint=${maxMintUrl}`);
1089
+ const error = new InsufficientBalanceError(
1090
+ adjustedAmount,
1091
+ totalMintBalance,
1092
+ maxBalance,
1093
+ maxMintUrl
1094
+ );
1095
+ return { success: false, error: error.message };
1096
+ }
1097
+ let lastError;
1098
+ for (const candidateMint of candidates) {
1099
+ try {
1100
+ this.logger.log(`createProviderToken: attempting mint=${candidateMint} amount=${requiredAmount}`);
1101
+ const token = await this.walletAdapter.sendToken(
1102
+ candidateMint,
1103
+ requiredAmount,
1104
+ p2pkPubkey
1105
+ );
1106
+ this.logger.log(`createProviderToken: success from mint=${candidateMint}`);
1107
+ return {
1108
+ success: true,
1109
+ token,
1110
+ selectedMintUrl: candidateMint,
1111
+ amountSpent: requiredAmount
1112
+ };
1113
+ } catch (error) {
1114
+ const errorMsg = error instanceof Error ? error.message : String(error);
1115
+ this.logger.error(`createProviderToken: mint=${candidateMint} failed: ${errorMsg}`);
1116
+ if (error instanceof Error) {
1117
+ lastError = errorMsg;
1118
+ if (isNetworkErrorMessage(error.message)) {
1119
+ this.logger.warn(`createProviderToken: network error from ${candidateMint}, trying next mint...`);
1120
+ continue;
1121
+ }
1122
+ }
1123
+ return {
1124
+ success: false,
1125
+ error: lastError || "Failed to create top up token"
1126
+ };
1127
+ }
1128
+ }
1129
+ this.logger.error(`createProviderToken: all candidate mints exhausted lastError=${lastError}`);
1130
+ return {
1131
+ success: false,
1132
+ error: lastError || "All candidate mints failed while creating top up token"
1133
+ };
1134
+ }
1135
+ _selectCandidateMints(options) {
1136
+ const {
1137
+ balances,
1138
+ units,
1139
+ amount,
1140
+ preferredMintUrl,
1141
+ excludeMints,
1142
+ allowedMints
1143
+ } = options;
1144
+ const candidates = [];
1145
+ const { selectedMintUrl: firstMint } = selectMintWithBalance(
1146
+ balances,
1147
+ units,
1148
+ amount,
1149
+ excludeMints
1150
+ );
1151
+ if (firstMint && (!allowedMints || allowedMints.length === 0 || allowedMints.includes(firstMint))) {
1152
+ candidates.push(firstMint);
1153
+ }
1154
+ const canUseMint = (mint) => {
1155
+ if (excludeMints.includes(mint)) return false;
1156
+ if (allowedMints && allowedMints.length > 0 && !allowedMints.includes(mint)) {
1157
+ return false;
1158
+ }
1159
+ const rawBalance = balances[mint] || 0;
1160
+ const unit = units[mint];
1161
+ const balanceInSats = getBalanceInSats(rawBalance, unit);
1162
+ return balanceInSats >= amount;
1163
+ };
1164
+ if (preferredMintUrl && canUseMint(preferredMintUrl) && !candidates.includes(preferredMintUrl)) {
1165
+ candidates.push(preferredMintUrl);
1166
+ }
1167
+ for (const mint in balances) {
1168
+ if (mint === preferredMintUrl || candidates.includes(mint)) continue;
1169
+ if (canUseMint(mint)) {
1170
+ candidates.push(mint);
1171
+ }
1172
+ }
1173
+ return candidates;
1174
+ }
1175
+ async _refundOtherProvidersForTopUp(baseUrl, mintUrl, retryCount) {
1176
+ const apiKeyDistribution = this.storageAdapter.getApiKeyDistribution();
1177
+ const forceRefund = retryCount >= 2;
1178
+ const apiKeysToRefund = apiKeyDistribution.filter(
1179
+ (apiKey) => apiKey.baseUrl !== baseUrl && apiKey.amount > 0
1180
+ );
1181
+ const apiKeyRefundResults = await Promise.allSettled(
1182
+ apiKeysToRefund.map(async (apiKeyEntry) => {
1183
+ const fullApiKeyEntry = this.storageAdapter.getApiKey(
1184
+ apiKeyEntry.baseUrl
1185
+ );
1186
+ if (!fullApiKeyEntry) {
1187
+ return { baseUrl: apiKeyEntry.baseUrl, success: false };
1188
+ }
1189
+ const result = await this.refundApiKey({
1190
+ mintUrl,
1191
+ baseUrl: apiKeyEntry.baseUrl,
1192
+ apiKey: fullApiKeyEntry.key,
1193
+ forceRefund
1194
+ });
1195
+ return { baseUrl: apiKeyEntry.baseUrl, success: result.success };
1196
+ })
1197
+ );
1198
+ for (const result of apiKeyRefundResults) {
1199
+ if (result.status === "fulfilled" && result.value.success) {
1200
+ this.storageAdapter.updateApiKeyBalance(result.value.baseUrl, 0);
1201
+ }
1202
+ }
1203
+ }
1204
+ /**
1205
+ * Post topup request to provider API
1206
+ */
1207
+ async _postTopUp(baseUrl, storedToken, cashuToken) {
1208
+ if (!baseUrl) {
1209
+ return {
1210
+ success: false,
1211
+ error: "No base URL configured"
1212
+ };
1213
+ }
1214
+ const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
1215
+ const url = `${normalizedBaseUrl}v1/wallet/topup?cashu_token=${encodeURIComponent(
1216
+ cashuToken
1217
+ )}`;
1218
+ const controller = new AbortController();
1219
+ const timeoutId = setTimeout(() => {
1220
+ controller.abort();
1221
+ }, 6e4);
1222
+ try {
1223
+ const response = await fetch(url, {
1224
+ method: "POST",
1225
+ headers: {
1226
+ Authorization: `Bearer ${storedToken}`,
1227
+ "Content-Type": "application/json"
1228
+ },
1229
+ signal: controller.signal
1230
+ });
1231
+ clearTimeout(timeoutId);
1232
+ const requestId = response.headers.get("x-routstr-request-id") || void 0;
1233
+ if (!response.ok) {
1234
+ const errorData = await response.json().catch(() => ({}));
1235
+ return {
1236
+ success: false,
1237
+ requestId,
1238
+ error: errorData?.detail || `Top up failed with status ${response.status}`
1239
+ };
1240
+ }
1241
+ return { success: true, requestId };
1242
+ } catch (error) {
1243
+ clearTimeout(timeoutId);
1244
+ this.logger.error("_postTopUp fetch error", error);
1245
+ if (error instanceof Error) {
1246
+ if (error.name === "AbortError") {
1247
+ return {
1248
+ success: false,
1249
+ error: "Request timed out after 1 minute"
1250
+ };
1251
+ }
1252
+ return {
1253
+ success: false,
1254
+ error: error.message
1255
+ };
1256
+ }
1257
+ return {
1258
+ success: false,
1259
+ error: "Unknown error occurred during top up request"
1260
+ };
1261
+ }
1262
+ }
1263
+ /**
1264
+ * Attempt to receive token back after failed top up
1265
+ */
1266
+ async _recoverFailedTopUp(cashuToken) {
1267
+ try {
1268
+ await this.cashuSpender.receiveToken(cashuToken);
1269
+ } catch (error) {
1270
+ this.logger.error("_recoverFailedTopUp: failed to recover token", error);
1271
+ }
1272
+ }
1273
+ /**
1274
+ * Handle refund errors with specific error types
1275
+ */
1276
+ _handleRefundError(error, mintUrl, requestId) {
1277
+ if (error instanceof Error) {
1278
+ if (isNetworkErrorMessage(error.message)) {
1279
+ return {
1280
+ success: false,
1281
+ message: `Failed to connect to the mint: ${mintUrl}`,
1282
+ requestId
1283
+ };
1284
+ }
1285
+ if (error.message.includes("Wallet not found")) {
1286
+ return {
1287
+ success: false,
1288
+ message: `Wallet couldn't be loaded. Please save this refunded cashu token manually.`,
1289
+ requestId
1290
+ };
1291
+ }
1292
+ return {
1293
+ success: false,
1294
+ message: error.message,
1295
+ requestId
1296
+ };
1297
+ }
1298
+ return {
1299
+ success: false,
1300
+ message: "Refund failed",
1301
+ requestId
1302
+ };
1303
+ }
1304
+ /**
1305
+ * Get token balance from provider
1306
+ */
1307
+ async getTokenBalance(token, baseUrl) {
1308
+ try {
1309
+ const response = await fetch(`${baseUrl}v1/wallet/info`, {
1310
+ headers: {
1311
+ Authorization: `Bearer ${token}`
1312
+ }
1313
+ });
1314
+ if (response.ok) {
1315
+ const data = await response.json();
1316
+ return {
1317
+ amount: data.balance,
1318
+ reserved: data.reserved ?? 0,
1319
+ unit: "msat",
1320
+ apiKey: data.api_key
1321
+ };
1322
+ } else {
1323
+ this.logger.warn(`getTokenBalance: status=${response.status}`);
1324
+ const data = await response.json();
1325
+ this.logger.warn("getTokenBalance: FAILED", data);
1326
+ const isInvalidApiKey = response.status === 401 && data?.detail?.error?.code === "invalid_api_key" && data?.detail?.error?.message?.includes("proofs already spent");
1327
+ return {
1328
+ amount: 0,
1329
+ reserved: data.reserved ?? 0,
1330
+ unit: "msat",
1331
+ apiKey: data.api_key,
1332
+ isInvalidApiKey,
1333
+ balanceUnknown: true
1334
+ };
1335
+ }
1336
+ } catch (error) {
1337
+ this.logger.error("getTokenBalance error", error);
1338
+ }
1339
+ return {
1340
+ amount: 0,
1341
+ reserved: 0,
1342
+ unit: "sat",
1343
+ apiKey: "",
1344
+ balanceUnknown: true
1345
+ };
1346
+ }
1347
+ /**
1348
+ * Handle topup errors with specific error types
1349
+ */
1350
+ _handleTopUpError(error, mintUrl, requestId) {
1351
+ if (error instanceof Error) {
1352
+ if (isNetworkErrorMessage(error.message)) {
1353
+ return {
1354
+ success: false,
1355
+ message: `Failed to connect to the mint: ${mintUrl}`,
1356
+ requestId
1357
+ };
1358
+ }
1359
+ if (error.message.includes("Wallet not found")) {
1360
+ return {
1361
+ success: false,
1362
+ message: "Wallet couldn't be loaded. The cashu token was recovered locally.",
1363
+ requestId
1364
+ };
1365
+ }
1366
+ return {
1367
+ success: false,
1368
+ message: error.message,
1369
+ requestId
1370
+ };
1371
+ }
1372
+ return {
1373
+ success: false,
1374
+ message: "Top up failed",
1375
+ requestId
1376
+ };
1377
+ }
1378
+ };
1379
+
1380
+ exports.BalanceManager = BalanceManager;
1381
+ exports.CashuSpender = CashuSpender;
1382
+ //# sourceMappingURL=index.js.map
1383
+ //# sourceMappingURL=index.js.map