Skip to content

About

Experiment: EVML modules as on-chain view-function contracts with revert-based off-chain effects (ERC-7412-style)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

On-chain EVML modules — prototype

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.

Layout

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

Run it

# 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 reads

The contract ↔ host protocol

A 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 here

The host loop (see driver/run.ts, ~30 lines of logic):

  1. eth_call runCommand(..., results) at a block pinned at loop start
  2. success → done, Action[] goes to the batch
  3. revert → decode Need*, perform effect, results.push(abi-encoded result), goto 1

The one invariant: pin the reads

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_call pinned 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.

What the ports demonstrate

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

Host-side arg resolution (division of labor)

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

Known deltas vs the TS modules (prototype scope)

  • 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; recordDonation failure counting matches).
  • donate fetches projects sequentially (replay is sequential by nature); the TS module used Promise.all. A batching variant of the protocol could revert with several Need*s at once (ERC-7412 does exactly this with a multicall wrapper).
  • No per-effect memoization across commands, no OffchainLookup compatibility shim for the read-only subset yet — both straightforward.

About

Experiment: EVML modules as on-chain view-function contracts with revert-based off-chain effects (ERC-7412-style)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages