payment-kit 1.24.4 → 1.25.0

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 (115) hide show
  1. package/api/src/index.ts +3 -0
  2. package/api/src/libs/credit-utils.ts +21 -0
  3. package/api/src/libs/discount/discount.ts +13 -0
  4. package/api/src/libs/env.ts +5 -0
  5. package/api/src/libs/error.ts +14 -0
  6. package/api/src/libs/exchange-rate/coingecko-provider.ts +193 -0
  7. package/api/src/libs/exchange-rate/coinmarketcap-provider.ts +180 -0
  8. package/api/src/libs/exchange-rate/index.ts +5 -0
  9. package/api/src/libs/exchange-rate/service.ts +583 -0
  10. package/api/src/libs/exchange-rate/token-address-mapping.ts +84 -0
  11. package/api/src/libs/exchange-rate/token-addresses.json +2147 -0
  12. package/api/src/libs/exchange-rate/token-data-provider.ts +142 -0
  13. package/api/src/libs/exchange-rate/types.ts +114 -0
  14. package/api/src/libs/exchange-rate/validator.ts +319 -0
  15. package/api/src/libs/invoice-quote.ts +158 -0
  16. package/api/src/libs/invoice.ts +143 -7
  17. package/api/src/libs/math-utils.ts +46 -0
  18. package/api/src/libs/notification/template/billing-discrepancy.ts +3 -4
  19. package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +174 -79
  20. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +2 -3
  21. package/api/src/libs/notification/template/customer-credit-insufficient.ts +3 -3
  22. package/api/src/libs/notification/template/customer-credit-low-balance.ts +3 -3
  23. package/api/src/libs/notification/template/customer-revenue-succeeded.ts +2 -3
  24. package/api/src/libs/notification/template/customer-reward-succeeded.ts +9 -4
  25. package/api/src/libs/notification/template/exchange-rate-alert.ts +202 -0
  26. package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +203 -0
  27. package/api/src/libs/notification/template/subscription-slippage-warning.ts +212 -0
  28. package/api/src/libs/notification/template/subscription-will-canceled.ts +2 -2
  29. package/api/src/libs/notification/template/subscription-will-renew.ts +22 -8
  30. package/api/src/libs/payment.ts +1 -1
  31. package/api/src/libs/price.ts +4 -1
  32. package/api/src/libs/queue/index.ts +8 -0
  33. package/api/src/libs/quote-service.ts +1132 -0
  34. package/api/src/libs/quote-validation.ts +388 -0
  35. package/api/src/libs/session.ts +686 -39
  36. package/api/src/libs/slippage.ts +135 -0
  37. package/api/src/libs/subscription.ts +185 -15
  38. package/api/src/libs/util.ts +64 -3
  39. package/api/src/locales/en.ts +50 -0
  40. package/api/src/locales/zh.ts +48 -0
  41. package/api/src/queues/auto-recharge.ts +295 -21
  42. package/api/src/queues/exchange-rate-health.ts +242 -0
  43. package/api/src/queues/invoice.ts +48 -1
  44. package/api/src/queues/notification.ts +167 -1
  45. package/api/src/queues/payment.ts +177 -7
  46. package/api/src/queues/subscription.ts +436 -6
  47. package/api/src/routes/auto-recharge-configs.ts +71 -6
  48. package/api/src/routes/checkout-sessions.ts +1730 -81
  49. package/api/src/routes/connect/auto-recharge-auth.ts +2 -0
  50. package/api/src/routes/connect/change-payer.ts +2 -0
  51. package/api/src/routes/connect/change-payment.ts +61 -8
  52. package/api/src/routes/connect/change-plan.ts +161 -17
  53. package/api/src/routes/connect/collect.ts +9 -6
  54. package/api/src/routes/connect/delegation.ts +1 -0
  55. package/api/src/routes/connect/pay.ts +157 -0
  56. package/api/src/routes/connect/setup.ts +32 -10
  57. package/api/src/routes/connect/shared.ts +159 -13
  58. package/api/src/routes/connect/subscribe.ts +32 -9
  59. package/api/src/routes/credit-grants.ts +99 -0
  60. package/api/src/routes/exchange-rate-providers.ts +248 -0
  61. package/api/src/routes/exchange-rates.ts +87 -0
  62. package/api/src/routes/index.ts +4 -0
  63. package/api/src/routes/invoices.ts +280 -2
  64. package/api/src/routes/payment-links.ts +13 -0
  65. package/api/src/routes/prices.ts +84 -2
  66. package/api/src/routes/subscriptions.ts +526 -15
  67. package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
  68. package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
  69. package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
  70. package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
  71. package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
  72. package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
  73. package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
  74. package/api/src/store/models/auto-recharge-config.ts +12 -0
  75. package/api/src/store/models/checkout-session.ts +7 -0
  76. package/api/src/store/models/exchange-rate-provider.ts +225 -0
  77. package/api/src/store/models/index.ts +6 -0
  78. package/api/src/store/models/payment-intent.ts +6 -0
  79. package/api/src/store/models/price-quote.ts +284 -0
  80. package/api/src/store/models/price.ts +53 -5
  81. package/api/src/store/models/subscription.ts +11 -0
  82. package/api/src/store/models/types.ts +61 -1
  83. package/api/tests/libs/change-payment-plan.spec.ts +282 -0
  84. package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
  85. package/api/tests/libs/quote-service.spec.ts +199 -0
  86. package/api/tests/libs/session.spec.ts +464 -0
  87. package/api/tests/libs/slippage.spec.ts +109 -0
  88. package/api/tests/libs/token-data-provider.spec.ts +267 -0
  89. package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
  90. package/api/tests/models/price-dynamic.spec.ts +100 -0
  91. package/api/tests/models/price-quote.spec.ts +112 -0
  92. package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
  93. package/api/tests/routes/subscription-slippage.spec.ts +254 -0
  94. package/blocklet.yml +1 -1
  95. package/package.json +7 -6
  96. package/src/components/customer/credit-overview.tsx +14 -0
  97. package/src/components/discount/discount-info.tsx +8 -2
  98. package/src/components/invoice/list.tsx +146 -16
  99. package/src/components/invoice/table.tsx +276 -71
  100. package/src/components/invoice-pdf/template.tsx +3 -7
  101. package/src/components/metadata/form.tsx +6 -8
  102. package/src/components/price/form.tsx +519 -149
  103. package/src/components/promotion/active-redemptions.tsx +5 -3
  104. package/src/components/quote/info.tsx +234 -0
  105. package/src/hooks/subscription.ts +132 -2
  106. package/src/locales/en.tsx +145 -0
  107. package/src/locales/zh.tsx +143 -1
  108. package/src/pages/admin/billing/invoices/detail.tsx +41 -4
  109. package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
  110. package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
  111. package/src/pages/admin/products/index.tsx +12 -1
  112. package/src/pages/customer/invoice/detail.tsx +36 -12
  113. package/src/pages/customer/subscription/change-payment.tsx +65 -3
  114. package/src/pages/customer/subscription/change-plan.tsx +207 -38
  115. package/src/pages/customer/subscription/detail.tsx +599 -419
@@ -0,0 +1,583 @@
1
+ /* eslint-disable no-await-in-loop */
2
+ import BigNumber from 'bignumber.js';
3
+ import { ExchangeRateProvider } from '../../store/models/exchange-rate-provider';
4
+ import logger from '../logger';
5
+ import { events } from '../event';
6
+ import type { ExchangeRateProviderSnapshot, ExchangeRateResult, IExchangeRateProvider } from './types';
7
+ import { SymbolNotSupportedError } from './types';
8
+ import { TokenDataProvider } from './token-data-provider';
9
+ import { CoinGeckoProvider } from './coingecko-provider';
10
+ import { CoinMarketCapProvider } from './coinmarketcap-provider';
11
+ import { exchangeRateCacheTTLSeconds } from '../env';
12
+
13
+ interface CacheEntry {
14
+ data: ExchangeRateResult;
15
+ timestamp: number;
16
+ }
17
+
18
+ // Cache TTL from environment variable, default 10 minutes
19
+ const CACHE_TTL_MS = (exchangeRateCacheTTLSeconds || 10 * 60) * 1000;
20
+ const MAX_RATE_AGE_MS = 5 * 60 * 1000; // 5 minutes
21
+ const MAX_DEVIATION_PERCENT = 5; // 5%
22
+ const HISTORY_WINDOW_SIZE = 10;
23
+ const MAX_PROVIDER_SPREAD_PERCENT = 5; // 5%
24
+ /**
25
+ * Check if an error is a rate limit error (for CoinGecko)
26
+ * Detects rate limit by checking for "rate limit" text or "429" status code
27
+ */
28
+ export function isRateLimitError(error: Error): boolean {
29
+ const message = (error.message || '').toLowerCase();
30
+ return message.includes('rate limit') || message.includes('429');
31
+ }
32
+
33
+ const formatRate = (value: BigNumber) => {
34
+ const decimals = value.decimalPlaces();
35
+ return value.toFixed(decimals === null ? 0 : decimals);
36
+ };
37
+
38
+ const getMedianRate = (rates: string[]): string => {
39
+ const sorted = rates
40
+ .map((rate) => new BigNumber(rate))
41
+ .filter((rate) => rate.isFinite())
42
+ .sort((a, b) => a.comparedTo(b) ?? 0);
43
+ if (sorted.length === 0) {
44
+ throw new Error('No rates available for median calculation');
45
+ }
46
+ const mid = Math.floor(sorted.length / 2);
47
+ if (sorted.length % 2 === 1) {
48
+ return formatRate(sorted[mid]!);
49
+ }
50
+ const average = sorted[mid - 1]!.plus(sorted[mid]!).div(2);
51
+ return formatRate(average);
52
+ };
53
+
54
+ const getRateSpreadPercent = (rates: string[], medianRate: string): number => {
55
+ const numericRates = rates.map((rate) => Number(rate)).filter((rate) => Number.isFinite(rate));
56
+ const median = Number(medianRate);
57
+ if (!numericRates.length || !Number.isFinite(median) || median <= 0) {
58
+ return 0;
59
+ }
60
+ const max = Math.max(...numericRates);
61
+ const min = Math.min(...numericRates);
62
+ return (Math.abs(max - min) / median) * 100;
63
+ };
64
+
65
+ /**
66
+ * Generate human-readable provider display string
67
+ * - Single source: "CoinGecko"
68
+ * - Multiple sources: "CoinGecko (2 sources)"
69
+ */
70
+ const getProviderDisplay = (providers?: ExchangeRateProviderSnapshot[], fallbackName?: string): string => {
71
+ if (!providers?.length) {
72
+ return fallbackName || '—';
73
+ }
74
+
75
+ // Get first provider name as primary
76
+ const primaryProvider = providers[0]?.provider_name;
77
+ if (!primaryProvider) {
78
+ return fallbackName || '—';
79
+ }
80
+
81
+ if (providers.length === 1) {
82
+ return primaryProvider;
83
+ }
84
+
85
+ return `${primaryProvider} (${providers.length} sources)`;
86
+ };
87
+
88
+ export class ExchangeRateService {
89
+ private cache: Map<string, CacheEntry> = new Map();
90
+ private rateHistory: Map<string, string[]> = new Map();
91
+ private providerInstances: Map<string, IExchangeRateProvider> = new Map();
92
+
93
+ // CoinGecko rate limit state
94
+ private coingeckoRateLimitedUntil: Date | null = null;
95
+ private coingeckoRetryCount: number = 0;
96
+ private readonly RATE_LIMIT_INITIAL_DELAY_MS = 10 * 60 * 1000; // 10 minutes
97
+ private readonly RATE_LIMIT_MAX_RETRIES = 5;
98
+ private readonly RATE_LIMIT_BACKOFF_MULTIPLIER = 2;
99
+
100
+ /**
101
+ * Check if CoinGecko provider should be skipped due to rate limiting
102
+ * Returns true if:
103
+ * - Currently in rate limit cooldown period
104
+ * - Max retries exceeded (provider disabled)
105
+ */
106
+ private shouldSkipCoinGecko(): boolean {
107
+ // Check if max retries exceeded (provider disabled)
108
+ if (this.coingeckoRetryCount >= this.RATE_LIMIT_MAX_RETRIES) {
109
+ return true;
110
+ }
111
+
112
+ // Check if in rate limit cooldown period
113
+ if (this.coingeckoRateLimitedUntil) {
114
+ if (new Date() < this.coingeckoRateLimitedUntil) {
115
+ return true;
116
+ }
117
+ // Cooldown expired, clear the rate limit state (retry will happen)
118
+ this.coingeckoRateLimitedUntil = null;
119
+ }
120
+
121
+ return false;
122
+ }
123
+
124
+ /**
125
+ * Handle CoinGecko rate limit error
126
+ * Implements exponential backoff: 10min -> 20min -> 40min -> 80min -> 160min
127
+ * After max retries, provider is disabled
128
+ */
129
+ private handleCoinGeckoRateLimit(): void {
130
+ // Don't increment beyond max
131
+ if (this.coingeckoRetryCount >= this.RATE_LIMIT_MAX_RETRIES) {
132
+ logger.warn('CoinGecko provider disabled due to max rate limit retries', {
133
+ retryCount: this.coingeckoRetryCount,
134
+ maxRetries: this.RATE_LIMIT_MAX_RETRIES,
135
+ });
136
+ return;
137
+ }
138
+
139
+ // Increment retry count
140
+ this.coingeckoRetryCount += 1;
141
+
142
+ // Calculate backoff delay: initialDelay * (multiplier ^ (retryCount - 1))
143
+ // Retry 1: 10min, Retry 2: 20min, Retry 3: 40min, Retry 4: 80min, Retry 5: 160min
144
+ const backoffMultiplier = this.RATE_LIMIT_BACKOFF_MULTIPLIER ** (this.coingeckoRetryCount - 1);
145
+ const delayMs = this.RATE_LIMIT_INITIAL_DELAY_MS * backoffMultiplier;
146
+
147
+ this.coingeckoRateLimitedUntil = new Date(Date.now() + delayMs);
148
+
149
+ logger.warn('CoinGecko rate limit detected, scheduling retry', {
150
+ retryCount: this.coingeckoRetryCount,
151
+ maxRetries: this.RATE_LIMIT_MAX_RETRIES,
152
+ delayMinutes: delayMs / 60000,
153
+ rateLimitedUntil: this.coingeckoRateLimitedUntil.toISOString(),
154
+ });
155
+ }
156
+
157
+ /**
158
+ * Reset CoinGecko rate limit state after successful request
159
+ * Called when CoinGecko returns a successful response
160
+ */
161
+ private resetCoinGeckoRateLimitState(): void {
162
+ if (this.coingeckoRetryCount > 0 || this.coingeckoRateLimitedUntil) {
163
+ logger.info('CoinGecko rate limit state reset after successful request', {
164
+ previousRetryCount: this.coingeckoRetryCount,
165
+ });
166
+ }
167
+ this.coingeckoRetryCount = 0;
168
+ this.coingeckoRateLimitedUntil = null;
169
+ }
170
+
171
+ /**
172
+ * Check if provider is CoinGecko type
173
+ */
174
+ private isCoinGeckoProvider(provider: { type?: string }): boolean {
175
+ return provider.type === 'coingecko';
176
+ }
177
+
178
+ /**
179
+ * Get exchange rate for a given symbol
180
+ * @param symbol - Token symbol (e.g., 'ABT', 'ETH')
181
+ * @returns Exchange rate result with provider info
182
+ */
183
+ async getRate(symbol: string): Promise<ExchangeRateResult> {
184
+ const cacheKey = symbol.toUpperCase();
185
+
186
+ // Check cache first
187
+ const cached = this.cache.get(cacheKey);
188
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
189
+ logger.debug('Exchange rate cache hit', { symbol, age: Date.now() - cached.timestamp });
190
+ return cached.data;
191
+ }
192
+
193
+ // Load active providers from database
194
+ const allProviders = await ExchangeRateProvider.getActiveProviders();
195
+ if (allProviders.length === 0) {
196
+ throw new Error('No active exchange rate providers available');
197
+ }
198
+
199
+ // Filter out CoinGecko if rate limited
200
+ const shouldSkipCG = this.shouldSkipCoinGecko();
201
+ const providers = allProviders.filter((provider) => {
202
+ if (this.isCoinGeckoProvider(provider) && shouldSkipCG) {
203
+ logger.info('Skipping CoinGecko provider due to rate limit', {
204
+ rateLimitedUntil: this.coingeckoRateLimitedUntil?.toISOString(),
205
+ retryCount: this.coingeckoRetryCount,
206
+ });
207
+ return false;
208
+ }
209
+ return true;
210
+ });
211
+
212
+ if (providers.length === 0) {
213
+ throw new Error('No active exchange rate providers available (all providers rate limited)');
214
+ }
215
+
216
+ // Fetch all providers and compute consensus (median)
217
+ let lastError: Error | null = null;
218
+ const results = await Promise.all(
219
+ providers.map(async (provider) => {
220
+ try {
221
+ const result = await this.fetchWithProvider(provider, symbol);
222
+
223
+ // Reset CoinGecko rate limit state on success
224
+ if (this.isCoinGeckoProvider(provider)) {
225
+ this.resetCoinGeckoRateLimitState();
226
+ }
227
+
228
+ return { ok: true as const, result };
229
+ } catch (error: any) {
230
+ lastError = error;
231
+
232
+ // Handle CoinGecko rate limit errors specially
233
+ if (this.isCoinGeckoProvider(provider) && isRateLimitError(error)) {
234
+ this.handleCoinGeckoRateLimit();
235
+ logger.warn('CoinGecko rate limit error handled', {
236
+ provider: provider.name,
237
+ symbol,
238
+ error: error.message,
239
+ nextRetryAt: this.coingeckoRateLimitedUntil?.toISOString(),
240
+ });
241
+ return { ok: false as const, error };
242
+ }
243
+
244
+ // SymbolNotSupportedError is a data issue, not a service issue
245
+ // Don't record it as a provider failure to avoid pausing healthy providers
246
+ if (error instanceof SymbolNotSupportedError) {
247
+ logger.info('Symbol not supported by provider', {
248
+ provider: provider.name,
249
+ symbol,
250
+ error: error.message,
251
+ });
252
+ } else {
253
+ logger.warn('Provider failed during consensus fetch', {
254
+ provider: provider.name,
255
+ symbol,
256
+ error: error.message,
257
+ });
258
+ // Only record failure for actual service errors
259
+ provider.recordFailure(error.message).catch((err) => {
260
+ logger.error('Failed to record provider failure', { error: err.message });
261
+ });
262
+ }
263
+ return { ok: false as const, error };
264
+ }
265
+ })
266
+ );
267
+
268
+ const successResults = results.filter((item) => item.ok).map((item) => item.result);
269
+ if (successResults.length === 0) {
270
+ const errorMessage =
271
+ lastError && typeof lastError === 'object' && 'message' in lastError
272
+ ? String((lastError as any).message)
273
+ : 'All providers failed';
274
+
275
+ // Alert admin that all providers are unavailable
276
+ this.emitProvidersUnavailableAlert(symbol, errorMessage);
277
+
278
+ throw new Error(`Failed to fetch exchange rate for ${symbol}: ${errorMessage}`);
279
+ }
280
+
281
+ const providerSnapshots: ExchangeRateProviderSnapshot[] = successResults.map((result) => ({
282
+ provider_id: result.provider_id,
283
+ provider_name: result.provider_name,
284
+ rate: result.rate,
285
+ timestamp_ms: result.timestamp_ms,
286
+ degraded: result.degraded,
287
+ degraded_reason: result.degraded_reason,
288
+ }));
289
+
290
+ const consensusRate = getMedianRate(providerSnapshots.map((p) => p.rate));
291
+ const consensusTimestamp = Math.max(...providerSnapshots.map((p) => p.timestamp_ms));
292
+ const spreadPercent = getRateSpreadPercent(
293
+ providerSnapshots.map((p) => p.rate),
294
+ consensusRate
295
+ );
296
+
297
+ const degradedReasons: string[] = [];
298
+ if (providers.length >= 2 && providerSnapshots.length < 2) {
299
+ degradedReasons.push(`Only ${providerSnapshots.length} provider returned a rate`);
300
+ }
301
+ if (providerSnapshots.length < providers.length) {
302
+ degradedReasons.push(`Some providers failed (${providerSnapshots.length}/${providers.length})`);
303
+ }
304
+ if (providerSnapshots.some((snapshot) => snapshot.degraded)) {
305
+ degradedReasons.push('One or more providers are degraded');
306
+ }
307
+ if (spreadPercent > MAX_PROVIDER_SPREAD_PERCENT) {
308
+ degradedReasons.push(`Rate spread ${spreadPercent.toFixed(2)}% exceeds threshold`);
309
+
310
+ // Emit alert for high rate spread between providers
311
+ this.emitRateSpreadAlert(symbol, spreadPercent, providerSnapshots);
312
+ }
313
+
314
+ logger.info('Exchange rate consensus computed', {
315
+ symbol,
316
+ providers_total: providers.length,
317
+ providers_success: providerSnapshots.length,
318
+ consensus_rate: consensusRate,
319
+ spread_percent: spreadPercent.toFixed(2),
320
+ degraded: degradedReasons.length > 0,
321
+ degraded_reason: degradedReasons.join('; ') || null,
322
+ });
323
+
324
+ const fetchedAt = Date.now();
325
+ const consensusResult: ExchangeRateResult = {
326
+ rate: consensusRate,
327
+ timestamp_ms: consensusTimestamp,
328
+ fetched_at: fetchedAt,
329
+ provider_id: 'consensus',
330
+ provider_name: 'median',
331
+ provider_display: getProviderDisplay(providerSnapshots),
332
+ degraded: degradedReasons.length > 0,
333
+ degraded_reason: degradedReasons.length > 0 ? degradedReasons.join('; ') : undefined,
334
+ providers: providerSnapshots,
335
+ consensus_method: 'median',
336
+ };
337
+
338
+ this.cache.set(cacheKey, {
339
+ data: consensusResult,
340
+ timestamp: fetchedAt,
341
+ });
342
+
343
+ return consensusResult;
344
+ }
345
+
346
+ /**
347
+ * Fetch rate from a specific provider with validation
348
+ */
349
+ private async fetchWithProvider(providerModel: ExchangeRateProvider, symbol: string): Promise<ExchangeRateResult> {
350
+ // Get provider instance
351
+ let provider = this.providerInstances.get(providerModel.name);
352
+ if (!provider) {
353
+ // Decrypt sensitive config data (API keys) before creating provider instance
354
+ const decryptedConfig = ExchangeRateProvider.decryptConfig(providerModel.config || {}) || {};
355
+ const type = providerModel.type || 'token-data';
356
+ switch (type) {
357
+ case 'token-data':
358
+ provider = new TokenDataProvider(decryptedConfig);
359
+ break;
360
+ case 'coingecko':
361
+ provider = new CoinGeckoProvider(decryptedConfig);
362
+ break;
363
+ case 'coinmarketcap':
364
+ provider = new CoinMarketCapProvider(decryptedConfig);
365
+ break;
366
+ default:
367
+ logger.warn(`Unknown provider type: ${type}, falling back to token-data`);
368
+ provider = new TokenDataProvider(decryptedConfig);
369
+ break;
370
+ }
371
+ this.providerInstances.set(providerModel.name, provider);
372
+ }
373
+
374
+ // Fetch rate
375
+ const rateData = await provider.fetch(symbol);
376
+
377
+ // Validate the rate
378
+ const validation = this.validateRate(symbol, rateData.rate, rateData.timestamp_ms, providerModel);
379
+
380
+ // Record success in database (fire-and-forget to avoid SQLITE_BUSY)
381
+ providerModel.recordSuccess().catch((err) => {
382
+ logger.error('Failed to record provider success', { error: err.message });
383
+ });
384
+
385
+ return {
386
+ ...rateData,
387
+ provider_id: providerModel.id,
388
+ provider_name: providerModel.name,
389
+ provider_display: providerModel.name, // Single source: just the name
390
+ degraded: validation.degraded,
391
+ degraded_reason: validation.degraded_reason,
392
+ };
393
+ }
394
+
395
+ /**
396
+ * Validate exchange rate data
397
+ */
398
+ private validateRate(
399
+ symbol: string,
400
+ rateStr: string,
401
+ timestampMs: number,
402
+ providerModel?: ExchangeRateProvider
403
+ ): { degraded: boolean; degraded_reason?: string } {
404
+ const rate = parseFloat(rateStr);
405
+
406
+ // 1. Check rate is positive and finite
407
+ if (rate <= 0 || !Number.isFinite(rate)) {
408
+ throw new Error(`Invalid rate value: ${rateStr}`);
409
+ }
410
+
411
+ // 2. Check timestamp freshness (≤ 5 minutes)
412
+ const rateAge = Date.now() - timestampMs;
413
+ if (rateAge > MAX_RATE_AGE_MS) {
414
+ logger.warn('Rate timestamp is stale', {
415
+ symbol,
416
+ provider: providerModel?.name,
417
+ provider_id: providerModel?.id,
418
+ rate_age_sec: Math.floor(rateAge / 1000),
419
+ max_age_sec: MAX_RATE_AGE_MS / 1000,
420
+ });
421
+ return {
422
+ degraded: true,
423
+ degraded_reason: `Rate is ${Math.floor(rateAge / 1000)}s old (max: ${MAX_RATE_AGE_MS / 1000}s)`,
424
+ };
425
+ }
426
+
427
+ // 3. Check deviation from historical average
428
+ const history = this.rateHistory.get(symbol) || [];
429
+ if (history.length >= 3) {
430
+ const historicalRates = history.map((r) => parseFloat(r));
431
+ const sum = historicalRates.reduce((acc, r) => acc + r, 0);
432
+ const avg = sum / history.length;
433
+ const deviationPercent = (Math.abs(rate - avg) / avg) * 100;
434
+
435
+ if (deviationPercent > MAX_DEVIATION_PERCENT) {
436
+ logger.warn('Rate deviation exceeds threshold', {
437
+ symbol,
438
+ provider: providerModel?.name,
439
+ provider_id: providerModel?.id,
440
+ rate: rateStr,
441
+ average: avg.toFixed(8),
442
+ deviation: `${deviationPercent.toFixed(2)}%`,
443
+ });
444
+
445
+ return {
446
+ degraded: true,
447
+ degraded_reason: `Rate deviates ${deviationPercent.toFixed(2)}% from historical average`,
448
+ };
449
+ }
450
+ }
451
+
452
+ // Update history
453
+ history.push(rateStr);
454
+ if (history.length > HISTORY_WINDOW_SIZE) {
455
+ history.shift();
456
+ }
457
+ this.rateHistory.set(symbol, history);
458
+
459
+ return { degraded: false };
460
+ }
461
+
462
+ /**
463
+ * Clear cache for a specific symbol or all symbols
464
+ */
465
+ clearCache(symbol?: string): void {
466
+ if (symbol) {
467
+ this.cache.delete(symbol.toUpperCase());
468
+ } else {
469
+ this.cache.clear();
470
+ }
471
+ }
472
+
473
+ // Alert state to prevent duplicate alerts
474
+ private rateSpreadAlertHistory: Map<string, number> = new Map();
475
+ private providersUnavailableAlertHistory: Map<string, number> = new Map();
476
+ private readonly ALERT_COOLDOWN_MS = 30 * 60 * 1000; // 30 minutes
477
+
478
+ /**
479
+ * Emit alert when rate spread between providers exceeds threshold
480
+ * Rate-limited to prevent alert fatigue
481
+ */
482
+ private emitRateSpreadAlert(
483
+ symbol: string,
484
+ spreadPercent: number,
485
+ providerSnapshots: ExchangeRateProviderSnapshot[]
486
+ ): void {
487
+ const alertKey = `spread-${symbol}`;
488
+ const lastAlert = this.rateSpreadAlertHistory.get(alertKey);
489
+ const now = Date.now();
490
+
491
+ // Rate limit alerts
492
+ if (lastAlert && now - lastAlert < this.ALERT_COOLDOWN_MS) {
493
+ return;
494
+ }
495
+
496
+ this.rateSpreadAlertHistory.set(alertKey, now);
497
+
498
+ const alertData = {
499
+ severity: 'warning',
500
+ type: 'rate_spread_exceeded',
501
+ symbol,
502
+ spreadPercent: spreadPercent.toFixed(2),
503
+ threshold: MAX_PROVIDER_SPREAD_PERCENT,
504
+ timestamp: new Date().toISOString(),
505
+ providers: providerSnapshots.map((p) => ({
506
+ id: p.provider_id,
507
+ name: p.provider_name,
508
+ rate: p.rate,
509
+ })),
510
+ impact: 'Exchange rate consensus may be less reliable',
511
+ action: 'Review provider configurations and check for data discrepancies',
512
+ };
513
+
514
+ logger.warn('Exchange rate spread alert', alertData);
515
+
516
+ // Emit internal event to trigger email notification to admin
517
+ events.emit('exchange_rate.spread_exceeded', {
518
+ alertType: 'spread_exceeded' as const,
519
+ symbol,
520
+ severity: 'warning',
521
+ timestamp: new Date().toISOString(),
522
+ spreadPercent: spreadPercent.toFixed(2),
523
+ threshold: MAX_PROVIDER_SPREAD_PERCENT,
524
+ providers: providerSnapshots.map((p) => ({
525
+ id: p.provider_id,
526
+ name: p.provider_name,
527
+ rate: p.rate,
528
+ })),
529
+ });
530
+ }
531
+
532
+ /**
533
+ * Emit alert when all exchange rate providers are unavailable
534
+ * Rate-limited to prevent alert fatigue
535
+ */
536
+ private emitProvidersUnavailableAlert(symbol: string, errorMessage: string): void {
537
+ const alertKey = `unavailable-${symbol}`;
538
+ const lastAlert = this.providersUnavailableAlertHistory.get(alertKey);
539
+ const now = Date.now();
540
+
541
+ // Rate limit alerts
542
+ if (lastAlert && now - lastAlert < this.ALERT_COOLDOWN_MS) {
543
+ return;
544
+ }
545
+
546
+ this.providersUnavailableAlertHistory.set(alertKey, now);
547
+
548
+ const alertData = {
549
+ severity: 'critical',
550
+ type: 'providers_unavailable',
551
+ symbol,
552
+ errorMessage,
553
+ timestamp: new Date().toISOString(),
554
+ impact: 'Users cannot make purchases requiring exchange rate conversion',
555
+ action: 'Check provider configurations and network connectivity immediately',
556
+ };
557
+
558
+ logger.error('All exchange rate providers unavailable', alertData);
559
+
560
+ // Emit internal event to trigger email notification to admin
561
+ events.emit('exchange_rate.providers_unavailable', {
562
+ alertType: 'providers_unavailable' as const,
563
+ symbol,
564
+ severity: 'critical',
565
+ timestamp: new Date().toISOString(),
566
+ providers: [],
567
+ });
568
+ }
569
+ }
570
+
571
+ // Singleton instance
572
+ let serviceInstance: ExchangeRateService | null = null;
573
+
574
+ export function getExchangeRateService(): ExchangeRateService {
575
+ if (!serviceInstance) {
576
+ serviceInstance = new ExchangeRateService();
577
+ logger.info('Exchange rate service initialized', {
578
+ cache_ttl_seconds: CACHE_TTL_MS / 1000,
579
+ cache_ttl_source: process.env.EXCHANGE_RATE_CACHE_TTL_SECONDS ? 'env' : 'default',
580
+ });
581
+ }
582
+ return serviceInstance;
583
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Token Address Mapping
3
+ *
4
+ * Provides mapping between token symbols and their Ethereum mainnet contract addresses
5
+ * Extracted from tokenList.json chainId: 1
6
+ */
7
+
8
+ import { ChainType } from '../../store/models';
9
+ import tokenAddresses from './token-addresses.json';
10
+
11
+ export interface TokenInfo {
12
+ address: string;
13
+ symbol: string;
14
+ name: string;
15
+ decimals: number;
16
+ coinGeckoId?: string;
17
+ }
18
+
19
+ // Token address mapping for Ethereum mainnet (chainId: 1)
20
+ // This is generated from tokenList.json via scripts/generate-token-mapping.js
21
+ export const TOKEN_ADDRESS_MAP: Record<string, TokenInfo> = tokenAddresses as any;
22
+
23
+ /**
24
+ * Get token info by symbol
25
+ */
26
+ export function getTokenInfo(symbol: string): TokenInfo | undefined {
27
+ return TOKEN_ADDRESS_MAP[symbol.toUpperCase()];
28
+ }
29
+
30
+ /**
31
+ * Get token address by symbol
32
+ */
33
+ export function getTokenAddress(symbol: string): string | undefined {
34
+ return TOKEN_ADDRESS_MAP[symbol.toUpperCase()]?.address;
35
+ }
36
+
37
+ /**
38
+ * Get CoinGecko ID by symbol
39
+ */
40
+ export function getCoinGeckoId(symbol: string): string | undefined {
41
+ return TOKEN_ADDRESS_MAP[symbol.toUpperCase()]?.coinGeckoId;
42
+ }
43
+
44
+ /**
45
+ * Check if a symbol has address mapping
46
+ */
47
+ export function hasTokenAddress(symbol: string, methodType?: ChainType): boolean {
48
+ if (methodType === 'arcblock') {
49
+ return !!TOKEN_ADDRESS_MAP.ABT;
50
+ }
51
+ return !!TOKEN_ADDRESS_MAP[symbol.toUpperCase()];
52
+ }
53
+
54
+ /**
55
+ * Get the actual symbol to use for exchange rate calculation
56
+ * For ArcBlock payment method, always use ABT regardless of the currency symbol
57
+ * For other payment methods, use the original symbol
58
+ */
59
+ export function getExchangeRateSymbol(symbol: string, methodType?: ChainType): string {
60
+ if (methodType === 'arcblock') {
61
+ return 'ABT';
62
+ }
63
+ return symbol.toUpperCase();
64
+ }
65
+
66
+ /**
67
+ * Get token info for exchange rate calculation
68
+ * For ArcBlock payment method, always return ABT token info
69
+ * For other payment methods, return the token info for the given symbol
70
+ */
71
+ export function getTokenInfoForRate(
72
+ symbol: string,
73
+ methodType?: 'arcblock' | 'ethereum' | 'base' | 'stripe'
74
+ ): TokenInfo | undefined {
75
+ const rateSymbol = getExchangeRateSymbol(symbol, methodType);
76
+ return TOKEN_ADDRESS_MAP[rateSymbol];
77
+ }
78
+
79
+ /**
80
+ * Get all supported symbols
81
+ */
82
+ export function getSupportedSymbols(): string[] {
83
+ return Object.keys(TOKEN_ADDRESS_MAP);
84
+ }