payment-kit 1.24.4 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/api/src/index.ts +3 -0
  2. package/api/src/libs/credit-utils.ts +21 -0
  3. package/api/src/libs/discount/discount.ts +13 -0
  4. package/api/src/libs/env.ts +5 -0
  5. package/api/src/libs/error.ts +14 -0
  6. package/api/src/libs/exchange-rate/coingecko-provider.ts +193 -0
  7. package/api/src/libs/exchange-rate/coinmarketcap-provider.ts +180 -0
  8. package/api/src/libs/exchange-rate/index.ts +5 -0
  9. package/api/src/libs/exchange-rate/service.ts +583 -0
  10. package/api/src/libs/exchange-rate/token-address-mapping.ts +84 -0
  11. package/api/src/libs/exchange-rate/token-addresses.json +2147 -0
  12. package/api/src/libs/exchange-rate/token-data-provider.ts +142 -0
  13. package/api/src/libs/exchange-rate/types.ts +114 -0
  14. package/api/src/libs/exchange-rate/validator.ts +319 -0
  15. package/api/src/libs/invoice-quote.ts +158 -0
  16. package/api/src/libs/invoice.ts +143 -7
  17. package/api/src/libs/math-utils.ts +46 -0
  18. package/api/src/libs/notification/template/billing-discrepancy.ts +3 -4
  19. package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +174 -79
  20. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +2 -3
  21. package/api/src/libs/notification/template/customer-credit-insufficient.ts +3 -3
  22. package/api/src/libs/notification/template/customer-credit-low-balance.ts +3 -3
  23. package/api/src/libs/notification/template/customer-revenue-succeeded.ts +2 -3
  24. package/api/src/libs/notification/template/customer-reward-succeeded.ts +9 -4
  25. package/api/src/libs/notification/template/exchange-rate-alert.ts +202 -0
  26. package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +203 -0
  27. package/api/src/libs/notification/template/subscription-slippage-warning.ts +212 -0
  28. package/api/src/libs/notification/template/subscription-will-canceled.ts +2 -2
  29. package/api/src/libs/notification/template/subscription-will-renew.ts +22 -8
  30. package/api/src/libs/payment.ts +1 -1
  31. package/api/src/libs/price.ts +4 -1
  32. package/api/src/libs/queue/index.ts +8 -0
  33. package/api/src/libs/quote-service.ts +1132 -0
  34. package/api/src/libs/quote-validation.ts +388 -0
  35. package/api/src/libs/session.ts +686 -39
  36. package/api/src/libs/slippage.ts +135 -0
  37. package/api/src/libs/subscription.ts +185 -15
  38. package/api/src/libs/util.ts +64 -3
  39. package/api/src/locales/en.ts +50 -0
  40. package/api/src/locales/zh.ts +48 -0
  41. package/api/src/queues/auto-recharge.ts +295 -21
  42. package/api/src/queues/exchange-rate-health.ts +242 -0
  43. package/api/src/queues/invoice.ts +48 -1
  44. package/api/src/queues/notification.ts +167 -1
  45. package/api/src/queues/payment.ts +177 -7
  46. package/api/src/queues/subscription.ts +436 -6
  47. package/api/src/routes/auto-recharge-configs.ts +71 -6
  48. package/api/src/routes/checkout-sessions.ts +1730 -81
  49. package/api/src/routes/connect/auto-recharge-auth.ts +2 -0
  50. package/api/src/routes/connect/change-payer.ts +2 -0
  51. package/api/src/routes/connect/change-payment.ts +61 -8
  52. package/api/src/routes/connect/change-plan.ts +161 -17
  53. package/api/src/routes/connect/collect.ts +9 -6
  54. package/api/src/routes/connect/delegation.ts +1 -0
  55. package/api/src/routes/connect/pay.ts +157 -0
  56. package/api/src/routes/connect/setup.ts +32 -10
  57. package/api/src/routes/connect/shared.ts +159 -13
  58. package/api/src/routes/connect/subscribe.ts +32 -9
  59. package/api/src/routes/credit-grants.ts +99 -0
  60. package/api/src/routes/exchange-rate-providers.ts +248 -0
  61. package/api/src/routes/exchange-rates.ts +87 -0
  62. package/api/src/routes/index.ts +4 -0
  63. package/api/src/routes/invoices.ts +280 -2
  64. package/api/src/routes/payment-links.ts +13 -0
  65. package/api/src/routes/prices.ts +84 -2
  66. package/api/src/routes/subscriptions.ts +526 -15
  67. package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
  68. package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
  69. package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
  70. package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
  71. package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
  72. package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
  73. package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
  74. package/api/src/store/models/auto-recharge-config.ts +12 -0
  75. package/api/src/store/models/checkout-session.ts +7 -0
  76. package/api/src/store/models/exchange-rate-provider.ts +225 -0
  77. package/api/src/store/models/index.ts +6 -0
  78. package/api/src/store/models/payment-intent.ts +6 -0
  79. package/api/src/store/models/price-quote.ts +284 -0
  80. package/api/src/store/models/price.ts +53 -5
  81. package/api/src/store/models/subscription.ts +11 -0
  82. package/api/src/store/models/types.ts +61 -1
  83. package/api/tests/libs/change-payment-plan.spec.ts +282 -0
  84. package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
  85. package/api/tests/libs/quote-service.spec.ts +199 -0
  86. package/api/tests/libs/session.spec.ts +464 -0
  87. package/api/tests/libs/slippage.spec.ts +109 -0
  88. package/api/tests/libs/token-data-provider.spec.ts +267 -0
  89. package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
  90. package/api/tests/models/price-dynamic.spec.ts +100 -0
  91. package/api/tests/models/price-quote.spec.ts +112 -0
  92. package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
  93. package/api/tests/routes/subscription-slippage.spec.ts +254 -0
  94. package/blocklet.yml +1 -1
  95. package/package.json +7 -6
  96. package/src/components/customer/credit-overview.tsx +14 -0
  97. package/src/components/discount/discount-info.tsx +8 -2
  98. package/src/components/invoice/list.tsx +146 -16
  99. package/src/components/invoice/table.tsx +276 -71
  100. package/src/components/invoice-pdf/template.tsx +3 -7
  101. package/src/components/metadata/form.tsx +6 -8
  102. package/src/components/price/form.tsx +519 -149
  103. package/src/components/promotion/active-redemptions.tsx +5 -3
  104. package/src/components/quote/info.tsx +234 -0
  105. package/src/hooks/subscription.ts +132 -2
  106. package/src/locales/en.tsx +145 -0
  107. package/src/locales/zh.tsx +143 -1
  108. package/src/pages/admin/billing/invoices/detail.tsx +41 -4
  109. package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
  110. package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
  111. package/src/pages/admin/products/index.tsx +12 -1
  112. package/src/pages/customer/invoice/detail.tsx +36 -12
  113. package/src/pages/customer/subscription/change-payment.tsx +65 -3
  114. package/src/pages/customer/subscription/change-plan.tsx +207 -38
  115. package/src/pages/customer/subscription/detail.tsx +599 -419
@@ -0,0 +1,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
+ }