diff --git a/e2e/oracle.e2e.ts b/e2e/oracle.e2e.ts deleted file mode 100644 index 360cfa6d..00000000 --- a/e2e/oracle.e2e.ts +++ /dev/null @@ -1,57 +0,0 @@ -// @note: This test is _not_ run automatically as part of git hooks or CI. -import dotenv from "dotenv"; -import winston from "winston"; -import { providers } from "ethers"; -import { getGasPriceEstimate } from "../src/gasPriceOracle"; -import { BigNumber, bnZero, parseUnits } from "../src/utils"; -import { expect, makeCustomTransport } from "../test/utils"; -dotenv.config({ path: ".env" }); - -const dummyLogger = winston.createLogger({ - level: "debug", - transports: [new winston.transports.Console()], -}); - -const stdLastBaseFeePerGas = parseUnits("12", 9); -const stdMaxPriorityFeePerGas = parseUnits("1", 9); // EIP-1559 chains only -const chainIds = [1, 10, 137, 324, 8453, 42161, 534352]; - -const customTransport = makeCustomTransport({ stdLastBaseFeePerGas, stdMaxPriorityFeePerGas }); - -const provider = new providers.StaticJsonRpcProvider("https://eth.llamarpc.com"); - -describe("Gas Price Oracle", function () { - it("Gas Price Retrieval", async function () { - 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}`, - maxFeePerGas, - maxPriorityFeePerGas, - }); - - expect(BigNumber.isBigNumber(maxFeePerGas)).to.be.true; - expect(BigNumber.isBigNumber(maxPriorityFeePerGas)).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 { - expect(maxFeePerGas.gt(bnZero)).to.be.true; - expect(maxPriorityFeePerGas.gt(bnZero)).to.be.true; - } - - delete process.env[chainKey]; - } - }); -}); diff --git a/package.json b/package.json index f1eefac8..daa54419 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@across-protocol/sdk", "author": "UMA Team", - "version": "3.3.32", + "version": "3.4.0", "license": "AGPL-3.0", "homepage": "https://docs.across.to/reference/sdk", "files": [ diff --git a/src/gasPriceOracle/adapters/arbitrum-viem.ts b/src/gasPriceOracle/adapters/arbitrum-viem.ts deleted file mode 100644 index fc920b33..00000000 --- a/src/gasPriceOracle/adapters/arbitrum-viem.ts +++ /dev/null @@ -1,13 +0,0 @@ -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/arbitrum.ts b/src/gasPriceOracle/adapters/arbitrum.ts index 50d0e997..7958dfc0 100644 --- a/src/gasPriceOracle/adapters/arbitrum.ts +++ b/src/gasPriceOracle/adapters/arbitrum.ts @@ -2,14 +2,21 @@ import { providers } from "ethers"; import { bnOne } from "../../utils"; import { GasPriceEstimate } from "../types"; import * as ethereum from "./ethereum"; +import { GasPriceEstimateOptions } from "../oracle"; -// Arbitrum Nitro implements EIP-1559 pricing, but the priority fee is always refunded to the caller. -// Reference: https://docs.arbitrum.io/how-arbitrum-works/gas-fees -export async function eip1559(provider: providers.Provider, chainId: number): Promise { - const { maxFeePerGas: _maxFeePerGas, maxPriorityFeePerGas } = await ethereum.eip1559(provider, chainId); +/** + * @notice Return Arbitrum orbit gas fees + * @dev Arbitrum Nitro implements EIP-1559 pricing, but the priority fee is always refunded to the caller. + * Reference: https://docs.arbitrum.io/how-arbitrum-works/gas-fees so we hardcode the priority fee + * to 1 wei. + * @param provider Ethers Provider + * @returns GasPriceEstimate + */ +export async function eip1559(provider: providers.Provider, opts: GasPriceEstimateOptions): Promise { + const { maxFeePerGas: _maxFeePerGas, maxPriorityFeePerGas } = await ethereum.eip1559(provider, opts); - // eip1559() sets maxFeePerGas = lastBaseFeePerGas + maxPriorityFeePerGas, so revert that. - // The caller may apply scaling as they wish afterwards. + // eip1559() sets maxFeePerGas = lastBaseFeePerGas + maxPriorityFeePerGas, so back out priority fee. + // The remaining maxFeePerGas should be scaled already. const maxFeePerGas = _maxFeePerGas.sub(maxPriorityFeePerGas).add(bnOne); return { maxPriorityFeePerGas: bnOne, maxFeePerGas }; diff --git a/src/gasPriceOracle/adapters/ethereum-viem.ts b/src/gasPriceOracle/adapters/ethereum-viem.ts deleted file mode 100644 index 050cadba..00000000 --- a/src/gasPriceOracle/adapters/ethereum-viem.ts +++ /dev/null @@ -1,19 +0,0 @@ -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/ethereum.ts b/src/gasPriceOracle/adapters/ethereum.ts index 19f87689..668c70f7 100644 --- a/src/gasPriceOracle/adapters/ethereum.ts +++ b/src/gasPriceOracle/adapters/ethereum.ts @@ -1,26 +1,40 @@ import assert from "assert"; import { providers } from "ethers"; -import { BigNumber, bnZero, getNetworkName } from "../../utils"; +import { BigNumber, bnZero, fixedPointAdjustment, getNetworkName } from "../../utils"; import { GasPriceEstimate } from "../types"; import { gasPriceError } from "../util"; +import { GasPriceEstimateOptions } from "../oracle"; + +// TODO: We intend to remove `eip1559Bad()` as an option and make eip1559Raw the only option eventually. The reason +// they both exist currently is because eip1559Raw is new and untested on production so we will slowly roll it out +// by using the convenient environment variable safety guard. /** + * @dev If GAS_PRICE_EIP1559_RAW_${chainId}=true, then constructs total fee by adding + * eth_getBlock("pending").baseFee to eth_maxPriorityFeePerGas, otherwise calls the ethers provider's + * getFeeData() method which adds eth_getBlock("latest").baseFee to a hardcoded priority fee of 1.5 gwei. * @param provider ethers RPC provider instance. - * @param chainId Chain ID of provider instance. * @returns Promise of gas price estimate object. */ -export function eip1559(provider: providers.Provider, chainId: number): Promise { - const useRaw = process.env[`GAS_PRICE_EIP1559_RAW_${chainId}`] === "true"; - return useRaw ? eip1559Raw(provider, chainId) : eip1559Bad(provider, chainId); +export function eip1559(provider: providers.Provider, opts: GasPriceEstimateOptions): Promise { + const useRaw = process.env[`GAS_PRICE_EIP1559_RAW_${opts.chainId}`] === "true"; + return useRaw + ? eip1559Raw(provider, opts.chainId, opts.baseFeeMultiplier) + : eip1559Bad(provider, opts.chainId, opts.baseFeeMultiplier); } /** * @note Performs direct RPC calls to retrieve the RPC-suggested priority fee for the next block. + * @dev Constructs total fee by adding eth_getBlock("pending").baseFee to eth_maxPriorityFeePerGas * @param provider ethers RPC provider instance. * @param chainId Chain ID of the provider instance. * @returns Promise of gas price estimate object. */ -export async function eip1559Raw(provider: providers.Provider, chainId: number): Promise { +export async function eip1559Raw( + provider: providers.Provider, + chainId: number, + baseFeeMultiplier: BigNumber +): Promise { const [{ baseFeePerGas }, _maxPriorityFeePerGas] = await Promise.all([ provider.getBlock("pending"), (provider as providers.JsonRpcProvider).send("eth_maxPriorityFeePerGas", []), @@ -28,19 +42,27 @@ export async function eip1559Raw(provider: providers.Provider, chainId: number): const maxPriorityFeePerGas = BigNumber.from(_maxPriorityFeePerGas); assert(BigNumber.isBigNumber(baseFeePerGas), `No baseFeePerGas received on ${getNetworkName(chainId)}`); + const scaledBaseFee = baseFeePerGas.mul(baseFeeMultiplier).div(fixedPointAdjustment); return { - maxFeePerGas: maxPriorityFeePerGas.add(baseFeePerGas), + maxFeePerGas: maxPriorityFeePerGas.add(scaledBaseFee), maxPriorityFeePerGas, }; } /** - * @note Resolves priority gas pricing poorly, because the priority fee is hardcoded to 1.5 Gwei in ethers v5. + * @notice Returns fee data using provider's getFeeData() method. + * @note Resolves priority gas pricing poorly, because the priority fee is hardcoded to 1.5 Gwei in ethers v5's + * getFeeData() method + * @dev TODO: Remove this function soon. See note above about slowly rolling out eip1559Raw. * @param provider ethers RPC provider instance. * @param chainId Chain ID of the provider instance. * @returns Promise of gas price estimate object. */ -export async function eip1559Bad(provider: providers.Provider, chainId: number): Promise { +export async function eip1559Bad( + provider: providers.Provider, + chainId: number, + baseFeeMultiplier: BigNumber +): Promise { const feeData = await provider.getFeeData(); [feeData.lastBaseFeePerGas, feeData.maxPriorityFeePerGas].forEach((field: BigNumber | null) => { @@ -48,18 +70,27 @@ export async function eip1559Bad(provider: providers.Provider, chainId: number): }); const maxPriorityFeePerGas = feeData.maxPriorityFeePerGas as BigNumber; - const maxFeePerGas = maxPriorityFeePerGas.add(feeData.lastBaseFeePerGas as BigNumber); + const scaledLastBaseFeePerGas = (feeData.lastBaseFeePerGas as BigNumber) + .mul(baseFeeMultiplier) + .div(fixedPointAdjustment); + const maxFeePerGas = maxPriorityFeePerGas.add(scaledLastBaseFeePerGas); return { maxPriorityFeePerGas, maxFeePerGas }; } -export async function legacy(provider: providers.Provider, chainId: number): Promise { +/** + * @notice Returns result of eth_gasPrice RPC call + * @dev Its recommended to use the eip1559Raw method over this one where possible as it will be more accurate. + * @returns GasPriceEstimate + */ +export async function legacy(provider: providers.Provider, opts: GasPriceEstimateOptions): Promise { + const { chainId, baseFeeMultiplier } = opts; const gasPrice = await provider.getGasPrice(); if (!BigNumber.isBigNumber(gasPrice) || gasPrice.lt(bnZero)) gasPriceError("getGasPrice()", chainId, gasPrice); return { - maxFeePerGas: gasPrice, + maxFeePerGas: gasPrice.mul(baseFeeMultiplier).div(fixedPointAdjustment), maxPriorityFeePerGas: bnZero, }; } diff --git a/src/gasPriceOracle/adapters/linea-viem.ts b/src/gasPriceOracle/adapters/linea-viem.ts index b642604a..68df19af 100644 --- a/src/gasPriceOracle/adapters/linea-viem.ts +++ b/src/gasPriceOracle/adapters/linea-viem.ts @@ -1,14 +1,40 @@ -import { PublicClient } from "viem"; +import { Address, Hex, PublicClient } from "viem"; import { estimateGas } from "viem/linea"; import { DEFAULT_SIMULATED_RELAYER_ADDRESS as account } from "../../constants"; import { InternalGasPriceEstimate } from "../types"; +import { GasPriceEstimateOptions } from "../oracle"; +import { fixedPointAdjustment } from "../../utils"; -export async function eip1559(provider: PublicClient, _chainId?: number): Promise { - const { baseFeePerGas, priorityFeePerGas } = await estimateGas(provider, { - account, - to: account, - value: BigInt(1), +/** + * @notice The Linea viem provider calls the linea_estimateGas RPC endpoint to estimate gas. Linea is unique + * in that the recommended fee per gas is hardcoded to 7 wei while the priority fee is dynamic based on the + * compressed transaction size, layer 1 verification costs and capacity, gas price ratio between layer 1 and layer 2, + * the transaction's gas usage, the minimum gas price on layer 2, + * and a minimum margin (for error) for gas price estimation. + * Source: https://docs.linea.build/get-started/how-to/gas-fees#how-gas-works-on-linea + * @dev Because the Linea priority fee is more volatile than the base fee, the base fee multiplier will be applied + * to the priority fee. + * @param provider Viem PublicClient + * @param _chainId Unused in this adapter + * @param baseFeeMultiplier Amount to multiply priority fee, since Linea's base fee is hardcoded while its priority + * fee is dynamic + * @param _unsignedTx Should contain any params passed to linea_estimateGas, which are listed + * here: https://docs.linea.build/api/reference/linea-estimategas#parameters + * @returns + */ +export async function eip1559( + provider: PublicClient, + opts: GasPriceEstimateOptions +): Promise { + const { unsignedTx, baseFeeMultiplier } = opts; + const { baseFeePerGas, priorityFeePerGas: _priorityFeePerGas } = await estimateGas(provider, { + account: (unsignedTx?.from as Address) ?? account, + to: (unsignedTx?.to as Address) ?? account, + value: BigInt(unsignedTx?.value?.toString() ?? "1"), + data: (unsignedTx?.data as Hex) ?? "0x", }); + const priorityFeePerGas = + (_priorityFeePerGas * BigInt(baseFeeMultiplier.toString())) / BigInt(fixedPointAdjustment.toString()); return { maxFeePerGas: baseFeePerGas + priorityFeePerGas, diff --git a/src/gasPriceOracle/adapters/linea.ts b/src/gasPriceOracle/adapters/linea.ts index e87ec5df..e3c3f6de 100644 --- a/src/gasPriceOracle/adapters/linea.ts +++ b/src/gasPriceOracle/adapters/linea.ts @@ -5,7 +5,12 @@ import { providers } from "ethers"; import { GasPriceEstimate } from "../types"; import * as ethereum from "./ethereum"; +import { GasPriceEstimateOptions } from "../oracle"; -export function eip1559(provider: providers.Provider, chainId: number): Promise { - return ethereum.legacy(provider, chainId); +export function eip1559(provider: providers.Provider, opts: GasPriceEstimateOptions): Promise { + // We use the legacy method to call `eth_gasPrice` which empirically returns a more accurate + // gas price estimate than `eth_maxPriorityFeePerGas` or ethersProvider.getFeeData in the EIP1559 "raw" or "bad" + // cases. Based on testing, `eth_gasPrice` returns the closest price to the Linea-specific `linea_estimateGas` + // endpoint which the Viem Linea adapter queries. + return ethereum.legacy(provider, opts); } diff --git a/src/gasPriceOracle/adapters/polygon-viem.ts b/src/gasPriceOracle/adapters/polygon-viem.ts deleted file mode 100644 index c1ea717d..00000000 --- a/src/gasPriceOracle/adapters/polygon-viem.ts +++ /dev/null @@ -1,86 +0,0 @@ -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/adapters/polygon.ts b/src/gasPriceOracle/adapters/polygon.ts index 9b8c93ea..a3a6bee6 100644 --- a/src/gasPriceOracle/adapters/polygon.ts +++ b/src/gasPriceOracle/adapters/polygon.ts @@ -1,10 +1,11 @@ import { providers } from "ethers"; import { BaseHTTPAdapter, BaseHTTPAdapterArgs } from "../../priceClient/adapters/baseAdapter"; -import { BigNumber, bnZero, isDefined, parseUnits } from "../../utils"; +import { BigNumber, bnZero, fixedPointAdjustment, isDefined, parseUnits } from "../../utils"; import { CHAIN_IDs } from "../../constants"; import { GasPriceEstimate } from "../types"; import { gasPriceError } from "../util"; import { eip1559 } from "./ethereum"; +import { GasPriceEstimateOptions } from "../oracle"; type Polygon1559GasPrice = { maxPriorityFee: number | string; @@ -27,7 +28,7 @@ type GasStationArgs = BaseHTTPAdapterArgs & { const { POLYGON } = CHAIN_IDs; -class PolygonGasStation extends BaseHTTPAdapter { +export class PolygonGasStation extends BaseHTTPAdapter { readonly chainId: number; constructor({ chainId = POLYGON, host, timeout = 1500, retries = 1 }: GasStationArgs = {}) { @@ -67,15 +68,55 @@ class PolygonGasStation extends BaseHTTPAdapter { } } -export async function gasStation(provider: providers.Provider, chainId: number): Promise { - const gasStation = new PolygonGasStation({ chainId: chainId, timeout: 2000, retries: 0 }); +class MockRevertingPolygonGasStation extends PolygonGasStation { + getFeeData(): Promise { + throw new Error(); + } +} + +export const MockPolygonGasStationBaseFee = () => parseUnits("12", 9); +export const MockPolygonGasStationPriorityFee = () => parseUnits("1", 9); + +class MockPolygonGasStation extends PolygonGasStation { + getFeeData(): Promise { + return Promise.resolve({ + maxPriorityFeePerGas: MockPolygonGasStationPriorityFee(), + maxFeePerGas: MockPolygonGasStationBaseFee().add(MockPolygonGasStationPriorityFee()), + }); + } +} + +/** + * @notice Returns the gas price suggested by the Polygon GasStation API or reconstructs it using + * the eip1559() method as a fallback. + * @param provider Ethers Provider. + * @returns GasPriceEstimate + */ +export async function gasStation( + provider: providers.Provider, + opts: GasPriceEstimateOptions +): Promise { + const { chainId, baseFeeMultiplier } = opts; + let gasStation: PolygonGasStation; + if (process.env.TEST_POLYGON_GAS_STATION === "true") { + gasStation = new MockPolygonGasStation(); + } else if (process.env.TEST_REVERTING_POLYGON_GAS_STATION === "true") { + gasStation = new MockRevertingPolygonGasStation(); + } else { + gasStation = new PolygonGasStation({ chainId: chainId, timeout: 2000, retries: 0 }); + } let maxPriorityFeePerGas: BigNumber; let maxFeePerGas: BigNumber; try { ({ maxPriorityFeePerGas, maxFeePerGas } = await gasStation.getFeeData()); + // Assume that the maxFeePerGas already includes the priority fee, so back out the priority fee before applying + // the baseFeeMultiplier. + const baseFeeMinusPriorityFee = maxFeePerGas.sub(maxPriorityFeePerGas); + const scaledBaseFee = baseFeeMinusPriorityFee.mul(baseFeeMultiplier).div(fixedPointAdjustment); + maxFeePerGas = scaledBaseFee.add(maxPriorityFeePerGas); } catch (err) { // Fall back to the RPC provider. May be less accurate. - ({ maxPriorityFeePerGas, maxFeePerGas } = await eip1559(provider, chainId)); + ({ maxPriorityFeePerGas, maxFeePerGas } = await eip1559(provider, opts)); // Per the GasStation docs, the minimum priority fee on Polygon is 30 Gwei. // https://docs.polygon.technology/tools/gas/polygon-gas-station/#interpretation diff --git a/src/gasPriceOracle/oracle.ts b/src/gasPriceOracle/oracle.ts index 425192f6..96566f8a 100644 --- a/src/gasPriceOracle/oracle.ts +++ b/src/gasPriceOracle/oracle.ts @@ -1,37 +1,61 @@ import assert from "assert"; import { Transport } from "viem"; -import { providers } from "ethers"; +import { PopulatedTransaction, providers } from "ethers"; import { CHAIN_IDs } from "../constants"; -import { BigNumber, chainIsOPStack } from "../utils"; +import { BigNumber, chainIsOPStack, fixedPointAdjustment, toBNWei } 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 lineaViem from "./adapters/linea-viem"; -import * as polygonViem from "./adapters/polygon-viem"; + +export interface GasPriceEstimateOptions { + // baseFeeMultiplier Multiplier applied to base fee for EIP1559 gas prices (or total fee for legacy). + baseFeeMultiplier: BigNumber; + // legacyFallback In the case of an unrecognized chain, fall back to type 0 gas estimation. + legacyFallback: boolean; + // chainId The chain ID to query for gas prices. If omitted can be inferred by provider. + chainId: number; + // unsignedTx The unsigned transaction used for simulation by Linea's Viem provider to produce the priority gas fee. + unsignedTx?: PopulatedTransaction; + // transport Viem Transport object to use for querying gas fees used for testing. + transport?: Transport; +} + +const GAS_PRICE_ESTIMATE_DEFAULTS = { + legacyFallback: true, +}; /** * 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. + * @param {opts} GasPriceEstimateOptions optional parameters. + * @returns An object of type GasPriceEstimate. */ export async function getGasPriceEstimate( provider: providers.Provider, - chainId?: number, - transport?: Transport, - legacyFallback = true + opts: Partial = {} ): Promise { - chainId ?? ({ chainId } = await provider.getNetwork()); + const baseFeeMultiplier = opts.baseFeeMultiplier ?? toBNWei("1"); + assert( + baseFeeMultiplier.gte(toBNWei("1.0")) && baseFeeMultiplier.lte(toBNWei("5")), + `Require 1.0 < base fee multiplier (${baseFeeMultiplier}) <= 5.0 for a total gas multiplier within [+1.0, +5.0]` + ); + const chainId = opts.chainId ?? (await provider.getNetwork()).chainId; + const optsWithDefaults: GasPriceEstimateOptions = { + ...GAS_PRICE_ESTIMATE_DEFAULTS, + baseFeeMultiplier, + ...opts, + chainId, + }; + // We only use the unsignedTx in the viem flow. const useViem = process.env[`NEW_GAS_PRICE_ORACLE_${chainId}`] === "true"; return useViem - ? getViemGasPriceEstimate(chainId, transport) - : getEthersGasPriceEstimate(provider, chainId, legacyFallback); + ? _getViemGasPriceEstimate(chainId, optsWithDefaults) + : _getEthersGasPriceEstimate(provider, optsWithDefaults); } /** @@ -39,13 +63,14 @@ export async function getGasPriceEstimate( * @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. + * @returns An object of type GasPriceEstimate. */ -function getEthersGasPriceEstimate( +function _getEthersGasPriceEstimate( provider: providers.Provider, - chainId: number, - legacyFallback = true + opts: GasPriceEstimateOptions ): Promise { + const { chainId, legacyFallback } = opts; + const gasPriceFeeds = { [CHAIN_IDs.ALEPH_ZERO]: arbitrum.eip1559, [CHAIN_IDs.ARBITRUM]: arbitrum.eip1559, @@ -60,39 +85,39 @@ function getEthersGasPriceEstimate( assert(gasPriceFeed || legacyFallback, `No suitable gas price oracle for Chain ID ${chainId}`); gasPriceFeed ??= chainIsOPStack(chainId) ? ethereum.eip1559 : ethereum.legacy; - return gasPriceFeed(provider, chainId); + return gasPriceFeed(provider, opts); } /** * 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. + * @param transport An optional Viem Transport object for custom gas price retrieval. + * @param unsignedTx Only used in Linea provider to estimate priority gas fee. + * @returns An object of type GasPriceEstimate. */ -export async function getViemGasPriceEstimate( +export async function _getViemGasPriceEstimate( providerOrChainId: providers.Provider | number, - transport?: Transport + opts: GasPriceEstimateOptions ): Promise { + const { baseFeeMultiplier, transport } = opts; + 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.LINEA]: lineaViem.eip1559, - [CHAIN_IDs.POLYGON]: polygonViem.gasStation, - } as const; + }; let maxFeePerGas: bigint; let maxPriorityFeePerGas: bigint; if (gasPriceFeeds[chainId]) { - ({ maxFeePerGas, maxPriorityFeePerGas } = await gasPriceFeeds[chainId](viemProvider, chainId)); + ({ maxFeePerGas, maxPriorityFeePerGas } = await gasPriceFeeds[chainId](viemProvider, opts)); } else { let gasPrice: bigint | undefined; ({ maxFeePerGas, maxPriorityFeePerGas, gasPrice } = await viemProvider.estimateFeesPerGas()); - maxFeePerGas ??= gasPrice!; + maxFeePerGas ??= (gasPrice! * BigInt(baseFeeMultiplier.toString())) / BigInt(fixedPointAdjustment.toString()); maxPriorityFeePerGas ??= BigInt(0); } diff --git a/src/relayFeeCalculator/chain-queries/alephZero.ts b/src/relayFeeCalculator/chain-queries/alephZero.ts index 5f6ce699..0c7ff48a 100644 --- a/src/relayFeeCalculator/chain-queries/alephZero.ts +++ b/src/relayFeeCalculator/chain-queries/alephZero.ts @@ -1,36 +1,8 @@ -import assert from "assert"; -import { getDeployedAddress } from "../../utils/DeploymentUtils"; -import { DEFAULT_LOGGER, Logger } from "../relayFeeCalculator"; -import { providers } from "ethers"; -import { CHAIN_IDs, DEFAULT_SIMULATED_RELAYER_ADDRESS, TOKEN_SYMBOLS_MAP } from "../../constants"; +import { CHAIN_IDs } from "../../constants"; import { Coingecko } from "../../coingecko/Coingecko"; -import { isDefined } from "../../utils"; import { QueryBase } from "./baseQuery"; export class AlephZeroQueries extends QueryBase { - constructor( - provider: providers.Provider, - symbolMapping = TOKEN_SYMBOLS_MAP, - spokePoolAddress = getDeployedAddress("SpokePool", CHAIN_IDs.ALEPH_ZERO), - simulatedRelayerAddress = DEFAULT_SIMULATED_RELAYER_ADDRESS, - coingeckoProApiKey?: string, - logger: Logger = DEFAULT_LOGGER, - gasMarkup = 0 - ) { - assert(isDefined(spokePoolAddress)); - super( - provider, - symbolMapping, - spokePoolAddress, - simulatedRelayerAddress, - gasMarkup, - logger, - coingeckoProApiKey, - undefined, - "usd" - ); - } - override async getTokenPrice(tokenSymbol: string): Promise { if (!this.symbolMapping[tokenSymbol]) throw new Error(`${tokenSymbol} does not exist in mapping`); const coingeckoInstance = Coingecko.get(this.logger, this.coingeckoProApiKey); diff --git a/src/relayFeeCalculator/chain-queries/baseQuery.ts b/src/relayFeeCalculator/chain-queries/baseQuery.ts index 2109ae29..8e493645 100644 --- a/src/relayFeeCalculator/chain-queries/baseQuery.ts +++ b/src/relayFeeCalculator/chain-queries/baseQuery.ts @@ -1,6 +1,7 @@ import { L2Provider } from "@eth-optimism/sdk/dist/interfaces/l2-provider"; -import { providers } from "ethers"; -import assert from "assert"; +import { isL2Provider as isOptimismL2Provider } from "@eth-optimism/sdk/dist/l2-provider"; + +import { PopulatedTransaction, providers, VoidSigner } from "ethers"; import { Coingecko } from "../../coingecko"; import { CHAIN_IDs, DEFAULT_SIMULATED_RELAYER_ADDRESS } from "../../constants"; import { Deposit } from "../../interfaces"; @@ -8,14 +9,16 @@ import { SpokePool, SpokePool__factory } from "../../typechain"; import { BigNumberish, TransactionCostEstimate, - estimateTotalGasRequiredByUnsignedTransaction, - fixedPointAdjustment, populateV3Relay, + BigNumber, toBNWei, + bnZero, + assert, + chainIsOPStack, } from "../../utils"; import { Logger, QueryInterface } from "../relayFeeCalculator"; import { Transport } from "viem"; - +import { getGasPriceEstimate } from "../../gasPriceOracle/oracle"; type Provider = providers.Provider; type OptimismProvider = L2Provider; type SymbolMappingType = Record< @@ -38,7 +41,6 @@ export class QueryBase implements QueryInterface { * @param symbolMapping A mapping to valid ERC20 tokens and their respective characteristics * @param spokePoolAddress The valid address of the Spoke Pool deployment * @param simulatedRelayerAddress The address that these queries will reference as the sender. Note: This address must be approved for USDC - * @param gasMarkup A multiplier that is applied to the total gas estimate * @param logger A logging utility to report logs * @param coingeckoProApiKey An optional CoinGecko API key that links to a PRO account * @param fixedGasPrice Overrides the gas price with a fixed value. Note: primarily used for the Boba blockchain @@ -49,7 +51,6 @@ export class QueryBase implements QueryInterface { readonly symbolMapping: SymbolMappingType, readonly spokePoolAddress: string, readonly simulatedRelayerAddress: string, - readonly gasMarkup: number, readonly logger: Logger, readonly coingeckoProApiKey?: string, readonly fixedGasPrice?: BigNumberish, @@ -65,7 +66,6 @@ export class QueryBase implements QueryInterface { * @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). */ @@ -75,37 +75,90 @@ export class QueryBase implements QueryInterface { options: Partial<{ gasPrice: BigNumberish; gasUnits: BigNumberish; - omitMarkup: boolean; + baseFeeMultiplier: BigNumber; transport: Transport; }> = {} ): Promise { - const { gasPrice = this.fixedGasPrice, gasUnits, omitMarkup, transport } = options; - - const gasMarkup = omitMarkup ? 0 : this.gasMarkup; - assert( - gasMarkup > -1 && gasMarkup <= 4, - `Require -1.0 < Gas Markup (${gasMarkup}) <= 4.0 for a total gas multiplier within (0, +5.0]` - ); - const gasTotalMultiplier = toBNWei(1.0 + gasMarkup); + const { gasPrice = this.fixedGasPrice, gasUnits, baseFeeMultiplier, transport } = options; const tx = await populateV3Relay(this.spokePool, deposit, relayer); const { nativeGasCost, tokenGasCost, gasPrice: impliedGasPrice, - } = await estimateTotalGasRequiredByUnsignedTransaction(tx, relayer, this.provider, { + } = await this.estimateGas(tx, relayer, this.provider, { gasPrice, gasUnits, + baseFeeMultiplier, transport, }); return { - nativeGasCost: nativeGasCost.mul(gasTotalMultiplier).div(fixedPointAdjustment), - tokenGasCost: tokenGasCost.mul(gasTotalMultiplier).div(fixedPointAdjustment), + nativeGasCost, + tokenGasCost, gasPrice: impliedGasPrice, }; } + /** + * Estimates the total gas cost required to submit an unsigned (populated) transaction on-chain. + * @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 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). + */ + async estimateGas( + unsignedTx: PopulatedTransaction, + senderAddress: string, + provider: providers.Provider | L2Provider, + options: Partial<{ + gasPrice: BigNumberish; + gasUnits: BigNumberish; + baseFeeMultiplier: BigNumber; + transport: Transport; + }> = {} + ): Promise { + const { gasPrice: _gasPrice, gasUnits, baseFeeMultiplier = toBNWei("1"), transport } = options || {}; + + const { chainId } = await provider.getNetwork(); + const voidSigner = new VoidSigner(senderAddress, provider); + + // Estimate the Gas units required to submit this transaction. + const queries = [ + gasUnits ? Promise.resolve(BigNumber.from(gasUnits)) : voidSigner.estimateGas(unsignedTx), + _gasPrice + ? Promise.resolve({ maxFeePerGas: _gasPrice }) + : getGasPriceEstimate(provider, { chainId, baseFeeMultiplier, transport, unsignedTx }), + ] as const; + const [nativeGasCost, { maxFeePerGas: gasPrice }] = await Promise.all(queries); + assert(nativeGasCost.gt(bnZero), "Gas cost should not be 0"); + let tokenGasCost: BigNumber; + + // OP stack is a special case; gas cost is computed by the SDK, without having to query price. + if (chainIsOPStack(chainId)) { + assert(isOptimismL2Provider(provider), `Unexpected provider for chain ID ${chainId}.`); + const populatedTransaction = await voidSigner.populateTransaction({ + ...unsignedTx, + gasLimit: nativeGasCost, // prevents additional gas estimation call + }); + const l1GasCost = await provider.estimateL1GasCost(populatedTransaction); + const l2GasCost = nativeGasCost.mul(gasPrice); + tokenGasCost = l1GasCost.add(l2GasCost); + } else { + tokenGasCost = nativeGasCost.mul(gasPrice); + } + + return { + nativeGasCost, // Units: gas + tokenGasCost, // Units: wei (nativeGasCost * wei/gas) + gasPrice: tokenGasCost.div(nativeGasCost), // Units: wei/gas + }; + } + /** * Retrieves the current price of a token * @param tokenSymbol A valid [CoinGecko-ID](https://api.coingecko.com/api/v3/coins/list) diff --git a/src/relayFeeCalculator/chain-queries/factory.ts b/src/relayFeeCalculator/chain-queries/factory.ts index 00a1051f..9bfee85f 100644 --- a/src/relayFeeCalculator/chain-queries/factory.ts +++ b/src/relayFeeCalculator/chain-queries/factory.ts @@ -27,7 +27,6 @@ export class QueryBase__factory { simulatedRelayerAddress = DEFAULT_SIMULATED_RELAYER_ADDRESS, coingeckoProApiKey?: string, logger: Logger = DEFAULT_LOGGER, - gasMarkup = 0, coingeckoBaseCurrency = "eth" ): QueryBase { assert(isDefined(spokePoolAddress)); @@ -38,9 +37,10 @@ export class QueryBase__factory { symbolMapping, spokePoolAddress, simulatedRelayerAddress, - coingeckoProApiKey, logger, - gasMarkup + coingeckoProApiKey, + fixedGasPrice[chainId], + "usd" ); } @@ -50,9 +50,10 @@ export class QueryBase__factory { symbolMapping, spokePoolAddress, simulatedRelayerAddress, - coingeckoProApiKey, logger, - gasMarkup + coingeckoProApiKey, + fixedGasPrice[chainId], + "usd" ); } @@ -64,7 +65,6 @@ export class QueryBase__factory { symbolMapping, spokePoolAddress, simulatedRelayerAddress, - gasMarkup, logger, coingeckoProApiKey, fixedGasPrice[chainId], diff --git a/src/relayFeeCalculator/chain-queries/polygon.ts b/src/relayFeeCalculator/chain-queries/polygon.ts index 10e29b38..29e1fa07 100644 --- a/src/relayFeeCalculator/chain-queries/polygon.ts +++ b/src/relayFeeCalculator/chain-queries/polygon.ts @@ -1,36 +1,8 @@ -import assert from "assert"; -import { getDeployedAddress } from "../../utils/DeploymentUtils"; -import { DEFAULT_LOGGER, Logger } from "../relayFeeCalculator"; -import { providers } from "ethers"; -import { CHAIN_IDs, DEFAULT_SIMULATED_RELAYER_ADDRESS, TOKEN_SYMBOLS_MAP } from "../../constants"; +import { CHAIN_IDs } from "../../constants"; import { Coingecko } from "../../coingecko/Coingecko"; -import { isDefined } from "../../utils"; import { QueryBase } from "./baseQuery"; export class PolygonQueries extends QueryBase { - constructor( - provider: providers.Provider, - symbolMapping = TOKEN_SYMBOLS_MAP, - spokePoolAddress = getDeployedAddress("SpokePool", CHAIN_IDs.POLYGON), - simulatedRelayerAddress = DEFAULT_SIMULATED_RELAYER_ADDRESS, - coingeckoProApiKey?: string, - logger: Logger = DEFAULT_LOGGER, - gasMarkup = 0 - ) { - assert(isDefined(spokePoolAddress)); - super( - provider, - symbolMapping, - spokePoolAddress, - simulatedRelayerAddress, - gasMarkup, - logger, - coingeckoProApiKey, - undefined, - "usd" - ); - } - override async getTokenPrice(tokenSymbol: string): Promise { if (!this.symbolMapping[tokenSymbol]) throw new Error(`${tokenSymbol} does not exist in mapping`); const coingeckoInstance = Coingecko.get(this.logger, this.coingeckoProApiKey); diff --git a/src/relayFeeCalculator/relayFeeCalculator.ts b/src/relayFeeCalculator/relayFeeCalculator.ts index 8974d303..d3922818 100644 --- a/src/relayFeeCalculator/relayFeeCalculator.ts +++ b/src/relayFeeCalculator/relayFeeCalculator.ts @@ -24,7 +24,12 @@ export interface QueryInterface { getGasCosts: ( deposit: Deposit, relayer: string, - options?: Partial<{ gasPrice: BigNumberish; gasUnits: BigNumberish; omitMarkup: boolean; transport: Transport }> + options?: Partial<{ + gasPrice: BigNumberish; + gasUnits: BigNumberish; + baseFeeMultiplier: BigNumber; + transport: Transport; + }> ) => Promise; getTokenPrice: (tokenSymbol: string) => Promise; getTokenDecimals: (tokenSymbol: string) => number; diff --git a/src/utils/common.ts b/src/utils/common.ts index 3b59362b..3b5c0337 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,16 +1,7 @@ -import { L2Provider } from "@eth-optimism/sdk/dist/interfaces/l2-provider"; -import { isL2Provider as isOptimismL2Provider } from "@eth-optimism/sdk/dist/l2-provider"; -import assert from "assert"; import Decimal from "decimal.js"; -import { ethers, PopulatedTransaction, providers, VoidSigner } from "ethers"; -import { getGasPriceEstimate } from "../gasPriceOracle"; -import { BigNumber, BigNumberish, BN, bnZero, formatUnits, parseUnits, toBN } from "./BigNumberUtils"; +import { ethers } from "ethers"; +import { BigNumber, BigNumberish, BN, formatUnits, parseUnits, toBN } from "./BigNumberUtils"; import { ConvertDecimals } from "./FormattingUtils"; -import { chainIsOPStack } from "./NetworkUtils"; -import { Address, Transport } from "viem"; -import { CHAIN_IDs } from "@across-protocol/constants"; -import { estimateGas } from "viem/linea"; -import { getPublicClient } from "../gasPriceOracle/util"; export type Decimalish = string | number | Decimal; export const AddressZero = ethers.constants.AddressZero; @@ -238,87 +229,6 @@ export type TransactionCostEstimate = { gasPrice: BigNumber; // Units: wei/gas }; -/** - * Estimates the total gas cost required to submit an unsigned (populated) transaction on-chain. - * @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 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, - 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); - - // Estimate the Gas units required to submit this transaction. - const queries = [ - gasUnits ? Promise.resolve(BigNumber.from(gasUnits)) : voidSigner.estimateGas(unsignedTx), - _gasPrice ? Promise.resolve({ maxFeePerGas: _gasPrice }) : getGasPriceEstimate(provider, chainId, transport), - ] as const; - let [nativeGasCost, { maxFeePerGas: gasPrice }] = await Promise.all(queries); - assert(nativeGasCost.gt(bnZero), "Gas cost should not be 0"); - let tokenGasCost: BigNumber; - - // OP stack is a special case; gas cost is computed by the SDK, without having to query price. - if (chainIsOPStack(chainId)) { - assert(isOptimismL2Provider(provider), `Unexpected provider for chain ID ${chainId}.`); - const populatedTransaction = await voidSigner.populateTransaction({ - ...unsignedTx, - gasLimit: nativeGasCost, // prevents additional gas estimation call - }); - const l1GasCost = await provider.estimateL1GasCost(populatedTransaction); - const l2GasCost = nativeGasCost.mul(gasPrice); - tokenGasCost = l1GasCost.add(l2GasCost); - } else { - if (chainId === CHAIN_IDs.LINEA && process.env[`NEW_GAS_PRICE_ORACLE_${chainId}`] === "true") { - // Permit linea_estimateGas via NEW_GAS_PRICE_ORACLE_59144=true - let baseFeePerGas: BigNumber, priorityFeePerGas: BigNumber; - ({ - gasLimit: nativeGasCost, - baseFeePerGas, - priorityFeePerGas, - } = await getLineaGasFees(chainId, transport, unsignedTx)); - gasPrice = baseFeePerGas.add(priorityFeePerGas); - } - - tokenGasCost = nativeGasCost.mul(gasPrice); - } - - return { - nativeGasCost, // Units: gas - tokenGasCost, // Units: wei (nativeGasCost * wei/gas) - gasPrice: tokenGasCost.div(nativeGasCost), // Units: wei/gas - }; -} - -async function getLineaGasFees(chainId: number, transport: Transport | undefined, unsignedTx: PopulatedTransaction) { - const { gasLimit, baseFeePerGas, priorityFeePerGas } = await estimateGas(getPublicClient(chainId, transport), { - account: unsignedTx.from as Address, - to: unsignedTx.to as Address, - value: BigInt(unsignedTx.value?.toString() || "1"), - }); - - return { - gasLimit: BigNumber.from(gasLimit.toString()), - baseFeePerGas: BigNumber.from(baseFeePerGas.toString()), - priorityFeePerGas: BigNumber.from(priorityFeePerGas.toString()), - }; -} - export function randomAddress() { return ethers.utils.getAddress(ethers.utils.hexlify(ethers.utils.randomBytes(20))); } diff --git a/test/GasPriceOracle.test.ts b/test/GasPriceOracle.test.ts new file mode 100644 index 00000000..43a3e5e1 --- /dev/null +++ b/test/GasPriceOracle.test.ts @@ -0,0 +1,229 @@ +// @note: This test is more of an e2e test because the Ethers provider tests send real RPC requests +// but I wanted to include it in the unit tests to prevent regressions. We should create mocked +// providers and API's to avoid the API requests. + +import dotenv from "dotenv"; +import { encodeFunctionData } from "viem"; +import { getGasPriceEstimate } from "../src/gasPriceOracle"; +import { BigNumber, bnZero, fixedPointAdjustment, parseUnits, toBNWei } from "../src/utils"; +import { assertPromiseError, expect, makeCustomTransport, randomAddress } from "../test/utils"; +import { MockedProvider } from "./utils/provider"; +import { MockPolygonGasStationBaseFee, MockPolygonGasStationPriorityFee } from "../src/gasPriceOracle/adapters/polygon"; +dotenv.config({ path: ".env" }); + +const stdLastBaseFeePerGas = parseUnits("12", 9); +const stdMaxPriorityFeePerGas = parseUnits("1", 9); // EIP-1559 chains only +const expectedLineaMaxFeePerGas = BigNumber.from("7"); +const legacyChainIds = [324, 59144, 534352]; +const arbOrbitChainIds = [42161, 41455]; +const ethersProviderChainIds = [10, 8453, ...legacyChainIds, ...arbOrbitChainIds]; + +const customTransport = makeCustomTransport({ stdLastBaseFeePerGas, stdMaxPriorityFeePerGas }); + +const provider = new MockedProvider(stdLastBaseFeePerGas, stdMaxPriorityFeePerGas); + +const ERC20ABI = [ + { + inputs: [ + { name: "to", type: "address" }, + { name: "amount", type: "uint256" }, + ], + name: "transfer", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, +]; +const erc20TransferTransactionObject = encodeFunctionData({ + abi: ERC20ABI, + functionName: "transfer", + args: [randomAddress(), 1n], +}); + +describe("Gas Price Oracle", function () { + it("baseFeeMultiplier is validated", async function () { + // Too low: + await assertPromiseError( + getGasPriceEstimate(provider, { + chainId: 1, + baseFeeMultiplier: toBNWei("0.5"), + }), + "base fee multiplier" + ); + // Too high: + await assertPromiseError( + getGasPriceEstimate(provider, { + chainId: 1, + baseFeeMultiplier: toBNWei("5.5"), + }), + "base fee multiplier" + ); + }); + it("Linea Viem gas price retrieval with unsignedTx", async function () { + const chainId = 59144; + const chainKey = `NEW_GAS_PRICE_ORACLE_${chainId}`; + process.env[chainKey] = "true"; + const unsignedTx = { + to: randomAddress(), + from: randomAddress(), + value: bnZero, + data: erc20TransferTransactionObject, + }; + const { maxFeePerGas, maxPriorityFeePerGas } = await getGasPriceEstimate(provider, { + chainId, + transport: customTransport, + unsignedTx, + baseFeeMultiplier: toBNWei("2.0"), + }); + + // For Linea, base fee is expected to be hardcoded and unaffected by the base fee multiplier while + // the priority fee gets scaled. + // Additionally, test that the unsignedTx with a non-empty data field gets passed into the + // Linea viem provider. We've mocked the customTransport to double the priority fee if + // the unsigned tx object has non-empty data + const expectedPriorityFee = stdMaxPriorityFeePerGas.mul(4.0); + expect(maxFeePerGas).to.equal(expectedLineaMaxFeePerGas.add(expectedPriorityFee)); + expect(maxPriorityFeePerGas).to.equal(expectedPriorityFee); + delete process.env[chainKey]; + }); + it("Linea Viem gas price retrieval", async function () { + const chainId = 59144; + const chainKey = `NEW_GAS_PRICE_ORACLE_${chainId}`; + process.env[chainKey] = "true"; + const { maxFeePerGas, maxPriorityFeePerGas } = await getGasPriceEstimate(provider, { + chainId, + transport: customTransport, + baseFeeMultiplier: toBNWei("2.0"), + }); + + // For Linea, base fee is expected to be hardcoded and unaffected by the base fee multiplier while + // the priority fee gets scaled. + const expectedPriorityFee = stdMaxPriorityFeePerGas.mul(2.0); + expect(maxFeePerGas).to.equal(expectedLineaMaxFeePerGas.add(expectedPriorityFee)); + expect(maxPriorityFeePerGas).to.equal(expectedPriorityFee); + delete process.env[chainKey]; + }); + it("Ethers gas price retrieval", async function () { + const baseFeeMultiplier = toBNWei("2.0"); + for (const chainId of ethersProviderChainIds) { + const { maxFeePerGas: markedUpMaxFeePerGas, maxPriorityFeePerGas: markedUpMaxPriorityFeePerGas } = + await getGasPriceEstimate(provider, { chainId, baseFeeMultiplier }); + + // Base fee for EIP1559 gas price feeds should be multiplied by multiplier. + // Returned max fee includes priority fee so back it out. + const expectedMarkedUpMaxFeePerGas = stdLastBaseFeePerGas.mul(2); + + if (arbOrbitChainIds.includes(chainId)) { + expect(markedUpMaxFeePerGas.sub(markedUpMaxPriorityFeePerGas)).to.equal(expectedMarkedUpMaxFeePerGas); + // Arbitrum orbit priority fee should be 1 wei. + expect(markedUpMaxPriorityFeePerGas).to.equal(1); + } else if (legacyChainIds.includes(chainId)) { + // Scroll and ZkSync use legacy pricing so priority fee should be 0. + expect(markedUpMaxPriorityFeePerGas).to.equal(0); + // Legacy gas price = base fee + priority fee and full value is scaled + expect(markedUpMaxFeePerGas).to.equal(stdLastBaseFeePerGas.add(stdMaxPriorityFeePerGas).mul(2)); + } else { + expect(markedUpMaxFeePerGas.sub(markedUpMaxPriorityFeePerGas)).to.equal(expectedMarkedUpMaxFeePerGas); + // Priority fees should be unscaled + expect(markedUpMaxPriorityFeePerGas).to.equal(stdMaxPriorityFeePerGas); + } + } + }); + it("Ethers EIP1559 Raw", async function () { + const baseFeeMultiplier = toBNWei("2.0"); + const chainId = 1; + const chainKey = `GAS_PRICE_EIP1559_RAW_${chainId}`; + process.env[chainKey] = "true"; + + const { maxFeePerGas: markedUpMaxFeePerGas, maxPriorityFeePerGas: markedUpMaxPriorityFeePerGas } = + await getGasPriceEstimate(provider, { chainId, baseFeeMultiplier }); + + // Base fee should be multiplied by multiplier. Returned max fee includes priority fee + // so back it out before scaling. + const expectedMarkedUpMaxFeePerGas = stdLastBaseFeePerGas + .mul(baseFeeMultiplier) + .div(fixedPointAdjustment) + .add(stdMaxPriorityFeePerGas); + expect(markedUpMaxFeePerGas).to.equal(expectedMarkedUpMaxFeePerGas); + + // Priority fees should be the same + expect(markedUpMaxPriorityFeePerGas).to.equal(stdMaxPriorityFeePerGas); + delete process.env[chainKey]; + }); + it("Ethers EIP1559 Bad", async function () { + // This test should return identical results to the Raw test but it makes different + // provider calls, so we're really testing that the expected provider functions are called. + const baseFeeMultiplier = toBNWei("2.0"); + const chainId = 1; + + const { maxFeePerGas: markedUpMaxFeePerGas, maxPriorityFeePerGas: markedUpMaxPriorityFeePerGas } = + await getGasPriceEstimate(provider, { chainId, baseFeeMultiplier }); + + // Base fee should be multiplied by multiplier. Returned max fee includes priority fee + // so back it out before scaling. + const expectedMarkedUpMaxFeePerGas = stdLastBaseFeePerGas + .mul(baseFeeMultiplier) + .div(fixedPointAdjustment) + .add(stdMaxPriorityFeePerGas); + expect(markedUpMaxFeePerGas).to.equal(expectedMarkedUpMaxFeePerGas); + + // Priority fees should be the same + expect(markedUpMaxPriorityFeePerGas).to.equal(stdMaxPriorityFeePerGas); + }); + it("Ethers Legacy", async function () { + const baseFeeMultiplier = toBNWei("2.0"); + const chainId = 324; + + const { maxFeePerGas: markedUpMaxFeePerGas, maxPriorityFeePerGas: markedUpMaxPriorityFeePerGas } = + await getGasPriceEstimate(provider, { chainId, baseFeeMultiplier }); + + // Legacy gas price is equal to base fee + priority fee and the full amount + // should be multiplied since the RPC won't return the broken down fee. + const expectedGasPrice = stdLastBaseFeePerGas.add(stdMaxPriorityFeePerGas); + const expectedMarkedUpMaxFeePerGas = expectedGasPrice.mul(baseFeeMultiplier).div(fixedPointAdjustment); + expect(expectedMarkedUpMaxFeePerGas).to.equal(markedUpMaxFeePerGas); + + // Priority fees should be zero + expect(markedUpMaxPriorityFeePerGas).to.equal(0); + }); + it("Ethers Polygon GasStation", async function () { + const baseFeeMultiplier = toBNWei("2.0"); + process.env["TEST_POLYGON_GAS_STATION"] = "true"; + const chainId = 137; + const { maxFeePerGas, maxPriorityFeePerGas } = await getGasPriceEstimate(provider, { + chainId, + baseFeeMultiplier, + }); + + expect(maxFeePerGas).to.equal( + MockPolygonGasStationBaseFee() + .mul(baseFeeMultiplier) + .div(fixedPointAdjustment) + .add(MockPolygonGasStationPriorityFee()) + ); + expect(maxPriorityFeePerGas).to.equal(MockPolygonGasStationPriorityFee()); + delete process.env["TEST_POLYGON_GAS_STATION"]; + }); + it("Ethers Polygon GasStation: Fallback", async function () { + const baseFeeMultiplier = toBNWei("2.0"); + process.env["TEST_REVERTING_POLYGON_GAS_STATION"] = "true"; + const chainId = 137; + + // If GasStation getFeeData throws, then the Polygon gas price oracle adapter should fallback to the + // ethereum EIP1559 logic. There should be logic to ensure the priority fee gets floored at 30 gwei. + const { maxFeePerGas, maxPriorityFeePerGas } = await getGasPriceEstimate(provider, { + chainId, + baseFeeMultiplier, + }); + + const minPolygonPriorityFee = parseUnits("30", 9); + const expectedPriorityFee = stdMaxPriorityFeePerGas.gt(minPolygonPriorityFee) + ? stdMaxPriorityFeePerGas + : minPolygonPriorityFee; + expect(maxFeePerGas).to.equal( + stdLastBaseFeePerGas.mul(baseFeeMultiplier).div(fixedPointAdjustment).add(expectedPriorityFee) + ); + expect(maxPriorityFeePerGas).to.equal(expectedPriorityFee); + delete process.env["TEST_REVERTING_POLYGON_GAS_STATION"]; + }); +}); diff --git a/test/common.test.ts b/test/common.test.ts index 7b0e2cf9..6432d9f9 100644 --- a/test/common.test.ts +++ b/test/common.test.ts @@ -1,19 +1,5 @@ import assert from "assert"; -import { SpokePool, SpokePool__factory } from "@across-protocol/contracts"; -import dotenv from "dotenv"; -import { providers } from "ethers"; -import { DEFAULT_SIMULATED_RELAYER_ADDRESS } from "../src/constants"; -import { - estimateTotalGasRequiredByUnsignedTransaction, - fixedPointAdjustment, - populateV3Relay, - retry, - toBNWei, -} from "../src/utils"; -import { toBN } from "../src/utils/BigNumberUtils"; -import { buildDepositForRelayerFeeTest, expect } from "./utils"; - -dotenv.config(); +import { retry } from "../src/utils"; describe("Utils test", () => { it("retry", async () => { @@ -35,37 +21,4 @@ describe("Utils test", () => { assert.rejects(() => retry(failN(3), 2, 1)), ]); }); - - // Disabled because it relies on external RPC providers and has proven to be periodically flaky. - it.skip("apply gas multiplier", async () => { - const spokePoolAddress = "0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5"; // mainnet - const relayerAddress = DEFAULT_SIMULATED_RELAYER_ADDRESS; - - // @todo: Ensure that NODE_URL_1 is always defined in test CI? - const rpcUrl = process.env.NODE_URL_1 ?? "https://mainnet.gateway.tenderly.co"; - const provider = new providers.JsonRpcProvider(rpcUrl, 1); - const spokePool: SpokePool = SpokePool__factory.connect(spokePoolAddress, provider); - - const gasPrice = await provider.getGasPrice(); - - const deposit = buildDepositForRelayerFeeTest("1", "usdc", 10, 1); - const fill = await populateV3Relay(spokePool, deposit, relayerAddress); - const { nativeGasCost: refGasCost, tokenGasCost: refGasEstimate } = - await estimateTotalGasRequiredByUnsignedTransaction(fill, relayerAddress, provider, 0.0, gasPrice); - expect(toBN(refGasEstimate).eq(toBN(refGasCost).mul(gasPrice))).to.be.true; - - for (let gasMarkup = -0.99; gasMarkup <= 1.0; gasMarkup += 0.33) { - const { nativeGasCost, tokenGasCost } = await estimateTotalGasRequiredByUnsignedTransaction( - fill, - relayerAddress, - provider, - gasMarkup, - gasPrice - ); - const gasMultiplier = toBNWei(1.0 + gasMarkup); - - expect(toBN(nativeGasCost).eq(toBN(refGasCost).mul(gasMultiplier).div(fixedPointAdjustment))).to.be.true; - expect(toBN(tokenGasCost).eq(toBN(refGasEstimate).mul(gasMultiplier).div(fixedPointAdjustment))).to.be.true; - } - }); }); diff --git a/test/relayFeeCalculator.test.ts b/test/relayFeeCalculator.test.ts index 7ff79dfd..310c7bcb 100644 --- a/test/relayFeeCalculator.test.ts +++ b/test/relayFeeCalculator.test.ts @@ -10,6 +10,7 @@ import { getCurrentTime, spreadEvent, isMessageEmpty, + fixedPointAdjustment, } from "../src/utils"; import { BigNumber, @@ -31,6 +32,8 @@ import { TOKEN_SYMBOLS_MAP } from "@across-protocol/constants"; import { EMPTY_MESSAGE, ZERO_ADDRESS } from "../src/constants"; import { SpokePool } from "@across-protocol/contracts"; import { QueryBase, QueryBase__factory } from "../src/relayFeeCalculator"; +import { getDefaultProvider } from "ethers"; +import { MockedProvider } from "./utils/provider"; dotenv.config({ path: ".env" }); @@ -454,3 +457,63 @@ describe("RelayFeeCalculator: Composable Bridging", function () { }); }); }); + +describe("QueryBase", function () { + describe("estimateGas", function () { + let queryBase: QueryBase; + beforeEach(function () { + queryBase = QueryBase__factory.create( + 1, // chainId + getDefaultProvider(), + undefined, // symbolMapping + undefined, // spokePoolAddress + undefined, // simulatedRelayerAddress + undefined, + this.logger + ); + }); + it("Uses passed in options", async function () { + const options = { + gasUnits: BigNumber.from(300_000), + gasPrice: toGWei("1.5"), + }; + const result = await queryBase.estimateGas( + {}, // populatedTransaction + randomAddress(), + getDefaultProvider(), + options + ); + expect(result.gasPrice).to.equal(options.gasPrice); + expect(result.nativeGasCost).to.equal(options.gasUnits); + expect(result.tokenGasCost).to.equal(options.gasPrice.mul(options.gasUnits)); + }); + it("Queries GasPriceOracle for gasPrice if not supplied", async function () { + const options = { + gasUnits: BigNumber.from(300_000), + gasPrice: undefined, + baseFeeMultiplier: toBNWei("2"), + }; + // Mocked provider gets queried to compute gas price. + const stdLastBaseFeePerGas = toGWei("12"); + const stdMaxPriorityFeePerGas = toGWei("1"); + const chainId = 1; // get gas price from GasPriceOracle.ethereum.eip1559() + const mockedProvider = new MockedProvider(stdLastBaseFeePerGas, stdMaxPriorityFeePerGas, chainId); + + const result = await queryBase.estimateGas( + {}, // populatedTransaction + randomAddress(), + mockedProvider, + options + ); + // In this test, verify that the baseFeeMultiplier is passed correctly to the + // GasPriceOracle. + const expectedGasPrice = stdLastBaseFeePerGas + .mul(options.baseFeeMultiplier) + .div(fixedPointAdjustment) + .add(stdMaxPriorityFeePerGas); + expect(result.gasPrice).to.equal(expectedGasPrice); + expect(result.nativeGasCost).to.equal(options.gasUnits); + expect(result.tokenGasCost).to.equal(expectedGasPrice.mul(options.gasUnits)); + }); + }); +}); diff --git a/test/utils/provider.ts b/test/utils/provider.ts new file mode 100644 index 00000000..3988c0a4 --- /dev/null +++ b/test/utils/provider.ts @@ -0,0 +1,67 @@ +import { BigNumber, providers } from "ethers"; +import { Block, BlockTag, FeeData } from "@ethersproject/abstract-provider"; +import { bnZero } from "../../src/utils/BigNumberUtils"; + +/** + * @notice Class used to test GasPriceOracle which makes ethers provider calls to the following implemented + * methods. + */ +export class MockedProvider extends providers.StaticJsonRpcProvider { + constructor( + readonly stdLastBaseFeePerGas: BigNumber, + readonly stdMaxPriorityFeePerGas: BigNumber, + readonly defaultChainId = 1 + ) { + super(); + } + + getBlock(_blockHashOrBlockTag: BlockTag | string | Promise): Promise { + const mockBlock: Block = { + transactions: [], + hash: "0x", + parentHash: "0x", + number: 0, + nonce: "0", + difficulty: 0, + _difficulty: bnZero, + timestamp: 0, + gasLimit: bnZero, + gasUsed: bnZero, + baseFeePerGas: this.stdLastBaseFeePerGas, + miner: "0x", + extraData: "0x", + }; + return Promise.resolve(mockBlock); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + send(method: string, _params: Array): Promise { + switch (method) { + case "eth_maxPriorityFeePerGas": + return Promise.resolve(this.stdMaxPriorityFeePerGas); + default: + throw new Error(`MockedProvider#Unimplemented method: ${method}`); + } + } + + getFeeData(): Promise { + return Promise.resolve({ + lastBaseFeePerGas: this.stdLastBaseFeePerGas, + maxPriorityFeePerGas: this.stdMaxPriorityFeePerGas, + // Following fields unused in GasPrice oracle + maxFeePerGas: null, + gasPrice: null, + }); + } + + getGasPrice(): Promise { + return Promise.resolve(this.stdLastBaseFeePerGas.add(this.stdMaxPriorityFeePerGas)); + } + + getNetwork(): Promise<{ chainId: number; name: string }> { + return Promise.resolve({ + name: "mocknetwork", + chainId: this.defaultChainId, + }); + } +} diff --git a/test/utils/transport.ts b/test/utils/transport.ts index 38915d97..bb344330 100644 --- a/test/utils/transport.ts +++ b/test/utils/transport.ts @@ -2,15 +2,15 @@ import { custom } from "viem"; import { BigNumber, parseUnits } from "../../src/utils"; export const makeCustomTransport = ( - params: Partial<{ stdLastBaseFeePerGas: BigNumber; stdMaxPriorityFeePerGas: BigNumber }> = {} + feeParams: Partial<{ stdLastBaseFeePerGas: BigNumber; stdMaxPriorityFeePerGas: BigNumber }> = {} ) => { - const { stdLastBaseFeePerGas = parseUnits("12", 9), stdMaxPriorityFeePerGas = parseUnits("1", 9) } = params; + const { stdLastBaseFeePerGas = parseUnits("12", 9), stdMaxPriorityFeePerGas = parseUnits("1", 9) } = feeParams; const stdMaxFeePerGas = stdLastBaseFeePerGas.add(stdMaxPriorityFeePerGas); const stdGasPrice = stdMaxFeePerGas; return custom({ // eslint-disable-next-line require-await - async request({ method }: { method: string; params: unknown }) { + async request({ method, params }: { method: string; params: unknown[] }) { switch (method) { case "eth_gasPrice": return BigInt(stdGasPrice.toString()); @@ -18,6 +18,18 @@ export const makeCustomTransport = ( return { baseFeePerGas: BigInt(stdLastBaseFeePerGas.toString()) }; case "eth_maxPriorityFeePerGas": return BigInt(stdMaxPriorityFeePerGas.toString()); + case "linea_estimateGas": + // For testing purposes, double the priority fee if txnData is not the empty string "0x" + return { + // Linea base fee is always 7 wei + baseFeePerGas: BigInt(7), + priorityFeePerGas: BigInt( + stdMaxPriorityFeePerGas + .mul((params as { data: string }[])[0]?.data?.slice(2).length > 0 ? 2 : 1) + .toString() + ), + gasLimit: BigInt("0"), + }; default: throw new Error(`Unsupported method: ${method}.`); }