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
|
@@ -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
|
+
}
|