diff --git a/src/relayFeeCalculator/chain-queries/arbitrum.ts b/src/relayFeeCalculator/chain-queries/arbitrum.ts index 4288239c..c23cb0c6 100644 --- a/src/relayFeeCalculator/chain-queries/arbitrum.ts +++ b/src/relayFeeCalculator/chain-queries/arbitrum.ts @@ -1,12 +1,16 @@ import { QueryInterface } from "../relayFeeCalculator"; -import { BigNumberish } from "../../utils"; -import { BigNumber, providers } from "ethers"; +import { + BigNumberish, + createUnsignedFillRelayTransaction, + estimateTotalGasRequiredByUnsignedTransaction, +} from "../../utils"; +import { providers } from "ethers"; import { SymbolMapping } from "./ethereum"; import { Coingecko } from "../../coingecko/Coingecko"; -import { ArbitrumSpokePool__factory, ArbitrumSpokePool } from "@across-protocol/contracts-v2"; +import { SpokePool__factory, SpokePool } from "@across-protocol/contracts-v2"; export class ArbitrumQueries implements QueryInterface { - private spokePool: ArbitrumSpokePool; + private spokePool: SpokePool; constructor( readonly provider: providers.Provider, @@ -15,13 +19,12 @@ export class ArbitrumQueries implements QueryInterface { private readonly usdcAddress = "0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8", private readonly simulatedRelayerAddress = "0x893d0d70ad97717052e3aa8903d9615804167759" ) { - this.spokePool = ArbitrumSpokePool__factory.connect(spokePoolAddress, provider); + this.spokePool = SpokePool__factory.connect(spokePoolAddress, provider); } async getGasCosts(_tokenSymbol: string): Promise { - const gasEstimate = await this.estimateGas(); - const gasPrice = BigNumber.from(await this.provider.getGasPrice()); - return gasPrice.mul(gasEstimate).toString(); + const tx = await createUnsignedFillRelayTransaction(this.spokePool, this.usdcAddress, this.simulatedRelayerAddress); + return estimateTotalGasRequiredByUnsignedTransaction(tx, this.simulatedRelayerAddress, this.provider); } async getTokenPrice(tokenSymbol: string): Promise { @@ -34,21 +37,4 @@ export class ArbitrumQueries implements QueryInterface { if (!this.symbolMapping[tokenSymbol]) throw new Error(`${tokenSymbol} does not exist in mapping`); return this.symbolMapping[tokenSymbol].decimals; } - - estimateGas() { - // Create a dummy transaction to estimate. Note: the simulated caller would need to be holding weth and have approved the contract. - return this.spokePool.estimateGas.fillRelay( - "0xBb23Cd0210F878Ea4CcA50e9dC307fb0Ed65Cf6B", - "0xBb23Cd0210F878Ea4CcA50e9dC307fb0Ed65Cf6B", - this.usdcAddress, - "10", - "10", - "1", - "1", - "1", - "1", - "1", - { from: this.simulatedRelayerAddress } - ); - } } diff --git a/src/relayFeeCalculator/chain-queries/boba.ts b/src/relayFeeCalculator/chain-queries/boba.ts index ef42f688..4f73893d 100644 --- a/src/relayFeeCalculator/chain-queries/boba.ts +++ b/src/relayFeeCalculator/chain-queries/boba.ts @@ -1,45 +1,38 @@ import { QueryInterface } from "../relayFeeCalculator"; -import { BigNumberish } from "../../utils"; +import { + BigNumberish, + createUnsignedFillRelayTransaction, + estimateTotalGasRequiredByUnsignedTransaction, +} from "../../utils"; import { utils, providers } from "ethers"; import { SymbolMapping } from "./ethereum"; import { Coingecko } from "../../coingecko/Coingecko"; -import { OptimismSpokePool__factory, OptimismSpokePool } from "@across-protocol/contracts-v2"; +import { SpokePool__factory, SpokePool } from "@across-protocol/contracts-v2"; const { parseUnits } = utils; export class BobaQueries implements QueryInterface { - private spokePool: OptimismSpokePool; + private spokePool: SpokePool; constructor( - provider: providers.Provider, + readonly provider: providers.Provider, readonly symbolMapping = SymbolMapping, spokePoolAddress = "0xBbc6009fEfFc27ce705322832Cb2068F8C1e0A58", private readonly usdcAddress = "0x66a2A913e447d6b4BF33EFbec43aAeF87890FBbc", private readonly simulatedRelayerAddress = "0x893d0d70ad97717052e3aa8903d9615804167759" ) { // TODO: replace with address getter. - this.spokePool = OptimismSpokePool__factory.connect(spokePoolAddress, provider); + this.spokePool = SpokePool__factory.connect(spokePoolAddress, provider); } async getGasCosts(_tokenSymbol: string): Promise { - // Create a dummy transaction to estimate. Note: the simulated caller would need to be holding weth and have approved the contract. - const gasEstimate = await this.spokePool.estimateGas.fillRelay( - "0xBb23Cd0210F878Ea4CcA50e9dC307fb0Ed65Cf6B", - "0xBb23Cd0210F878Ea4CcA50e9dC307fb0Ed65Cf6B", - this.usdcAddress, - "10", - "10", - "1", - "1", - "1", - "1", - "1", - { from: this.simulatedRelayerAddress } + const tx = await createUnsignedFillRelayTransaction(this.spokePool, this.usdcAddress, this.simulatedRelayerAddress); + return estimateTotalGasRequiredByUnsignedTransaction( + tx, + this.simulatedRelayerAddress, + this.provider, + parseUnits("1", 9) ); - - // Boba's gas price is hardcoded to 1 gwei. - const bobaGasPrice = parseUnits("1", 9); - return gasEstimate.mul(bobaGasPrice).toString(); } async getTokenPrice(tokenSymbol: string): Promise { diff --git a/src/relayFeeCalculator/chain-queries/ethereum.ts b/src/relayFeeCalculator/chain-queries/ethereum.ts index a79573a2..b8733a2a 100644 --- a/src/relayFeeCalculator/chain-queries/ethereum.ts +++ b/src/relayFeeCalculator/chain-queries/ethereum.ts @@ -1,7 +1,12 @@ import { QueryInterface } from "../relayFeeCalculator"; -import { BigNumberish } from "../../utils"; +import { + BigNumberish, + createUnsignedFillRelayTransaction, + estimateTotalGasRequiredByUnsignedTransaction, +} from "../../utils"; import { Coingecko } from "../../coingecko/Coingecko"; -import { providers, BigNumber } from "ethers"; +import { providers } from "ethers"; +import { SpokePool__factory, SpokePool } from "@across-protocol/contracts-v2"; // Note: these are the mainnet addresses for these symbols meant to be used for pricing. export const SymbolMapping: { [symbol: string]: { address: string; decimals: number } } = { @@ -67,18 +72,21 @@ export const SymbolMapping: { [symbol: string]: { address: string; decimals: num }, }; -export const defaultAverageGas = 116006; - export class EthereumQueries implements QueryInterface { + private spokePool: SpokePool; + constructor( - public readonly provider: providers.Provider, - public readonly averageGas = defaultAverageGas, - readonly symbolMapping = SymbolMapping - ) {} + readonly provider: providers.Provider, + readonly symbolMapping = SymbolMapping, + readonly spokePoolAddress = "0x4D9079Bb4165aeb4084c526a32695dCfd2F77381", + readonly usdcAddress = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + readonly simulatedRelayerAddress = "0x893d0D70AD97717052E3AA8903D9615804167759" + ) { + this.spokePool = SpokePool__factory.connect(this.spokePoolAddress, this.provider); + } async getGasCosts(_tokenSymbol: string): Promise { - return BigNumber.from(await this.provider.getGasPrice()) - .mul(this.averageGas) - .toString(); + const tx = await createUnsignedFillRelayTransaction(this.spokePool, this.usdcAddress, this.simulatedRelayerAddress); + return estimateTotalGasRequiredByUnsignedTransaction(tx, this.simulatedRelayerAddress, this.provider); } async getTokenPrice(tokenSymbol: string): Promise { diff --git a/src/relayFeeCalculator/chain-queries/optimism.ts b/src/relayFeeCalculator/chain-queries/optimism.ts index 7549f957..7e231474 100644 --- a/src/relayFeeCalculator/chain-queries/optimism.ts +++ b/src/relayFeeCalculator/chain-queries/optimism.ts @@ -1,15 +1,19 @@ import { QueryInterface } from "../relayFeeCalculator"; -import { BigNumberish } from "../../utils"; -import { providers, VoidSigner } from "ethers"; +import { + BigNumberish, + createUnsignedFillRelayTransaction, + estimateTotalGasRequiredByUnsignedTransaction, +} from "../../utils"; +import { providers } from "ethers"; import { SymbolMapping } from "./ethereum"; import { Coingecko } from "../../coingecko/Coingecko"; -import { OptimismSpokePool__factory, OptimismSpokePool } from "@across-protocol/contracts-v2"; +import { SpokePool__factory, SpokePool } from "@across-protocol/contracts-v2"; import { L2Provider, asL2Provider } from "@eth-optimism/sdk"; export class OptimismQueries implements QueryInterface { - private spokePool: OptimismSpokePool; - private provider: L2Provider; + private spokePool: SpokePool; + readonly provider: L2Provider; constructor( provider: providers.Provider, @@ -19,27 +23,12 @@ export class OptimismQueries implements QueryInterface { private readonly simulatedRelayerAddress = "0x893d0d70ad97717052e3aa8903d9615804167759" ) { this.provider = asL2Provider(provider); - this.spokePool = OptimismSpokePool__factory.connect(spokePoolAddress, provider); + this.spokePool = SpokePool__factory.connect(spokePoolAddress, provider); } async getGasCosts(_tokenSymbol: string): Promise { - // Create a dummy transaction to estimate. Note: the simulated caller would need to be holding weth and have approved the contract. - const tx = await this.spokePool.populateTransaction.fillRelay( - "0xBb23Cd0210F878Ea4CcA50e9dC307fb0Ed65Cf6B", - "0xBb23Cd0210F878Ea4CcA50e9dC307fb0Ed65Cf6B", - this.usdcAddress, - "10", - "10", - "1", - "1", - "1", - "1", - "1" - ); - const populatedTransaction = await new VoidSigner(this.simulatedRelayerAddress, this.provider).populateTransaction( - tx - ); - return (await this.provider.estimateTotalGasCost(populatedTransaction)).toString(); + const tx = await createUnsignedFillRelayTransaction(this.spokePool, this.usdcAddress, this.simulatedRelayerAddress); + return estimateTotalGasRequiredByUnsignedTransaction(tx, this.simulatedRelayerAddress, this.provider); } async getTokenPrice(tokenSymbol: string): Promise { diff --git a/src/relayFeeCalculator/chain-queries/polygon.ts b/src/relayFeeCalculator/chain-queries/polygon.ts index 65ae2338..dc19202e 100644 --- a/src/relayFeeCalculator/chain-queries/polygon.ts +++ b/src/relayFeeCalculator/chain-queries/polygon.ts @@ -1,20 +1,30 @@ import { QueryInterface } from "../relayFeeCalculator"; -import { BigNumberish } from "../../utils"; -import { providers, BigNumber } from "ethers"; -import { defaultAverageGas, SymbolMapping } from "./ethereum"; +import { + BigNumberish, + createUnsignedFillRelayTransaction, + estimateTotalGasRequiredByUnsignedTransaction, +} from "../../utils"; +import { providers } from "ethers"; +import { SymbolMapping } from "./ethereum"; import { Coingecko } from "../../coingecko/Coingecko"; +import { SpokePool__factory, SpokePool } from "@across-protocol/contracts-v2"; export class PolygonQueries implements QueryInterface { + private spokePool: SpokePool; + constructor( readonly provider: providers.Provider, - public readonly averageGas = defaultAverageGas, - readonly symbolMapping = SymbolMapping - ) {} + readonly symbolMapping = SymbolMapping, + readonly spokePoolAddress = "0x69B5c72837769eF1e7C164Abc6515DcFf217F920", + readonly usdcAddress = "0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + readonly simulatedRelayerAddress = "0x893d0D70AD97717052E3AA8903D9615804167759" + ) { + this.spokePool = SpokePool__factory.connect(spokePoolAddress, provider); + } async getGasCosts(_tokenSymbol: string): Promise { - return BigNumber.from(await this.provider.getGasPrice()) - .mul(this.averageGas) - .toString(); + const tx = await createUnsignedFillRelayTransaction(this.spokePool, this.usdcAddress, this.simulatedRelayerAddress); + return estimateTotalGasRequiredByUnsignedTransaction(tx, this.simulatedRelayerAddress, this.provider); } async getTokenPrice(tokenSymbol: string): Promise { diff --git a/src/relayFeeCalculator/chain-queries/queries.e2e.ts b/src/relayFeeCalculator/chain-queries/queries.e2e.ts index 41eeadef..63c697e7 100644 --- a/src/relayFeeCalculator/chain-queries/queries.e2e.ts +++ b/src/relayFeeCalculator/chain-queries/queries.e2e.ts @@ -2,6 +2,8 @@ // NODE_URL_42161 // NODE_URL_288 // NODE_URL_10 +// NODE_URL_1 +// NODE_URL_137 import dotenv from "dotenv"; @@ -35,7 +37,8 @@ describe("Queries", function () { ]); }); test("Ethereum", async function () { - const ethereumQueries = new EthereumQueries(); + const provider = new providers.JsonRpcProvider(process.env.NODE_URL_1); + const ethereumQueries = new EthereumQueries(provider); await Promise.all([ ethereumQueries.getGasCosts("USDC"), ethereumQueries.getTokenDecimals("USDC"), @@ -52,7 +55,8 @@ describe("Queries", function () { ]); }); test("Polygon", async function () { - const polygonQueries = new PolygonQueries(); + const provider = new providers.JsonRpcProvider(process.env.NODE_URL_137); + const polygonQueries = new PolygonQueries(provider); await Promise.all([ polygonQueries.getGasCosts("USDC"), polygonQueries.getTokenDecimals("USDC"), diff --git a/src/relayFeeCalculator/relayFeeCalculator.test.ts b/src/relayFeeCalculator/relayFeeCalculator.test.ts index 365382f7..6c3852bd 100644 --- a/src/relayFeeCalculator/relayFeeCalculator.test.ts +++ b/src/relayFeeCalculator/relayFeeCalculator.test.ts @@ -39,6 +39,22 @@ describe("RelayFeeCalculator", () => { beforeAll(() => { queries = new ExampleQueries(); }); + it("gasPercentageFee", async () => { + client = new RelayFeeCalculator({ queries }); + // A list of inputs and ground truth [input, ground truth] + const gasFeePercents = [ + [1000, "30557200000000000000000"], + [5000, "6111440000000000000000"], + // A test with a prime number + [104729, "291774007199534035462"], + ]; + for (const [input, truth] of gasFeePercents) { + const result = (await client.gasFeePercent(input, "usdc")).toString(); + expect(result).toEqual(truth); + } + // Test that zero amount fails + await expect(client.gasFeePercent(0, "USDC")).rejects.toThrowError(); + }); it("relayerFeeDetails", async () => { client = new RelayFeeCalculator({ queries }); const result = await client.relayerFeeDetails(100000000, "usdc"); diff --git a/src/utils.ts b/src/utils.ts index 8d7094a5..1926fb40 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,8 @@ -import { BigNumber, ethers } from "ethers"; +import { BigNumber, ethers, PopulatedTransaction, providers, VoidSigner } from "ethers"; import * as uma from "@uma/sdk"; import Decimal from "decimal.js"; +import { isL2Provider as isOptimismL2Provider, L2Provider } from "@eth-optimism/sdk"; +import { SpokePool } from "@across-protocol/contracts-v2"; export type BigNumberish = string | number | BigNumber; export type BN = BigNumber; @@ -222,3 +224,63 @@ export async function retry(call: () => Promise, times: number, delayS: nu }); return promiseChain; } + +/** + * 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 gasPrice A manually provided gas price - if set, this function will not resolve the current gas price + * @returns The total gas cost to submit this transaction - i.e. gasPrice * estimatedGasUnits + */ +export async function estimateTotalGasRequiredByUnsignedTransaction( + unsignedTx: PopulatedTransaction, + senderAddress: string, + provider: providers.Provider | L2Provider, + gasPrice?: BigNumberish +): Promise { + const voidSigner = new VoidSigner(senderAddress, provider); + // This branches in the Optimism case because they use a special provider, called L2Provider, and special gas logic + // to compute gas costs on Optimism. + if (isOptimismL2Provider(provider)) { + const populatedTransaction = await voidSigner.populateTransaction(unsignedTx); + return (await provider.estimateTotalGasCost(populatedTransaction)).toString(); + } else { + // Estimate the Gas units required to submit this transaction + const estimatedGasUnits = await voidSigner.estimateGas(unsignedTx); + // Provide a default gas price of the market rate if this condition has not been set + const resolvedGasPrice = gasPrice ?? (await provider.getGasPrice()); + // Find the total gas cost by taking the product of the gas + // price & the estimated number of gas units needed + return BigNumber.from(resolvedGasPrice).mul(estimatedGasUnits).toString(); + } +} + +/** + * Create an unsigned transaction of a fillRelay contract call + * @param spokePool The specific spokepool that will populate this tx + * @param destinationTokenAddress A valid ERC20 token (system-wide default is UDSC) + * @param simulatedRelayerAddress The relayer address that relays this transaction + * @returns A populated (but unsigned) transaction that can be signed/sent or used for estimating gas costs + */ +export async function createUnsignedFillRelayTransaction( + spokePool: SpokePool, + destinationTokenAddress: string, + simulatedRelayerAddress: string +): Promise { + // Populate and return an unsigned tx as per the given spoke pool + // NOTE: 0xBb23Cd0210F878Ea4CcA50e9dC307fb0Ed65Cf6B is a dummy address + return await spokePool.populateTransaction.fillRelay( + "0xBb23Cd0210F878Ea4CcA50e9dC307fb0Ed65Cf6B", + "0xBb23Cd0210F878Ea4CcA50e9dC307fb0Ed65Cf6B", + destinationTokenAddress, + "10", + "10", + "1", + "1", + "1", + "1", + "1", + { from: simulatedRelayerAddress } + ); +}