@routstr/sdk 0.1.0 → 0.1.2

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