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
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?
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
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
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
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?
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
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?
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?)