Skip to content

Commit

Permalink
feat: Support opt-in use of viem for gas pricing (#745)
Browse files Browse the repository at this point in the history
This change updates the gasPriceOracle to support querying gas via 
viem's gas pricing strategies. This is beneficial because viem has an
active community that are actively adding support for new chains and 
improving support for existing chains. An example of this is Linea,
where linea_estimateGas is supported out of the box after a recent
update.

In order to maintain some degree of compatibility with existing users of
the gasPriceOracle, the external interface still accepts an ethers
Provider instance. It then internally instantiates a viem PublicClient
instance and uses that instead. There is still a residual issue of how 
to resolve the relevant provider URLs, since it's not necessarily
reliable to pull from the provider due to the uncertain type (i.e.
StaticJsonRpcProvider or one of the extended variants that wraps
multiple providers).

Viem support can be enabled per-chain via the 
NEW_GAS_PRICE_ORACLE_<chainId> environment variable, which must be set 
explicitly to true. All other queries will revert to the existing ethers
implementation.

Co-authored-by: Dong-Ha Kim <[email protected]>
  • Loading branch information
pxrl and dohaki authored Nov 13, 2024
1 parent 952bb0c commit 29af3e7
Show file tree
Hide file tree
Showing 15 changed files with 398 additions and 223 deletions.
209 changes: 25 additions & 184 deletions e2e/oracle.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,97 +1,31 @@
// @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({
level: "debug",
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<FeeData> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (this.testFeeData as any) ?? (await super.getFeeData());
}

override async getGasPrice(): Promise<BigNumber> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return this.testGasPrice !== undefined ? (this.testGasPrice as any) : await super.getGasPrice();
}
}

/**
* Note: If NODE_URL_<chainId> 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}`,
Expand All @@ -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];
}
});
});
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down
13 changes: 13 additions & 0 deletions src/gasPriceOracle/adapters/arbitrum-viem.ts
Original file line number Diff line number Diff line change
@@ -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<InternalGasPriceEstimate> {
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 };
}
19 changes: 19 additions & 0 deletions src/gasPriceOracle/adapters/ethereum-viem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { PublicClient } from "viem";
import { InternalGasPriceEstimate } from "../types";

export function eip1559(provider: PublicClient, _chainId: number): Promise<InternalGasPriceEstimate> {
return provider.estimateFeesPerGas();
}

export async function legacy(
provider: PublicClient,
_chainId: number,
_test?: number
): Promise<InternalGasPriceEstimate> {
const gasPrice = await provider.getGasPrice();

return {
maxFeePerGas: gasPrice,
maxPriorityFeePerGas: BigInt(0),
};
}
86 changes: 86 additions & 0 deletions src/gasPriceOracle/adapters/polygon-viem.ts
Original file line number Diff line number Diff line change
@@ -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<InternalGasPriceEstimate> {
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<InternalGasPriceEstimate> {
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 };
}
Loading

0 comments on commit 29af3e7

Please sign in to comment.