diff --git a/src/config.ts b/src/config.ts deleted file mode 100644 index 502c9ba..0000000 --- a/src/config.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { LegacyGasPrices } from './types'; - -export enum ChainId { - MAINNET = 1, - GOERLI = 5, - BSC = 56, - XDAI = 100, - POLYGON = 137, - OPTIMISM = 10, - ARBITRUM = 42161, - AVAX = 43114, -} - -export enum InstanceTokenSymbol { - DAI = 'dai', - cDAI = 'cdai', - WBTC = 'wbtc', - USDT = 'usdt', - USDC = 'usdc', -} - -export type GasPricesConfig = { - [chainId in ChainId]: LegacyGasPrices; -}; - -export const defaultGasPrices: GasPricesConfig = { - [ChainId.MAINNET]: { - instant: 80, - fast: 50, - standard: 25, - low: 8, - }, - [ChainId.GOERLI]: { - instant: 80, - fast: 50, - standard: 25, - low: 8, - }, - [ChainId.OPTIMISM]: { - instant: 0.001, - fast: 0.001, - standard: 0.001, - low: 0.001, - }, - [ChainId.XDAI]: { - instant: 6, - fast: 5, - standard: 4, - low: 1, - }, - [ChainId.BSC]: { - instant: 5, - fast: 4, - standard: 3, - low: 3, - }, - [ChainId.POLYGON]: { - instant: 100, - fast: 75, - standard: 50, - low: 30, - }, - [ChainId.ARBITRUM]: { - instant: 4, - fast: 3, - standard: 2.52, - low: 2.29, - }, - [ChainId.AVAX]: { - instant: 225, - fast: 35, - standard: 25, - low: 25, - }, -}; - -type GasLimitConfig = { - [chainId in ChainId]: number; -}; - -export const defaultWithdrawalGasLimit: GasLimitConfig = { - [ChainId.MAINNET]: 550000, - [ChainId.GOERLI]: 550000, - [ChainId.ARBITRUM]: 1900000, - [ChainId.OPTIMISM]: 440000, - [ChainId.AVAX]: 390000, - [ChainId.BSC]: 390000, - [ChainId.POLYGON]: 390000, - [ChainId.XDAI]: 390000, -}; - -type InstanceTokenGasLimitConfig = { - [tokenSymbol in InstanceTokenSymbol]: number; -}; - -export const defaultInstanceTokensGasLimit: InstanceTokenGasLimitConfig = { - [InstanceTokenSymbol.DAI]: 55000, - [InstanceTokenSymbol.cDAI]: 425000, - [InstanceTokenSymbol.WBTC]: 85000, - [InstanceTokenSymbol.USDT]: 100000, - [InstanceTokenSymbol.USDC]: 80000, -}; - -export const optimismL1FeeOracleAddress = '0x420000000000000000000000000000000000000F'; -export const offchainOracleAddress = '0x07D91f5fb9Bf7798734C3f606dB065549F6893bb'; -export const multiCallAddress = '0xda3c19c6fe954576707fa24695efb830d9cca1ca'; diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..6e7d1d0 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,18 @@ +export enum ChainId { + MAINNET = 1, + BSC = 56, + XDAI = 100, + POLYGON = 137, + OPTIMISM = 10, + ARBITRUM = 42161, + AVAX = 43114, + SEPOLIA = 11155111, +} + +export type ChainIdType = ChainId | number; + +export const ENS_CHAINS = [ChainId.MAINNET, ChainId.SEPOLIA]; + +export const multiCallAddress = '0xcA11bde05977b3631167028862bE2a173976CA11'; + +export const POLYGON_GAS_STATION = 'https://gasstation.polygon.technology/v2'; diff --git a/src/feeOracle.ts b/src/feeOracle.ts index 9fb44ac..029dfc8 100644 --- a/src/feeOracle.ts +++ b/src/feeOracle.ts @@ -1,279 +1,117 @@ -import { GasPriceOracle } from '@tornado/gas-price-oracle'; -import { BigNumber, BigNumberish, ethers } from 'ethers'; -import BigNumberFloat from 'bignumber.js'; -import { parseUnits } from 'ethers/lib/utils'; -import { - TransactionData, - TxType, - ITornadoFeeOracle, - LegacyGasPriceKey, - GasPriceParams, - GetGasParamsRes, - HexadecimalStringifiedNumber, - GetGasInput, - GetGasParamsInput, -} from './types'; -import { JsonRpcProvider } from '@ethersproject/providers'; -import { ChainId, defaultGasPrices, defaultInstanceTokensGasLimit, InstanceTokenSymbol } from './config'; -import { bump, calculateGasPriceInWei, convertETHToToken, fromGweiToWeiHex, serializeTx } from './utils'; -import { getOptimismL1FeeOracle } from './contracts/factories'; -import { GetWithdrawalFeeViaRelayerInput } from './types'; -import { AvailableTokenSymbols } from '@tornado/tornado-config'; +import { Transaction, parseUnits } from 'ethers'; +import type { BigNumberish, TransactionLike } from 'ethers'; +import { OptimismL1FeeOracle, OptimismL1FeeOracle__factory } from './contracts'; +import { ChainIdType } from './constants'; +import { getProvider } from './providers'; +import { networkConfig } from './types'; +import { convertETHToTokenAmount } from './utils'; -export abstract class TornadoFeeOracle implements ITornadoFeeOracle { - protected provider: JsonRpcProvider; +const DUMMY_ADDRESS = '0x1111111111111111111111111111111111111111'; - public constructor( - public version: 4 | 5, - protected chainId: ChainId, - rpcUrl: string, - protected oracle: GasPriceOracle, - ) { - this.provider = new ethers.providers.JsonRpcProvider(rpcUrl); - } +const DUMMY_NONCE = '0x1111111111111111111111111111111111111111111111111111111111111111'; - /** - * Because Optimism transaction published on Mainnet, for each OP transaction we need to calculate L1 security fee: - * https://community.optimism.io/docs/developers/build/transaction-fees/#priority-fee - * @param {TransactionData} [tx] Transaction data to estimate L1 additional fee - * @returns {Promise} Fee in WEI (MATIC), '0' if chain is not Optimism - */ - async fetchL1OptimismFee(tx?: TransactionData): Promise { - if (this.chainId != ChainId.OPTIMISM) return BigNumber.from(0).toHexString(); +const DUMMY_WITHDRAW_DATA = + '0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111'; - const optimismL1FeeOracle = getOptimismL1FeeOracle(this.provider); - const l1Fee = await optimismL1FeeOracle.getL1Fee(serializeTx(tx)); +export interface RelayerFeeParams { + gasPrice: BigNumberish; + gasLimit?: BigNumberish; + l1Fee?: BigNumberish; + denomination: BigNumberish; + ethRefund: BigNumberish; + tokenPriceInWei: BigNumberish; + tokenDecimals: number; + relayerFeePercent?: number; + isEth?: boolean; + premiumPercent?: number; +} - return l1Fee.toHexString(); - } +export class TornadoFeeOracle { + optimismL1FeeOracle?: OptimismL1FeeOracle; - /** - * Estimate gas price, gas limit and l1Fee for sidechain (if exists) - * @param {GetGasParamsInput} [params] Function input arguments object - * @param {TransactionData} [params.tx] Transaction data in web3 / ethers format - * @param {TxType} [params.txType=other] Tornado transaction type: withdrawal by user, withdrawal by relayer or 'other' - * @param {number} [params.predefinedGasLimit] Predefined gas limit, if already calculated (no refetching) - * @param {number} [params.predefinedGasPrice] Predefined gas price, if already calculated (no refetching) - * @param {number} [params.bumpGasLimitPercent] Gas limit bump percent to prioritize transaction (if gas limit not predefined, recenlty used) - * @param {number} [params.bumpGasPricePercent] Gas price bump percent to prioritize transaction (if gas limit not predefined, rarely used) - * @param {LegacyGasPriceKey} [params.speed] Preferred transaction speed, if uses legacy gas (before EIP-1559) - * @param {boolean} [params.includeL1FeeToGasLimit=true] Include L1 additional fee on Optimism to gas limit (get fee and divide by gas price) - * @returns {Promise} Object with fields 'gasPrice' and 'gasLimit', L1 fee, if exists, included in gasLimit - */ - async getGasParams(params: GetGasParamsInput = {}): Promise { - let { - tx, - txType = 'other', - bumpGasLimitPercent, - bumpGasPricePercent, - predefinedGasLimit: gasLimit, - predefinedGasPrice: gasPrice, - speed, - includeL1FeeToGasLimit = true, - } = params; + constructor(netId: ChainIdType, rpcUrl: string, config: networkConfig) { + const { optimismL1FeeOracleAddress } = config; - let l1Fee: string = '0'; - if (!gasLimit && !gasPrice) { - [gasPrice, gasLimit, l1Fee] = await Promise.all([ - this.getGasPrice(speed, bumpGasPricePercent), - this.getGasLimit(tx, txType, bumpGasLimitPercent), - this.fetchL1OptimismFee(tx), - ]); - } - if (!gasLimit) { - [gasLimit, l1Fee] = await Promise.all([ - this.getGasLimit(tx, txType, bumpGasLimitPercent), - this.fetchL1OptimismFee(tx), - ]); - } - if (!gasPrice) gasPrice = await this.getGasPrice(speed, bumpGasPricePercent); - - if (includeL1FeeToGasLimit) - // Include L1 fee in gas limit (divide by gas price before), if l1 fee is 0, gas limit wont change - gasLimit = BigNumberFloat(gasLimit) - .plus(BigNumberFloat(l1Fee).div(BigNumberFloat(gasPrice))) - .decimalPlaces(0, 1) - .toNumber(); - - return { gasLimit, gasPrice }; - } - - /** - * Estimates next block gas for signed, unsigned or incomplete Tornado transaction - * @param {GetGasInput} [params] Function input arguments object - * @param {TransactionData} [params.tx] Transaction data in web3 / ethers format - * @param {TxType} [params.txType] Tornado transaction type: withdrawal by user, withdrawal by relayer or 'other' - * @param {number} [params.predefinedGasLimit] Predefined gas limit, if already calculated (no refetching) - * @param {number} [params.predefinedGasPrice] Predefined gas price, if already calculated (no refetching) - * @param {number} [params.bumpGasLimitPercent] Gas limit bump percent to prioritize transaction (if gas limit not predefined, recenlty used) - * @param {number} [params.bumpGasPricePercent] Gas price bump percent to prioritize transaction (if gas price not predefined, rarely used) - * @param {LegacyGasPriceKey} [params.speed] Preferred transaction speed, if uses legacy gas (before EIP-1559) - * @returns {Promise} Gas value in WEI (hex-format) - */ - async getGas(params: GetGasInput = {}): Promise { - const { gasPrice, gasLimit } = await this.getGasParams({ ...params, includeL1FeeToGasLimit: true }); - - return BigNumber.from(gasPrice).mul(gasLimit).toHexString(); - } - - /** - * Estimate next block gas price - * @param {LegacyGasPriceKey} [speed] Preferred transaction speed, if uses legacy gas (before EIP-1559) - * @param {number} [bumpPercent=0] Gas bump percent to prioritize transaction - * @returns {Promise} Estimated gas price info in WEI (hexed) - legacy object with gasPrice property or - * EIP-1559 object with maxFeePerGas and maxPriorityFeePerGas properties - */ - async getGasPriceParams(speed?: LegacyGasPriceKey, bumpPercent: number = 0): Promise { - // Use instant for BSC, because on this chain "fast" and "instant" differs more than third and "fast" transaction can take hours - if (!speed) speed = this.chainId === ChainId.BSC ? 'instant' : 'fast'; - try { - return await this.oracle.getTxGasParams({ legacySpeed: speed, bumpPercent }); - } catch (e) { - return { gasPrice: bump(fromGweiToWeiHex(defaultGasPrices[this.chainId][speed]), bumpPercent).toHexString() }; + if (optimismL1FeeOracleAddress) { + const provider = getProvider(netId, rpcUrl, config); + this.optimismL1FeeOracle = OptimismL1FeeOracle__factory.connect(optimismL1FeeOracleAddress, provider); } } /** - * Estimate next block gas price - * @param {LegacyGasPriceKey} [speed] Preferred transaction speed, if uses legacy gas (before EIP-1559) - * @param {number} [bumpPercent] Gas bump percent to prioritize transaction - * @returns {Promise} Gas price in WEI (hex string) - */ - async getGasPrice(speed?: LegacyGasPriceKey, bumpPercent?: number): Promise { - const gasPriceParams = await this.getGasPriceParams(speed, bumpPercent); - return calculateGasPriceInWei(gasPriceParams).toHexString(); - } - - /** - * Estimates gas limit for transaction (or basic gas limit, if no tx data provided) - * @param {TransactionData} [tx] Transaction data (object in web3 / ethers format) - * @param {TxType} [type] Tornado transaction type: withdrawal by user, withdrawal by relayer, relayer fee check or 'other' - * @param {number} [bumpPercent] Gas bump percent to prioritize transaction - * @returns {Promise} Gas limit - */ - abstract getGasLimit(tx?: TransactionData, type?: TxType, bumpPercent?: number): Promise; - - /** - * If user withdraw non-native tokens on ETH or Goerli, we need to calculate refund value: - * if the withdrawal is successful, this amount will be returned to the user after the transfer to the relayer, - * and if the relayer pays a commission and the transfer of tokens fails, this commission will remain to the relayer. + * Calculate L1 fee for op-stack chains * - * Refund needed that recipient can use tokens after withdrawal (covers gas fee for send/swap) - * @param {BigNumberish} gasPrice Actual gas price - * @param {InstanceTokenSymbol} tokenSymbol Withdrawal token (currency) symbol - for example, 'dai' - * @returns {HexadecimalStringifiedNumber} Refund amount in WEI (in hex format) + * This is required since relayers would pay the full transaction fees for users */ - calculateRefundInETH(gasPrice: BigNumberish, tokenSymbol: InstanceTokenSymbol): HexadecimalStringifiedNumber { - // Refund only available for non-native tokens on Ethereum Mainnet and Goerli - if (![ChainId.MAINNET, ChainId.GOERLI].includes(this.chainId) || (tokenSymbol as AvailableTokenSymbols) === 'eth') - return '0'; - - // Notify user about error if incorrect token symbol provided - if (!Object.values(InstanceTokenSymbol).includes(tokenSymbol)) { - console.error( - `Invalid token symbol: ${tokenSymbol}, must be lowercase token from one of Tornado ETH Mainnet pools`, - ); - return '0'; + fetchL1OptimismFee(tx?: TransactionLike): Promise { + if (!this.optimismL1FeeOracle) { + return new Promise((resolve) => resolve(BigInt(0))); } - // In Tornado we need to calculate refund only on user side, relayer get refund value in proof - const gasLimit = defaultInstanceTokensGasLimit[tokenSymbol]; - return BigNumber.from(gasPrice).mul(gasLimit).mul(2).toHexString(); - } - - /** - * Fetched actual gas price and calculates refund amount - * @param {InstanceTokenSymbol} tokenSymbol Withdrawal token (currency) symbol - for example, 'dai' - * @returns {Promise} Refund amount in WEI (in hex format) - */ - async fetchRefundInETH(tokenSymbol: InstanceTokenSymbol): Promise { - const gasPrice = await this.getGasPrice(); - return this.calculateRefundInETH(gasPrice, tokenSymbol); - } - - /** - * Get refund amount on ETH or Goerli in non-native token - * @param {BigNumberish} gasPrice Actual gas price in ETH - * @param {BigNumberish} tokenPriceInEth Token price in WEI in ETH - * @param {HexadecimalStringifiedNumber | number} tokenDecimals Token (currency) decimals - * @param {InstanceTokenSymbol} tokenSymbol Withdrawal token (currency) symbol - for example, 'dai' - * @returns {HexadecimalStringifiedNumber} Refund amount in WEI in selected token (hexed number) - */ - calculateRefundInToken( - gasPrice: BigNumberish, - tokenPriceInEth: BigNumberish, - tokenDecimals: HexadecimalStringifiedNumber | number, - tokenSymbol: InstanceTokenSymbol, - ): HexadecimalStringifiedNumber { - const refundInEth = this.calculateRefundInETH(gasPrice, tokenSymbol); - return convertETHToToken(refundInEth, tokenDecimals, tokenPriceInEth).toHexString(); - } - - /** - * Calculates relayer fee in selected currency (ETH, DAI, BNB etc) in WEI - * @param {number | string} relayerFeePercent Relayer percent (0.4 for ETH Mainnet, for example) - * @param {HexadecimalStringifiedNumber | number} amount Amount in selected currency (10 for 10 ETH, 1000 for 1000 DAI) - * @param {string | number} decimals Decimal places in selected token (currency) - * @returns {HexadecimalStringifiedNumber} Fee in WEI (hexed stingified number) - */ - calculateRelayerFeeInWei( - relayerFeePercent: number | string, - amount: HexadecimalStringifiedNumber | number, - decimals: string | number, - ): HexadecimalStringifiedNumber { - return parseUnits(amount.toString(), decimals) - .mul(`${Math.floor(Number(relayerFeePercent) * 1e10)}`) - .div(`${100 * 1e10}`) - .toHexString(); - } - - /** - * Estimates fee for withdrawal via relayer depending on type: gas bump percent is bigger, if it calculates by user, - * so that the real commission from the relayer side is a little less, - * in order to the relayer can send a transaction without fear that he will go into the red - * @param {GetWithdrawalFeeViaRelayerInput} params Function input arguments object - * @param {TxType} params.txType Tornado transaction type: withdrawal costs calculation from user side or from relayer side - * @param {TransactionData} [params.tx] Transaction data (object in web3 / ethers format) - * @param {number} params.relayerFeePercent Relayer fee percent from the transaction amount (for example, 0.15 for BNB or 0.4 for ETH Mainnet) - * @param {AvailableTokenSymbols | Uppercase} params.currency Currency symbol - * @param {number | HexadecimalStringifiedNumber } params.amount Withdrawal amount in selected currency - * @param {number | HexadecimalStringifiedNumber } params.decimals Token (currency) decimals - * @param {BigNumberish} [params.refundInEth] Refund in ETH, if withdrawed other tokens on Mainnet (not ETH). Can not be provided, if user-side calculation - * @param {BigNumberish} [params.tokenPriceInEth] If withdrawing other token on Mainnet or Goerli, need to provide token price in ETH (in WEI) - * @param {number} [params.gasLimit] Predefined gas limit, if already calculated (no refetching) - * @param {number} [params.gasPrice] Predefined gas price, if already calculated (no refetching) - * - * @returns {Promise} Fee in WEI (hexed string) - */ - async calculateWithdrawalFeeViaRelayer({ - tx, - txType, - relayerFeePercent, - currency, - amount, - decimals, - refundInEth, - tokenPriceInEth, - predefinedGasLimit, - predefinedGasPrice, - }: GetWithdrawalFeeViaRelayerInput): Promise { - const relayerFee = this.calculateRelayerFeeInWei(relayerFeePercent, amount, decimals); - const { gasPrice, gasLimit } = await this.getGasParams({ tx, txType, predefinedGasLimit, predefinedGasPrice }); - const gasCosts = BigNumber.from(gasPrice).mul(gasLimit); - - if ((this.chainId === ChainId.MAINNET || this.chainId === ChainId.GOERLI) && currency.toLowerCase() != 'eth') { - if (!tokenPriceInEth) { - console.error('Token price is required argument, if not native chain token is withdrawed'); - return '0'; - } - - if (txType === 'user_withdrawal' && refundInEth === undefined) - refundInEth = this.calculateRefundInETH(gasPrice, currency.toLowerCase() as InstanceTokenSymbol); - - const feeInEth = BigNumber.from(gasCosts).add(refundInEth || 0); - return convertETHToToken(feeInEth, decimals, tokenPriceInEth).add(relayerFee).toHexString(); + if (!tx) { + // this tx is only used to simulate bytes size of the encoded tx so has nothing to with the accuracy + // inspired by the old style classic-ui calculation + tx = { + type: 0, + gasLimit: 1_000_000, + nonce: Number(DUMMY_NONCE), + data: DUMMY_WITHDRAW_DATA, + gasPrice: parseUnits('1', 'gwei'), + from: DUMMY_ADDRESS, + to: DUMMY_ADDRESS, + }; } - return BigNumber.from(gasCosts).add(relayerFee).toHexString(); + return this.optimismL1FeeOracle.getL1Fee.staticCall(Transaction.from(tx).unsignedSerialized); + } + + /** + * We don't need to distinguish default refunds by tokens since most users interact with other defi protocols after withdrawal + * So we default with 1M gas which is enough for two or three swaps + * Using 30 gwei for default but it is recommended to supply cached gasPrice value from the UI + */ + defaultEthRefund(gasPrice?: BigNumberish, gasLimit?: BigNumberish): bigint { + return (gasPrice ? BigInt(gasPrice) : parseUnits('30', 'gwei')) * BigInt(gasLimit || 1_000_000); + } + + /** + * Calculates token amount for required ethRefund purchases required to calculate fees + */ + calculateTokenAmount(ethRefund: BigNumberish, tokenPriceInEth: BigNumberish, tokenDecimals?: number): bigint { + return convertETHToTokenAmount(ethRefund, tokenPriceInEth, tokenDecimals); + } + + /** + * Warning: For tokens you need to check if the fees are above denomination + * (Usually happens for small denomination pool or if the gas price is high) + */ + calculateRelayerFee({ + gasPrice, + gasLimit = 600_000, + l1Fee = 0, + denomination, + ethRefund = BigInt(0), + tokenPriceInWei, + tokenDecimals = 18, + relayerFeePercent = 0.33, + isEth = true, + premiumPercent = 20, + }: RelayerFeeParams): bigint { + const gasCosts = BigInt(gasPrice) * BigInt(gasLimit) + BigInt(l1Fee); + + const relayerFee = (BigInt(denomination) * BigInt(Math.floor(10000 * relayerFeePercent))) / BigInt(10000 * 100); + + if (isEth) { + // Add 20% premium + return ((gasCosts + relayerFee) * BigInt(premiumPercent ? 100 + premiumPercent : 100)) / BigInt(100); + } + + const feeInEth = gasCosts + BigInt(ethRefund); + + return ( + ((convertETHToTokenAmount(feeInEth, tokenPriceInWei, tokenDecimals) + relayerFee) * + BigInt(premiumPercent ? 100 + premiumPercent : 100)) / + BigInt(100) + ); } } diff --git a/src/feeOracleV4.ts b/src/feeOracleV4.ts deleted file mode 100644 index d073362..0000000 --- a/src/feeOracleV4.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { defaultWithdrawalGasLimit } from './config'; -import { TornadoFeeOracle } from './feeOracle'; -import { ITornadoFeeOracle, TransactionData, TxType, LegacyGasPrices } from './types'; -import { GasPriceOracle } from '@tornado/gas-price-oracle'; -import { bump } from './utils'; - -/** - * Oracle for V4 (old-version) transactions - estimates fee with predefined gas limit and without smart bumping - */ -export class TornadoFeeOracleV4 extends TornadoFeeOracle implements ITornadoFeeOracle { - public constructor(chainId: number, rpcUrl: string, defaultGasPrices?: LegacyGasPrices) { - const oracleConfig = { - chainId, - defaultRpc: rpcUrl, - defaultFallbackGasPrices: defaultGasPrices, - }; - const gasPriceOracle = new GasPriceOracle(oracleConfig); - - super(4, chainId, rpcUrl, gasPriceOracle); - } - - async getGasLimit(tx?: TransactionData, type: TxType = 'other', bumpPercent: number = 0): Promise { - if (type === 'user_withdrawal') return bump(defaultWithdrawalGasLimit[this.chainId], bumpPercent).toNumber(); - - // Need to bump relayer gas limit for transaction, because predefined gas limit to small to be 100% sure that transaction will be sent - // This leads to fact that relayer often pays extra for gas from his own funds, however, this was designed by previous developers - if (type === 'relayer_withdrawal') - return bump(defaultWithdrawalGasLimit[this.chainId], bumpPercent || 25).toNumber(); - // For compatibility reasons, when wee check user-provided fee for V4 withdrawal transaction, we need dump gas limit - // for about 20 percent,so that the transaction will be sent, even if it results in some loss for the relayer - if (type === 'relayer_withdrawal_check_v4') return bump(defaultWithdrawalGasLimit[this.chainId], -25).toNumber(); - if (!tx || Object.keys(tx).length === 0) return bump(23_000, bumpPercent).toNumber(); - - return bump(await this.provider.estimateGas(tx), bumpPercent).toNumber(); - } -} diff --git a/src/feeOracleV5.ts b/src/feeOracleV5.ts deleted file mode 100644 index 7babced..0000000 --- a/src/feeOracleV5.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { ChainId } from './config'; -import { TornadoFeeOracle } from './feeOracle'; -import { - ITornadoFeeOracle, - TransactionData, - TxType, - LegacyGasPrices, - LegacyGasPriceKey, - GasPriceParams, -} from './types'; -import { GasPriceOracle } from '@tornado/gas-price-oracle'; -import { bump } from './utils'; -import { TornadoFeeOracleV4 } from './feeOracleV4'; - -/** - * Oracle for new V5 version - estimates transaction fees with smart gas limit & bumping - */ -export class TornadoFeeOracleV5 extends TornadoFeeOracle implements ITornadoFeeOracle { - private fallbackFeeOracle: TornadoFeeOracleV4; - - public constructor(chainId: number, rpcUrl: string, defaultGasPrices?: LegacyGasPrices) { - const oracleConfig = { - chainId, - defaultRpc: rpcUrl, - minPriority: chainId === ChainId.MAINNET || chainId === ChainId.GOERLI ? 2 : chainId === ChainId.BSC ? 3 : 0.05, - percentile: 5, - blocksCount: 20, - defaultFallbackGasPrices: defaultGasPrices, - }; - const gasPriceOracle = new GasPriceOracle(oracleConfig); - - super(5, chainId, rpcUrl, gasPriceOracle); - - this.fallbackFeeOracle = new TornadoFeeOracleV4(chainId, rpcUrl, defaultGasPrices); - } - - async getGasLimit(tx?: TransactionData, type: TxType = 'other', bumpPercent: number = 20): Promise { - if (!tx || Object.keys(tx).length === 0) return this.fallbackFeeOracle.getGasLimit(tx, type, bumpPercent); - - /* Relayer gas limit must be lower so that fluctuations in gas price cannot lead to the fact that - * the relayer will actually pay for gas more than the money allocated for this by the user - * (that is, in fact, relayer will pay for gas from his own money, unless we make the bump percent less for him) - */ - if (type === 'relayer_withdrawal') bumpPercent = 10; - if (type === 'user_withdrawal') bumpPercent = 30; - - try { - const fetchedGasLimit = await this.provider.estimateGas(tx); - return bump(fetchedGasLimit, bumpPercent).toNumber(); - } catch (e) { - return this.fallbackFeeOracle.getGasLimit(tx, type, bumpPercent); - } - } - - async getGasPriceParams(speed?: LegacyGasPriceKey, bumpPercent?: number): Promise { - // Only if bump percent didn't provided (if user provides 0, no need to recalculate) - if (bumpPercent === undefined) { - switch (this.chainId) { - case ChainId.GOERLI: - bumpPercent = 100; - break; - case ChainId.POLYGON: - case ChainId.AVAX: - case ChainId.XDAI: - bumpPercent = 30; - break; - default: - bumpPercent = 10; - } - } - - return super.getGasPriceParams(speed, bumpPercent); - } -} diff --git a/src/index.ts b/src/index.ts index bbf0996..b9c3074 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,7 @@ -export * from './feeOracleV4'; -export * from './feeOracleV5'; +export * from './constants'; +export * from './feeOracle'; +export * from './multicall'; +export * from './providers'; export * from './tokenPriceOracle'; +export * from './types'; +export * from './utils'; diff --git a/src/multicall.ts b/src/multicall.ts new file mode 100644 index 0000000..7e92944 --- /dev/null +++ b/src/multicall.ts @@ -0,0 +1,28 @@ +import { Interface } from 'ethers'; +import { Multicall } from './contracts'; +import type { Call3 } from './types'; + +export async function multicall(Multicall: Multicall, calls: Call3[]) { + const calldata = calls.map((call) => { + const target = (call.contract?.target || call.address) as string; + const callInterface = (call.contract?.interface || call.interface) as Interface; + + return { + target, + callData: callInterface.encodeFunctionData(call.name, call.params), + allowFailure: call.allowFailure ?? false, + }; + }); + + const returnData = await Multicall.aggregate3.staticCall(calldata); + + const res = returnData.map((call, i) => { + const callInterface = (calls[i].contract?.interface || calls[i].interface) as Interface; + const [result, data] = call; + const decodeResult = + result && data && data !== '0x' ? callInterface.decodeFunctionResult(calls[i].name, data) : null; + return !decodeResult ? null : decodeResult.length === 1 ? decodeResult[0] : decodeResult; + }); + + return res; +} diff --git a/src/providers.ts b/src/providers.ts new file mode 100644 index 0000000..2b8b14c --- /dev/null +++ b/src/providers.ts @@ -0,0 +1,107 @@ +import { JsonRpcProvider, Network, parseUnits, FetchUrlFeeDataNetworkPlugin, EnsPlugin, GasCostPlugin } from 'ethers'; +import { GasPriceOracle, GasPriceOracle__factory, Multicall, Multicall__factory } from './contracts'; +import { ENS_CHAINS, ChainIdType, multiCallAddress, POLYGON_GAS_STATION } from './constants'; +import { multicall } from './multicall'; +import type { networkConfig, gasOracleOptions } from './types'; +import { isNode } from './utils'; + +// caching to improve performance +const oracleMapper = new Map(); +const multicallMapper = new Map(); + +export function getGasOraclePlugin(networkKey: string, gasOracleOptions?: gasOracleOptions) { + const gasStationApi = gasOracleOptions?.gasStationApi || POLYGON_GAS_STATION; + + return new FetchUrlFeeDataNetworkPlugin(gasStationApi, async (fetchFeeData, provider, request) => { + if (!oracleMapper.has(networkKey)) { + oracleMapper.set( + networkKey, + GasPriceOracle__factory.connect(gasOracleOptions?.gasPriceOracle as string, provider), + ); + } + if (!multicallMapper.has(networkKey)) { + multicallMapper.set(networkKey, Multicall__factory.connect(multiCallAddress, provider)); + } + const Oracle = oracleMapper.get(networkKey) as GasPriceOracle; + const Multicall = multicallMapper.get(networkKey) as Multicall; + + const [timestamp, heartbeat, feePerGas, priorityFeePerGas] = await multicall(Multicall, [ + { + contract: Oracle, + name: 'timestamp', + }, + { + contract: Oracle, + name: 'heartbeat', + }, + { + contract: Oracle, + name: 'maxFeePerGas', + }, + { + contract: Oracle, + name: 'maxPriorityFeePerGas', + }, + ]); + + const isOutdated = Number(timestamp) <= Date.now() / 1000 - Number(heartbeat); + + if (!isOutdated) { + const maxPriorityFeePerGas = (priorityFeePerGas * BigInt(13)) / BigInt(10); + const maxFeePerGas = feePerGas * BigInt(2) + maxPriorityFeePerGas; + + return { + gasPrice: maxFeePerGas, + maxFeePerGas, + maxPriorityFeePerGas, + }; + } + + if (isNode) { + // Prevent Cloudflare from blocking our request in node.js + request.setHeader('User-Agent', 'ethers'); + } + + const [ + { + bodyJson: { fast }, + }, + { gasPrice }, + ] = await Promise.all([request.send(), fetchFeeData()]); + + return { + gasPrice, + maxFeePerGas: parseUnits(`${fast.maxFee}`, 9), + maxPriorityFeePerGas: parseUnits(`${fast.maxPriorityFee}`, 9), + }; + }); +} + +export function getProvider(netId: ChainIdType, rpcUrl: string, config: networkConfig): JsonRpcProvider { + const { networkName, gasPriceOracleContract, gasStationApi, pollInterval } = config; + const hasEns = ENS_CHAINS.includes(netId); + + const staticNetwork = new Network(networkName, netId); + if (hasEns) { + staticNetwork.attachPlugin(new EnsPlugin(null, Number(netId))); + } + + staticNetwork.attachPlugin(new GasCostPlugin()); + + if (gasPriceOracleContract) { + staticNetwork.attachPlugin( + getGasOraclePlugin(`${netId}_${rpcUrl}`, { + gasPriceOracle: gasPriceOracleContract, + gasStationApi, + }), + ); + } + + const provider = new JsonRpcProvider(rpcUrl, staticNetwork, { + staticNetwork, + }); + + provider.pollingInterval = pollInterval * 1000; + + return provider; +} diff --git a/src/tokenPriceOracle.ts b/src/tokenPriceOracle.ts index a2c0ae8..d75b4c6 100644 --- a/src/tokenPriceOracle.ts +++ b/src/tokenPriceOracle.ts @@ -1,107 +1,54 @@ -import { MultiCall } from './contracts/MulticallAbi'; -import { ITornadoPriceOracle, Token, TokenPrices, TokenSymbol } from './types'; -import { MulticallAbi, OffchainOracleAbi } from './contracts'; -import { getMultiCallContract, getOffchainOracleContract } from './contracts/factories'; -import { Provider } from '@ethersproject/abstract-provider'; -import { ethers, BigNumber } from 'ethers'; -import { defaultAbiCoder } from 'ethers/lib/utils'; -import { instances, Instances } from '@tornado/tornado-config'; -import { ChainId } from './config'; +import { Provider, parseEther } from 'ethers'; +import { Multicall, Multicall__factory, OffchainOracle, OffchainOracle__factory } from './contracts'; +import { ChainIdType, multiCallAddress } from './constants'; +import { multicall } from './multicall'; +import { getProvider } from './providers'; +import { Token, TokenPrices, networkConfig } from './types'; -/** - * Filter non-native tokens from Tornado instances config - * @param instances Tornado instances from torn-token config ('@tornado/tornado-config' library) - * @returns Array of non-native tokens - */ -function filterTokensFromTornadoInstances(instances: Instances): Token[] { - const ethInstances = Object.values(instances[ChainId.MAINNET]); - return ethInstances.filter((tokenInstance) => !!tokenInstance.tokenAddress) as Token[]; -} +export class TokenPriceOracle { + oracle?: OffchainOracle; + multicall: Multicall; + provider: Provider; -const tornToken: Token = { - tokenAddress: '0x77777FeDdddFfC19Ff86DB637967013e6C6A116C', - symbol: 'torn', - decimals: 18, -}; -const tornadoTokens = [tornToken, ...filterTokensFromTornadoInstances(instances)]; + constructor(netId: ChainIdType, rpcUrl: string, config: networkConfig) { + const { offchainOracleContract } = config; -const defaultTornadoTokenPrices: TokenPrices = { - torn: '1689423546359032', - dai: '598416104472725', - cdai: '13384388487019', - usdc: '599013776676721', - usdt: '599323410893614', - wbtc: '15659889148334216720', -}; + this.provider = getProvider(netId, rpcUrl, config); + this.multicall = Multicall__factory.connect(multiCallAddress, this.provider); -export class TokenPriceOracle implements ITornadoPriceOracle { - private oracle: OffchainOracleAbi; - private multiCall: MulticallAbi; - private provider: Provider; - - /** - * Constructs TokenPriceOracle class instance - * @param {string} rpcUrl http RPC (Ethereum Mainnet) url to fetch token prices from contract - * @param {Token[]} [tokens] Array of tokens - * @param {TokenPrices} [defaultTokenPrices] Default token prices, fallback if nothing loaded from contract - */ - constructor( - rpcUrl: string, - private tokens: Token[] = tornadoTokens, - private defaultTokenPrices: TokenPrices = defaultTornadoTokenPrices, - ) { - this.provider = new ethers.providers.JsonRpcProvider(rpcUrl); - this.oracle = getOffchainOracleContract(this.provider); - this.multiCall = getMultiCallContract(this.provider); - } - - // Instant return default token prices - get defaultPrices(): TokenPrices { - return this.defaultTokenPrices; - } - - /** - * Prepare data for MultiCall contract - * @param {Token[]} [tokens] Tokens array - * @returns Valid structure to provide to MultiCall contract - */ - private prepareCallData(tokens: Token[] = this.tokens): MultiCall.CallStruct[] { - return tokens.map((token) => ({ - to: this.oracle.address, - data: this.oracle.interface.encodeFunctionData('getRateToEth', [token.tokenAddress, true]), - })); - } - - /** - * Fetch actual tokens price rate to ETH from offchain oracles - * @param {Token[]} [tokens] Token array - * @returns {TokenPrices} Object with token price rate to ETH in WEI - */ - async fetchPrices(tokens: Token[] = this.tokens): Promise { - try { - if (!tokens?.length) return {}; - - const callData = this.prepareCallData(tokens); - const { results, success } = await this.multiCall.multicall(callData); - const prices: TokenPrices = {}; - - for (let i = 0; i < results.length; i++) { - const tokenSymbol = tokens[i].symbol.toLowerCase() as TokenSymbol; - if (!success[i]) { - if (this.defaultTokenPrices[tokenSymbol]) prices[tokenSymbol] = this.defaultTokenPrices[tokenSymbol]; - continue; - } - - const decodedRate = defaultAbiCoder.decode(['uint256'], results[i]).toString(); - const tokenDecimals = BigNumber.from(10).pow(tokens[i].decimals); - const ethDecimals = BigNumber.from(10).pow(18); - const price = BigNumber.from(decodedRate).mul(tokenDecimals).div(ethDecimals); - prices[tokenSymbol] = price.toString(); - } - return prices; - } catch (e) { - console.error('Cannot get token prices, return default: ' + e); - return this.defaultTokenPrices; + if (offchainOracleContract) { + this.oracle = OffchainOracle__factory.connect(offchainOracleContract, this.provider); } } + + async fetchPrices(tokens: Token[]): Promise { + // setup mock price for testnets + if (!this.oracle) { + return new Promise((resolve) => + resolve( + tokens.reduce((acc, _, index) => { + acc[tokens[index].symbol] = parseEther('0.0001').toString(); + return acc; + }, {} as TokenPrices), + ), + ); + } + + const prices = (await multicall( + this.multicall, + tokens.map(({ tokenAddress }) => ({ + contract: this.oracle, + name: 'getRateToEth', + params: [tokenAddress, true], + })), + )) as bigint[]; + + return prices.reduce((acc, price, index) => { + const tokenPriceInwei = (price * BigInt(10 ** tokens[index].decimals)) / BigInt(10 ** 18); + + acc[tokens[index].symbol] = tokenPriceInwei.toString(); + + return acc; + }, {} as TokenPrices); + } } diff --git a/src/types.ts b/src/types.ts index b21a705..59cea99 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,114 +1,31 @@ -import { BigNumberish, BytesLike } from 'ethers'; -import { GasPriceKey, GetTxGasParamsRes } from '@tornado/gas-price-oracle/lib/services'; -import { AvailableTokenSymbols } from '@tornado/tornado-config'; -import { InstanceTokenSymbol } from './config'; +import type { BaseContract, Interface } from 'ethers'; -// Type for big hexadecimal numbers, like 0x1eff87f47e37a0 -export type HexadecimalStringifiedNumber = string; - -export type LegacyGasPriceKey = GasPriceKey; -export type GasPriceParams = GetTxGasParamsRes; -export type LegacyGasPrices = { - [gasPriceType in LegacyGasPriceKey]: number; -}; - -/* Tornado-specific transaction types: - - 'user_withdrawal' - Fee calculation on user side, when user withdraw funds itself or via relayer on site - - 'relayer_withdrawal' - Fee calculation on relayer side, when relayer sends withdrawal transaction - - 'relayer_withdrawal_check_v4' - Fee calculation on relayer side, when V4 relayer checks user-provided fee. For compatibility reasons - - 'other' - Any other non-specific transaction -*/ -export type WithdrawalTxType = 'user_withdrawal' | 'relayer_withdrawal' | 'relayer_withdrawal_check_v4'; -export type TxType = WithdrawalTxType | 'other'; - -export interface TransactionData { - to: string; - from?: string; - nonce?: number; - gasLimit?: BigNumberish; - gasPrice?: BigNumberish; - data?: string; - value: BigNumberish; - chainId?: number; - type?: number; - maxFeePerGas?: BigNumberish; - maxPriorityFeePerGas?: BigNumberish; -} - -export interface ITornadoFeeOracle { - getGasParams: (params?: GetGasParamsInput) => Promise; - getGas: (params?: GetGasInput) => Promise; - getGasPriceParams: (speed?: LegacyGasPriceKey, bumpPercent?: number) => Promise; - getGasPrice: (speed?: LegacyGasPriceKey, bumpPercent?: number) => Promise; - getGasLimit: (tx?: TransactionData, type?: TxType, bumpPercent?: number) => Promise; - fetchL1OptimismFee: (tx?: TransactionData) => Promise; - calculateRefundInETH: (gasPrice: BigNumberish, tokenSymbol: InstanceTokenSymbol) => HexadecimalStringifiedNumber; - fetchRefundInETH: (tokenSymbol: InstanceTokenSymbol) => Promise; - calculateRefundInToken: ( - gasPrice: BigNumberish, - tokenPriceInEth: BigNumberish, - tokenDecimals: HexadecimalStringifiedNumber | number, - tokenSymbol: InstanceTokenSymbol, - ) => HexadecimalStringifiedNumber; - calculateWithdrawalFeeViaRelayer: (params: GetWithdrawalFeeViaRelayerInput) => Promise; -} - -export interface ITornadoPriceOracle { - defaultPrices: TokenPrices; - fetchPrices: (tokens?: Token[]) => Promise; -} - -export type WithdrawalData = { - contract: string; - proof: BytesLike; - args: [BytesLike, BytesLike, string, string, BigNumberish, BigNumberish]; -}; -export type TornadoPoolInstance = { - currency: AvailableTokenSymbols; - amount: number; - decimals: number; -}; - -// All non-native tokens from Tornado instances on ETH mainnet and TORN itself -export type TokenSymbol = 'dai' | 'cdai' | 'usdc' | 'usdt' | 'wbtc' | 'torn'; export type Token = { tokenAddress: string; - symbol: TokenSymbol; + symbol: string; decimals: number; }; -export type TokenPrices = { [tokenSymbol in TokenSymbol]?: BigNumberish }; +export type TokenPrices = { [tokenSymbol in string]?: string }; -// Reponse type for getGasParams function of fee oracle -export type GetGasParamsRes = { - gasLimit: number; - gasPrice: HexadecimalStringifiedNumber; // Gas price in native currency -}; +export interface Call3 { + contract?: BaseContract; + address?: string; + interface?: Interface; + name: string; + params?: any[]; + allowFailure?: boolean; +} -export type GetGasInput = { - // Transaction type: user-side calculation, relayer-side calculation or - // relayer calculation to check user-provided fee in old V4 relayer (for backwards compatibility) - txType?: TxType; - tx?: TransactionData; // Transaction data in ethers format - predefinedGasPrice?: HexadecimalStringifiedNumber; // Predefined gas price for withdrawal tx (wont be calculated again in function) - predefinedGasLimit?: number; // Predefined gas limit for withdrawal tx (wont be calculated again in function) - bumpGasLimitPercent?: number; // Gas limit bump percent to prioritize transaction (recenlty used) - bumpGasPricePercent?: number; // Gas price bump percent to prioritize transaction (rarely used) - speed?: LegacyGasPriceKey; // Preferred transaction speed, if uses legacy gas (before EIP-1559) -}; +export interface gasOracleOptions { + gasPriceOracle?: string; + gasStationApi?: string; +} -export type GetGasParamsInput = GetGasInput & { includeL1FeeToGasLimit?: boolean }; - -export type GetWithdrawalFeeViaRelayerInput = { - // Transaction type: user-side calculation, relayer-side calculation or - // relayer calculation to check user-provided fee in old V4 relayer (for backwards compatibility) - txType: WithdrawalTxType; - tx?: TransactionData; // Transaction data in ethers format - relayerFeePercent: number | string; // Relayer fee percent from withdrawal amount (for example, 0.15 for BNB or 0.4 for ETH Mainnet) - currency: AvailableTokenSymbols | Uppercase; // Currency (token) symbol - amount: string | number; // Withdrawal amount in selected currency (10 for 10 ETH, 10000 for 10000 DAI) - decimals: string | number; // Token (currency) decimal places - refundInEth?: HexadecimalStringifiedNumber; // Refund amount in ETH, if withdrawing non-native currency on ETH Mainnet or Goerli - tokenPriceInEth?: HexadecimalStringifiedNumber | string; // Token (currency) price in ETH wei, if withdrawing non-native currency - predefinedGasPrice?: HexadecimalStringifiedNumber; // Predefined gas price for withdrawal tx (wont be calculated again in function) - predefinedGasLimit?: number; // Predefined gas limit for withdrawal tx (wont be calculated again in function) -}; +export interface networkConfig { + networkName: string; + offchainOracleContract?: string; + optimismL1FeeOracleAddress?: string; + gasPriceOracleContract?: string; + gasStationApi?: string; + pollInterval: number; +} diff --git a/src/utils.ts b/src/utils.ts index 60cc307..6b9fcd3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,44 +1,30 @@ -import { serialize } from '@ethersproject/transactions'; -import { GasPriceParams, TransactionData } from './types'; -import { BigNumber, BigNumberish } from 'ethers'; -import BigNumberFloat from 'bignumber.js'; -BigNumberFloat.config({ EXPONENTIAL_AT: 100 }); +import type { BigNumberish } from 'ethers'; -const GWEI = 1e9; +export const isNode = + !( + ( + process as typeof process & { + browser?: boolean; + } + ).browser + // prettier-ignore + // @ts-ignore + ) && typeof globalThis.window === 'undefined'; -export function serializeTx(tx?: TransactionData | string): string { - if (!tx) tx = '0x'; - if (typeof tx === 'string') return tx; - - return serialize(tx); +export function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); } -export function calculateGasPriceInWei(gasPrice: GasPriceParams): BigNumber { - // @ts-ignore - return BigNumber.from(gasPrice.gasPrice || gasPrice.maxFeePerGas); -} - -export function bump(value: BigNumberish, percent: number): BigNumber { - const hundredPercents = BigNumberFloat(100); - - return BigNumber.from( - BigNumberFloat(BigNumber.from(value).toHexString()) - .times(hundredPercents.plus(BigNumberFloat(percent))) - .div(hundredPercents) - .decimalPlaces(0, 1) - .toString(), - ); -} - -export function fromGweiToWeiHex(value: number | string): BigNumberish { - return BigNumber.from(BigNumberFloat(value).times(GWEI).toString()).toHexString(); -} - -export function convertETHToToken( - amountInWEI: BigNumberish, - tokenDecimals: number | string, +/** + * Example: + * + * amountInWei (0.1 ETH) * tokenDecimals (18) * tokenPriceInWei (0.0008) = 125 TOKEN + */ +export function convertETHToTokenAmount( + amountInWei: BigNumberish, tokenPriceInWei: BigNumberish, -): BigNumber { - const tokenDecimalsMultiplier = BigNumber.from(10).pow(tokenDecimals); - return BigNumber.from(amountInWEI).mul(tokenDecimalsMultiplier).div(tokenPriceInWei); + tokenDecimals: number = 18, +): bigint { + const tokenDecimalsMultiplier = BigInt(10 ** Number(tokenDecimals)); + return (BigInt(amountInWei) * tokenDecimalsMultiplier) / BigInt(tokenPriceInWei); }