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.
- package/api/src/index.ts +3 -0
- package/api/src/libs/credit-utils.ts +21 -0
- package/api/src/libs/discount/discount.ts +13 -0
- package/api/src/libs/env.ts +5 -0
- package/api/src/libs/error.ts +14 -0
- package/api/src/libs/exchange-rate/coingecko-provider.ts +193 -0
- package/api/src/libs/exchange-rate/coinmarketcap-provider.ts +180 -0
- package/api/src/libs/exchange-rate/index.ts +5 -0
- package/api/src/libs/exchange-rate/service.ts +583 -0
- package/api/src/libs/exchange-rate/token-address-mapping.ts +84 -0
- package/api/src/libs/exchange-rate/token-addresses.json +2147 -0
- package/api/src/libs/exchange-rate/token-data-provider.ts +142 -0
- package/api/src/libs/exchange-rate/types.ts +114 -0
- package/api/src/libs/exchange-rate/validator.ts +319 -0
- package/api/src/libs/invoice-quote.ts +158 -0
- package/api/src/libs/invoice.ts +143 -7
- package/api/src/libs/math-utils.ts +46 -0
- package/api/src/libs/notification/template/billing-discrepancy.ts +3 -4
- package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +174 -79
- package/api/src/libs/notification/template/customer-credit-grant-granted.ts +2 -3
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +3 -3
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +3 -3
- package/api/src/libs/notification/template/customer-revenue-succeeded.ts +2 -3
- package/api/src/libs/notification/template/customer-reward-succeeded.ts +9 -4
- package/api/src/libs/notification/template/exchange-rate-alert.ts +202 -0
- package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +203 -0
- package/api/src/libs/notification/template/subscription-slippage-warning.ts +212 -0
- package/api/src/libs/notification/template/subscription-will-canceled.ts +2 -2
- package/api/src/libs/notification/template/subscription-will-renew.ts +22 -8
- package/api/src/libs/payment.ts +1 -1
- package/api/src/libs/price.ts +4 -1
- package/api/src/libs/queue/index.ts +8 -0
- package/api/src/libs/quote-service.ts +1132 -0
- package/api/src/libs/quote-validation.ts +388 -0
- package/api/src/libs/session.ts +686 -39
- package/api/src/libs/slippage.ts +135 -0
- package/api/src/libs/subscription.ts +185 -15
- package/api/src/libs/util.ts +64 -3
- package/api/src/locales/en.ts +50 -0
- package/api/src/locales/zh.ts +48 -0
- package/api/src/queues/auto-recharge.ts +295 -21
- package/api/src/queues/exchange-rate-health.ts +242 -0
- package/api/src/queues/invoice.ts +48 -1
- package/api/src/queues/notification.ts +167 -1
- package/api/src/queues/payment.ts +177 -7
- package/api/src/queues/subscription.ts +436 -6
- package/api/src/routes/auto-recharge-configs.ts +71 -6
- package/api/src/routes/checkout-sessions.ts +1730 -81
- package/api/src/routes/connect/auto-recharge-auth.ts +2 -0
- package/api/src/routes/connect/change-payer.ts +2 -0
- package/api/src/routes/connect/change-payment.ts +61 -8
- package/api/src/routes/connect/change-plan.ts +161 -17
- package/api/src/routes/connect/collect.ts +9 -6
- package/api/src/routes/connect/delegation.ts +1 -0
- package/api/src/routes/connect/pay.ts +157 -0
- package/api/src/routes/connect/setup.ts +32 -10
- package/api/src/routes/connect/shared.ts +159 -13
- package/api/src/routes/connect/subscribe.ts +32 -9
- package/api/src/routes/credit-grants.ts +99 -0
- package/api/src/routes/exchange-rate-providers.ts +248 -0
- package/api/src/routes/exchange-rates.ts +87 -0
- package/api/src/routes/index.ts +4 -0
- package/api/src/routes/invoices.ts +280 -2
- package/api/src/routes/payment-links.ts +13 -0
- package/api/src/routes/prices.ts +84 -2
- package/api/src/routes/subscriptions.ts +526 -15
- package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
- package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
- package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
- package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
- package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
- package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
- package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
- package/api/src/store/models/auto-recharge-config.ts +12 -0
- package/api/src/store/models/checkout-session.ts +7 -0
- package/api/src/store/models/exchange-rate-provider.ts +225 -0
- package/api/src/store/models/index.ts +6 -0
- package/api/src/store/models/payment-intent.ts +6 -0
- package/api/src/store/models/price-quote.ts +284 -0
- package/api/src/store/models/price.ts +53 -5
- package/api/src/store/models/subscription.ts +11 -0
- package/api/src/store/models/types.ts +61 -1
- package/api/tests/libs/change-payment-plan.spec.ts +282 -0
- package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
- package/api/tests/libs/quote-service.spec.ts +199 -0
- package/api/tests/libs/session.spec.ts +464 -0
- package/api/tests/libs/slippage.spec.ts +109 -0
- package/api/tests/libs/token-data-provider.spec.ts +267 -0
- package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
- package/api/tests/models/price-dynamic.spec.ts +100 -0
- package/api/tests/models/price-quote.spec.ts +112 -0
- package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
- package/api/tests/routes/subscription-slippage.spec.ts +254 -0
- package/blocklet.yml +1 -1
- package/package.json +7 -6
- package/src/components/customer/credit-overview.tsx +14 -0
- package/src/components/discount/discount-info.tsx +8 -2
- package/src/components/invoice/list.tsx +146 -16
- package/src/components/invoice/table.tsx +276 -71
- package/src/components/invoice-pdf/template.tsx +3 -7
- package/src/components/metadata/form.tsx +6 -8
- package/src/components/price/form.tsx +519 -149
- package/src/components/promotion/active-redemptions.tsx +5 -3
- package/src/components/quote/info.tsx +234 -0
- package/src/hooks/subscription.ts +132 -2
- package/src/locales/en.tsx +145 -0
- package/src/locales/zh.tsx +143 -1
- package/src/pages/admin/billing/invoices/detail.tsx +41 -4
- package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
- package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
- package/src/pages/admin/products/index.tsx +12 -1
- package/src/pages/customer/invoice/detail.tsx +36 -12
- package/src/pages/customer/subscription/change-payment.tsx +65 -3
- package/src/pages/customer/subscription/change-plan.tsx +207 -38
- 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
|
};
|
package/api/src/libs/env.ts
CHANGED
|
@@ -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
|
package/api/src/libs/error.ts
CHANGED
|
@@ -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
|
+
}
|