@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.
- package/README.md +90 -111
- package/dist/application/ports/ILogger.d.ts +0 -1
- package/dist/application/ports/ILogger.js +0 -1
- package/dist/domain/config/TokenDefaults.d.ts +0 -32
- package/dist/domain/config/TokenDefaults.js +7 -73
- package/dist/domain/exceptions.d.ts +8 -4
- package/dist/domain/exceptions.js +12 -4
- package/dist/domain/repositories.d.ts +3 -2
- package/dist/domain/repositories.js +10 -1
- package/dist/factory.d.ts +15 -46
- package/dist/factory.js +18 -28
- package/dist/index.d.ts +6 -1
- package/dist/index.js +4 -1
- package/dist/infrastructure/blockchain/staticProvider.d.ts +3 -0
- package/dist/infrastructure/blockchain/staticProvider.js +36 -0
- package/dist/infrastructure/config/ProtocolTokenConfig.d.ts +0 -1
- package/dist/infrastructure/config/ProtocolTokenConfig.js +7 -5
- package/dist/infrastructure/config/gasOracleConfigs.d.ts +2 -13
- package/dist/infrastructure/config/gasOracleConfigs.js +7 -34
- package/dist/infrastructure/gas/GasPriceOracle.d.ts +14 -17
- package/dist/infrastructure/gas/GasPriceOracle.js +89 -44
- package/dist/infrastructure/mixerx/MixerXFeeOracle.d.ts +99 -0
- package/dist/infrastructure/mixerx/MixerXFeeOracle.js +292 -0
- package/dist/infrastructure/multicall/MulticallProvider.d.ts +9 -3
- package/dist/infrastructure/multicall/MulticallProvider.js +48 -10
- package/dist/infrastructure/token-price/TokenPriceOracle.d.ts +10 -47
- package/dist/infrastructure/token-price/TokenPriceOracle.js +75 -107
- package/dist/types.d.ts +11 -2
- package/dist/types.js +0 -1
- package/package.json +37 -15
- package/dist/application/ports/ILogger.d.ts.map +0 -1
- package/dist/application/ports/ILogger.js.map +0 -1
- package/dist/application/ports/index.d.ts +0 -6
- package/dist/application/ports/index.d.ts.map +0 -1
- package/dist/application/ports/index.js +0 -18
- package/dist/application/ports/index.js.map +0 -1
- package/dist/application/services/TokenPriceService.d.ts +0 -61
- package/dist/application/services/TokenPriceService.d.ts.map +0 -1
- package/dist/application/services/TokenPriceService.js +0 -137
- package/dist/application/services/TokenPriceService.js.map +0 -1
- package/dist/domain/config/TokenDefaults.d.ts.map +0 -1
- package/dist/domain/config/TokenDefaults.js.map +0 -1
- package/dist/domain/config/index.d.ts +0 -2
- package/dist/domain/config/index.d.ts.map +0 -1
- package/dist/domain/config/index.js +0 -20
- package/dist/domain/config/index.js.map +0 -1
- package/dist/domain/exceptions.d.ts.map +0 -1
- package/dist/domain/exceptions.js.map +0 -1
- package/dist/domain/repositories.d.ts.map +0 -1
- package/dist/domain/repositories.js.map +0 -1
- package/dist/factory.d.ts.map +0 -1
- package/dist/factory.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/infrastructure/blockchain/contracts.d.ts +0 -20
- package/dist/infrastructure/blockchain/contracts.d.ts.map +0 -1
- package/dist/infrastructure/blockchain/contracts.js +0 -10
- package/dist/infrastructure/blockchain/contracts.js.map +0 -1
- package/dist/infrastructure/config/ProtocolTokenConfig.d.ts.map +0 -1
- package/dist/infrastructure/config/ProtocolTokenConfig.js.map +0 -1
- package/dist/infrastructure/config/gasOracleConfigs.d.ts.map +0 -1
- package/dist/infrastructure/config/gasOracleConfigs.js.map +0 -1
- package/dist/infrastructure/config/index.d.ts +0 -5
- package/dist/infrastructure/config/index.d.ts.map +0 -1
- package/dist/infrastructure/config/index.js +0 -11
- package/dist/infrastructure/config/index.js.map +0 -1
- package/dist/infrastructure/gas/GasPriceOracle.d.ts.map +0 -1
- package/dist/infrastructure/gas/GasPriceOracle.js.map +0 -1
- package/dist/infrastructure/multicall/MulticallProvider.d.ts.map +0 -1
- package/dist/infrastructure/multicall/MulticallProvider.js.map +0 -1
- package/dist/infrastructure/token-price/TokenPriceOracle.d.ts.map +0 -1
- package/dist/infrastructure/token-price/TokenPriceOracle.js.map +0 -1
- package/dist/infrastructure/token-price/index.d.ts +0 -3
- package/dist/infrastructure/token-price/index.d.ts.map +0 -1
- package/dist/infrastructure/token-price/index.js +0 -6
- package/dist/infrastructure/token-price/index.js.map +0 -1
- package/dist/infrastructure/tornado/TornadoFeeOracle.d.ts +0 -90
- package/dist/infrastructure/tornado/TornadoFeeOracle.d.ts.map +0 -1
- package/dist/infrastructure/tornado/TornadoFeeOracle.js +0 -230
- package/dist/infrastructure/tornado/TornadoFeeOracle.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- 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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
20
|
-
private
|
|
21
|
-
private
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
18
|
+
defaultTokens;
|
|
19
|
+
constructor(rpcUrl, chainId, offchainOracleAddress, multicallAddress, tokens, logger, rpcTimeoutMs) {
|
|
32
20
|
this.offchainOracleAddress = offchainOracleAddress ?? (0, ProtocolTokenConfig_1.resolveOffchainOracleAddress)(chainId);
|
|
33
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
93
|
-
this.logger.
|
|
94
|
-
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
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
|