Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support opt-in use of viem for gas pricing #745

Merged
merged 14 commits into from
Nov 13, 2024
209 changes: 25 additions & 184 deletions e2e/oracle.e2e.ts
pxrl marked this conversation as resolved.
Show resolved Hide resolved
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
Loading