Onchain.EVM (onchain_evm v0.6.0)

Copy Markdown View Source

Local EVM simulation powered by revm via Rustler NIF.

Simulates contract execution locally by forking Ethereum mainnet state from a mainnet RPC endpoint. Zero gas cost, zero latency compared to on-chain execution.

Core Use Cases

  • Run hundreds of "what if" scenarios (supply X, borrow Y, price drops Z%)
  • Test liquidation thresholds without on-chain risk
  • Estimate gas usage for transactions before sending
  • Validate contract interactions with state overrides

Error Format

SourceError Shape
EVM execution reverted{:error, {:evm_revert, revert_data_hex}}
EVM execution error{:error, {:evm_error, reason}}
Fork/RPC connection error{:error, {:fork_error, reason}}
RPC request timeout{:error, {:timeout, reason}}
Invalid address input{:error, {:invalid_address, input}}
Invalid hex data input{:error, {:invalid_data, input}}
Invalid batch calls{:error, {:invalid_calls, input}}
Invalid RPC URL{:error, {:invalid_rpc_url, reason}}
Invalid block option{:error, {:invalid_block, input}}
Invalid value option{:error, {:invalid_value, input}}
Invalid gas_limit option{:error, {:invalid_gas_limit, input}}
Invalid timeout_ms option{:error, {:invalid_timeout_ms, input}}
Invalid state_overrides option{:error, {:invalid_state_overrides, input}}

Fork revision

A :block pin selects both the forked state and the EVM revision that was active at that block, via the Ethereum mainnet hardfork schedule (keyed on eth_chainId plus the header's block number). RPC endpoints whose chain id is not Ethereum mainnet (1) return {:error, {:fork_error, _}} rather than silently executing under mainnet rules.

Timeouts

Each NIF call configures a reqwest::Client with a per-RPC-request timeout. Default is 30 seconds (sufficient for archive-node reads). Pass :timeout_ms to override. Note this caps each individual RPC request, not the aggregate simulation time — a single simulate_transaction may issue many RPC reads for accounts and storage slots.

Functions

FunctionPurpose
simulate_call/3Read-only call simulation → raw hex output
simulate_call!/3Same, raises on error
simulate_transaction/3Full tx simulation → success, gas, output, logs
simulate_transaction!/3Same, raises on error
simulate_batch/2Batch calls on shared fork → list of results
simulate_batch!/2Same, raises on error

Batch Partial Failure

simulate_batch/2 is resilient by design: one reverting call does not abort the batch. The outer {:ok, results} therefore signals only that the fork itself was built and every call was executed — not that every call succeeded. A reverted call appears in the list as %{success: false, output: "0x", ...}, in position, and later calls still observe the state it left behind.

Check :success per element; never treat {:ok, _} as "all calls succeeded":

{:ok, results} = Onchain.EVM.simulate_batch(calls, rpc_url: url)
Enum.filter(results, & &1.success == false)

Only simulate_call/3 and simulate_transaction/3 surface a revert as {:error, {:evm_revert, data}}; the batch path deliberately does not.

API Functions

FunctionArityDescriptionParam Kinds
simulate_batch!2Simulate multiple calls on a shared fork. Raises on error.calls: value, opts: value
simulate_batch2Simulate multiple calls on a single forked EVM state.calls: value, opts: value
simulate_transaction!3Simulate a full transaction. Raises on error.address: value, data: value, opts: value
simulate_transaction3Simulate a full transaction locally, returning gas usage, output, and logs.address: value, data: value, opts: value
simulate_call!3Simulate a read-only contract call. Raises on error.address: value, data: value, opts: value
simulate_call3Simulate a read-only contract call locally using a forked EVM state.address: value, data: value, opts: value

Summary

Types

All possible errors from EVM simulation functions.

EVM log entry from transaction simulation.

Errors from the Rust NIF during EVM execution.

RPC URL validation sub-reasons.

Options for EVM simulation functions.

State overrides to apply before simulation.

Transaction simulation result with gas usage and logs.

Validation errors from Elixir-side input checks.

Functions

Simulate multiple calls on a single forked EVM state.

Simulate multiple calls on a shared fork. Raises on error.

Simulate a read-only contract call locally using a forked EVM state.

Simulate a read-only contract call. Raises on error.

Simulate a full transaction locally, returning gas usage, output, and logs.

Simulate a full transaction. Raises on error.

Types

evm_error()

@type evm_error() :: validation_error() | nif_error()

All possible errors from EVM simulation functions.

log_entry()

@type log_entry() :: %{address: String.t(), topics: [String.t()], data: String.t()}

EVM log entry from transaction simulation.

nif_error()

@type nif_error() ::
  {:evm_revert, String.t()}
  | {:evm_error, String.t()}
  | {:fork_error, String.t()}
  | {:timeout, String.t()}

Errors from the Rust NIF during EVM execution.

rpc_url_reason()

@type rpc_url_reason() ::
  :missing
  | :empty
  | {:not_a_string, term()}
  | {:invalid_scheme, String.t()}
  | {:missing_host, String.t()}

RPC URL validation sub-reasons.

sim_opts()

@type sim_opts() :: [
  rpc_url: String.t(),
  block: non_neg_integer() | String.t(),
  from: String.t() | binary(),
  value: String.t(),
  gas_limit: non_neg_integer(),
  timeout_ms: pos_integer(),
  state_overrides: state_overrides()
]

Options for EVM simulation functions.

state_overrides()

@type state_overrides() :: %{
  optional(String.t()) => %{optional(String.t()) => String.t()}
}

State overrides to apply before simulation.

Keys are 0x-prefixed address strings. Values are maps with string keys: "balance" (hex string), "nonce" (string of integer), "code" (hex string), "storage" (JSON string of slot→value map).

All keys and values must be strings — the NIF decodes them as HashMap<String, String>.

%{
  "0xAddress..." => %{
    "balance" => "0xDE0B6B3A7640000",
    "nonce" => "5",
    "code" => "0x6080...",
    "storage" => ~s({"0x0": "0x1"})
  }
}

tx_result()

@type tx_result() :: %{
  success: boolean(),
  gas_used: non_neg_integer(),
  output: String.t(),
  logs: [log_entry()]
}

Transaction simulation result with gas usage and logs.

validation_error()

@type validation_error() ::
  {:invalid_rpc_url, rpc_url_reason()}
  | {:invalid_address, term()}
  | {:invalid_data, term()}
  | {:invalid_calls, term()}
  | {:invalid_block, term()}
  | {:invalid_value, term()}
  | {:invalid_gas_limit, term()}
  | {:invalid_timeout_ms, term()}
  | {:invalid_state_overrides, term()}

Validation errors from Elixir-side input checks.

Functions

simulate_batch(calls, opts \\ [])

@spec simulate_batch([{String.t() | binary(), String.t()}], sim_opts()) ::
  {:ok, [tx_result()]} | {:error, evm_error()}

Simulate multiple calls on a single forked EVM state.

Parameters

  • calls - List of {address, data} tuples — each address as 0x hex, data as 0x hex calldata (value)
  • opts - Options: :rpc_url (required), :block, :from, :gas_limit, :timeout_ms, :state_overrides (default: [], value)

Returns

List of transaction results, one per call ({:ok, [tx_result()]} | {:error, evm_error()})

# descripex:contract
%{
  params: %{
    calls: %{
      description: "List of {address, data} tuples — each address as 0x hex, data as 0x hex calldata",
      kind: :value
    },
    opts: %{
      default: [],
      description: "Options: :rpc_url (required), :block, :from, :gas_limit, :timeout_ms, :state_overrides",
      kind: :value
    }
  },
  returns: %{
    type: "{:ok, [tx_result()]} | {:error, evm_error()}",
    description: "List of transaction results, one per call"
  }
}

simulate_batch!(calls, opts \\ [])

@spec simulate_batch!([{String.t() | binary(), String.t()}], sim_opts()) :: [
  tx_result()
]

Simulate multiple calls on a shared fork. Raises on error.

Parameters

  • calls - List of {address, data} tuples (value)
  • opts - Simulation options (default: [], value)

Returns

List of transaction results ([tx_result()])

# descripex:contract
%{
  params: %{
    calls: %{description: "List of {address, data} tuples", kind: :value},
    opts: %{default: [], description: "Simulation options", kind: :value}
  },
  returns: %{type: "[tx_result()]", description: "List of transaction results"}
}

simulate_call(address, data, opts \\ [])

@spec simulate_call(String.t() | binary(), String.t(), sim_opts()) ::
  {:ok, String.t()} | {:error, evm_error()}

Simulate a read-only contract call locally using a forked EVM state.

Parameters

  • address - Contract address as 0x hex string or 20-byte binary (value)
  • data - 0x-prefixed hex-encoded calldata (from ABI.encode_call) (value)
  • opts - Options: :rpc_url (required), :block, :from, :value, :gas_limit, :timeout_ms, :state_overrides (default: [], value)

Returns

Raw 0x-prefixed hex output, compatible with ABI.decode_response/2 ({:ok, hex_string} | {:error, evm_error()})

# descripex:contract
%{
  params: %{
    data: %{
      description: "0x-prefixed hex-encoded calldata (from ABI.encode_call)",
      kind: :value
    },
    opts: %{
      default: [],
      description: "Options: :rpc_url (required), :block, :from, :value, :gas_limit, :timeout_ms, :state_overrides",
      kind: :value
    },
    address: %{
      description: "Contract address as 0x hex string or 20-byte binary",
      kind: :value
    }
  },
  returns: %{
    type: "{:ok, hex_string} | {:error, evm_error()}",
    description: "Raw 0x-prefixed hex output, compatible with ABI.decode_response/2"
  }
}

simulate_call!(address, data, opts \\ [])

@spec simulate_call!(String.t() | binary(), String.t(), sim_opts()) :: String.t()

Simulate a read-only contract call. Raises on error.

Parameters

  • address - Contract address (value)
  • data - 0x-prefixed hex-encoded calldata (value)
  • opts - Simulation options (default: [], value)

Returns

Raw 0x-prefixed hex output (string)

# descripex:contract
%{
  params: %{
    data: %{description: "0x-prefixed hex-encoded calldata", kind: :value},
    opts: %{default: [], description: "Simulation options", kind: :value},
    address: %{description: "Contract address", kind: :value}
  },
  returns: %{type: :string, description: "Raw 0x-prefixed hex output"}
}

simulate_transaction(address, data, opts \\ [])

@spec simulate_transaction(String.t() | binary(), String.t(), sim_opts()) ::
  {:ok, tx_result()} | {:error, evm_error()}

Simulate a full transaction locally, returning gas usage, output, and logs.

Parameters

  • address - Contract address as 0x hex string or 20-byte binary (value)
  • data - 0x-prefixed hex-encoded calldata (value)
  • opts - Options: :rpc_url (required), :block, :from, :value, :gas_limit, :timeout_ms, :state_overrides (default: [], value)

Returns

Transaction result with :success, :gas_used, :output, :logs ({:ok, tx_result()} | {:error, evm_error()})

# descripex:contract
%{
  params: %{
    data: %{description: "0x-prefixed hex-encoded calldata", kind: :value},
    opts: %{
      default: [],
      description: "Options: :rpc_url (required), :block, :from, :value, :gas_limit, :timeout_ms, :state_overrides",
      kind: :value
    },
    address: %{
      description: "Contract address as 0x hex string or 20-byte binary",
      kind: :value
    }
  },
  returns: %{
    type: "{:ok, tx_result()} | {:error, evm_error()}",
    description: "Transaction result with :success, :gas_used, :output, :logs"
  }
}

simulate_transaction!(address, data, opts \\ [])

@spec simulate_transaction!(String.t() | binary(), String.t(), sim_opts()) ::
  tx_result()

Simulate a full transaction. Raises on error.

Parameters

  • address - Contract address (value)
  • data - 0x-prefixed hex-encoded calldata (value)
  • opts - Simulation options (default: [], value)

Returns

Transaction result map (tx_result())

# descripex:contract
%{
  params: %{
    data: %{description: "0x-prefixed hex-encoded calldata", kind: :value},
    opts: %{default: [], description: "Simulation options", kind: :value},
    address: %{description: "Contract address", kind: :value}
  },
  returns: %{type: "tx_result()", description: "Transaction result map"}
}