Estimating Gas and Tx Fees

Scroll Alpha Testnet is now deprecated.

Please visit our new documentation for the Scroll Sepolia Testnet at https://docs.scroll.io/

Since Scroll is an L2 rollup, part of the transaction lifecycle is committing some data to L1 for security. To pay for this, all transaction incurs an additional fee called the L1 fee.

In this guide, we will go over how to estimate the L1 fee on Scroll and calculate final transaction fee estimations through a hands-on code example you can find here.

The formula

In short, the transaction fee on Scroll, at any given moment, can be calculated as

totalFee = (l2GasPrice * l2GasUsed) + l1Fee

An important distinction we need to make is that l1Fee is separated from the l2Fee.

For a more comprehensive explanation and details on the formula, check the documentation on how Transaction Fees work on Scroll.

Interfacing with values

To fetch these values and calculate the final fee, we’ll interact with Scroll’s public RPC and pre-deployed Smart Contract L1GasOracle.sol, which is deployed at 0x5300000000000000000000000000000000000002

For our example codebase, we make a Hardhat project and fetch values using the Ethers.js library.

l2GasUsed - We get this using the estimateGas method, which queries our RPC with the TX data to get an estimate of the gas to be used.

l2GasPrice - For this, we’ll use the getFeeData method, which will get the current market conditions on the L2

The Gas Oracle smart contract exposes multiple key fields we need to figure out the cost of the transaction. overhead() , scalar() , l1BaseFee() and getL1GasUsed(bytes memory data)

But it also exposes the getL1Fee(bytes memory data) function, which abstracts all these complexities and allows us to get the fee in just one function call.

Hands-on example

Project structure

First of all, let’s quickly go over the key folders inside our project structure.

It’s a standard Hardhat project, but most of our work is inside the contracts and scripts folders.

This tutorial will review the critical parts of the code, using the example code as an easy way to get an overview of what’s done in the project. Some code, like error handling, will be excluded for simplicity.

The smart contract

First, we need a Smart Contract to interact with to showcase the gas estimation. For that, we’ll create a ExampleContract.sol.

pragma solidity ^0.8.17;

contract ExampleContract {
    uint public exampleVariable;

    function setExampleVariable(uint _exampleVariable) external {
        exampleVariable = _exampleVariable;
    }
}

Remember the setExampleVariable method signature since we’ll use it later as an example

Once deployed, we fill the EXAMPLE_CONTRACT_ADDRESS value in our .env file. For the example project, it’s already deployed at 0xc37ee92c73753B46eB865Ee156d89741ad0a8777 and pre-filled, so nothing has to be done here.

Estimating the fees

The central part of the example lives in the /scripts/gasEstimation.ts file.

We’ll do just four things:

  1. Create a dummy transaction using the ExampleContract

  2. Estimate the L2 fee of that transaction

  3. Estimate the L1 fee of that transaction

  4. Emit an actual transaction to Scroll and compare the values

Creating the dummy transaction

The goal of this step is to create an (RLP) Serialized Unsigned Transaction to be used later on as the parameter for calling the oracle gas estimation method.

  1. Let’s populate our transaction with the needed values to estimate its cost by calling buildPopulatedExampleContractTransaction. This will fill the data, to, gasPrice, type and gasLimit fields:

export async function buildPopulatedExampleContractTransaction(exampleContractAddress: string, newValueToSet: number): Promise<ContractTransaction> {
  const exampleContract = await ethers.getContractAt("ExampleContract", exampleContractAddress);

  return exampleContract.setExampleVariable.populateTransaction(newValueToSet);
}
  1. Now, let’s trim it down to the basic fields using buildUnsignedTransaction. We won’t be signing it.

export async function buildUnsignedTransaction(signer: HardhatEthersSigner, populatedTransaction: ContractTransaction): Promise<UnsignedTransaction> {
  const nonce = await signer.getNonce();

  return {
    data: populatedTransaction.data,
    to: populatedTransaction.to,
    gasPrice: populatedTransaction.gasPrice,
    type: populatedTransaction.type,
    gasLimit: populatedTransaction.gasLimit,
    nonce,
  };

Make sure that the nonce is a valid one!

  1. With the output of the previous function, we serialize it using getSerializedTransaction

export function getSerializedTransaction(tx: UnsignedTransaction) {
  return serialize(tx);
}

Estimating the L2 fee

This step is pretty standard and the same as on Ethereum. We’ll use the estimateL2Fee function which takes the populated transaction as an input and returns an estimate of the total gas it will use by multiplying the current gas price and gas needed to be used.

export async function estimateL2Fee(tx: ContractTransaction): Promise<bigint> {
  const gasToUse = await ethers.provider.estimateGas(tx);
  const feeData = await ethers.provider.getFeeData();
  const gasPrice = feeData.gasPrice;

  return gasToUse * gasPrice;
}

Estimating the L1 fee of that transaction

This step is very straightforward. Using the output of the getSerializedTransaction function, we query the oracle and get the estimated fee in return.

export async function estimateL1Fee(gasOraclePrecompileAddress: string, unsignedSerializedTransaction: string): Promise<bigint> {
  const l1GasOracle = await ethers.getContractAt("IL1GasPriceOracle", gasOraclePrecompileAddress);

  return l1GasOracle.getL1Fee(unsignedSerializedTransaction);
}

Emitting the transaction and comparing estimated vs actual values

Emitting the transaction

We’ll create a call to our contract using the same values used for the dummy transaction.

const tx = await exampleContract.setExampleVariable(newValueToSetOnExampleContract);
const txReceipt = await tx.wait(5);

Calculating the L2 fee

Using the transaction receipt we can see the amount of gas used by the transaction

const l2Fee = txReceipt.gasUsed * txReceipt.gasPrice;

Getting the amount used to pay for the L1 fee

To do this, we’ll compare the balance of the account before the transaction execution, the account balance after the execution and then subtract the L2 fee.

const totalFee = signerBalanceBefore - signerBalanceAfter;
const l1Fee = totalFee - l2Fee;

Comparing the values

Fee markets are constantly moving and unpredictable. Since the values estimated may differ from the actual execution, let’s check how much they differ.

We can run all the code that we previously wrote by typing yarn gas:estimate command in our project that will run the gasEstimation.ts script and give us the output below:

Estimated L1 fee (wei): 208705598167252
Estimated L2 fee (wei): 26416000000
Estimated total fee (wei):  208732014167252

Actual L1 fee (wei): 210830909757550
Actual L2 fee (wei): 26416000000
Actual total fee (wei):  210857325757550

(actual fee - estimated fee)
Difference L1 fee (wei): 2125311590298 (1.0183299389002531%)
Difference L2 fee (wei): 0 (0%)
Difference total fee (wei): 2125311590298 (1.0182010645453987%)

We can see above that the estimated values (in wei) differ by around 1%, but keep in mind that during spikes in gas prices, this difference may increase. Be sure to account for this in your front end for users!

For more details about what happens when gas fluctuates on L1 and about the limits set there, check out the lifecycle of a transaction on the Gas Fees.

Last updated