From 641576405f432f01ac3e212c996bbc7ee71ebd79 Mon Sep 17 00:00:00 2001 From: 0xdewy <15720036+0xdewy@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:05:58 -0700 Subject: [PATCH] Harden multicall execution and prepare a tested release candidate --- .agentify-lock | 41 - .claude/context-map.md | 50 - .claude/rules/javascript.md | 58 - .claude/rules/rust.md | 46 - .claude/rules/solidity.md | 55 - .claude/rules/testing.md | 63 - .claude/skills/multicall-scripting/SKILL.md | 145 + .gas-snapshot | 147 +- .github/workflows/test.yml | 95 +- .gitignore | 10 +- .gitmodules | 3 - AGENTS.md | 429 +- AUDIT.md | 165 - CLAUDE.md | 75 +- README.md | 390 +- SECURITY.md | 242 + docs/NAVIGATION.md | 35 - docs/OVERVIEW.md | 96 - docs/contracts.md | 76 - docs/examples.md | 60 - docs/javascript.md | 91 - docs/plans/README.md | 43 - docs/plans/ci-pipeline.md | 35 - docs/plans/composability-verification.md | 80 - docs/plans/formal-verification.md | 57 - docs/plans/fuzz-edge-cases.md | 42 - docs/plans/gas-benchmark-suite.md | 78 - docs/plans/gas-regression.md | 48 - docs/plans/security-self-audit.md | 45 - docs/plans/shared-schema.md | 38 - docs/plans/symbolic-execution.md | 70 - docs/plans/unimplemented-features.md | 46 - docs/research/delegatecall-support/PLAN.md | 132 - .../evm-vm-gas-verification/00-question.md | 44 - .../evm-vm-gas-verification/01-lit-map.md | 64 - .../evm-vm-gas-verification/02-hypotheses.md | 62 - .../03-open-questions/H1.md | 10 - .../03-open-questions/H2.md | 10 - .../03-open-questions/H3.md | 9 - .../03-open-questions/H4.md | 10 - .../03-open-questions/draft-contrarian.json | 49 - .../03-open-questions/draft-frontier.json | 35 - .../03-open-questions/draft-mechanistic.json | 46 - .../03-open-questions/draft-null.json | 47 - .../04-evidence/H1-falsifier.md | 70 - .../04-evidence/H1-proponent.md | 118 - .../04-evidence/H1-rebuttal.md | 195 - .../04-evidence/H2-falsifier.md | 73 - .../04-evidence/H2-proponent.md | 60 - .../04-evidence/H2-rebuttal.md | 77 - .../04-evidence/H3-falsifier.md | 98 - .../04-evidence/H3-proponent.md | 295 - .../04-evidence/H3-rebuttal.md | 70 - .../04-evidence/H4-falsifier.md | 71 - .../04-evidence/H4-proponent.md | 112 - .../04-evidence/H4-rebuttal.md | 147 - .../evm-vm-gas-verification/04-synthesis.md | 92 - .../05-decision-log.md | 53 - .../evm-vm-gas-verification/06-red-team.md | 86 - .../evm-vm-gas-verification/README.md | 31 - .../evm-vm-gas-verification/RESEARCH.md | 95 - .../evm-vm-gas-verification/data/corpus.json | 11051 ---------------- .../data/corpus_focused.json | 7987 ----------- .../evm-vm-gas-verification/data/filter.py | 35 - .../data/filter_tmp.py | 9 - ...er-for-evm-handling-memory-and-storage.txt | 930 -- ...or-the-security-analysis-of-ethereum-s.txt | 974 -- ...act-analysis-tools-a-systematic-review.txt | 1139 -- ...ovably-sound-static-analysis-of-ethere.txt | 1080 -- ...-optimization-for-ethereum-smart-contr.txt | 383 - .../data/fulltext/manifest.json | 178 - ...nting-out-of-gas-vulnerabilities-in-et.txt | 745 -- ...-verifier-for-solidity-smart-contracts.txt | 908 -- ...safety-verification-of-smart-contracts.txt | 1218 -- .../data/integrity-flags.json | 70 - .../evm-vm-gas-verification/data/keyabs.py | 25 - .../evm-vm-gas-verification/data/keyabs.txt | 52 - .../evm-vm-gas-verification/data/summarize.py | 14 - .../evm-vm-gas-verification/data/summary.txt | 46 - .../evm-vm-gas-verification/data/topics.py | 19 - .../evm-vm-gas-verification/data/topics.txt | 74 - docs/reviews/README.md | 43 - docs/rust.md | 74 - docs/specs/README.md | 47 - docs/testing.md | 143 - env.example | 30 +- foundry.lock | 5 +- foundry.toml | 8 +- js/.gas-snapshot | 72 - js/abi.js | 8 +- js/build-vectors.json | 57 +- js/cli.js | 18 +- js/encoding.js | 228 +- js/examples/abis.js | 315 - js/examples/anvilFork.js | 148 - js/examples/approve_and_deposit.js | 5 +- js/examples/erc20_balance_transfer.js | 2 +- js/examples/helpers.js | 305 - js/examples/multiple_swaps.js | 338 +- js/examples/run_anvil.sh | 1 - js/index.js | 870 +- js/offset-schema.json | 33 +- js/package.json | 7 +- js/scripts/gen-build-vectors.js | 29 + js/test/7702Caller.js | 40 - js/test/buildVectors.test.js | 3 + js/test/cli.test.js | 40 + js/test/layouts.js | 46 + js/test/layouts.test.js | 235 + js/test/mainnet.js | 178 + js/test/property-tests.test.js | 390 +- js/test/readonly.js | 22 + js/test/schemaRoundtrip.test.js | 38 +- js/test/struct_edge_cases.js | 242 - lib/solady | 1 - rust/crates/builder/src/lib.rs | 468 +- rust/crates/builder/tests/builder.rs | 159 +- rust/crates/builder/tests/parity.rs | 161 +- rust/crates/cli/src/main.rs | 112 +- rust/crates/codec/build.rs | 12 +- rust/crates/codec/src/lib.rs | 55 +- rust/crates/codec/tests/properties.rs | 63 +- schema/offset-schema.json | 33 +- script/Deploy.s.sol | 58 +- script/check-memory-bounds.py | 26 + script/deploy.sh | 105 +- script/rehearse.sh | 56 + src/7702Caller.sol | 366 +- src/CallBuilder.sol | 294 - src/MulticallScripter.sol | 388 +- src/MulticallScripterReadOnly.sol | 142 + test/7702Caller.t.sol | 514 +- test/7702Invariant.t.sol | 161 + test/Adversarial.t.sol | 363 + test/CallBuilder.sol | 251 + test/CallBuilder.t.sol | 62 +- test/Ecosystem.sol | 37 +- test/ExecutorInvariant.t.sol | 105 + test/GasBenchmarks.t.sol | 153 +- test/GasComparisons.t.sol | 33 +- test/Helpers.sol | 86 +- test/JsLibrary.t.sol | 116 +- test/MulticallScripter.t.sol | 584 +- test/MulticallScripterReadOnly.t.sol | 467 + test/RustLibrary.t.sol | 32 +- 145 files changed, 5702 insertions(+), 35933 deletions(-) delete mode 100644 .agentify-lock delete mode 100644 .claude/context-map.md delete mode 100644 .claude/rules/javascript.md delete mode 100644 .claude/rules/rust.md delete mode 100644 .claude/rules/solidity.md delete mode 100644 .claude/rules/testing.md create mode 100644 .claude/skills/multicall-scripting/SKILL.md delete mode 100644 AUDIT.md create mode 100644 SECURITY.md delete mode 100644 docs/NAVIGATION.md delete mode 100644 docs/OVERVIEW.md delete mode 100644 docs/contracts.md delete mode 100644 docs/examples.md delete mode 100644 docs/javascript.md delete mode 100644 docs/plans/README.md delete mode 100644 docs/plans/ci-pipeline.md delete mode 100644 docs/plans/composability-verification.md delete mode 100644 docs/plans/formal-verification.md delete mode 100644 docs/plans/fuzz-edge-cases.md delete mode 100644 docs/plans/gas-benchmark-suite.md delete mode 100644 docs/plans/gas-regression.md delete mode 100644 docs/plans/security-self-audit.md delete mode 100644 docs/plans/shared-schema.md delete mode 100644 docs/plans/symbolic-execution.md delete mode 100644 docs/plans/unimplemented-features.md delete mode 100644 docs/research/delegatecall-support/PLAN.md delete mode 100644 docs/research/evm-vm-gas-verification/00-question.md delete mode 100644 docs/research/evm-vm-gas-verification/01-lit-map.md delete mode 100644 docs/research/evm-vm-gas-verification/02-hypotheses.md delete mode 100644 docs/research/evm-vm-gas-verification/03-open-questions/H1.md delete mode 100644 docs/research/evm-vm-gas-verification/03-open-questions/H2.md delete mode 100644 docs/research/evm-vm-gas-verification/03-open-questions/H3.md delete mode 100644 docs/research/evm-vm-gas-verification/03-open-questions/H4.md delete mode 100644 docs/research/evm-vm-gas-verification/03-open-questions/draft-contrarian.json delete mode 100644 docs/research/evm-vm-gas-verification/03-open-questions/draft-frontier.json delete mode 100644 docs/research/evm-vm-gas-verification/03-open-questions/draft-mechanistic.json delete mode 100644 docs/research/evm-vm-gas-verification/03-open-questions/draft-null.json delete mode 100644 docs/research/evm-vm-gas-verification/04-evidence/H1-falsifier.md delete mode 100644 docs/research/evm-vm-gas-verification/04-evidence/H1-proponent.md delete mode 100644 docs/research/evm-vm-gas-verification/04-evidence/H1-rebuttal.md delete mode 100644 docs/research/evm-vm-gas-verification/04-evidence/H2-falsifier.md delete mode 100644 docs/research/evm-vm-gas-verification/04-evidence/H2-proponent.md delete mode 100644 docs/research/evm-vm-gas-verification/04-evidence/H2-rebuttal.md delete mode 100644 docs/research/evm-vm-gas-verification/04-evidence/H3-falsifier.md delete mode 100644 docs/research/evm-vm-gas-verification/04-evidence/H3-proponent.md delete mode 100644 docs/research/evm-vm-gas-verification/04-evidence/H3-rebuttal.md delete mode 100644 docs/research/evm-vm-gas-verification/04-evidence/H4-falsifier.md delete mode 100644 docs/research/evm-vm-gas-verification/04-evidence/H4-proponent.md delete mode 100644 docs/research/evm-vm-gas-verification/04-evidence/H4-rebuttal.md delete mode 100644 docs/research/evm-vm-gas-verification/04-synthesis.md delete mode 100644 docs/research/evm-vm-gas-verification/05-decision-log.md delete mode 100644 docs/research/evm-vm-gas-verification/06-red-team.md delete mode 100644 docs/research/evm-vm-gas-verification/README.md delete mode 100644 docs/research/evm-vm-gas-verification/RESEARCH.md delete mode 100644 docs/research/evm-vm-gas-verification/data/corpus.json delete mode 100644 docs/research/evm-vm-gas-verification/data/corpus_focused.json delete mode 100644 docs/research/evm-vm-gas-verification/data/filter.py delete mode 100644 docs/research/evm-vm-gas-verification/data/filter_tmp.py delete mode 100644 docs/research/evm-vm-gas-verification/data/fulltext/a-max-smt-superoptimizer-for-evm-handling-memory-and-storage.txt delete mode 100644 docs/research/evm-vm-gas-verification/data/fulltext/a-semantic-framework-for-the-security-analysis-of-ethereum-s.txt delete mode 100644 docs/research/evm-vm-gas-verification/data/fulltext/ethereum-smart-contract-analysis-tools-a-systematic-review.txt delete mode 100644 docs/research/evm-vm-gas-verification/data/fulltext/ethor-practical-and-provably-sound-static-analysis-of-ethere.txt delete mode 100644 docs/research/evm-vm-gas-verification/data/fulltext/gasol-gas-analysis-and-optimization-for-ethereum-smart-contr.txt delete mode 100644 docs/research/evm-vm-gas-verification/data/fulltext/manifest.json delete mode 100644 docs/research/evm-vm-gas-verification/data/fulltext/running-on-fumes-preventing-out-of-gas-vulnerabilities-in-et.txt delete mode 100644 docs/research/evm-vm-gas-verification/data/fulltext/solc-verify-a-modular-verifier-for-solidity-smart-contracts.txt delete mode 100644 docs/research/evm-vm-gas-verification/data/fulltext/verx-safety-verification-of-smart-contracts.txt delete mode 100644 docs/research/evm-vm-gas-verification/data/integrity-flags.json delete mode 100644 docs/research/evm-vm-gas-verification/data/keyabs.py delete mode 100644 docs/research/evm-vm-gas-verification/data/keyabs.txt delete mode 100644 docs/research/evm-vm-gas-verification/data/summarize.py delete mode 100644 docs/research/evm-vm-gas-verification/data/summary.txt delete mode 100644 docs/research/evm-vm-gas-verification/data/topics.py delete mode 100644 docs/research/evm-vm-gas-verification/data/topics.txt delete mode 100644 docs/reviews/README.md delete mode 100644 docs/rust.md delete mode 100644 docs/specs/README.md delete mode 100644 docs/testing.md delete mode 100644 js/.gas-snapshot delete mode 100644 js/examples/abis.js delete mode 100644 js/examples/anvilFork.js delete mode 100644 js/examples/helpers.js delete mode 100755 js/examples/run_anvil.sh delete mode 100644 js/test/7702Caller.js create mode 100644 js/test/cli.test.js create mode 100644 js/test/layouts.js create mode 100644 js/test/layouts.test.js create mode 100644 js/test/mainnet.js create mode 100644 js/test/readonly.js delete mode 100644 js/test/struct_edge_cases.js delete mode 160000 lib/solady create mode 100644 script/check-memory-bounds.py create mode 100755 script/rehearse.sh delete mode 100644 src/CallBuilder.sol create mode 100644 src/MulticallScripterReadOnly.sol create mode 100644 test/7702Invariant.t.sol create mode 100644 test/Adversarial.t.sol create mode 100644 test/CallBuilder.sol create mode 100644 test/ExecutorInvariant.t.sol create mode 100644 test/MulticallScripterReadOnly.t.sol diff --git a/.agentify-lock b/.agentify-lock deleted file mode 100644 index df79c76a9..000000000 --- a/.agentify-lock +++ /dev/null @@ -1,41 +0,0 @@ -{ - "date": "2026-06-01", - "skill_version": "1.4.0", - "project_name": "multicall-scripting", - "project_type": "library", - "files_created": [ - "CLAUDE.md", - ".claude/rules/solidity.md", - ".claude/rules/javascript.md", - ".claude/rules/testing.md", - ".claude/context-map.md", - "docs/OVERVIEW.md", - "docs/contracts.md", - "docs/javascript.md", - "docs/testing.md", - "docs/examples.md", - "docs/NAVIGATION.md", - "docs/plans/README.md", - "docs/specs/README.md", - "docs/reviews/README.md" - ], - "files_updated": [ - "CLAUDE.md", - ".claude/rules/solidity.md", - ".claude/rules/javascript.md", - ".claude/rules/testing.md" - ], - "files_kept": [ - ".claude/context-map.md", - "docs/OVERVIEW.md", - "docs/contracts.md", - "docs/javascript.md", - "docs/testing.md", - "docs/examples.md", - "docs/NAVIGATION.md", - "docs/plans/README.md", - "docs/specs/README.md", - "docs/reviews/README.md" - ], - "coordination_dirs": ["docs/plans", "docs/specs", "docs/reviews"] -} diff --git a/.claude/context-map.md b/.claude/context-map.md deleted file mode 100644 index e020ea29b..000000000 --- a/.claude/context-map.md +++ /dev/null @@ -1,50 +0,0 @@ - - - - -# Context Map: multicall-scripting - -## Architecture and Design - -- System architecture, components, data flow → `docs/OVERVIEW.md` -- Solidity contracts internals, patterns, key files → `docs/contracts.md` -- JavaScript library internals, patterns, key files → `docs/javascript.md` -- Testing internals, patterns, key files → `docs/testing.md` -- Examples internals, patterns, key files → `docs/examples.md` - -## Coding Conventions - -- Solidity conventions (assembly, constants, imports, errors) → `.claude/rules/solidity.md` -- JavaScript conventions (BigInt, descriptors, encoding, private methods) → `.claude/rules/javascript.md` -- Testing conventions (Foundry, Bun, FFI integration, fuzz tests) → `.claude/rules/testing.md` -- Your personal preferences → `CLAUDE.local.md` (gitignored, keep yours) - -## Project Operations - -- How to build, test, run, lint → `CLAUDE.md` (Commands section) -- How to navigate and update these docs → `docs/NAVIGATION.md` -- Session learnings → `~/.claude/projects/.../memory/MEMORY.md` (auto memory, Claude-managed) - -## Finding Specific Things - -- Where data enters the system → `docs/OVERVIEW.md` (Data Flow section) -- How to add a new call type flag → `docs/contracts.md` + `docs/javascript.md` -- Where offset encoding is defined → `src/MulticallScripter.sol` (execute()) + `js/index.js` (encoding helpers) -- Where bit alignment is validated → `test/JsLibrary.t.sol` (test_js_encoding_roundtrip) -- All file-pointer cross-references → this file - -## Agent Coordination - -- Implementation plans for agents to pick up → `docs/plans/` -- Feature and API specifications → `docs/specs/` -- Code and design review findings → `docs/reviews/` - -## Subagent Use - -This context map is designed to be useful both in the main session and in -subagents. Subagents load their own copy of CLAUDE.md and path-scoped rules. -When spawning a subagent, reference the relevant doc from this map as part -of the subagent's task prompt. - ---- -*To rebuild this context system: run `/agentify update` in this repo.* diff --git a/.claude/rules/javascript.md b/.claude/rules/javascript.md deleted file mode 100644 index cb48271b3..000000000 --- a/.claude/rules/javascript.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -paths: - - "js/**/*.js" - - "js/**/*.ts" ---- - - -## Conventions - -- ES modules only: use `import`/`export`, never `require()` or `module.exports` -- Use `BigInt` for all 256-bit numeric values (offsets, masks, bit shifts) — JavaScript `Number` loses precision above 2^53 -- Private methods use `#methodName` syntax -- Error handling: `throw new Error("message", { cause: originalError })` for error chaining -- Single dependency: `viem` (v2.x) for `encodeFunctionData` and `getAbiItem`; do not add new runtime deps without discussion - -## Patterns - -### Descriptor system -Return values from `addCall()` are proxy objects carrying metadata (`callIndex`, `offset`, `size`, `type`). Pass them as arguments to subsequent `addCall()` calls to wire return values: -```javascript -const balance = builder.addCall(ERC20_ABI, token, "balanceOf", [user]); -builder.addCall(ERC20_ABI, token, "transfer", [recipient, balance]); -``` - -### Dynamic type sizing -Dynamic return types (bytes, string, arrays) require `.with_length(n)` before use: -```javascript -const text = builder.addCall(abi, target, "getName", []); -text.with_length(24); // byte length of the return data -builder.addCall(abi, target, "setName", [text]); -``` - -### Offset encoding -Encoding helpers (`staticCall`, `staticCallPartialReturn`, `stateChangingCall`, `callPartialReturn`) pack fields into a 256-bit BigInt. The bit layout must match `MulticallScripter.sol` exactly: -``` -Partial return: [8:calltype][8:valueIndex][120:memTargets(40×3)][48:resultLengths(16×3)][48:returnOffsets(16×3)][16:returnDataSize][8:num_vars] -``` - -### File structure -- `js/index.js` — TransactionBuilder class + encoding helpers + type utilities -- `js/cli.js` — CLI wrapper invoked as `tx-builder` (declared in package.json `bin`) -- `js/abi.js` — ABI loading helper -- `js/test/` — Bun test files (one per feature) -- `js/examples/` — Runnable strategy scripts - -## Pitfalls - -- Don't reuse descriptors: each return value can only be wired once — `usedDescriptors` Set enforces this -- Don't chain return values from state-changing calls in the current implementation — only static calls support partial return -- At most one dynamic return type per function, and it must be the last return value -- Maximum 3 variables per call (`PARTIAL_RETURN_VARS = 3`) -- When adding new call type flags, add the constant, the encode helper, and tests in both layers - -## See Also - -- TransactionBuilder API → docs/javascript.md -- Bit layout reference → docs/contracts.md -- Example usage patterns → docs/examples.md diff --git a/.claude/rules/rust.md b/.claude/rules/rust.md deleted file mode 100644 index c89244f45..000000000 --- a/.claude/rules/rust.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -paths: - - "rust/**/*.rs" - - "rust/**/Cargo.toml" ---- - -## Conventions - -- **Stack: alloy** (v1.x) — `alloy-primitives` (`U256`, `Address`, `Bytes`), `alloy-json-abi` - (parse ABIs), `alloy-dyn-abi` (`DynSolType`/`DynSolValue` runtime encoding — the role viem - plays in the JS layer). Do not add `ethers-rs` (deprecated). -- **Offsets are `U256`** — mirror `js/encoding.js`'s `BigInt` shift/mask logic exactly (shift - amounts 120/72/24/8, masks for uint120/uint40/uint16). Encode/decode live in the `codec` crate. -- **Never hand-edit offset constants.** They are generated by `rust/crates/codec/build.rs` from - `schema/offset-schema.json` into `$OUT_DIR/schema_constants.rs`. Edit the schema, then rebuild. -- **Errors** use `thiserror`; the `codec` error messages mirror the JS `throw new Error(...)` - strings so the layers agree on rejection cases as well as encodings. - -## Crate layout - -- `crates/codec` (`multicall-scripter-codec`) — mirrors `js/encoding.js` (constants + encode/decode). -- `crates/builder` (`multicall-scripter`) — mirrors `js/index.js` (`TransactionBuilder`, `Arg`, - `ReturnRef`). -- `crates/cli` (bin `tx-builder-rs`) — mirrors `js/cli.js`; emits the same - `{targets, offsets, calldatas, msgValues}` JSON consumed by Foundry FFI tests. - -## Parity (required when changing encoding) - -- Regenerate `js/test-vectors.json` (`bun js/scripts/gen-test-vectors.js`); both the JS suite and - `crates/codec/tests/golden.rs` assert against it. -- Run `cargo test` (unit + proptest roundtrips) and `forge test --match-contract RustLibrary` - (Rust CLI → on-chain execution). - -## Pitfalls - -- The builder currently ports the **scalar-chaining + single dynamic return** path. References - nested inside array/tuple args, array-element refs, and struct-field-by-name are **not yet - ported** (see the module docs in `crates/builder/src/lib.rs`); use positional `ReturnRef`s. -- `js/cli.js` cannot accept partial-return refs via its CLI (its JSON reviver stringifies the - numeric ref fields); the Rust CLI accepts them. Parity for ref-chaining is verified on-chain - via the `RustLibrary` FFI test, not by diffing the two CLIs. - -## See Also - -- Rust crate deep-dive → docs/rust.md -- Canonical bit layout → schema/offset-schema.json, docs/contracts.md diff --git a/.claude/rules/solidity.md b/.claude/rules/solidity.md deleted file mode 100644 index bcf39de2b..000000000 --- a/.claude/rules/solidity.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -paths: - - "src/**/*.sol" - - "test/**/*.sol" - - "script/**/*.sol" ---- - - -## Conventions - -- All Solidity files start with `// SPDX-License-Identifier: GPL3` followed by `pragma solidity ^0.8.28;` -- Named imports only: `import {ContractName} from "path/to/file.sol";` — never `import "path.sol";` -- For custom errors, use either `error Name(uint256 arg);` with 4-byte selector reverts in assembly OR `require()` with string messages — both patterns exist in the codebase; follow the pattern of the file you're editing -- Test contracts use multiple inheritance: `contract MyTest is Test, CallBuilder, MulticallScripter { }` to use both offset-building helpers and execution methods directly -- Contract-level state arrays (`targets[]`, `offsets[]`, `calldatas[]`, `values[]`) are used in tests — Foundry's test isolation ensures they're fresh per test - -## Patterns - -### Offset constant definitions -```solidity -uint256 constant STATIC_CALL_FLAG = 0xFF; -uint256 constant CALL_FLAG = 0xFE; -uint256 constant VALUE_OFFSET = 248; -``` -These must match `js/index.js` exactly. When adding a new flag: add it to both files, add a decode case in the `execute()` assembly dispatch, and add the encode helper in both `CallBuilder.sol` and `js/index.js`. - -### Assembly blocks -- The `execute()` function in `MulticallScripter.sol` is pure Yul assembly — all memory management, looping, and call dispatch happens in assembly -- Every assembly section has comment blocks showing exact bit field layouts: - ```solidity - // [8:calltype][8:valueIndex][120:memTarget][120:resultLength] - ``` -- Error reverts in assembly use a 4-byte selector written to memory then `revert(pos, 4)`: - ```solidity - mstore(0, 0x8f61746f) // InvalidCalltype selector - revert(0x1c, 0x04) - ``` - -### Memory layout in execute() -- All calldata entries are copied contiguously into memory at the start of `execute()` -- Free memory pointer (`mstore(0x40, ...)`) is advanced past the calldata region before any calls -- After each static call, return data is `mcopy`'d from `returndatasize` region to the target positions within calldata memory - -## Pitfalls - -- NEVER change bit field sizes in offset encoding without updating both the Solidity assembly decoder AND the JavaScript encoder — the layouts are hand-rolled and there is no shared schema -- NEVER use `.transfer()` or `.send()` for ETH — the contract is stateless and rejects direct ETH in `receive()`. Use `execute()` with `values[]` array -- NEVER import `src/` contracts into `CallBuilder.sol` — it only imports `Constants` to avoid circular dependencies -- Dynamic return types in tests: arrays are encoded as `abi.encodeWithSelector` with fixed params; use `abi.decode()` to parse dynamic arrays back - -## See Also - -- On-chain execution flow → docs/contracts.md -- JavaScript offset encoding → docs/javascript.md -- Constants sync between layers → docs/OVERVIEW.md diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md deleted file mode 100644 index ef1d4890d..000000000 --- a/.claude/rules/testing.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -paths: - - "test/**/*.sol" - - "js/test/**" - - "js/**/*.test.*" ---- - - -## Conventions - -### Solidity tests (Foundry) -- All test contracts inherit `Test` from forge-std: `contract MyTest is Test { }` -- Setup in `setUp()`: deploy fresh contract instances before each test (Foundry isolates storage) -- Use `assertEq()`, `assertGt()`, `assertLt()` for assertions — `assertEq` with 3 args includes a revert message -- Fuzz tests: declare function parameters without explicit input ranges (Foundry generates values): - ```solidity - function test_fuzz_simple_set_and_get(uint256 set) public { ... } - ``` -- Use `vm.expectRevert(bytes)` to match error selectors: `vm.expectRevert(abi.encodeWithSelector(InvalidCalltype.selector, 0x00));` - -### JavaScript tests (Bun) -- One test file per feature under `js/test/` -- Import `common.js` helpers: `loadABI()` for loading ABI files -- Use `describe`/`test` from Bun's test runner -- JSON output from test scripts is parsed by Solidity tests via `vm.ffi()` - -## Patterns - -### Cross-layer integration tests (JsLibrary.t.sol) -The pattern for verifying JavaScript-built data against Solidity: -```solidity -string[] memory inputs = new string[](4); -inputs[0] = "bun"; -inputs[1] = "js/test/featureName.js"; -inputs[2] = vm.toString(address(contractInstance)); -inputs[3] = "out/Helpers.sol/ContractName.json"; -bytes memory res = vm.ffi(inputs); -string memory json = string(res); -address[] memory jsTargets = vm.parseJsonAddressArray(json, ".targets"); -uint256[] memory jsOffsets = vm.parseJsonUintArray(json, ".offsets"); -bytes[] memory jsCalldatas = vm.parseJsonBytesArray(json, ".calldatas"); -uint256[] memory jsMsgValues = vm.parseJsonUintArray(json, ".msgValues"); -multicall.execute(jsTargets, jsOffsets, jsCalldatas, jsMsgValues); -``` - -### Encoding roundtrip tests -`test_js_encoding_roundtrip()` validates that JS-built offsets decode correctly in Solidity using `CallDecoder`. Always add a roundtrip test when adding new call types or modifying offset encoding. - -### Gas comparison tests -`GasComparisons.t.sol` compares MulticallScripter against Weiroll for equivalent operations. Follow the pattern: deploy both, build equivalent call sets in both systems, compare gas usage. - -## Pitfalls - -- FFI requires `ffi = true` in `foundry.toml` — already enabled -- The ABI file path for FFI tests uses `out/Helpers.sol/ContractName.json` (Foundry's output directory) -- Don't forget to `forge build` before running tests that depend on `out/` ABIs -- In msgValue comparison tests, the `values` array must be the same length as `targets` — use `stateChangingCall(0x0)` (valueIndex=0) for calls with no value - -## See Also - -- Test helper contracts → test/Helpers.sol -- JS test structure → docs/javascript.md -- Integration test patterns → docs/testing.md diff --git a/.claude/skills/multicall-scripting/SKILL.md b/.claude/skills/multicall-scripting/SKILL.md new file mode 100644 index 000000000..afb682186 --- /dev/null +++ b/.claude/skills/multicall-scripting/SKILL.md @@ -0,0 +1,145 @@ +--- +name: multicall-scripting +description: Build and execute atomic multi-call transactions with return-value chaining using the multicall-scripter JS library and MulticallScripter executor. Use when a user wants to batch contract calls where a later call needs an earlier call's return value (balance → transfer, reserves → swap, deposit → borrow), when debugging an execute() revert, or when wiring an EOA through the EIP-7702 delegate. +--- + +# multicall-scripting + +Copy this folder into `.claude/skills/` of any project that uses the library. + +## Mental model + +- `TransactionBuilder.addCall(abi, target, fn, args, msgValue?)` records a call and returns a + **descriptor** for its return value. A descriptor is a placeholder, not a value. +- Passing a descriptor inside a later call's `args` tells the builder "put the real return data + here at run time". Descriptors may sit anywhere in the args: top level, struct fields, array + elements, structs inside arrays. +- `build()` returns `{ targets, offsets, calldatas, msgValues }`. Submit them to + `MulticallScripter.execute(targets, offsets, calldatas, msgValues)` with + `value = sum(msgValues)`. +- Calls run **from the executor's address**. Tokens received mid-batch land in the executor; + `balanceOf(executorAddress)` reads them; the batch must send them onward before it ends. + +## Recipe + +```javascript +import { TransactionBuilder } from "multicall-scripter"; + +const b = new TransactionBuilder(); + +// 1. reads: view/pure functions → static calls; their outputs are descriptors +const bal = b.addCall(ERC20, token, "balanceOf", [EXECUTOR]); +const [r0, r1] = b.addCall(PAIR, pair, "getReserves", []); // multiple outputs +const acct = b.addCall(AAVE, pool, "getUserAccountData", [me]); // struct: acct.availableBorrowsBase + +// 2. dynamic returns need an exact length before use (elements for T[], bytes for bytes/string) +const owners = b.addCall(SAFE, safe, "getOwners", []); +owners.with_length(3); + +// 3. writes: nonpayable/payable functions → calls; last arg is msg.value (bigint) +b.addCall(WETH, weth, "deposit", [], 10n ** 18n); +b.addCall(ERC20, token, "transfer", [owners[0], bal]); +b.addCall(ROUTER, router, "getAmountOut", [1n, r0, r1]); + +// 4. build and send +const { targets, offsets, calldatas, msgValues } = b.build(); +await wallet.writeContract({ address: EXECUTOR, abi: EXECUTOR_ABI, functionName: "execute", + args: [targets, offsets, calldatas, msgValues], value: msgValues.reduce((a, v) => a + v, 0n) }); +``` + +Executor ABI fragment: + +```json +[{"type":"function","name":"execute","stateMutability":"payable", + "inputs":[{"name":"targets","type":"address[]"},{"name":"offsets","type":"uint256[]"}, + {"name":"calldatas","type":"bytes"},{"name":"values","type":"uint256[]"}], + "outputs":[]}] +``` + +## Reading without a transaction + +`MulticallScripterReadOnly.execute(targets, offsets, calldatas, msgValues)` is `view`, takes the +same inputs, runs everything as `staticcall`, and returns `bytes[]` — one entry per call with its +full return data. Use it via `eth_call` / `readContract` for quote pipelines and to inspect every +intermediate value of a batch before sending it to the real executor. Writing callees revert under +it, and `msgValues` is ignored. + +## Rules the builder enforces (and the errors it throws) + +| Rule | Error text | +|---|---| +| Dynamic values need `with_length(n)` first | `requires with_length() before use` | +| At most 3 slices per producing call | `Too many variables` — read the value again with another call | +| One dynamic value per return | `more than one dynamic value in the return data` | +| No arrays of dynamic elements | `arrays of dynamic elements are not supported` | +| Descriptor kind must match the parameter (static↔static, dynamic↔dynamic) | `cannot pass a X return value as a Y argument` | +| No `msg.value` on view/pure | `cannot receive msg.value` | + +`with_length` must be exact: the original length word is copied. An incorrect declaration can +revert or make the consumer read adjacent arguments; it is not a safe truncation operation. Prefer functions whose return length you control. + +## Decoding an `execute()` revert + +| Selector | Error | Usual cause | +|---|---|---| +| `0xcbce8a22` | `InsufficientReturnData()` | wrong target address (no code), wrong ABI, or `with_length` too large | +| `0xd558ad4e` | `InvalidMemoryTarget()` | hand-built offsets; or a descriptor used by the *last* call's producer with no consumer region | +| `0x6115f2de` | `InvalidOffset(uint256)` | hand-built offsets with a bad calltype byte | +| `0x3b1a9b29` | `InvalidValueIndex()` | `msgValues` array does not match the offsets (rebuild, do not edit arrays by hand) | +| `0xf0c49d44` | `RefundFailed()` | the caller is a contract without `receive()` and overpaid `msg.value` | +| `0x60f8f321` | `EthNotAccepted()` | ETH sent directly to the executor; use `msgValue` on a call instead | +| anything else | callee revert | bubbled unchanged; decode with the callee's ABI | + +The batch is atomic: a failing call reverts everything, including earlier transfers. + +## Safety checklist before sending + +0. The user has **not** approved the executor address for any token (anyone could + `transferFrom` them). Funds enter a batch via `msg.value`, via transfers made inside the batch + by the executor itself, or by running the batch from the user's EOA through the 7702 delegate. +1. The last call(s) move every asset the batch received out of the executor. +2. No approvals are left on the executor beyond what the batch consumes (`approve` exactly the + amount, or approve then spend it fully). +3. `value` on the transaction equals `sum(msgValues)`; surplus is refunded but wastes gas. +4. Simulate first (`eth_call` / `simulateContract`) — the executor is permissionless and the + transaction is public. +5. Slippage: values read on-chain (reserves, balances) are used as-is; put `amountOutMin` + literals in the same batch to bound the outcome. + +## EIP-7702 + +If the user wants the calls to come *from their EOA* (its allowances, its `msg.sender`), delegate +the EOA to `SevenSevenZeroTwoCaller` and either: + +- send the transaction **to your own address** calling `execute(...)`, or +- sign `hashExecute(targets, offsets, calldatas, values, nonce(), deadline)` (EIP-712, domain + `MulticallScripter7702`/`2`, verifying contract = the EOA) and let a relayer call + `executeWithSignature(..., deadline, signature)`. + +The delegate has no other functions. `values` are paid from the EOA's balance. + +## CLI + +`bun cli.js ''` where each call is +`{"abiPath","target","functionName","args","value"}` and an argument may be a reference +`{"callIndex":0,"offset":0,"size":32}` (return-data byte offset and size of a static value). +Prints the four inputs as JSON; used by the Foundry FFI tests. + +## Limits worth stating to the user up front + +- Cancun or later chains only (`mcopy`); the 7702 delegate needs Prague. +- Return data captured per partial-return call is capped at 65535 bytes. +- Fixed-size array outputs are indexable; fixed-size arrays *containing* dynamic types are not. +- The Rust builder supports scalar chaining and one dynamic value; nested references are JS-only. + +## Packed ABI migration + +The executor and read-only twin take `execute(address[],uint256[],bytes,uint256[])`. +`build().calldatas` is one packed hex string (Rust: `Bytes`), not an array. Each frame is +`[32-byte length][calldata padded to 32 bytes]`; no inner head table. Update ABIs and rebuild +saved batches from older releases. The 7702 signature domain version is `3`. + +Consumer input rules: use BigInt/strings for large integers; CLI JSON numbers must be safe +integers. Narrow overloaded ABIs to the desired function. A successful EVM call returning false +is not a revert; the executor does not add ERC-20 success checks. Specify slippage bounds and +check residual balances/allowances. Run `script/rehearse.sh` for the mainnet-fork example. diff --git a/.gas-snapshot b/.gas-snapshot index 8d642efdf..8e34b3cf9 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -1,38 +1,109 @@ -GasBenchmarksTest:test_benchmark_dispatch_only() (gas: 4259747) -GasBenchmarksTest:test_benchmark_dispatch_only_10() (gas: 1478024) -GasBenchmarksTest:test_benchmark_marshalling_1024bytes() (gas: 1245339) -GasBenchmarksTest:test_benchmark_marshalling_320bytes() (gas: 870886) -GasBenchmarksTest:test_benchmark_marshalling_32bytes() (gas: 3779611) -GasBenchmarksTest:test_benchmark_partial_return_3vars() (gas: 987726) -GasBenchmarksTest:test_benchmark_total_vs_native() (gas: 4407732) -GasTest:testGasCompareAddUints() (gas: 13978737) -MulticallScriptTest:test_call_partial_return() (gas: 463028) -MulticallScriptTest:test_call_with_unpadded_calldata() (gas: 257270) -MulticallScriptTest:test_empty_batch() (gas: 16474) -MulticallScriptTest:test_fuzz_bytes(bytes) (runs: 256, μ: 693652, ~: 675532) -MulticallScriptTest:test_fuzz_bytes_alt(bytes) (runs: 256, μ: 659395, ~: 648785) -MulticallScriptTest:test_fuzz_partialReturn(uint40,uint40,uint40,uint16,uint16,uint16,uint16,uint16,uint16,uint16) (runs: 256, μ: 274050, ~: 285244) -MulticallScriptTest:test_fuzz_simple_set_and_get(uint256) (runs: 256, μ: 222369, ~: 223613) -MulticallScriptTest:test_invalid_calltype_reverts() (gas: 199779) -MulticallScriptTest:test_mint_weth_and_swap() (gas: 676584) -MulticallScriptTest:test_mint_weth_and_swap_multicall3() (gas: 121859) -MulticallScriptTest:test_multiple_raw_data_simple() (gas: 340113) -MulticallScriptTest:test_partial_return_boundary() (gas: 508009) -MulticallScriptTest:test_partial_return_data() (gas: 715270) -MulticallScriptTest:test_partial_return_max_size() (gas: 417292) -MulticallScriptTest:test_partial_return_multiple_vars() (gas: 802309) -MulticallScriptTest:test_partial_return_overflow() (gas: 506273) -MulticallScriptTest:test_partial_return_second_var() (gas: 459195) -MulticallScriptTest:test_raw_data_multiple_values() (gas: 445461) -MulticallScriptTest:test_raw_data_simple_value() (gas: 233939) -MulticallScriptTest:test_set_tuple_raw() (gas: 314668) -MulticallScriptTest:test_simple_usage() (gas: 768713) -MulticallScriptTest:test_simple_usage_raw() (gas: 461692) -MulticallScriptTest:test_single_call_batch() (gas: 223489) -MulticallScriptTest:test_state_changing_partial_return() (gas: 506196) -MulticallScriptTest:test_staticcall_with_unpadded_calldata() (gas: 358050) -MulticallScriptTest:test_use_raw_data_simple() (gas: 223467) -MulticallScriptTest:test_use_state_changing_call_return(uint256,uint256) (runs: 256, μ: 462843, ~: 463776) -MulticallScriptTest:test_use_static_call_data_simple() (gas: 274381) -MulticallScriptTest:test_use_tuple_data() (gas: 326680) -MulticallScriptTest:test_use_tuple_packed_data() (gas: 283615) \ No newline at end of file +AdversarialTest:testFuzz_mutated_frames_have_identical_success_semantics(uint256,uint8) (runs: 256, μ: 589905, ~: 606295) +AdversarialTest:testFuzz_revert_bytes_bubble_exactly_and_rollback(uint16,bytes32) (runs: 256, μ: 1237678, ~: 632846) +AdversarialTest:testFuzz_static_context_blocks_every_stateful_opcode(uint8,uint8) (runs: 256, μ: 508180, ~: 512930) +AdversarialTest:test_false_return_can_be_checked_explicitly_and_roll_back() (gas: 439064) +AdversarialTest:test_false_return_is_data_not_a_revert() (gas: 165357) +AdversarialTest:test_forced_eth_is_publicly_spendable() (gas: 331717) +AdversarialTest:test_nested_execute_does_not_corrupt_outer_splice() (gas: 1208554) +AdversarialTest:test_public_executor_can_spend_any_allowance_granted_to_it() (gas: 1415756) +AdversarialTest:test_reader_and_writer_have_distinct_caller_contexts() (gas: 318769) +AdversarialTest:test_reentrant_signature_replay_cannot_reuse_nonce() (gas: 2270952) +AdversarialTest:test_refund_callback_can_execute_a_separate_batch() (gas: 987695) +AdversarialTest:test_wrong_dynamic_length_can_consume_an_adjacent_argument() (gas: 364145) +CallBuilderTest:test_partial_return_multiple_vars() (gas: 782246) +CallBuilderTest:test_partial_return_second_var() (gas: 446719) +CallBuilderTest:test_simple_usage() (gas: 757391) +CallBuilderTest:test_state_changing_partial_return() (gas: 493221) +GasBenchmarksTest:test_benchmark_dispatch_only() (gas: 4229405) +GasBenchmarksTest:test_benchmark_dispatch_only_10() (gas: 1467840) +GasBenchmarksTest:test_benchmark_marshalling_1024bytes() (gas: 1980803) +GasBenchmarksTest:test_benchmark_marshalling_320bytes() (gas: 1183714) +GasBenchmarksTest:test_benchmark_marshalling_32bytes() (gas: 3757147) +GasBenchmarksTest:test_benchmark_partial_return_3vars() (gas: 827931) +GasBenchmarksTest:test_benchmark_value_calls() (gas: 1467731) +GasBenchmarksTest:test_benchmark_write_chain() (gas: 1350807) +GasBenchmarksTest:test_benchmark_write_partial_return() (gas: 1362691) +GasTest:testGasCompareAddUints() (gas: 13395560) +MulticallScriptTest:test_call_partial_return() (gas: 460984) +MulticallScriptTest:test_call_partial_return_with_value() (gas: 435082) +MulticallScriptTest:test_call_with_unpadded_calldata() (gas: 258583) +MulticallScriptTest:test_callee_revert_bubbles() (gas: 298549) +MulticallScriptTest:test_direct_eth_rejected() (gas: 15574) +MulticallScriptTest:test_empty_batch() (gas: 16327) +MulticallScriptTest:test_execution_follows_relocated_abi_tail() (gas: 223211) +MulticallScriptTest:test_fuzz_bytes(bytes) (runs: 256, μ: 689595, ~: 669746) +MulticallScriptTest:test_fuzz_bytes_alt(bytes) (runs: 256, μ: 655718, ~: 643318) +MulticallScriptTest:test_fuzz_simple_set_and_get(uint256) (runs: 256, μ: 221770, ~: 222548) +MulticallScriptTest:test_invalid_calltype_reverts() (gas: 176598) +MulticallScriptTest:test_last_call_cannot_write_return_data() (gas: 151795) +MulticallScriptTest:test_length_mismatch() (gas: 64476) +MulticallScriptTest:test_multiple_raw_data_simple() (gas: 337855) +MulticallScriptTest:test_partial_return_boundary() (gas: 334119) +MulticallScriptTest:test_partial_return_data() (gas: 710132) +MulticallScriptTest:test_partial_return_max_size() (gas: 365379) +MulticallScriptTest:test_partial_return_out_of_bounds() (gas: 365520) +MulticallScriptTest:test_partial_return_overflow() (gas: 333095) +MulticallScriptTest:test_partial_too_many_vars_reverts() (gas: 151378) +MulticallScriptTest:test_raw_data_multiple_values() (gas: 334739) +MulticallScriptTest:test_raw_data_simple_value() (gas: 233391) +MulticallScriptTest:test_refund_to_non_payable_caller_reverts() (gas: 638841) +MulticallScriptTest:test_regular_return_out_of_bounds() (gas: 247018) +MulticallScriptTest:test_short_return_data_reverts_partial_slice() (gas: 385356) +MulticallScriptTest:test_short_return_data_reverts_regular() (gas: 248823) +MulticallScriptTest:test_simple_usage_raw() (gas: 458549) +MulticallScriptTest:test_staticcall_with_unpadded_calldata() (gas: 358503) +MulticallScriptTest:test_unspent_value_is_refunded() (gas: 241696) +MulticallScriptTest:test_use_state_changing_call_return(uint256,uint256) (runs: 256, μ: 460291, ~: 460447) +MulticallScriptTest:test_use_static_call_data_simple() (gas: 272876) +MulticallScriptTest:test_use_tuple_data() (gas: 324136) +MulticallScriptTest:test_use_tuple_packed_data() (gas: 279403) +MulticallScriptTest:test_value_index_out_of_range() (gas: 158247) +MulticallScripterReadOnlyTest:test_call_flag_on_pure_function_is_fine() (gas: 341534) +MulticallScripterReadOnlyTest:test_callee_revert_bubbles() (gas: 203430) +MulticallScripterReadOnlyTest:test_chain_returns_every_result() (gas: 608473) +MulticallScripterReadOnlyTest:test_consumer_two_calls_later() (gas: 417977) +MulticallScripterReadOnlyTest:test_dynamic_return_data_captured() (gas: 307657) +MulticallScripterReadOnlyTest:test_element_access_chain() (gas: 461167) +MulticallScripterReadOnlyTest:test_empty_batch() (gas: 19592) +MulticallScripterReadOnlyTest:test_execution_follows_relocated_abi_tail() (gas: 236158) +MulticallScripterReadOnlyTest:test_fuzz_chaining_matches_byte_model(uint256,uint8) (runs: 256, μ: 716149, ~: 676244) +MulticallScripterReadOnlyTest:test_fuzz_internal_padding_is_signed(uint8) (runs: 256, μ: 457563, ~: 457539) +MulticallScripterReadOnlyTest:test_fuzz_packed_frame_bounds(uint256,uint8,uint8) (runs: 256, μ: 519382, ~: 519275) +MulticallScripterReadOnlyTest:test_fuzz_partial_boundaries(uint16,uint16,uint16,uint8) (runs: 256, μ: 311992, ~: 311359) +MulticallScripterReadOnlyTest:test_fuzz_returns_calldata_sized_data(bytes) (runs: 256, μ: 213767, ~: 205833) +MulticallScripterReadOnlyTest:test_invalid_offset_reverts() (gas: 133354) +MulticallScripterReadOnlyTest:test_last_call_cannot_splice() (gas: 156776) +MulticallScripterReadOnlyTest:test_length_mismatch() (gas: 66346) +MulticallScripterReadOnlyTest:test_missing_frame_reverts_both_executors() (gas: 441589) +MulticallScripterReadOnlyTest:test_out_of_bounds_target_reverts() (gas: 256160) +MulticallScripterReadOnlyTest:test_partial_short_capture_reverts() (gas: 156022) +MulticallScripterReadOnlyTest:test_partial_slices_into_a_later_call() (gas: 447138) +MulticallScripterReadOnlyTest:test_raw_returns_have_canonical_padding() (gas: 426450) +MulticallScripterReadOnlyTest:test_short_return_data_reverts() (gas: 253846) +MulticallScripterReadOnlyTest:test_spliced_frame_length_cannot_escape_region() (gas: 732625) +MulticallScripterReadOnlyTest:test_state_change_reverts() (gas: 295711) +SevenSevenZeroTwoCallerTest:testFuzz_malleated_signature_cannot_bypass_nonce(uint256) (runs: 256, μ: 137433, ~: 139089) +SevenSevenZeroTwoCallerTest:testFuzz_previous_signature_domains_rejected(uint8) (runs: 256, μ: 74353, ~: 74385) +SevenSevenZeroTwoCallerTest:test_deadline_is_inclusive_but_cannot_be_extended() (gas: 157050) +SevenSevenZeroTwoCallerTest:test_empty_signed_batch_cancels_pending_nonce() (gas: 87824) +SevenSevenZeroTwoCallerTest:test_execute_as_self() (gas: 85511) +SevenSevenZeroTwoCallerTest:test_execute_rejects_other_callers() (gas: 39151) +SevenSevenZeroTwoCallerTest:test_execute_spends_account_balance() (gas: 157290) +SevenSevenZeroTwoCallerTest:test_execute_with_signature() (gas: 126385) +SevenSevenZeroTwoCallerTest:test_failed_call_reverts_and_keeps_nonce() (gas: 72748) +SevenSevenZeroTwoCallerTest:test_fuzz_signature_bound_to_chain(uint64) (runs: 256, μ: 70784, ~: 70784) +SevenSevenZeroTwoCallerTest:test_fuzz_signature_bound_to_every_input(uint8) (runs: 256, μ: 94199, ~: 84525) +SevenSevenZeroTwoCallerTest:test_invalid_offset_through_wrapper() (gas: 30310) +SevenSevenZeroTwoCallerTest:test_malformed_signature_rejected() (gas: 55394) +SevenSevenZeroTwoCallerTest:test_migration_writing_slot_zero_cannot_revive_a_used_signature() (gas: 131436) +SevenSevenZeroTwoCallerTest:test_nonce_namespace_matches_erc7201_and_preserves_other_storage() (gas: 119484) +SevenSevenZeroTwoCallerTest:test_receives_eth() (gas: 15843) +SevenSevenZeroTwoCallerTest:test_rejected_relayer_refund_rolls_back_nonce_and_calls() (gas: 261557) +SevenSevenZeroTwoCallerTest:test_relayer_overpayment_refunded() (gas: 135853) +SevenSevenZeroTwoCallerTest:test_signature_bound_to_account() (gas: 73997) +SevenSevenZeroTwoCallerTest:test_signature_bound_to_batch() (gas: 71351) +SevenSevenZeroTwoCallerTest:test_signature_bound_to_values() (gas: 109691) +SevenSevenZeroTwoCallerTest:test_signature_cannot_be_replayed() (gas: 127684) +SevenSevenZeroTwoCallerTest:test_signature_covers_frame_padding() (gas: 69963) +SevenSevenZeroTwoCallerTest:test_signature_expired() (gas: 39369) +SevenSevenZeroTwoCallerTest:test_signature_from_other_key_rejected() (gas: 67090) \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aeb74b6ed..1020f609c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,58 +1,77 @@ name: test on: + workflow_dispatch: + inputs: + fork_block: + description: Ethereum block for the release rehearsal (empty uses current) + required: false + type: string push: - branches: [main] + branches: [master] pull_request: - branches: [main] - -env: - FOUNDRY_PROFILE: ci + branches: [master] jobs: foundry: - name: Foundry tests + gas check + name: Foundry (incl. JS + Rust FFI suites) + gas check runs-on: ubuntu-latest + env: + # optional: enables the two mainnet-fork tests in test/CallBuilder.t.sol + ETH_RPC_URL: ${{ secrets.ETH_RPC_URL }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: submodules: recursive - - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 + - uses: oven-sh/setup-bun@v2 + - uses: dtolnay/rust-toolchain@stable + - uses: foundry-rs/foundry-toolchain@v1 with: - version: nightly - - - name: Run Forge build - run: | - forge --version - forge build --sizes - id: build - - - name: Run Forge tests - run: | - forge test -vvv - id: test - - - name: Check gas regression - run: | - forge snapshot --check --tolerance 5 --no-match-contract JsLibrary - id: gas + version: v1.7.1 + - run: cd js && bun install --frozen-lockfile + - run: cargo build --manifest-path rust/Cargo.toml -p multicall-scripter-cli + - run: forge build --sizes + - run: forge test -vvv + - run: forge snapshot --fuzz-seed 0x51c7 --check --tolerance 5 --no-match-contract 'JsLibrary|RustLibrary|Invariant' --no-match-test 'test_mint_weth_and_swap' bun: - name: JavaScript tests + name: JavaScript runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: cd js && bun install --frozen-lockfile + - run: cd js && bun test - - uses: oven-sh/setup-bun@v1 + rust: + name: Rust + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable with: - bun-version: latest + components: clippy + - run: cd rust && cargo test + - run: cd rust && cargo clippy --all-targets -- -D warnings - - name: Install dependencies - working-directory: js - run: bun install - - - name: Run tests - working-directory: js - run: bun test + mainnet-fork: + name: Mainnet fork deployment and protocol rehearsal + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + env: + ETH_RPC_URL: ${{ secrets.ETH_RPC_URL }} + FORK_BLOCK: ${{ inputs.fork_block }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: oven-sh/setup-bun@v2 + - uses: foundry-rs/foundry-toolchain@v1 + with: + version: v1.7.1 + - run: cd js && bun install --frozen-lockfile + - run: forge test --match-contract Invariant -vv + env: + FOUNDRY_INVARIANT_RUNS: 1024 + FOUNDRY_INVARIANT_DEPTH: 512 + - run: script/rehearse.sh diff --git a/.gitignore b/.gitignore index 2ca91d896..2b2d9a876 100644 --- a/.gitignore +++ b/.gitignore @@ -2,13 +2,15 @@ cache/ out/ -# Ignores development broadcast logs -!/broadcast -/broadcast/*/31337/ -/broadcast/**/dry-run/ +# Deployment logs are local artifacts, including live-chain broadcasts. +broadcast/ # Dotenv file .env +.env.* +*.key +*.pem +.claude/settings.local.json .aider* js/node_modules/ diff --git a/.gitmodules b/.gitmodules index 8bba58499..1f0cac0f7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,6 +4,3 @@ [submodule "lib/weiroll-huff"] path = lib/weiroll-huff url = https://github.com/0xdewy/weiroll-huff -[submodule "lib/solady"] - path = lib/solady - url = https://github.com/Vectorized/solady diff --git a/AGENTS.md b/AGENTS.md index 3263ae7b3..b3b4fcd61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,306 +1,127 @@ -# AGENTS.md - Multicall Scripting Guide for LLMs +# AGENTS.md — working on multicall-scripting + +Read this before changing anything. It is the one place that explains how the three layers fit +together and what must stay true. README.md is the user-facing guide; SECURITY.md the trust model. + +## What this is + +An on-chain executor (`MulticallScripter.execute`) runs a list of calls and splices return data +from earlier calls into the calldata of later ones, driven by one 256-bit "offset word" per call. +Two off-chain builders (JavaScript, Rust) produce targets, packed calldata, values and offset words from ABIs. An +optional EIP-7702 delegate wraps the executor for EOAs. + +``` +src/MulticallScripter.sol executor (Yul). Constants contract mirrors the schema by hand. +src/MulticallScripterReadOnly.sol view twin: staticcall everything, return bytes[] of all return data +src/7702Caller.sol EIP-7702 delegate: self-call or EIP-712 signed batch. +schema/offset-schema.json canonical bit layout. Edit this first. +js/encoding.js offset encode/decode (reads js/offset-schema.json, a synced mirror) +js/index.js TransactionBuilder + ABI layout engine + descriptors +js/cli.js JSON in → {targets, offsets, calldatas, msgValues} out (used by FFI tests) +js/test/*.test.js bun unit tests; js/test/*.js (no .test) are FFI scripts run by forge +js/test-vectors.json golden offset vectors (JS-generated, asserted by JS and Rust) +js/build-vectors.json golden whole-build vectors (JS-generated, asserted by JS and Rust) +rust/crates/codec encode/decode; constants generated from the schema by build.rs +rust/crates/builder TransactionBuilder port (scalar + single dynamic value paths) +rust/crates/cli tx-builder-rs, same JSON contract as js/cli.js +test/MulticallScripter.t.sol executor unit + fuzz tests (every error path has a test) +test/MulticallScripterReadOnly.t.sol read-only twin (same error paths, result encoding) +test/7702Caller.t.sol delegate tests (EOA simulated with vm.etch) +test/7702Invariant.t.sol stateful nonce, rollback and ETH-accounting model +test/ExecutorInvariant.t.sol committed arithmetic, refunds, ETH conservation and read-only observations +test/Adversarial.t.sol malicious callees, replay callbacks, static restrictions, malformed frames +script/rehearse.sh deterministic deployment + real protocol and 7702 mainnet-fork rehearsal +test/CallBuilder.sol test-only Solidity offset encoders + a small Scripter DSL +test/JsLibrary.t.sol JS builder → on-chain (vm.ffi bun ...) +test/RustLibrary.t.sol Rust CLI → on-chain (vm.ffi cargo run ...) +test/Gas*.t.sol gas benchmarks and a Weiroll comparison (lib/weiroll-huff) +.claude/skills/multicall-scripting installable skill for people using the library +``` + +## Commands -This document provides essential information for LLMs (Large Language Models) working with the Multicall Scripting codebase. It covers architecture, key files, development workflows, and common patterns. - -## 🏗️ Project Overview - -**Multicall Scripting** enables atomic execution of multi-contract strategies with return value chaining between calls. Unlike basic multicall solutions, it allows using return values from one call as inputs to another within the same transaction. - -### Core Components -- **Solidity Layer**: Smart contracts for on-chain execution -- **JavaScript Layer**: Offchain transaction builder and utilities -- **Descriptor System**: Proxy objects representing future return values - -## 📁 Key Files & Their Purposes - -### Solidity Contracts (`src/`) -| File | Purpose | Key Functions | -|------|---------|---------------| -| `MulticallScripter.sol` | Core execution contract | `execute()` - runs chained calls | -| `CallBuilder.sol` | Solidity DSL for tests | `call_static()`, `useCallOutput()` | -| `7702Caller.sol` | EIP-7702 compatibility | Account abstraction support | - -### JavaScript Library (`js/`) -| File | Purpose | Key Components | -|------|---------|---------------| -| `index.js` | Main TransactionBuilder class | `addCall()`, `build()`, offset encoding | -| `cli.js` | Command-line interface | Transaction building utility | -| `test/` | JavaScript test suite | Individual feature tests | - -### Test Suite (`test/`) -| File | Purpose | Coverage | -|------|---------|----------| -| `MulticallScripter.t.sol` | Core contract tests | Execution logic, edge cases | -| `CallBuilder.t.sol` | DSL functionality tests | Chaining patterns, error cases | -| `JsLibrary.t.sol` | JavaScript integration tests | Cross-layer compatibility | - -## 🧠 Architecture Guide for LLMs - -### Memory Offset System -The system uses compact 256-bit offset encoding to specify how return data should be handled: - -#### Regular Calls (StaticCall and StateChangingCall) -``` -Bits: [8:calltype][120:memTarget/returnOffset][120:resultLength/returnSize] -- calltype: 0xFF (static), 0xFE (regular) -- memTarget: Memory offset for return data storage -- resultLength: Size of return data to copy -``` - -#### Partial Return Calls (STATIC_CALL_PARTIAL_RETURN_FLAG = 0xFC) -``` -Bits: [8:calltype][8:valueIndex][120:memTargets(40×3)][48:resultLengths(16×3)][48:returnOffsets(16×3)][16:returnDataSize][8:num_vars] -- calltype: 0xFC (partial return) -- memTargets: Array of 3 memory targets (40 bits each) -- resultLengths: Array of 3 variable lengths (16 bits each) -- returnOffsets: Array of 3 return data offsets (16 bits each) -``` - -### Descriptor System (JavaScript) -- **Proxy objects** represent future return values -- **Dynamic types** require `.with_length()` specification -- **Struct access** uses dot notation: `result.fieldName` -- **Array elements** use bracket notation: `result[0]` - -### Key Constants -```javascript -// JavaScript (js/index.js) -STATIC_CALL_FLAG = 0xFF -CALL_FLAG = 0xFE -STATIC_CALL_PARTIAL_RETURN_FLAG = 0xFC -VALUE_OFFSET = 248 - -// Solidity (src/MulticallScripter.sol) -uint256 constant STATIC_CALL_FLAG = 0xFF; -uint256 constant CALL_FLAG = 0xFE; -uint256 constant STATIC_CALL_PARTIAL_RETURN_FLAG = 0xFC; -uint256 constant VALUE_OFFSET = 248; -``` - -## 🛠️ Development Workflows - -### Setup Commands -```bash -# Install JavaScript dependencies -cd js && bun install - -# Install Foundry dependencies -forge install - -# Update Foundry -foundryup -``` - -### Testing Commands ```bash -# Run all Solidity tests (verbose) -forge test -vv - -# Run specific test file -forge test --match-test test_partial_return_data - -# Run JavaScript tests -cd js && bun test - -# Run specific JavaScript test -cd js && bun test/test/simpleValue.js -``` - -### Build & Example Execution -```bash -# Build transaction via CLI -cd js && node cli.js - -# Run example strategy -cd js/examples && bun run multiple_swaps.js -``` - -## 📝 Common Tasks for LLMs - -### Adding New Features -1. **Modify both layers**: Solidity contract + JavaScript builder -2. **Update offset encoding**: Ensure bit alignment matches between layers -3. **Add tests**: Include both Foundry and JavaScript test cases -4. **Update documentation**: README.md and code comments - -### Fixing Bugs -1. **Check offset consistency**: Verify JavaScript encoding matches Solidity decoding -2. **Test edge cases**: Use existing test patterns as reference -3. **Run full test suite**: Both `forge test` and `bun test` -4. **Verify bit operations**: Pay attention to shift/mask operations - -### Writing Tests -**Solidity Tests (Foundry):** -```solidity -// Follow pattern from test/MulticallScripter.t.sol -function test_NewFeature() public { - // Setup - bytes memory calldata = abi.encodeWithSelector(...); - - // Execution - uint256 callIndex = call_static(target, calldata); - - // Assertions - assertEq(result, expected); -} -``` - -**JavaScript Tests:** -```javascript -// Follow pattern from js/test/simpleValue.js -const builder = new TransactionBuilder(); -const result = builder.addCall(abi, target, "function", args); -const built = builder.build(); - -// Verify offset encoding -assert(built.offsets[0].toString().includes(expected)); -``` - -## ⚠️ Important Constraints - -### Technical Limits -1. **Maximum 3 variables per call** due to 256-bit encoding space -2. **Dynamic arrays require `.with_length()`** before use -3. **No return data reuse** - each value can only be used once -4. **Memory offset limits**: - - `memTargets`: 40-bit offsets - - `resultLengths`/`returnOffsets`: 16-bit values - - `returnDataSize`: max 65535 bytes - -### Consistency Requirements -1. **Bit alignment must match** across Solidity, JavaScript, and Rust (`rust/`) -2. **Constants must be identical** in all three layers — `schema/offset-schema.json` is the single - source of truth (JS reads it, Rust codegen's from it via `rust/crates/codec/build.rs`, Solidity - mirrors it by hand). Edit the schema first, then regenerate golden vectors - (`bun js/scripts/gen-test-vectors.js`). -3. **Test coverage should be parallel** across layers (incl. `cargo test` and the `RustLibrary` - FFI test) -4. **Error handling should be consistent** across interfaces -5. **See `docs/rust.md`** for the Rust layer's crate map, parity model, and current scope/limits - -## 🔍 Troubleshooting Guide - -### Common Issues & Solutions - -#### Offset Encoding Errors -``` -Problem: "Invalid offset encoding" or memory corruption -Check: -1. Bit shift operations in JavaScript match Solidity -2. Constants (VALUE_OFFSET, etc.) are identical -3. Field sizes (8, 120, 48, 16 bits) align correctly -``` - -#### Test Failures -``` -Problem: Tests pass in one layer but fail in another -Action: -1. Run both test suites: `forge test` AND `bun test` -2. Check cross-layer integration tests (JsLibrary.t.sol) -3. Verify example scripts still work -``` - -#### Build Errors -``` -Problem: Dependency or compilation issues -Fix: -1. Update dependencies: `bun install` and `forge install` -2. Clear cache: `forge clean` -3. Check Solidity version: Must be ^0.8.28 -``` - -### Debugging Tips -- **Use `-vv` flag**: `forge test -vv` for verbose output -- **Check assembly**: Pay attention to inline assembly in Solidity -- **Verify bit masks**: Ensure AND operations use correct masks -- **Test incrementally**: Add small changes and test frequently - -## 📚 Code Conventions - -### Solidity Style -- **License**: GPL3 -- **Version**: `^0.8.28` -- **Assembly**: Used for gas efficiency with detailed comments -- **Constants**: UPPER_SNAKE_CASE in separate contract -- **Errors**: Custom error types with descriptive names - -### JavaScript Style -- **Imports**: Use `viem` for ABI encoding -- **BigInt**: Use for 256-bit values (not Number) -- **Classes**: TransactionBuilder as main interface -- **Utilities**: TypeUtils for dynamic type detection -- **Exports**: Named exports for constants and functions - -### Naming Patterns -- **Offsets**: Descriptive names like `memTarget`, `returnOffset` -- **Functions**: `call_static()`, `useCallOutput()`, `addCall()` -- **Variables**: `callIndex`, `resultLength`, `num_vars` -- **Tests**: `test_` prefix with descriptive names - -## 🔗 Integration Points - -### EIP-7702 Compatibility -- See `7702Caller.sol` for account abstraction support -- Follows EIP-7702 standard for external ownership -- Integrates with MulticallScripter execution model - -### Related Projects -- **Multicall3**: Basic batching (this project adds chaining) -- **Weiroll**: Different approach to VM scripting -- **Comparison**: Documented in README.md "Related Projects" - -### External Dependencies -- **Foundry**: Development and testing framework -- **Viem**: Ethereum TypeScript/JavaScript library -- **Bun**: JavaScript runtime for tests and examples - -## 🎯 Quick Reference Tables - -### Offset Bit Layouts -| Call Type | Total Bits | Field Breakdown | -|-----------|------------|-----------------| -| Regular | 256 | 8 + 120 + 120 | -| Partial Return | 256 | 8 + 8 + 120 + 48 + 48 + 16 + 8 | - -### Type Utilities (JavaScript) -| Function | Purpose | Example | -|----------|---------|---------| -| `isDynamicType()` | Check if type is dynamic | `isDynamicType("string") → true` | -| `getElementType()` | Get array element type | `getElementType("address[]") → "address"` | -| `isArrayType()` | Check if type is array | `isArrayType("uint256[]") → true` | - -### Test Helpers (`js/test/common.js`) -| Function | Purpose | -|----------|---------| -| `loadABI()` | Load ABI from file path | -| Test utilities | Common setup for JavaScript tests | - -## 🤖 LLM-Specific Guidance - -### When Modifying Code -1. **Always update both layers**: Solidity changes need JavaScript updates -2. **Check offset encoding**: Use test files as reference for bit operations -3. **Run full test suite**: Don't rely on partial test results -4. **Verify examples**: Ensure example scripts still work - -### When Adding Features -1. **Start with tests**: Write tests first to define expected behavior -2. **Follow existing patterns**: Use similar code structure to existing features -3. **Document changes**: Update README.md and code comments -4. **Consider constraints**: Remember 3-variable limit and bit encoding limits - -### When Debugging -1. **Use verbose output**: `-vv` flag in Foundry tests -2. **Check both test suites**: Solidity and JavaScript tests may fail differently -3. **Examine bit operations**: Most bugs are in shift/mask calculations -4. **Compare with working code**: Use existing features as reference implementation - -### Best Practices -1. **Keep constants synchronized**: Update both Solidity and JavaScript -2. **Maintain test parity**: Features should have tests in both layers -3. **Document edge cases**: Especially for bit manipulation code -4. **Use descriptive names**: Especially for offset-related variables - ---- - -*This AGENTS.md file is maintained to help LLMs understand and contribute to the Multicall Scripting project effectively. Update it when adding significant new features or changing architecture patterns.* \ No newline at end of file +forge build && forge test # needs bun + cargo on PATH for the FFI suites +ETH_RPC_URL=... FORK_BLOCK=... forge test --mc CallBuilder # the two mainnet-fork tests (skipped otherwise) +uv run script/check-memory-bounds.py # scoped solver checks (not full formal verification) +script/rehearse.sh # local deployment, real protocol/7702 transactions, fork tests +forge snapshot --fuzz-seed 0x51c7 --no-match-contract 'JsLibrary|RustLibrary|Invariant' --no-match-test 'test_mint_weth_and_swap' # refresh .gas-snapshot (CI checks it, 5% tolerance) +cd js && bun install && bun test +cd rust && cargo test && cargo clippy --all-targets +bun js/scripts/gen-test-vectors.js && bun js/scripts/gen-build-vectors.js # after changing encoders/builder +cd js && bun run sync:schema # after editing schema/offset-schema.json +``` + +## Invariants — do not break these + +1. **One bit layout.** `schema/offset-schema.json` is canonical. Solidity mirrors it by hand + (`Constants` + the masks/shifts in `execute`), JS reads the synced mirror, Rust generates + constants at build time. Changing a field means: schema → `bun run sync:schema` → Solidity → + regenerate both vector files → all three suites green. +2. **memTargets are relative to the call after the producer.** `execute` resolves + `memTarget` against the start of the calldata of call `i+1` when call `i` produced the data. + Builders must add the region size (`32 + pad32(len)`) of every call between producer and + consumer. The JS builder does this in `addCall`; Rust in `add_call`. +3. **Positions come from real encodings, never guesses.** The JS builder locates argument + positions by reading head pointers out of the viem-encoded calldata (`locate`), and lays out + return data from the ABI outputs (`layoutTuple`). Anything whose position is only known at run + time (a second dynamic value, arrays of dynamic elements) is marked `unsupported` and throws on + use. Keep it that way; a wrong position means the executor splices into the wrong argument. +4. **The executor validates what it can.** Every write must stay inside the calldata region + (`InvalidMemoryTarget`), every byte spliced must come from real return data + (`InsufficientReturnData`), `valueIndex` must be in range, unspent `msg.value` is refunded. + Execution depends only on the decoded inputs, not on how they were encoded (the packed `bytes` buffer is located through its ABI head). The 7702 signature covers `abi.encode` of all four inputs. + The third argument is packed `bytes`: `[length][data padded to 32]` frames, with no inner + head table. All bytes are signed; frame bounds and the final frame count are validated. +5. **Stateless executor.** No storage, no held balances, `receive()` reverts. The 7702 delegate + has exactly one namespaced storage slot (`nonce`, ERC-7201 `multicall-scripting.7702.nonce`) and no admin surface. +6. **Error selectors are hardcoded in Yul.** If you add or rename an error, recompute the selector + (`cast sig 'Name()'`), update the Yul literal and the `@dev` comment, and add an + `expectRevert` test — the test is what proves the literal is right. +7. **Golden vectors pin Rust to JS.** If a JS change alters `build()` output for an existing + scenario, that is either a bug fix (regenerate vectors, and expect Rust to need the same fix) or + a regression. Never regenerate vectors to make a failing test pass without understanding why. + +## How a change flows + +- **New executor behaviour** → `src/MulticallScripter.sol` *and* `src/MulticallScripterReadOnly.sol` + (they share the decode/splice rules — keep them in step; the writer copies partial slices directly from returndata) → test in `test/MulticallScripter.t.sol` + (and `test/CallBuilder.sol` if the encoders change) → `forge snapshot` → both builders if the + encoding changed. +- **New builder capability** → `js/index.js` → unit test in `js/test/layouts.test.js` with + hand-computed positions → on-chain proof in `js/test/layouts.js` + `test/JsLibrary.t.sol` + (add a function to `Layouts` in `test/Helpers.sol`) → port to Rust if it is on the supported + path, or extend the "Not ported" list in `rust/crates/builder/src/lib.rs`. +- **Docs** → README.md for users, this file for contributors, SECURITY.md for trust/limits, the + skill for library consumers. Keep them factual; delete rather than let them drift. + +## Conventions + +- Solidity: `// SPDX-License-Identifier: GPL-3.0`, `pragma solidity ^0.8.28`, named imports, + custom errors (no revert strings in `src/`), `assembly ("memory-safe")`. Build is via-IR with + the optimizer on; the executors are written as small Yul functions (`step`, `partial`/`splice`, + `doCall`) — that, plus via-IR, is what keeps them clear of stack-too-deep. Do not fold them back + into one block. +- JS: ES modules, `BigInt` for every 256-bit quantity, viem is the only runtime dependency, + private helpers stay module-local. Tests are bun (`*.test.js`); FFI scripts print one JSON line. +- Rust: alloy only (no ethers-rs), `thiserror` errors whose messages mirror the JS strings, + offsets are `U256`, constants never hand-written. +- Tests: every executor error has a test; every builder position rule has a hand-computed unit + test and an on-chain FFI test; write benchmarks cap executor-only gas at the pre-review baseline (no regression headroom). + +## Things that look like bugs but are not + +- `values` may be shorter than `targets`; only calls with `valueIndex > 0` read it. +- A static call with `staticCall(0, 0)` and no consumer is legal (side-effect-free read that + nobody uses); the builder emits it for unconsumed view calls. +- The last call can never chain (there is no next call); the executor reverts with + `InvalidMemoryTarget` rather than writing past the region. +- `with_length(0)` is allowed (empty array / bytes → 32-byte length word only). +- The CLIs reject unsafe JSON numbers. Quote large integers; JS API callers use BigInt or + strings. Reference objects contain safe integer positions. Ambiguous overloaded names are rejected. + +## Things that are genuinely out of scope + +- `delegatecall` from the executor (removed; it would break statelessness). +- ERC-4337 / entry-point integration in the 7702 delegate. +- Chaining values whose position depends on runtime data (second dynamic value, dynamic elements). diff --git a/AUDIT.md b/AUDIT.md deleted file mode 100644 index 34893bbb9..000000000 --- a/AUDIT.md +++ /dev/null @@ -1,165 +0,0 @@ -# AUDIT.md — Self-Assessment of Security Risks - -MulticallScripter has not been audited by an independent third party. This -document provides a self-assessment of the trust model, attack surface, known -risks, and gaps. It is intended to help users and auditors understand the -security boundaries of the system. - -See also `docs/plans/security-self-audit.md` for the original plan. - ---- - -## Trust Model - -The caller supplies four arrays: - -| Array | Content | Controlled by | -|---|---|---| -| `targets[]` | Contract addresses to call | Caller | -| `offsets[]` | 256-bit packed encoding of call type + memory layout | Caller | -| `calldatas[]` | Raw calldata bytes per call | Caller | -| `values[]` | ETH values to forward (only used with 0xFE/0xFB) | Caller | - -**The VM does not validate call targets or calldata contents.** It guarantees: -- Atomic execution order (sequential, all-or-nothing via EVM transaction) -- Correct return-data splicing at the offsets specified by the caller - -If the caller trusts the callee contracts, the VM output is deterministic for a -given script. The VM's security model is: it faithfully executes the user's -script, splicing return data exactly as the offset encoding dictates. It does not -protect against malicious or buggy callee contracts. - ---- - -## Component Analysis - -### MulticallScripter.sol - -**Stateless executor.** No storage variables, no Ether balance (rejects direct -ETH via `receive()`), no `selfdestruct`, no `delegatecall`. The contract cannot -hold tokens, be reentered, or modify its own state. - -**Assembly-only implementation.** All execution logic in `execute()` is Yul -assembly. Key implications: -- Standard Solidity safety nets (checked arithmetic, out-of-bounds array access) - do not apply within assembly blocks -- The `mcopy` opcode (Cancun+, EIP-5656) is used for partial-return (0xFC/0xFB) - data splicing -- Loop logic (`i`, `u`) and pointer arithmetic (`calldataOffset`) are - hand-managed via Yul locals — no compiler-level protection - -**Return-data bounds checking (partial, 0xFC/0xFB only).** -- For partial returns (0xFC/0xFB): `require(gt(add(memTarget, resLength), free_mem))` - reverts with `InvalidMemoryTarget()` if a variable's memory target exceeds the - pre-allocated calldata region. This is a bounds check on the `mcopy` destination. -- For regular calls (0xFF/0xFE): the `staticcall`/`call` writes `returnSize` - bytes to `calldataOffset + 0x20 + memTarget` **without an explicit bounds check**. - If the caller supplies a `memTarget + resultLength` exceeding the next call's - calldata region, the write may overflow into subsequent calldata or unallocated - memory. This is the intended chaining mechanism — return data is written into - subsequent calldata — but the VM does not guard against misconfiguration. - *Mitigation: caller controls all inputs; a malformed script will either corrupt - its own calldata (detectable) or cause a revert in the corrupted call.* - -**Invalid offset recovery.** Every call reads its `offset` from the offsets array. -Unknown call types revert with `InvalidCalltype(uint256)`. The `DELEGATE_CALL_FLAG -(0xFD)` is defined in both layers but reverts — it has no implementation path. - -**No return-data re-use guard in assembly.** The `execute()` loop reads each -offset independently — there is no assembly-level check that return data is used -only once. However, the JavaScript `TransactionBuilder` enforces this at build -time via `usedDescriptors`. - -### 7702Caller.sol - -**Stateful EIP-7702 wrapper.** This contract holds ETH, manages authorized signers, -and supports delegatecall. It inherits `MulticallScripter` and wraps `execute()`. - -**Auth model:** -- `onlyAuthorized`: caller must be in `authorizedSigners` mapping -- `onlyEntryPointOrAuthorized`: caller must be entry point OR authorized -- `execute()` uses `onlyEntryPointOrAuthorized` — allows EIP-7702 entry point OR - direct calls from authorized signers -- Signers can add/remove other signers (except themselves) -- Entry point can be updated by any authorized signer - -**ETH handling:** -- `receive()` accepts ETH (overrides base contract's rejection) -- `withdrawETH(to, amount)` restricted to `onlyAuthorized` -- `executeCall()` forwards ETH via `call{value: value}` — restricted to - `onlyAuthorized` -- During `execute()`, the `values[]` array is used to forward ETH to callees; - the contract must hold sufficient balance - -**Delegatecall risk:** `executeDelegateCall()` allows authorized signers to -execute arbitrary code in the contract's storage context. This is a powerful -primitive but a standard one in account abstraction designs. Signers are the -security boundary. - -**EIP-712 authorization:** `executeWithAuthorization()` supports gasless -execution via typed signatures. Includes nonce-based replay protection, expiry -validation, and domain separator with chain ID. The signer recovery logic -(`_recoverSigner`) validates signature length, v-value recovery, and non-zero -address. - -### JavaScript TransactionBuilder - -**Off-chain component.** The `TransactionBuilder` and `encoding.js` produce the -arrays that `execute()` consumes. A bug here produces malformed offsets, which -would cause at worst a revert (InvalidCalltype, InvalidMemoryTarget) or corrupted -calldata in a correctly-named contract call. - -**Offset encoding correctness** is validated by: -- `js/test/schemaRoundtrip.test.js` — 14 unit tests covering encode→decode roundtrips -- `js/test/property-tests.test.js` — 8 property-based fuzz tests on random call sequences -- `test/JsLibrary.t.sol` — FFI integration tests comparing JS-generated offsets - against Solidity-generated equivalents - -A single bit-shift error in the JS encoder would produce valid-looking offsets -that decode differently in Solidity. The current defense is exhaustive sampling, -not formal proof. - ---- - -## Findings - -| # | Severity | Finding | Component | -|---|---|---|---| -| F1 | Medium | **No bounds check on returnOffset+returnSize for 0xFF/0xFE.** `staticcall`/`call` writes return data to memory without verifying `memTarget + returnSize` fits in the calldata region. 0xFC/0xFB have this check via `InvalidMemoryTarget`. | MulticallScripter | -| F2 | Low | **DELEGATE_CALL_FLAG (0xFD) defined but unimplemented.** Both Solidty and JS define the constant and validate offsets with it, but `execute()` reverts. Dead code that may confuse users. | MulticallScripter + JS | -| F3 | Low | **CALL_PARTIAL_RETURN_FLAG (0xFB) undertested.** Single Solidity test (`CallBuilder.t.sol`) and no JS roundtrip test in the schema test suite. The encoding path has less coverage than 0xFC. | MulticallScripter + JS | -| F4 | Low | **Stateless contract = reentrancy-safe.** No storage to corrupt on classical reentrancy. However, read-only reentrancy (view functions called mid-execution) is not applicable since the contract has no state to read. `7702Caller` introduces state but with auth guards. | MulticallScripter | -| F5 | Info | **Offset encoding correctness not formally verified.** Sampled integration tests only. A single bit-shift error could produce valid-encoding, wrong-semantics offsets silently. | JS encoding | -| F6 | Info | **Return-data write is a memory corruption vector if offsets are malformed.** If `memTarget + returnSize` exceeds the calldata region, data can be written into subsequent calls' calldatas or unallocated memory. Equivalent to calldata corruption: incorrect results but no fund loss (VM does not hold assets). | MulticallScripter | -| F7 | Info | **No bounds on values[] array length relative to value indices.** If a script uses a `valueIndex` exceeding `values.length-1`, the `calldataload(add(values.offset, sub(shl(5, value), 0x20)))` reads garbage from outside the values array. In practice, this reads calldata bytes belonging to other arrays — not an exploit, but a user footgun. | MulticallScripter | - ---- - -## Known Gaps - -- `DELEGATE_CALL_FLAG (0xFD)` — defined but unimplemented (see `docs/plans/unimplemented-features.md`) -- `CALL_PARTIAL_RETURN_FLAG (0xFB)` — thin test coverage -- No formal verification of encoding invariants (see `docs/plans/formal-verification.md`) -- No symbolic execution of memory bounds (see `docs/plans/symbolic-execution.md`) -- Gas regression not enforced in CI (see `docs/plans/gas-regression.md`) -- CI runs only `forge test`, not `forge snapshot --check` or `bun test` (see `docs/plans/ci-pipeline.md`) - ---- - -## Recommendations - -1. **Independent audit** before any production deployment. This self-assessment - is a starting point, not a substitute for third-party review. -2. **Add bounds check for 0xFF/0xFE return data writes.** Either document as - caller responsibility (matching current behavior) or add an explicit - `require` like the 0xFC/0xFB path already has. -3. **Implement or remove 0xFD.** The dead constant is technical debt. Either - implement the delegatecall path or remove the flag from both layers. -4. **Add Halmos/hevm symbolic execution** for the offset decoding in `execute()`. - Proves absence of out-of-bounds memory writes for all valid offset inputs. -5. **Enforce gas regression in CI** via `forge snapshot --check`. -6. **Add invariant fuzzing on the JS builder** (complete — see `js/test/property-tests.test.js`). - ---- - -*Last updated: 2026-06-01* diff --git a/CLAUDE.md b/CLAUDE.md index 1a63a3a4d..5d07b4e4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,71 +1,10 @@ - -# multicall-scripting - -Atomic multi-contract execution with return-value chaining: the return data of one -call can be spliced into the calldata of a later call within the same transaction. -A Solidity executor (`src/MulticallScripter.sol`, pure-assembly `execute()`), a -JavaScript transaction builder (`js/index.js`), and a Rust builder (`rust/`, alloy-based) -all share a 256-bit offset encoding defined canonically in `schema/offset-schema.json`. - @AGENTS.md -## Commands - -```bash -# Install dependencies (JS) -cd js && bun install - -# Install dependencies (Solidity/Foundry) -forge install - -# Build contracts -forge build - -# Test all layers -forge test -vv -cd js && bun test -cd rust && cargo test - -# Run a specific Solidity test -forge test --match-test test_partial_return_data -vv - -# Run a specific JS test -cd js && bun test/test/simpleValue.js - -# Build/test the Rust layer (offset constants are codegen'd from offset-schema.json) -cd rust && cargo build && cargo test - -# Regenerate cross-language golden vectors after changing the JS encoders -bun js/scripts/gen-test-vectors.js - -# Execute example strategies -cd js/examples && bun run multiple_swaps.js -``` - -## Key Constraints - -- **Bit alignment must match across all three layers** — Solidity, JavaScript, and Rust share one bit layout. `schema/offset-schema.json` is the single source of truth: the Rust constants are codegen'd from it (`rust/crates/codec/build.rs`), the JS layer reads a generated mirror `js/offset-schema.json` (synced via `bun run sync:schema`; the npm package bundles the mirror), and `src/MulticallScripter.sol` mirrors it by hand. When adding a flag/feature, edit `schema/offset-schema.json` first, then `bun run sync:schema`, update `src/MulticallScripter.sol`, `js/index.js`, and `rust/`, and regenerate golden vectors (`bun js/scripts/gen-test-vectors.js`). -- **The `mcopy` opcode (EIP-5656) is required** — this project targets Cancun+ EVM. Do not remove mcopy usage or replace with slower opcodes. -- **Maximum 3 return variables per call** — the 256-bit offset encoding only fits 3×40-bit memTargets, 3×16-bit resultLengths, and 3×16-bit returnOffsets. -- **Each return value descriptor can only be used once** — passing the same proxy to two calls throws. Create a new addCall() for a fresh descriptor. - -## Navigation +Repo-specific notes for Claude Code sessions: -| Topic | Where to find it | -|-------|-----------------| -| Architecture & components | docs/OVERVIEW.md | -| Solidity contracts | docs/contracts.md | -| JavaScript library | docs/javascript.md | -| Rust library | docs/rust.md | -| Test patterns | docs/testing.md | -| Example strategies | docs/examples.md | -| Solidity conventions | .claude/rules/solidity.md | -| JavaScript conventions | .claude/rules/javascript.md | -| Rust conventions | .claude/rules/rust.md | -| Testing conventions | .claude/rules/testing.md | -| Implementation plans | docs/plans/ | -| Feature specs | docs/specs/ | -| Review findings | docs/reviews/ | -| Find anything quickly | .claude/context-map.md | -| How to maintain these docs | docs/NAVIGATION.md | -| Personal preferences | CLAUDE.local.md (add to .gitignore) | +- Run the full matrix before declaring anything done: `forge test`, `cd js && bun test`, + `cd rust && cargo test`. The FFI suites in `forge test` need `bun` and `cargo` on PATH. +- Do not add docs directories, plans, or generated context maps. README.md, AGENTS.md, + SECURITY.md and the skill are the whole documentation set. +- When a builder position rule changes, add a hand-computed case to `js/test/layouts.test.js` + and an on-chain case to `js/test/layouts.js` / `test/JsLibrary.t.sol` in the same change. diff --git a/README.md b/README.md index c76271b19..3fd7b1ea9 100644 --- a/README.md +++ b/README.md @@ -1,192 +1,342 @@ # multicall-scripting -[![License: GPL3](https://img.shields.io/badge/License-GPL3-blue.svg)](LICENSE) +[![License: GPL-3.0](https://img.shields.io/badge/License-GPL3-blue.svg)](LICENSE) [![Solidity](https://img.shields.io/badge/Solidity-^0.8.28-363636?logo=solidity)](https://soliditylang.org/) [![Foundry](https://img.shields.io/badge/Built%20with-Foundry-FF6C37?logo=ethereum)](https://getfoundry.sh/) -Execute complex multi-contract strategies in a single atomic transaction with full return value chaining between calls. +Run a sequence of contract calls in one atomic transaction, where the return value of any call can +be used as an argument to a later call. -## The problem - -Regular multicall (like Multicall3) only batches independent calls — it can't use the return value from one call as an argument to the next within the same transaction. The usual workarounds are writing a custom contract per strategy, or splitting into multiple transactions. Both approaches add friction and break atomicity. - -Multicall Scripting solves this by precalculating memory offsets offchain and using the `mcopy` opcode to copy return data directly into subsequent call arguments at execution time. +Plain multicall (Multicall3 and friends) only batches independent calls. If step 3 needs the number +step 1 returned, you either write a bespoke contract or split the flow into several transactions. +This project precomputes, off-chain, exactly which bytes of each call's return data must land where +in a later call's calldata, and a tiny on-chain executor copies return data directly into calldata before +each call, without persistent executor state. ## Components -- **`MulticallScripter.sol`** — core execution contract. Accepts a batch of calls with encoded offset metadata and runs them in sequence, writing return data to the locations specified by the offsets. -- **`CallBuilder.sol`** — Solidity DSL for building call chains in tests. -- **`TransactionBuilder` (JS)** — builds the call batch, encodes offsets, and returns ABI-encoded arguments ready for `execute()`. -- **`multicall-scripter` (Rust, `rust/`)** — an alloy-based port of the builder (codec + builder + CLI) for Rust/EVM tooling. See [docs/rust.md](docs/rust.md). -- **Descriptors** — proxy objects returned by `addCall()`. Pass them as arguments to subsequent calls to express data dependencies. - -All three layers share one canonical bit layout, [`schema/offset-schema.json`](schema/offset-schema.json) — Solidity mirrors it by hand, JS reads a generated mirror, and Rust codegens from it. +| Piece | Path | Role | +|---|---|---| +| `MulticallScripter` | `src/MulticallScripter.sol` | The executor. One function, `execute(targets, offsets, calldatas, values)`, in Yul. Stateless. | +| `MulticallScripterReadOnly` | `src/MulticallScripterReadOnly.sol` | Same arguments and encoding, but `view`: every call is a `staticcall` and the function returns each call's full return data. For `eth_call` reads. | +| `SevenSevenZeroTwoCaller` | `src/7702Caller.sol` | Optional EIP-7702 delegate so an EOA can run a batch as itself, or have a relayer submit a signed batch. | +| `TransactionBuilder` (JS) | `js/` | Builds the four inputs. Tracks return-value dependencies and encodes the 256-bit offset words. Published as `multicall-scripter`. | +| `multicall-scripter` (Rust) | `rust/` | Alloy-based port of the builder (codec + builder + CLI) for Rust tooling. Covers the scalar and single-dynamic-value paths. | +| `schema/offset-schema.json` | | The single definition of the offset-word bit layout all three layers follow. | ## Quick start ```bash -git clone https://github.com/0xdewy/multicall-scripting -cd multicall-scripting - -# JS library -cd js && bun install - -# Solidity (requires Foundry) -curl -L https://foundry.paradigm.xyz | bash && foundryup -forge install - -# Rust library (optional) -cd rust && cargo build +git clone https://github.com/0xdewy/multicall-scripting && cd multicall-scripting +forge install # Solidity (needs Foundry) +cd js && bun install # JavaScript +cd ../rust && cargo build # Rust (optional) ``` -### Read a balance, then transfer it +### Read a balance, then transfer exactly that amount ```javascript -import { TransactionBuilder } from "./js/index.js"; +import { TransactionBuilder } from "multicall-scripter"; const builder = new TransactionBuilder(); -// addCall returns a descriptor for the return value -const balance = builder.addCall(ERC20_ABI, tokenAddress, "balanceOf", [userAddress]); +// addCall returns a descriptor: a placeholder for the value this call will return +const balance = builder.addCall(ERC20_ABI, token, "balanceOf", [executor]); -// pass the descriptor as an argument — the actual value is wired at execution time -builder.addCall(ERC20_ABI, tokenAddress, "transfer", [recipientAddress, balance]); +// pass the descriptor as an argument; the executor splices the real value in at run time +builder.addCall(ERC20_ABI, token, "transfer", [recipient, balance]); const { targets, offsets, calldatas, msgValues } = builder.build(); -await multicallScripter.execute(targets, offsets, calldatas, msgValues); -``` -### Struct field access - -```javascript -// getUser() returns a struct; access its fields directly as descriptors -const user = builder.addCall(aaveABI, pool, "getUserAccountData", [userAddress]); - -builder.addCall(aaveABI, pool, "borrow", [ - daiAddress, - user.availableBorrowsBase, - 2, - 0, - userAddress, -]); +await walletClient.writeContract({ + address: EXECUTOR, abi: EXECUTOR_ABI, functionName: "execute", + args: [targets, offsets, calldatas, msgValues], + value: msgValues.reduce((a, b) => a + b, 0n), +}); ``` -### Dynamic types +Calls run from the executor's address, so `balanceOf(executor)` above reads tokens the batch sent +to the executor earlier in the same transaction. See [`js/examples`](js/examples) for a full +ETH → WETH → DAI → USDC → user flow against an Anvil fork. -Dynamic types (strings, bytes, dynamic arrays) require calling `.with_length(n)` before use so the builder can calculate the memory layout: +### Struct fields, multiple returns, arrays ```javascript -const text = builder.addCall(abi, target, "getName", []); -text.with_length(24); // expected byte length of the returned string +// multiple outputs come back as an array of descriptors +const [reserve0, reserve1] = builder.addCall(PAIR_ABI, pair, "getReserves", []); -builder.addCall(abi, target, "setName", [text]); +// a struct output is an array of its fields that also answers to field names +const user = builder.addCall(AAVE_ABI, pool, "getUserAccountData", [me]); +builder.addCall(AAVE_ABI, pool, "borrow", [dai, user.availableBorrowsBase, 2, 0, me]); + +// descriptors can sit anywhere inside an argument: structs, arrays, structs in arrays +builder.addCall(abi, target, "setPairs", [[{ a: reserve0, b: 1n }, { a: 2n, b: reserve1 }]]); ``` -Array elements can be accessed by index after `.with_length()`: +### Dynamic values -```javascript -const addresses = builder.addCall(abi, target, "getOwners", []); -addresses.with_length(3); +`bytes`, `string` and `T[]` return values need their runtime length declared before use, because +the builder reserves that much space in the consuming call's calldata: -builder.addCall(abi, target, "transfer", [addresses[0], amount]); +```javascript +const owners = builder.addCall(abi, safe, "getOwners", []); +owners.with_length(3); // element count for arrays, byte length for bytes/string +builder.addCall(abi, target, "setOwners", [owners]); // whole array +builder.addCall(abi, target, "transfer", [owners[0], 1n]); // or one element ``` +The declared length must be exact. The executor reverts (`InsufficientReturnData`) if the callee +returns too few bytes; incorrect lengths can also make the consumer read adjacent arguments. +The declaration is a caller precondition, not a runtime length check. + +## Rules + +- **Successful calls can return `false`.** The executor propagates EVM reverts; it does not interpret ERC-20 boolean results. Scripts must enforce their own success conditions. +- **The executor is shared and permissionless.** Anything left in it when the transaction ends + (tokens, ETH, allowances) belongs to whoever calls next. End every batch by moving assets out. + Unspent `msg.value` is refunded to the caller automatically. +- **Never approve the executor to spend your tokens.** Anyone could then call `execute` with a + `transferFrom` against you. Fund a batch with `msg.value`, with tokens transferred *inside* the + batch, or run it from your own address through the EIP-7702 delegate. +- **At most 3 return-value slices per producing call.** The offset word has room for three + (target, length, source) triples. Read the same value twice with two calls if you need more. +- **Descriptors belong to one builder.** Generated references from another builder are rejected. Raw CLI references remain explicit byte positions. +- **Descriptors can be reused.** One result may feed multiple arguments or later calls, within the three-slice limit. +- **`with_length(n)` is an exact precondition, not a runtime check or safe truncation.** The original length word is copied too. A wrong length can revert or make the consumer read adjacent arguments. Use only canonical ABI returns with known exact lengths. +- **Dynamic argument layouts must match.** Array element types and tuple components are checked before reserving splice space. +- **One dynamic value per return.** If a function returns two strings, only the static outputs + are usable; the position of the second string depends on the first's length. +- **Arrays of dynamic elements** (`string[]`, `bytes[]`, structs containing strings inside arrays) + cannot be chained. +- **`msg.value` only on state-changing calls.** The builder rejects value on `view`/`pure`. +- **Cancun or later** (`mcopy`). The 7702 delegate additionally needs Prague. + ## How it works -The JavaScript builder tracks a virtual memory layout as you add calls. When you pass a descriptor as an argument, the builder records where in memory the source call's return data should land and what slice of the destination call's calldata it should overwrite. This produces a compact 256-bit offset per call that encodes the copy instructions. +`build()` produces, per call, a 256-bit offset word describing what to do with the call's return +data. The executor copies all calldata into one contiguous memory region, then for each call: -At execution time, `MulticallScripter.execute()` copies all calldata into memory up front, then executes each call in order. After each static call, it copies the specified slices of return data to the specified memory locations (into the calldata of later calls) using `mcopy`. State-changing calls use the same layout but don't support output chaining yet. +1. reads the offset word; for state-changing calls, picks `msg.value` from `values`, +2. performs `staticcall` / `call` with return data written straight into the memory of a later + call's calldata (regular layout) or with `returndatacopy` for each selected slice (partial layout), +3. reverts with the callee's revert data if the call fails, or `InsufficientReturnData` if the + callee returned fewer bytes than the word requires. -## API +Memory targets are byte offsets from the start of the *next* call's calldata; the builder adds the +size of any calls in between. A target outside the calldata region reverts (`InvalidMemoryTarget`). -### `TransactionBuilder` +### Packed calldata ABI (breaking change) -```javascript -import { TransactionBuilder } from "multicall-scripter"; +`execute(address[],uint256[],bytes,uint256[])` takes one packed `bytes` buffer as its third +argument. Both builders return it as `calldatas` (`0x…` in JS/JSON, `Bytes` in Rust). Update +contract ABIs and rebuild saved scripts; the previous `bytes[]` selector is no longer supported. +The 7702 signature domain is version **3**, so old signatures must be replaced too. +The Rust codec now returns `Result` from `decode_partial_return`; handle malformed words explicitly. -const builder = new TransactionBuilder(); +The buffer is the concatenation of `[uint256 byteLength][call bytes][zero padding to 32 bytes]` +for each call. There is no array length or table of element offsets inside it. For example, one +empty call is a single zero word; an empty batch is `0x`. The frame count must match `targets` +and `offsets`. The executor validates each frame's bounds and copies the buffer once. +Every byte inside the buffer, including frame padding, is covered by a signed batch hash. +Builders emit zero padding; hand-written scripts may splice into later frame headers or padding, +but the resulting frames must remain within the buffer and consume it exactly. + +### Offset word layouts + +Canonical definition: [`schema/offset-schema.json`](schema/offset-schema.json). + +Regular — `0xFF` static, `0xFE` call: + +| Bits | Field | | +|---|---|---| +| 8 | calltype | | +| 8 | valueIndex | 1-based index into `values`; 0 = none (`0xFE` only) | +| 120 | memTarget | where the return data is written | +| 120 | returnSize | bytes of return data written (callee must return at least this many) | + +Partial return — `0xFC` static, `0xFB` call: + +| Bits | Field | | +|---|---|---| +| 8 | calltype | | +| 8 | valueIndex | as above (`0xFB` only) | +| 3×40 | memTargets | destination per slice, MSB-first | +| 3×16 | resultLengths | bytes per slice | +| 3×16 | returnOffsets | source offset within the return data per slice | +| 16 | returnDataSize | bytes of return data captured (max 65535) | +| 8 | numVars | slices in use (≤ 3) | + +### Errors + +| Error | Selector | Meaning | +|---|---|---| +| `LengthMismatch()` | `0xff633a38` | target/offset/frame counts differ, or packed buffer is not word-padded | +| `InvalidOffset(uint256)` | `0x6115f2de` | unknown calltype byte, or `numVars > 3` | +| `InvalidMemoryTarget()` | `0xd558ad4e` | a frame or return-data write would leave the calldata region | +| `InvalidValueIndex()` | `0x3b1a9b29` | `valueIndex > values.length` | +| `InsufficientReturnData()` | `0xcbce8a22` | callee returned fewer bytes than required | +| `RefundFailed()` | `0xf0c49d44` | caller could not receive the unspent `msg.value` | +| `EthNotAccepted()` | `0x60f8f321` | ETH sent to the executor outside `execute` | + +Any other revert is the callee's, bubbled unchanged. + +## Reading a chain off-chain + +`MulticallScripterReadOnly.execute` takes the same four inputs and returns `bytes[]`, one entry +per call with that call's complete return data, so a chain of reads (reserves → quote → health +factor) is one `eth_call` and every intermediate value is visible: + +```javascript +const results = await publicClient.readContract({ + address: READER, abi: READER_ABI, functionName: "execute", + args: [targets, offsets, calldatas, msgValues], +}); +const quote = decodeAbiParameters([{ type: "uint256" }], results[1])[0]; ``` -**`addCall(abi, target, functionName, args, msgValue = 0n)`** +Targets see the reader as `msg.sender`, not the write executor or a delegated EOA. Reads that +depend on the caller can therefore differ; use explicit account arguments when appropriate. +This endpoint evaluates read chains, not a simulation of state-changing batches. + +State-changing offset words are accepted but executed as `staticcall`, so a callee that writes +reverts; `values` is ignored. It shares the executor's errors except `InvalidValueIndex`, `RefundFailed`, and `EthNotAccepted`. -Adds a call to the sequence. Returns a descriptor (or object of descriptors for structs, or array-like proxy for dynamic arrays) representing the call's return value. Descriptors can be passed as arguments to subsequent `addCall()` invocations. +## EIP-7702 -**`build()`** +`SevenSevenZeroTwoCaller` lets an EOA that delegates to it run a batch with the EOA as +`msg.sender`, spending the EOA's own balance and allowances: -Returns `{ targets, offsets, calldatas, msgValues }` — the four arrays expected by `MulticallScripter.execute()`. +- `execute(...)` — callable only by the account itself (send the transaction to your own address). +- `executeWithSignature(targets, offsets, calldatas, values, deadline, signature)` — callable by + anyone with an EIP-712 signature from the account over the whole batch, a nonce and a deadline. + `hashExecute(...)` returns the digest to sign; `nonce()` the next nonce. -### Offset data layout +The domain is `{ name: "MulticallScripter7702", version: "3", chainId, verifyingContract: }` +and the type is `Execute(bytes32 batchHash,uint256 nonce,uint256 deadline)` with +`batchHash = keccak256(abi.encode(targets, offsets, calldatas, values))`. -Each offset is a 256-bit value encoding the call type and copy instructions. +The delegate has no owner list, entry point or admin functions; the EOA key is the only authority. +Its nonce uses the ERC-7201 namespace `multicall-scripting.7702.nonce`, rather than slot zero. +Version 3 rejects signatures from the earlier slot-zero implementation; regenerate signed batches. -**Static call / state-changing call:** +Delegation changes preserve account storage. Removing delegation does **not** cancel unused batch +signatures: they can work again after restoration if their nonce and deadline are still valid. +To cancel signatures for the current nonce, sign and execute an empty batch with that nonce; +signatures for future nonces require separate invalidation or expiry. An ordinary self-call does +not advance the signature nonce. A competing relayer can race cancellation until it is confirmed. +A failed authorization transaction can still install delegation: the protocol authorization is +processed separately from EVM execution rollback. The rehearsal tests these lifecycle cases. +Only migrate to code whose storage and signature behavior you have reviewed; namespacing prevents +accidental ordinary-slot collisions, not deliberate writes by another delegate. -| Bits | Field | Description | -|------|-------|-------------| -| 8 | `calltype` | `0xFF` static, `0xFE` regular call | -| 120 | `memTarget` | Memory offset where return data is written | -| 120 | `resultLength` | Bytes of return data to copy | +## Deployment and mainnet rehearsal -**Partial return (`0xFC`)** — used when a call produces multiple distinct output slots: +The deployment wrapper targets **Ethereum (chain 1)** or a local fork (31337). It deploys both +executors; set `DEPLOY_7702=true` to include the delegate. It uses the CREATE2 factory at +`0x4e59b44847b379578588920cA78FbF26c0B4956C`, checks the predicted addresses and exact runtime +bytecode, and safely accepts an existing matching deployment. Addresses depend on the salt and +**exact creation bytecode**, including compiler settings and metadata. -| Bits | Field | Description | -|------|-------|-------------| -| 8 | `calltype` | `0xFC` | -| 8 | `valueIndex` | Index into values array for msg.value | -| 120 | `memTargets` (40×3) | Destination offsets for up to 3 variables | -| 48 | `resultLengths` (16×3) | Byte lengths for each variable | -| 48 | `returnOffsets` (16×3) | Source offsets within the return data | -| 16 | `returnDataSize` | Total return data size (max 65535 bytes) | -| 8 | `num_vars` | Number of variable slots used | +Rehearse the release against real mainnet state, without a mainnet wallet: -## Limitations +```bash +cd js && bun install && cd .. +script/rehearse.sh +# Reproduce a specific block using an archive-capable endpoint: +ETH_RPC_URL=https://your-archive-rpc FORK_BLOCK=25914306 script/rehearse.sh +``` -- **Max 3 variables per call** — the partial return encoding uses 248 bits for three 40+16+16 bit triplets. -- **Dynamic types require `.with_length(n)`** — the builder needs to know the byte length up front to calculate memory positions. -- **Each return value can only be used once** — passing the same descriptor to two different calls throws. -- **Return values from state-changing calls cannot be chained** — only static calls support the partial return mechanism. -- **Return data size capped at 65535 bytes** — the `returnDataSize` field is 16 bits. -- **Requires Cancun or later** — the contract uses the `mcopy` opcode (EIP-5656). +The rehearsal starts its own Anvil process on an automatically selected free local port +(set `FORK_PORT` to require a specific port), +prints the fork block, deploys all three contracts, checks a second deployment run, and verifies: -## Testing & verification +- deployed runtime bytecode equals the local release artifacts; +- read-only Uniswap → Curve quotes equal direct calls; +- ETH → WETH → DAI → USDC reaches the recipient and leaves no executor balances or allowances; +- a late slippage failure rolls back the swaps, balances and approvals; +- a real EIP-7702 authorization, self-call, signed relayed batch and rejected replay work; +- a deployment rerun rejects mismatched code, and both Solidity mainnet-fork swaps pass. -The three layers are kept in lockstep by a verification system built in three rings, all anchored -on the canonical `schema/offset-schema.json`: +The process uses public Anvil test accounts and sends transactions only to its own local node. +Historical blocks may require an archive RPC; public endpoints can impose rate or history limits. -1. **Within each layer** — Rust codec property tests (random-input encode/decode roundtrips), - Solidity fuzz/unit tests. -2. **Cross-language golden vectors** — the JS builder generates committed vectors - (`js/test-vectors.json` for offsets, `js/build-vectors.json` for whole transactions) that both - the JS and Rust suites assert against. This proves the layers encode identically, down to the - ABI-encoded calldata. -3. **On-chain** — Foundry FFI tests (`JsLibrary.t.sol`, `RustLibrary.t.sol`) execute built - transactions through the real contract and check the resulting state. +Simulate the actual Ethereum deployment with an explicit deployer address: ```bash -# Solidity (incl. the Rust→on-chain FFI tests) -forge test -vv +DEPLOY_7702=true script/deploy.sh https://your-mainnet-rpc --sender YOUR_DEPLOYER_ADDRESS +``` + +To broadcast an approved release, pass `--broadcast` and Foundry wallet options, for example +`--account YOUR_KEYSTORE --sender YOUR_DEPLOYER_ADDRESS --broadcast --verify`. Use `--ledger` +if signing with a hardware wallet. Explorer verification needs the appropriate Foundry explorer +configuration. The wrapper neither sources a shell `.env` nor requires a raw private key. +Foundry itself supports dotenv; prefer exported settings and keystore/hardware-wallet signing. +A failed deployment or bytecode check exits nonzero and preserves the full error output. -# JavaScript -cd js && bun test +## Testing -# Rust -cd rust && cargo test +```bash +forge test +cd js && bun test && cd .. +cd rust && cargo test && cargo clippy --all-targets -- -D warnings && cd .. +forge test --match-contract 'AdversarialTest|MulticallScripterReadOnlyTest|MulticallScriptTest|SevenSevenZeroTwoCallerTest' --fuzz-runs 4096 --fuzz-seed 0x51c7 +FOUNDRY_INVARIANT_RUNS=1024 FOUNDRY_INVARIANT_DEPTH=512 forge test --match-contract Invariant --fuzz-seed 0x51c7 +uv run script/check-memory-bounds.py # scoped arithmetic checks, not whole-contract verification ``` -When changing the encoding, edit `schema/offset-schema.json`, then -`cd js && bun run sync:schema && bun js/scripts/gen-test-vectors.js && bun js/scripts/gen-build-vectors.js`, -and re-run all three suites — any drift between layers fails a test. See -[docs/testing.md](docs/testing.md) for the full model. +`forge test` includes JS and Rust FFI tests, so Bun and Cargo must be on PATH. Two swaps in +`test/CallBuilder.t.sol` require `ETH_RPC_URL`; they skip in an offline run and are required by +the fork rehearsal. `FORK_BLOCK` pins the state; otherwise those tests use the endpoint's current +block. The CI workflow has a manual mainnet-fork rehearsal job as well. + +The byte-copy model checks both executors against expected intermediate calldata. Adversarial +tests exercise malformed frames, exact revert propagation, static-context restrictions, nested +execution, refund callbacks, reentrant signature replay, forced ETH and false return values. +Stateful models track committed arithmetic, calls, refunds, balances and nonces across success +and rollback; the read-only executor must report the modeled state. Unexpected handler reverts +fail the invariant run. + +JS property tests generate reproducible batches through the public builder API and interpret +them independently. Golden vectors pin Rust and JS encodings. Both CLIs reject invalid ETH values +and unsafe JSON numbers: quote integers larger than `9007199254740991`; the JS API accepts +`BigInt` or strings. For overloaded functions, supply an ABI containing only the intended overload. +See [SECURITY.md](SECURITY.md) for release evidence and limits, and [AGENTS.md](AGENTS.md) for the +contributor workflow. + +### Gas regression gates + +`test/GasBenchmarks.t.sol` measures the executor call itself with `vm.lastCallGas()`, excluding +batch construction and ABI encoding in the test. Its nine hard caps are the old executor's +measurements at commit `f8d42dfc4d73a9c571e033c704f430de4b31e668`, compiled with the same current +Solidity 0.8.28, via-IR, 1,000,000 optimizer runs and Prague settings. Every cap passes without +regression tolerance. Whole-test snapshots are a separate check, using a fixed fuzz seed to avoid sampling noise. + +Actual Anvil Prague transaction receipts, with identical targets and calls for both versions: + +| Workload | Previous gas | Packed gas | Reduction | +|---|---:|---:|---:| +| 10 reads | 50,020 | 46,220 | 7.6% | +| 30 reads | 102,020 | 90,620 | 11.2% | +| 30 scalar links + event | 105,170 | 93,390 | 11.2% | +| 5 links, 320-byte payload | 259,227 | 258,190 | 0.4% | +| 5 links, 1024-byte payload | 743,196 | 742,166 | 0.1% | +| 5 calls, three slices | 39,764 | 38,316 | 3.6% | +| 10 chained writes | 65,579 | 63,866 | 2.6% | +| 10 writes, partial slices | 68,263 | 66,521 | 2.6% | +| 10 ETH-bearing writes | 132,635 | 131,122 | 1.1% | + +The same receipt comparison also passes against the old compiler configuration (optimizer off). +Execution-only gas and transaction gas differ: transaction receipts include calldata charges, +including [Prague's calldata floor](https://eips.ethereum.org/EIPS/eip-7623). The packed buffer saves one 32-byte ABI head per call. ## Security -This repository has not been audited. See [AUDIT.md](AUDIT.md) for a self-assessment of known issues. Not suitable for production use without independent review. - -## Related projects +Not independently audited. [SECURITY.md](SECURITY.md) describes the trust model, what has been +reviewed, and the remaining limitations. -- [Multicall3](https://github.com/mds1/multicall) — standard call batching without return-value chaining -- [Weiroll](https://github.com/weiroll/weiroll) — a more complete scripting VM for EVM +## Related -## License +- [Multicall3](https://github.com/mds1/multicall) — batching without return-value chaining +- [Weiroll](https://github.com/weiroll/weiroll) — a full scripting VM for the EVM GPL-3.0 — see [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..11a3e61d6 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,242 @@ +# Security + +This code has not been audited by an independent third party. This document states the trust +model, what the internal review covered and fixed, and the limits that remain. Report issues +privately to the maintainer listed in `js/package.json` before disclosing them. + +## Trust model + +`MulticallScripter.execute` takes four caller-supplied inputs and guarantees only: + +1. the calls run in order, atomically (any failure reverts the whole transaction with the + callee's revert data); +2. return data is written exactly where the offset words say, and only inside the memory that + holds the batch's own calldata; +3. every byte spliced comes from bytes the callee actually returned; +4. `msg.value` forwarded per call is taken from `values` by a checked index, and whatever part + of `msg.value` the batch did not spend is returned to the caller. + +It does **not** validate targets or calldata contents. A script that calls a malicious contract +gets whatever that contract does. Execution depends only on the decoded inputs, not on how they +were ABI-encoded. + +The executor is stateless and permissionless. **Anything in its possession when a transaction ends +— tokens, ETH, allowances — can be taken by the next caller.** Scripts must move all assets out +before finishing. Direct ETH transfers to the executor revert. For the same reason, **no one +should ever grant the executor a token allowance**: any caller could spend it. + +`SevenSevenZeroTwoCaller` is the opposite: it *is* an account (the delegating EOA) and holds +value. Its only authority is the EOA's own key: `execute` requires `msg.sender == address(this)`, +`executeWithSignature` requires an EIP-712 signature by the account over the full batch, a nonce +and a deadline. There are no signers, owners or entry points to misconfigure. + +## What the internal review (September 2026) found and fixed + +Executor (`src/MulticallScripter.sol`): + +- Return-data length was never checked. A call to an address with no code (a typo'd token + address) succeeded with empty return data, leaving the placeholder argument in place. Now + `InsufficientReturnData`. +- Partial-return slices could read past the captured return data (zeros). Now checked. +- `valueIndex` out of range read past the `values` array. Now `InvalidValueIndex`. +- Unspent `msg.value` stayed in the executor, claimable by anyone. Now refunded. +- Execution walked calldata bodies sequentially and assumed the `values` array followed them, + so a non-canonical encoding of the same inputs could execute different bytes than a signature + covered. The third argument is now a packed `bytes` buffer, located through its ABI head. Every + internal byte, including frame padding, is signed. Frame lengths and final consumption are + bounds-checked. This removes the per-call normalization pass and the unsigned-padding ambiguity. + The ABI changed from `bytes[]` to `bytes`, and the 7702 domain version changed to 2. +- `InvalidCalltype` was reverted with malformed error data (selector right-aligned in a + 32-byte word). Replaced by a correctly encoded `InvalidOffset(uint256)`. +- A defined-but-unimplemented `delegatecall` flag was removed from every layer. + +EIP-7702 delegate (`src/7702Caller.sol`, rewritten): + +- `executeWithAuthorization` accepted a signature that did not cover the batch and did not check + the signer was authorised: anyone could sign for themselves and drain the wallet. The whole + contract was replaced by the two-entry-point design above. +- Constructor-initialised `authorizedSigners` / `entryPoint` are meaningless under 7702 (the + constructor never runs in the EOA's storage). Removed. + +JavaScript builder (`js/index.js`, rewritten): + +- Memory targets were positions inside the consuming call, but the executor interprets them + relative to the call after the producer. Chaining silently corrupted the intermediate call's + arguments whenever the consumer was not the very next call. +- Argument positions assumed every parameter head is 32 bytes (wrong for static tuples), that + dynamic tails follow a single 32-byte head (wrong for multi-parameter functions), that string + literals are ASCII, and that descriptors inside struct arguments sit at the struct's first slot. + Return offsets ignored the pointer word of dynamic tuple outputs and the position of arrays + that were not the first output. All positions are now derived from the real encoding; layouts + that cannot be known before execution are rejected. + +Both builders now reject incompatible dynamic layouts (including array element sizes), permit +reusing a return value within the three-slice limit, and validate references before recording +splices. A failed addition does not consume a descriptor. Oversized dynamic lengths are rejected +before allocating placeholders. + +The Rust builder shared the head-size and consumer-distance bugs and was fixed the same way. + +Build: the optimizer was off; it is now on (via-IR), and the compilation target is pinned. + +## Remaining limits + +- **No whole-contract formal verification.** The executor's memory safety argument is by construction (every + write is bounds-checked against the calldata region; partial slices read directly from the EVM + return-data buffer) and by tests, + not by proof. +- **Delegation lifecycle.** The nonce uses ERC-7201 namespace `multicall-scripting.7702.nonce`. + Domain version 3 rejects old slot-zero signatures. An arbitrary delegate can still deliberately + overwrite this namespace; storage-safe migration is a caller responsibility. Removing delegation + preserves storage and does not invalidate unused signatures after restoration. An empty signed + batch consumes the current nonce; ordinary self-calls do not. Future-nonce signatures need + separate invalidation or expiry, and pending cancellations can be raced. Protocol authorization + survives a reverted execution transaction. These rules follow [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702) + and are exercised with actual Anvil authorization transactions, not just etched runtime code. +- **Callee behaviour is the caller's responsibility.** Nested execution has separate EVM memory, + and the bare executor has no storage. This is not protection against malicious callees: they + can reenter the executor or batch targets and interact with assets and allowances available + during the batch. Only include targets and calldata the caller intends to trust. +- **EVM success is not protocol success.** An ERC-20 returning `false` does not revert the batch. + The executor does not interpret results or add SafeERC20 checks. Use protocols that revert on + failure or explicitly enforce the necessary return-value conditions in the script's callees. +- **Reader caller context.** Targets see `msg.sender == reader`, which differs from the write + executor or a delegated EOA. Matching input arrays do not guarantee matching caller-dependent + reads; the reader is not a state-changing batch simulator. This distinction has a regression test. +- **Resource limits.** Gas exhaustion or very large return/revert data can fail a batch or an RPC + read. The reader returns all results, so its memory/output costs grow with returned data. +- **`with_length` must be exact.** It reserves space; it does not validate the runtime length. + The original length word is copied. If a callee returns more than declared, the consumer can + revert or read an adjacent argument as part of that value. Fewer bytes may trigger + `InsufficientReturnData`, but padding can also mask a wrong declaration. Only use canonical ABI + return encodings with exact known lengths. The new adversarial test demonstrates adjacent-array + interpretation; there is no safe-truncation guarantee. +- **Value accounting.** Refunds cover unspent incoming `msg.value`, not the contract's entire + balance. ETH can be forced in or pre-funded before deployment and is publicly spendable in + the bare executor. A call can spend an existing balance; insufficient available ETH causes + the call to fail. The delegate deliberately spends its EOA's balance. Amounts returned by + callees are not automatically swept as refunds; scripts must dispose of those assets. +- **Signature malleability** is irrelevant to replay (nonces), but a low-`s` check is not + enforced; do not use the signature bytes as an identifier. +- **The Rust builder** covers scalar chaining and one dynamic value per call. Nested references + and element access are JS-only. + +## Release validation — 2026-09-05 + +This is an internal adversarial review and deployment rehearsal, not an independent audit or a +proof of optimal gas. The write executor remains byte-for-byte unchanged in this follow-up; +all nine previous-code execution gas caps still pass. The reader's custody comment was corrected +(changing its metadata/hash, not execution logic). The delegate now has namespaced nonce storage +and signature domain version 3. + +The release pass additionally fixed CLI input handling: unsafe JSON numbers now fail instead of +being rounded, invalid Rust ETH values fail instead of becoming zero, and ambiguous overloaded +function names are rejected. The swap example requires explicit deadline/slippage bounds. +Deployment now targets one explicit Ethereum/local-fork RPC, needs no raw key for simulation, +checks exact runtime bytecode and is idempotent. It preserves failures and full diagnostics. + +### Adversarial and stateful evidence + +- The full Solidity suite passes **128 tests** with both mainnet swaps enabled (no skips). + JavaScript passes **63 tests** and Rust **21 tests**. Strict Clippy and fixed-seed gas snapshots + pass as well. +- The follow-up invariant campaign uses seeds `0xdecaf` and `0xbadc0de`, 1,024 runs and depth 512 + for each of three properties: **3,145,728 handler actions, zero unexpected reverts**. Expected failing batches + are caught inside handlers, and their lack of state/balance changes is checked explicitly. +- Executor accounting models committed chained arithmetic, call counts, ETH paid, unspent + refunds, callee failures and rejected refunds. The reader's observations must match that model. + The 7702 model separately tracks nonce, replay rejection, authorization and account/relayer ETH. +- The 4,096-run fuzz campaign covers malformed/mutated frames, byte-copy differential behavior, + all four call flags, static restrictions (SSTORE, TSTORE, LOG, CREATE, SELFDESTRUCT, value CALL), + and exact binary revert payloads up to 4 KiB with rollback of earlier writes and ETH transfers. +- Directed attacks cover nested execute/splicing, refund callbacks, signature replay during an + active signed batch, forced ETH, and successful calls returning false. + +The follow-up reproduced and fixed three additional implementation problems: + +- JavaScript unsigned offset fields accepted negative/inexact/coerced values. All encode/decode + boundaries now validate exact unsigned inputs. JS and Rust reject impossible partial counts; + Rust's `decode_partial_return` now returns `Result` instead of panicking on malformed words. +- Generated descriptors from a different builder could silently refer to a local call with the + same index. Both builders now bind generated references to their originating builder. Raw CLI + references remain explicitly supplied byte positions. +- A delegation migration overwriting ordinary slot zero could reset the old nonce and revive a + used signature. A failing-before/passing-after regression demonstrates this case. The new + ERC-7201 namespace avoids ordinary slot collisions; domain version 3 invalidates earlier-domain + signatures instead of reusing them against the new storage location. + +Additional tests cover signature malleability plus replay rejection, cancellation with an empty +signed batch, exact deadline boundaries, rejected-relayer-refund rollback, public token-approval +exploitation, explicit checking of false return values, and the unsafe dynamic-length assumption. +The 7702 invariant handler now interleaves cancellations and other-delegate slot-zero writes. + +`uv run script/check-memory-bounds.py` runs four scoped Z3 bit-vector checks: packed-frame bounds, +regular and partial destination bounds, and partial returndata containment. All produce `unsat` +for a violation, assuming allocated memory addresses below 2^64 (far above feasible EVM memory). +These manually modeled inequalities are **not** formal verification of the whole executor, +compiler, memory-safe annotation, protocol integrations, or signature system. + +In the earlier release pass, six deliberate source mutations were compiled and tested in isolated copies. All were detected: + +| Broken behavior | Detecting regression test | +|---|---| +| Removed packed-frame bound | `test_fuzz_packed_frame_bounds` | +| Removed regular short-return check | `test_short_return_data_reverts_regular` | +| Shifted partial-copy source by one byte | `test_fuzz_chaining_matches_byte_model` | +| Removed ETH value-index check | `test_value_index_out_of_range` | +| Copied one fewer byte in the reader | `test_chain_returns_every_result` | +| Removed nonce increment | `test_reentrant_signature_replay_cannot_reuse_nonce` | + +### Deployment rehearsal + +`script/rehearse.sh` deploys the exact artifacts on an Anvil mainnet fork and verifies runtime +bytecode against local compilation. It sends real local transactions through WETH, Uniswap V2 +and Curve 3pool, checks residual balances/allowances and a failed-slippage rollback, compares +read-only results with direct RPC calls, and tests real EIP-7702 delegation, self-execution, +relayed execution and replay rejection. It also rejects deliberately corrupted deployed code. +The Solidity swaps assert actual token output. Their test deployment address already has ETH +on mainnet, so they check that the batch retains no additional ETH rather than assuming the +address began empty. Fork-only deployments below are not announcements of a mainnet deployment. + +Final follow-up rehearsal: Ethereum block **25,915,378**, hash +`0x0e733e5c94981a274a07b5ed069a5fd8b399ca3dd90567d80034ccfba3c9f81c`; local chain 31337, Anvil hardfork `Bpo1`. +The 0.1 ETH multi-protocol swap used **299,535 gas**; authorization/self-call used **88,098 gas**. +The first signed batch used **78,009 gas**, and the same workload after nonce initialization used +**64,329 gas**. The prior slot-zero rehearsal used pre-existing nonzero mainnet storage at the test +EOA, so its first call was not a comparable fresh-nonce measurement. An isolated same-compiler, +same-state empty-batch comparison measured a **5 execution-gas increase** for namespaced nonce +access (26,490 → 26,495 at zero; 6,590 → 6,595 with initialized, warm nonce storage). This is an +intentional delegate hardening cost; write-executor gas did not increase. +These are workload-specific receipts and measurements, not universal gas estimates. + +| Contract | CREATE2 address checked on the fork | +|---|---| +| `MulticallScripter` | `0xe9Ac863E40A25d460299Bc1c514fd798020574cc` | +| `MulticallScripterReadOnly` | `0xdf9e2601030184F463DB25D57be30cE1E035cEF8` | +| `SevenSevenZeroTwoCaller` | `0x1D0E0C2bAd56212c16c2a64dB2A6cF2A7e20c03B` | + +Runtime code hashes for this tested build: + +```text +MulticallScripter: 0xaae6b023e1b63db280bf39a5caf8393a7c5c641f22deea06f3d617ec92ca44d3 +MulticallScripterReadOnly: 0xc69d894479aeddc65682897328b79041d583c41ca6477a52b1b26a89225e6940 +SevenSevenZeroTwoCaller: 0x3b4eb5a03f2bbcdf18d564848190b265dd59d8411f5431e7beaabbcfa60ff665 +``` + +### Compiler review and remaining release work + +Compilation is pinned to Solidity 0.8.28, via-IR, optimizer runs 1,000,000, EVM target Prague. +Foundry 1.7.1, Bun 1.3.14 and Cargo 1.97.1 were used for this review; CI pins Foundry 1.7.1. +The [Solidity known-bug list](https://docs.solidity.org/en/latest/bugs.html) was checked: +`UnsoundSpillInMutualRecursion` needs mutually recursive internal functions; +`TransientStorageClearingHelperCollision` needs clearing both transient and persistent storage; +`LostStorageArrayWriteOnSlotOverflow` needs storage arrays crossing the slot boundary. +The production contracts do not contain those triggering constructs. The storage-layout warning +bug introduced in 0.8.29 does not affect 0.8.28. This source-level screening is not a compiler proof. +SELFDESTRUCT/transient-storage warnings in the adversarial fixtures are intentional attack cases, +not operations in the production executors. + +Before directing substantial funds through a live release, obtain independent review of the +final assembly and deployed build, verify the live chain's bytecode and run the intended production +batches with their actual tokens, slippage limits and callers. The local rehearsal cannot establish +safety for arbitrary third-party contracts, token behavior, future compiler changes or RPC services. diff --git a/docs/NAVIGATION.md b/docs/NAVIGATION.md deleted file mode 100644 index 944031226..000000000 --- a/docs/NAVIGATION.md +++ /dev/null @@ -1,35 +0,0 @@ - -# How to Navigate and Maintain These Docs - -## The hierarchy - -- **CLAUDE.md** — always loaded; project purpose, commands, navigation table; imports AGENTS.md -- **docs/OVERVIEW.md** — architecture, components, data flow; load on demand -- **docs/{component}.md** — deep dives per component; load on demand -- **.claude/rules/** — domain conventions; auto-loaded when editing matched file types -- **.claude/context-map.md** — lookup index: "to find X, read Y" -- **Auto memory (MEMORY.md)** — Claude-managed session notes; not generated by this tool - -## Subagent note - -Subagents load their own copy of CLAUDE.md and path-scoped rules. Keep component -docs self-contained so they work in a subagent's fresh context. - -## When to update - -- New Solidity contract → update `docs/contracts.md`, check `.claude/rules/solidity.md` -- New JS API or encoding helper → update `docs/javascript.md`, check `.claude/rules/javascript.md` -- New call type flag added → update both Solidity and JS, add roundtrip test, update contracts.md -- Large drift from reality → run: `/agentify update` - -## Token budgets - -| File | Limit | -|------|-------| -| CLAUDE.md | 200 lines | -| .claude/rules/*.md | 100 lines each | -| docs/OVERVIEW.md | 400 lines | -| docs/{component}.md | 300 lines each | -| .claude/context-map.md | 150 lines | - -Overflow always moves **down** the hierarchy, never up. diff --git a/docs/OVERVIEW.md b/docs/OVERVIEW.md deleted file mode 100644 index 3891ca551..000000000 --- a/docs/OVERVIEW.md +++ /dev/null @@ -1,96 +0,0 @@ - -# Multicall Scripting — Architecture Overview - -Execute complex multi-contract strategies in a single atomic transaction with -full return value chaining between calls. Regular multicall solutions (like -Multicall3) only batch independent calls — they cannot use one call's return -value as an argument to the next. Multicall Scripting solves this with -precalculated memory offsets and the `mcopy` opcode, enabling strategies like -"read a user's balance from Aave, then borrow DAI against it" in one atomic step. - -The system has two layers that must stay in sync: -1. **Solidity** (`src/`) — on-chain execution engine that decodes offset metadata - and copies return data between calls using Yul assembly -2. **JavaScript** (`js/`) — offchain transaction builder that simulates the EVM - memory layout, tracks data dependencies between calls, and encodes compact - 256-bit offset words - ---- - -## Architecture Diagram - -```mermaid -graph TD - User[User / Dapp] -->|builds transaction| JS[JS TransactionBuilder] - JS -->|addCall chain| DESC[Descriptor Proxies] - DESC -->|tracks deps| MEM[Virtual Memory Layout] - MEM -->|encode offsets| BUILD[build()] - BUILD -->|targets, offsets, calldatas, msgValues| TX[Transaction] - TX -->|submits to| MC[MulticallScripter.execute] - MC -->|dispatches by calltype| DISP[Assembly Dispatch] - DISP -->|0xFF| SC[staticcall] - DISP -->|0xFE| CC[call] - DISP -->|0xFC/0xFB| PR[Partial Return] - SC -->|mcopy return data| CD[In-Memory Calldata] - CD -->|next call finds| CC2[Subsequent Call] - PR -->|mcopy per variable| CD -``` - -## Components - -| Component | Responsibility | Key Files | -|-----------|---------------|-----------| -| MulticallScripter | On-chain execution: copies calldata to memory, executes calls in order, uses mcopy to wire return values | `src/MulticallScripter.sol` | -| CallBuilder | Solidity DSL for building call chains in tests; encodes offset words with bit-packing | `src/CallBuilder.sol` | -| 7702Caller | EIP-7702 account abstraction compatibility wrapper | `src/7702Caller.sol` | -| TransactionBuilder | JavaScript class for building call batches; tracks memory layout, creates descriptor proxies | `js/index.js` | -| CLI | Command-line interface for transaction building via JSON input | `js/cli.js` | -| Helpers (test) | Mock contracts for testing: SimpleReturn, DynamicReturn, Structs, Fuzzy, etc. | `test/Helpers.sol` | -| JS Examples | Runnable strategy scripts: Uniswap reserves, Aave deposits, multi-swaps | `js/examples/` | - -## Data Flow - -```mermaid -sequenceDiagram - participant Dapp as Dapp / Script - participant JS as TransactionBuilder - participant Mem as Virtual Memory - participant SC as MulticallScripter - - Dapp->>JS: addCall(abi, target, "balanceOf", [user]) - JS-->>Dapp: descriptor {callIndex:0, offset:0, size:32} - Dapp->>JS: addCall(abi, target, "transfer", [recipient, descriptor]) - JS->>Mem: reserve 32 bytes at calldata position 0x24 - JS->>Mem: link descriptor to source call 0 at offset 0x00 - JS-->>Dapp: callIndex: 1 - Dapp->>JS: build() - JS-->>Dapp: {targets:[], offsets:[0xFF...], calldatas:[], msgValues:[]} - Dapp->>SC: execute(targets, offsets, calldatas, msgValues) - SC->>SC: copy all calldata to memory - SC->>SC: staticcall(target0, calldata0) → return data - SC->>SC: mcopy return data into calldata1 at offset 0x24 - SC->>SC: call(target1, calldata1) — now has the real balance - SC-->>Dapp: tx receipt -``` - -## External Dependencies - -| Dependency | Purpose | Version | -|-----------|---------|---------| -| Foundry | Solidity development framework (forge, cast, anvil) | latest stable | -| Bun | JavaScript runtime for tests, examples, CLI | latest stable | -| viem | ABI encoding (`encodeFunctionData`, `getAbiItem`) | ^2.32.1 | -| forge-std | Foundry standard test library | (submodule) | -| solady | Gas-optimized Solidity utilities | (submodule) | -| weiroll-huff | Weiroll implementation (gas comparison baseline) | (submodule) | - -## Read More - -- Solidity contracts deep dive → [docs/contracts.md](contracts.md) -- JavaScript library deep dive → [docs/javascript.md](javascript.md) -- Rust library deep dive → [docs/rust.md](rust.md) -- Test patterns and suites → [docs/testing.md](testing.md) -- Example strategy scripts → [docs/examples.md](examples.md) -- Solidity conventions → [.claude/rules/solidity.md](../.claude/rules/solidity.md) -- JavaScript conventions → [.claude/rules/javascript.md](../.claude/rules/javascript.md) -- Testing conventions → [.claude/rules/testing.md](../.claude/rules/testing.md) diff --git a/docs/contracts.md b/docs/contracts.md deleted file mode 100644 index 5a7d1ea51..000000000 --- a/docs/contracts.md +++ /dev/null @@ -1,76 +0,0 @@ - -# Solidity Contracts - -The on-chain execution layer. Three contracts handle call dispatch, offset -decoding, and account abstraction. The core contract (`MulticallScripter`) is -written entirely in Yul assembly for gas efficiency and precise memory control. -A companion DSL (`CallBuilder`) provides a Solidity-native way to build test -call chains, and `7702Caller` adds EIP-7702 compatibility. - -## Key Files - -| File | Lines | Purpose | -|------|-------|---------| -| `src/MulticallScripter.sol` | 236 | Core executor: `execute()` runs chained calls with mcopy-based return data wiring | -| `src/CallBuilder.sol` | 294 | Offset encoding helpers + `Scripter` DSL for building call chains in tests | -| `src/7702Caller.sol` | — | EIP-7702 account abstraction wrapper | - -## Internal Architecture - -```mermaid -graph LR - CB[CallBuilder] -->|inherits| K[Constants] - MSC[MulticallScripter] -->|inherits| K - SCR[Scripter] -->|inherits| CB - SCR -->|calls| MSC - T7702[7702Caller] -->|delegates| MSC -``` - -## Important Patterns - -### Offset encoding is hand-rolled bit packing -The canonical definition lives in `schema/offset-schema.json` — the Solidity constants -in `src/MulticallScripter.sol` must match it exactly. - -There are 5 call type flags, each with a unique bit layout packed into one `uint256`: - -| Flag | Type | Layout | -|------|------|--------| -| `0xFF` | Static call | `[8:flag][120:memTarget][120:resultLength]` | -| `0xFE` | State-changing call | `[8:flag][8:valueIndex][120:memTarget][120:resultLength]` | -| `0xFC` | Static partial return | `[8:flag][120:memTargets(40×3)][48:resultLengths(16×3)][48:returnOffsets(16×3)][16:returnDataSize][8:num_vars]` | -| `0xFB` | Call partial return | Same as 0xFC but includes valueIndex and uses `call` instead of `staticcall` | -| `0xFD` | Delegate call | Reserved, currently reverts with `InvalidCalltype` | - -### Assembly dispatch in execute() -The `execute()` function (lines 45-233 of `MulticallScripter.sol`) is pure Yul assembly: -1. Copies all calldata entries contiguously into memory -2. Advances free memory pointer past calldata region -3. Loops through all calls, extracting `calltype` via `shr(VALUE_OFFSET, offset)` -4. Dispatches to the matching handler based on flag -5. After each static/partial-return call, uses `mcopy` to copy return data into target calldata positions - -### Value indexing is 1-based -In state-changing calls, `valueIndex=0` means "no msg.value". `valueIndex=1` references `values[0]`. This is converted to byte offsets with `shl(5, value)` (×32 bytes per word). - -### Custom error selectors in assembly -```solidity -mstore(0, 0x8f61746f) // InvalidCalltype -revert(0x1c, 0x04) // revert with 4 bytes starting at position 28 -``` -The 4-byte selector is written to the rightmost bytes of the first memory word, then reverted with a small slice. - -## Gotchas - -- **Bit alignment drift**: Every change to offset bit packing must be mirrored exactly in `js/index.js`. The two layers share no schema. Roundtrip tests in `test/JsLibrary.t.sol` and `test/MulticallScripter.t.sol` catch mismatches. -- **mcopy requirement**: The contract uses `mcopy` (EIP-5656, Cancun+). Running on pre-Cancun EVMs will fail at the opcode level. -- **Stateless contract**: `receive()` rejects all ETH. The contract must never hold balances — it's a pure execution engine. -- **Partial return bounds**: `memTarget + resultLength` must not overflow into the pre-allocated calldata region. Violations revert with `InvalidMemoryTarget()`. -- **Delegate call unimplemented**: `DELEGATE_CALL_FLAG (0xFD)` reverts. Adding it requires handling `delegatecall`'s context (storage, msg.sender, msg.value are inherited from caller). - -## See Also - -- How the JS layer wires calls → [docs/javascript.md](javascript.md) -- Test patterns for contracts → [docs/testing.md](testing.md) -- Solidity coding conventions → [.claude/rules/solidity.md](../.claude/rules/solidity.md) -- Architecture overview → [docs/OVERVIEW.md](OVERVIEW.md) diff --git a/docs/examples.md b/docs/examples.md deleted file mode 100644 index e0f50db99..000000000 --- a/docs/examples.md +++ /dev/null @@ -1,60 +0,0 @@ - -# Example Strategies - -Working JavaScript scripts demonstrating common usage patterns. Each example -shows a specific strategy built with the TransactionBuilder. Run them with Bun: - -```bash -cd js/examples && bun run