On this page:
2.1 A first scenario
2.2 The scenario language
2.2.1 Setup directives
2.2.2 Actions
2.3 The four observations
2.3.1 Receipt (with decoded logs and revert reasons)
2.3.2 Call trace
2.3.3 Step trace
2.3.4 State diff and root
2.4 The engine API
make-simulator
sim-account!
sim-fund!
name->address
eth
sim-deploy!
sim-send!
sim-view
sim-mine!
sim-withdraw!
sim-balance
sim-nonce
sim-code
sim-storage
sim-address
sim-state-root
sim-diff
receipt
9.3

2 #lang evm-redex/sim — simulating transactions🔗

 #lang evm-redex/sim package: evm-redex

Where #lang evm-redex/asm — writing and running EVM assembly runs a single code fragment, #lang evm-redex/sim runs transactions: its source is a scenario that declares accounts and initial state and then submits a sequence of transactions against an evolving chain. Running the module executes the whole scenario — validating nonces, charging gas, threading the world from one transaction to the next — and prints a receipt per transaction plus the final state root and a diff.

The engine underneath is also usable directly, (require evm-redex/sim); the scenario language is a thin front-end over it.

Honest limits, inherited from the semantics: the active hard fork is the current-fork parameter (the scenario’s .fork sets it), not derived from the block; EIP-7702 authorizations and EIP-4844 blobs are the library’s simplified forms (signature recovery assumed already done). The state root is the library’s own Merkle-Patricia trie.

2.1 A first scenario🔗

#lang evm-redex/sim
.fork Prague
.account ALICE balance=10eth
.account BOB
.deploy TOKEN from=ALICE code=@Token.json:Token
 
tx from=ALICE to=TOKEN sig="mint(address,uint256)" args=(ALICE, 1000)
tx from=ALICE to=TOKEN sig="transfer(address,uint256)" args=(BOB, 100)
call from=BOB to=TOKEN sig="balanceOf(address)" args=(BOB) returns=uint256

Running it prints a receipt for each tx, the decoded result of the call, and the final state:

deployed TOKEN at 0x479a82c754d9b3e2bfebf618dad979ccadc5cce7

tx ALICE -> TOKEN

  status:    success

  gas used:  68764

  log:       Transfer(from=0x0, to=0xacc0…0001, value=0x3e8)

  return:    0x

tx ALICE -> TOKEN

  status:    success

  gas used:  52122

  log:       Transfer(from=0xacc0…0001, to=0xacc0…0002, value=0x64)

  return:    0x0000…0001

call TOKEN.balanceOf(address) = (100)

 

--- final state ---

state root: 0x81a5f2e3…4882

0x479a82…cce7  nonce 0->1

    slot 0x0: 0x0 -> 0x3e8

    slot 0x25d8…1e3c: 0x0 -> 0x64

    slot 0x6e05…b3bb: 0x0 -> 0x384

0xacc0…0001  nonce 0->3

2.2 The scenario language🔗

One command per line; ; starts a comment. A command is a head followed by positionals and key=value fields. Values are: numbers (0x… or decimal, optional eth/gwei/wei unit), account names (bare identifiers, bound by the setup directives), quoted "signatures", tuples (a, b, …) for call arguments, storage maps {slot:val, …}, code references @file:Contract, and relative +N for block fields.

2.2.1 Setup directives🔗

  • .fork Name — the hard fork (default Prague).

  • .account NAME [0xaddr] [balance=…] [nonce=…] — an externally-owned account. With no explicit address the account’s address is derived from its name (the low 20 bytes of keccak(NAME)), so it is deterministic and reproducible across runs. balance takes a unit (10eth).

  • .contract NAME 0xaddr code=… [balance=…] [storage={0x0:0x7b, …}] — a pre-existing contract: runtime code and initial storage, installed directly.

  • .deploy NAME from=… code=… [value=…] — run a creation transaction and bind NAME to the created address. With a @file.json code reference the contract’s ABI is remembered, so its events decode in later receipts.

  • .block [number=… timestamp=… coinbase=… basefee=…] — set the current block environment (absolute, or +N relative).

  • .trace call|step|off — the default trace for later txs.

2.2.2 Actions🔗

  • tx from=… to=… [value=…] [gas=…] [sig="…" args=(…) | data=0x…] [gasprice=… | maxfee=… maxpriority=…] [trace=call|step] — submit a transaction and print its receipt. The tx type follows the fee fields (gasprice → legacy, maxfee/maxpriority → EIP-1559). sig + args are ABI-encoded for you; args may name accounts.

  • call from=… to=… [sig="…" args=(…)] [returns=types] — a read-only frame call (no state change); prints the decoded return.

  • mine [number=… timestamp=… coinbase=…] — close the current block (EIP-4788/2935 system calls, then withdrawals) and advance to the next (number +1, timestamp +12 unless overridden).

  • withdrawal to=… amount=… — a withdrawal credited on the next mine.

Code references. code=@file.json:Contract reads a solc/Foundry artifact (creation bytecode + ABI) via read-artifact; code=@file reads raw hex; code=0x… is inline bytecode. Paths resolve relative to the scenario file.

2.3 The four observations🔗

2.3.1 Receipt (with decoded logs and revert reasons)🔗

Every tx prints a receipt: status (success/revert/error), gas used, each log, and the return data. Logs emitted by a contract whose ABI is known (deployed from a .json artifact) are decoded to EventName(arg=…, …); a revert is decoded to its reason — Error("…"), Panic(0x…: …), a custom-error selector, or raw bytes:

tx ALICE -> TOKEN

  status:    revert  (Error("insufficient balance"))

  gas used:  23742

  return:    0x08c379a0…

2.3.2 Call trace🔗

trace=call prepends the tree of nested CALL/DELEGATECALL/ STATICCALL/CREATEs — caller, callee, value, gas, and success — built on the interpreter’s current-call-observer hook:

call trace:

  call 0x…a11ce -> 0x…token  value=0 gas=…  

    staticcall 0x…token -> 0x…oracle  value=0 gas=…    Error("stale price")

2.3.3 Step trace🔗

trace=step prepends one line per opcode (pc, mnemonic, resulting stack), nested frames indented, from current-frame-tracer:

step trace:

   0  PUSH1  stack (0x80)

   2  PUSH1  stack (0x40 0x80)

   4  MSTORE stack ()

2.3.4 State diff and root🔗

After the last command the scenario prints the state root and a diff from the initial state — balance deltas, nonce changes, and per-account storage slots that moved (shown in the first example above).

2.4 The engine API🔗

The same simulator is available as functions through (require evm-redex/sim) — a mutable chain that the scenario language drives, and that you can drive yourself.

procedure

(make-simulator [#:fork fork    
  #:coinbase coinbase    
  #:number number    
  #:timestamp timestamp    
  #:basefee basefee    
  #:gas gas    
  #:gas-price gas-price])  simulator?
  fork : symbol? = 'Prague
  coinbase : exact-nonnegative-integer? = 0
  number : exact-nonnegative-integer? = 0
  timestamp : exact-nonnegative-integer? = 0
  basefee : exact-nonnegative-integer? = 0
  gas : exact-positive-integer? = 10000000
  gas-price : exact-nonnegative-integer? = 0
A fresh chain: an empty world, a block at the given environment, and the given transaction defaults.

procedure

(sim-account! s    
  name    
  [#:address address    
  #:balance balance    
  #:nonce nonce    
  #:code code    
  #:storage storage    
  #:abi abi])  exact-nonnegative-integer?
  s : simulator?
  name : (or/c #f symbol?)
  address : (or/c #f exact-nonnegative-integer?) = #f
  balance : exact-nonnegative-integer? = 0
  nonce : exact-nonnegative-integer? = 0
  code : (listof byte?) = '()
  storage : list? = '()
  abi : any/c = #f

procedure

(sim-fund! s who wei)  void?

  s : simulator?
  who : (or/c symbol? exact-nonnegative-integer?)
  wei : exact-nonnegative-integer?

procedure

(name->address name)  exact-nonnegative-integer?

  name : symbol?

procedure

(eth n)  exact-nonnegative-integer?

  n : real?
Declare an account (binding name for later use) and top up a balance. With no #:address, the account’s address is (name->address name) — the low 20 bytes of keccak (name), deterministic across runs. eth is the wei in n ether.

procedure

(sim-deploy! s    
  creation    
  #:from from    
  [#:value value    
  #:gas gas    
  #:name name    
  #:abi abi])  exact-nonnegative-integer?
  s : simulator?
  creation : (listof byte?)
  from : (or/c symbol? exact-nonnegative-integer?)
  value : exact-nonnegative-integer? = 0
  gas : (or/c #f exact-positive-integer?) = #f
  name : (or/c #f symbol?) = #f
  abi : any/c = #f
Run a creation transaction, bind name to the created address, and remember abi (so the contract’s events decode in receipts).

procedure

(sim-send! s    
  #:from from    
  #:to to    
  [#:value value    
  #:data data    
  #:sig sig    
  #:args args    
  #:gas gas    
  #:gas-price gas-price    
  #:max-fee max-fee    
  #:max-priority max-priority    
  #:trace trace])  receipt?
  s : simulator?
  from : (or/c symbol? exact-nonnegative-integer?)
  to : (or/c symbol? exact-nonnegative-integer?)
  value : exact-nonnegative-integer? = 0
  data : (or/c #f (listof byte?)) = #f
  sig : (or/c #f string?) = #f
  args : list? = '()
  gas : (or/c #f exact-positive-integer?) = #f
  gas-price : (or/c #f exact-nonnegative-integer?) = #f
  max-fee : (or/c #f exact-nonnegative-integer?) = #f
  max-priority : (or/c #f exact-nonnegative-integer?) = #f
  trace : (or/c #f 'call 'step) = #f
Submit a transaction from from’s current nonce and return a receipt. Give either #:data or #:sig + #:args (ABI-encoded, names resolved). The world advances (even on revert — gas is still spent and the nonce still bumps).

procedure

(sim-view s    
  [#:from from]    
  #:to to    
  [#:sig sig    
  #:args args    
  #:data data    
  #:returns returns])  any/c
  s : simulator?
  from : (or/c symbol? exact-nonnegative-integer?) = 0
  to : (or/c symbol? exact-nonnegative-integer?)
  sig : (or/c #f string?) = #f
  args : list? = '()
  data : (or/c #f (listof byte?)) = #f
  returns : (or/c #f string? (listof string?)) = #f
A read-only frame call; returns the decoded values when #:returns is given, else the raw return bytes.

procedure

(sim-mine! s    
  [#:number number    
  #:timestamp timestamp    
  #:coinbase coinbase])  void?
  s : simulator?
  number : (or/c #f exact-nonnegative-integer?) = #f
  timestamp : (or/c #f exact-nonnegative-integer?) = #f
  coinbase : (or/c #f exact-nonnegative-integer?) = #f

procedure

(sim-withdraw! s who wei)  void?

  s : simulator?
  who : (or/c symbol? exact-nonnegative-integer?)
  wei : exact-nonnegative-integer?
Close the current block — the EIP-4788/EIP-2935 system calls (no-ops unless those contracts exist) then the queued withdrawals — and advance the block.

procedure

(sim-balance s who)  exact-nonnegative-integer?

  s : simulator?
  who : any/c

procedure

(sim-nonce s who)  exact-nonnegative-integer?

  s : simulator?
  who : any/c

procedure

(sim-code s who)  (listof byte?)

  s : simulator?
  who : any/c

procedure

(sim-storage s who slot)  exact-nonnegative-integer?

  s : simulator?
  who : any/c
  slot : exact-nonnegative-integer?

procedure

(sim-address s name)  (or/c #f exact-nonnegative-integer?)

  s : simulator?
  name : symbol?

procedure

(sim-state-root s)  bytes?

  s : simulator?

procedure

(sim-diff s)  list?

  s : simulator?
Read the current state, the state root, and the diff from the initial snapshot: each diff entry is (list addr balance-delta nonce-before nonce-after storage-changes), where a storage change is (list slot before after).

struct

(struct receipt (status
    gas-used
    output
    revert-reason
    logs
    logs-text
    calls
    steps
    err)
    #:transparent)
  status : (or/c 'success 'revert 'error)
  gas-used : exact-nonnegative-integer?
  output : (listof byte?)
  revert-reason : (or/c #f string?)
  logs : list?
  logs-text : (listof string?)
  calls : any/c
  steps : any/c
  err : (or/c #f string?)
What sim-send! returns; print-receipt renders it. logs-text is the decoded one-line-per-log rendering; calls / steps are the traces when requested, else #f.