Agent Cookbook12 copy-ready prompts for AI coding agents.12 prompts for AI agentsSee recipes
All Blog Posts
eth_simulateV1: simulate dependent Ethereum calls

eth_simulateV1: simulate dependent Ethereum calls

By Elias Faltin 7min read

A wallet can show a token trade failing because it checked the steps separately. Some trades require you to give the trading app permission to use your tokens first. If the wallet previews that permission and the trade separately, the trade preview still sees the permission as missing.

eth_simulateV1 lets the wallet test both steps in order. The trade simulation can use the permission created by the first simulation. These checks send no transactions and grant no real permission. The execution API specification defines how changes carry over between simulated calls.

Separate eth_call requests each start from the selected blockchain state. Grouping them in a JSON-RPC batch sends them together, but does not carry changes from one simulation into another. Use eth_simulateV1 when later steps need the changes made by earlier steps. Keep eth_call for independent reads or a single simulation.

What carries over between calls

The request contains a blockStateCalls array and a parent block identifier. Each entry defines one simulated block. Its calls execute in order, then the next block continues from that simulated state.

stateOverrides patch accounts before that block's calls. You can replace balances, nonces, code, or storage. Those changes continue into later simulated blocks unless another override or call changes them.

blockOverrides change the block context, including its number, timestamp, and base fee. Increasing a block number by more than 1 inserts empty gap blocks. Block numbers and timestamps must increase. The execution API notes describe the defaults and gap behavior.

A write sets storage slot zero to seven. Later calls read seven across simulated blocks, while a separate eth_call starts from zero.

A simulation does not predict which other transactions will execute before yours. Your result describes the supplied calls and block context, starting from the selected parent state.

Choose validation for the question you need answered

validation: false is the default. It gives you eth_call-style execution with relaxed transaction checks. Use it to inspect contract behavior under a chosen state. It still has execution rules and gas limits.

validation: true checks transaction conditions such as nonce, available funds, and fees against the simulated state. Geth excludes signature and contract-sender checks. It does not reproduce every provider's mempool policy or guarantee transaction inclusion.

Both modes accept state and block overrides. Funding a sender through an override does not require validation: false. Neither does simulating a later block. Choose the mode based on whether transaction checks belong in your preview.

The two requests fund the sender and use a 1-wei maximum fee below a fixed 1,000,000,000-wei base fee. Validation false succeeds; validation true rejects the request with -38012. Neither guarantees inclusion.

With validation enabled, supply fees and nonces that match your simulated scenario. A changed balance or base fee can make an otherwise rejected transaction pass. Report those assumptions alongside a wallet preview.

Run a sequence that proves shared state

This example installs a small storage contract through an override. Nonempty calldata writes slot 0; empty calldata reads it. Both paths return the stored value.

The first block writes 7, then reads 7. The second block reads 7 again. A separate eth_call with the original override reads 0. This checks the behavior that independent calls cannot provide.

Use Node.js 22 or later. Save the script as simulate.mjs and set RPC_URL to an Ethereum mainnet endpoint that accepts both methods and state overrides.

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

The script checks the chain ID, pins a parent number, and verifies its hash afterward. It rejects missing results, failed calls, and unexpected state. A changed parent hash requires a fresh run.

JAVASCRIPT
import assert from 'node:assert/strict';

const endpoint = process.env.RPC_URL;
if (!endpoint) throw new Error('Set RPC_URL to an Ethereum endpoint');
const timeout = Number(process.env.RPC_TIMEOUT_MS ?? 30000);
let id = 0;

async function rpc(method, params) {
  const requestId = ++id;
  const response = await fetch(endpoint, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ jsonrpc: '2.0', id: requestId, method, params }),
    signal: AbortSignal.timeout(timeout),
  });
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  const body = await response.json();
  assert.equal(body?.jsonrpc, '2.0', 'Invalid JSON-RPC version');
  assert.equal(body.id, requestId, 'Unexpected response ID');
  if (body.error) throw new Error(`RPC ${body.error.code}: ${body.error.message}`);
  assert.ok(body.result != null, `${method} returned no result`);
  return body.result;
}

const contract = '0xc000000000000000000000000000000000000001';
const sender = '0xc000000000000000000000000000000000000002';
const zero = `0x${'0'.repeat(64)}`;
const seven = `0x${'0'.repeat(63)}7`;
// Nonempty calldata writes slot 0. Empty calldata reads it. Both return it.
const code = '0x3615600b576000356000555b60005460005260206000f3';
const stateOverrides = {
  [contract]: { code, state: { [zero]: zero } },
  [sender]: { balance: '0xde0b6b3a7640000', nonce: '0x0' },
};
const read = { from: sender, to: contract, data: '0x', gas: '0x186a0' };
const write = { ...read, data: seven };

try {
  assert.equal(await rpc('eth_chainId', []), '0x1', 'Expected Ethereum mainnet');
  const parent = await rpc('eth_getBlockByNumber', ['latest', false]);
  assert.match(parent.number, /^0x[0-9a-f]+$/i);
  assert.match(parent.hash, /^0x[0-9a-f]{64}$/i);
  const payload = {
    blockStateCalls: [
      { stateOverrides, calls: [write, read] },
      { calls: [read] },
    ],
    validation: false,
    traceTransfers: false,
    returnFullTransactions: false,
  };
  const blocks = await rpc('eth_simulateV1', [payload, parent.number]);
  assert.ok(Array.isArray(blocks), 'Expected simulated blocks');
  assert.equal(blocks.length, 2, 'Expected two simulated blocks');
  for (const [index, block] of blocks.entries()) {
    assert.equal(BigInt(block.number), BigInt(parent.number) + BigInt(index + 1));
    assert.ok(Array.isArray(block.calls), 'Missing call results');
    assert.equal(block.calls.length, index === 0 ? 2 : 1);
    for (const call of block.calls) {
      assert.equal(call.status, '0x1', JSON.stringify(call.error ?? call));
      assert.equal(call.returnData?.toLowerCase(), seven, 'Shared state was lost');
    }
  }
  const isolated = await rpc('eth_call', [read, parent.number, stateOverrides]);
  assert.equal(isolated, zero, 'An independent read should start at zero');
  const canonical = await rpc('eth_getBlockByNumber', [parent.number, false]);
  assert.equal(canonical.hash, parent.hash, 'Parent changed; rerun the example');
  console.log(JSON.stringify({
    parent: { number: parent.number, hash: parent.hash },
    simulated: blocks.map(block => block.calls.map(call => call.returnData)),
    isolated,
  }, null, 2));
} catch (error) {
  console.error(error instanceof Error ? error.message : error);
  process.exitCode = 1;
}

The output contains 7 as a 32-byte hexadecimal value for all 3 simulated calls. The independent read returns the corresponding value for 0.

Handle request failures and call failures separately

An HTTP success does not prove simulation success. First inspect the JSON-RPC response. An error rejects the request; a missing or null result provides no usable simulation.

Then inspect every returned call's status. A 0x0 marks a failed execution, even when the request returned a result. Its error can contain a revert reason or execution failure. A 0x1 marks successful execution.

Geth continues after an execution revert, so later calls can still return results. They run against the state left after that failure, with the reverted call's execution changes undone. Later success does not make the intended sequence successful. Reject a required-call failure in your application.

Do not replace an unavailable eth_simulateV1 request with independent eth_call requests when the calls depend on each other. That fallback changes the meaning of the preview. Use another verified simulation endpoint or a local fork instead.

Bound the work and test the actual endpoint

Geth limits each request to 256 simulated blocks, including inserted gap blocks. Its --rpc.gascap setting bounds gas across the simulation. Other clients and provider gateways can impose different limits.

Set explicit gas limits per call and a request timeout. Keep the sequence within the endpoint's documented limits. Splitting a dependent sequence into separate requests loses the intermediate state.

A client version string does not establish endpoint support. A gateway can block a method that its backend implements. Run a small representative sequence against each endpoint you intend to use, including the fallback.

Before rollout, check that the endpoint preserves state across your calls and blocks. Check your validation mode, a deliberate revert, and request-level rejection. Record the parent block, endpoint, overrides, and response when a preview fails.

Add transfer logs only when the preview needs them

traceTransfers: true adds synthetic logs for native ETH transfers. Their emitter is 0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee. They use the ERC-20 Transfer format, as described in the transfer-log specification.

Keep these simulated native transfers separate from contract-emitted token logs and mined receipts. They describe the simulation, not confirmed on-chain events.

returnFullTransactions: true returns full transaction objects instead of hashes in each simulated block's transaction list. Call results remain in calls in either mode. Leave both options off when you only need status and return data.

Put the right method behind your preview

Choose the method based on the state your result needs.

Your requirementMethod
One call or independent readseth_call
Ordered calls that share writes across calls or blockseth_simulateV1
Internal call frames, gas by frame, or opcodesdebug_traceCall

Dwellir provides Ethereum eth_call endpoints for contract reads and single-call simulations. If your application needs dependent sequences, contact the Dwellir team with your chain, overrides, and validation requirements. Confirm eth_simulateV1 availability and limits for the intended endpoint before integrating it.

read another blog post