diff --git a/e2e/oracle.e2e.ts b/e2e/oracle.e2e.ts index df2b0e3d..360cfa6d 100644 --- a/e2e/oracle.e2e.ts +++ b/e2e/oracle.e2e.ts @@ -1,10 +1,10 @@ // @note: This test is _not_ run automatically as part of git hooks or CI. import dotenv from "dotenv"; import winston from "winston"; -import { providers, utils as ethersUtils } from "ethers"; +import { providers } from "ethers"; import { getGasPriceEstimate } from "../src/gasPriceOracle"; -import { BigNumber } from "../src/utils"; -import { assertPromiseError, expect } from "../test/utils"; +import { BigNumber, bnZero, parseUnits } from "../src/utils"; +import { expect, makeCustomTransport } from "../test/utils"; dotenv.config({ path: ".env" }); const dummyLogger = winston.createLogger({ @@ -12,86 +12,20 @@ const dummyLogger = winston.createLogger({ transports: [new winston.transports.Console()], }); -type FeeData = providers.FeeData; +const stdLastBaseFeePerGas = parseUnits("12", 9); +const stdMaxPriorityFeePerGas = parseUnits("1", 9); // EIP-1559 chains only +const chainIds = [1, 10, 137, 324, 8453, 42161, 534352]; -class MockedProvider extends providers.StaticJsonRpcProvider { - // Unknown type => exercise our validation logic - public testFeeData: unknown; - public testGasPrice: unknown; +const customTransport = makeCustomTransport({ stdLastBaseFeePerGas, stdMaxPriorityFeePerGas }); - constructor(url: string) { - super(url); - } - - override async getFeeData(): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (this.testFeeData as any) ?? (await super.getFeeData()); - } - - override async getGasPrice(): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return this.testGasPrice !== undefined ? (this.testGasPrice as any) : await super.getGasPrice(); - } -} - -/** - * Note: If NODE_URL_ envvars exist, they will be used. The - * RPCs defined below are otherwise used as default/fallback options. - * These may be subject to rate-limiting, in which case the retrieved - * price will revert to 0. - * - * Note also that Optimism is only supported as a fallback/legacy test - * case. It works, but is not the recommended method for conjuring gas - * prices on Optimism. - */ -const networks: { [chainId: number]: string } = { - 1: "https://eth.llamarpc.com", - 10: "https://mainnet.optimism.io", - 137: "https://polygon.llamarpc.com", - 288: "https://mainnet.boba.network", - 324: "https://mainnet.era.zksync.io", - 8453: "https://mainnet.base.org", - 42161: "https://rpc.ankr.com/arbitrum", - 534352: "https://rpc.scroll.io", -}; - -const stdGasPrice = ethersUtils.parseUnits("10", 9); -const stdMaxPriorityFeePerGas = ethersUtils.parseUnits("1.5", 9); // EIP-1559 chains only -const stdLastBaseFeePerGas = stdGasPrice.sub(stdMaxPriorityFeePerGas); -const stdMaxFeePerGas = stdGasPrice; -const eip1559Chains = [1, 10, 137, 8453, 42161]; -const legacyChains = [288, 324, 534352]; - -let providerInstances: { [chainId: number]: MockedProvider } = {}; +const provider = new providers.StaticJsonRpcProvider("https://eth.llamarpc.com"); describe("Gas Price Oracle", function () { - before(() => { - providerInstances = Object.fromEntries( - Object.entries(networks).map(([_chainId, _rpcUrl]) => { - const chainId = Number(_chainId); - const rpcUrl: string = process.env[`NODE_URL_${chainId}`] ?? _rpcUrl; - const provider = new MockedProvider(rpcUrl); - return [chainId, provider]; - }) - ); - }); - - beforeEach(() => { - for (const provider of Object.values(providerInstances)) { - provider.testFeeData = { - gasPrice: stdGasPrice, - lastBaseFeePerGas: stdLastBaseFeePerGas, - maxPriorityFeePerGas: stdMaxPriorityFeePerGas, - }; - provider.testGasPrice = stdGasPrice; // Required: same as provider.feeData.gasPrice. - } - }); - it("Gas Price Retrieval", async function () { - for (const [_chainId, provider] of Object.entries(providerInstances)) { - const chainId = Number(_chainId); - - const { maxFeePerGas, maxPriorityFeePerGas } = await getGasPriceEstimate(provider); + for (const chainId of chainIds) { + const chainKey = `NEW_GAS_PRICE_ORACLE_${chainId}`; + process.env[chainKey] = "true"; + const { maxFeePerGas, maxPriorityFeePerGas } = await getGasPriceEstimate(provider, chainId, customTransport); dummyLogger.debug({ at: "Gas Price Oracle#Gas Price Retrieval", message: `Retrieved gas price estimate for chain ID ${chainId}`, @@ -102,115 +36,22 @@ describe("Gas Price Oracle", function () { expect(BigNumber.isBigNumber(maxFeePerGas)).to.be.true; expect(BigNumber.isBigNumber(maxPriorityFeePerGas)).to.be.true; - if (eip1559Chains.includes(chainId)) { - if (chainId === 137) { - // The Polygon gas station isn't mocked, so just ensure that the fees have a valid relationship. - expect(maxFeePerGas.gt(0)).to.be.true; - expect(maxPriorityFeePerGas.gt(0)).to.be.true; - expect(maxPriorityFeePerGas.lt(maxFeePerGas)).to.be.true; - } else if (chainId === 42161) { - // Arbitrum priority fees are refunded, so drop the priority fee from estimates. - expect(maxFeePerGas.eq(stdLastBaseFeePerGas.add(1))).to.be.true; - expect(maxPriorityFeePerGas.eq(1)).to.be.true; - } else { - expect(maxFeePerGas.eq(stdMaxFeePerGas)).to.be.true; - expect(maxPriorityFeePerGas.eq(stdMaxPriorityFeePerGas)).to.be.true; - } + if (chainId === 137) { + // The Polygon gas station isn't mocked, so just ensure that the fees have a valid relationship. + expect(maxFeePerGas.gt(0)).to.be.true; + expect(maxPriorityFeePerGas.gt(0)).to.be.true; + expect(maxPriorityFeePerGas.lt(maxFeePerGas)).to.be.true; + } else if (chainId === 42161) { + // Arbitrum priority fees are refunded, so drop the priority fee from estimates. + // Expect a 1.2x multiplier on the last base fee. + expect(maxFeePerGas.eq(stdLastBaseFeePerGas.mul("120").div("100").add(1))).to.be.true; + expect(maxPriorityFeePerGas.eq(1)).to.be.true; } else { - // Defaults to Legacy (Type 0) - expect(maxFeePerGas.eq(stdGasPrice)).to.be.true; - expect(maxPriorityFeePerGas.eq(0)).to.be.true; + expect(maxFeePerGas.gt(bnZero)).to.be.true; + expect(maxPriorityFeePerGas.gt(bnZero)).to.be.true; } - } - }); - - it("Gas Price Retrieval Failure", async function () { - const feeDataFields = ["gasPrice", "lastBaseFeePerGas", "maxPriorityFeePerGas"]; - const feeDataValues = [null, "test", "1234", 5678, BigNumber.from(-1)]; - - // Iterate over various faulty values for gasPrice & feeData. - // Loop one chain at a time to minimise rate-limiting in case a public RPC is being used. - for (const field of feeDataFields) { - for (const value of feeDataValues) { - for (const [_chainId, provider] of Object.entries(providerInstances)) { - const chainId = Number(_chainId); - - provider.testGasPrice = field === "gasPrice" ? value : stdGasPrice; - provider.testFeeData = { - gasPrice: field === "gasPrice" ? value : stdGasPrice, - lastBaseFeePerGas: field === "lastBaseFeePerGas" ? value : stdLastBaseFeePerGas, - maxPriorityFeePerGas: field === "maxPriorityFeePerGas" ? value : stdMaxPriorityFeePerGas, - }; - - // Malformed inputs were supplied; ensure an exception is thrown. - if ( - (legacyChains.includes(chainId) && ["gasPrice"].includes(field)) || - (chainId !== 137 && - eip1559Chains.includes(chainId) && - ["lastBaseFeePerGas", "maxPriorityFeePerGas"].includes(field)) - ) { - provider.testGasPrice = field === "gasPrice" ? value : stdGasPrice; - await assertPromiseError(getGasPriceEstimate(provider, chainId)); - } else { - // Expect sane results to be returned; validate them. - const { maxFeePerGas, maxPriorityFeePerGas } = await getGasPriceEstimate(provider, chainId); - - dummyLogger.debug({ - at: "Gas Price Oracle#Gas Price Retrieval Failure", - message: `Retrieved gas price estimate for chain ID ${chainId}.`, - maxFeePerGas, - maxPriorityFeePerGas, - }); - - expect(BigNumber.isBigNumber(maxFeePerGas)).to.be.true; - expect(BigNumber.isBigNumber(maxPriorityFeePerGas)).to.be.true; - - if (eip1559Chains.includes(chainId)) { - if (chainId === 137) { - expect(maxFeePerGas.gt(0)).to.be.true; - expect(maxPriorityFeePerGas.gt(0)).to.be.true; - expect(maxPriorityFeePerGas.lt(maxFeePerGas)).to.be.true; - } else if (chainId === 42161) { - expect(maxFeePerGas.eq(stdLastBaseFeePerGas.add(1))).to.be.true; - expect(maxPriorityFeePerGas.eq(1)).to.be.true; - } else { - expect(maxFeePerGas.eq(stdMaxFeePerGas)).to.be.true; - expect(maxPriorityFeePerGas.eq(stdMaxPriorityFeePerGas)).to.be.true; - } - } else { - // Legacy - expect(maxFeePerGas.eq(stdGasPrice)).to.be.true; - expect(maxPriorityFeePerGas.eq(0)).to.be.true; - } - } - } - } - } - }); - - it("Gas Price Fallback Behaviour", async function () { - for (const provider of Object.values(providerInstances)) { - const fakeChainId = 1337; - - const chainId = (await provider.getNetwork()).chainId; - dummyLogger.debug({ - at: "Gas Price Oracle#Gas Price Fallback Behaviour", - message: `Testing on chainId ${chainId}.`, - }); - - provider.testGasPrice = stdMaxFeePerGas; // Suppress RPC lookup. - - const { maxFeePerGas, maxPriorityFeePerGas } = await getGasPriceEstimate(provider, fakeChainId, true); - - // Require legacy pricing when fallback is permitted. - expect(BigNumber.isBigNumber(maxFeePerGas)).to.be.true; - expect(maxFeePerGas.eq(stdMaxFeePerGas)).to.be.true; - - expect(BigNumber.isBigNumber(maxPriorityFeePerGas)).to.be.true; - expect(maxPriorityFeePerGas.eq(0)).to.be.true; - // Verify an assertion is thrown when fallback is not permitted. - await assertPromiseError(getGasPriceEstimate(provider, fakeChainId, false)); + delete process.env[chainKey]; } }); }); diff --git a/package.json b/package.json index 94cab29f..12ccfc1a 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,8 @@ "lodash": "^4.17.21", "lodash.get": "^4.4.2", "superstruct": "^0.15.4", - "tslib": "^2.6.2" + "tslib": "^2.6.2", + "viem": "^2.21.15" }, "publishConfig": { "registry": "https://registry.npmjs.com/", diff --git a/src/gasPriceOracle/adapters/arbitrum-viem.ts b/src/gasPriceOracle/adapters/arbitrum-viem.ts new file mode 100644 index 00000000..fc920b33 --- /dev/null +++ b/src/gasPriceOracle/adapters/arbitrum-viem.ts @@ -0,0 +1,13 @@ +import { PublicClient } from "viem"; +import { InternalGasPriceEstimate } from "../types"; + +const MAX_PRIORITY_FEE_PER_GAS = BigInt(1); + +// Arbitrum Nitro implements EIP-1559 pricing, but the priority fee is always refunded to the caller. +// Swap it for 1 Wei to avoid inaccurate transaction cost estimates. +// Reference: https://developer.arbitrum.io/faqs/gas-faqs#q-priority +export async function eip1559(provider: PublicClient, _chainId: number): Promise { + const { maxFeePerGas: _maxFeePerGas, maxPriorityFeePerGas } = await provider.estimateFeesPerGas(); + const maxFeePerGas = BigInt(_maxFeePerGas) - maxPriorityFeePerGas + MAX_PRIORITY_FEE_PER_GAS; + return { maxFeePerGas, maxPriorityFeePerGas: MAX_PRIORITY_FEE_PER_GAS }; +} diff --git a/src/gasPriceOracle/adapters/ethereum-viem.ts b/src/gasPriceOracle/adapters/ethereum-viem.ts new file mode 100644 index 00000000..050cadba --- /dev/null +++ b/src/gasPriceOracle/adapters/ethereum-viem.ts @@ -0,0 +1,19 @@ +import { PublicClient } from "viem"; +import { InternalGasPriceEstimate } from "../types"; + +export function eip1559(provider: PublicClient, _chainId: number): Promise { + return provider.estimateFeesPerGas(); +} + +export async function legacy( + provider: PublicClient, + _chainId: number, + _test?: number +): Promise { + const gasPrice = await provider.getGasPrice(); + + return { + maxFeePerGas: gasPrice, + maxPriorityFeePerGas: BigInt(0), + }; +} diff --git a/src/gasPriceOracle/adapters/polygon-viem.ts b/src/gasPriceOracle/adapters/polygon-viem.ts new file mode 100644 index 00000000..c1ea717d --- /dev/null +++ b/src/gasPriceOracle/adapters/polygon-viem.ts @@ -0,0 +1,86 @@ +import { PublicClient } from "viem"; +import { BaseHTTPAdapter, BaseHTTPAdapterArgs } from "../../priceClient/adapters/baseAdapter"; +import { isDefined } from "../../utils"; +import { CHAIN_IDs } from "../../constants"; +import { InternalGasPriceEstimate } from "../types"; +import { gasPriceError } from "../util"; +import { eip1559 } from "./ethereum-viem"; + +type Polygon1559GasPrice = { + maxPriorityFee: number | string; + maxFee: number | string; +}; + +type GasStationV2Response = { + safeLow: Polygon1559GasPrice; + standard: Polygon1559GasPrice; + fast: Polygon1559GasPrice; + estimatedBaseFee: number | string; + blockTime: number | string; + blockNumber: number | string; +}; + +type GasStationArgs = BaseHTTPAdapterArgs & { + chainId?: number; + host?: string; +}; + +const { POLYGON } = CHAIN_IDs; + +const GWEI = BigInt(1_000_000_000); +class PolygonGasStation extends BaseHTTPAdapter { + readonly chainId: number; + + constructor({ chainId = POLYGON, host, timeout = 1500, retries = 1 }: GasStationArgs = {}) { + host = host ?? chainId === POLYGON ? "gasstation.polygon.technology" : "gasstation-testnet.polygon.technology"; + + super("Polygon Gas Station", host, { timeout, retries }); + this.chainId = chainId; + } + + async getFeeData(strategy: "safeLow" | "standard" | "fast" = "fast"): Promise { + const gas = await this.query("v2", {}); + + const gasPrice = (gas as GasStationV2Response)?.[strategy]; + if (!this.isPolygon1559GasPrice(gasPrice)) { + // @todo: generalise gasPriceError() to accept a reason/cause? + gasPriceError("getFeeData()", this.chainId, gasPrice); + } + + const maxPriorityFeePerGas = BigInt(gasPrice.maxPriorityFee) * GWEI; + const maxFeePerGas = BigInt(gasPrice.maxFee) * GWEI; + + return { maxPriorityFeePerGas, maxFeePerGas }; + } + + protected isPolygon1559GasPrice(gasPrice: unknown): gasPrice is Polygon1559GasPrice { + if (!isDefined(gasPrice)) { + return false; + } + const _gasPrice = gasPrice as Polygon1559GasPrice; + return [_gasPrice.maxPriorityFee, _gasPrice.maxFee].every((field) => ["number", "string"].includes(typeof field)); + } +} + +export async function gasStation(provider: PublicClient, chainId: number): Promise { + const gasStation = new PolygonGasStation({ chainId, timeout: 2000, retries: 0 }); + let maxPriorityFeePerGas: bigint; + let maxFeePerGas: bigint; + try { + ({ maxPriorityFeePerGas, maxFeePerGas } = await gasStation.getFeeData()); + } catch (err) { + // Fall back to the RPC provider. May be less accurate. + ({ maxPriorityFeePerGas, maxFeePerGas } = await eip1559(provider, chainId)); + + // Per the GasStation docs, the minimum priority fee on Polygon is 30 Gwei. + // https://docs.polygon.technology/tools/gas/polygon-gas-station/#interpretation + const minPriorityFee = BigInt(30) * GWEI; + if (minPriorityFee > maxPriorityFeePerGas) { + const priorityDelta = minPriorityFee - maxPriorityFeePerGas; + maxPriorityFeePerGas = minPriorityFee; + maxFeePerGas = maxFeePerGas + priorityDelta; + } + } + + return { maxPriorityFeePerGas, maxFeePerGas }; +} diff --git a/src/gasPriceOracle/oracle.ts b/src/gasPriceOracle/oracle.ts index c16c17f2..d072dc6f 100644 --- a/src/gasPriceOracle/oracle.ts +++ b/src/gasPriceOracle/oracle.ts @@ -1,11 +1,15 @@ +import { Transport } from "viem"; import { providers } from "ethers"; import { CHAIN_IDs } from "../constants"; -import { chainIsOPStack } from "../utils"; -import { GasPriceEstimate, GasPriceFeed } from "./types"; +import { BigNumber, chainIsOPStack } from "../utils"; +import { GasPriceEstimate } from "./types"; +import { getPublicClient } from "./util"; import * as arbitrum from "./adapters/arbitrum"; import * as ethereum from "./adapters/ethereum"; import * as linea from "./adapters/linea"; import * as polygon from "./adapters/polygon"; +import * as arbitrumViem from "./adapters/arbitrum-viem"; +import * as polygonViem from "./adapters/polygon-viem"; /** * Provide an estimate for the current gas price for a particular chain. @@ -17,17 +21,39 @@ import * as polygon from "./adapters/polygon"; export async function getGasPriceEstimate( provider: providers.Provider, chainId?: number, + transport?: Transport, legacyFallback = true ): Promise { if (chainId === undefined) { ({ chainId } = await provider.getNetwork()); } - const gasPriceFeeds: { [chainId: number]: GasPriceFeed } = { + const useViem = process.env[`NEW_GAS_PRICE_ORACLE_${chainId}`] === "true"; + return useViem + ? getViemGasPriceEstimate(chainId, transport) + : getEthersGasPriceEstimate(provider, chainId, legacyFallback); +} + +/** + * Provide an estimate for the current gas price for a particular chain. + * @param chainId The chain ID to query for gas prices. + * @param provider A valid ethers provider. + * @param legacyFallback In the case of an unrecognised chain, fall back to type 0 gas estimation. + * @returns Am object of type GasPriceEstimate. + */ +async function getEthersGasPriceEstimate( + provider: providers.Provider, + chainId?: number, + legacyFallback = true +): Promise { + if (chainId === undefined) { + ({ chainId } = await provider.getNetwork()); + } + + const gasPriceFeeds = { [CHAIN_IDs.ALEPH_ZERO]: arbitrum.eip1559, [CHAIN_IDs.ARBITRUM]: arbitrum.eip1559, [CHAIN_IDs.BASE]: ethereum.eip1559, - [CHAIN_IDs.BOBA]: ethereum.legacy, [CHAIN_IDs.LINEA]: linea.eip1559, // @todo: Support linea_estimateGas in adapter. [CHAIN_IDs.MAINNET]: ethereum.eip1559, [CHAIN_IDs.MODE]: ethereum.eip1559, @@ -35,7 +61,7 @@ export async function getGasPriceEstimate( [CHAIN_IDs.POLYGON]: polygon.gasStation, [CHAIN_IDs.ZK_SYNC]: ethereum.legacy, [CHAIN_IDs.SCROLL]: ethereum.legacy, - }; + } as const; let gasPriceFeed = gasPriceFeeds[chainId]; if (gasPriceFeed === undefined) { @@ -47,3 +73,41 @@ export async function getGasPriceEstimate( return gasPriceFeed(provider, chainId); } + +/** + * Provide an estimate for the current gas price for a particular chain. + * @param providerOrChainId A valid ethers provider or a chain ID. + * @param transport An optional transport object for custom gas price retrieval. + * @returns Am object of type GasPriceEstimate. + */ +export async function getViemGasPriceEstimate( + providerOrChainId: providers.Provider | number, + transport?: Transport +): Promise { + const chainId = + typeof providerOrChainId === "number" ? providerOrChainId : (await providerOrChainId.getNetwork()).chainId; + const viemProvider = getPublicClient(chainId, transport); + + const gasPriceFeeds = { + [CHAIN_IDs.ALEPH_ZERO]: arbitrumViem.eip1559, + [CHAIN_IDs.ARBITRUM]: arbitrumViem.eip1559, + [CHAIN_IDs.POLYGON]: polygonViem.gasStation, + } as const; + + let maxFeePerGas: bigint; + let maxPriorityFeePerGas: bigint; + if (gasPriceFeeds[chainId]) { + ({ maxFeePerGas, maxPriorityFeePerGas } = await gasPriceFeeds[chainId](viemProvider, chainId)); + } else { + let gasPrice: bigint | undefined; + ({ maxFeePerGas, maxPriorityFeePerGas, gasPrice } = await viemProvider.estimateFeesPerGas()); + + maxFeePerGas ??= gasPrice!; + maxPriorityFeePerGas ??= BigInt(0); + } + + return { + maxFeePerGas: BigNumber.from(maxFeePerGas.toString()), + maxPriorityFeePerGas: BigNumber.from(maxPriorityFeePerGas.toString()), + }; +} diff --git a/src/gasPriceOracle/types.ts b/src/gasPriceOracle/types.ts index 3385eb6c..f2ac5636 100644 --- a/src/gasPriceOracle/types.ts +++ b/src/gasPriceOracle/types.ts @@ -1,11 +1,13 @@ -import { providers } from "ethers"; +import { type Chain, type Transport, PublicClient, FeeValuesEIP1559 } from "viem"; import { BigNumber } from "../utils"; +export type InternalGasPriceEstimate = FeeValuesEIP1559; + export type GasPriceEstimate = { maxFeePerGas: BigNumber; maxPriorityFeePerGas: BigNumber; }; export interface GasPriceFeed { - (provider: providers.Provider, chainId: number): Promise; + (provider: PublicClient, chainId: number): Promise; } diff --git a/src/gasPriceOracle/util.ts b/src/gasPriceOracle/util.ts index debea4c6..98e299fe 100644 --- a/src/gasPriceOracle/util.ts +++ b/src/gasPriceOracle/util.ts @@ -1,6 +1,15 @@ -import { providers } from "ethers"; -import { BigNumber } from "../utils"; +import assert from "assert"; +import { type Chain, type PublicClient, createPublicClient, http, Transport } from "viem"; +import * as chains from "viem/chains"; -export function gasPriceError(method: string, chainId: number, data: providers.FeeData | BigNumber): void { +export function gasPriceError(method: string, chainId: number, data: unknown): void { throw new Error(`Malformed ${method} response on chain ID ${chainId} (${JSON.stringify(data)})`); } + +export function getPublicClient(chainId: number, transport?: Transport): PublicClient { + transport ??= http(); // @todo: Inherit URL from provider. + const chain: Chain | undefined = Object.values(chains).find((chain) => chain.id === chainId); + assert(chain); + + return createPublicClient({ chain, transport }); +} diff --git a/src/relayFeeCalculator/chain-queries/baseQuery.ts b/src/relayFeeCalculator/chain-queries/baseQuery.ts index f6f0db0e..514075eb 100644 --- a/src/relayFeeCalculator/chain-queries/baseQuery.ts +++ b/src/relayFeeCalculator/chain-queries/baseQuery.ts @@ -14,6 +14,7 @@ import { toBNWei, } from "../../utils"; import { Logger, QueryInterface } from "../relayFeeCalculator"; +import { Transport } from "viem"; type Provider = providers.Provider; type OptimismProvider = L2Provider; @@ -61,17 +62,25 @@ export class QueryBase implements QueryInterface { * Retrieves the current gas costs of performing a fillRelay contract at the referenced SpokePool. * @param deposit V3 deposit instance. * @param relayerAddress Relayer address to simulate with. - * @param gasPrice Optional gas price to use for the simulation. - * @param gasUnits Optional gas units to use for the simulation. + * @param options + * @param options.gasPrice Optional gas price to use for the simulation. + * @param options.gasUnits Optional gas units to use for the simulation. + * @param options.omitMarkup Optional flag to omit the gas markup. + * @param options.transport Optional transport object for custom gas price retrieval. * @returns The gas estimate for this function call (multiplied with the optional buffer). */ async getGasCosts( deposit: Deposit, relayer = DEFAULT_SIMULATED_RELAYER_ADDRESS, - gasPrice = this.fixedGasPrice, - gasUnits?: BigNumberish, - omitMarkup?: boolean + options: Partial<{ + gasPrice: BigNumberish; + gasUnits: BigNumberish; + omitMarkup: boolean; + transport: Transport; + }> = {} ): Promise { + const { gasPrice = this.fixedGasPrice, gasUnits, omitMarkup, transport } = options; + const gasMarkup = omitMarkup ? 0 : this.gasMarkup; assert( gasMarkup > -1 && gasMarkup <= 4, @@ -84,8 +93,11 @@ export class QueryBase implements QueryInterface { tx, relayer, this.provider, - gasPrice, - gasUnits + { + gasPrice, + gasUnits, + transport, + } ); return { diff --git a/src/relayFeeCalculator/relayFeeCalculator.ts b/src/relayFeeCalculator/relayFeeCalculator.ts index 0ea82747..8974d303 100644 --- a/src/relayFeeCalculator/relayFeeCalculator.ts +++ b/src/relayFeeCalculator/relayFeeCalculator.ts @@ -17,14 +17,14 @@ import { toBN, toBNWei, } from "../utils"; +import { Transport } from "viem"; // This needs to be implemented for every chain and passed into RelayFeeCalculator export interface QueryInterface { getGasCosts: ( deposit: Deposit, relayer: string, - gasPrice?: BigNumberish, - gasLimit?: BigNumberish + options?: Partial<{ gasPrice: BigNumberish; gasUnits: BigNumberish; omitMarkup: boolean; transport: Transport }> ) => Promise; getTokenPrice: (tokenSymbol: string) => Promise; getTokenDecimals: (tokenSymbol: string) => number; @@ -230,7 +230,8 @@ export class RelayFeeCalculator { _tokenPrice?: number, tokenMapping = TOKEN_SYMBOLS_MAP, gasPrice?: BigNumberish, - gasLimit?: BigNumberish + gasLimit?: BigNumberish, + transport?: Transport ): Promise { if (toBN(amountToRelay).eq(bnZero)) return MAX_BIG_INT; @@ -245,16 +246,18 @@ export class RelayFeeCalculator { const simulatedAmount = simulateZeroFill ? safeOutputAmount : toBN(amountToRelay); deposit = { ...deposit, outputAmount: simulatedAmount }; - const getGasCosts = this.queries.getGasCosts(deposit, relayerAddress, gasPrice, gasLimit).catch((error) => { - this.logger.error({ - at: "sdk/gasFeePercent", - message: "Error while fetching gas costs", - error, - simulateZeroFill, - deposit, + const getGasCosts = this.queries + .getGasCosts(deposit, relayerAddress, { gasPrice, gasUnits: gasLimit, transport }) + .catch((error) => { + this.logger.error({ + at: "sdk/gasFeePercent", + message: "Error while fetching gas costs", + error, + simulateZeroFill, + deposit, + }); + throw error; }); - throw error; - }); const [{ tokenGasCost }, tokenPrice] = await Promise.all([ getGasCosts, _tokenPrice ?? diff --git a/src/utils/common.ts b/src/utils/common.ts index bd7ea303..4c8ff27a 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -8,6 +8,7 @@ import { TypedMessage } from "../interfaces/TypedData"; import { BigNumber, BigNumberish, BN, formatUnits, parseUnits, toBN } from "./BigNumberUtils"; import { ConvertDecimals } from "./FormattingUtils"; import { chainIsOPStack } from "./NetworkUtils"; +import { Transport } from "viem"; export type Decimalish = string | number | Decimal; export const AddressZero = ethers.constants.AddressZero; @@ -239,17 +240,24 @@ export type TransactionCostEstimate = { * @param unsignedTx The unsigned transaction that this function will estimate. * @param senderAddress The address that the transaction will be submitted from. * @param provider A valid ethers provider - will be used to reason the gas price. - * @param gasPrice A manually provided gas price - if set, this function will not resolve the current gas price. - * @param gasUnits A manually provided gas units - if set, this function will not estimate the gas units. + * @param options + * @param options.gasPrice A manually provided gas price - if set, this function will not resolve the current gas price. + * @param options.gasUnits A manually provided gas units - if set, this function will not estimate the gas units. + * @param options.transport A custom transport object for custom gas price retrieval. * @returns Estimated cost in units of gas and the underlying gas token (gasPrice * estimatedGasUnits). */ export async function estimateTotalGasRequiredByUnsignedTransaction( unsignedTx: PopulatedTransaction, senderAddress: string, provider: providers.Provider | L2Provider, - gasPrice?: BigNumberish, - gasUnits?: BigNumberish + options: Partial<{ + gasPrice: BigNumberish; + gasUnits: BigNumberish; + transport: Transport; + }> = {} ): Promise { + const { gasPrice: _gasPrice, gasUnits, transport } = options || {}; + const { chainId } = await provider.getNetwork(); const voidSigner = new VoidSigner(senderAddress, provider); @@ -268,13 +276,14 @@ export async function estimateTotalGasRequiredByUnsignedTransaction( // `provider.estimateTotalGasCost` to improve performance. const [l1GasCost, l2GasPrice] = await Promise.all([ provider.estimateL1GasCost(populatedTransaction), - gasPrice || provider.getGasPrice(), + _gasPrice || provider.getGasPrice(), ]); const l2GasCost = nativeGasCost.mul(l2GasPrice); tokenGasCost = l1GasCost.add(l2GasCost); } else { + let gasPrice = _gasPrice; if (!gasPrice) { - const gasPriceEstimate = await getGasPriceEstimate(provider, chainId); + const gasPriceEstimate = await getGasPriceEstimate(provider, chainId, transport); gasPrice = gasPriceEstimate.maxFeePerGas; } tokenGasCost = nativeGasCost.mul(gasPrice); diff --git a/test/relayFeeCalculator.test.ts b/test/relayFeeCalculator.test.ts index e72e9b89..7b020f25 100644 --- a/test/relayFeeCalculator.test.ts +++ b/test/relayFeeCalculator.test.ts @@ -25,6 +25,7 @@ import { getContractFactory, randomAddress, setupTokensForWallet, + makeCustomTransport, } from "./utils"; import { TOKEN_SYMBOLS_MAP } from "@across-protocol/constants"; import { EMPTY_MESSAGE, ZERO_ADDRESS } from "../src/constants"; @@ -286,6 +287,7 @@ describe("RelayFeeCalculator: Composable Bridging", function () { let owner: SignerWithAddress, relayer: SignerWithAddress, depositor: SignerWithAddress; let tokenMap: typeof TOKEN_SYMBOLS_MAP; let testGasFeePct: (message?: string) => Promise; + const customTransport = makeCustomTransport(); beforeEach(async function () { [owner, relayer, depositor] = await ethers.getSigners(); @@ -345,7 +347,10 @@ describe("RelayFeeCalculator: Composable Bridging", function () { false, relayer.address, 1, - tokenMap + tokenMap, + undefined, + undefined, + customTransport ); }); it("should not revert if no message is passed", async () => { diff --git a/test/utils/index.ts b/test/utils/index.ts index 91f17f9c..fb9d65a2 100644 --- a/test/utils/index.ts +++ b/test/utils/index.ts @@ -6,3 +6,4 @@ export * from "./utils"; export * from "./BlockchainUtils"; export * from "./SpokePoolUtils"; export * from "./SpyTransport"; +export * from "./transport"; diff --git a/test/utils/transport.ts b/test/utils/transport.ts new file mode 100644 index 00000000..38915d97 --- /dev/null +++ b/test/utils/transport.ts @@ -0,0 +1,26 @@ +import { custom } from "viem"; +import { BigNumber, parseUnits } from "../../src/utils"; + +export const makeCustomTransport = ( + params: Partial<{ stdLastBaseFeePerGas: BigNumber; stdMaxPriorityFeePerGas: BigNumber }> = {} +) => { + const { stdLastBaseFeePerGas = parseUnits("12", 9), stdMaxPriorityFeePerGas = parseUnits("1", 9) } = params; + const stdMaxFeePerGas = stdLastBaseFeePerGas.add(stdMaxPriorityFeePerGas); + const stdGasPrice = stdMaxFeePerGas; + + return custom({ + // eslint-disable-next-line require-await + async request({ method }: { method: string; params: unknown }) { + switch (method) { + case "eth_gasPrice": + return BigInt(stdGasPrice.toString()); + case "eth_getBlockByNumber": + return { baseFeePerGas: BigInt(stdLastBaseFeePerGas.toString()) }; + case "eth_maxPriorityFeePerGas": + return BigInt(stdMaxPriorityFeePerGas.toString()); + default: + throw new Error(`Unsupported method: ${method}.`); + } + }, + }); +}; diff --git a/yarn.lock b/yarn.lock index 0177ad55..443fdad3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -58,6 +58,11 @@ yargs "^17.7.2" zksync-web3 "^0.14.3" +"@adraffy/ens-normalize@1.10.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@adraffy/ens-normalize/-/ens-normalize-1.10.0.tgz#d2a39395c587e092d77cbbc80acf956a54f38bf7" + integrity sha512-nA9XHtlAkYfJxY7bce8DcN7eKxWWCWkU+1GR9d+U6MbNpfwQp8TI7vqOsBsMcHoT4mBu2kypKoSKnghEzOOq5Q== + "@apollo/protobufjs@1.2.6": version "1.2.6" resolved "https://registry.yarnpkg.com/@apollo/protobufjs/-/protobufjs-1.2.6.tgz#d601e65211e06ae1432bf5993a1a0105f2862f27" @@ -1520,13 +1525,27 @@ dependencies: "@noble/hashes" "1.3.1" -"@noble/curves@^1.4.2": +"@noble/curves@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.4.0.tgz#f05771ef64da724997f69ee1261b2417a49522d6" + integrity sha512-p+4cb332SFCrReJkCYe8Xzm0OWi4Jji5jVdIZRL/PmacmDkFNw6MrrV+gGpiPxLHbV+zKFRywUWbaseT+tZRXg== + dependencies: + "@noble/hashes" "1.4.0" + +"@noble/curves@^1.4.0", "@noble/curves@^1.4.2": version "1.6.0" resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.6.0.tgz#be5296ebcd5a1730fccea4786d420f87abfeb40b" integrity sha512-TlaHRXDehJuRNR9TfZDNQ45mMEd5dwUwmicsafcIX4SsNiqnCHKjE/1alYPd/lDRVhxdhUAlv8uEhMCI5zjIJQ== dependencies: "@noble/hashes" "1.5.0" +"@noble/curves@~1.4.0": + version "1.4.2" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.4.2.tgz#40309198c76ed71bc6dbf7ba24e81ceb4d0d1fe9" + integrity sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw== + dependencies: + "@noble/hashes" "1.4.0" + "@noble/ed25519@^1.6.1": version "1.7.3" resolved "https://registry.yarnpkg.com/@noble/ed25519/-/ed25519-1.7.3.tgz#57e1677bf6885354b466c38e2b620c62f45a7123" @@ -1542,7 +1561,12 @@ resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.1.tgz#8831ef002114670c603c458ab8b11328406953a9" integrity sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA== -"@noble/hashes@1.5.0", "@noble/hashes@^1.3.1", "@noble/hashes@^1.4.0": +"@noble/hashes@1.4.0", "@noble/hashes@~1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.4.0.tgz#45814aa329f30e4fe0ba49426f49dfccdd066426" + integrity sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg== + +"@noble/hashes@1.5.0", "@noble/hashes@^1.3.1", "@noble/hashes@^1.4.0", "@noble/hashes@~1.5.0": version "1.5.0" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.5.0.tgz#abadc5ca20332db2b1b2aa3e496e9af1213570b0" integrity sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA== @@ -2189,6 +2213,11 @@ resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.1.tgz#ebb651ee52ff84f420097055f4bf46cfba403938" integrity sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA== +"@scure/base@~1.1.6", "@scure/base@~1.1.8": + version "1.1.9" + resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.9.tgz#e5e142fbbfe251091f9c5f1dd4c834ac04c3dbd1" + integrity sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg== + "@scure/bip32@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.1.0.tgz#dea45875e7fbc720c2b4560325f1cf5d2246d95b" @@ -2207,6 +2236,15 @@ "@noble/hashes" "~1.3.1" "@scure/base" "~1.1.0" +"@scure/bip32@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.4.0.tgz#4e1f1e196abedcef395b33b9674a042524e20d67" + integrity sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg== + dependencies: + "@noble/curves" "~1.4.0" + "@noble/hashes" "~1.4.0" + "@scure/base" "~1.1.6" + "@scure/bip39@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.1.0.tgz#92f11d095bae025f166bef3defcc5bf4945d419a" @@ -2223,6 +2261,14 @@ "@noble/hashes" "~1.3.0" "@scure/base" "~1.1.0" +"@scure/bip39@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.4.0.tgz#664d4f851564e2e1d4bffa0339f9546ea55960a6" + integrity sha512-BEEm6p8IueV/ZTfQLp/0vhw4NPnT9oWf5+28nvmeUICjP99f4vr2d+qc7AVGDDtwRep6ifR43Yed9ERVmiITzw== + dependencies: + "@noble/hashes" "~1.5.0" + "@scure/base" "~1.1.8" + "@sentry/core@5.30.0": version "5.30.0" resolved "https://registry.yarnpkg.com/@sentry/core/-/core-5.30.0.tgz#6b203664f69e75106ee8b5a2fe1d717379b331f3" @@ -3665,6 +3711,11 @@ abbrev@1.0.x: web3-eth-abi "^1.2.1" web3-utils "^1.2.1" +abitype@1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/abitype/-/abitype-1.0.5.tgz#29d0daa3eea867ca90f7e4123144c1d1270774b6" + integrity sha512-YzDhti7cjlfaBhHutMaboYB21Ha3rXR9QTkNJFzYC4kC8YclaiwPBBBJY8ejFdu2wnJeZCVZSMlQJ7fi8S6hsw== + abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" @@ -9622,6 +9673,11 @@ isomorphic-ws@^4.0.1: resolved "https://registry.yarnpkg.com/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz#55fd4cd6c5e6491e76dc125938dd863f5cd4f2dc" integrity sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w== +isows@1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/isows/-/isows-1.0.4.tgz#810cd0d90cc4995c26395d2aa4cfa4037ebdf061" + integrity sha512-hEzjY+x9u9hPmBom9IIAqdJCwNLax+xrPb51vEPpERoFlIxgmZcHzsT5jKG06nvInKOBGvReAVz80Umed5CczQ== + isstream@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" @@ -15055,6 +15111,21 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" +viem@^2.21.15: + version "2.21.15" + resolved "https://registry.yarnpkg.com/viem/-/viem-2.21.15.tgz#068c010946151e7f256bb7da601ab9b8287c583b" + integrity sha512-Ae05NQzMsqPWRwuAHf1OfmL0SjI+1GBgiFB0JA9BAbK/61nJXsTPsQxfV5CbLe4c3ct8IEZTX89rdeW4dqf97g== + dependencies: + "@adraffy/ens-normalize" "1.10.0" + "@noble/curves" "1.4.0" + "@noble/hashes" "1.4.0" + "@scure/bip32" "1.4.0" + "@scure/bip39" "1.4.0" + abitype "1.0.5" + isows "1.0.4" + webauthn-p256 "0.0.5" + ws "8.17.1" + vlq@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/vlq/-/vlq-2.0.4.tgz#6057b85729245b9829e3cc7755f95b228d4fe041" @@ -15548,6 +15619,14 @@ web3@1.8.2, web3@^1.6.0: web3-shh "1.8.2" web3-utils "1.8.2" +webauthn-p256@0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/webauthn-p256/-/webauthn-p256-0.0.5.tgz#0baebd2ba8a414b21cc09c0d40f9dd0be96a06bd" + integrity sha512-drMGNWKdaixZNobeORVIqq7k5DsRC9FnG201K2QjeOoQLmtSDaSsVZdkg6n5jUALJKcAG++zBPJXmv6hy0nWFg== + dependencies: + "@noble/curves" "^1.4.0" + "@noble/hashes" "^1.4.0" + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" @@ -15747,6 +15826,11 @@ ws@7.4.6: resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c" integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A== +ws@8.17.1: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" + integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== + ws@^3.0.0: version "3.3.3" resolved "https://registry.yarnpkg.com/ws/-/ws-3.3.3.tgz#f1cf84fe2d5e901ebce94efaece785f187a228f2"