payment-kit 1.24.4 → 1.25.1

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 (116) 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 +3 -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/refund.ts +41 -9
  47. package/api/src/queues/subscription.ts +436 -6
  48. package/api/src/routes/auto-recharge-configs.ts +71 -6
  49. package/api/src/routes/checkout-sessions.ts +1730 -81
  50. package/api/src/routes/connect/auto-recharge-auth.ts +2 -0
  51. package/api/src/routes/connect/change-payer.ts +2 -0
  52. package/api/src/routes/connect/change-payment.ts +61 -8
  53. package/api/src/routes/connect/change-plan.ts +161 -17
  54. package/api/src/routes/connect/collect.ts +9 -6
  55. package/api/src/routes/connect/delegation.ts +1 -0
  56. package/api/src/routes/connect/pay.ts +157 -0
  57. package/api/src/routes/connect/setup.ts +32 -10
  58. package/api/src/routes/connect/shared.ts +159 -13
  59. package/api/src/routes/connect/subscribe.ts +32 -9
  60. package/api/src/routes/credit-grants.ts +99 -0
  61. package/api/src/routes/exchange-rate-providers.ts +248 -0
  62. package/api/src/routes/exchange-rates.ts +87 -0
  63. package/api/src/routes/index.ts +4 -0
  64. package/api/src/routes/invoices.ts +280 -2
  65. package/api/src/routes/payment-links.ts +13 -0
  66. package/api/src/routes/prices.ts +84 -2
  67. package/api/src/routes/subscriptions.ts +526 -15
  68. package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
  69. package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
  70. package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
  71. package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
  72. package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
  73. package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
  74. package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
  75. package/api/src/store/models/auto-recharge-config.ts +12 -0
  76. package/api/src/store/models/checkout-session.ts +7 -0
  77. package/api/src/store/models/exchange-rate-provider.ts +225 -0
  78. package/api/src/store/models/index.ts +6 -0
  79. package/api/src/store/models/payment-intent.ts +6 -0
  80. package/api/src/store/models/price-quote.ts +284 -0
  81. package/api/src/store/models/price.ts +53 -5
  82. package/api/src/store/models/subscription.ts +11 -0
  83. package/api/src/store/models/types.ts +61 -1
  84. package/api/tests/libs/change-payment-plan.spec.ts +282 -0
  85. package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
  86. package/api/tests/libs/quote-service.spec.ts +199 -0
  87. package/api/tests/libs/session.spec.ts +464 -0
  88. package/api/tests/libs/slippage.spec.ts +109 -0
  89. package/api/tests/libs/token-data-provider.spec.ts +267 -0
  90. package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
  91. package/api/tests/models/price-dynamic.spec.ts +100 -0
  92. package/api/tests/models/price-quote.spec.ts +112 -0
  93. package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
  94. package/api/tests/routes/subscription-slippage.spec.ts +254 -0
  95. package/blocklet.yml +1 -1
  96. package/package.json +7 -6
  97. package/src/components/customer/credit-overview.tsx +14 -0
  98. package/src/components/discount/discount-info.tsx +8 -2
  99. package/src/components/invoice/list.tsx +146 -16
  100. package/src/components/invoice/table.tsx +276 -71
  101. package/src/components/invoice-pdf/template.tsx +3 -7
  102. package/src/components/metadata/form.tsx +6 -8
  103. package/src/components/price/form.tsx +519 -149
  104. package/src/components/promotion/active-redemptions.tsx +5 -3
  105. package/src/components/quote/info.tsx +234 -0
  106. package/src/hooks/subscription.ts +132 -2
  107. package/src/locales/en.tsx +145 -0
  108. package/src/locales/zh.tsx +143 -1
  109. package/src/pages/admin/billing/invoices/detail.tsx +41 -4
  110. package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
  111. package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
  112. package/src/pages/admin/products/index.tsx +12 -1
  113. package/src/pages/customer/invoice/detail.tsx +36 -12
  114. package/src/pages/customer/subscription/change-payment.tsx +65 -3
  115. package/src/pages/customer/subscription/change-plan.tsx +207 -38
  116. package/src/pages/customer/subscription/detail.tsx +599 -419
package/api/src/index.ts CHANGED
@@ -30,6 +30,7 @@ import { startCreditGrantQueue } from './queues/credit-grant';
30
30
  import { startReconciliationQueue } from './queues/credit-reconciliation';
31
31
  import { startDiscountStatusQueue } from './queues/discount-status';
32
32
  import { startEventQueue } from './queues/event';
33
+ import { startExchangeRateHealthQueue } from './queues/exchange-rate-health';
33
34
  import { startInvoiceQueue } from './queues/invoice';
34
35
  import { startNotificationQueue } from './queues/notification';
35
36
  import { startPaymentQueue } from './queues/payment';
@@ -147,6 +148,8 @@ export const server = app.listen(port, (err?: any) => {
147
148
  startTokenTransferQueue().then(() => logger.info('token transfer queue started'));
148
149
  startReconciliationQueue().then(() => logger.info('credit reconciliation queue started'));
149
150
  startDiscountStatusQueue().then(() => logger.info('discount status queue started'));
151
+ startExchangeRateHealthQueue();
152
+ logger.info('exchange rate health queue started');
150
153
  startUploadBillingInfoListener();
151
154
 
152
155
  if (process.env.BLOCKLET_MODE === 'production') {
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Credit-related utility functions
3
+ * Extracted to break circular dependency between payment.ts, session.ts, and subscription.ts
4
+ */
5
+ import type { TLineItemExpanded, TPriceExpanded } from '../store/models';
6
+ import { Price as TPrice } from '../store/models/price';
7
+
8
+ export function isCreditMetered(price: TPrice | TPriceExpanded) {
9
+ return price.type === 'recurring' && price.recurring?.usage_type === 'metered' && !!price.recurring?.meter_id;
10
+ }
11
+
12
+ export function isCreditMeteredLineItems(lineItems: TLineItemExpanded[]) {
13
+ return lineItems.every((item) => item.price && isCreditMetered(item.price));
14
+ }
15
+
16
+ /**
17
+ * Get the effective price for a subscription item (upsell_price takes precedence)
18
+ */
19
+ export function getSubscriptionItemPrice(item: TLineItemExpanded) {
20
+ return item.upsell_price || item.price;
21
+ }
@@ -11,6 +11,9 @@ const getItemsTotalAmount = (lineItems: TLineItemExpanded[], currencyId: string,
11
11
 
12
12
  lineItems.forEach((item) => {
13
13
  const price = item.upsell_price || item.price;
14
+
15
+ const dynamicAmount =
16
+ price?.pricing_type === 'dynamic' ? (item as any).custom_amount || (item as any).quoted_amount : undefined;
14
17
  const unitPrice = getPriceUintAmountByCurrency(price, currencyId);
15
18
 
16
19
  if (price?.type === 'recurring') {
@@ -22,6 +25,11 @@ const getItemsTotalAmount = (lineItems: TLineItemExpanded[], currencyId: string,
22
25
  }
23
26
  }
24
27
 
28
+ if (dynamicAmount) {
29
+ totalUnitPrice = totalUnitPrice.add(new BN(dynamicAmount));
30
+ return;
31
+ }
32
+
25
33
  totalUnitPrice = totalUnitPrice.add(new BN(unitPrice).mul(new BN(item.quantity)));
26
34
  });
27
35
  return totalUnitPrice.toString();
@@ -29,6 +37,11 @@ const getItemsTotalAmount = (lineItems: TLineItemExpanded[], currencyId: string,
29
37
 
30
38
  const getItemAmount = (item: TLineItemExpanded, currencyId: string) => {
31
39
  const price = item.upsell_price || item.price;
40
+ const dynamicAmount =
41
+ price?.pricing_type === 'dynamic' ? (item as any).custom_amount || (item as any).quoted_amount : undefined;
42
+ if (dynamicAmount) {
43
+ return new BN(dynamicAmount).toString();
44
+ }
32
45
  const unitPrice = getPriceUintAmountByCurrency(price, currencyId);
33
46
  return new BN(unitPrice).mul(new BN(item.quantity)).toString();
34
47
  };
@@ -18,6 +18,7 @@ export const depositVaultCronTime: string = process.env.DEPOSIT_VAULT_CRON_TIME
18
18
  export const creditConsumptionCronTime: string = process.env.CREDIT_CONSUMPTION_CRON_TIME || '0 */10 * * * *'; // 默认每 10 min 执行一次
19
19
  export const vendorStatusCheckCronTime: string = process.env.VENDOR_STATUS_CHECK_CRON_TIME || '0 */10 * * * *'; // 默认每 10 min 执行一次
20
20
  export const vendorReturnScanCronTime: string = process.env.VENDOR_RETURN_SCAN_CRON_TIME || '0 */10 * * * *'; // 默认每 10 min 执行一次
21
+ export const quoteCleanupCronTime: string = process.env.QUOTE_CLEANUP_CRON_TIME || '0 0 2 * * *'; // 默认每天凌晨 2 点执行一次
21
22
  export const vendorTimeoutMinutes: number = process.env.VENDOR_TIMEOUT_MINUTES
22
23
  ? +process.env.VENDOR_TIMEOUT_MINUTES
23
24
  : 10; // 默认 10 分钟超时
@@ -46,6 +47,10 @@ export const updateDataConcurrency: number = process.env.UPDATE_DATA_CONCURRENCY
46
47
  ? +process.env.UPDATE_DATA_CONCURRENCY
47
48
  : 5; // 默认并发数为 5
48
49
 
50
+ export const exchangeRateCacheTTLSeconds: number = process.env.EXCHANGE_RATE_CACHE_TTL_SECONDS
51
+ ? +process.env.EXCHANGE_RATE_CACHE_TTL_SECONDS
52
+ : 10 * 60;
53
+
49
54
  // System-level maximum pending amount limit (in token format, e.g., "10")
50
55
  // Default is 0 (disabled). Set PAYMENT_KIT_MAX_PENDING_AMOUNT to enable this limit.
51
56
  export const systemMaxPendingAmount: number = process.env.PAYMENT_KIT_MAX_PENDING_AMOUNT
@@ -1,3 +1,4 @@
1
+ /* eslint-disable max-classes-per-file */
1
2
  export default class CustomError extends Error {
2
3
  code: string;
3
4
 
@@ -11,3 +12,16 @@ export default class CustomError extends Error {
11
12
  this.code = code;
12
13
  }
13
14
  }
15
+
16
+ /**
17
+ * NonRetryableError - Used for errors that should not be retried
18
+ * Typical cases: configuration errors, missing data, validation failures
19
+ */
20
+ export class NonRetryableError extends CustomError {
21
+ nonRetryable: boolean = true;
22
+
23
+ constructor(code: string, message: string) {
24
+ super(code, message);
25
+ this.name = 'NonRetryableError';
26
+ }
27
+ }
@@ -0,0 +1,193 @@
1
+ import axios from 'axios';
2
+ import BigNumber from 'bignumber.js';
3
+
4
+ import logger from '../logger';
5
+ import { getTokenInfo } from './token-address-mapping';
6
+ import type { ExchangeRateData, IExchangeRateProvider, ProviderConfig } from './types';
7
+ import { SymbolNotSupportedError } from './types';
8
+
9
+ /**
10
+ * CoinGecko Exchange Rate Provider
11
+ *
12
+ * Fetches token prices from CoinGecko API
13
+ * Uses the /simple/token_price/ethereum endpoint which supports contract addresses
14
+ * Uses Ethereum mainnet (chainId: 1) contract addresses
15
+ */
16
+ export class CoinGeckoProvider implements IExchangeRateProvider {
17
+ public readonly name = 'coingecko';
18
+
19
+ private baseUrl: string;
20
+ private timeout: number;
21
+ private apiKey?: string;
22
+ private zeroAddress = '0x0000000000000000000000000000000000000000';
23
+
24
+ constructor(config: ProviderConfig = {}) {
25
+ this.baseUrl = config.base_url || 'https://api.coingecko.com/api/v3';
26
+ this.timeout = config.timeout || 10000; // 10 seconds default
27
+ this.apiKey = config.api_key;
28
+ }
29
+
30
+ async fetch(symbol: string): Promise<ExchangeRateData> {
31
+ try {
32
+ // Get token info from mapping
33
+ const tokenInfo = getTokenInfo(symbol);
34
+ if (!tokenInfo) {
35
+ // Symbol not in mapping - this is a data issue, not a service issue
36
+ throw new SymbolNotSupportedError(symbol, this.name);
37
+ }
38
+
39
+ const { address, coinGeckoId } = tokenInfo;
40
+ const addressLower = address?.toLowerCase();
41
+ const useTokenPriceEndpoint = !!address && addressLower !== this.zeroAddress;
42
+
43
+ if (!useTokenPriceEndpoint) {
44
+ return await this.fetchByCoinId(symbol, coinGeckoId);
45
+ }
46
+
47
+ // Use contract address endpoint for Ethereum tokens
48
+ const url = `${this.baseUrl}/simple/token_price/ethereum`;
49
+ const params: any = {
50
+ contract_addresses: address,
51
+ // IMPORTANT: Must be 'usd' because the entire dynamic pricing system uses USD as the sole base currency
52
+ // All exchange rates are defined as "USD per Token" (e.g., 1 ABT = 0.258 USD)
53
+ // This is a P0 architectural constraint - see plan.md "USD 是唯一价值锚点"
54
+ vs_currencies: 'usd',
55
+ include_last_updated_at: true,
56
+ };
57
+
58
+ // Add API key if configured (for rate limit increase)
59
+ if (this.apiKey) {
60
+ params.x_cg_pro_api_key = this.apiKey;
61
+ }
62
+
63
+ logger.debug('Fetching exchange rate from CoinGecko', { symbol, address, url });
64
+
65
+ const response = await axios.get(url, {
66
+ params,
67
+ timeout: this.timeout,
68
+ headers: {
69
+ 'User-Agent': 'PaymentKit/1.0',
70
+ Accept: 'application/json',
71
+ },
72
+ });
73
+
74
+ if (!response.data || typeof response.data !== 'object') {
75
+ throw new Error('Invalid response format from CoinGecko');
76
+ }
77
+
78
+ // CoinGecko returns data keyed by lowercase address
79
+ const tokenData = response.data[addressLower];
80
+
81
+ if (!tokenData) {
82
+ if (coinGeckoId) {
83
+ return await this.fetchByCoinId(symbol, coinGeckoId);
84
+ }
85
+ throw new SymbolNotSupportedError(symbol, this.name);
86
+ }
87
+
88
+ return this.buildRateResult(tokenData.usd, tokenData.last_updated_at, tokenInfo);
89
+ } catch (error: any) {
90
+ // Log the error
91
+ logger.error('Failed to fetch exchange rate from CoinGecko', {
92
+ symbol,
93
+ error: error.message,
94
+ response: error.response?.data,
95
+ });
96
+
97
+ // Determine error type and throw appropriate error
98
+ // Re-throw SymbolNotSupportedError as-is (don't wrap it)
99
+ if (error instanceof SymbolNotSupportedError) {
100
+ throw error;
101
+ }
102
+ if (error.response) {
103
+ if (error.response.status === 404) {
104
+ throw new SymbolNotSupportedError(symbol, this.name);
105
+ } else if (error.response.status === 429) {
106
+ throw new Error('CoinGecko rate limit exceeded');
107
+ } else if (error.response.status >= 500) {
108
+ throw new Error(`CoinGecko server error: ${error.response.status}`);
109
+ } else {
110
+ throw new Error(`CoinGecko request failed: ${error.response.status}`);
111
+ }
112
+ } else if (error.code === 'ECONNABORTED') {
113
+ throw new Error(`CoinGecko request timeout after ${this.timeout}ms`);
114
+ } else if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
115
+ throw new Error(`Cannot connect to CoinGecko: ${error.message}`);
116
+ } else {
117
+ throw new Error(`CoinGecko fetch error: ${error.message}`);
118
+ }
119
+ }
120
+ }
121
+
122
+ private async fetchByCoinId(symbol: string, coinGeckoId?: string): Promise<ExchangeRateData> {
123
+ if (!coinGeckoId) {
124
+ throw new SymbolNotSupportedError(symbol, this.name, `CoinGecko ID is missing for ${symbol}`);
125
+ }
126
+
127
+ const url = `${this.baseUrl}/simple/price`;
128
+ const params: any = {
129
+ ids: coinGeckoId,
130
+ vs_currencies: 'usd',
131
+ include_last_updated_at: true,
132
+ };
133
+
134
+ if (this.apiKey) {
135
+ params.x_cg_pro_api_key = this.apiKey;
136
+ }
137
+
138
+ logger.debug('Fetching exchange rate from CoinGecko by coin id', { symbol, coinGeckoId, url });
139
+
140
+ const response = await axios.get(url, {
141
+ params,
142
+ timeout: this.timeout,
143
+ headers: {
144
+ 'User-Agent': 'PaymentKit/1.0',
145
+ Accept: 'application/json',
146
+ },
147
+ });
148
+
149
+ if (!response.data || typeof response.data !== 'object') {
150
+ throw new Error('Invalid response format from CoinGecko');
151
+ }
152
+
153
+ const tokenData = response.data[coinGeckoId];
154
+ if (!tokenData) {
155
+ throw new SymbolNotSupportedError(symbol, this.name);
156
+ }
157
+
158
+ const tokenInfo = getTokenInfo(symbol);
159
+ if (!tokenInfo) {
160
+ throw new SymbolNotSupportedError(symbol, this.name);
161
+ }
162
+
163
+ return this.buildRateResult(tokenData.usd, tokenData.last_updated_at, tokenInfo);
164
+ }
165
+
166
+ private buildRateResult(
167
+ usdPrice: number,
168
+ lastUpdatedAt: number | undefined,
169
+ tokenInfo: ReturnType<typeof getTokenInfo>
170
+ ): ExchangeRateData {
171
+ if (!usdPrice || typeof usdPrice !== 'number') {
172
+ throw new Error(`No valid USD price found for ${tokenInfo?.symbol || 'token'}`);
173
+ }
174
+
175
+ const rate = new BigNumber(usdPrice);
176
+ if (rate.lte(0) || !rate.isFinite()) {
177
+ throw new Error(`Invalid price value: ${usdPrice}`);
178
+ }
179
+
180
+ const timestampMs = lastUpdatedAt ? lastUpdatedAt * 1000 : Date.now();
181
+
182
+ return {
183
+ rate: rate.toString(),
184
+ timestamp_ms: timestampMs,
185
+ metadata: {
186
+ source: 'coingecko',
187
+ address: tokenInfo?.address,
188
+ symbol: tokenInfo?.symbol,
189
+ coin_id: tokenInfo?.coinGeckoId,
190
+ },
191
+ };
192
+ }
193
+ }
@@ -0,0 +1,180 @@
1
+ import axios from 'axios';
2
+ import BigNumber from 'bignumber.js';
3
+
4
+ import logger from '../logger';
5
+ import type { ExchangeRateData, IExchangeRateProvider, ProviderConfig } from './types';
6
+ import { SymbolNotSupportedError } from './types';
7
+
8
+ /**
9
+ * CoinMarketCap Exchange Rate Provider
10
+ *
11
+ * Fetches token prices from CoinMarketCap API
12
+ * This is the upstream data source for token-data service
13
+ *
14
+ * API Documentation: https://coinmarketcap.com/api/documentation/v1/
15
+ */
16
+ export class CoinMarketCapProvider implements IExchangeRateProvider {
17
+ public readonly name = 'coinmarketcap';
18
+
19
+ private baseUrl: string;
20
+ private timeout: number;
21
+ private apiKey?: string;
22
+
23
+ constructor(config: ProviderConfig = {}) {
24
+ this.baseUrl = config.base_url || 'https://pro-api.coinmarketcap.com/v1';
25
+ this.timeout = config.timeout || 10000; // 10 seconds default
26
+ this.apiKey = config.api_key;
27
+
28
+ if (!this.apiKey) {
29
+ logger.warn('CoinMarketCap API key not configured. Please set api_key in provider config.');
30
+ }
31
+ }
32
+
33
+ async fetch(symbol: string): Promise<ExchangeRateData> {
34
+ if (!this.apiKey) {
35
+ throw new Error('CoinMarketCap API key is required. Please configure api_key in provider settings.');
36
+ }
37
+
38
+ try {
39
+ const url = `${this.baseUrl}/cryptocurrency/quotes/latest`;
40
+
41
+ logger.debug('Fetching exchange rate from CoinMarketCap', { symbol, url });
42
+
43
+ const response = await axios.get(url, {
44
+ params: {
45
+ symbol: symbol.toUpperCase(),
46
+ convert: 'USD',
47
+ },
48
+ timeout: this.timeout,
49
+ headers: {
50
+ 'X-CMC_PRO_API_KEY': this.apiKey,
51
+ Accept: 'application/json',
52
+ 'User-Agent': 'PaymentKit/1.0',
53
+ },
54
+ });
55
+
56
+ if (!response.data || typeof response.data !== 'object') {
57
+ throw new Error('Invalid response format from CoinMarketCap');
58
+ }
59
+
60
+ const { data, status } = response.data;
61
+
62
+ // Check API status
63
+ if (status?.error_code !== 0) {
64
+ throw new Error(`CoinMarketCap API error: ${status?.error_message || 'Unknown error'}`);
65
+ }
66
+
67
+ // Get token data
68
+ const tokenData = data?.[symbol.toUpperCase()];
69
+ if (!tokenData) {
70
+ // Symbol not found - this is a data issue, not a service issue
71
+ throw new SymbolNotSupportedError(symbol, this.name);
72
+ }
73
+
74
+ // Extract USD price from quote
75
+ const quote = tokenData.quote?.USD;
76
+ if (!quote) {
77
+ throw new Error(`No USD quote found for ${symbol}`);
78
+ }
79
+
80
+ const usdPrice = quote.price;
81
+ if (!usdPrice || typeof usdPrice !== 'number') {
82
+ throw new Error(`No valid USD price found for ${symbol}`);
83
+ }
84
+
85
+ // Validate price is positive
86
+ const rate = new BigNumber(usdPrice);
87
+ if (rate.lte(0) || !rate.isFinite()) {
88
+ throw new Error(`Invalid price value: ${usdPrice}`);
89
+ }
90
+
91
+ // Get timestamp
92
+ const timestampMs = quote.last_updated ? new Date(quote.last_updated).getTime() : Date.now();
93
+
94
+ return {
95
+ rate: rate.toString(),
96
+ timestamp_ms: timestampMs,
97
+ metadata: {
98
+ source: 'coinmarketcap',
99
+ symbol: tokenData.symbol,
100
+ name: tokenData.name,
101
+ cmc_id: tokenData.id,
102
+ cmc_rank: tokenData.cmc_rank,
103
+ market_cap: quote.market_cap,
104
+ volume_24h: quote.volume_24h,
105
+ percent_change_24h: quote.percent_change_24h,
106
+ },
107
+ };
108
+ } catch (error: any) {
109
+ // Log the error
110
+ logger.error('Failed to fetch exchange rate from CoinMarketCap', {
111
+ symbol,
112
+ error: error.message,
113
+ response: error.response?.data,
114
+ });
115
+
116
+ // Determine error type and throw appropriate error
117
+ if (error.response) {
118
+ const { status, data } = error.response;
119
+
120
+ // Check for CoinMarketCap specific error codes
121
+ if (data?.status?.error_code) {
122
+ const errorCode = data.status.error_code;
123
+ const errorMessage = data.status.error_message;
124
+
125
+ switch (errorCode) {
126
+ case 1001: // API key invalid
127
+ throw new Error('CoinMarketCap API key is invalid');
128
+ case 1002: // API key missing
129
+ throw new Error('CoinMarketCap API key is missing');
130
+ case 1003: // API key plan rate limit
131
+ throw new Error('CoinMarketCap API rate limit exceeded for your plan');
132
+ case 1004: // API key plan payment overdue
133
+ throw new Error('CoinMarketCap API key payment overdue');
134
+ case 1005: // API key plan payment failed
135
+ throw new Error('CoinMarketCap API key payment failed');
136
+ case 1006: // API key plan upgrade required
137
+ throw new Error('CoinMarketCap API plan upgrade required');
138
+ case 1007: // API key disabled
139
+ throw new Error('CoinMarketCap API key has been disabled');
140
+ case 1008: // API key plan minute rate limit
141
+ throw new Error('CoinMarketCap API minute rate limit exceeded');
142
+ case 1009: // API key plan daily rate limit
143
+ throw new Error('CoinMarketCap API daily rate limit exceeded');
144
+ case 1010: // API key plan monthly rate limit
145
+ throw new Error('CoinMarketCap API monthly rate limit exceeded');
146
+ case 400: // Bad Request
147
+ throw new Error(`CoinMarketCap bad request: ${errorMessage}`);
148
+ case 401: // Unauthorized
149
+ throw new Error('CoinMarketCap API authentication failed');
150
+ case 403: // Forbidden
151
+ throw new Error('CoinMarketCap API access forbidden');
152
+ case 429: // Too Many Requests
153
+ throw new Error('CoinMarketCap rate limit exceeded');
154
+ case 500: // Internal Server Error
155
+ throw new Error('CoinMarketCap server error');
156
+ default:
157
+ throw new Error(`CoinMarketCap API error (${errorCode}): ${errorMessage}`);
158
+ }
159
+ }
160
+
161
+ // Generic HTTP error handling
162
+ if (status === 404) {
163
+ throw new SymbolNotSupportedError(symbol, this.name);
164
+ } else if (status === 429) {
165
+ throw new Error('CoinMarketCap rate limit exceeded');
166
+ } else if (status >= 500) {
167
+ throw new Error(`CoinMarketCap server error: ${status}`);
168
+ } else {
169
+ throw new Error(`CoinMarketCap request failed: ${status}`);
170
+ }
171
+ } else if (error.code === 'ECONNABORTED') {
172
+ throw new Error(`CoinMarketCap request timeout after ${this.timeout}ms`);
173
+ } else if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
174
+ throw new Error(`Cannot connect to CoinMarketCap: ${error.message}`);
175
+ } else {
176
+ throw new Error(`CoinMarketCap fetch error: ${error.message}`);
177
+ }
178
+ }
179
+ }
180
+ }
@@ -0,0 +1,5 @@
1
+ export * from './types';
2
+ export * from './token-data-provider';
3
+ export * from './service';
4
+ export * from './validator';
5
+ export * from './token-address-mapping';