@mixerx/oracles 1.0.0 → 2.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 (79) hide show
  1. package/README.md +78 -111
  2. package/dist/application/ports/ILogger.d.ts +0 -1
  3. package/dist/application/ports/ILogger.js.map +1 -1
  4. package/dist/application/ports/index.d.ts +0 -1
  5. package/dist/application/ports/index.js.map +1 -1
  6. package/dist/domain/config/TokenDefaults.d.ts +0 -32
  7. package/dist/domain/config/TokenDefaults.js +7 -72
  8. package/dist/domain/config/TokenDefaults.js.map +1 -1
  9. package/dist/domain/config/index.d.ts +1 -2
  10. package/dist/domain/config/index.js +1 -13
  11. package/dist/domain/config/index.js.map +1 -1
  12. package/dist/domain/exceptions.d.ts +8 -4
  13. package/dist/domain/exceptions.js +12 -3
  14. package/dist/domain/exceptions.js.map +1 -1
  15. package/dist/domain/repositories.d.ts +3 -1
  16. package/dist/domain/repositories.js +10 -0
  17. package/dist/domain/repositories.js.map +1 -1
  18. package/dist/factory.d.ts +11 -32
  19. package/dist/factory.js +16 -26
  20. package/dist/factory.js.map +1 -1
  21. package/dist/index.d.ts +5 -1
  22. package/dist/index.js +4 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/infrastructure/blockchain/staticProvider.d.ts +3 -0
  25. package/dist/infrastructure/blockchain/staticProvider.js +37 -0
  26. package/dist/infrastructure/blockchain/staticProvider.js.map +1 -0
  27. package/dist/infrastructure/config/ProtocolTokenConfig.d.ts +0 -1
  28. package/dist/infrastructure/config/ProtocolTokenConfig.js +2 -0
  29. package/dist/infrastructure/config/ProtocolTokenConfig.js.map +1 -1
  30. package/dist/infrastructure/config/gasOracleConfigs.d.ts +2 -13
  31. package/dist/infrastructure/config/gasOracleConfigs.js +7 -33
  32. package/dist/infrastructure/config/gasOracleConfigs.js.map +1 -1
  33. package/dist/infrastructure/config/index.d.ts +0 -1
  34. package/dist/infrastructure/config/index.js.map +1 -1
  35. package/dist/infrastructure/gas/GasPriceOracle.d.ts +12 -2
  36. package/dist/infrastructure/gas/GasPriceOracle.js +77 -38
  37. package/dist/infrastructure/gas/GasPriceOracle.js.map +1 -1
  38. package/dist/infrastructure/mixerx/MixerXFeeOracle.d.ts +88 -0
  39. package/dist/infrastructure/mixerx/MixerXFeeOracle.js +220 -0
  40. package/dist/infrastructure/mixerx/MixerXFeeOracle.js.map +1 -0
  41. package/dist/infrastructure/multicall/MulticallProvider.d.ts +5 -3
  42. package/dist/infrastructure/multicall/MulticallProvider.js +44 -9
  43. package/dist/infrastructure/multicall/MulticallProvider.js.map +1 -1
  44. package/dist/infrastructure/token-price/TokenPriceOracle.d.ts +11 -46
  45. package/dist/infrastructure/token-price/TokenPriceOracle.js +68 -101
  46. package/dist/infrastructure/token-price/TokenPriceOracle.js.map +1 -1
  47. package/dist/infrastructure/token-price/index.d.ts +2 -2
  48. package/dist/infrastructure/token-price/index.js.map +1 -1
  49. package/dist/types.d.ts +5 -1
  50. package/dist/types.js.map +1 -1
  51. package/package.json +56 -40
  52. package/dist/application/ports/ILogger.d.ts.map +0 -1
  53. package/dist/application/ports/index.d.ts.map +0 -1
  54. package/dist/application/services/TokenPriceService.d.ts +0 -61
  55. package/dist/application/services/TokenPriceService.d.ts.map +0 -1
  56. package/dist/application/services/TokenPriceService.js +0 -137
  57. package/dist/application/services/TokenPriceService.js.map +0 -1
  58. package/dist/domain/config/TokenDefaults.d.ts.map +0 -1
  59. package/dist/domain/config/index.d.ts.map +0 -1
  60. package/dist/domain/exceptions.d.ts.map +0 -1
  61. package/dist/domain/repositories.d.ts.map +0 -1
  62. package/dist/factory.d.ts.map +0 -1
  63. package/dist/index.d.ts.map +0 -1
  64. package/dist/infrastructure/blockchain/contracts.d.ts +0 -20
  65. package/dist/infrastructure/blockchain/contracts.d.ts.map +0 -1
  66. package/dist/infrastructure/blockchain/contracts.js +0 -10
  67. package/dist/infrastructure/blockchain/contracts.js.map +0 -1
  68. package/dist/infrastructure/config/ProtocolTokenConfig.d.ts.map +0 -1
  69. package/dist/infrastructure/config/gasOracleConfigs.d.ts.map +0 -1
  70. package/dist/infrastructure/config/index.d.ts.map +0 -1
  71. package/dist/infrastructure/gas/GasPriceOracle.d.ts.map +0 -1
  72. package/dist/infrastructure/multicall/MulticallProvider.d.ts.map +0 -1
  73. package/dist/infrastructure/token-price/TokenPriceOracle.d.ts.map +0 -1
  74. package/dist/infrastructure/token-price/index.d.ts.map +0 -1
  75. package/dist/infrastructure/tornado/TornadoFeeOracle.d.ts +0 -90
  76. package/dist/infrastructure/tornado/TornadoFeeOracle.d.ts.map +0 -1
  77. package/dist/infrastructure/tornado/TornadoFeeOracle.js +0 -230
  78. package/dist/infrastructure/tornado/TornadoFeeOracle.js.map +0 -1
  79. package/dist/types.d.ts.map +0 -1
@@ -0,0 +1,220 @@
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 TokenPriceOracle_1 = require("../token-price/TokenPriceOracle");
10
+ const DEFAULT_MAX_PRICE_AGE_MS = 120_000;
11
+ const DEFAULT_MAX_REFUND_WEI = 1000000000000000000n;
12
+ const FEE_SCALE = 1000000n;
13
+ class MixerXFeeOracle {
14
+ chainId;
15
+ relayerFeePartsPerMillion;
16
+ gasPriceOracle;
17
+ tokenPriceOracle;
18
+ priceRepository;
19
+ logger;
20
+ maxPriceAgeMs;
21
+ maxRefundWei;
22
+ supportedCurrencies;
23
+ constructor(options) {
24
+ if (!Number.isFinite(options.relayerFeePercent) || options.relayerFeePercent < 0 || options.relayerFeePercent > 0.1) {
25
+ throw new exceptions_1.OracleError('INVALID_CONFIG', 'relayerFeePercent must be between 0 and 0.1');
26
+ }
27
+ this.maxPriceAgeMs = options.maxPriceAgeMs ?? DEFAULT_MAX_PRICE_AGE_MS;
28
+ if (!Number.isInteger(this.maxPriceAgeMs) || this.maxPriceAgeMs < 1_000 || this.maxPriceAgeMs > 3_600_000) {
29
+ throw new exceptions_1.OracleError('INVALID_CONFIG', 'maxPriceAgeMs must be between 1000 and 3600000');
30
+ }
31
+ this.maxRefundWei = options.maxRefundWei ?? DEFAULT_MAX_REFUND_WEI;
32
+ if (this.maxRefundWei < 0n) {
33
+ throw new exceptions_1.OracleError('INVALID_CONFIG', 'maxRefundWei cannot be negative');
34
+ }
35
+ this.chainId = options.chainId;
36
+ this.relayerFeePartsPerMillion = BigInt(Math.round(options.relayerFeePercent * Number(FEE_SCALE)));
37
+ this.logger = options.logger ?? new ILogger_1.NullLogger();
38
+ this.gasPriceOracle = options.gasPriceOracle ?? new GasPriceOracle_1.GasPriceOracle({
39
+ chainId: options.chainId,
40
+ rpcUrl: options.rpcUrl,
41
+ logger: this.logger,
42
+ rpcTimeoutMs: options.rpcTimeoutMs,
43
+ maxGasPriceGwei: options.maxGasPriceGwei,
44
+ });
45
+ this.tokenPriceOracle = options.tokenPriceOracle ?? new TokenPriceOracle_1.TokenPriceOracle(options.rpcUrl, options.chainId, options.offchainOracleAddress, options.multicallAddress, undefined, this.logger, options.rpcTimeoutMs);
46
+ this.supportedCurrencies = new Set([
47
+ 'eth',
48
+ ...this.tokenPriceOracle.getSupportedTokens().map((token) => token.symbol),
49
+ ]);
50
+ this.priceRepository = options.priceRepository;
51
+ }
52
+ async calculateWithdrawalFee(input) {
53
+ const currency = this.validateFeeInput(input);
54
+ const txType = input.txType ?? 'relayer_withdrawal';
55
+ const gasParams = await this.getGasParametersForTx({
56
+ txType,
57
+ purpose: this.normalizePurpose(input.purpose ?? 'fee'),
58
+ isLegacy: false,
59
+ });
60
+ const gasLimit = input.gasLimit;
61
+ const gasPriceWei = this.effectiveGasPrice(gasParams);
62
+ const gasCostWei = gasPriceWei * BigInt(gasLimit);
63
+ const relayerFee = (input.amount * this.relayerFeePartsPerMillion) / FEE_SCALE;
64
+ if (currency === 'eth') {
65
+ if (input.refund !== undefined && this.parseRefund(input.refund) !== 0n) {
66
+ throw new exceptions_1.OracleError('INVALID_INPUT', 'refund must be zero for native ETH withdrawals');
67
+ }
68
+ const totalFee = gasCostWei + relayerFee;
69
+ this.assertTotalFee(totalFee, input.amount);
70
+ return this.result({
71
+ gasCost: gasCostWei,
72
+ relayerFee,
73
+ refundAmount: 0n,
74
+ totalFee,
75
+ currency,
76
+ gasLimit,
77
+ gasPriceWei,
78
+ gasParams,
79
+ txType,
80
+ priceSource: 'native',
81
+ });
82
+ }
83
+ const quote = await this.getFreshPrice(currency);
84
+ const decimalsFactor = 10n ** BigInt(input.decimals);
85
+ const gasCost = (gasCostWei * decimalsFactor) / quote.priceInEthWei;
86
+ const refundWei = this.parseRefund(input.refund ?? '0');
87
+ const refundAmount = (refundWei * decimalsFactor) / quote.priceInEthWei;
88
+ const totalFee = gasCost + relayerFee + refundAmount;
89
+ this.assertTotalFee(totalFee, input.amount);
90
+ return this.result({
91
+ gasCost,
92
+ relayerFee,
93
+ refundAmount,
94
+ totalFee,
95
+ currency,
96
+ gasLimit,
97
+ gasPriceWei,
98
+ gasParams,
99
+ txType,
100
+ tokenPriceInEth: quote.priceInEthWei,
101
+ priceSource: quote.source,
102
+ priceObservedAtMs: quote.observedAtMs,
103
+ });
104
+ }
105
+ async getGasParametersForTx(input = {}) {
106
+ const txType = input.txType ?? 'relayer_withdrawal';
107
+ const purpose = this.normalizePurpose(input.purpose ?? 'send');
108
+ const bumpPercent = purpose === 'send' ? 0 : (0, TokenDefaults_1.getBumpPercent)(txType);
109
+ const gasParams = await this.gasPriceOracle.getTxGasParams({
110
+ isLegacy: input.isLegacy ?? false,
111
+ bumpPercent,
112
+ legacySpeed: 'fast',
113
+ });
114
+ return { ...gasParams, txType, purpose, bumpPercent };
115
+ }
116
+ async assertReady() {
117
+ await this.tokenPriceOracle.fetchPrices();
118
+ }
119
+ async getGasParameters(isLegacy = false) {
120
+ return this.gasPriceOracle.getTxGasParams({ isLegacy });
121
+ }
122
+ getChainId() {
123
+ return this.chainId;
124
+ }
125
+ validateFeeInput(input) {
126
+ const currency = input.currency.trim().toLowerCase();
127
+ if (!/^[a-z0-9]{2,16}$/.test(currency)) {
128
+ throw new exceptions_1.OracleError('INVALID_INPUT', 'currency must be a valid protocol token symbol');
129
+ }
130
+ if (!this.supportedCurrencies.has(currency)) {
131
+ throw new exceptions_1.OracleError('INVALID_INPUT', `Unsupported MixerX currency: ${currency}`);
132
+ }
133
+ if (input.amount <= 0n) {
134
+ throw new exceptions_1.OracleError('INVALID_INPUT', 'amount must be greater than zero');
135
+ }
136
+ if (!Number.isInteger(input.decimals) || input.decimals < 0 || input.decimals > 255) {
137
+ throw new exceptions_1.OracleError('INVALID_INPUT', 'decimals must be an integer between 0 and 255');
138
+ }
139
+ if (!Number.isInteger(input.gasLimit) || input.gasLimit < 21_000 || input.gasLimit > 5_000_000) {
140
+ throw new exceptions_1.OracleError('INVALID_INPUT', 'gasLimit must be an integer between 21000 and 5000000');
141
+ }
142
+ return currency;
143
+ }
144
+ parseRefund(raw) {
145
+ if (!/^\d+$/.test(raw)) {
146
+ throw new exceptions_1.OracleError('INVALID_INPUT', 'refund must be an unsigned base-10 integer string');
147
+ }
148
+ const refund = BigInt(raw);
149
+ if (refund > this.maxRefundWei) {
150
+ throw new exceptions_1.OracleError('ECONOMIC_LIMIT_EXCEEDED', 'refund exceeds maxRefundWei');
151
+ }
152
+ return refund;
153
+ }
154
+ async getFreshPrice(symbol) {
155
+ if (!this.priceRepository) {
156
+ throw new exceptions_1.OracleError('TOKEN_PRICE_UNAVAILABLE', `No fresh price repository configured for ${symbol}`);
157
+ }
158
+ const [price, observedAtMs] = await Promise.all([
159
+ this.priceRepository.getPrice(symbol),
160
+ this.priceRepository.getLastUpdateTimestamp(symbol),
161
+ ]);
162
+ const quote = (0, repositories_1.toPriceQuote)(price, observedAtMs);
163
+ if (!quote) {
164
+ throw new exceptions_1.OracleError('TOKEN_PRICE_UNAVAILABLE', `Price unavailable for ${symbol}`);
165
+ }
166
+ if (Date.now() - quote.observedAtMs > this.maxPriceAgeMs || quote.observedAtMs > Date.now() + 5_000) {
167
+ throw new exceptions_1.OracleError('TOKEN_PRICE_STALE', `Price is stale for ${symbol}`);
168
+ }
169
+ return quote;
170
+ }
171
+ effectiveGasPrice(params) {
172
+ if (params.gasPrice) {
173
+ return BigInt(params.gasPrice);
174
+ }
175
+ if (params.baseFeePerGas && params.maxFeePerGas && params.maxPriorityFeePerGas) {
176
+ const maxFee = BigInt(params.maxFeePerGas);
177
+ const likelyEffective = BigInt(params.baseFeePerGas) + BigInt(params.maxPriorityFeePerGas);
178
+ return likelyEffective < maxFee ? likelyEffective : maxFee;
179
+ }
180
+ throw new exceptions_1.OracleError('RPC_REQUEST_FAILED', 'Gas price response is incomplete');
181
+ }
182
+ assertTotalFee(totalFee, amount) {
183
+ if (totalFee < 0n || totalFee > amount) {
184
+ throw new exceptions_1.OracleError('ECONOMIC_LIMIT_EXCEEDED', 'Total fee must be between zero and withdrawal amount');
185
+ }
186
+ }
187
+ normalizePurpose(purpose) {
188
+ if (purpose === 'fee_quote' || purpose === 'fee_validation') {
189
+ return 'fee';
190
+ }
191
+ return purpose;
192
+ }
193
+ result(input) {
194
+ this.logger.debug('Withdrawal fee calculated', {
195
+ priceSource: input.priceSource,
196
+ gasPurpose: 'fee',
197
+ });
198
+ return {
199
+ gasCost: input.gasCost,
200
+ relayerFee: input.relayerFee,
201
+ refundAmount: input.refundAmount,
202
+ totalFee: input.totalFee,
203
+ currency: input.currency,
204
+ gasLimit: input.gasLimit,
205
+ gasPriceWei: input.gasPriceWei,
206
+ baseFeePerGas: input.gasParams.baseFeePerGas,
207
+ maxFeePerGas: input.gasParams.maxFeePerGas,
208
+ maxPriorityFeePerGas: input.gasParams.maxPriorityFeePerGas,
209
+ gasPrice: input.gasParams.gasPrice,
210
+ tokenPriceInEth: input.tokenPriceInEth,
211
+ priceSource: input.priceSource,
212
+ priceObservedAtMs: input.priceObservedAtMs,
213
+ bumpPercent: input.gasParams.bumpPercent,
214
+ txType: input.txType,
215
+ gasPurpose: 'fee',
216
+ };
217
+ }
218
+ }
219
+ exports.MixerXFeeOracle = MixerXFeeOracle;
220
+ //# sourceMappingURL=MixerXFeeOracle.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"MixerXFeeOracle.js","sourceRoot":"","sources":["../../../src/infrastructure/mixerx/MixerXFeeOracle.ts"],"names":[],"mappings":"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","sourcesContent":["import type { ILogger } from '../../application/ports/ILogger';\r\nimport { NullLogger } from '../../application/ports/ILogger';\r\nimport { getBumpPercent } from '../../domain/config/TokenDefaults';\r\nimport { OracleError } from '../../domain/exceptions';\r\nimport { toPriceQuote, type ITokenPriceRepository } from '../../domain/repositories';\r\nimport type { TokenPriceQuote } from '../../types';\r\nimport { GasPriceOracle, type GasPriceParams } from '../gas/GasPriceOracle';\r\nimport type { ChainId } from '../config/gasOracleConfigs';\r\nimport { TokenPriceOracle } from '../token-price/TokenPriceOracle';\r\n\r\nconst DEFAULT_MAX_PRICE_AGE_MS = 120_000;\r\nconst DEFAULT_MAX_REFUND_WEI = 1_000_000_000_000_000_000n;\r\nconst FEE_SCALE = 1_000_000n;\r\n\r\nexport type MixerXTxType = 'relayer_withdrawal' | 'user_withdrawal' | 'relayer_withdrawal_check_v4';\r\nexport type MixerXGasPurpose = 'fee' | 'send' | 'replacement';\r\nexport type LegacyFeePurpose = 'fee_quote' | 'fee_validation';\r\n\r\nexport interface MixerXFeeOracleOptions {\r\n chainId: ChainId;\r\n rpcUrl: string;\r\n relayerFeePercent: number;\r\n gasPriceOracle?: GasPriceOracle;\r\n tokenPriceOracle?: TokenPriceOracle;\r\n priceRepository?: ITokenPriceRepository;\r\n logger?: ILogger;\r\n multicallAddress?: string;\r\n offchainOracleAddress?: string;\r\n rpcTimeoutMs?: number;\r\n maxGasPriceGwei?: number;\r\n maxPriceAgeMs?: number;\r\n maxRefundWei?: bigint;\r\n}\r\n\r\nexport interface MixerXWithdrawalFeeInput {\r\n currency: string;\r\n amount: bigint;\r\n decimals: number;\r\n /** Gas limit obtained from a simulation or eth_estimateGas for this exact transaction. */\r\n gasLimit: number;\r\n refund?: string;\r\n txType?: MixerXTxType;\r\n purpose?: 'fee' | LegacyFeePurpose;\r\n}\r\n\r\nexport interface MixerXGasParametersForTxInput {\r\n txType?: MixerXTxType;\r\n purpose?: MixerXGasPurpose | LegacyFeePurpose;\r\n isLegacy?: boolean;\r\n}\r\n\r\nexport interface MixerXGasParametersForTxResult extends GasPriceParams {\r\n txType: MixerXTxType;\r\n purpose: MixerXGasPurpose;\r\n bumpPercent: number;\r\n}\r\n\r\nexport interface MixerXFeeCalculationResult {\r\n gasCost: bigint;\r\n relayerFee: bigint;\r\n refundAmount: bigint;\r\n totalFee: bigint;\r\n currency: string;\r\n gasLimit: number;\r\n gasPriceWei: bigint;\r\n baseFeePerGas?: string;\r\n maxFeePerGas?: string;\r\n maxPriorityFeePerGas?: string;\r\n gasPrice?: string;\r\n tokenPriceInEth?: bigint;\r\n priceSource: 'native' | 'repository';\r\n priceObservedAtMs?: number;\r\n bumpPercent: number;\r\n txType: MixerXTxType;\r\n gasPurpose: 'fee';\r\n}\r\n\r\nexport type WithdrawalFeeInput = MixerXWithdrawalFeeInput;\r\nexport type FeeCalculationResult = MixerXFeeCalculationResult;\r\n\r\nexport class MixerXFeeOracle {\r\n private readonly chainId: ChainId;\r\n private readonly relayerFeePartsPerMillion: bigint;\r\n private readonly gasPriceOracle: GasPriceOracle;\r\n private readonly tokenPriceOracle: TokenPriceOracle;\r\n private readonly priceRepository?: ITokenPriceRepository;\r\n private readonly logger: ILogger;\r\n private readonly maxPriceAgeMs: number;\r\n private readonly maxRefundWei: bigint;\r\n private readonly supportedCurrencies: ReadonlySet<string>;\r\n\r\n constructor(options: MixerXFeeOracleOptions) {\r\n if (!Number.isFinite(options.relayerFeePercent) || options.relayerFeePercent < 0 || options.relayerFeePercent > 0.1) {\r\n throw new OracleError('INVALID_CONFIG', 'relayerFeePercent must be between 0 and 0.1');\r\n }\r\n this.maxPriceAgeMs = options.maxPriceAgeMs ?? DEFAULT_MAX_PRICE_AGE_MS;\r\n if (!Number.isInteger(this.maxPriceAgeMs) || this.maxPriceAgeMs < 1_000 || this.maxPriceAgeMs > 3_600_000) {\r\n throw new OracleError('INVALID_CONFIG', 'maxPriceAgeMs must be between 1000 and 3600000');\r\n }\r\n this.maxRefundWei = options.maxRefundWei ?? DEFAULT_MAX_REFUND_WEI;\r\n if (this.maxRefundWei < 0n) {\r\n throw new OracleError('INVALID_CONFIG', 'maxRefundWei cannot be negative');\r\n }\r\n\r\n this.chainId = options.chainId;\r\n this.relayerFeePartsPerMillion = BigInt(Math.round(options.relayerFeePercent * Number(FEE_SCALE)));\r\n this.logger = options.logger ?? new NullLogger();\r\n this.gasPriceOracle = options.gasPriceOracle ?? new GasPriceOracle({\r\n chainId: options.chainId,\r\n rpcUrl: options.rpcUrl,\r\n logger: this.logger,\r\n rpcTimeoutMs: options.rpcTimeoutMs,\r\n maxGasPriceGwei: options.maxGasPriceGwei,\r\n });\r\n this.tokenPriceOracle = options.tokenPriceOracle ?? new TokenPriceOracle(\r\n options.rpcUrl,\r\n options.chainId,\r\n options.offchainOracleAddress,\r\n options.multicallAddress,\r\n undefined,\r\n this.logger,\r\n options.rpcTimeoutMs,\r\n );\r\n this.supportedCurrencies = new Set([\r\n 'eth',\r\n ...this.tokenPriceOracle.getSupportedTokens().map((token) => token.symbol),\r\n ]);\r\n this.priceRepository = options.priceRepository;\r\n }\r\n\r\n async calculateWithdrawalFee(input: MixerXWithdrawalFeeInput): Promise<MixerXFeeCalculationResult> {\r\n const currency = this.validateFeeInput(input);\r\n const txType = input.txType ?? 'relayer_withdrawal';\r\n const gasParams = await this.getGasParametersForTx({\r\n txType,\r\n purpose: this.normalizePurpose(input.purpose ?? 'fee'),\r\n isLegacy: false,\r\n });\r\n const gasLimit = input.gasLimit;\r\n const gasPriceWei = this.effectiveGasPrice(gasParams);\r\n const gasCostWei = gasPriceWei * BigInt(gasLimit);\r\n const relayerFee = (input.amount * this.relayerFeePartsPerMillion) / FEE_SCALE;\r\n\r\n if (currency === 'eth') {\r\n if (input.refund !== undefined && this.parseRefund(input.refund) !== 0n) {\r\n throw new OracleError('INVALID_INPUT', 'refund must be zero for native ETH withdrawals');\r\n }\r\n const totalFee = gasCostWei + relayerFee;\r\n this.assertTotalFee(totalFee, input.amount);\r\n return this.result({\r\n gasCost: gasCostWei,\r\n relayerFee,\r\n refundAmount: 0n,\r\n totalFee,\r\n currency,\r\n gasLimit,\r\n gasPriceWei,\r\n gasParams,\r\n txType,\r\n priceSource: 'native',\r\n });\r\n }\r\n\r\n const quote = await this.getFreshPrice(currency);\r\n const decimalsFactor = 10n ** BigInt(input.decimals);\r\n const gasCost = (gasCostWei * decimalsFactor) / quote.priceInEthWei;\r\n const refundWei = this.parseRefund(input.refund ?? '0');\r\n const refundAmount = (refundWei * decimalsFactor) / quote.priceInEthWei;\r\n const totalFee = gasCost + relayerFee + refundAmount;\r\n this.assertTotalFee(totalFee, input.amount);\r\n\r\n return this.result({\r\n gasCost,\r\n relayerFee,\r\n refundAmount,\r\n totalFee,\r\n currency,\r\n gasLimit,\r\n gasPriceWei,\r\n gasParams,\r\n txType,\r\n tokenPriceInEth: quote.priceInEthWei,\r\n priceSource: quote.source,\r\n priceObservedAtMs: quote.observedAtMs,\r\n });\r\n }\r\n\r\n async getGasParametersForTx(\r\n input: MixerXGasParametersForTxInput = {},\r\n ): Promise<MixerXGasParametersForTxResult> {\r\n const txType = input.txType ?? 'relayer_withdrawal';\r\n const purpose = this.normalizePurpose(input.purpose ?? 'send');\r\n const bumpPercent = purpose === 'send' ? 0 : getBumpPercent(txType);\r\n const gasParams = await this.gasPriceOracle.getTxGasParams({\r\n isLegacy: input.isLegacy ?? false,\r\n bumpPercent,\r\n legacySpeed: 'fast',\r\n });\r\n return { ...gasParams, txType, purpose, bumpPercent };\r\n }\r\n\r\n async assertReady(): Promise<void> {\r\n await this.tokenPriceOracle.fetchPrices();\r\n }\r\n\r\n async getGasParameters(isLegacy: boolean = false): Promise<GasPriceParams> {\r\n return this.gasPriceOracle.getTxGasParams({ isLegacy });\r\n }\r\n\r\n getChainId(): ChainId {\r\n return this.chainId;\r\n }\r\n\r\n private validateFeeInput(input: MixerXWithdrawalFeeInput): string {\r\n const currency = input.currency.trim().toLowerCase();\r\n if (!/^[a-z0-9]{2,16}$/.test(currency)) {\r\n throw new OracleError('INVALID_INPUT', 'currency must be a valid protocol token symbol');\r\n }\r\n if (!this.supportedCurrencies.has(currency)) {\r\n throw new OracleError('INVALID_INPUT', `Unsupported MixerX currency: ${currency}`);\r\n }\r\n if (input.amount <= 0n) {\r\n throw new OracleError('INVALID_INPUT', 'amount must be greater than zero');\r\n }\r\n if (!Number.isInteger(input.decimals) || input.decimals < 0 || input.decimals > 255) {\r\n throw new OracleError('INVALID_INPUT', 'decimals must be an integer between 0 and 255');\r\n }\r\n if (!Number.isInteger(input.gasLimit) || input.gasLimit < 21_000 || input.gasLimit > 5_000_000) {\r\n throw new OracleError('INVALID_INPUT', 'gasLimit must be an integer between 21000 and 5000000');\r\n }\r\n return currency;\r\n }\r\n\r\n private parseRefund(raw: string): bigint {\r\n if (!/^\\d+$/.test(raw)) {\r\n throw new OracleError('INVALID_INPUT', 'refund must be an unsigned base-10 integer string');\r\n }\r\n const refund = BigInt(raw);\r\n if (refund > this.maxRefundWei) {\r\n throw new OracleError('ECONOMIC_LIMIT_EXCEEDED', 'refund exceeds maxRefundWei');\r\n }\r\n return refund;\r\n }\r\n\r\n private async getFreshPrice(symbol: string): Promise<TokenPriceQuote> {\r\n if (!this.priceRepository) {\r\n throw new OracleError('TOKEN_PRICE_UNAVAILABLE', `No fresh price repository configured for ${symbol}`);\r\n }\r\n const [price, observedAtMs] = await Promise.all([\r\n this.priceRepository.getPrice(symbol),\r\n this.priceRepository.getLastUpdateTimestamp(symbol),\r\n ]);\r\n const quote = toPriceQuote(price, observedAtMs);\r\n if (!quote) {\r\n throw new OracleError('TOKEN_PRICE_UNAVAILABLE', `Price unavailable for ${symbol}`);\r\n }\r\n if (Date.now() - quote.observedAtMs > this.maxPriceAgeMs || quote.observedAtMs > Date.now() + 5_000) {\r\n throw new OracleError('TOKEN_PRICE_STALE', `Price is stale for ${symbol}`);\r\n }\r\n return quote;\r\n }\r\n\r\n private effectiveGasPrice(params: GasPriceParams): bigint {\r\n if (params.gasPrice) {\r\n return BigInt(params.gasPrice);\r\n }\r\n if (params.baseFeePerGas && params.maxFeePerGas && params.maxPriorityFeePerGas) {\r\n const maxFee = BigInt(params.maxFeePerGas);\r\n const likelyEffective = BigInt(params.baseFeePerGas) + BigInt(params.maxPriorityFeePerGas);\r\n return likelyEffective < maxFee ? likelyEffective : maxFee;\r\n }\r\n throw new OracleError('RPC_REQUEST_FAILED', 'Gas price response is incomplete');\r\n }\r\n\r\n private assertTotalFee(totalFee: bigint, amount: bigint): void {\r\n if (totalFee < 0n || totalFee > amount) {\r\n throw new OracleError('ECONOMIC_LIMIT_EXCEEDED', 'Total fee must be between zero and withdrawal amount');\r\n }\r\n }\r\n\r\n private normalizePurpose(purpose: MixerXGasPurpose | LegacyFeePurpose): MixerXGasPurpose {\r\n if (purpose === 'fee_quote' || purpose === 'fee_validation') {\r\n return 'fee';\r\n }\r\n return purpose;\r\n }\r\n\r\n private result(input: {\r\n gasCost: bigint;\r\n relayerFee: bigint;\r\n refundAmount: bigint;\r\n totalFee: bigint;\r\n currency: string;\r\n gasLimit: number;\r\n gasPriceWei: bigint;\r\n gasParams: MixerXGasParametersForTxResult;\r\n txType: MixerXTxType;\r\n tokenPriceInEth?: bigint;\r\n priceSource: 'native' | 'repository';\r\n priceObservedAtMs?: number;\r\n }): MixerXFeeCalculationResult {\r\n this.logger.debug('Withdrawal fee calculated', {\r\n priceSource: input.priceSource,\r\n gasPurpose: 'fee',\r\n });\r\n return {\r\n gasCost: input.gasCost,\r\n relayerFee: input.relayerFee,\r\n refundAmount: input.refundAmount,\r\n totalFee: input.totalFee,\r\n currency: input.currency,\r\n gasLimit: input.gasLimit,\r\n gasPriceWei: input.gasPriceWei,\r\n baseFeePerGas: input.gasParams.baseFeePerGas,\r\n maxFeePerGas: input.gasParams.maxFeePerGas,\r\n maxPriorityFeePerGas: input.gasParams.maxPriorityFeePerGas,\r\n gasPrice: input.gasParams.gasPrice,\r\n tokenPriceInEth: input.tokenPriceInEth,\r\n priceSource: input.priceSource,\r\n priceObservedAtMs: input.priceObservedAtMs,\r\n bumpPercent: input.gasParams.bumpPercent,\r\n txType: input.txType,\r\n gasPurpose: 'fee',\r\n };\r\n }\r\n}\r\n"]}
@@ -1,3 +1,4 @@
1
+ import type { ChainId } from '../../types';
1
2
  export interface Call {
2
3
  target: string;
3
4
  callData: string;
@@ -8,7 +9,8 @@ export interface CallResult {
8
9
  }
9
10
  export declare class MulticallProvider {
10
11
  private readonly provider;
11
- constructor(rpcUrl: string, _multicallAddress?: string);
12
- aggregate(calls: Call[]): Promise<CallResult[]>;
12
+ private readonly multicall;
13
+ private readonly chainId;
14
+ constructor(rpcUrl: string, multicallAddress: string | undefined, chainId: ChainId | number, rpcTimeoutMs?: number);
15
+ aggregate(calls: readonly Call[]): Promise<CallResult[]>;
13
16
  }
14
- //# sourceMappingURL=MulticallProvider.d.ts.map
@@ -2,21 +2,56 @@
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 [];
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 rawResults = (await this.multicall.aggregate3.staticCall(calls.map((call) => ({ ...call, allowFailure: true }))));
41
+ if (rawResults.length !== calls.length) {
42
+ throw new exceptions_1.OracleError('RPC_REQUEST_FAILED', 'Multicall returned an unexpected number of results');
18
43
  }
19
- }));
44
+ return rawResults.map((result) => ({
45
+ success: result.success,
46
+ returnData: result.returnData,
47
+ }));
48
+ }
49
+ catch (error) {
50
+ if (error instanceof exceptions_1.OracleError) {
51
+ throw error;
52
+ }
53
+ throw new exceptions_1.OracleError('RPC_REQUEST_FAILED', 'Multicall RPC request failed');
54
+ }
20
55
  }
21
56
  }
22
57
  exports.MulticallProvider = MulticallProvider;
@@ -1 +1 @@
1
- {"version":3,"file":"MulticallProvider.js","sourceRoot":"","sources":["../../../src/infrastructure/multicall/MulticallProvider.ts"],"names":[],"mappings":";;;AAAA,mCAAgC;AAYhC,MAAa,iBAAiB;IACX,QAAQ,CAAyB;IAElD,YAAY,MAAc,EAAE,iBAA0B;QACpD,IAAI,CAAC,QAAQ,GAAG,IAAI,eAAM,CAAC,eAAe,CAAC,MAAM,CAAC,CAAC;IACrD,CAAC;IAED,KAAK,CAAC,SAAS,CAAC,KAAa;QAC3B,OAAO,OAAO,CAAC,GAAG,CAChB,KAAK,CAAC,GAAG,CAAC,KAAK,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE,EAAE;YACvC,IAAI,CAAC;gBACH,MAAM,UAAU,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAC;gBAC5E,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC;YACvC,CAAC;YAAC,MAAM,CAAC;gBACP,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC;YAC9C,CAAC;QACH,CAAC,CAAC,CACH,CAAC;IACJ,CAAC;CACF;AAnBD,8CAmBC"}
1
+ {"version":3,"file":"MulticallProvider.js","sourceRoot":"","sources":["../../../src/infrastructure/multicall/MulticallProvider.ts"],"names":[],"mappings":";;;AAAA,mCAAgC;AAChC,wDAAsD;AAEtD,iEAGsC;AACtC,iEAAkE;AAElE,MAAM,kBAAkB,GAAG,4CAA4C,CAAC;AACxE,MAAM,SAAS,GAAG,EAAE,CAAC;AACrB,MAAM,cAAc,GAAG;IACrB,4JAA4J;CACpJ,CAAC;AAYX,MAAa,iBAAiB;IACX,QAAQ,CAAyB;IACjC,SAAS,CAAkB;IAC3B,OAAO,CAAS;IAEjC,YACE,MAAc,EACd,mBAA2B,kBAAkB,EAC7C,OAAyB,EACzB,YAAqB;QAErB,IAAA,uCAAoB,EAAC,OAAO,CAAC,CAAC;QAC9B,IAAI,CAAC,eAAM,CAAC,SAAS,CAAC,gBAAgB,CAAC,EAAE,CAAC;YACxC,MAAM,IAAI,wBAAW,CAAC,gBAAgB,EAAE,8CAA8C,CAAC,CAAC;QAC1F,CAAC;QAED,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;QACvB,IAAI,CAAC,QAAQ,GAAG,IAAA,8CAA6B,EAAC,MAAM,EAAE,OAAO,EAAE,EAAE,aAAa,EAAE,CAAC,EAAE,EAAE,YAAY,CAAC,CAAC;QACnG,IAAI,CAAC,SAAS,GAAG,IAAI,eAAM,CAAC,QAAQ,CAAC,gBAAgB,EAAE,cAAc,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC;IACxF,CAAC;IAED,KAAK,CAAC,SAAS,CAAC,KAAsB;QACpC,IAAI,KAAK,CAAC,MAAM,GAAG,SAAS,EAAE,CAAC;YAC7B,MAAM,IAAI,wBAAW,CAAC,eAAe,EAAE,8BAA8B,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC;QAClG,CAAC;QACD,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACvB,OAAO,EAAE,CAAC;QACZ,CAAC;QAED,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,IAAI,CAAC,eAAM,CAAC,SAAS,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,eAAM,CAAC,WAAW,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC;gBACzE,MAAM,IAAI,wBAAW,CAAC,eAAe,EAAE,yDAAyD,CAAC,CAAC;YACpG,CAAC;QACH,CAAC;QAED,IAAI,CAAC;YACH,MAAM,IAAA,sCAAqB,EAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;YACzD,MAAM,UAAU,GAAG,CAAC,MAAM,IAAI,CAAC,SAAS,CAAC,UAAU,CAAC,UAAU,CAC5D,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,CAAC,CAAC,CACvD,CAAwD,CAAC;YAE1D,IAAI,UAAU,CAAC,MAAM,KAAK,KAAK,CAAC,MAAM,EAAE,CAAC;gBACvC,MAAM,IAAI,wBAAW,CAAC,oBAAoB,EAAE,oDAAoD,CAAC,CAAC;YACpG,CAAC;YAED,OAAO,UAAU,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;gBACjC,OAAO,EAAE,MAAM,CAAC,OAAO;gBACvB,UAAU,EAAE,MAAM,CAAC,UAAU;aAC9B,CAAC,CAAC,CAAC;QACN,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,KAAK,YAAY,wBAAW,EAAE,CAAC;gBACjC,MAAM,KAAK,CAAC;YACd,CAAC;YACD,MAAM,IAAI,wBAAW,CAAC,oBAAoB,EAAE,8BAA8B,CAAC,CAAC;QAC9E,CAAC;IACH,CAAC;CACF;AAxDD,8CAwDC","sourcesContent":["import { ethers } from 'ethers';\r\nimport { OracleError } from '../../domain/exceptions';\r\nimport type { ChainId } from '../../types';\r\nimport {\r\n assertProviderNetwork,\r\n createVerifiedJsonRpcProvider,\r\n} from '../blockchain/staticProvider';\r\nimport { assertSepoliaChainId } from '../config/gasOracleConfigs';\r\n\r\nconst MULTICALL3_ADDRESS = '0xcA11bde05977b3631167028862bE2a173976CA11';\r\nconst MAX_CALLS = 64;\r\nconst MULTICALL3_ABI = [\r\n 'function aggregate3(tuple(address target, bool allowFailure, bytes callData)[] calls) payable returns (tuple(bool success, bytes returnData)[] returnData)',\r\n] as const;\r\n\r\nexport interface Call {\r\n target: string;\r\n callData: string;\r\n}\r\n\r\nexport interface CallResult {\r\n success: boolean;\r\n returnData: string;\r\n}\r\n\r\nexport class MulticallProvider {\r\n private readonly provider: ethers.JsonRpcProvider;\r\n private readonly multicall: ethers.Contract;\r\n private readonly chainId: number;\r\n\r\n constructor(\r\n rpcUrl: string,\r\n multicallAddress: string = MULTICALL3_ADDRESS,\r\n chainId: ChainId | number,\r\n rpcTimeoutMs?: number,\r\n ) {\r\n assertSepoliaChainId(chainId);\r\n if (!ethers.isAddress(multicallAddress)) {\r\n throw new OracleError('INVALID_CONFIG', 'multicallAddress must be a valid EVM address');\r\n }\r\n\r\n this.chainId = chainId;\r\n this.provider = createVerifiedJsonRpcProvider(rpcUrl, chainId, { batchMaxCount: 1 }, rpcTimeoutMs);\r\n this.multicall = new ethers.Contract(multicallAddress, MULTICALL3_ABI, this.provider);\r\n }\r\n\r\n async aggregate(calls: readonly Call[]): Promise<CallResult[]> {\r\n if (calls.length > MAX_CALLS) {\r\n throw new OracleError('INVALID_INPUT', `Multicall supports at most ${String(MAX_CALLS)} calls`);\r\n }\r\n if (calls.length === 0) {\r\n return [];\r\n }\r\n\r\n for (const call of calls) {\r\n if (!ethers.isAddress(call.target) || !ethers.isHexString(call.callData)) {\r\n throw new OracleError('INVALID_INPUT', 'Every multicall target and calldata value must be valid');\r\n }\r\n }\r\n\r\n try {\r\n await assertProviderNetwork(this.provider, this.chainId);\r\n const rawResults = (await this.multicall.aggregate3.staticCall(\r\n calls.map((call) => ({ ...call, allowFailure: true })),\r\n )) as readonly { success: boolean; returnData: string }[];\r\n\r\n if (rawResults.length !== calls.length) {\r\n throw new OracleError('RPC_REQUEST_FAILED', 'Multicall returned an unexpected number of results');\r\n }\r\n\r\n return rawResults.map((result) => ({\r\n success: result.success,\r\n returnData: result.returnData,\r\n }));\r\n } catch (error) {\r\n if (error instanceof OracleError) {\r\n throw error;\r\n }\r\n throw new OracleError('RPC_REQUEST_FAILED', 'Multicall RPC request failed');\r\n }\r\n }\r\n}\r\n"]}
@@ -1,50 +1,15 @@
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, TokenPrices } from '../../types';
16
3
  export declare class TokenPriceOracle {
17
- private multicall;
18
- private offchainOracleAddress;
19
- private offchainOracleAbi;
4
+ private readonly multicall;
5
+ private readonly offchainOracleAddress;
6
+ private readonly interface;
7
+ private readonly logger;
20
8
  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
- */
9
+ constructor(rpcUrl: string, chainId: ChainId | number, offchainOracleAddress?: string, multicallAddress?: string, tokens?: Token[], logger?: ILogger, rpcTimeoutMs?: number);
10
+ fetchPrices(tokens?: readonly Token[]): Promise<TokenPrices>;
11
+ fetchPrice(token: Token): Promise<string>;
40
12
  getSupportedTokens(): Token[];
41
- /**
42
- * Add custom tokens
43
- */
44
- addTokens(tokens: Token[]): void;
45
- /**
46
- * Encode getRateToEth call
47
- */
48
- private encodeGetRateToEth;
13
+ addTokens(tokens: readonly Token[]): void;
14
+ private normalizeTokens;
49
15
  }
50
- //# sourceMappingURL=TokenPriceOracle.d.ts.map
@@ -2,131 +2,98 @@
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 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 { ...prices };
98
61
  }
99
- /**
100
- * Fetch price for single token
101
- */
102
62
  async fetchPrice(token) {
103
63
  const prices = await this.fetchPrices([token]);
104
- return prices[token.symbol.toLowerCase()] || null;
64
+ return prices[token.symbol.trim().toLowerCase()];
105
65
  }
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];
67
+ return this.defaultTokens.map((token) => ({ ...token }));
117
68
  }
118
- /**
119
- * Add custom tokens
120
- */
121
69
  addTokens(tokens) {
122
- this.defaultTokens = [...this.defaultTokens, ...tokens];
70
+ this.defaultTokens = this.normalizeTokens([...this.defaultTokens, ...tokens]);
123
71
  }
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]);
72
+ normalizeTokens(tokens) {
73
+ if (tokens.length === 0) {
74
+ throw new exceptions_1.OracleError('INVALID_CONFIG', `Token list must contain between 1 and ${String(MAX_TOKENS)} items`);
75
+ }
76
+ const byAddress = new Map();
77
+ const symbols = new Set();
78
+ for (const token of tokens) {
79
+ const address = token.address.trim().toLowerCase();
80
+ const symbol = token.symbol.trim().toLowerCase();
81
+ if (!ethers_1.ethers.isAddress(address) || !/^[a-z0-9]{2,16}$/.test(symbol)) {
82
+ throw new exceptions_1.OracleError('INVALID_CONFIG', 'Token address or symbol is invalid');
83
+ }
84
+ if (!Number.isInteger(token.decimals) || token.decimals < 0 || token.decimals > 255) {
85
+ throw new exceptions_1.OracleError('INVALID_CONFIG', `Invalid decimals for token ${symbol}`);
86
+ }
87
+ if (symbols.has(symbol) && byAddress.get(address)?.symbol !== symbol) {
88
+ throw new exceptions_1.OracleError('INVALID_CONFIG', `Duplicate token symbol: ${symbol}`);
89
+ }
90
+ symbols.add(symbol);
91
+ byAddress.set(address, { address, symbol, decimals: token.decimals });
92
+ }
93
+ if (byAddress.size > MAX_TOKENS) {
94
+ throw new exceptions_1.OracleError('INVALID_CONFIG', `Token list exceeds ${String(MAX_TOKENS)} unique addresses`);
95
+ }
96
+ return [...byAddress.values()];
130
97
  }
131
98
  }
132
99
  exports.TokenPriceOracle = TokenPriceOracle;