@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.
- package/README.md +78 -111
- package/dist/application/ports/ILogger.d.ts +0 -1
- package/dist/application/ports/ILogger.js.map +1 -1
- package/dist/application/ports/index.d.ts +0 -1
- package/dist/application/ports/index.js.map +1 -1
- package/dist/domain/config/TokenDefaults.d.ts +0 -32
- package/dist/domain/config/TokenDefaults.js +7 -72
- package/dist/domain/config/TokenDefaults.js.map +1 -1
- package/dist/domain/config/index.d.ts +1 -2
- package/dist/domain/config/index.js +1 -13
- package/dist/domain/config/index.js.map +1 -1
- package/dist/domain/exceptions.d.ts +8 -4
- package/dist/domain/exceptions.js +12 -3
- package/dist/domain/exceptions.js.map +1 -1
- package/dist/domain/repositories.d.ts +3 -1
- package/dist/domain/repositories.js +10 -0
- package/dist/domain/repositories.js.map +1 -1
- package/dist/factory.d.ts +11 -32
- package/dist/factory.js +16 -26
- package/dist/factory.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/infrastructure/blockchain/staticProvider.d.ts +3 -0
- package/dist/infrastructure/blockchain/staticProvider.js +37 -0
- package/dist/infrastructure/blockchain/staticProvider.js.map +1 -0
- package/dist/infrastructure/config/ProtocolTokenConfig.d.ts +0 -1
- package/dist/infrastructure/config/ProtocolTokenConfig.js +2 -0
- package/dist/infrastructure/config/ProtocolTokenConfig.js.map +1 -1
- package/dist/infrastructure/config/gasOracleConfigs.d.ts +2 -13
- package/dist/infrastructure/config/gasOracleConfigs.js +7 -33
- package/dist/infrastructure/config/gasOracleConfigs.js.map +1 -1
- package/dist/infrastructure/config/index.d.ts +0 -1
- package/dist/infrastructure/config/index.js.map +1 -1
- package/dist/infrastructure/gas/GasPriceOracle.d.ts +12 -2
- package/dist/infrastructure/gas/GasPriceOracle.js +77 -38
- package/dist/infrastructure/gas/GasPriceOracle.js.map +1 -1
- package/dist/infrastructure/mixerx/MixerXFeeOracle.d.ts +88 -0
- package/dist/infrastructure/mixerx/MixerXFeeOracle.js +220 -0
- package/dist/infrastructure/mixerx/MixerXFeeOracle.js.map +1 -0
- package/dist/infrastructure/multicall/MulticallProvider.d.ts +5 -3
- package/dist/infrastructure/multicall/MulticallProvider.js +44 -9
- package/dist/infrastructure/multicall/MulticallProvider.js.map +1 -1
- package/dist/infrastructure/token-price/TokenPriceOracle.d.ts +11 -46
- package/dist/infrastructure/token-price/TokenPriceOracle.js +68 -101
- package/dist/infrastructure/token-price/TokenPriceOracle.js.map +1 -1
- package/dist/infrastructure/token-price/index.d.ts +2 -2
- package/dist/infrastructure/token-price/index.js.map +1 -1
- package/dist/types.d.ts +5 -1
- package/dist/types.js.map +1 -1
- package/package.json +56 -40
- package/dist/application/ports/ILogger.d.ts.map +0 -1
- package/dist/application/ports/index.d.ts.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/index.d.ts.map +0 -1
- package/dist/domain/exceptions.d.ts.map +0 -1
- package/dist/domain/repositories.d.ts.map +0 -1
- package/dist/factory.d.ts.map +0 -1
- package/dist/index.d.ts.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/gasOracleConfigs.d.ts.map +0 -1
- package/dist/infrastructure/config/index.d.ts.map +0 -1
- package/dist/infrastructure/gas/GasPriceOracle.d.ts.map +0 -1
- package/dist/infrastructure/multicall/MulticallProvider.d.ts.map +0 -1
- package/dist/infrastructure/token-price/TokenPriceOracle.d.ts.map +0 -1
- package/dist/infrastructure/token-price/index.d.ts.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
|
@@ -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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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 [];
|
|
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 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;
|
|
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
|
|
4
|
+
private readonly multicall;
|
|
5
|
+
private readonly offchainOracleAddress;
|
|
6
|
+
private readonly interface;
|
|
7
|
+
private readonly logger;
|
|
20
8
|
private defaultTokens;
|
|
21
|
-
|
|
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
|
-
*/
|
|
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
|
-
|
|
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
|
-
|
|
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 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 { ...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()]
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
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;
|