The evm-redex test suite: tutorial, tests, and examples
This documents the test suite bundled with the evm-redex library, under its "tests/" directory: the worked property-based-testing examples, the unit suites, the bundled conformance corpora, the Web3Bugs reproduction benchmark, the Equivalence testing harness, and the Coverage demonstration. Every test module imports the specification through its public surface ((require evm-redex), (require evm-redex/pbt), (require evm-redex/crypto)) — never through "private/".
Run it all with raco test tests (or a single layer, e.g. raco test tests/unit); see Running the tests.
4.5 Validation: separating a weak property from a strong one |
The documentation opens with a hands-on Tutorial: testing Solidity contracts, which applies the property-based testing library to real Solidity contracts — every example in it is a runnable module under "tests/". (The library’s own documentation is the reference for each function the tutorial uses.)
The tests themselves are organised in three independent, self-checking layers: fast unit suites, the bundled conformance corpora run under rackunit, and the parallel conformance runner that drives the full battery and prints a CI-style report. The pure Racket build passes all three on its own; the optional native accelerators, when present, are validated to produce identical results.
1 Tutorial: testing Solidity contracts
This chapter is a hands-on introduction to the evm-redex/pbt library: it applies it to three contracts from the Solidity documentation, in increasing order of difficulty. The Storage example fixes the whole pipeline (compile, deploy, observe, check); Ballot adds constructor arguments, access control, and a tally to verify; and BlindAuction adds time-dependent behaviour, Ether transfers, and a hash commitment. Each example is a runnable module in this project, with its Solidity source, a solc artifact, and a property file, and each subsection’s Running block shows the actual test output. The raco test and read-artifact paths below are relative to this project’s root.
The workflow is always the same four moves — read-artifact → deploy → encode-call / call / abi-decode to observe → define-evm-property to state and check the law. The Library reference: the property-based testing DSL in the library’s documentation is the complete reference for every function used here.
Property-based testing here means: you fix the program under test and let the engine generate the environment and inputs, running each trial through the semantics and searching for a counterexample (which it shrinks to a minimal failing case). It finds bugs; it does not prove their absence.
1.1 A storage contract
We start from the Storage example in the Solidity documentation’s introduction to smart contracts. It is the smallest contract that still has interesting behaviour: one state variable, a setter and a getter.
// SPDX-License-Identifier: GPL-3.0 |
pragma solidity >=0.4.16 <0.9.0; |
|
contract SimpleStorage { |
uint storedData; |
|
function set(uint x) public { storedData = x; } |
function get() public view returns (uint) { return storedData; } |
} |
The source, the compiled artifact, and the finished property file are bundled in "storage/" ("Storage.sol", "storage.json", "storage-props.rkt"); this section reconstructs that file step by step.
1.1.1 Step 1: compile the contract
The library tests EVM bytecode, so first compile the source with the solc on the dev shell’s PATH. The –combined-json shape is one read-artifact understands:
solc --combined-json bin,bin-runtime,abi Storage.sol > storage.json |
This produces both the creation (init) code, which runs the constructor and returns the runtime code, and the runtime code that ends up stored at the address.
1.1.2 Step 2: deploy it through the semantics
read-artifact pulls the two bytecodes out of the JSON; deploy runs the creation code as a real contract-creation transaction and hands back the post-deployment world with the runtime installed:
(require evm-redex/pbt) |
|
(define art (read-artifact "storage/storage.json" |
#:contract "SimpleStorage")) |
(define dep (deploy (artifact-creation art))) |
|
(deploy-result-ok? dep) ; => #t |
(define S (deploy-result-address dep)) ; the contract's address |
(define BASE (deploy-result-world dep)) ; world with the runtime code installed |
Deploying (running the creation code) is the faithful path: it runs the constructor and installs exactly the runtime code the chain would. Installing the runtime directly with #:contract/#:address is a shortcut that skips the constructor — fine for SimpleStorage, whose constructor does nothing, but wrong for any contract that sets storage or patches immutables in its constructor.
1.1.3 Step 3: read the getter
get() is a view function: it returns a value the transaction layer would discard, so read it with call (a raw frame execution) and decode the returned bytes with abi-decode. encode-call builds the calldata from the function signature:
(define (get-of w) |
(car (abi-decode (list "uint256") |
(call-result-return (call w S #:data (encode-call "get()")))))) |
|
(get-of BASE) ; => 0 (storedData starts at zero) |
1.1.4 Step 4: the round-trip property
Now the actual test. set(x) changes storage, so it must run in transaction mode (#:call a make-tx). The property we want is the round-trip law: for every 256-bit value x, calling set(x) and then get() returns x. #:given draws x from gen-word (edge-biased over the full uint256 range), and the #:post reads the getter from the post-state world w1:
(define-evm-property set-then-get-roundtrips |
#:given ([x gen-word]) |
#:world BASE |
#:call (make-tx #:sender CALLER #:to S #:gas-limit 100000 #:gas-price 0 |
#:data (encode-call "set(uint256)" x)) |
#:post (lambda (w0 w1 r) |
(and (eq? (txn-run-outcome r) 'success) |
(= (get-of w1) x)))) |
|
(check-evm-property set-then-get-roundtrips #:trials 100 #:deadline 300) |
;; => property set-then-get-roundtrips passed 100 tests. |
Because storedData is the contract’s single state variable, it lives at Solidity storage slot 0, so you can also assert on the raw slot directly with sload instead of going through the getter, and check that set wrote exactly that slot:
(define-evm-property set-persists-in-slot-0 |
#:given ([x gen-word]) |
#:world BASE |
#:call (make-tx #:sender CALLER #:to S #:gas-limit 100000 #:gas-price 0 |
#:data (encode-call "set(uint256)" x)) |
#:post (lambda (w0 w1 r) (= (sload w1 S 0) x))) |
Run raco test storage/storage-props.rkt to see the bundled version, which adds a third property (that get is a pure view). Each trial executes the contract through the fast Racket executor, so 100 trials per property run in about a second.
That is the whole loop: compile, deploy, encode-call / call / abi-decode to observe, and define-evm-property to state and check the law. The Library reference: the property-based testing DSL is the reference for each piece.
1.2 A voting contract
The Storage example above is deliberately trivial. This section applies the same pipeline to the Ballot contract from the Solidity documentation’s Solidity by Example (voting with delegation), which has real correctness obligations: access control, one-person-one-vote, and a tally that must return the winner. The bundled version is "voting/" ("Ballot.sol", "ballot.json", "ballot-props.rkt").
1.2.1 The ballot and its correctness properties
The chairperson (the account that deploys the ballot) hands out the right to vote one address at a time; each enfranchised voter may vote once, for one proposal; winningProposal() returns the index of the proposal with the most votes and winnerName() its name. Turning the informal description into checkable laws:
Access control. Only the chairperson may call giveRightToVote; every other caller reverts.
Enfranchisement. After the chairperson grants the right to a fresh address, that address has voting weight 1 and has not yet voted.
One person, one vote. A weighted voter’s vote succeeds, marks them as having voted, and adds their weight to exactly one proposal’s count; a second vote from the same voter reverts.
Bounds safety. Voting for an out-of-range proposal index reverts, so no partial state change survives.
Tally correctness. winningProposal / winnerName compute the arg-max of the vote counts.
1.2.2 Encoding the voting laws
Two wrinkles distinguish this from Storage. First, the constructor takes a bytes32[] of proposal names, so the encoded arguments are appended to the creation code before deploy:
(define art (read-artifact "voting/ballot.json" #:contract "Ballot")) |
(define NAMES (list (name->b32 "alpha") (name->b32 "beta") (name->b32 "gamma"))) |
(define dep (deploy (append (artifact-creation art) |
(abi-encode (list "bytes32[]") (list NAMES))) |
#:from CHAIR)) |
(define V (deploy-result-address dep)) ; the ballot address |
(define BASE (deploy-result-world dep)) |
Second, because the deployer is the chairperson, its account nonce has already advanced by the time we send transactions from it. A small helper reads each sender’s current nonce from the world so that any sender, chairperson included, produces a valid transaction:
(define (nonce-of w a) (let ([acc (world-ref w a)]) (if acc (acct-nonce acc) 0))) |
(define (tx-from w sender to data) |
(make-tx #:sender sender #:nonce (nonce-of w sender) |
#:to to #:gas-limit 300000 #:gas-price 0 #:data data)) |
The negative laws (access control, bounds, double-voting) are the crisp case for #:revert-when: the obligation is simply “this transaction must revert” under a generated precondition. Access control, for example, generates an arbitrary caller and asserts that any non-chairperson reverts:
(define-evm-property give-right-is-chair-only |
#:given ([caller gen-address] [voter gen-address]) |
#:world BASE |
#:call (tx-from BASE caller V (encode-call "giveRightToVote(address)" voter)) |
#:pre (not (= caller CHAIR)) ; a non-chairperson caller ... |
#:revert-when #t) ; ... must always revert |
The positive laws read state back through the public getters with call + abi-decode (the voters mapping getter returns the (weight voted delegate vote) tuple; proposals returns (name voteCount)). A single weighted vote must both bump its proposal’s count and make it the unique winner:
(define-evm-property vote-counts-and-wins |
#:given ([p (gen:integer-in 0 (- N 1))]) |
#:world BASE ; chair is weighted and has not voted |
#:call (tx-from BASE CHAIR V (encode-call "vote(uint256)" p)) |
#:post (lambda (w0 w1 r) |
(and (eq? (txn-run-outcome r) 'success) |
(voter-voted? w1 CHAIR) |
(= (vote-count w1 p) 1) |
(= (winning w1) p)))) |
Tally correctness over a non-trivial tally needs several voters, each casting its own transaction, so it is expressed as a deterministic scenario (three voters producing the counts (0 1 2)) rather than a generated property, and checked with plain check-equal?: the winner must be proposal 2, and winnerName() its name.
1.2.3 Running the voting properties
$ raco test voting/ballot-props.rkt |
✓ property give-right-is-chair-only passed 100 tests. |
✓ property give-right-grants-weight-1 passed 100 tests. |
✓ property vote-counts-and-wins passed 100 tests. |
✓ property vote-out-of-range-reverts passed 100 tests. |
✓ property double-vote-reverts passed 100 tests. |
ballot-props: all checks passed |
1.3 A blind auction with time and Ether
The BlindAuction contract (same Solidity by Example page) is the most demanding of the three: it depends on block.timestamp, moves Ether, and uses a hash commitment. It shows the two features that separate property-based testing from single-scenario tests, namely generating over time and over the secret. The bundled version is "blind-auction/".
1.3.1 The auction and its correctness properties
The auction runs in two timed phases. During bidding (block.timestamp below biddingEnd) a bidder submits keccak256 (abi.encodePacked value fake secret) together with a deposit; during reveal (between biddingEnd and revealEnd) bidders open their commitments. A revealed bid counts only if the hash matches the commitment, the bid is not flagged fake, and the deposit covers the claimed value; after revealEnd, auctionEnd pays the highest bid to the beneficiary. The correctness obligations:
Bidding window. bid succeeds exactly when block.timestamp < biddingEnd and reverts (TooLate) otherwise.
Reveal window. reveal succeeds only strictly inside (biddingEnd revealEnd).
Commitment binding. Revealing the committed bid counts it (the bid becomes the highest and the bidder the highest bidder) only when the opened secret matches the committed one; a wrong secret leaves the auction untouched.
Honest bids only. A bid revealed as fake, or with a deposit below the claimed value, never becomes the highest bid.
Settlement. auctionEnd may run only after revealEnd, is idempotent (a second call reverts), and pays the beneficiary exactly the highest bid.
1.3.2 Encoding time, Ether, and the commitment
Time is an input, so it is controlled through #:block: the property draws a timestamp and builds the block for that trial. Ether is carried by make-tx’s #:value. The commitment is reconstructed exactly as Solidity packs it (a uint256 as 32 bytes, a bool as one byte, a bytes32 as 32 bytes, concatenated, then Keccak-256):
(define (blind value fake secret) ; secret and result are 32-byte lists |
(bytes->list (keccak256 (list->bytes (append (bytes->list (integer->bytes value 32)) |
(list (if fake 1 0)) |
secret))))) |
The window laws are the natural home for the pair #:revert-when / #:succeed-when, both keyed on the generated timestamp: one property states both the must-revert and the must-succeed halves of the guard at once:
(define-evm-property bid-only-before-bidding-end |
#:given ([t (gen-word-in 900 1300)]) |
#:world BASE |
#:block (blk t) |
#:call (mktx BASE BIDDER (encode-call "bid(bytes32)" COMMIT) 1000) |
#:revert-when (>= t BIDDING-END) |
#:succeed-when (< t BIDDING-END)) |
Commitment binding is where a generated input does the real work. We build a COMMITTED world by placing one honest bid during the bidding phase, then generate the secret used at reveal time. The property asserts the two-sided law: the right secret makes the bid win; any other secret (the hash no longer matches) leaves highestBid at zero.
(define-evm-property reveal-honours-the-commitment |
#:given ([s gen-word]) ; the secret supplied at reveal time |
#:world COMMITTED |
#:block (blk 1150) |
#:call (mktx COMMITTED BIDDER (reveal-data VALUE #f (b32 s)) 0) |
#:post (lambda (w0 w1 r) |
(if (= s #x1234) ; == the committed secret |
(and (= (highest-bid w1) VALUE) (= (highest-bidder w1) BIDDER)) |
(= (highest-bid w1) 0)))) ; hash mismatch => not revealed |
Settlement, which chains bid then reveal then auctionEnd across three timestamps, is checked as a deterministic end-to-end scenario: it asserts that the early auctionEnd reverts, that the beneficiary’s balance grows by exactly the highest bid, and that the second auctionEnd reverts.
1.3.3 Running the blind-auction properties
$ raco test blind-auction/blind-auction-props.rkt |
✓ property bid-only-before-bidding-end passed 100 tests. |
✓ property reveal-only-in-window passed 100 tests. |
✓ property reveal-honours-the-commitment passed 100 tests. |
✓ property fake-bid-never-wins passed 100 tests. |
blind-auction-props: all checks passed |
Both examples run every trial through the fast Racket executor — even though the blind auction executes Keccak-256 and value transfers per trial, 100 trials per property finish in a couple of seconds. That the reveal-window and commitment-binding properties pass across the whole generated range is exactly the assurance a handful of hand-written scenarios cannot give.
1.4 Further worked examples
The same pipeline scales from these toy contracts to production Solidity. This project bundles several more worked examples, each with its .sol, a solc artifact, and a property file:
"storage/", "voting/" and "blind-auction/" — the three examples above (the Solidity documentation’s Storage, Ballot and BlindAuction).
"erc20/" and "erc721/" — the minimal ERC-20 / ERC-721 from solidity-by-example.
"open-zeppelin/" — property specifications for a curated set of OpenZeppelin contracts (ERC20, Ownable, AccessControl, ERC721, ERC1155), built from the verbatim OZ sources plus one concrete instance (*Mock.sol) per contract. Each "properties/<Contract>.rkt" attests the invariant classes the OZ audits emphasise: supply / ownership conservation, allowance and access-control semantics, no unauthorized mint, ERC-conformant reverts, and OZ specifics such as the infinite-allowance optimisation not decrementing.
raco test erc20/ ; one bundled contract |
raco test open-zeppelin/properties/ERC20.rkt ; one OpenZeppelin contract |
raco test open-zeppelin/properties/ ; all of them (slow) |
Every trial executes the contract through the fast Racket executor, so hundreds of trials per property run quickly. Running the ERC-721 example even surfaced a genuine interpreter bug (a LOG that failed to pop its stack operands, masked by the conformance fixtures): property-based testing over real contracts exercises paths the reference vectors do not. It finds counterexamples; it does not prove their absence.
2 Layer 1 — unit suites
"unit/" holds focused rackunit suites for the building blocks:
"words-test.rkt" — 256-bit word arithmetic, sign handling.
"mpt-test.rkt" — Merkle-Patricia trie roots against known vectors.
"interp-test.rkt" — opcode-level interpreter behaviour.
"precompiles-test.rkt" — precompiles 0x01–0x11 (ecrecover, hashes, modexp, bn254, BLS12-381, KZG, blake2f).
"forks-test.rkt" — fork ordering, EIP-gate flips at Berlin/London/Shanghai/Prague, and opcode availability (e.g. PUSH0 valid on Cancun, invalid on London, end-to-end).
"m2-test.rkt", "m3-test.rkt", "m4-test.rkt" — message- and transaction-layer behaviour (value transfer, SSTORE, the EIP-7623 calldata floor, contract creation, refunds).
"m13-test.rkt" — the native-accelerator layer: it checks that every accelerator reports 'native or 'pure, that the pure Keccak vector still holds, and — when a native library is loaded — that each accelerator matches the pure oracle (secp256k1 recovery, blst G1 add and pairing, mcl bn254 add and pairing). It also runs the conformance runner in-process over a small corpus.
Run them all:
raco test tests/unit/ |
This takes a few seconds and reports all checks passed per file. In the pure build it is 170 tests; inside the native environment the extra guarded checks bring it to 177. A single file can be run directly, e.g. raco test tests/unit/precompiles-test.rkt.
3 Equivalence testing
| (require evm-redex/tests/equivalence/equivalence) | |
| package: evm-redex | |
Do contracts A and B behave the same? This is the differential question — an optimised rewrite against a naive reference, a patched contract against the version it replaces — and it is the one property-based testing over an executable semantics is best at.
The harness lives here, with the tests, rather than in the library: it is a way of using evm-redex/pbt (a twin world, a generated call sequence, an ABI-level comparison), not a part of the semantics. It is built entirely on the library’s public surface.
The equivalence is stated observationally, at the ABI level: for the same generated sequence of calls, the two contracts must agree on what they return, whether they revert, what they log, and what their getters say afterwards. It is deliberately not a bytecode diff, and deliberately not a storage-slot comparison — an optimised build is entitled to differ in both.
Three facts about the EVM shape the design, and they are worth knowing even if you only use the macro.
Twin worlds, one address. A contract’s address is keccak(rlp(deployer, nonce)), so deploying A and B from the same deployer at the same nonce lands them at the same address, each in its own world. That is the point: address(this) is then not an observable difference, which kills a whole class of false positives (EIP-712 domains, self-referencing mappings). Putting both in one world would force different addresses.
Frame calls, not transactions. The engine drives both sides with call, which does no nonce validation (so the same call can be replayed on both sides) and exposes the return data. run-txn does neither.
A small actor pool. Freshly generated 160-bit addresses never collide, so an approve and a later transferFrom would never refer to the same spender: almost every call would revert on both sides and the property would hold vacuously. Calls are therefore drawn from a small pool of pre-funded actors.
Honest scope: this is bounded falsification, not proof. Equivalence here is always relative to the observations you list and to the reach of your generators — which is why equivalence-report-effective (how many calls actually succeeded) is part of every report.
3.1 Twins
struct
(struct twin (world-a world-b address actors) #:transparent) world-a : list? world-b : list? address : exact-nonnegative-integer? actors : (listof exact-nonnegative-integer?)
procedure
(deploy-twin creation-a creation-b [ #:ctor ctor #:actors actors #:balance balance #:value value #:gas gas]) → twin? creation-a : (listof byte?) creation-b : (listof byte?) ctor : (listof byte?) = '() actors : (listof exact-nonnegative-integer?) = DEFAULT-ACTORS balance : exact-nonnegative-integer? = (expt 2 64) value : exact-nonnegative-integer? = 0 gas : exact-nonnegative-integer? = 30000000
3.2 Calls and generated sequences
struct
(struct evm-call (sig args from value) #:transparent) sig : string? args : list? from : exact-nonnegative-integer? value : exact-nonnegative-integer?
procedure
(gen-evm-call schemas [ #:actors actors #:value gval]) → gen? schemas : list? actors : list? = DEFAULT-ACTORS gval : gen? = (gen:const 0)
procedure
(gen-call-sequence schemas [ #:length n #:actors actors #:value gval]) → gen? schemas : list? n : exact-nonnegative-integer? = 5 actors : list? = DEFAULT-ACTORS gval : gen? = (gen:const 0)
(define SCHEMAS (list (list "transfer(address,uint256)" (gen:one-of ACTORS) gen-amount) (list "approve(address,uint256)" (gen:one-of ACTORS) gen-amount) (list "transferFrom(address,address,uint256)" (gen:one-of ACTORS) (gen:one-of ACTORS) gen-amount)))
3.3 The engine
procedure
(run-equivalence tw seq [ #:observe getters #:compare cmp]) → equivalence-report? tw : twin? seq : (listof evm-call?) getters : list? = '() cmp : (listof symbol?) = '(outcome return logs state)
The comparison kinds:
Kind |
| Compares |
'outcome |
| the outcome, normalised to ok / revert / fail. The revert BYTES are not compared: custom errors carry the contract's own selectors, so equal behaviour routinely produces different revert data. |
'return |
| the returned bytes, when both sides succeeded. |
'logs |
| the events emitted. |
'state |
| after the sequence, each #:observe getter on both worlds. Getters, never storage slots. |
'gas-le |
| a BOUND, not an equality: B must not use more gas than A. Not in the default set — two implementations of the same behaviour essentially never cost the same, which is usually the whole point of B. |
struct
(struct equivalence-report (diverged? step kind a b effective) #:transparent) diverged? : (or/c #f symbol?) step : any/c kind : any/c a : any/c b : any/c effective : exact-nonnegative-integer?
procedure
(equivalent? rep) → boolean?
rep : equivalence-report?
procedure
(explain-equivalence tw seq [ #:observe getters #:compare cmp]) → equivalence-report? tw : twin? seq : (listof evm-call?) getters : list? = '() cmp : (listof symbol?) = '(outcome return logs state)
3.4 The macro
syntax
(define-evm-equivalence name clause ...)
clause = #:twin tw | #:contracts (creation-a creation-b) | #:ctor bytes | #:actors addrs | #:given ([x gen] ...) | #:sequence seq | #:call one | #:observe getters | #:compare kinds | #:trials n | #:seed n
Give either #:twin (a twin you built yourself) or #:contracts (two creation codes, deployed once at definition time — not per trial). Prefer #:twin when you want to hand the shrunk counterexample to explain-equivalence, which needs the twin.
(define TW (deploy-twin REF MUTANT #:actors ACTORS)) (define-evm-equivalence mutant-is-distinguished #:twin TW #:given ([seq (gen-call-sequence SCHEMAS #:length 8 #:actors ACTORS)]) #:sequence (append SETUP seq) #:observe GETTERS #:compare '(outcome return logs state) #:trials 100 #:seed 20260712)
#:sequence is any expression producing a list of evm-call — the example above prepends a fixed SETUP prefix (mints that fund the actors) to the generated part, so the generated calls can get straight to the behaviour under test instead of having to stumble on a valid state by luck. That trick, and a small actor pool, are usually what stands between a property that finds the bug and one that passes vacuously.
3.5 How the harness is validated
A harness that only ever reports equivalent is worthless, so "equivalence/erc20-equivalence.rkt" pins down all three outcomes it must be able to produce. The contracts ("equivalence/contracts/ERC20Ref.sol" and "ERC20Mutant.sol") are compiled and committed alongside it.
Self-equivalence — ERC20Ref against itself: must pass. If this ever falsifies, the harness itself is nondeterministic.
Optimiser — ERC20Ref against its own solc –optimize build (same source, 2652 vs 5486 bytes of bytecode): must pass. This is what proves the comparison is behavioural and not a bytecode diff.
Mutant — ERC20Ref against ERC20Mutant, which carries one seeded bug (transferFrom checks the allowance but never debits it): must falsify. The counterexample shrinks to two calls, approve followed by transferFrom, and the divergence is reported on the allowance getter.
"pbt/equivalence-test.rkt" pins the pieces underneath: twins land at the same address in two separate worlds, equivalence-report-effective counts only the calls that actually succeeded, gas is enforced as a bound rather than an equality, and a reverted call emits no logs.
The mutant case is also a lesson about generators. With three actors and a six-call sequence the seeded bug was not caught in 40 trials — the engine was fine, the generator’s reach was not. A two-actor pool and a fixed setup prefix (mints that fund the actors) are what make the property find it.
raco test tests/equivalence/ |
4 Coverage
| (require evm-redex/tests/coverage/coverage) | |
| package: evm-redex | |
How much of the contract did a property actually execute? A property can pass 200 trials and never enter the require(allowance >= v) branch — the green tick says nothing about reach. This harness measures it.
Like the equivalence harness, it lives here rather than in the library: it is a way of using the semantics, not part of them. It is built on the library’s public surface plus the one thing an outside tool cannot reconstruct for itself — which instruction each frame executed. The library exposes exactly that, and nothing more, as Tracing hook (current-frame-tracer): a hook called once per frame, returning a per-step procedure. Everything below — the sink, the denominator, the branch bookkeeping, the source maps, the reports — is ordinary Racket on top of it.
Because that hook sits in the interpreter’s driver loop rather than in either executor, the numbers are identical under both execution backends, are not inflated by the EVM_ORACLE double-execution, and nested CALL / DELEGATECALL frames and a CREATE’s constructor are all seen.
4.1 The two measures
Instructions — the instruction starts of the runtime code that were executed. The denominator is the walk that steps over PUSH immediates (a byte inside PUSH data is not an instruction and can never be covered), minus solc’s non-executable metadata trailer, which would otherwise cap coverage at an arbitrary ceiling.
JUMPI branches — for each JUMPI, whether both sides were taken. This is the measure that catches a weak property: instruction coverage saturates early, while a require the generators never falsify leaves its JUMPI one-sided forever.
Coverage is necessary, not sufficient. 100% of the instructions with a vacuous #:post still proves nothing — it says the code ran, not that anything was checked. Read it as a lower bound on the property’s reach, never as evidence of correctness.
4.2 Collecting
procedure
(evm-coverage? v) → boolean?
v : any/c
syntax
(with-coverage body ...)
syntax
(without-coverage body ...)
procedure
(check-evm-property/coverage p [ #:trials trials #:seed seed #:deadline deadline]) → evm-coverage? p : evm-property? trials : (or/c #f exact-positive-integer?) = #f seed : (or/c #f exact-integer?) = #f deadline : (or/c #f real?) = #f
procedure
(run-evm-property/coverage p [ #:trials trials #:seed seed #:deadline deadline])
→
evm-result? evm-coverage? p : evm-property? trials : (or/c #f exact-positive-integer?) = #f seed : (or/c #f exact-integer?) = #f deadline : (or/c #f real?) = #f
procedure
(coverage-merge cov ...) → evm-coverage?
cov : evm-coverage?
4.3 Reading the result
struct
(struct code-coverage ( address kind size metadata instructions hit jumpi-total jumpi-both jumpi-one-sided) #:transparent) address : exact-nonnegative-integer? kind : (or/c 'runtime 'init) size : exact-nonnegative-integer? metadata : exact-nonnegative-integer? instructions : exact-nonnegative-integer? hit : exact-nonnegative-integer? jumpi-total : exact-nonnegative-integer? jumpi-both : exact-nonnegative-integer? jumpi-one-sided : exact-nonnegative-integer?
procedure
(coverage-summary cov #:address address [ #:kind kind]) → (or/c #f code-coverage?) cov : evm-coverage? address : exact-nonnegative-integer? kind : (or/c 'runtime 'init) = 'runtime
procedure
(coverage-summaries cov) → (listof code-coverage?)
cov : evm-coverage?
procedure
(instruction-ratio cc) → real?
cc : code-coverage?
procedure
(branch-ratio cc) → real?
cc : code-coverage?
procedure
(uncovered-pcs cov #:address address [ #:kind kind]) → (listof (list/c exact-nonnegative-integer? symbol?)) cov : evm-coverage? address : exact-nonnegative-integer? kind : (or/c 'runtime 'init) = 'runtime
procedure
(one-sided-jumpis cov #:address address [ #:kind kind])
→
(listof (list/c exact-nonnegative-integer? (or/c 'only-taken 'only-fallthrough))) cov : evm-coverage? address : exact-nonnegative-integer? kind : (or/c 'runtime 'init) = 'runtime
procedure
(print-coverage cov [#:uncovered n]) → void?
cov : evm-coverage? n : exact-nonnegative-integer? = 0
coverage 0xc7d1…9067 (runtime, 3782 bytes, 53 of metadata dropped) |
instructions 599/1787 33.5% |
jumpi branches 2/27 7.4% (14 reached but one-sided) |
4.4 Solidity lines
When the artifact carries a runtime source map, the same hits lift to source lines. solc’s map has one entry per instruction, in order, so the i-th entry belongs to the i-th instruction start — and the two counts agreeing is an independent check that the instruction walk is right.
An instruction is attributed to the line of its start offset only, not to its whole source range: solc maps the dispatcher to the entire contract, and honouring the range would paint every line green. And under –optimize the attribution is approximate — the optimiser merges and moves instructions, so a line reported as missed may be one whose code was folded elsewhere.
procedure
(coverage-lines cov art #:address address [ #:kind kind #:root root]) → hash? cov : evm-coverage? art : artifact? address : exact-nonnegative-integer? kind : (or/c 'runtime 'init) = 'runtime root : path-string? = "."
procedure
(line-ratio lines) → real?
lines : hash?
procedure
(print-source-coverage cov art #:address address [ #:kind kind #:root root #:files files #:misses-only misses-only]) → void? cov : evm-coverage? art : artifact? address : exact-nonnegative-integer? kind : (or/c 'runtime 'init) = 'runtime root : path-string? = "." files : (or/c #f (listof string?)) = #f misses-only : boolean? = #f
contracts/Token.sol — lines 10/34 29.4% |
✓ 24 | function transfer(address to, uint256 amount) public returns (bool) { |
✓ 25 | require(to != address(0), "zero address"); |
✗ 33 | function approve(address spender, uint256 amount) public returns (bool) { |
✗ 34 | allowance[msg.sender][spender] = amount; |
Build an artifact with the map by adding srcmap-runtime to solc’s –combined-json list; it leaves the bytecode byte-for-byte identical, so it can be added to any existing artifact without moving anything else in a suite.
4.5 Validation: separating a weak property from a strong one
The house rule: a measure you have never seen distinguish anything is not evidence. "coverage/coverage-demo.rkt" states two properties over the same contract ("coverage/contracts/Token.sol", a plain token). Both are true, and both pass. They differ only in reach:
The weak one calls a single function, transfer, and only ever with amounts the sender can afford. Every require in the contract succeeds on every trial.
The strong one drives all four entry points and lets the generators produce amounts that overshoot the balance and the allowance, and a zero recipient — so the failing side of each check is exercised too.
A green suite cannot tell these apart. Coverage can, and the module asserts that it does: the weak property covers strictly fewer instructions and fewer lines, never enters approve / transferFrom / burn (checked at the level of specific source lines), and leaves strictly more branches one-sided.
=== weak property === |
instructions 599/1787 33.5% |
jumpi branches 2/27 7.4% (14 reached but one-sided) |
|
=== strong property === |
instructions 1468/1787 82.1% |
jumpi branches 10/27 37.0% (13 reached but one-sided) |
Run racket coverage/coverage-demo.rkt to also print the annotated Solidity listing of what the weak property never executed.
"pbt/coverage-test.rkt" pins the mechanism underneath. Two checks carry most of the weight. First, the instruction walk (which steps over PUSH immediates) is compared against the number of entries in solc’s runtime source map — two independent computations of "how many instructions does this code have", which must agree, or every pc → line attribution would be off by some amount. Second, the covered instructions and the uncovered ones must partition the instruction set exactly, which is what says no byte inside a PUSH immediate was ever reported as executed. The rest: constructor coverage is kept apart from runtime coverage, coverage-merge is a union, and the numbers are identical under the Redex reference backend — which they must be, since the tracing hook lives in the driver loop rather than in either executor.
The artifact for this demo is built with the runtime source map and without the optimiser: solc –combined-json bin,bin-runtime,abi,srcmap-runtime contracts/Token.sol. The optimiser makes the line attribution approximate, since it merges and moves instructions.
raco test tests/coverage/ |
5 Layer 2 — bundled conformance corpora (rackunit)
The repository bundles curated ethereum/tests fixtures: "conformance/fixtures/" (GeneralStateTests) and "conformance/blockchain-fixtures/" (block-level tests). Two rackunit entry points execute them and assert post-state roots and log hashes:
"conformance-test.rkt" — state tests: builds the pre-world and transaction from JSON, runs the semantics, and compares the state root and logs hash. Every fixture runs under every supported fork in its post map (e.g. Cancun and Prague).
"blockchain-test.rkt" — block-level processing: system calls (beacon-root, history), ordered transactions of every type, and withdrawals, compared to the block’s state root.
Run them:
raco test tests/conformance/ |
This is the correctness gate: a divergent state root fails the corresponding check with the expected-vs-actual roots.
6 Layer 3 — the parallel conformance runner
"conformance/runner.rkt" is the full-battery driver and the tool to use when iterating. It:
discovers every fixture and explodes each state fixture into one work unit per (test, fork, post-entry);
distributes the units across racket/place workers (one per core) with a per-unit timeout safeguard;
prints a report bucketed by failure category — fail:state-root, fail:logs-hash, error:*, timeout — and exits non-zero if anything is non-pass.
The fine granularity is deliberate: a single fixture with dozens of post-entries would otherwise serialise on one worker. The per-unit timeout is a safeguard, but note that some pure-Racket primitives (large-bignum modular-expt, pairing) are not interruptible by the thread scheduler, so the timeout cannot bound them — that is exactly what the optional native accelerators address.
# both bundled corpora, one worker per core: |
racket tests/conformance/runner.rkt |
|
# tune parallelism / timeout, or point it at specific dirs or files: |
racket tests/conformance/runner.rkt --jobs 22 --timeout 120 path/to/dir ... |
|
# run one file sequentially in-process (easiest to debug): |
racket tests/conformance/runner.rkt --seq --quiet a.json |
Options are –jobs N, –timeout SECONDS (per unit), –seq (no places, in-process), –samples N (failing names shown per category), and –quiet. Over the bundled corpora a full run reports 1274 pass / 0 fail (135 files → 1269 units across Cancun and Prague).
7 Running with the native accelerators
The pure build passes the whole battery unaided. To also exercise the native crypto paths, enter the Nix environment, which provisions GMP, libsecp256k1, blst, and mcl and puts them on LD_LIBRARY_PATH:
nix develop # or: nix-shell |
racket -e '(require evm-redex) native-status' |
;; => ((keccak256 . pure) (secp256k1-recover . native) (modular-expt . native) |
;; (blst . native) (mcl-bn254 . native)) |
|
raco test tests/unit/ # 177 tests (native paths validated) |
racket tests/conformance/runner.rkt # 1274 / 0, accelerators active |
Because each accelerator is oracle-gated (adopted only after it reproduces the pure result), the pure and native environments return identical outcomes — the same 1274 pass / 0 fail either way.
8 Running the tests
The whole suite lives under the library’s "tests/" directory and runs with raco:
raco test tests # the whole suite |
raco test tests/unit # one layer (fast unit suites) |
raco test tests/conformance # bundled ethereum/tests corpora (rackunit) |
racket tests/conformance/runner.rkt # the full parallel conformance runner |
The bundled JSON corpora ("tests/conformance/fixtures", "…/blockchain-fixtures") are data, not runnable modules — the package’s test-omit-paths keeps raco test from descending into them.
Requirements.
Core: Racket 8+ with the redex-lib, rackcheck-lib, and rackunit-lib packages (the full Racket distribution bundles them; on racket-minimal, raco pkg install them). libgmp ships with Racket and is used automatically. This is all that is needed for the entire battery in pure Racket.
Optional, for the native paths: Nix with flakes — nix develop builds and loads everything — or, manually, libsecp256k1, blst, and herumi/mcl on LD_LIBRARY_PATH.
See "README.md" for full installation instructions and the "flake.nix" outputs. }