@mixerx/oracles 1.0.0 → 3.0.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 (82) hide show
  1. package/README.md +90 -111
  2. package/dist/application/ports/ILogger.d.ts +0 -1
  3. package/dist/application/ports/ILogger.js +0 -1
  4. package/dist/domain/config/TokenDefaults.d.ts +0 -32
  5. package/dist/domain/config/TokenDefaults.js +7 -73
  6. package/dist/domain/exceptions.d.ts +8 -4
  7. package/dist/domain/exceptions.js +12 -4
  8. package/dist/domain/repositories.d.ts +3 -2
  9. package/dist/domain/repositories.js +10 -1
  10. package/dist/factory.d.ts +15 -46
  11. package/dist/factory.js +18 -28
  12. package/dist/index.d.ts +6 -1
  13. package/dist/index.js +4 -1
  14. package/dist/infrastructure/blockchain/staticProvider.d.ts +3 -0
  15. package/dist/infrastructure/blockchain/staticProvider.js +36 -0
  16. package/dist/infrastructure/config/ProtocolTokenConfig.d.ts +0 -1
  17. package/dist/infrastructure/config/ProtocolTokenConfig.js +7 -5
  18. package/dist/infrastructure/config/gasOracleConfigs.d.ts +2 -13
  19. package/dist/infrastructure/config/gasOracleConfigs.js +7 -34
  20. package/dist/infrastructure/gas/GasPriceOracle.d.ts +14 -17
  21. package/dist/infrastructure/gas/GasPriceOracle.js +89 -44
  22. package/dist/infrastructure/mixerx/MixerXFeeOracle.d.ts +99 -0
  23. package/dist/infrastructure/mixerx/MixerXFeeOracle.js +292 -0
  24. package/dist/infrastructure/multicall/MulticallProvider.d.ts +9 -3
  25. package/dist/infrastructure/multicall/MulticallProvider.js +48 -10
  26. package/dist/infrastructure/token-price/TokenPriceOracle.d.ts +10 -47
  27. package/dist/infrastructure/token-price/TokenPriceOracle.js +75 -107
  28. package/dist/types.d.ts +11 -2
  29. package/dist/types.js +0 -1
  30. package/package.json +37 -15
  31. package/dist/application/ports/ILogger.d.ts.map +0 -1
  32. package/dist/application/ports/ILogger.js.map +0 -1
  33. package/dist/application/ports/index.d.ts +0 -6
  34. package/dist/application/ports/index.d.ts.map +0 -1
  35. package/dist/application/ports/index.js +0 -18
  36. package/dist/application/ports/index.js.map +0 -1
  37. package/dist/application/services/TokenPriceService.d.ts +0 -61
  38. package/dist/application/services/TokenPriceService.d.ts.map +0 -1
  39. package/dist/application/services/TokenPriceService.js +0 -137
  40. package/dist/application/services/TokenPriceService.js.map +0 -1
  41. package/dist/domain/config/TokenDefaults.d.ts.map +0 -1
  42. package/dist/domain/config/TokenDefaults.js.map +0 -1
  43. package/dist/domain/config/index.d.ts +0 -2
  44. package/dist/domain/config/index.d.ts.map +0 -1
  45. package/dist/domain/config/index.js +0 -20
  46. package/dist/domain/config/index.js.map +0 -1
  47. package/dist/domain/exceptions.d.ts.map +0 -1
  48. package/dist/domain/exceptions.js.map +0 -1
  49. package/dist/domain/repositories.d.ts.map +0 -1
  50. package/dist/domain/repositories.js.map +0 -1
  51. package/dist/factory.d.ts.map +0 -1
  52. package/dist/factory.js.map +0 -1
  53. package/dist/index.d.ts.map +0 -1
  54. package/dist/index.js.map +0 -1
  55. package/dist/infrastructure/blockchain/contracts.d.ts +0 -20
  56. package/dist/infrastructure/blockchain/contracts.d.ts.map +0 -1
  57. package/dist/infrastructure/blockchain/contracts.js +0 -10
  58. package/dist/infrastructure/blockchain/contracts.js.map +0 -1
  59. package/dist/infrastructure/config/ProtocolTokenConfig.d.ts.map +0 -1
  60. package/dist/infrastructure/config/ProtocolTokenConfig.js.map +0 -1
  61. package/dist/infrastructure/config/gasOracleConfigs.d.ts.map +0 -1
  62. package/dist/infrastructure/config/gasOracleConfigs.js.map +0 -1
  63. package/dist/infrastructure/config/index.d.ts +0 -5
  64. package/dist/infrastructure/config/index.d.ts.map +0 -1
  65. package/dist/infrastructure/config/index.js +0 -11
  66. package/dist/infrastructure/config/index.js.map +0 -1
  67. package/dist/infrastructure/gas/GasPriceOracle.d.ts.map +0 -1
  68. package/dist/infrastructure/gas/GasPriceOracle.js.map +0 -1
  69. package/dist/infrastructure/multicall/MulticallProvider.d.ts.map +0 -1
  70. package/dist/infrastructure/multicall/MulticallProvider.js.map +0 -1
  71. package/dist/infrastructure/token-price/TokenPriceOracle.d.ts.map +0 -1
  72. package/dist/infrastructure/token-price/TokenPriceOracle.js.map +0 -1
  73. package/dist/infrastructure/token-price/index.d.ts +0 -3
  74. package/dist/infrastructure/token-price/index.d.ts.map +0 -1
  75. package/dist/infrastructure/token-price/index.js +0 -6
  76. package/dist/infrastructure/token-price/index.js.map +0 -1
  77. package/dist/infrastructure/tornado/TornadoFeeOracle.d.ts +0 -90
  78. package/dist/infrastructure/tornado/TornadoFeeOracle.d.ts.map +0 -1
  79. package/dist/infrastructure/tornado/TornadoFeeOracle.js +0 -230
  80. package/dist/infrastructure/tornado/TornadoFeeOracle.js.map +0 -1
  81. package/dist/types.d.ts.map +0 -1
  82. package/dist/types.js.map +0 -1
@@ -0,0 +1,292 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MixerXFeeOracle = void 0;
4
+ const ILogger_1 = require("../../application/ports/ILogger");
5
+ const TokenDefaults_1 = require("../../domain/config/TokenDefaults");
6
+ const exceptions_1 = require("../../domain/exceptions");
7
+ const repositories_1 = require("../../domain/repositories");
8
+ const GasPriceOracle_1 = require("../gas/GasPriceOracle");
9
+ const gasOracleConfigs_1 = require("../config/gasOracleConfigs");
10
+ const TokenPriceOracle_1 = require("../token-price/TokenPriceOracle");
11
+ const DEFAULT_MAX_PRICE_AGE_MS = 120_000;
12
+ const DEFAULT_MAX_REFUND_WEI = 1000000000000000000n;
13
+ const DEFAULT_MAX_TOTAL_FEE_BPS = 1_000;
14
+ const BASIS_POINTS_SCALE = 10000n;
15
+ const FEE_SCALE = 1000000n;
16
+ const MAX_REFUND_DIGITS = 256;
17
+ const TX_TYPES = new Set([
18
+ 'relayer_withdrawal',
19
+ 'user_withdrawal',
20
+ 'relayer_withdrawal_check_v4',
21
+ ]);
22
+ const GAS_PURPOSES = new Set([
23
+ 'fee',
24
+ 'send',
25
+ 'replacement',
26
+ 'fee_quote',
27
+ 'fee_validation',
28
+ ]);
29
+ class MixerXFeeOracle {
30
+ relayerFeePartsPerMillion;
31
+ gasPriceOracle;
32
+ tokenPriceOracle;
33
+ priceRepository;
34
+ logger;
35
+ maxPriceAgeMs;
36
+ maxRefundWei;
37
+ maxTotalFeeBps;
38
+ currencyDecimals;
39
+ priceBounds;
40
+ constructor(options) {
41
+ (0, gasOracleConfigs_1.assertSepoliaChainId)(options.chainId);
42
+ if (!Number.isFinite(options.relayerFeePercent) || options.relayerFeePercent < 0 || options.relayerFeePercent > 0.1) {
43
+ throw new exceptions_1.OracleError('INVALID_CONFIG', 'relayerFeePercent must be between 0 and 0.1');
44
+ }
45
+ const relayerFeePartsPerMillion = options.relayerFeePercent * Number(FEE_SCALE);
46
+ if (!Number.isInteger(relayerFeePartsPerMillion)) {
47
+ throw new exceptions_1.OracleError('INVALID_CONFIG', 'relayerFeePercent exceeds supported precision');
48
+ }
49
+ this.maxPriceAgeMs = options.maxPriceAgeMs ?? DEFAULT_MAX_PRICE_AGE_MS;
50
+ if (!Number.isInteger(this.maxPriceAgeMs) || this.maxPriceAgeMs < 1_000 || this.maxPriceAgeMs > 3_600_000) {
51
+ throw new exceptions_1.OracleError('INVALID_CONFIG', 'maxPriceAgeMs must be between 1000 and 3600000');
52
+ }
53
+ this.maxRefundWei = options.maxRefundWei ?? DEFAULT_MAX_REFUND_WEI;
54
+ if (this.maxRefundWei < 0n) {
55
+ throw new exceptions_1.OracleError('INVALID_CONFIG', 'maxRefundWei cannot be negative');
56
+ }
57
+ this.maxTotalFeeBps = options.maxTotalFeeBps ?? DEFAULT_MAX_TOTAL_FEE_BPS;
58
+ if (!Number.isInteger(this.maxTotalFeeBps) ||
59
+ this.maxTotalFeeBps < 1 ||
60
+ this.maxTotalFeeBps > DEFAULT_MAX_TOTAL_FEE_BPS) {
61
+ throw new exceptions_1.OracleError('INVALID_CONFIG', 'maxTotalFeeBps must be an integer between 1 and 1000');
62
+ }
63
+ this.relayerFeePartsPerMillion = BigInt(relayerFeePartsPerMillion);
64
+ this.logger = options.logger ?? new ILogger_1.NullLogger();
65
+ this.gasPriceOracle = options.gasPriceOracle ?? new GasPriceOracle_1.GasPriceOracle({
66
+ chainId: options.chainId,
67
+ rpcUrl: options.rpcUrl,
68
+ logger: this.logger,
69
+ rpcTimeoutMs: options.rpcTimeoutMs,
70
+ maxGasPriceGwei: options.maxGasPriceGwei,
71
+ });
72
+ this.tokenPriceOracle = options.tokenPriceOracle ?? new TokenPriceOracle_1.TokenPriceOracle(options.rpcUrl, options.chainId, options.offchainOracleAddress, options.multicallAddress, undefined, this.logger, options.rpcTimeoutMs);
73
+ const supportedTokens = this.tokenPriceOracle.getSupportedTokens();
74
+ if (supportedTokens.some((token) => token.symbol.trim().toLowerCase() === 'eth')) {
75
+ throw new exceptions_1.OracleError('INVALID_CONFIG', 'Token symbol eth is reserved for native ETH');
76
+ }
77
+ this.currencyDecimals = new Map([
78
+ ['eth', 18],
79
+ ...supportedTokens.map((token) => [token.symbol, token.decimals]),
80
+ ]);
81
+ this.priceBounds = this.validatePriceBounds(options.priceBounds ?? {});
82
+ this.priceRepository = options.priceRepository;
83
+ }
84
+ async calculateWithdrawalFee(input) {
85
+ const currency = this.validateFeeInput(input);
86
+ const txType = this.validateTxType(input.txType ?? 'relayer_withdrawal');
87
+ const gasParams = await this.getGasParametersForTx({
88
+ txType,
89
+ purpose: this.normalizePurpose(input.purpose ?? 'fee'),
90
+ isLegacy: false,
91
+ });
92
+ const gasLimit = input.gasLimit;
93
+ const gasPriceWei = this.effectiveGasPrice(gasParams);
94
+ const gasCostWei = gasPriceWei * BigInt(gasLimit);
95
+ const relayerFee = (input.amount * this.relayerFeePartsPerMillion) / FEE_SCALE;
96
+ if (currency === 'eth') {
97
+ if ((input.refund !== undefined && this.parseRefund(input.refund) !== 0n) ||
98
+ (input.refundGasMultiplier !== undefined && input.refundGasMultiplier !== 0)) {
99
+ throw new exceptions_1.OracleError('INVALID_INPUT', 'refund must be zero for native ETH withdrawals');
100
+ }
101
+ const totalFee = gasCostWei + relayerFee;
102
+ this.assertTotalFee(totalFee, input.amount);
103
+ return this.result({
104
+ gasCost: gasCostWei,
105
+ relayerFee,
106
+ refundAmount: 0n,
107
+ refundWei: 0n,
108
+ totalFee,
109
+ currency,
110
+ gasLimit,
111
+ gasPriceWei,
112
+ gasParams,
113
+ txType,
114
+ priceSource: 'native',
115
+ });
116
+ }
117
+ const quote = await this.getFreshPrice(currency);
118
+ const decimalsFactor = 10n ** BigInt(input.decimals);
119
+ const gasCost = (gasCostWei * decimalsFactor) / quote.priceInEthWei;
120
+ const refundWei = this.resolveRefundWei(input, gasCostWei);
121
+ const refundAmount = (refundWei * decimalsFactor) / quote.priceInEthWei;
122
+ const totalFee = gasCost + relayerFee + refundAmount;
123
+ this.assertTotalFee(totalFee, input.amount);
124
+ return this.result({
125
+ gasCost,
126
+ relayerFee,
127
+ refundAmount,
128
+ refundWei,
129
+ totalFee,
130
+ currency,
131
+ gasLimit,
132
+ gasPriceWei,
133
+ gasParams,
134
+ txType,
135
+ tokenPriceInEth: quote.priceInEthWei,
136
+ priceSource: quote.source,
137
+ priceObservedAtMs: quote.observedAtMs,
138
+ });
139
+ }
140
+ async getGasParametersForTx(input = {}) {
141
+ const txType = this.validateTxType(input.txType ?? 'relayer_withdrawal');
142
+ const purpose = this.normalizePurpose(input.purpose ?? 'send');
143
+ const bumpPercent = purpose === 'send' ? 0 : (0, TokenDefaults_1.getBumpPercent)(txType);
144
+ const gasParams = await this.gasPriceOracle.getTxGasParams({
145
+ isLegacy: input.isLegacy ?? false,
146
+ bumpPercent,
147
+ });
148
+ return { ...gasParams, txType, purpose, bumpPercent };
149
+ }
150
+ async assertReady() {
151
+ await Promise.all([
152
+ this.gasPriceOracle.getTxGasParams(),
153
+ this.tokenPriceOracle.fetchPrices(),
154
+ ]);
155
+ await Promise.all(this.tokenPriceOracle
156
+ .getSupportedTokens()
157
+ .map((token) => this.getFreshPrice(token.symbol)));
158
+ }
159
+ validateFeeInput(input) {
160
+ const currency = input.currency.trim().toLowerCase();
161
+ if (!/^[a-z0-9]{2,16}$/.test(currency)) {
162
+ throw new exceptions_1.OracleError('INVALID_INPUT', 'currency must be a valid protocol token symbol');
163
+ }
164
+ const configuredDecimals = this.currencyDecimals.get(currency);
165
+ if (configuredDecimals === undefined) {
166
+ throw new exceptions_1.OracleError('INVALID_INPUT', `Unsupported MixerX currency: ${currency}`);
167
+ }
168
+ if (input.amount <= 0n) {
169
+ throw new exceptions_1.OracleError('INVALID_INPUT', 'amount must be greater than zero');
170
+ }
171
+ if (!Number.isInteger(input.decimals) || input.decimals < 0 || input.decimals > 255) {
172
+ throw new exceptions_1.OracleError('INVALID_INPUT', 'decimals must be an integer between 0 and 255');
173
+ }
174
+ if (input.decimals !== configuredDecimals) {
175
+ throw new exceptions_1.OracleError('INVALID_INPUT', `${currency} must use configured decimals ${String(configuredDecimals)}`);
176
+ }
177
+ if (!Number.isInteger(input.gasLimit) || input.gasLimit < 21_000 || input.gasLimit > 5_000_000) {
178
+ throw new exceptions_1.OracleError('INVALID_INPUT', 'gasLimit must be an integer between 21000 and 5000000');
179
+ }
180
+ return currency;
181
+ }
182
+ parseRefund(raw) {
183
+ if (typeof raw !== 'string' || raw.length > MAX_REFUND_DIGITS || !/^\d+$/.test(raw)) {
184
+ throw new exceptions_1.OracleError('INVALID_INPUT', 'refund must be an unsigned base-10 integer string');
185
+ }
186
+ const refund = BigInt(raw);
187
+ if (refund > this.maxRefundWei) {
188
+ throw new exceptions_1.OracleError('ECONOMIC_LIMIT_EXCEEDED', 'refund exceeds maxRefundWei');
189
+ }
190
+ return refund;
191
+ }
192
+ resolveRefundWei(input, gasCostWei) {
193
+ if (input.refund !== undefined && input.refundGasMultiplier !== undefined) {
194
+ throw new exceptions_1.OracleError('INVALID_INPUT', 'Specify either refund or refundGasMultiplier, not both');
195
+ }
196
+ if (input.refundGasMultiplier === undefined) {
197
+ return this.parseRefund(input.refund ?? '0');
198
+ }
199
+ if (!Number.isSafeInteger(input.refundGasMultiplier) ||
200
+ input.refundGasMultiplier < 0 ||
201
+ input.refundGasMultiplier > 10) {
202
+ throw new exceptions_1.OracleError('INVALID_INPUT', 'refundGasMultiplier must be an integer between 0 and 10');
203
+ }
204
+ return this.parseRefund((gasCostWei * BigInt(input.refundGasMultiplier)).toString());
205
+ }
206
+ async getFreshPrice(symbol) {
207
+ if (!this.priceRepository) {
208
+ throw new exceptions_1.OracleError('TOKEN_PRICE_UNAVAILABLE', `No fresh price repository configured for ${symbol}`);
209
+ }
210
+ const storedQuote = await this.priceRepository.getQuote(symbol);
211
+ const quote = (0, repositories_1.toPriceQuote)(storedQuote?.priceInEthWei, storedQuote?.observedAtMs);
212
+ if (!quote) {
213
+ throw new exceptions_1.OracleError('TOKEN_PRICE_UNAVAILABLE', `Price unavailable for ${symbol}`);
214
+ }
215
+ if (Date.now() - quote.observedAtMs > this.maxPriceAgeMs || quote.observedAtMs > Date.now() + 5_000) {
216
+ throw new exceptions_1.OracleError('TOKEN_PRICE_STALE', `Price is stale for ${symbol}`);
217
+ }
218
+ const bounds = this.priceBounds.get(symbol);
219
+ if (bounds &&
220
+ (quote.priceInEthWei < bounds.minimumWeiPerToken ||
221
+ quote.priceInEthWei > bounds.maximumWeiPerToken)) {
222
+ throw new exceptions_1.OracleError('TOKEN_PRICE_OUT_OF_RANGE', `Price is outside configured bounds for ${symbol}`);
223
+ }
224
+ return quote;
225
+ }
226
+ validatePriceBounds(configuredBounds) {
227
+ const validated = new Map();
228
+ for (const [rawSymbol, bounds] of Object.entries(configuredBounds)) {
229
+ const symbol = rawSymbol.trim().toLowerCase();
230
+ if (!this.currencyDecimals.has(symbol) ||
231
+ bounds.minimumWeiPerToken <= 0n ||
232
+ bounds.maximumWeiPerToken <= bounds.minimumWeiPerToken) {
233
+ throw new exceptions_1.OracleError('INVALID_CONFIG', `Invalid token price bounds for ${symbol}`);
234
+ }
235
+ validated.set(symbol, Object.freeze({ ...bounds }));
236
+ }
237
+ return validated;
238
+ }
239
+ effectiveGasPrice(params) {
240
+ return BigInt(params.effectiveGasPriceWei);
241
+ }
242
+ assertTotalFee(totalFee, amount) {
243
+ const maximumFee = (amount * BigInt(this.maxTotalFeeBps)) / BASIS_POINTS_SCALE;
244
+ if (totalFee <= 0n || totalFee > maximumFee) {
245
+ const maximumPercent = (this.maxTotalFeeBps / 100).toFixed(2);
246
+ throw new exceptions_1.OracleError('ECONOMIC_LIMIT_EXCEEDED', `Total fee must be greater than zero and no more than ${maximumPercent}% of the withdrawal amount`);
247
+ }
248
+ }
249
+ normalizePurpose(purpose) {
250
+ if (typeof purpose !== 'string' || !GAS_PURPOSES.has(purpose)) {
251
+ throw new exceptions_1.OracleError('INVALID_INPUT', 'purpose is not supported');
252
+ }
253
+ if (purpose === 'fee_quote' || purpose === 'fee_validation') {
254
+ return 'fee';
255
+ }
256
+ return purpose;
257
+ }
258
+ validateTxType(txType) {
259
+ if (typeof txType !== 'string' || !TX_TYPES.has(txType)) {
260
+ throw new exceptions_1.OracleError('INVALID_INPUT', 'txType is not supported');
261
+ }
262
+ return txType;
263
+ }
264
+ result(input) {
265
+ this.logger.debug('Withdrawal fee calculated', {
266
+ priceSource: input.priceSource,
267
+ gasPurpose: 'fee',
268
+ });
269
+ return {
270
+ gasCost: input.gasCost,
271
+ relayerFee: input.relayerFee,
272
+ refundAmount: input.refundAmount,
273
+ refundWei: input.refundWei,
274
+ totalFee: input.totalFee,
275
+ currency: input.currency,
276
+ gasLimit: input.gasLimit,
277
+ gasPriceWei: input.gasPriceWei,
278
+ gasSource: input.gasParams.gasSource,
279
+ baseFeePerGas: input.gasParams.baseFeePerGas,
280
+ maxFeePerGas: input.gasParams.maxFeePerGas,
281
+ maxPriorityFeePerGas: input.gasParams.maxPriorityFeePerGas,
282
+ gasPrice: input.gasParams.gasPrice,
283
+ tokenPriceInEth: input.tokenPriceInEth,
284
+ priceSource: input.priceSource,
285
+ priceObservedAtMs: input.priceObservedAtMs,
286
+ bumpPercent: input.gasParams.bumpPercent,
287
+ txType: input.txType,
288
+ gasPurpose: 'fee',
289
+ };
290
+ }
291
+ }
292
+ exports.MixerXFeeOracle = MixerXFeeOracle;
@@ -1,3 +1,4 @@
1
+ import type { ChainId } from '../../types';
1
2
  export interface Call {
2
3
  target: string;
3
4
  callData: string;
@@ -6,9 +7,14 @@ export interface CallResult {
6
7
  success: boolean;
7
8
  returnData: string;
8
9
  }
10
+ export interface MulticallResult {
11
+ blockNumber: number;
12
+ results: CallResult[];
13
+ }
9
14
  export declare class MulticallProvider {
10
15
  private readonly provider;
11
- constructor(rpcUrl: string, _multicallAddress?: string);
12
- aggregate(calls: Call[]): Promise<CallResult[]>;
16
+ private readonly multicall;
17
+ private readonly chainId;
18
+ constructor(rpcUrl: string, multicallAddress: string | undefined, chainId: ChainId | number, rpcTimeoutMs?: number);
19
+ aggregate(calls: readonly Call[]): Promise<MulticallResult>;
13
20
  }
14
- //# sourceMappingURL=MulticallProvider.d.ts.map
@@ -2,22 +2,60 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MulticallProvider = void 0;
4
4
  const ethers_1 = require("ethers");
5
+ const exceptions_1 = require("../../domain/exceptions");
6
+ const staticProvider_1 = require("../blockchain/staticProvider");
7
+ const gasOracleConfigs_1 = require("../config/gasOracleConfigs");
8
+ const MULTICALL3_ADDRESS = '0xcA11bde05977b3631167028862bE2a173976CA11';
9
+ const MAX_CALLS = 64;
10
+ const MULTICALL3_ABI = [
11
+ 'function aggregate3(tuple(address target, bool allowFailure, bytes callData)[] calls) payable returns (tuple(bool success, bytes returnData)[] returnData)',
12
+ ];
5
13
  class MulticallProvider {
6
14
  provider;
7
- constructor(rpcUrl, _multicallAddress) {
8
- this.provider = new ethers_1.ethers.JsonRpcProvider(rpcUrl);
15
+ multicall;
16
+ chainId;
17
+ constructor(rpcUrl, multicallAddress = MULTICALL3_ADDRESS, chainId, rpcTimeoutMs) {
18
+ (0, gasOracleConfigs_1.assertSepoliaChainId)(chainId);
19
+ if (!ethers_1.ethers.isAddress(multicallAddress)) {
20
+ throw new exceptions_1.OracleError('INVALID_CONFIG', 'multicallAddress must be a valid EVM address');
21
+ }
22
+ this.chainId = chainId;
23
+ this.provider = (0, staticProvider_1.createVerifiedJsonRpcProvider)(rpcUrl, chainId, { batchMaxCount: 1 }, rpcTimeoutMs);
24
+ this.multicall = new ethers_1.ethers.Contract(multicallAddress, MULTICALL3_ABI, this.provider);
9
25
  }
10
26
  async aggregate(calls) {
11
- return Promise.all(calls.map(async ({ target, callData }) => {
12
- try {
13
- const returnData = await this.provider.call({ to: target, data: callData });
14
- return { success: true, returnData };
27
+ if (calls.length > MAX_CALLS) {
28
+ throw new exceptions_1.OracleError('INVALID_INPUT', `Multicall supports at most ${String(MAX_CALLS)} calls`);
29
+ }
30
+ if (calls.length === 0) {
31
+ return { blockNumber: await this.provider.getBlockNumber(), results: [] };
32
+ }
33
+ for (const call of calls) {
34
+ if (!ethers_1.ethers.isAddress(call.target) || !ethers_1.ethers.isHexString(call.callData)) {
35
+ throw new exceptions_1.OracleError('INVALID_INPUT', 'Every multicall target and calldata value must be valid');
15
36
  }
16
- catch {
17
- return { success: false, returnData: '0x' };
37
+ }
38
+ try {
39
+ await (0, staticProvider_1.assertProviderNetwork)(this.provider, this.chainId);
40
+ const blockNumber = await this.provider.getBlockNumber();
41
+ const rawResults = (await this.multicall.aggregate3.staticCall(calls.map((call) => ({ ...call, allowFailure: true })), { blockTag: blockNumber }));
42
+ if (rawResults.length !== calls.length) {
43
+ throw new exceptions_1.OracleError('RPC_REQUEST_FAILED', 'Multicall returned an unexpected number of results');
18
44
  }
19
- }));
45
+ return {
46
+ blockNumber,
47
+ results: rawResults.map((result) => ({
48
+ success: result.success,
49
+ returnData: result.returnData,
50
+ })),
51
+ };
52
+ }
53
+ catch (error) {
54
+ if (error instanceof exceptions_1.OracleError) {
55
+ throw error;
56
+ }
57
+ throw new exceptions_1.OracleError('RPC_REQUEST_FAILED', 'Multicall RPC request failed');
58
+ }
20
59
  }
21
60
  }
22
61
  exports.MulticallProvider = MulticallProvider;
23
- //# sourceMappingURL=MulticallProvider.js.map
@@ -1,50 +1,13 @@
1
- import { ILogger } from '../../application/ports/ILogger';
2
- import { ChainId, Token } from '../../types';
3
- export interface TokenPrices {
4
- [symbol: string]: string;
5
- }
6
- /**
7
- * TokenPriceOracle
8
- * Fetches token prices from OffchainOracle using Multicall
9
- * Based on @tornado/tornado-oracles TokenPriceOracle
10
- *
11
- * Uses protocol-configured OffchainOracle from @mixerx/config
12
- * Returns price rate to ETH in wei (how much ETH 1 token = X wei)
13
- *
14
- * NOTE: Uses centralized TokenDefaults to avoid duplication
15
- */
1
+ import type { ILogger } from '../../application/ports/ILogger';
2
+ import type { ChainId, Token, TokenPriceSnapshot } from '../../types';
16
3
  export declare class TokenPriceOracle {
17
- private multicall;
18
- private offchainOracleAddress;
19
- private offchainOracleAbi;
20
- private defaultTokens;
21
- private defaultPrices;
22
- private logger;
23
- constructor(rpcUrl: string, chainId: ChainId | number, offchainOracleAddress?: string, multicallAddress?: string, tokens?: Token[], logger?: ILogger);
24
- /**
25
- * Fetch prices for tokens
26
- * Uses multicall to batch requests
27
- */
28
- fetchPrices(tokens?: Token[]): Promise<TokenPrices>;
29
- /**
30
- * Fetch price for single token
31
- */
32
- fetchPrice(token: Token): Promise<string | null>;
33
- /**
34
- * Get default prices
35
- */
36
- getDefaultPrices(): TokenPrices;
37
- /**
38
- * Get supported tokens
39
- */
4
+ private readonly multicall;
5
+ private readonly offchainOracleAddress;
6
+ private readonly interface;
7
+ private readonly logger;
8
+ private readonly defaultTokens;
9
+ constructor(rpcUrl: string, chainId: ChainId | number, offchainOracleAddress?: string, multicallAddress?: string, tokens?: Token[], logger?: ILogger, rpcTimeoutMs?: number);
10
+ fetchPrices(tokens?: readonly Token[]): Promise<TokenPriceSnapshot>;
40
11
  getSupportedTokens(): Token[];
41
- /**
42
- * Add custom tokens
43
- */
44
- addTokens(tokens: Token[]): void;
45
- /**
46
- * Encode getRateToEth call
47
- */
48
- private encodeGetRateToEth;
12
+ private normalizeTokens;
49
13
  }
50
- //# sourceMappingURL=TokenPriceOracle.d.ts.map
@@ -2,132 +2,100 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TokenPriceOracle = void 0;
4
4
  const ethers_1 = require("ethers");
5
- const MulticallProvider_1 = require("../multicall/MulticallProvider");
6
- const TokenDefaults_1 = require("../../domain/config/TokenDefaults");
7
5
  const ILogger_1 = require("../../application/ports/ILogger");
6
+ const exceptions_1 = require("../../domain/exceptions");
8
7
  const ProtocolTokenConfig_1 = require("../config/ProtocolTokenConfig");
9
- /**
10
- * TokenPriceOracle
11
- * Fetches token prices from OffchainOracle using Multicall
12
- * Based on @tornado/tornado-oracles TokenPriceOracle
13
- *
14
- * Uses protocol-configured OffchainOracle from @mixerx/config
15
- * Returns price rate to ETH in wei (how much ETH 1 token = X wei)
16
- *
17
- * NOTE: Uses centralized TokenDefaults to avoid duplication
18
- */
8
+ const MulticallProvider_1 = require("../multicall/MulticallProvider");
9
+ const MAX_TOKENS = 32;
10
+ const OFFCHAIN_ORACLE_ABI = [
11
+ 'function getRateToEth(address srcToken, bool useSrcWrappers) external view returns (uint256 weightedRate)',
12
+ ];
19
13
  class TokenPriceOracle {
20
14
  multicall;
21
15
  offchainOracleAddress;
22
- offchainOracleAbi = [
23
- 'function getRateToEth(address srcToken, bool useSrcWrappers) external view returns (uint256 weightedRate)',
24
- ];
25
- // Default tokens from @mixerx/config by chain
26
- defaultTokens;
27
- // Default prices as fallback (in wei) - from TokenDefaults
28
- defaultPrices = TokenDefaults_1.DEFAULT_TOKEN_PRICES;
16
+ interface = new ethers_1.ethers.Interface(OFFCHAIN_ORACLE_ABI);
29
17
  logger;
30
- constructor(rpcUrl, chainId, offchainOracleAddress, multicallAddress, tokens, logger) {
31
- this.multicall = new MulticallProvider_1.MulticallProvider(rpcUrl, multicallAddress);
18
+ defaultTokens;
19
+ constructor(rpcUrl, chainId, offchainOracleAddress, multicallAddress, tokens, logger, rpcTimeoutMs) {
32
20
  this.offchainOracleAddress = offchainOracleAddress ?? (0, ProtocolTokenConfig_1.resolveOffchainOracleAddress)(chainId);
33
- this.defaultTokens = tokens ?? (0, ProtocolTokenConfig_1.resolveDefaultTokens)(chainId);
21
+ if (!ethers_1.ethers.isAddress(this.offchainOracleAddress)) {
22
+ throw new exceptions_1.OracleError('INVALID_CONFIG', 'offchainOracleAddress must be a valid EVM address');
23
+ }
24
+ this.defaultTokens = this.normalizeTokens(tokens ?? (0, ProtocolTokenConfig_1.resolveDefaultTokens)(chainId));
25
+ this.multicall = new MulticallProvider_1.MulticallProvider(rpcUrl, multicallAddress, chainId, rpcTimeoutMs);
34
26
  this.logger = logger ?? new ILogger_1.NullLogger();
35
27
  }
36
- /**
37
- * Fetch prices for tokens
38
- * Uses multicall to batch requests
39
- */
40
28
  async fetchPrices(tokens = this.defaultTokens) {
41
- try {
42
- if (tokens.length === 0) {
43
- return {};
29
+ const normalizedTokens = this.normalizeTokens(tokens);
30
+ const calls = normalizedTokens.map((token) => ({
31
+ target: this.offchainOracleAddress,
32
+ callData: this.interface.encodeFunctionData('getRateToEth', [token.address, true]),
33
+ }));
34
+ const { blockNumber, results } = await this.multicall.aggregate(calls);
35
+ const prices = Object.create(null);
36
+ const failedSymbols = [];
37
+ for (const [index, result] of results.entries()) {
38
+ const token = normalizedTokens[index];
39
+ if (!result.success) {
40
+ failedSymbols.push(token.symbol);
41
+ continue;
44
42
  }
45
- // Prepare multicall data
46
- const calls = tokens.map(token => ({
47
- target: this.offchainOracleAddress,
48
- callData: this.encodeGetRateToEth(token.address),
49
- }));
50
- // Execute multicall
51
- const results = await this.multicall.aggregate(calls);
52
- const prices = {};
53
- for (let i = 0; i < results.length; i++) {
54
- const token = tokens[i];
55
- const result = results[i];
56
- if (!result.success) {
57
- // Use default price if available
58
- const defaultPrice = this.defaultPrices[token.symbol.toLowerCase()];
59
- if (defaultPrice) {
60
- prices[token.symbol.toLowerCase()] = defaultPrice;
61
- this.logger.warn('TokenPriceOracle default price used (call failed)', {
62
- symbol: token.symbol,
63
- address: token.address,
64
- });
65
- }
43
+ try {
44
+ const rateWei = this.interface.decodeFunctionResult('getRateToEth', result.returnData)[0];
45
+ const price = (rateWei * 10n ** BigInt(token.decimals)) / 10n ** 18n;
46
+ if (price <= 0n) {
47
+ failedSymbols.push(token.symbol);
66
48
  continue;
67
49
  }
68
- try {
69
- // Decode result
70
- const rateWei = ethers_1.ethers.AbiCoder.defaultAbiCoder().decode(['uint256'], result.returnData)[0];
71
- // Convert rate to proper decimals
72
- // Formula: (rate * 10^tokenDecimals) / 10^18
73
- const tokenDecimals = 10n ** BigInt(token.decimals);
74
- const ethDecimals = 10n ** 18n;
75
- const price = (rateWei * tokenDecimals) / ethDecimals;
76
- prices[token.symbol.toLowerCase()] = price.toString();
77
- }
78
- catch (decodeError) {
79
- // Use default if decoding fails
80
- const defaultPrice = this.defaultPrices[token.symbol.toLowerCase()];
81
- if (defaultPrice) {
82
- prices[token.symbol.toLowerCase()] = defaultPrice;
83
- this.logger.warn('TokenPriceOracle default price used (decode failed)', {
84
- symbol: token.symbol,
85
- address: token.address,
86
- });
87
- }
88
- }
50
+ prices[token.symbol] = price.toString();
51
+ }
52
+ catch {
53
+ failedSymbols.push(token.symbol);
89
54
  }
90
- return prices;
91
55
  }
92
- catch (error) {
93
- this.logger.error('Failed to fetch token prices:', error);
94
- // Return defaults on complete failure
95
- this.logger.warn('TokenPriceOracle using default prices (fetch failed)');
96
- return this.defaultPrices;
56
+ if (failedSymbols.length > 0) {
57
+ this.logger.warn('Token price batch rejected', { failedSymbols: [...failedSymbols] });
58
+ throw new exceptions_1.TokenPriceError('One or more on-chain token prices are unavailable', failedSymbols);
97
59
  }
60
+ return Object.freeze({
61
+ prices: Object.freeze({ ...prices }),
62
+ observedAtMs: Date.now(),
63
+ blockNumber,
64
+ });
98
65
  }
99
- /**
100
- * Fetch price for single token
101
- */
102
- async fetchPrice(token) {
103
- const prices = await this.fetchPrices([token]);
104
- return prices[token.symbol.toLowerCase()] || null;
105
- }
106
- /**
107
- * Get default prices
108
- */
109
- getDefaultPrices() {
110
- return { ...this.defaultPrices };
111
- }
112
- /**
113
- * Get supported tokens
114
- */
115
66
  getSupportedTokens() {
116
- return [...this.defaultTokens];
117
- }
118
- /**
119
- * Add custom tokens
120
- */
121
- addTokens(tokens) {
122
- this.defaultTokens = [...this.defaultTokens, ...tokens];
67
+ return this.defaultTokens.map((token) => ({ ...token }));
123
68
  }
124
- /**
125
- * Encode getRateToEth call
126
- */
127
- encodeGetRateToEth(tokenAddress) {
128
- const iface = new ethers_1.ethers.Interface(this.offchainOracleAbi);
129
- return iface.encodeFunctionData('getRateToEth', [tokenAddress, true]);
69
+ normalizeTokens(tokens) {
70
+ if (tokens.length === 0) {
71
+ throw new exceptions_1.OracleError('INVALID_CONFIG', `Token list must contain between 1 and ${String(MAX_TOKENS)} items`);
72
+ }
73
+ const byAddress = new Map();
74
+ const symbols = new Set();
75
+ for (const token of tokens) {
76
+ const address = token.address.trim().toLowerCase();
77
+ const symbol = token.symbol.trim().toLowerCase();
78
+ if (!ethers_1.ethers.isAddress(address) || !/^[a-z0-9]{2,16}$/.test(symbol)) {
79
+ throw new exceptions_1.OracleError('INVALID_CONFIG', 'Token address or symbol is invalid');
80
+ }
81
+ if (!Number.isInteger(token.decimals) || token.decimals < 0 || token.decimals > 255) {
82
+ throw new exceptions_1.OracleError('INVALID_CONFIG', `Invalid decimals for token ${symbol}`);
83
+ }
84
+ if (symbols.has(symbol) && byAddress.get(address)?.symbol !== symbol) {
85
+ throw new exceptions_1.OracleError('INVALID_CONFIG', `Duplicate token symbol: ${symbol}`);
86
+ }
87
+ const existingToken = byAddress.get(address);
88
+ if (existingToken &&
89
+ (existingToken.symbol !== symbol || existingToken.decimals !== token.decimals)) {
90
+ throw new exceptions_1.OracleError('INVALID_CONFIG', `Conflicting metadata for token address: ${address}`);
91
+ }
92
+ symbols.add(symbol);
93
+ byAddress.set(address, { address, symbol, decimals: token.decimals });
94
+ }
95
+ if (byAddress.size > MAX_TOKENS) {
96
+ throw new exceptions_1.OracleError('INVALID_CONFIG', `Token list exceeds ${String(MAX_TOKENS)} unique addresses`);
97
+ }
98
+ return [...byAddress.values()];
130
99
  }
131
100
  }
132
101
  exports.TokenPriceOracle = TokenPriceOracle;
133
- //# sourceMappingURL=TokenPriceOracle.js.map