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