Skip to content

Harden return chaining and add a read-only executor - #3

Draft
0xdewy wants to merge 1 commit into
masterfrom
release/multicall-hardening
Draft

0xdewy wants to merge 1 commit into
masterfrom
release/multicall-hardening

Conversation

@0xdewy

@0xdewy 0xdewy commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Return-value chaining could splice the wrong calldata, accept insufficient return data, and leave funds in a permissionless executor; the previous EIP-7702 authorization path did not securely bind the executed batch. This PR hardens execution and both builders, adds a read-only executor, and prepares a tested release candidate while keeping all nine write-execution benchmarks at or below the previous-code baseline.

What changed

  • Execute packed, signed calldata frames with frame-count, memory-target, return-data, and ETH-value-index checks. Refund unspent incoming ETH and propagate callee reverts atomically.
  • Add a read-only executor that chains static calls and returns every result.
  • Derive builder positions from actual ABI layouts, fix references across intervening calls, support result reuse within the three-slice limit, and reject unsupported layouts, foreign-builder references, unsafe numbers, and malformed offsets. Rust decoding returns an error instead of panicking on invalid partial counts.
  • Replace the optional EIP-7702 delegate with account self-calls or EIP-712 signed batches bound to the account, chain, inputs, nonce, and deadline. Store the nonce in an ERC-7201 namespace to avoid ordinary-slot collisions during delegation changes.
  • Add deterministic, idempotent deployment with exact runtime-bytecode checks and a mainnet-fork rehearsal covering real protocol transactions and delegation lifecycle behavior.
  • Remove the unimplemented delegatecall flag, unused dependency, obsolete examples, and duplicate documentation and research artifacts. Consolidate usage, trust assumptions, and release evidence in the README and security guide.

Breaking changes and rollout

  • execute(address[],uint256[],bytes[],uint256[]) becomes execute(address[],uint256[],bytes,uint256[]). The byte buffer contains [length][calldata padded to 32] frames. Regenerate saved batches; the JavaScript package moves to version 2.
  • The EIP-7702 signature domain is version 3. Regenerate signatures and use the new implementation addresses and hashes recorded in SECURITY.md. Removing delegation preserves storage and does not cancel unused signatures after restoration.
  • Rust callers must handle Result from decode_partial_return.
  • The public writer must not retain assets or receive token approvals. Callees remain trusted input, boolean failure results need explicit checks, and dynamic lengths must be exact. The reader has a different caller context and is not a state-changing transaction simulator.
  • Keep the delegate optional (DEPLOY_7702=false by default). Independent review is still required before treating it as production wallet infrastructure. Mainnet deployment, package publication, and an independent audit are outside this PR.

Verification

  • Full Solidity suite: 128 tests in the release campaign; the latest local run passed all non-fork tests, including JS/Rust FFI and both invariant suites. The fork rehearsal separately passed both fork-only swaps.
  • JavaScript: 63 passed. Rust: 21 passed, with strict Clippy passing in the release campaign.
  • Two invariant campaigns, each with 1,024 runs and depth 512 across three properties: 3,145,728 handler actions, zero unexpected reverts.
  • Extended fuzz tests: 4,096 cases per property covering frames, byte-copy behavior, static restrictions, signature binding, and exact revert propagation.
  • Four scoped Z3 bit-vector checks found no counterexample to the modeled memory and returndata bounds under their stated assumptions. These are not whole-contract formal verification.
  • Mainnet-fork rehearsal at block 25,915,378: exact deployments, repeat-deployment checks, Uniswap and Curve reads and swaps, slippage rollback, actual EIP-7702 authorization, replay rejection, removal and restoration, signature cancellation, and Solidity fork tests all passed.
  • All nine historical write-execution gas caps and the fixed-seed snapshot check pass. The multi-protocol swap used 299,535 gas. Namespaced nonce access costs 5 additional execution gas in an identical-state delegate comparison; first-use storage initialization is reported separately from subsequent calls.
  • The proposed tree and patch were scanned for credentials and local/private paths. Local deployment artifacts, private environment files, and nested submodule working changes are excluded.

Reproduce with:

forge test
(cd js && bun test)
(cd rust && cargo test && cargo clippy --all-targets -- -D warnings)
FOUNDRY_INVARIANT_RUNS=1024 FOUNDRY_INVARIANT_DEPTH=512 forge test --match-contract Invariant --fuzz-seed 0xdecaf
forge snapshot --fuzz-seed 0x51c7 --check --tolerance 5 --no-match-contract 'JsLibrary|RustLibrary|Invariant' --no-match-test 'test_mint_weth_and_swap'
uv run script/check-memory-bounds.py
bash script/rehearse.sh

Plain forge test skips the two fork swaps without ETH_RPC_URL; the rehearsal runs them. No live-mainnet transaction or independent audit was performed. This is not a claim of unconditional safety or globally optimal gas.

Reviewer notes

Start with the executor bounds and copy logic, delegate authorization and nonce storage, then builder position calculations and adversarial tests. Schema mirrors, golden vectors, and gas snapshots support those changes. Most deleted lines are obsolete documentation, research outputs, and superseded examples rather than production code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant