payment-kit 1.24.3 → 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/crons/overdue-detection.ts +10 -1
- 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 +190 -3
- 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/meter-events.ts +3 -0
- 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,142 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
3
|
+
import BigNumber from 'bignumber.js';
|
|
4
|
+
|
|
5
|
+
import logger from '../logger';
|
|
6
|
+
import { getTokenInfo } from './token-address-mapping';
|
|
7
|
+
import type { ExchangeRateData, IExchangeRateProvider, ProviderConfig } from './types';
|
|
8
|
+
import { SymbolNotSupportedError } from './types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Token Data Provider (ArcBlock)
|
|
12
|
+
*
|
|
13
|
+
* Fetches token prices from token-data.arcblock.io using contract addresses
|
|
14
|
+
* Uses Ethereum mainnet (chainId: 1) contract addresses
|
|
15
|
+
*/
|
|
16
|
+
export class TokenDataProvider implements IExchangeRateProvider {
|
|
17
|
+
public readonly name = 'token-data';
|
|
18
|
+
|
|
19
|
+
private baseUrl: string;
|
|
20
|
+
private timeout: number;
|
|
21
|
+
private zeroAddress = '0x0000000000000000000000000000000000000000';
|
|
22
|
+
|
|
23
|
+
constructor(config: ProviderConfig = {}) {
|
|
24
|
+
this.baseUrl = config.base_url || 'https://token-data.arcblock.io';
|
|
25
|
+
this.timeout = config.timeout || 10000; // 10 seconds default
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async fetch(symbol: string): Promise<ExchangeRateData> {
|
|
29
|
+
try {
|
|
30
|
+
// Get token address from mapping
|
|
31
|
+
const tokenInfo = getTokenInfo(symbol);
|
|
32
|
+
if (!tokenInfo) {
|
|
33
|
+
// Symbol not in mapping - this is a data issue, not a service issue
|
|
34
|
+
throw new SymbolNotSupportedError(symbol, this.name);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const addressLower = (tokenInfo.address || '').toLowerCase();
|
|
38
|
+
const useSymbolEndpoint = !addressLower || addressLower === this.zeroAddress || !addressLower.startsWith('0x');
|
|
39
|
+
const url = useSymbolEndpoint
|
|
40
|
+
? `${this.baseUrl}/api/token-price-by-symbol`
|
|
41
|
+
: `${this.baseUrl}/api/token-price-by-address`;
|
|
42
|
+
|
|
43
|
+
logger.debug('Fetching exchange rate from token-data', {
|
|
44
|
+
symbol,
|
|
45
|
+
address: tokenInfo.address,
|
|
46
|
+
endpoint: useSymbolEndpoint ? 'token-price-by-symbol' : 'token-price-by-address',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const response = await axios.get(url, {
|
|
50
|
+
params: useSymbolEndpoint
|
|
51
|
+
? {
|
|
52
|
+
symbols: tokenInfo.symbol,
|
|
53
|
+
}
|
|
54
|
+
: {
|
|
55
|
+
addresses: addressLower,
|
|
56
|
+
},
|
|
57
|
+
timeout: this.timeout,
|
|
58
|
+
headers: {
|
|
59
|
+
'User-Agent': 'PaymentKit/1.0',
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (!response.data || typeof response.data !== 'object') {
|
|
64
|
+
logger.error('Invalid response format from token-data', { response: response.data });
|
|
65
|
+
throw new Error('Invalid response format from token-data');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const responseData = response.data;
|
|
69
|
+
|
|
70
|
+
// API returns: { data: [...], total, page, size }
|
|
71
|
+
if (!responseData.data || !Array.isArray(responseData.data) || responseData.data.length === 0) {
|
|
72
|
+
logger.error('No token data in response', {
|
|
73
|
+
symbol,
|
|
74
|
+
address: addressLower,
|
|
75
|
+
endpoint: useSymbolEndpoint ? 'token-price-by-symbol' : 'token-price-by-address',
|
|
76
|
+
});
|
|
77
|
+
throw new Error(`No token data found for ${symbol}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const tokenData = responseData.data[0];
|
|
81
|
+
|
|
82
|
+
// Extract USD price from tokenData.price.USD
|
|
83
|
+
const usdPrice = tokenData.price?.USD;
|
|
84
|
+
if (!usdPrice || typeof usdPrice !== 'number') {
|
|
85
|
+
logger.error('No valid USD price in token data', {
|
|
86
|
+
symbol,
|
|
87
|
+
priceObject: tokenData.price,
|
|
88
|
+
});
|
|
89
|
+
throw new Error(`No valid USD price found for ${symbol}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Validate price is positive
|
|
93
|
+
const rate = new BigNumber(usdPrice);
|
|
94
|
+
if (rate.lte(0) || !rate.isFinite()) {
|
|
95
|
+
throw new Error(`Invalid price value: ${usdPrice}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Get timestamp - use updatedAt from token data
|
|
99
|
+
const timestampMs = tokenData.updatedAt ? new Date(tokenData.updatedAt).getTime() : Date.now();
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
rate: rate.toString(),
|
|
103
|
+
timestamp_ms: timestampMs,
|
|
104
|
+
metadata: {
|
|
105
|
+
source: 'token-data',
|
|
106
|
+
address: tokenInfo.address,
|
|
107
|
+
symbol: tokenData.symbol,
|
|
108
|
+
name: tokenData.name,
|
|
109
|
+
percent_change_24h: tokenData.percent_change_24h?.USD,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
} catch (error: any) {
|
|
113
|
+
// Log the error
|
|
114
|
+
logger.error('Failed to fetch exchange rate from token-data', {
|
|
115
|
+
symbol,
|
|
116
|
+
error: error.message,
|
|
117
|
+
response: error.response?.data,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Determine error type and throw appropriate error
|
|
121
|
+
// Re-throw SymbolNotSupportedError as-is (don't wrap it)
|
|
122
|
+
if (error instanceof SymbolNotSupportedError) {
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
if (error.response) {
|
|
126
|
+
if (error.response.status === 404) {
|
|
127
|
+
throw new SymbolNotSupportedError(symbol, this.name);
|
|
128
|
+
} else if (error.response.status >= 500) {
|
|
129
|
+
throw new Error(`token-data server error: ${error.response.status}`);
|
|
130
|
+
} else {
|
|
131
|
+
throw new Error(`token-data request failed: ${error.response.status}`);
|
|
132
|
+
}
|
|
133
|
+
} else if (error.code === 'ECONNABORTED') {
|
|
134
|
+
throw new Error(`token-data request timeout after ${this.timeout}ms`);
|
|
135
|
+
} else if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
|
|
136
|
+
throw new Error(`Cannot connect to token-data: ${error.message}`);
|
|
137
|
+
} else {
|
|
138
|
+
throw new Error(`token-data fetch error: ${error.message}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exchange Rate Provider Interface
|
|
3
|
+
*
|
|
4
|
+
* All exchange rate providers must implement this interface.
|
|
5
|
+
* Rates are expressed as "USD per token" (e.g., 1 ABT = 0.1 USD means rate = 0.1)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface IExchangeRateProvider {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Fetch the current exchange rate for a given token symbol
|
|
13
|
+
* @param symbol - Token symbol (e.g., 'ABT', 'ETH')
|
|
14
|
+
* @returns Exchange rate data
|
|
15
|
+
* @throws Error if fetching fails or data is invalid
|
|
16
|
+
*/
|
|
17
|
+
fetch(symbol: string): Promise<ExchangeRateData>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ExchangeRateData {
|
|
21
|
+
/**
|
|
22
|
+
* Exchange rate in USD per token
|
|
23
|
+
* Example: 1 ABT = 0.1 USD => rate = "0.1"
|
|
24
|
+
*/
|
|
25
|
+
rate: string;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Timestamp of the rate in milliseconds
|
|
29
|
+
*/
|
|
30
|
+
timestamp_ms: number;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Optional metadata
|
|
34
|
+
*/
|
|
35
|
+
metadata?: Record<string, any>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ExchangeRateResult extends ExchangeRateData {
|
|
39
|
+
/**
|
|
40
|
+
* Consensus method used to compute the final rate
|
|
41
|
+
*/
|
|
42
|
+
consensus_method?: string;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Provider snapshots participating in consensus
|
|
46
|
+
*/
|
|
47
|
+
providers?: ExchangeRateProviderSnapshot[];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Provider ID from database
|
|
51
|
+
*/
|
|
52
|
+
provider_id: string;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Provider name
|
|
56
|
+
*/
|
|
57
|
+
provider_name: string;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Human-readable provider display string
|
|
61
|
+
* - Single source: "CoinGecko"
|
|
62
|
+
* - Multiple sources: "CoinGecko (2 sources)"
|
|
63
|
+
*/
|
|
64
|
+
provider_display?: string;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether the rate is from a degraded provider
|
|
68
|
+
*/
|
|
69
|
+
degraded: boolean;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Reason for degradation (if degraded)
|
|
73
|
+
*/
|
|
74
|
+
degraded_reason?: string;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Timestamp when we fetched the data (milliseconds)
|
|
78
|
+
* Different from timestamp_ms which is the provider's data timestamp
|
|
79
|
+
*/
|
|
80
|
+
fetched_at?: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ExchangeRateProviderSnapshot {
|
|
84
|
+
provider_id: string;
|
|
85
|
+
provider_name: string;
|
|
86
|
+
rate: string;
|
|
87
|
+
timestamp_ms: number;
|
|
88
|
+
degraded: boolean;
|
|
89
|
+
degraded_reason?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ProviderConfig {
|
|
93
|
+
base_url?: string;
|
|
94
|
+
api_key?: string;
|
|
95
|
+
timeout?: number;
|
|
96
|
+
[key: string]: any;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Error thrown when a symbol is not supported by the provider
|
|
101
|
+
* This error should NOT be recorded as a provider failure
|
|
102
|
+
* because it's a data issue, not a service issue
|
|
103
|
+
*/
|
|
104
|
+
export class SymbolNotSupportedError extends Error {
|
|
105
|
+
public readonly symbol: string;
|
|
106
|
+
public readonly provider: string;
|
|
107
|
+
|
|
108
|
+
constructor(symbol: string, provider: string, message?: string) {
|
|
109
|
+
super(message || `Symbol ${symbol} is not supported by ${provider}`);
|
|
110
|
+
this.name = 'SymbolNotSupportedError';
|
|
111
|
+
this.symbol = symbol;
|
|
112
|
+
this.provider = provider;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exchange Rate Validator
|
|
3
|
+
*
|
|
4
|
+
* Implements dual-layer validation for dynamic pricing:
|
|
5
|
+
* 1. System-level: Rate trustworthiness (multi-source deviation, volatility)
|
|
6
|
+
* 2. User-level: Slippage protection (preview vs submit price change)
|
|
7
|
+
*
|
|
8
|
+
* @see Intent: blocklets/core/ai/intent/20260112-dynamic-price.md
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExchangeRateResult } from './types';
|
|
12
|
+
import logger from '../logger';
|
|
13
|
+
|
|
14
|
+
// System-level thresholds (non-bypassable)
|
|
15
|
+
const MULTI_SOURCE_DEVIATION_THRESHOLD = 0.1; // 10%
|
|
16
|
+
const VOLATILITY_30S_THRESHOLD = 0.2; // 20%
|
|
17
|
+
const VOLATILITY_WINDOW_MS = 30 * 1000; // 30 seconds
|
|
18
|
+
|
|
19
|
+
// Default user-level slippage threshold
|
|
20
|
+
export const DEFAULT_SLIPPAGE_PERCENT = 0.5; // 0.5%
|
|
21
|
+
|
|
22
|
+
export type RateValidationErrorCode = 'PRICE_UNAVAILABLE' | 'PRICE_UNSTABLE';
|
|
23
|
+
|
|
24
|
+
export interface RateValidationResult {
|
|
25
|
+
trustworthy: boolean;
|
|
26
|
+
error_code?: RateValidationErrorCode;
|
|
27
|
+
error_message?: string;
|
|
28
|
+
deviation_percent?: number;
|
|
29
|
+
volatility_percent?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SlippageValidationResult {
|
|
33
|
+
passed: boolean;
|
|
34
|
+
change_percent: number;
|
|
35
|
+
preview_rate: string;
|
|
36
|
+
submit_rate: string;
|
|
37
|
+
slippage_threshold: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface RateHistoryEntry {
|
|
41
|
+
rate: string;
|
|
42
|
+
timestamp_ms: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// In-memory rate history for volatility calculation
|
|
46
|
+
const rateHistoryMap: Map<string, RateHistoryEntry[]> = new Map();
|
|
47
|
+
const RATE_HISTORY_MAX_SIZE = 100;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Record a trusted rate for volatility tracking
|
|
51
|
+
* Only rates that pass system-level validation should be recorded
|
|
52
|
+
*/
|
|
53
|
+
export function recordTrustedRate(symbol: string, rate: string, timestampMs: number): void {
|
|
54
|
+
const key = symbol.toUpperCase();
|
|
55
|
+
const history = rateHistoryMap.get(key) || [];
|
|
56
|
+
|
|
57
|
+
history.push({ rate, timestamp_ms: timestampMs });
|
|
58
|
+
|
|
59
|
+
// Keep history bounded
|
|
60
|
+
while (history.length > RATE_HISTORY_MAX_SIZE) {
|
|
61
|
+
history.shift();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
rateHistoryMap.set(key, history);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get the previous trusted rate within the volatility window
|
|
69
|
+
* rate_prev: The most recent rate that passed system-level validation
|
|
70
|
+
*/
|
|
71
|
+
function getPreviousTrustedRate(symbol: string, currentTimestampMs: number): RateHistoryEntry | null {
|
|
72
|
+
const key = symbol.toUpperCase();
|
|
73
|
+
const history = rateHistoryMap.get(key) || [];
|
|
74
|
+
|
|
75
|
+
// Find the most recent rate within the 30s window
|
|
76
|
+
const windowStart = currentTimestampMs - VOLATILITY_WINDOW_MS;
|
|
77
|
+
|
|
78
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
79
|
+
const entry = history[i];
|
|
80
|
+
if (entry && entry.timestamp_ms >= windowStart && entry.timestamp_ms < currentTimestampMs) {
|
|
81
|
+
return entry;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* System-level rate validation (non-bypassable)
|
|
90
|
+
*
|
|
91
|
+
* Validates:
|
|
92
|
+
* 1. Data source availability - at least one provider returned a rate
|
|
93
|
+
* 2. Multi-source deviation - if multiple sources, deviation ≤ 10%
|
|
94
|
+
* 3. 30s volatility - rate change within 30s window ≤ 20%
|
|
95
|
+
*
|
|
96
|
+
* @param rateResult - The exchange rate result from providers
|
|
97
|
+
* @param symbol - Token symbol for volatility tracking
|
|
98
|
+
* @returns Validation result with error details if failed
|
|
99
|
+
*/
|
|
100
|
+
export function validateRateTrustworthy(rateResult: ExchangeRateResult, symbol: string): RateValidationResult {
|
|
101
|
+
const providers = rateResult.providers || [];
|
|
102
|
+
|
|
103
|
+
// 1. Check data source availability
|
|
104
|
+
if (providers.length === 0) {
|
|
105
|
+
logger.warn('Rate validation failed: No providers available', { symbol });
|
|
106
|
+
return {
|
|
107
|
+
trustworthy: false,
|
|
108
|
+
error_code: 'PRICE_UNAVAILABLE',
|
|
109
|
+
error_message: 'All exchange rate providers are unavailable',
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 2. Check multi-source deviation (if multiple sources)
|
|
114
|
+
if (providers.length >= 2) {
|
|
115
|
+
const rates = providers.map((p) => parseFloat(p.rate)).filter((r) => Number.isFinite(r));
|
|
116
|
+
if (rates.length >= 2) {
|
|
117
|
+
const maxRate = Math.max(...rates);
|
|
118
|
+
const minRate = Math.min(...rates);
|
|
119
|
+
const median = parseFloat(rateResult.rate);
|
|
120
|
+
|
|
121
|
+
if (median > 0) {
|
|
122
|
+
const deviationPercent = (maxRate - minRate) / median;
|
|
123
|
+
|
|
124
|
+
if (deviationPercent > MULTI_SOURCE_DEVIATION_THRESHOLD) {
|
|
125
|
+
logger.warn('Rate validation failed: Multi-source deviation too high', {
|
|
126
|
+
symbol,
|
|
127
|
+
deviation: `${(deviationPercent * 100).toFixed(2)}%`,
|
|
128
|
+
threshold: `${MULTI_SOURCE_DEVIATION_THRESHOLD * 100}%`,
|
|
129
|
+
rates: providers.map((p) => ({ provider: p.provider_name, rate: p.rate })),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
trustworthy: false,
|
|
134
|
+
error_code: 'PRICE_UNSTABLE',
|
|
135
|
+
error_message: `Multi-source deviation ${(deviationPercent * 100).toFixed(2)}% exceeds 10% threshold`,
|
|
136
|
+
deviation_percent: deviationPercent * 100,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 3. Check 30s volatility
|
|
144
|
+
const prevEntry = getPreviousTrustedRate(symbol, rateResult.timestamp_ms);
|
|
145
|
+
if (prevEntry) {
|
|
146
|
+
const currentRate = parseFloat(rateResult.rate);
|
|
147
|
+
const prevRate = parseFloat(prevEntry.rate);
|
|
148
|
+
|
|
149
|
+
if (prevRate > 0 && Number.isFinite(currentRate) && Number.isFinite(prevRate)) {
|
|
150
|
+
const volatilityPercent = Math.abs(currentRate - prevRate) / prevRate;
|
|
151
|
+
|
|
152
|
+
if (volatilityPercent > VOLATILITY_30S_THRESHOLD) {
|
|
153
|
+
logger.warn('Rate validation failed: 30s volatility too high', {
|
|
154
|
+
symbol,
|
|
155
|
+
volatility: `${(volatilityPercent * 100).toFixed(2)}%`,
|
|
156
|
+
threshold: `${VOLATILITY_30S_THRESHOLD * 100}%`,
|
|
157
|
+
currentRate: rateResult.rate,
|
|
158
|
+
prevRate: prevEntry.rate,
|
|
159
|
+
timeDiff: `${(rateResult.timestamp_ms - prevEntry.timestamp_ms) / 1000}s`,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
trustworthy: false,
|
|
164
|
+
error_code: 'PRICE_UNSTABLE',
|
|
165
|
+
error_message: `30s volatility ${(volatilityPercent * 100).toFixed(2)}% exceeds 20% threshold`,
|
|
166
|
+
volatility_percent: volatilityPercent * 100,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Rate is trustworthy - record it for future volatility calculations
|
|
173
|
+
recordTrustedRate(symbol, rateResult.rate, rateResult.timestamp_ms);
|
|
174
|
+
|
|
175
|
+
logger.debug('Rate validation passed', {
|
|
176
|
+
symbol,
|
|
177
|
+
rate: rateResult.rate,
|
|
178
|
+
providers: providers.length,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return { trustworthy: true };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* User-level slippage validation (bypassable with user confirmation)
|
|
186
|
+
*
|
|
187
|
+
* Validates that the price change between preview and submit is within
|
|
188
|
+
* the user's configured slippage tolerance.
|
|
189
|
+
*
|
|
190
|
+
* @param previewRate - Rate displayed during preview
|
|
191
|
+
* @param submitRate - Rate at submit time
|
|
192
|
+
* @param userSlippage - User's slippage tolerance (default 0.5%)
|
|
193
|
+
* @param priceConfirmed - Whether user has confirmed the price change
|
|
194
|
+
* @returns Validation result
|
|
195
|
+
*/
|
|
196
|
+
export function validateUserSlippage(
|
|
197
|
+
previewRate: string,
|
|
198
|
+
submitRate: string,
|
|
199
|
+
userSlippage: number = DEFAULT_SLIPPAGE_PERCENT,
|
|
200
|
+
priceConfirmed: boolean = false
|
|
201
|
+
): SlippageValidationResult {
|
|
202
|
+
const previewNum = parseFloat(previewRate);
|
|
203
|
+
const submitNum = parseFloat(submitRate);
|
|
204
|
+
|
|
205
|
+
// Invalid rates - treat as passed (backend will handle separately)
|
|
206
|
+
if (!Number.isFinite(previewNum) || !Number.isFinite(submitNum) || previewNum <= 0) {
|
|
207
|
+
return {
|
|
208
|
+
passed: true,
|
|
209
|
+
change_percent: 0,
|
|
210
|
+
preview_rate: previewRate,
|
|
211
|
+
submit_rate: submitRate,
|
|
212
|
+
slippage_threshold: userSlippage,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const changePercent = (Math.abs(submitNum - previewNum) / previewNum) * 100;
|
|
217
|
+
const slippageThreshold = userSlippage; // Already in percent
|
|
218
|
+
|
|
219
|
+
// If user has confirmed, always pass
|
|
220
|
+
if (priceConfirmed) {
|
|
221
|
+
logger.info('Slippage check bypassed: User confirmed price change', {
|
|
222
|
+
change: `${changePercent.toFixed(4)}%`,
|
|
223
|
+
threshold: `${slippageThreshold}%`,
|
|
224
|
+
});
|
|
225
|
+
return {
|
|
226
|
+
passed: true,
|
|
227
|
+
change_percent: changePercent,
|
|
228
|
+
preview_rate: previewRate,
|
|
229
|
+
submit_rate: submitRate,
|
|
230
|
+
slippage_threshold: slippageThreshold,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const passed = changePercent <= slippageThreshold;
|
|
235
|
+
|
|
236
|
+
if (!passed) {
|
|
237
|
+
logger.info('Slippage check failed: Price change exceeds user threshold', {
|
|
238
|
+
change: `${changePercent.toFixed(4)}%`,
|
|
239
|
+
threshold: `${slippageThreshold}%`,
|
|
240
|
+
preview_rate: previewRate,
|
|
241
|
+
submit_rate: submitRate,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
passed,
|
|
247
|
+
change_percent: changePercent,
|
|
248
|
+
preview_rate: previewRate,
|
|
249
|
+
submit_rate: submitRate,
|
|
250
|
+
slippage_threshold: slippageThreshold,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Combined validation for Submit phase
|
|
256
|
+
*
|
|
257
|
+
* Executes both validations in order:
|
|
258
|
+
* 1. System-level (non-bypassable)
|
|
259
|
+
* 2. User-level (bypassable with confirmation)
|
|
260
|
+
*/
|
|
261
|
+
export interface SubmitValidationResult {
|
|
262
|
+
passed: boolean;
|
|
263
|
+
error_code?: 'PRICE_UNAVAILABLE' | 'PRICE_UNSTABLE' | 'PRICE_CHANGED';
|
|
264
|
+
error_message?: string;
|
|
265
|
+
system_validation: RateValidationResult;
|
|
266
|
+
slippage_validation?: SlippageValidationResult;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function validateForSubmit(
|
|
270
|
+
rateResult: ExchangeRateResult,
|
|
271
|
+
symbol: string,
|
|
272
|
+
previewRate: string | undefined,
|
|
273
|
+
userSlippage: number = DEFAULT_SLIPPAGE_PERCENT,
|
|
274
|
+
priceConfirmed: boolean = false
|
|
275
|
+
): SubmitValidationResult {
|
|
276
|
+
// 1. System-level validation (non-bypassable)
|
|
277
|
+
const systemResult = validateRateTrustworthy(rateResult, symbol);
|
|
278
|
+
if (!systemResult.trustworthy) {
|
|
279
|
+
return {
|
|
280
|
+
passed: false,
|
|
281
|
+
error_code: systemResult.error_code,
|
|
282
|
+
error_message: systemResult.error_message,
|
|
283
|
+
system_validation: systemResult,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// 2. User-level slippage validation (bypassable)
|
|
288
|
+
if (previewRate) {
|
|
289
|
+
const slippageResult = validateUserSlippage(previewRate, rateResult.rate, userSlippage, priceConfirmed);
|
|
290
|
+
|
|
291
|
+
if (!slippageResult.passed) {
|
|
292
|
+
return {
|
|
293
|
+
passed: false,
|
|
294
|
+
error_code: 'PRICE_CHANGED',
|
|
295
|
+
error_message: `Price changed ${slippageResult.change_percent.toFixed(2)}% (threshold: ${slippageResult.slippage_threshold}%)`,
|
|
296
|
+
system_validation: systemResult,
|
|
297
|
+
slippage_validation: slippageResult,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
passed: true,
|
|
303
|
+
system_validation: systemResult,
|
|
304
|
+
slippage_validation: slippageResult,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
passed: true,
|
|
310
|
+
system_validation: systemResult,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Clear rate history (for testing)
|
|
316
|
+
*/
|
|
317
|
+
export function clearRateHistory(): void {
|
|
318
|
+
rateHistoryMap.clear();
|
|
319
|
+
}
|