Agent Cookbook12 copy-ready prompts for AI coding agents.12 prompts for AI agentsSee recipes
All Blog Posts
eth_createAccessList: compare gas before attaching the list

eth_createAccessList: compare gas before attaching the list

By Elias Faltin Updated 11min read

You want to lower the fee for a contract transaction before asking the user to sign it. Your node can suggest which contract addresses and storage locations the transaction will use. Attaching that list looks like a gas optimization, but it can increase the fee instead.

Ethereum charges for every listed address and storage location before the contract runs. Those charges can exceed the execution gas they save. A valid list therefore needs a cost comparison before it belongs in your transaction.

eth_createAccessList generates this list through a read-only simulation. This guide explains the extra charge for the called contract, demonstrates its break-even point, and shows how to reject an unhelpful list.

What the method actually returns

An access list contains contract addresses and storage keys. Each key identifies a 32-byte storage location belonging to its address. The list makes those items cheaper to access during execution, in exchange for an upfront gas charge.

The Execution API specification defines a transaction call object and a block selector for eth_createAccessList. Pass the intended sender, recipient, calldata, value, and a simulation gas budget. Use one explicit block for list generation and both gas estimates.

A successful JSON-RPC response can contain this result:

JSON
{
  "accessList": [],
  "gasUsed": "0x520e",
  "error": "execution reverted"
}

The node handled the request, but the contract execution failed. Reject results with error before using their list. Also reject missing results and malformed addresses or storage keys.

gasUsed describes that simulated execution with its generated list. It is not a recommended transaction gas limit. Use eth_estimateGas with the exact list you intend to attach.

Geth's implementation defaults to latest when the block selector is absent. It also accepts state overrides as a third parameter. Overrides replace selected account state for the simulation without changing the chain.

That third parameter is a client extension to the specification's two-parameter method. Verify support on your endpoint before using it.

Why the called contract can make the list expensive

Ethereum distinguishes a first access, called cold, from an already prepared access, called warm. Under EIP-2929, the transaction sender and recipient start warm. Precompiled contracts start warm too; EIP-3651 also warms the block's fee recipient.

A warm address does not imply warm storage. The called contract's storage keys can still be cold.

Under EIP-2930, listing an address costs 2,400 gas. Each listed storage key costs another 1,900 gas. A first SLOAD, the instruction that reads storage, costs 2,100 gas without a list and 100 gas with one.

For a key read once, the arithmetic is:

TEXT
Without a list:       2,100 gas
With a listed key:    1,900 + 100 = 2,000 gas
Saving per key:      100 gas

The key must sit under its contract address in the list. When that address is the transaction recipient, its 2,400-gas charge buys no additional address warming.

For this specific pattern, listing the recipient costs 2,400 - 100 × distinct keys extra gas. Reading 24 distinct keys breaks even. Reading 25 saves 100 gas.

A recipient entry costs 2,400 gas. One first-read key saves 100 gas, leaving 2,300 extra gas. Twenty-five keys save 2,500 gas, leaving 100 gas saved.

The recipient address is already warm. Its access-list entry still costs 2,400 gas.

Each distinct key first accessed by SLOAD saves 100 gas after its prepaid cost. One key leaves a net cost of 2,300 gas. Twenty-five such keys produce a net saving of 100 gas.

This threshold applies to distinct keys first touched by SLOAD under these gas rules. A first SSTORE, which writes storage, has different accounting. Repeated reads of one key do not create more first-access savings.

A genuinely cold address touched by CALL has different economics. Its first access falls from 2,600 to 100 gas. Prepaying 2,400 saves 100 gas before considering its keys.

A tracer finds accesses, not the cheapest list

Geth's access-list tracer records storage operations under the executing contract's address. It does this even when that address is the recipient.

Its address exclusion rules therefore do not remove a recipient that carries storage keys. Geth repeats execution until its discovered list stops changing. A stable list still need not minimize gas.

Do not remove every recipient entry as a blanket rule. The 25-key example needs that entry to retain its saving. Compare the full generated list first. If you try a smaller candidate, estimate that exact candidate again.

A read-only experiment that shows both outcomes

To isolate the mechanism, use temporary code that reads distinct storage keys and stops. The one-key version is 0x6000545000. It executes PUSH1 0, SLOAD, POP, and STOP.

The 25-key version repeats the read for keys 0 through 24. State overrides install this code only within the simulation. No contract deployment or transaction submission is needed.

On 14 October 2026, these fixtures produced the following execution gas:

Temporary contractNo listGenerated listDifference
Reads 1 key23,10525,4052,300 more
Reads 25 keys73,62573,525100 less

Both ethereum-rpc.publicnode.com and eth.drpc.org returned these values through eth_simulateV1. The respective parent blocks were 26,191,579 and 26,191,582. Each run pinned its block number and rechecked its hash afterward.

These are controlled simulation results, not token-transfer benchmarks. Each generated list contained the called address and exactly the expected storage keys. Both endpoints also returned higher estimates for the one-key list and lower estimates for the 25-key list.

The one-key plain estimate was 23,442 gas, above its 23,105 execution cost. This difference is another reason to keep gasUsed separate from the gas limit.

For multi-call simulation behavior, see eth_simulateV1 and shared state.

Select a list only after both estimates succeed

This read-only example uses Node.js 22 or later and viem 2.57.6. Install the tested version in a separate example directory:

BASH
npm install --save-exact viem@2.57.6

Save the helper as choose-viem.mjs. Its client must use the checked transport shown below. Pass an ordinary contract call without an existing accessList. Keep its sender, recipient, calldata, value, and fee fields unchanged.

JAVASCRIPT
const address = /^0x[0-9a-f]{40}$/i;
const key = /^0x[0-9a-f]{64}$/i;

function validList(list) {
  return Array.isArray(list) && list.length > 0 && list.every((entry) =>
    entry && typeof entry.address === 'string' && address.test(entry.address) &&
    Array.isArray(entry.storageKeys) &&
    entry.storageKeys.every((slot) => typeof slot === 'string' && key.test(slot)));
}

export async function chooseAccessList(client, tx, blockNumber) {
  const estimate = (call) => client.estimateGas({ ...call, blockNumber, prepare: false });
  const plain = await estimate(tx);
  if (plain <= 0n) throw new Error('Invalid plain estimate');
  const fallback = { gas: plain };
  try {
    const result = await client.createAccessList({ ...tx, blockNumber });
    if (result.gasUsed <= 0n || !validList(result.accessList)) return fallback;
    const gas = await estimate({ ...tx, accessList: result.accessList });
    if (gas <= 0n || gas >= plain) return fallback;
    return { gas, accessList: result.accessList };
  } catch {
    return fallback;
  }
}

Save this transport as checked-http.mjs. It checks raw hexadecimal quantities before viem converts them to bigint. Otherwise, JavaScript can turn an invalid boolean into a usable-looking number. It also checks the block fields this example consumes.

JAVASCRIPT
import { custom, http } from 'viem';

const quantity = (value) => typeof value === 'string' && /^0x(0|[1-9a-f][0-9a-f]*)$/.test(value);

export function checkedHttp(url) {
  const base = http(url, { timeout: 8_000, retryCount: 0 })({});
  return custom({
    async request(args) {
      const result = await base.request(args);
      if (['eth_estimateGas', 'eth_chainId'].includes(args.method) && !quantity(result)) {
        throw new Error('Invalid RPC quantity');
      }
      if (args.method === 'eth_createAccessList' && !quantity(result?.gasUsed)) {
        throw new Error('Invalid discovery gas quantity');
      }
      if (args.method === 'eth_getBlockByNumber' &&
          (!quantity(result?.number) || !quantity(result?.baseFeePerGas) ||
           typeof result?.hash !== 'string' || !/^0x[0-9a-f]{64}$/.test(result.hash))) {
        throw new Error('Invalid block identity or base fee');
      }
      return result;
    },
  }, { retryCount: 0 });
}

Save this client as viem-run.mjs. It simulates a zero-value WETH deposit, checks the chain, and rechecks the chosen block's hash. It does not sign or submit a transaction.

JAVASCRIPT
import { createPublicClient } from 'viem';
import { mainnet } from 'viem/chains';
import { chooseAccessList } from './choose-viem.mjs';
import { checkedHttp } from './checked-http.mjs';

async function run() {
  const client = createPublicClient({
    chain: mainnet,
    transport: checkedHttp(process.env.RPC_URL),
  });
  if (await client.getChainId() !== 1) throw new Error('Wrong chain');
  const block = await client.getBlock({ blockTag: 'latest' });
  const tx = {
    account: '0x1111111111111111111111111111111111111111',
    to: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
    data: '0xd0e30db0', value: 0n, gas: 100_000n,
    maxFeePerGas: block.baseFeePerGas * 2n, maxPriorityFeePerGas: 0n,
  };
  const plan = await chooseAccessList(client, tx, block.number);
  const canonical = await client.getBlock({ blockNumber: block.number });
  if (canonical.hash !== block.hash) throw new Error('Block changed; repeat the comparison');
  console.log({ blockHash: block.hash, ...plan, gas: plan.gas.toString() });
}

run().catch(() => {
  console.error('Comparison failed; no result accepted.');
  process.exitCode = 1;
});

Run both files with your configured endpoint:

BASH
RPC_URL=https://ethereum-rpc.publicnode.com node viem-run.mjs

The client reports a failed comparison without printing credential-bearing endpoint errors. The sample account is for this read-only demonstration. Replace it and the call fields with your intended transaction before using the comparison.

Viem returns gas values as JavaScript bigint values. The helper compares them directly, then converts the selected estimate to a string for display.

The gas field provides the discovery budget. Viem's estimateGas action does not forward that field in its request. Both estimates use the same remaining call fields and pinned block. Apply your gas-margin policy to the selected estimate afterward.

The plain estimate runs first. If it fails, the helper throws. A failed optional request, a reverted discovery result, or an unusable list returns the successful plain estimate.

A list wins only when its estimate is lower. Equal estimates keep the plain transaction. This rule also avoids attaching a list when an endpoint returns identical estimates for both variants.

Estimate the plain call first. Stop if it fails. Generate and estimate a candidate list, then keep it only when its estimate is lower. Optional failures use the plain estimate.

Start with a plain gas estimate at a pinned block. A failure stops the comparison.

Generate a candidate access list. Reject an execution error or malformed result. Estimate the exact candidate. Use the list only if its estimate is lower than the plain estimate. Optional failures return the successful plain estimate.

Estimate differences do not prove exact execution savings. Estimators can include headroom. Use a controlled simulation when a small difference matters, and retain the estimate as the gas-limit input.

In viem's createAccessList action, a result-level error becomes a thrown BaseError. The helper catches this optional failure. The required plain estimate remains outside that catch.

Keep list generation optional for call paths that already execute successfully without a list. EIP-2930 also supports calls with fixed internal gas budgets. That correctness use needs separate validation if the plain call fails.

Keep the comparison valid until signing

A block number can refer to a replacement block after a chain reorganization. Read the block hash before the comparison and recheck it afterward. Discard the result if it changed.

Before signing, verify that the transaction fields still match the comparison. Re-plan if the chain state has moved beyond your application's accepted age. This also applies to the plain estimate.

If you attach a list, use the gas estimate obtained with that exact list. Apply your application's gas-margin policy afterward. A successful comparison cannot guarantee execution against a future block.

List generation requires state at the selected block. If that state is unavailable, obtain a fresh block and repeat the required plain estimate. Do not treat missing historical state as proof that the transaction would fail.

For a permanently unavailable method, disable list discovery in that endpoint's configuration. Do not probe it before every transaction. Timeouts and temporary failures can skip the optional comparison for that attempt.

Generating a list and estimating it adds 2 remote procedure calls, or RPCs, beyond the plain estimate. The second estimate depends on the generated list. Measure that added delay before enabling the path for time-sensitive transactions.

The access list changes gas consumption. Select fee caps separately with your eth_feeHistory workflow.

Recheck the rules after a network upgrade

The arithmetic above uses EIP-2929 and EIP-2930 costs. Pending proposals can change it. EIP-8038 changes access-list charges, while EIP-7981 proposes an additional charge for access-list bytes.

Do not apply a mainnet result to a testnet or another chain without checking its active rules. Repeat the comparison after an upgrade instead of keeping a fixed pruning threshold in application code.

Use Dwellir's Ethereum documentation to configure your connection. Check optional method support on the endpoint you will use, then test your own contract calls with and without a list.

read another blog post