fleet/evm-asserts โ€” web3 exploit gym (Ethernaut + Damn Vulnerable DeFi)

Local EVM challenges driven through a JSON-line agent protocol, graded by on-chain post-condition asserts (not by eyeballing). Built for the cyber-factory trajectory pipeline: every episode yields a machine-checkable trajectory + verdict pair.

OpenZeppelin/ethernaut (wargame; contracts sub-project is Foundry; level factories live in contracts/src/levels/) and theredguild/damn-vulnerable-defi (MIT; Foundry-based since v4 โ€” the v3 Hardhat era survives in community ports like maxweisspoker/damn-vulnerable-defi-foundry).

encoder (function selectors aren't in hashlib), verified against published vectors.

Layout


evmlib.py             keccak-256, ABI encode/decode, minimal JSON-RPC client
runner.py             the gym: spec -> agent protocol -> verifier -> trajectory
import_ethernaut.py   deploy Ethernaut hub + level factories on the local node
challenges/*.json     per-challenge task specs (address, ABI, goal, verifier tree)
examples/agent_fallback.py   reference agent: solves Fallback, documents the protocol
setup/fetch_upstream.sh      clone the two wargames into vendor/ (gitignored)
setup/start_anvil.sh         RECOMMENDED node: foundry anvil (deterministic accounts)
setup/start_hardhat.sh       alternative node: `npx hardhat node` (same accounts)
selftest.py           30 offline checks incl. a full mock-chain episode
smoke_test.sh         live end-to-end (needs foundry)

Quickstart


# machine needs: foundry (anvil+forge), git, python3
python3 selftest.py          # offline logic โ€” always run this first
bash smoke_test.sh           # live: Fallback level solved end-to-end

Two supported nodes (documented both, pick one)

anvil (foundry)hardhat network
startbash setup/start_anvil.shHH_DIR=/path/to/hh bash setup/start_hardhat.sh
chain3133731337
accountsmnemonic test โ€ฆ junk (acct0 deployer 0xf39Fโ€ฆ92266, acct1 player 0x7099โ€ฆ79C8)same mnemonic โ†’ same addresses
signingunlocked โ€” runner uses raw eth_sendTransaction, no keyssame

The runner never signs transactions: it relies on unlocked accounts (default on both nodes), which keeps it stdlib-only (no secp256k1 in stdlib). Compile ethernaut contracts with forge (setup/fetch_upstream.sh + import_ethernaut.py does it); the importer also accepts hardhat-style artifacts (artifacts/<Name>.json) if you prefer a hardhat build.

Ethernaut import

import_ethernaut.py builds with forge, then deploys Ethernaut (the hub) and each <Level>Factory, and calls registerLevel (hub-owner only) โ€” matching upstream's verified API: createLevelInstance(Level) payable, submitLevelInstance(address), registerLevel(Level) onlyOwner, events LevelInstanceCreatedLog / LevelCompletedLog, and the Level base's createInstance(address) / validateInstance(address,address). Output: deployments/ethernaut.json (chain id, hub, factory per level, upstream commit).


python3 import_ethernaut.py --levels Fallback,Vault,Token,Reentrance

The runner (protocol + verifiers)

Run a challenge:


python3 runner.py --challenge challenges/ethernaut_fallback.json \
  --deployments deployments/ethernaut.json \
  --agent-cmd "python3 examples/agent_fallback.py"   # any program speaking JSON lines

Agent protocol (JSON per line over the subprocess's stdin/stdout; full spec in runner.py's docstring): the runner sends {"type":"challenge", โ€ฆ goal, instance, player, ABI โ€ฆ}; the agent sends commands send_tx / call / read / get_balance / get_storage_at / get_code / get_logs / deploy / submit / done and gets replies. Terminal commands: submit (verify now), done (give up), fail. DVD mode adds exploit_code (Solidity statements spliced into the upstream test).

Verifier โ€” per-challenge predicate tree evaluated on-chain after the episode:

predicatepost-condition familyexample
ethernaut_validateupstream factory.validateInstance(instance, player)any Ethernaut level
balancebalance equals threshold (drain)Reentrance โ†’ instance == 0 wei
balance_deltabalance moved since episode startarbitrary steal detection
calldecoded view-call compared to a valueowner() == $player (Fallback)
storageraw slot compareVault locked flip
eventevent logged since start block, params matchedLevelCompletedLog
forge_testupstream DVD _isSolved() via patched forge testnaive-receiver
all / anycomposeevery shipped spec

Trajectory: runs/<ts>_<id>/trajectory.jsonl (every protocol message both directions, timestamps, tx hashes) + result.json (verdict, predicate tree, spec sha256) โ€” exactly what the factory's SFT/RLVR stages consume.

Damn Vulnerable DeFi integration (foundry)

DVD v4 challenges ship as Foundry tests with a stable anchor: the player-editable region marked /* CODE YOUR SOLUTION HERE / inside a checkSolvedByPlayer test; upstream _isSolved() is the success condition. Our DVD mode keeps the challenge untouched (the rules say don't alter initial/final conditions):

  1. agent replies {"cmd":"exploit_code","code":"<solidity statements>"},
  2. runner patches a copy in place (marker replaced, original backed up),
  3. forge test --mp test/<challenge>/<Name>.t.sol (ยฑ --isolate for

tx-count-limited challenges) is the verifier,

  1. the pristine file is always restored (selftest proves the restore symmetry).

python3 runner.py --challenge challenges/dvd_naive_receiver.json \
  --agent-cmd "python3 your_dvd_agent.py"

Smoke test (documented walkthrough)

smoke_test.sh = the reference end-to-end, Fallback level:

  1. selftest.py passes (keccak vectors incl. `keccak256("") ==

c5d2โ€ฆ470, famous selectors owner()=0x8da5cb5b, transfer=0xa9059cbb`; ABI round trips; a full mock-chain episode with the real runner + real agent subprocess; negative control: a submit-only agent must NOT solve).

  1. anvil up (chain 31337), hub + FallbackFactory deployed, level registered.
  2. runner drives examples/agent_fallback.py: contribute() 1 wei โ†’ empty-calldata

1 wei (triggers receive(), ownership flips) โ†’ withdraw() drains โ†’ submit.

  1. Verifier: ethernaut_validate โˆง owner() == $player โˆง balance($instance) == 0

โ†’ solved: true, exit code 0.

Legal / license notes

we only compile/deploy contracts); Damn Vulnerable DeFi is MIT. Both fetched at recorded commits (vendor/upstream.json + deployments file). Trajectories we generate ourselves are clean; upstream challenge source stays in vendor/ (gitignored) and is never published.

Adding a challenge

Copy the closest spec in challenges/, set goal/abi/verifier predicates, reference factories via $ref:<LevelName> + --deployments. For a non-Ethernaut target, set instance directly and drop the ethernaut_validate predicate.