diff --git a/.changeset/curvy-eyes-bake.md b/.changeset/curvy-eyes-bake.md new file mode 100644 index 0000000000..a334d76bb0 --- /dev/null +++ b/.changeset/curvy-eyes-bake.md @@ -0,0 +1,5 @@ +--- +"viem": patch +--- + +Added estimateOperatorFee action for OP Stack chains diff --git a/.gitignore b/.gitignore index e0b753dcf7..d82c3f918f 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ vectors/**/*.json site/dist .vercel vocs.config.tsx.timestamp* +site/.cache diff --git a/site/pages/op-stack/actions/estimateOperatorFee.md b/site/pages/op-stack/actions/estimateOperatorFee.md new file mode 100644 index 0000000000..bc902360ac --- /dev/null +++ b/site/pages/op-stack/actions/estimateOperatorFee.md @@ -0,0 +1,174 @@ +--- +description: Estimates the operator fee to execute an L2 transaction. +--- + +# estimateOperatorFee + +Estimates the [operator fee](https://docs.optimism.io/stack/transactions/fees#operator-fee) to execute an L2 transaction. + +The operator fee is part of the Isthmus upgrade and allows OP Stack operators to recover costs related to Alt-DA, ZK proving, or custom gas tokens. Returns 0 for pre-Isthmus chains or when operator fee functions don't exist. + +The fee is calculated using the formula: `operatorFee = operatorFeeConstant + operatorFeeScalar * gasUsed / 1e6` + +## Usage + +:::code-group + +```ts [example.ts] +import { account, publicClient } from './config' + +const fee = await publicClient.estimateOperatorFee({ // [!code focus:7] + account, + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + value: parseEther('1') +}) +``` + +```ts [config.ts] +import { createPublicClient, http } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { base } from 'viem/chains' +import { publicActionsL2 } from 'viem/op-stack' + +// JSON-RPC Account +export const account = '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266' +// Local Account +export const account = privateKeyToAccount(...) + +export const publicClient = createPublicClient({ + chain: base, + transport: http() +}).extend(publicActionsL2()) +``` + +::: + +## Returns + +`bigint` + +The operator fee (in wei). + +## Parameters + +### account + +- **Type:** `Account | Address` + +The Account to estimate fee from. + +Accepts a [JSON-RPC Account](/docs/clients/wallet#json-rpc-accounts) or [Local Account (Private Key, etc)](/docs/clients/wallet#local-accounts-private-key-mnemonic-etc). + +```ts +const fee = await publicClient.estimateOperatorFee({ + account: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', // [!code focus] + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + value: parseEther('1') +}) +``` + +### data (optional) + +- **Type:** `0x${string}` + +Contract code or a hashed method call with encoded args. + +```ts +const fee = await publicClient.estimateOperatorFee({ + data: '0x...', // [!code focus] + account: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + value: parseEther('1') +}) +``` + +### l1BlockAddress (optional) + +- **Type:** [`Address`](/docs/glossary/types#address) + +Address of the L1Block predeploy contract. + +```ts +const fee = await publicClient.estimateOperatorFee({ + account: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', + l1BlockAddress: '0x4200000000000000000000000000000000000015', // [!code focus] + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + value: parseEther('1') +}) +``` + +### maxFeePerGas (optional) + +- **Type:** `bigint` + +Total fee per gas (in wei), inclusive of `maxPriorityFeePerGas`. + +```ts +const fee = await publicClient.estimateOperatorFee({ + account: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', + maxFeePerGas: parseGwei('20'), // [!code focus] + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + value: parseEther('1') +}) +``` + +### maxPriorityFeePerGas (optional) + +- **Type:** `bigint` + +Max priority fee per gas (in wei). + +```ts +const fee = await publicClient.estimateOperatorFee({ + account: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', + maxFeePerGas: parseGwei('20'), + maxPriorityFeePerGas: parseGwei('2'), // [!code focus] + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + value: parseEther('1') +}) +``` + +### nonce (optional) + +- **Type:** `number` + +Unique number identifying this transaction. + +```ts +const fee = await publicClient.estimateOperatorFee({ + account: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', + maxFeePerGas: parseGwei('20'), + maxPriorityFeePerGas: parseGwei('2'), + nonce: 69, // [!code focus] + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + value: parseEther('1') +}) +``` + +### to (optional) + +- **Type:** [`Address`](/docs/glossary/types#address) + +Transaction recipient. + +```ts +const fee = await publicClient.estimateOperatorFee({ + account: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', // [!code focus] + value: parseEther('1') +}) +``` + +### value (optional) + +- **Type:** `bigint` + +Value (in wei) sent with this transaction. + +```ts +const fee = await publicClient.estimateOperatorFee({ + account: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + value: parseEther('1') // [!code focus] +}) +``` \ No newline at end of file diff --git a/site/pages/op-stack/actions/estimateTotalFee.md b/site/pages/op-stack/actions/estimateTotalFee.md index 620c89bbbb..fb8d23aefb 100644 --- a/site/pages/op-stack/actions/estimateTotalFee.md +++ b/site/pages/op-stack/actions/estimateTotalFee.md @@ -1,12 +1,12 @@ --- -description: Estimates the L1 + L2 fee to execute an L2 transaction. +description: Estimates the L1 + L2 + operator fee to execute an L2 transaction. --- # estimateTotalFee -Estimates the [L1 data fee](https://docs.optimism.io/stack/transactions/fees#l1-data-fee) + L2 fee to execute an L2 transaction. +Estimates the [L1 data fee](https://docs.optimism.io/stack/transactions/fees#l1-data-fee) + L2 fee + [operator fee](https://docs.optimism.io/stack/transactions/fees#operator-fee) to execute an L2 transaction. -It is the sum of [`estimateL1Fee`](/op-stack/actions/estimateL1Fee) (L1 Gas) and [`estimateGas`](/docs/actions/public/estimateGas.md) * [`getGasPrice`](/docs/actions/public/getGasPrice.md) (L2 Gas * L2 Gas Price). +It is the sum of [`estimateL1Fee`](/op-stack/actions/estimateL1Fee) (L1 Gas), [`estimateOperatorFee`](/op-stack/actions/estimateOperatorFee) (Operator Fee), and [`estimateGas`](/docs/actions/public/estimateGas.md) * [`getGasPrice`](/docs/actions/public/getGasPrice.md) (L2 Gas * L2 Gas Price). ## Usage @@ -45,7 +45,7 @@ export const publicClient = createPublicClient({ `bigint` -The L1 fee (in wei). +The total fee (L1 + L2 + operator fee, in wei). ## Parameters @@ -95,6 +95,21 @@ const fee = await publicClient.estimateTotalFee({ }) ``` +### l1BlockAddress (optional) + +- **Type:** [`Address`](/docs/glossary/types#address) + +Address of the L1Block predeploy contract. + +```ts +const fee = await publicClient.estimateTotalFee({ + account: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', + l1BlockAddress: '0x4200000000000000000000000000000000000015', // [!code focus] + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + value: parseEther('1') +}) +``` + ### maxFeePerGas (optional) - **Type:** `bigint` diff --git a/site/sidebar.ts b/site/sidebar.ts index 5ee05ef257..663d300de1 100644 --- a/site/sidebar.ts +++ b/site/sidebar.ts @@ -1590,6 +1590,10 @@ export const sidebar = { text: 'estimateL1Gas', link: '/op-stack/actions/estimateL1Gas', }, + { + text: 'estimateOperatorFee', + link: '/op-stack/actions/estimateOperatorFee', + }, { text: 'estimateTotalFee', link: '/op-stack/actions/estimateTotalFee', diff --git a/src/op-stack/abis.ts b/src/op-stack/abis.ts index b4aaa18cb3..e208e7296f 100644 --- a/src/op-stack/abis.ts +++ b/src/op-stack/abis.ts @@ -76,6 +76,27 @@ export const gasPriceOracleAbi = [ }, ] as const +/** + * ABI for the OP Stack [`L1Block` contract](https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/src/L2/L1Block.sol). + * @see https://optimistic.etherscan.io/address/0x4200000000000000000000000000000000000015 + */ +export const l1BlockAbi = [ + { + inputs: [], + name: 'operatorFeeScalar', + outputs: [{ internalType: 'uint32', name: '', type: 'uint32' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'operatorFeeConstant', + outputs: [{ internalType: 'uint64', name: '', type: 'uint64' }], + stateMutability: 'view', + type: 'function', + }, +] as const + export const l2OutputOracleAbi = [ { inputs: [ diff --git a/src/op-stack/actions/estimateL1Fee.ts b/src/op-stack/actions/estimateL1Fee.ts index 40cc966127..1f348ad56f 100644 --- a/src/op-stack/actions/estimateL1Fee.ts +++ b/src/op-stack/actions/estimateL1Fee.ts @@ -81,7 +81,7 @@ export async function estimateL1Fee< const gasPriceOracleAddress = (() => { if (gasPriceOracleAddress_) return gasPriceOracleAddress_ - if (chain) + if (chain?.contracts?.gasPriceOracle) return getChainContractAddress({ chain, contract: 'gasPriceOracle', diff --git a/src/op-stack/actions/estimateL1Gas.ts b/src/op-stack/actions/estimateL1Gas.ts index dc45862027..d7c8cde86b 100644 --- a/src/op-stack/actions/estimateL1Gas.ts +++ b/src/op-stack/actions/estimateL1Gas.ts @@ -81,7 +81,7 @@ export async function estimateL1Gas< const gasPriceOracleAddress = (() => { if (gasPriceOracleAddress_) return gasPriceOracleAddress_ - if (chain) + if (chain?.contracts?.gasPriceOracle) return getChainContractAddress({ chain, contract: 'gasPriceOracle', diff --git a/src/op-stack/actions/estimateOperatorFee.test.ts b/src/op-stack/actions/estimateOperatorFee.test.ts new file mode 100644 index 0000000000..0d81e6ff2b --- /dev/null +++ b/src/op-stack/actions/estimateOperatorFee.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from 'vitest' + +import { accounts } from '~test/src/constants.js' + +import { anvilOptimism } from '../../../test/src/anvil.js' +import { parseGwei, type TransactionRequestEIP1559 } from '../../index.js' +import { parseEther } from '../../utils/unit/parseEther.js' +import { estimateOperatorFee } from './estimateOperatorFee.js' + +const optimismClient = anvilOptimism.getClient() +const optimismClientWithAccount = anvilOptimism.getClient({ account: true }) +const optimismClientWithoutChain = anvilOptimism.getClient({ chain: false }) + +const baseTransaction = { + maxFeePerGas: parseGwei('100'), + maxPriorityFeePerGas: parseGwei('1'), + to: accounts[1].address, + value: parseEther('0.1'), +} as const satisfies Omit + +test('default', async () => { + const fee = await estimateOperatorFee( + optimismClientWithAccount, + baseTransaction, + ) + expect(fee).toBeDefined() +}) + +test('minimal', async () => { + const fee = await estimateOperatorFee(optimismClientWithAccount, {}) + expect(fee).toBeDefined() +}) + +test('args: account', async () => { + const fee = await estimateOperatorFee(optimismClient, { + ...baseTransaction, + account: accounts[0].address, + }) + expect(fee).toBeDefined() +}) + +test('args: data', async () => { + const fee = await estimateOperatorFee(optimismClientWithAccount, { + ...baseTransaction, + data: '0x00000000000000000000000000000000000000000000000004fefa17b7240000', + }) + expect(fee).toBeDefined() +}) + +test('args: l1BlockAddress', async () => { + const fee = await estimateOperatorFee(optimismClientWithAccount, { + ...baseTransaction, + l1BlockAddress: '0x4200000000000000000000000000000000000015', + }) + expect(fee).toBeDefined() +}) + +test('args: nonce', async () => { + const fee = await estimateOperatorFee(optimismClientWithAccount, { + ...baseTransaction, + nonce: 69, + }) + expect(fee).toBeDefined() +}) + +test('args: nullish chain', async () => { + const fee = await estimateOperatorFee(optimismClientWithoutChain, { + ...baseTransaction, + account: accounts[0].address, + chain: null, + }) + expect(fee).toBeDefined() +}) diff --git a/src/op-stack/actions/estimateOperatorFee.ts b/src/op-stack/actions/estimateOperatorFee.ts new file mode 100644 index 0000000000..fa9f032e49 --- /dev/null +++ b/src/op-stack/actions/estimateOperatorFee.ts @@ -0,0 +1,119 @@ +import type { Address } from 'abitype' + +import { + type EstimateGasErrorType, + type EstimateGasParameters, + estimateGas, +} from '../../actions/public/estimateGas.js' +import { + type ReadContractErrorType, + readContract, +} from '../../actions/public/readContract.js' +import type { Client } from '../../clients/createClient.js' +import type { Transport } from '../../clients/transports/createTransport.js' +import type { ErrorType } from '../../errors/utils.js' +import type { Account, GetAccountParameter } from '../../types/account.js' +import type { Chain, GetChainParameter } from '../../types/chain.js' +import type { TransactionRequestEIP1559 } from '../../types/transaction.js' +import type { RequestErrorType } from '../../utils/buildRequest.js' +import { getChainContractAddress } from '../../utils/chain/getChainContractAddress.js' +import type { HexToNumberErrorType } from '../../utils/encoding/fromHex.js' +import { l1BlockAbi } from '../abis.js' +import { contracts } from '../contracts.js' + +export type EstimateOperatorFeeParameters< + TChain extends Chain | undefined = Chain | undefined, + TAccount extends Account | undefined = Account | undefined, + TChainOverride extends Chain | undefined = undefined, +> = Omit & + GetAccountParameter & + GetChainParameter & { + /** L1 block attributes contract address. */ + l1BlockAddress?: Address | undefined + } + +export type EstimateOperatorFeeReturnType = bigint + +export type EstimateOperatorFeeErrorType = + | RequestErrorType + | EstimateGasErrorType + | HexToNumberErrorType + | ReadContractErrorType + | ErrorType + +/** + * Estimates the operator fee required to execute an L2 transaction. + * + * Operator fees are part of the Isthmus upgrade and allow OP Stack operators + * to recover costs related to Alt-DA, ZK proving, or custom gas tokens. + * Returns 0 for pre-Isthmus chains or when operator fee functions don't exist. + * + * @param client - Client to use + * @param parameters - {@link EstimateOperatorFeeParameters} + * @returns The operator fee (in wei). {@link EstimateOperatorFeeReturnType} + * + * @example + * import { createPublicClient, http, parseEther } from 'viem' + * import { optimism } from 'viem/chains' + * import { estimateOperatorFee } from 'viem/chains/optimism' + * + * const client = createPublicClient({ + * chain: optimism, + * transport: http(), + * }) + * const operatorFee = await estimateOperatorFee(client, { + * account: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e', + * to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + * value: parseEther('1'), + * }) + */ +export async function estimateOperatorFee< + TChain extends Chain | undefined, + TAccount extends Account | undefined, + TChainOverride extends Chain | undefined = undefined, +>( + client: Client, + args: EstimateOperatorFeeParameters, +): Promise { + const { chain = client.chain, l1BlockAddress: l1BlockAddress_ } = args + + const l1BlockAddress = (() => { + if (l1BlockAddress_) return l1BlockAddress_ + if (chain?.contracts?.l1Block) + return getChainContractAddress({ + chain, + contract: 'l1Block', + }) + return contracts.l1Block.address + })() + + // Try to get operator fee parameters. If any of these calls fail, + // it means this is a pre-Isthmus chain and operator fees don't apply + try { + // Get operator fee parameters first to fail fast if not supported + const [operatorFeeScalar, operatorFeeConstant] = await Promise.all([ + readContract(client, { + abi: l1BlockAbi, + address: l1BlockAddress, + functionName: 'operatorFeeScalar', + }), + readContract(client, { + abi: l1BlockAbi, + address: l1BlockAddress, + functionName: 'operatorFeeConstant', + }), + ]) + + // Estimate gas for the actual transaction + const gasUsed = await estimateGas(client, args as EstimateGasParameters) + + // Calculate operator fee: saturatingAdd(saturatingMul(gasUsed, scalar) / 1e6, constant) + // Using saturating arithmetic to prevent overflow + const scaledFee = (gasUsed * BigInt(operatorFeeScalar)) / 1_000_000n + return scaledFee + BigInt(operatorFeeConstant) + } catch { + // If any call fails, this is likely a pre-Isthmus chain or the contract + // doesn't support these functions. Return 0 for operator fee. + return 0n + } +} diff --git a/src/op-stack/actions/estimateTotalFee.test.ts b/src/op-stack/actions/estimateTotalFee.test.ts index ed88791a1d..ad81b5d788 100644 --- a/src/op-stack/actions/estimateTotalFee.test.ts +++ b/src/op-stack/actions/estimateTotalFee.test.ts @@ -3,8 +3,12 @@ import { expect, test } from 'vitest' import { accounts } from '~test/src/constants.js' import { anvilOptimism } from '../../../test/src/anvil.js' +import { estimateGas } from '../../actions/public/estimateGas.js' +import { getGasPrice } from '../../actions/public/getGasPrice.js' import { parseGwei, type TransactionRequestEIP1559 } from '../../index.js' import { parseEther } from '../../utils/unit/parseEther.js' +import { estimateL1Fee } from './estimateL1Fee.js' +import { estimateOperatorFee } from './estimateOperatorFee.js' import { estimateTotalFee } from './estimateTotalFee.js' const optimismClient = anvilOptimism.getClient() @@ -60,6 +64,14 @@ test('args: nonce', async () => { expect(fee).toBeDefined() }) +test('args: l1BlockAddress', async () => { + const fee = await estimateTotalFee(optimismClientWithAccount, { + ...baseTransaction, + l1BlockAddress: '0x4200000000000000000000000000000000000015', + }) + expect(fee).toBeDefined() +}) + test('args: nullish chain', async () => { const fee = await estimateTotalFee(optimismClientWithoutChain, { ...baseTransaction, @@ -68,3 +80,16 @@ test('args: nullish chain', async () => { }) expect(fee).toBeDefined() }) + +test('includes operator fee in total', async () => { + const [totalFee, l1Fee, operatorFee, l2Gas, l2GasPrice] = await Promise.all([ + estimateTotalFee(optimismClientWithAccount, baseTransaction), + estimateL1Fee(optimismClientWithAccount, baseTransaction), + estimateOperatorFee(optimismClientWithAccount, baseTransaction), + estimateGas(optimismClientWithAccount, baseTransaction), + getGasPrice(optimismClientWithAccount), + ]) + + const expectedTotal = l1Fee + operatorFee + l2Gas * l2GasPrice + expect(totalFee).toEqual(expectedTotal) +}) diff --git a/src/op-stack/actions/estimateTotalFee.ts b/src/op-stack/actions/estimateTotalFee.ts index fe3c5eafbe..e6e1b0c07e 100644 --- a/src/op-stack/actions/estimateTotalFee.ts +++ b/src/op-stack/actions/estimateTotalFee.ts @@ -23,12 +23,18 @@ import { type EstimateL1FeeParameters, estimateL1Fee, } from './estimateL1Fee.js' +import { + type EstimateOperatorFeeErrorType, + type EstimateOperatorFeeParameters, + estimateOperatorFee, +} from './estimateOperatorFee.js' export type EstimateTotalFeeParameters< chain extends Chain | undefined = Chain | undefined, account extends Account | undefined = Account | undefined, chainOverride extends Chain | undefined = Chain | undefined, -> = EstimateL1FeeParameters +> = EstimateL1FeeParameters & + EstimateOperatorFeeParameters export type EstimateTotalFeeReturnType = bigint @@ -36,12 +42,13 @@ export type EstimateTotalFeeErrorType = | RequestErrorType | PrepareTransactionRequestErrorType | EstimateL1FeeErrorType + | EstimateOperatorFeeErrorType | EstimateGasErrorType | GetGasPriceErrorType | ErrorType /** - * Estimates the L1 data fee + L2 fee to execute an L2 transaction. + * Estimates the L1 data fee + L2 fee + operator fee to execute an L2 transaction. * * @param client - Client to use * @param parameters - {@link EstimateTotalFeeParameters} @@ -76,11 +83,12 @@ export async function estimateTotalFee< args as PrepareTransactionRequestParameters, ) - const [l1Fee, l2Gas, l2GasPrice] = await Promise.all([ + const [l1Fee, operatorFee, l2Gas, l2GasPrice] = await Promise.all([ estimateL1Fee(client, request as EstimateL1FeeParameters), + estimateOperatorFee(client, request as EstimateOperatorFeeParameters), estimateGas(client, request as EstimateGasParameters), getGasPrice(client), ]) - return l1Fee + l2Gas * l2GasPrice + return l1Fee + operatorFee + l2Gas * l2GasPrice } diff --git a/src/op-stack/actions/getL1BaseFee.ts b/src/op-stack/actions/getL1BaseFee.ts index 20ec7ac98c..0954cda16e 100644 --- a/src/op-stack/actions/getL1BaseFee.ts +++ b/src/op-stack/actions/getL1BaseFee.ts @@ -65,7 +65,7 @@ export async function getL1BaseFee< const gasPriceOracleAddress = (() => { if (gasPriceOracleAddress_) return gasPriceOracleAddress_ - if (chain) + if (chain?.contracts?.gasPriceOracle) return getChainContractAddress({ chain, contract: 'gasPriceOracle', diff --git a/src/op-stack/index.ts b/src/op-stack/index.ts index b7ec1b30ff..54e40983b5 100644 --- a/src/op-stack/index.ts +++ b/src/op-stack/index.ts @@ -59,6 +59,12 @@ export { type EstimateL1GasReturnType, estimateL1Gas, } from './actions/estimateL1Gas.js' +export { + type EstimateOperatorFeeErrorType, + type EstimateOperatorFeeParameters, + type EstimateOperatorFeeReturnType, + estimateOperatorFee, +} from './actions/estimateOperatorFee.js' export { type EstimateTotalFeeErrorType, type EstimateTotalFeeParameters,