EVMcrispr modules as deployed contracts with view-only entrypoints. Off-chain
effects (HTTP, signing, wall-clock time, inline transactions) are requested
by reverting with a typed Need* error; the host performs the effect and
re-executes the call with the result appended — CCIP-Read (EIP-3668)
generalized, closest prior art ERC-7412. No cheatcode precompiles: modules
are standard contracts, executable by any host that can eth_call and decode
a revert.
src/Evml.sol Action / EvmlContext structs, IEvmlModule interface
src/Fx.sol the effect library: Need* errors + replay cursor
src/Str.sol strings kit (EIP-55 checksums, ISO-8601, formatUnits, JSON escaping)
src/AclModule.sol full acl port — 19 commands, 10 helpers, ZERO effects
src/GivethModule.sol giveth:donate port — the most effectful command we have
src/vendor/ Solady JSONParserLib (JSON parsing happens in-EVM)
test/ forge tests, incl. an in-Solidity host loop for donate
driver/run.ts the real host loop: eth_call + revert decode over anvil
# prerequisites: foundry (forge + anvil) and bun
bun install # viem, for the driver
forge test # 13 tests, incl. byte-exact SIWE golden message
bun driver/run.ts # end-to-end vs anvil: real signing, real txs, pinned readsA leaf command is one view function:
runCommand(name, ctx, args, fxResults) → Action[]Module code is written linearly; each fx.* call consumes the next entry of
fxResults or reverts with the effect it needs:
string memory body = d.fx.http("POST", url, headers, payload); // ← suspends here
bytes memory sig = d.fx.signMessage(message); // ← then here
bytes32 txHash = d.fx.transact(chainId, to, value, data); // ← then hereThe host loop (see driver/run.ts, ~30 lines of logic):
eth_call runCommand(..., results)at a block pinned at loop start- success → done,
Action[]goes to the batch - revert → decode
Need*, perform effect,results.push(abi-encoded result), goto 1
If an effect mutates chain state (the approve tx), the replayed prefix must NOT observe it — otherwise the allowance read changes the branch taken and the cursor desyncs from the results array. Two equivalent implementations, both in this prototype:
- driver/run.ts —
eth_callpinned to the block at loop start while txs land on head (what a fork-backend host would do with a snapshot); - GivethModule.t.sol — transactions deferred until the loop converges (pinned reads ≡ deferred writes, from the contract's point of view).
test_replay_prefix_is_deterministic proves the whole flow replays
identically against pinned state.
acl (all 19 commands + 10 helpers): a pure module needs no effects at
all. Everything the TS module did with client.readContract becomes a plain
staticcall against the fork; role-name resolution (keccak-hashed names,
DEFAULT_ADMIN_ROLE, AccessManager numeric ids) ports 1:1 into Solidity.
13.2 KB — deployable on any chain today.
giveth:donate (20.6 KB, also under EIP-170): GraphQL project lookups
parsed in-EVM with Solady's JSONParserLib, allowance/symbol/decimals as
staticcalls, a byte-exact EIP-4361 SIWE message built in Solidity (EIP-55
checksummed account, ISO-8601 Issued At from the NeedTime effect) that
verifyMessage accepts server-side, USDT-style zero-reset approve logic,
inline txs whose hashes feed the createDonation records POSTed back with
human-unit decimal amounts. Effect trace for a 2-project + tip ERC-20
donation: 12 effects (7 http, 1 time, 1 sign, 2 tx... the approve and the
batched donateManyERC20).
The host still owns the language: parsing, control flow, variables, arg
coercion. Args arrive ABI-encoded per the module's schema; signature + params pairs (acl schedule/execute-scheduled) arrive as pre-encoded
bytes because encoding arbitrary user-typed params is arg resolution, not
module logic. Optional opts are abi.encode(bool present, value).
describe()returns names only; a real deployment needs the full arg schema + docs so the editor can drive completions/diagnostics from chain.- GraphQL/API error aggregation is close but not byte-identical (error
strings differ;
recordDonationfailure counting matches). donatefetches projects sequentially (replay is sequential by nature); the TS module usedPromise.all. A batching variant of the protocol could revert with severalNeed*s at once (ERC-7412 does exactly this with a multicall wrapper).- No per-effect memoization across commands, no
OffchainLookupcompatibility shim for the read-only subset yet — both straightforward.