@mixerx/oracles 2.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 +31 -19
- package/dist/application/ports/ILogger.js +0 -1
- package/dist/domain/config/TokenDefaults.js +0 -1
- package/dist/domain/exceptions.d.ts +1 -1
- package/dist/domain/exceptions.js +0 -1
- package/dist/domain/repositories.d.ts +1 -2
- package/dist/domain/repositories.js +0 -1
- package/dist/factory.d.ts +5 -15
- package/dist/factory.js +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +0 -1
- package/dist/infrastructure/blockchain/staticProvider.js +0 -1
- package/dist/infrastructure/config/ProtocolTokenConfig.js +5 -5
- package/dist/infrastructure/config/gasOracleConfigs.js +0 -1
- package/dist/infrastructure/gas/GasPriceOracle.d.ts +5 -18
- package/dist/infrastructure/gas/GasPriceOracle.js +51 -45
- package/dist/infrastructure/mixerx/MixerXFeeOracle.d.ts +16 -5
- package/dist/infrastructure/mixerx/MixerXFeeOracle.js +110 -38
- package/dist/infrastructure/multicall/MulticallProvider.d.ts +5 -1
- package/dist/infrastructure/multicall/MulticallProvider.js +10 -7
- package/dist/infrastructure/token-price/TokenPriceOracle.d.ts +3 -5
- package/dist/infrastructure/token-price/TokenPriceOracle.js +11 -10
- package/dist/types.d.ts +6 -1
- package/dist/types.js +0 -1
- package/package.json +62 -56
- package/dist/application/ports/ILogger.js.map +0 -1
- package/dist/application/ports/index.d.ts +0 -5
- package/dist/application/ports/index.js +0 -18
- package/dist/application/ports/index.js.map +0 -1
- package/dist/domain/config/TokenDefaults.js.map +0 -1
- package/dist/domain/config/index.d.ts +0 -1
- package/dist/domain/config/index.js +0 -8
- package/dist/domain/config/index.js.map +0 -1
- package/dist/domain/exceptions.js.map +0 -1
- package/dist/domain/repositories.js.map +0 -1
- package/dist/factory.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/infrastructure/blockchain/staticProvider.js.map +0 -1
- package/dist/infrastructure/config/ProtocolTokenConfig.js.map +0 -1
- package/dist/infrastructure/config/gasOracleConfigs.js.map +0 -1
- package/dist/infrastructure/config/index.d.ts +0 -4
- package/dist/infrastructure/config/index.js +0 -11
- package/dist/infrastructure/config/index.js.map +0 -1
- package/dist/infrastructure/gas/GasPriceOracle.js.map +0 -1
- package/dist/infrastructure/mixerx/MixerXFeeOracle.js.map +0 -1
- package/dist/infrastructure/multicall/MulticallProvider.js.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.js +0 -6
- package/dist/infrastructure/token-price/index.js.map +0 -1
- package/dist/types.js.map +0 -1
|
@@ -6,12 +6,27 @@ const TokenDefaults_1 = require("../../domain/config/TokenDefaults");
|
|
|
6
6
|
const exceptions_1 = require("../../domain/exceptions");
|
|
7
7
|
const repositories_1 = require("../../domain/repositories");
|
|
8
8
|
const GasPriceOracle_1 = require("../gas/GasPriceOracle");
|
|
9
|
+
const gasOracleConfigs_1 = require("../config/gasOracleConfigs");
|
|
9
10
|
const TokenPriceOracle_1 = require("../token-price/TokenPriceOracle");
|
|
10
11
|
const DEFAULT_MAX_PRICE_AGE_MS = 120_000;
|
|
11
12
|
const DEFAULT_MAX_REFUND_WEI = 1000000000000000000n;
|
|
13
|
+
const DEFAULT_MAX_TOTAL_FEE_BPS = 1_000;
|
|
14
|
+
const BASIS_POINTS_SCALE = 10000n;
|
|
12
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
|
+
]);
|
|
13
29
|
class MixerXFeeOracle {
|
|
14
|
-
chainId;
|
|
15
30
|
relayerFeePartsPerMillion;
|
|
16
31
|
gasPriceOracle;
|
|
17
32
|
tokenPriceOracle;
|
|
@@ -19,11 +34,18 @@ class MixerXFeeOracle {
|
|
|
19
34
|
logger;
|
|
20
35
|
maxPriceAgeMs;
|
|
21
36
|
maxRefundWei;
|
|
22
|
-
|
|
37
|
+
maxTotalFeeBps;
|
|
38
|
+
currencyDecimals;
|
|
39
|
+
priceBounds;
|
|
23
40
|
constructor(options) {
|
|
41
|
+
(0, gasOracleConfigs_1.assertSepoliaChainId)(options.chainId);
|
|
24
42
|
if (!Number.isFinite(options.relayerFeePercent) || options.relayerFeePercent < 0 || options.relayerFeePercent > 0.1) {
|
|
25
43
|
throw new exceptions_1.OracleError('INVALID_CONFIG', 'relayerFeePercent must be between 0 and 0.1');
|
|
26
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
|
+
}
|
|
27
49
|
this.maxPriceAgeMs = options.maxPriceAgeMs ?? DEFAULT_MAX_PRICE_AGE_MS;
|
|
28
50
|
if (!Number.isInteger(this.maxPriceAgeMs) || this.maxPriceAgeMs < 1_000 || this.maxPriceAgeMs > 3_600_000) {
|
|
29
51
|
throw new exceptions_1.OracleError('INVALID_CONFIG', 'maxPriceAgeMs must be between 1000 and 3600000');
|
|
@@ -32,8 +54,13 @@ class MixerXFeeOracle {
|
|
|
32
54
|
if (this.maxRefundWei < 0n) {
|
|
33
55
|
throw new exceptions_1.OracleError('INVALID_CONFIG', 'maxRefundWei cannot be negative');
|
|
34
56
|
}
|
|
35
|
-
this.
|
|
36
|
-
|
|
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);
|
|
37
64
|
this.logger = options.logger ?? new ILogger_1.NullLogger();
|
|
38
65
|
this.gasPriceOracle = options.gasPriceOracle ?? new GasPriceOracle_1.GasPriceOracle({
|
|
39
66
|
chainId: options.chainId,
|
|
@@ -43,15 +70,20 @@ class MixerXFeeOracle {
|
|
|
43
70
|
maxGasPriceGwei: options.maxGasPriceGwei,
|
|
44
71
|
});
|
|
45
72
|
this.tokenPriceOracle = options.tokenPriceOracle ?? new TokenPriceOracle_1.TokenPriceOracle(options.rpcUrl, options.chainId, options.offchainOracleAddress, options.multicallAddress, undefined, this.logger, options.rpcTimeoutMs);
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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]),
|
|
49
80
|
]);
|
|
81
|
+
this.priceBounds = this.validatePriceBounds(options.priceBounds ?? {});
|
|
50
82
|
this.priceRepository = options.priceRepository;
|
|
51
83
|
}
|
|
52
84
|
async calculateWithdrawalFee(input) {
|
|
53
85
|
const currency = this.validateFeeInput(input);
|
|
54
|
-
const txType = input.txType ?? 'relayer_withdrawal';
|
|
86
|
+
const txType = this.validateTxType(input.txType ?? 'relayer_withdrawal');
|
|
55
87
|
const gasParams = await this.getGasParametersForTx({
|
|
56
88
|
txType,
|
|
57
89
|
purpose: this.normalizePurpose(input.purpose ?? 'fee'),
|
|
@@ -62,7 +94,8 @@ class MixerXFeeOracle {
|
|
|
62
94
|
const gasCostWei = gasPriceWei * BigInt(gasLimit);
|
|
63
95
|
const relayerFee = (input.amount * this.relayerFeePartsPerMillion) / FEE_SCALE;
|
|
64
96
|
if (currency === 'eth') {
|
|
65
|
-
if (input.refund !== undefined && this.parseRefund(input.refund) !== 0n)
|
|
97
|
+
if ((input.refund !== undefined && this.parseRefund(input.refund) !== 0n) ||
|
|
98
|
+
(input.refundGasMultiplier !== undefined && input.refundGasMultiplier !== 0)) {
|
|
66
99
|
throw new exceptions_1.OracleError('INVALID_INPUT', 'refund must be zero for native ETH withdrawals');
|
|
67
100
|
}
|
|
68
101
|
const totalFee = gasCostWei + relayerFee;
|
|
@@ -71,6 +104,7 @@ class MixerXFeeOracle {
|
|
|
71
104
|
gasCost: gasCostWei,
|
|
72
105
|
relayerFee,
|
|
73
106
|
refundAmount: 0n,
|
|
107
|
+
refundWei: 0n,
|
|
74
108
|
totalFee,
|
|
75
109
|
currency,
|
|
76
110
|
gasLimit,
|
|
@@ -83,7 +117,7 @@ class MixerXFeeOracle {
|
|
|
83
117
|
const quote = await this.getFreshPrice(currency);
|
|
84
118
|
const decimalsFactor = 10n ** BigInt(input.decimals);
|
|
85
119
|
const gasCost = (gasCostWei * decimalsFactor) / quote.priceInEthWei;
|
|
86
|
-
const refundWei = this.
|
|
120
|
+
const refundWei = this.resolveRefundWei(input, gasCostWei);
|
|
87
121
|
const refundAmount = (refundWei * decimalsFactor) / quote.priceInEthWei;
|
|
88
122
|
const totalFee = gasCost + relayerFee + refundAmount;
|
|
89
123
|
this.assertTotalFee(totalFee, input.amount);
|
|
@@ -91,6 +125,7 @@ class MixerXFeeOracle {
|
|
|
91
125
|
gasCost,
|
|
92
126
|
relayerFee,
|
|
93
127
|
refundAmount,
|
|
128
|
+
refundWei,
|
|
94
129
|
totalFee,
|
|
95
130
|
currency,
|
|
96
131
|
gasLimit,
|
|
@@ -103,31 +138,31 @@ class MixerXFeeOracle {
|
|
|
103
138
|
});
|
|
104
139
|
}
|
|
105
140
|
async getGasParametersForTx(input = {}) {
|
|
106
|
-
const txType = input.txType ?? 'relayer_withdrawal';
|
|
141
|
+
const txType = this.validateTxType(input.txType ?? 'relayer_withdrawal');
|
|
107
142
|
const purpose = this.normalizePurpose(input.purpose ?? 'send');
|
|
108
143
|
const bumpPercent = purpose === 'send' ? 0 : (0, TokenDefaults_1.getBumpPercent)(txType);
|
|
109
144
|
const gasParams = await this.gasPriceOracle.getTxGasParams({
|
|
110
145
|
isLegacy: input.isLegacy ?? false,
|
|
111
146
|
bumpPercent,
|
|
112
|
-
legacySpeed: 'fast',
|
|
113
147
|
});
|
|
114
148
|
return { ...gasParams, txType, purpose, bumpPercent };
|
|
115
149
|
}
|
|
116
150
|
async assertReady() {
|
|
117
|
-
await
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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)));
|
|
124
158
|
}
|
|
125
159
|
validateFeeInput(input) {
|
|
126
160
|
const currency = input.currency.trim().toLowerCase();
|
|
127
161
|
if (!/^[a-z0-9]{2,16}$/.test(currency)) {
|
|
128
162
|
throw new exceptions_1.OracleError('INVALID_INPUT', 'currency must be a valid protocol token symbol');
|
|
129
163
|
}
|
|
130
|
-
|
|
164
|
+
const configuredDecimals = this.currencyDecimals.get(currency);
|
|
165
|
+
if (configuredDecimals === undefined) {
|
|
131
166
|
throw new exceptions_1.OracleError('INVALID_INPUT', `Unsupported MixerX currency: ${currency}`);
|
|
132
167
|
}
|
|
133
168
|
if (input.amount <= 0n) {
|
|
@@ -136,13 +171,16 @@ class MixerXFeeOracle {
|
|
|
136
171
|
if (!Number.isInteger(input.decimals) || input.decimals < 0 || input.decimals > 255) {
|
|
137
172
|
throw new exceptions_1.OracleError('INVALID_INPUT', 'decimals must be an integer between 0 and 255');
|
|
138
173
|
}
|
|
174
|
+
if (input.decimals !== configuredDecimals) {
|
|
175
|
+
throw new exceptions_1.OracleError('INVALID_INPUT', `${currency} must use configured decimals ${String(configuredDecimals)}`);
|
|
176
|
+
}
|
|
139
177
|
if (!Number.isInteger(input.gasLimit) || input.gasLimit < 21_000 || input.gasLimit > 5_000_000) {
|
|
140
178
|
throw new exceptions_1.OracleError('INVALID_INPUT', 'gasLimit must be an integer between 21000 and 5000000');
|
|
141
179
|
}
|
|
142
180
|
return currency;
|
|
143
181
|
}
|
|
144
182
|
parseRefund(raw) {
|
|
145
|
-
if (!/^\d+$/.test(raw)) {
|
|
183
|
+
if (typeof raw !== 'string' || raw.length > MAX_REFUND_DIGITS || !/^\d+$/.test(raw)) {
|
|
146
184
|
throw new exceptions_1.OracleError('INVALID_INPUT', 'refund must be an unsigned base-10 integer string');
|
|
147
185
|
}
|
|
148
186
|
const refund = BigInt(raw);
|
|
@@ -151,45 +189,78 @@ class MixerXFeeOracle {
|
|
|
151
189
|
}
|
|
152
190
|
return refund;
|
|
153
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
|
+
}
|
|
154
206
|
async getFreshPrice(symbol) {
|
|
155
207
|
if (!this.priceRepository) {
|
|
156
208
|
throw new exceptions_1.OracleError('TOKEN_PRICE_UNAVAILABLE', `No fresh price repository configured for ${symbol}`);
|
|
157
209
|
}
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
this.priceRepository.getLastUpdateTimestamp(symbol),
|
|
161
|
-
]);
|
|
162
|
-
const quote = (0, repositories_1.toPriceQuote)(price, observedAtMs);
|
|
210
|
+
const storedQuote = await this.priceRepository.getQuote(symbol);
|
|
211
|
+
const quote = (0, repositories_1.toPriceQuote)(storedQuote?.priceInEthWei, storedQuote?.observedAtMs);
|
|
163
212
|
if (!quote) {
|
|
164
213
|
throw new exceptions_1.OracleError('TOKEN_PRICE_UNAVAILABLE', `Price unavailable for ${symbol}`);
|
|
165
214
|
}
|
|
166
215
|
if (Date.now() - quote.observedAtMs > this.maxPriceAgeMs || quote.observedAtMs > Date.now() + 5_000) {
|
|
167
216
|
throw new exceptions_1.OracleError('TOKEN_PRICE_STALE', `Price is stale for ${symbol}`);
|
|
168
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
|
+
}
|
|
169
224
|
return quote;
|
|
170
225
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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 }));
|
|
179
236
|
}
|
|
180
|
-
|
|
237
|
+
return validated;
|
|
238
|
+
}
|
|
239
|
+
effectiveGasPrice(params) {
|
|
240
|
+
return BigInt(params.effectiveGasPriceWei);
|
|
181
241
|
}
|
|
182
242
|
assertTotalFee(totalFee, amount) {
|
|
183
|
-
|
|
184
|
-
|
|
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`);
|
|
185
247
|
}
|
|
186
248
|
}
|
|
187
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
|
+
}
|
|
188
253
|
if (purpose === 'fee_quote' || purpose === 'fee_validation') {
|
|
189
254
|
return 'fee';
|
|
190
255
|
}
|
|
191
256
|
return purpose;
|
|
192
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
|
+
}
|
|
193
264
|
result(input) {
|
|
194
265
|
this.logger.debug('Withdrawal fee calculated', {
|
|
195
266
|
priceSource: input.priceSource,
|
|
@@ -199,10 +270,12 @@ class MixerXFeeOracle {
|
|
|
199
270
|
gasCost: input.gasCost,
|
|
200
271
|
relayerFee: input.relayerFee,
|
|
201
272
|
refundAmount: input.refundAmount,
|
|
273
|
+
refundWei: input.refundWei,
|
|
202
274
|
totalFee: input.totalFee,
|
|
203
275
|
currency: input.currency,
|
|
204
276
|
gasLimit: input.gasLimit,
|
|
205
277
|
gasPriceWei: input.gasPriceWei,
|
|
278
|
+
gasSource: input.gasParams.gasSource,
|
|
206
279
|
baseFeePerGas: input.gasParams.baseFeePerGas,
|
|
207
280
|
maxFeePerGas: input.gasParams.maxFeePerGas,
|
|
208
281
|
maxPriorityFeePerGas: input.gasParams.maxPriorityFeePerGas,
|
|
@@ -217,4 +290,3 @@ class MixerXFeeOracle {
|
|
|
217
290
|
}
|
|
218
291
|
}
|
|
219
292
|
exports.MixerXFeeOracle = MixerXFeeOracle;
|
|
220
|
-
//# sourceMappingURL=MixerXFeeOracle.js.map
|
|
@@ -7,10 +7,14 @@ export interface CallResult {
|
|
|
7
7
|
success: boolean;
|
|
8
8
|
returnData: string;
|
|
9
9
|
}
|
|
10
|
+
export interface MulticallResult {
|
|
11
|
+
blockNumber: number;
|
|
12
|
+
results: CallResult[];
|
|
13
|
+
}
|
|
10
14
|
export declare class MulticallProvider {
|
|
11
15
|
private readonly provider;
|
|
12
16
|
private readonly multicall;
|
|
13
17
|
private readonly chainId;
|
|
14
18
|
constructor(rpcUrl: string, multicallAddress: string | undefined, chainId: ChainId | number, rpcTimeoutMs?: number);
|
|
15
|
-
aggregate(calls: readonly Call[]): Promise<
|
|
19
|
+
aggregate(calls: readonly Call[]): Promise<MulticallResult>;
|
|
16
20
|
}
|
|
@@ -28,7 +28,7 @@ class MulticallProvider {
|
|
|
28
28
|
throw new exceptions_1.OracleError('INVALID_INPUT', `Multicall supports at most ${String(MAX_CALLS)} calls`);
|
|
29
29
|
}
|
|
30
30
|
if (calls.length === 0) {
|
|
31
|
-
return [];
|
|
31
|
+
return { blockNumber: await this.provider.getBlockNumber(), results: [] };
|
|
32
32
|
}
|
|
33
33
|
for (const call of calls) {
|
|
34
34
|
if (!ethers_1.ethers.isAddress(call.target) || !ethers_1.ethers.isHexString(call.callData)) {
|
|
@@ -37,14 +37,18 @@ class MulticallProvider {
|
|
|
37
37
|
}
|
|
38
38
|
try {
|
|
39
39
|
await (0, staticProvider_1.assertProviderNetwork)(this.provider, this.chainId);
|
|
40
|
-
const
|
|
40
|
+
const blockNumber = await this.provider.getBlockNumber();
|
|
41
|
+
const rawResults = (await this.multicall.aggregate3.staticCall(calls.map((call) => ({ ...call, allowFailure: true })), { blockTag: blockNumber }));
|
|
41
42
|
if (rawResults.length !== calls.length) {
|
|
42
43
|
throw new exceptions_1.OracleError('RPC_REQUEST_FAILED', 'Multicall returned an unexpected number of results');
|
|
43
44
|
}
|
|
44
|
-
return
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
return {
|
|
46
|
+
blockNumber,
|
|
47
|
+
results: rawResults.map((result) => ({
|
|
48
|
+
success: result.success,
|
|
49
|
+
returnData: result.returnData,
|
|
50
|
+
})),
|
|
51
|
+
};
|
|
48
52
|
}
|
|
49
53
|
catch (error) {
|
|
50
54
|
if (error instanceof exceptions_1.OracleError) {
|
|
@@ -55,4 +59,3 @@ class MulticallProvider {
|
|
|
55
59
|
}
|
|
56
60
|
}
|
|
57
61
|
exports.MulticallProvider = MulticallProvider;
|
|
58
|
-
//# sourceMappingURL=MulticallProvider.js.map
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
import type { ILogger } from '../../application/ports/ILogger';
|
|
2
|
-
import type { ChainId, Token,
|
|
2
|
+
import type { ChainId, Token, TokenPriceSnapshot } from '../../types';
|
|
3
3
|
export declare class TokenPriceOracle {
|
|
4
4
|
private readonly multicall;
|
|
5
5
|
private readonly offchainOracleAddress;
|
|
6
6
|
private readonly interface;
|
|
7
7
|
private readonly logger;
|
|
8
|
-
private defaultTokens;
|
|
8
|
+
private readonly defaultTokens;
|
|
9
9
|
constructor(rpcUrl: string, chainId: ChainId | number, offchainOracleAddress?: string, multicallAddress?: string, tokens?: Token[], logger?: ILogger, rpcTimeoutMs?: number);
|
|
10
|
-
fetchPrices(tokens?: readonly Token[]): Promise<
|
|
11
|
-
fetchPrice(token: Token): Promise<string>;
|
|
10
|
+
fetchPrices(tokens?: readonly Token[]): Promise<TokenPriceSnapshot>;
|
|
12
11
|
getSupportedTokens(): Token[];
|
|
13
|
-
addTokens(tokens: readonly Token[]): void;
|
|
14
12
|
private normalizeTokens;
|
|
15
13
|
}
|
|
@@ -31,7 +31,7 @@ class TokenPriceOracle {
|
|
|
31
31
|
target: this.offchainOracleAddress,
|
|
32
32
|
callData: this.interface.encodeFunctionData('getRateToEth', [token.address, true]),
|
|
33
33
|
}));
|
|
34
|
-
const results = await this.multicall.aggregate(calls);
|
|
34
|
+
const { blockNumber, results } = await this.multicall.aggregate(calls);
|
|
35
35
|
const prices = Object.create(null);
|
|
36
36
|
const failedSymbols = [];
|
|
37
37
|
for (const [index, result] of results.entries()) {
|
|
@@ -57,18 +57,15 @@ class TokenPriceOracle {
|
|
|
57
57
|
this.logger.warn('Token price batch rejected', { failedSymbols: [...failedSymbols] });
|
|
58
58
|
throw new exceptions_1.TokenPriceError('One or more on-chain token prices are unavailable', failedSymbols);
|
|
59
59
|
}
|
|
60
|
-
return {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
60
|
+
return Object.freeze({
|
|
61
|
+
prices: Object.freeze({ ...prices }),
|
|
62
|
+
observedAtMs: Date.now(),
|
|
63
|
+
blockNumber,
|
|
64
|
+
});
|
|
65
65
|
}
|
|
66
66
|
getSupportedTokens() {
|
|
67
67
|
return this.defaultTokens.map((token) => ({ ...token }));
|
|
68
68
|
}
|
|
69
|
-
addTokens(tokens) {
|
|
70
|
-
this.defaultTokens = this.normalizeTokens([...this.defaultTokens, ...tokens]);
|
|
71
|
-
}
|
|
72
69
|
normalizeTokens(tokens) {
|
|
73
70
|
if (tokens.length === 0) {
|
|
74
71
|
throw new exceptions_1.OracleError('INVALID_CONFIG', `Token list must contain between 1 and ${String(MAX_TOKENS)} items`);
|
|
@@ -87,6 +84,11 @@ class TokenPriceOracle {
|
|
|
87
84
|
if (symbols.has(symbol) && byAddress.get(address)?.symbol !== symbol) {
|
|
88
85
|
throw new exceptions_1.OracleError('INVALID_CONFIG', `Duplicate token symbol: ${symbol}`);
|
|
89
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
|
+
}
|
|
90
92
|
symbols.add(symbol);
|
|
91
93
|
byAddress.set(address, { address, symbol, decimals: token.decimals });
|
|
92
94
|
}
|
|
@@ -97,4 +99,3 @@ class TokenPriceOracle {
|
|
|
97
99
|
}
|
|
98
100
|
}
|
|
99
101
|
exports.TokenPriceOracle = TokenPriceOracle;
|
|
100
|
-
//# sourceMappingURL=TokenPriceOracle.js.map
|
package/dist/types.d.ts
CHANGED
|
@@ -5,7 +5,12 @@ export interface Token {
|
|
|
5
5
|
decimals: number;
|
|
6
6
|
}
|
|
7
7
|
export interface TokenPrices {
|
|
8
|
-
[symbol: string]: string;
|
|
8
|
+
readonly [symbol: string]: string;
|
|
9
|
+
}
|
|
10
|
+
export interface TokenPriceSnapshot {
|
|
11
|
+
readonly prices: TokenPrices;
|
|
12
|
+
readonly observedAtMs: number;
|
|
13
|
+
readonly blockNumber: number;
|
|
9
14
|
}
|
|
10
15
|
export interface TokenPriceQuote {
|
|
11
16
|
priceInEthWei: bigint;
|
package/dist/types.js
CHANGED
package/package.json
CHANGED
|
@@ -1,56 +1,62 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@mixerx/oracles",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Fail-closed Sepolia gas, token-price and withdrawal-fee policies for MixerX",
|
|
5
|
-
"main": "./dist/index.js",
|
|
6
|
-
"types": "./dist/index.d.ts",
|
|
7
|
-
"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"
|
|
55
|
-
|
|
56
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@mixerx/oracles",
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "Fail-closed Sepolia gas, token-price and withdrawal-fee policies for MixerX",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"packageManager": "yarn@4.12.0",
|
|
14
|
+
"files": [
|
|
15
|
+
"dist/**/*"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "yarn clean && tsc",
|
|
19
|
+
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"lint": "eslint src tests",
|
|
22
|
+
"test": "yarn build && node --test \"tests/**/*.test.cjs\"",
|
|
23
|
+
"validate": "yarn typecheck && yarn lint && yarn test",
|
|
24
|
+
"prepack": "yarn build",
|
|
25
|
+
"prepublish": "yarn validate"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"mixerx",
|
|
29
|
+
"ethereum",
|
|
30
|
+
"gas",
|
|
31
|
+
"oracle",
|
|
32
|
+
"fees"
|
|
33
|
+
],
|
|
34
|
+
"author": "MixerX Contributors",
|
|
35
|
+
"license": "GPL-3.0-only",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/zkusd-mixerx/mixerx-oracles.git"
|
|
39
|
+
},
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/zkusd-mixerx/mixerx-oracles/issues"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://github.com/zkusd-mixerx/mixerx-oracles#readme",
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@mixerx/config": "1.0.0",
|
|
49
|
+
"ethers": "6.17.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@eslint/js": "9.39.1",
|
|
53
|
+
"@types/node": "24.10.1",
|
|
54
|
+
"eslint": "9.39.1",
|
|
55
|
+
"globals": "16.5.0",
|
|
56
|
+
"typescript": "5.9.3",
|
|
57
|
+
"typescript-eslint": "8.48.1"
|
|
58
|
+
},
|
|
59
|
+
"engines": {
|
|
60
|
+
"node": ">=24.12.0"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"ILogger.js","sourceRoot":"","sources":["../../../src/application/ports/ILogger.ts"],"names":[],"mappings":";;;AAMA,MAAa,UAAU;IACrB,KAAK,CAAC,QAAgB,EAAE,KAAe,IAAS,CAAC;IACjD,IAAI,CAAC,QAAgB,EAAE,KAAe,IAAS,CAAC;IAChD,KAAK,CAAC,QAAgB,EAAE,KAAe,IAAS,CAAC;CAClD;AAJD,gCAIC;AAED,MAAa,aAAa;IACK;IAA7B,YAA6B,QAAQ,gBAAgB;QAAxB,UAAK,GAAL,KAAK,CAAmB;IAAG,CAAC;IAEzD,KAAK,CAAC,OAAe,EAAE,IAAc;QACnC,OAAO,CAAC,KAAK,CAAC,IAAI,IAAI,CAAC,KAAK,KAAK,OAAO,EAAE,EAAE,IAAI,IAAI,EAAE,CAAC,CAAC;IAC1D,CAAC;IAED,IAAI,CAAC,OAAe,EAAE,IAAc;QAClC,OAAO,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,KAAK,KAAK,OAAO,EAAE,EAAE,IAAI,IAAI,EAAE,CAAC,CAAC;IACzD,CAAC;IAED,KAAK,CAAC,OAAe,EAAE,IAAc;QACnC,OAAO,CAAC,KAAK,CAAC,IAAI,IAAI,CAAC,KAAK,KAAK,OAAO,EAAE,EAAE,IAAI,IAAI,EAAE,CAAC,CAAC;IAC1D,CAAC;CACF;AAdD,sCAcC","sourcesContent":["export interface ILogger {\r\n debug(message: string, meta?: unknown): void;\r\n warn(message: string, meta?: unknown): void;\r\n error(message: string, meta?: unknown): void;\r\n}\r\n\r\nexport class NullLogger implements ILogger {\r\n debug(_message: string, _meta?: unknown): void {}\r\n warn(_message: string, _meta?: unknown): void {}\r\n error(_message: string, _meta?: unknown): void {}\r\n}\r\n\r\nexport class ConsoleLogger implements ILogger {\r\n constructor(private readonly scope = 'mixerx-oracles') {}\r\n\r\n debug(message: string, meta?: unknown): void {\r\n console.debug(`[${this.scope}] ${message}`, meta ?? '');\r\n }\r\n\r\n warn(message: string, meta?: unknown): void {\r\n console.warn(`[${this.scope}] ${message}`, meta ?? '');\r\n }\r\n\r\n error(message: string, meta?: unknown): void {\r\n console.error(`[${this.scope}] ${message}`, meta ?? '');\r\n }\r\n}\r\n"]}
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
-
if (k2 === undefined) k2 = k;
|
|
4
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
-
}
|
|
8
|
-
Object.defineProperty(o, k2, desc);
|
|
9
|
-
}) : (function(o, m, k, k2) {
|
|
10
|
-
if (k2 === undefined) k2 = k;
|
|
11
|
-
o[k2] = m[k];
|
|
12
|
-
}));
|
|
13
|
-
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
-
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
-
};
|
|
16
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
__exportStar(require("./ILogger"), exports);
|
|
18
|
-
//# sourceMappingURL=index.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/application/ports/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAMA,4CAA0B","sourcesContent":["import type { Token, TokenPrices } from '../../types';\r\n\r\nexport interface ITokenPriceFetcher {\r\n fetchPrices(tokens?: Token[]): Promise<TokenPrices>;\r\n}\r\n\r\nexport * from './ILogger';\r\n"]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"TokenDefaults.js","sourceRoot":"","sources":["../../../src/domain/config/TokenDefaults.ts"],"names":[],"mappings":";;AAMA,wCAGC;AATD,MAAM,gBAAgB,GAAgC,IAAI,GAAG,CAAC;IAC5D,CAAC,oBAAoB,EAAE,EAAE,CAAC;IAC1B,CAAC,iBAAiB,EAAE,EAAE,CAAC;IACvB,CAAC,6BAA6B,EAAE,CAAC,CAAC;CACnC,CAAC,CAAC;AAEH,SAAgB,cAAc,CAAC,MAAc;IAC3C,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IACxC,OAAO,gBAAgB,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;AACzC,CAAC","sourcesContent":["const BUMP_PERCENTAGES: ReadonlyMap<string, number> = new Map([\r\n ['relayer_withdrawal', 10],\r\n ['user_withdrawal', 30],\r\n ['relayer_withdrawal_check_v4', 0],\r\n]);\r\n\r\nexport function getBumpPercent(txType: string): number {\r\n const key = txType.trim().toLowerCase();\r\n return BUMP_PERCENTAGES.get(key) ?? 10;\r\n}\r\n"]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { getBumpPercent, } from './TokenDefaults';
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
// Domain configuration exports
|
|
3
|
-
// Centralized defaults to eliminate duplication across the codebase
|
|
4
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
-
exports.getBumpPercent = void 0;
|
|
6
|
-
var TokenDefaults_1 = require("./TokenDefaults");
|
|
7
|
-
Object.defineProperty(exports, "getBumpPercent", { enumerable: true, get: function () { return TokenDefaults_1.getBumpPercent; } });
|
|
8
|
-
//# sourceMappingURL=index.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/domain/config/index.ts"],"names":[],"mappings":";AAAA,+BAA+B;AAC/B,oEAAoE;;;AAEpE,iDAEyB;AADvB,+GAAA,cAAc,OAAA","sourcesContent":["// Domain configuration exports\r\n// Centralized defaults to eliminate duplication across the codebase\r\n\r\nexport {\r\n getBumpPercent,\r\n} from './TokenDefaults';\r\n"]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"exceptions.js","sourceRoot":"","sources":["../../src/domain/exceptions.ts"],"names":[],"mappings":";;;AASA,MAAa,WAAY,SAAQ,KAAK;IAElB;IADlB,YACkB,IAAqB,EACrC,OAAe;QAEf,KAAK,CAAC,OAAO,CAAC,CAAC;QAHC,SAAI,GAAJ,IAAI,CAAiB;QAIrC,IAAI,CAAC,IAAI,GAAG,aAAa,CAAC;IAC5B,CAAC;CACF;AARD,kCAQC;AAED,MAAa,eAAgB,SAAQ,WAAW;IACD;IAA7C,YAAY,OAAe,EAAkB,UAA6B,EAAE;QAC1E,KAAK,CAAC,yBAAyB,EAAE,OAAO,CAAC,CAAC;QADC,YAAO,GAAP,OAAO,CAAwB;QAE1E,IAAI,CAAC,IAAI,GAAG,iBAAiB,CAAC;IAChC,CAAC;CACF;AALD,0CAKC","sourcesContent":["export type OracleErrorCode =\r\n | 'INVALID_CONFIG'\r\n | 'INVALID_INPUT'\r\n | 'NETWORK_MISMATCH'\r\n | 'RPC_REQUEST_FAILED'\r\n | 'TOKEN_PRICE_UNAVAILABLE'\r\n | 'TOKEN_PRICE_STALE'\r\n | 'ECONOMIC_LIMIT_EXCEEDED';\r\n\r\nexport class OracleError extends Error {\r\n constructor(\r\n public readonly code: OracleErrorCode,\r\n message: string,\r\n ) {\r\n super(message);\r\n this.name = 'OracleError';\r\n }\r\n}\r\n\r\nexport class TokenPriceError extends OracleError {\r\n constructor(message: string, public readonly symbols: readonly string[] = []) {\r\n super('TOKEN_PRICE_UNAVAILABLE', message);\r\n this.name = 'TokenPriceError';\r\n }\r\n}\r\n"]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"repositories.js","sourceRoot":"","sources":["../../src/domain/repositories.ts"],"names":[],"mappings":";;AAOA,oCAaC;AAbD,SAAgB,YAAY,CAC1B,KAAgC,EAChC,YAAuC;IAEvC,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,IAAI,EAAE,EAAE,CAAC;QACzD,OAAO,IAAI,CAAC;IACd,CAAC;IAED,IAAI,CAAC,MAAM,CAAC,aAAa,CAAC,YAAY,CAAC,IAAI,CAAC,YAAY,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC;QACpE,OAAO,IAAI,CAAC;IACd,CAAC;IAED,OAAO,EAAE,aAAa,EAAE,KAAK,EAAE,YAAY,EAAE,YAAsB,EAAE,MAAM,EAAE,YAAY,EAAE,CAAC;AAC9F,CAAC","sourcesContent":["import type { TokenPriceQuote } from '../types';\r\n\r\nexport interface ITokenPriceRepository {\r\n getPrice(symbol: string): Promise<bigint | null | undefined>;\r\n getLastUpdateTimestamp(symbol: string): Promise<number | null | undefined>;\r\n}\r\n\r\nexport function toPriceQuote(\r\n price: bigint | null | undefined,\r\n observedAtMs: number | null | undefined,\r\n): TokenPriceQuote | null {\r\n if (price === null || price === undefined || price <= 0n) {\r\n return null;\r\n }\r\n\r\n if (!Number.isSafeInteger(observedAtMs) || (observedAtMs ?? -1) < 0) {\r\n return null;\r\n }\r\n\r\n return { priceInEthWei: price, observedAtMs: observedAtMs as number, source: 'repository' };\r\n}\r\n"]}
|