From d7dbee136ac89b094eaeb0fcb4f00ed244207856 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Wed, 3 Sep 2025 18:42:24 -0400 Subject: [PATCH 1/8] feat: add callSansTo support to ERC7821 execute --- src/GuardedExecutor.sol | 2 +- src/IthacaAccount.sol | 71 ++++++- src/libraries/ERC7821Ithaca.sol | 331 ++++++++++++++++++++++++++++++++ test/Account.t.sol | 117 +++++++++++ test/Base.t.sol | 6 +- 5 files changed, 523 insertions(+), 4 deletions(-) create mode 100644 src/libraries/ERC7821Ithaca.sol diff --git a/src/GuardedExecutor.sol b/src/GuardedExecutor.sol index cf5eb502..1f484569 100644 --- a/src/GuardedExecutor.sol +++ b/src/GuardedExecutor.sol @@ -1,7 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; -import {ERC7821} from "solady/accounts/ERC7821.sol"; import {LibSort} from "solady/utils/LibSort.sol"; import {LibBytes} from "solady/utils/LibBytes.sol"; import {LibZip} from "solady/utils/LibZip.sol"; @@ -13,6 +12,7 @@ import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol"; import {FixedPointMathLib as Math} from "solady/utils/FixedPointMathLib.sol"; import {DateTimeLib} from "solady/utils/DateTimeLib.sol"; import {ICallChecker} from "./interfaces/ICallChecker.sol"; +import {ERC7821Ithaca as ERC7821} from "./libraries/ERC7821Ithaca.sol"; /// @title GuardedExecutor /// @notice Mixin for spend limits and calldata execution guards. diff --git a/src/IthacaAccount.sol b/src/IthacaAccount.sol index 0f0068ac..765998c0 100644 --- a/src/IthacaAccount.sol +++ b/src/IthacaAccount.sol @@ -485,6 +485,32 @@ contract IthacaAccount is IIthacaAccount, EIP712, GuardedExecutor { return isMultichain ? _hashTypedDataSansChainId(structHash) : _hashTypedData(structHash); } + function computeDigest(CallSansTo[] calldata calls, address to, uint256 nonce) + public + view + virtual + returns (bytes32 result) + { + bytes32[] memory a = EfficientHashLib.malloc(calls.length); + for (uint256 i; i < calls.length; ++i) { + (uint256 value, bytes calldata data) = _get(calls, i); + a.set( + i, + EfficientHashLib.hash( + CALL_TYPEHASH, + bytes32(uint256(uint160(to))), + bytes32(value), + EfficientHashLib.hashCalldata(data) + ) + ); + } + bool isMultichain = nonce >> 240 == MULTICHAIN_NONCE_PREFIX; + bytes32 structHash = EfficientHashLib.hash( + uint256(EXECUTE_TYPEHASH), LibBit.toUint(isMultichain), uint256(a.hash()), nonce + ); + return isMultichain ? _hashTypedDataSansChainId(structHash) : _hashTypedData(structHash); + } + /// @dev Returns if the signature is valid, along with its `keyHash`. /// The `signature` is a wrapped signature, given by /// `abi.encodePacked(bytes(innerSignature), bytes32(keyHash), bool(prehash))`. @@ -669,6 +695,49 @@ contract IthacaAccount is IIthacaAccount, EIP712, GuardedExecutor { // ERC7821 //////////////////////////////////////////////////////////////////////// + function _execute( + bytes32, + bytes calldata, + address to, + CallSansTo[] calldata calls, + bytes calldata opData + ) internal virtual override { + // Orchestrator workflow. + if (msg.sender == ORCHESTRATOR) { + // opdata + // 0x00: keyHash + if (opData.length != 0x20) revert OpDataError(); + bytes32 _keyHash = LibBytes.loadCalldata(opData, 0x00); + + LibTStack.TStack(_KEYHASH_STACK_TRANSIENT_SLOT).push(_keyHash); + _execute(calls, to, _keyHash); + LibTStack.TStack(_KEYHASH_STACK_TRANSIENT_SLOT).pop(); + + return; + } + + // Simple workflow without `opData`. + if (opData.length == uint256(0)) { + if (msg.sender != address(this)) revert Unauthorized(); + return _execute(calls, to, bytes32(0)); + } + + // Simple workflow with `opData`. + if (opData.length < 0x20) revert OpDataError(); + uint256 nonce = uint256(LibBytes.loadCalldata(opData, 0x00)); + LibNonce.checkAndIncrement(_getAccountStorage().nonceSeqs, nonce); + emit NonceInvalidated(nonce); + + (bool isValid, bytes32 keyHash) = unwrapAndValidateSignature( + computeDigest(calls, to, nonce), LibBytes.sliceCalldata(opData, 0x20) + ); + + if (!isValid) revert Unauthorized(); + LibTStack.TStack(_KEYHASH_STACK_TRANSIENT_SLOT).push(keyHash); + _execute(calls, to, keyHash); + LibTStack.TStack(_KEYHASH_STACK_TRANSIENT_SLOT).pop(); + } + /// @dev For ERC7821. function _execute(bytes32, bytes calldata, Call[] calldata calls, bytes calldata opData) internal @@ -705,8 +774,6 @@ contract IthacaAccount is IIthacaAccount, EIP712, GuardedExecutor { computeDigest(calls, nonce), LibBytes.sliceCalldata(opData, 0x20) ); if (!isValid) revert Unauthorized(); - - // TODO: Figure out where else to add these operations, after removing delegate call. LibTStack.TStack(_KEYHASH_STACK_TRANSIENT_SLOT).push(keyHash); _execute(calls, keyHash); LibTStack.TStack(_KEYHASH_STACK_TRANSIENT_SLOT).pop(); diff --git a/src/libraries/ERC7821Ithaca.sol b/src/libraries/ERC7821Ithaca.sol new file mode 100644 index 00000000..120d82e8 --- /dev/null +++ b/src/libraries/ERC7821Ithaca.sol @@ -0,0 +1,331 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + +import {Receiver} from "solady/accounts/Receiver.sol"; + +/// @notice Minimal batch executor mixin. +/// @author Solady (https://github.com/vectorized/solady/blob/main/src/accounts/ERC7821.sol) +/// +/// @dev This contract can be inherited to create fully-fledged smart accounts. +/// If you merely want to combine approve-swap transactions into a single transaction +/// using [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702), you will need to implement basic +/// [ERC-1271](https://eips.ethereum.org/EIPS/eip-1271) `isValidSignature` functionality to +/// validate signatures with `ecrecover` against the EOA address. This is necessary because some +/// signature checks skip `ecrecover` if the signer has code. For a basic EOA batch executor, +/// please refer to [BEBE](https://github.com/vectorized/bebe), which inherits from this class. +contract ERC7821Ithaca is Receiver { + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* STRUCTS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /// @dev Call struct for the `execute` function. + struct Call { + address to; // Replaced as `address(this)` if `address(0)`. Renamed to `to` for Ithaca Porto. + uint256 value; // Amount of native currency (i.e. Ether) to send. + bytes data; // Calldata to send with the call. + } + + struct CallSansTo { + uint256 value; // Amount of native currency (i.e. Ether) to send. + bytes data; // Calldata to send with the call. + } + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* CUSTOM ERRORS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /// @dev The execution mode is not supported. + error UnsupportedExecutionMode(); + + /// @dev Cannot decode `executionData` as a batch of batches `abi.encode(bytes[])`. + error BatchOfBatchesDecodingError(); + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* EXECUTION OPERATIONS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /// @dev Executes the calls in `executionData`. + /// Reverts and bubbles up error if any call fails. + /// + /// `executionData` encoding (single batch): + /// - If `opData` is empty, `executionData` is simply `abi.encode(calls)`. + /// - Else, `executionData` is `abi.encode(calls, opData)`. + /// See: https://eips.ethereum.org/EIPS/eip-7579 + /// + /// `executionData` encoding (batch of batches): + /// - `executionData` is `abi.encode(bytes[])`, where each element in `bytes[]` + /// is an `executionData` for a single batch. + /// + /// Supported modes: + /// - `0x01000000000000000000...`: Single batch. Does not support optional `opData`. + /// - `0x01000000000078210001...`: Single batch. Supports optional `opData`. + /// - `0x01000000000078210002...`: Batch of batches. + /// + /// For the "batch of batches" mode, each batch will be recursively passed into + /// `execute` internally with mode `0x01000000000078210001...`. + /// Useful for passing in batches signed by different signers. + /// + /// Authorization checks: + /// - If `opData` is empty, the implementation SHOULD require that + /// `msg.sender == address(this)`. + /// - If `opData` is not empty, the implementation SHOULD use the signature + /// encoded in `opData` to determine if the caller can perform the execution. + /// - If `msg.sender` is an authorized entry point, then `execute` MAY accept + /// calls from the entry point, and MAY use `opData` for specialized logic. + /// + /// `opData` may be used to store additional data for authentication, + /// paymaster data, gas limits, etc. + function execute(bytes32 mode, bytes calldata executionData) public payable virtual { + uint256 id = _executionModeId(mode); + if (id == 3) return _executeBatchOfBatches(mode, executionData); + if (id == 4) return _executeBatchCommonTo(mode, executionData); + Call[] calldata calls; + bytes calldata opData; + + // + /// @solidity memory-safe-assembly + assembly { + if iszero(id) { + mstore(0x00, 0x7f181275) // `UnsupportedExecutionMode()`. + revert(0x1c, 0x04) + } + // Use inline assembly to extract the calls and optional `opData` efficiently. + opData.length := 0 + let o := add(executionData.offset, calldataload(executionData.offset)) + calls.offset := add(o, 0x20) + calls.length := calldataload(o) + // If the offset of `executionData` allows for `opData`, and the mode supports it. + if gt(eq(id, 2), gt(0x40, calldataload(executionData.offset))) { + let q := add(executionData.offset, calldataload(add(0x20, executionData.offset))) + opData.offset := add(q, 0x20) + opData.length := calldataload(q) + } + // Bounds checking for `executionData` is skipped here for efficiency. + // This is safe if it is only used as an argument to `execute` externally. + // If `executionData` used as an argument to other functions externally, + // please perform the bounds checks via `LibERC7579.decodeBatchAndOpData` + /// or `abi.decode` in the other functions for safety. + } + _execute(mode, executionData, calls, opData); + } + + /// @dev Provided for execution mode support detection. + function supportsExecutionMode(bytes32 mode) public view virtual returns (bool result) { + return _executionModeId(mode) != 0; + } + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* INTERNAL HELPERS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /// @dev 0: invalid mode, 1: no `opData` support, 2: with `opData` support, 3: batch of batches. + function _executionModeId(bytes32 mode) internal view virtual returns (uint256 id) { + // Only supports atomic batched executions. + // For the encoding scheme, see: https://eips.ethereum.org/EIPS/eip-7579 + // Bytes Layout: + // - [0] ( 1 byte ) `0x01` for batch call. + // - [1] ( 1 byte ) `0x00` for revert on any failure. + // - [2..5] ( 4 bytes) Reserved by ERC7579 for future standardization. + // - [6..9] ( 4 bytes) `0x00000000` or `0x78210001` or `0x78210002`. + // - [10..31] (22 bytes) Unused. Free for use. + /// @solidity memory-safe-assembly + assembly { + let m := and(shr(mul(22, 8), mode), 0xffff00000000ffffffff) + id := eq(m, 0x01000000000000000000) // 1. + id := or(shl(1, eq(m, 0x01000000000078210001)), id) // 2. + id := or(mul(3, eq(m, 0x01000000000078210002)), id) // 3. + id := or(mul(4, eq(m, 0x01000000000078210003)), id) // 4. + } + } + + function _executeBatchCommonTo(bytes32 mode, bytes calldata executionData) internal virtual { + // Execution Data: abi.encode(address to, CallSansTo[] calls, bytes opData) + address to; + CallSansTo[] calldata calls; + bytes calldata opData; + + assembly ("memory-safe") { + to := calldataload(executionData.offset) + + let callOffset := + add(executionData.offset, calldataload(add(0x20, executionData.offset))) + calls.offset := add(callOffset, 0x20) + calls.length := calldataload(callOffset) + + // This line is needed to ensure that opdata is valid in all code paths. + // Otherwise the compiler complains. + opData.length := 0 + // If the offset of `executionData` allows for `opData`, and the mode supports it. + if not(gt(0x60, calldataload(add(0x20, executionData.offset)))) { + let opDataOffset := + add(executionData.offset, calldataload(add(0x40, executionData.offset))) + opData.offset := add(opDataOffset, 0x20) + opData.length := calldataload(opDataOffset) + } + } + + _execute(mode, executionData, to, calls, opData); + } + + /// @dev For execution of a batch of batches. + function _executeBatchOfBatches(bytes32 mode, bytes calldata executionData) internal virtual { + // Replace with `0x0100________78210001...` while preserving optional and reserved fields. + mode ^= bytes32(uint256(3 << (22 * 8))); // `2 XOR 3 = 1`. + (uint256 n, uint256 o, uint256 e) = (0, 0, 0); + /// @solidity memory-safe-assembly + assembly { + let j := calldataload(executionData.offset) + let t := add(executionData.offset, j) + n := calldataload(t) // `batches.length`. + o := add(0x20, t) // Offset of `batches[0]`. + e := add(executionData.offset, executionData.length) // End of `executionData`. + // Do the bounds check on `executionData` treating it as `abi.encode(bytes[])`. + // Not too expensive, so we will just do it right here right now. + if or(shr(64, j), or(lt(executionData.length, 0x20), gt(add(o, shl(5, n)), e))) { + mstore(0x00, 0x3995943b) // `BatchOfBatchesDecodingError()`. + revert(0x1c, 0x04) + } + } + unchecked { + for (uint256 i; i != n; ++i) { + bytes calldata batch; + /// @solidity memory-safe-assembly + assembly { + let j := calldataload(add(o, shl(5, i))) + let t := add(o, j) + batch.offset := add(t, 0x20) + batch.length := calldataload(t) + // Validate that `batches[i]` is not out-of-bounds. + if or(shr(64, j), gt(add(batch.offset, batch.length), e)) { + mstore(0x00, 0x3995943b) // `BatchOfBatchesDecodingError()`. + revert(0x1c, 0x04) + } + } + execute(mode, batch); + } + } + } + + /// @dev Executes the calls. + /// Reverts and bubbles up error if any call fails. + /// The `mode` and `executionData` are passed along in case there's a need to use them. + function _execute( + bytes32 mode, + bytes calldata executionData, + Call[] calldata calls, + bytes calldata opData + ) internal virtual { + // Silence compiler warning on unused variables. + mode = mode; + executionData = executionData; + // Very basic auth to only allow this contract to be called by itself. + // Override this function to perform more complex auth with `opData`. + if (opData.length == uint256(0)) { + require(msg.sender == address(this)); + // Remember to return `_execute(calls, extraData)` when you override this function. + return _execute(calls, bytes32(0)); + } + revert(); // In your override, replace this with logic to operate on `opData`. + } + + function _execute( + bytes32 mode, + bytes calldata executionData, + address to, + CallSansTo[] calldata calls, + bytes calldata opData + ) internal virtual { + // Silence compiler warning on unused variables. + mode = mode; + executionData = executionData; + // Very basic auth to only allow this contract to be called by itself. + // Override this function to perform more complex auth with `opData`. + if (opData.length == uint256(0)) { + require(msg.sender == address(this)); + // Remember to return `_execute(calls, extraData)` when you override this function. + return _execute(calls, to, bytes32(0)); + } + revert(); // In your override, replace this with logic to operate on `opData`. + } + + /// @dev Executes the calls. + /// Reverts and bubbles up error if any call fails. + /// `extraData` can be any supplementary data (e.g. a memory pointer, some hash). + function _execute(Call[] calldata calls, bytes32 extraData) internal virtual { + unchecked { + uint256 i; + if (calls.length == uint256(0)) return; + do { + (address to, uint256 value, bytes calldata data) = _get(calls, i); + _execute(to, value, data, extraData); + } while (++i != calls.length); + } + } + + function _execute(CallSansTo[] calldata calls, address to, bytes32 keyHash) internal virtual { + unchecked { + uint256 i; + if (calls.length == uint256(0)) return; + do { + (uint256 value, bytes calldata data) = _get(calls, i); + _execute(to, value, data, keyHash); + } while (++i != calls.length); + } + } + + /// @dev Executes the call. + /// Reverts and bubbles up error if any call fails. + /// `extraData` can be any supplementary data (e.g. a memory pointer, some hash). + function _execute(address to, uint256 value, bytes calldata data, bytes32 extraData) + internal + virtual + { + /// @solidity memory-safe-assembly + assembly { + extraData := extraData // Silence unused variable compiler warning. + let m := mload(0x40) // Grab the free memory pointer. + calldatacopy(m, data.offset, data.length) + if iszero(call(gas(), to, value, m, data.length, codesize(), 0x00)) { + // Bubble up the revert if the call reverts. + returndatacopy(m, 0x00, returndatasize()) + revert(m, returndatasize()) + } + } + } + + function _get(CallSansTo[] calldata calls, uint256 i) + internal + view + virtual + returns (uint256 value, bytes calldata data) + { + /// @solidity memory-safe-assembly + assembly { + let c := add(calls.offset, calldataload(add(calls.offset, shl(5, i)))) + value := calldataload(c) + let o := add(c, calldataload(add(c, 0x20))) + data.offset := add(o, 0x20) + data.length := calldataload(o) + } + } + + /// @dev Convenience function for getting `calls[i]`, without bounds checks. + function _get(Call[] calldata calls, uint256 i) + internal + view + virtual + returns (address to, uint256 value, bytes calldata data) + { + /// @solidity memory-safe-assembly + assembly { + let c := add(calls.offset, calldataload(add(calls.offset, shl(5, i)))) + // Replaces `to` with `address(this)` if `address(0)` is provided. + let t := shr(96, shl(96, calldataload(c))) + to := or(mul(address(), iszero(t)), t) + value := calldataload(add(c, 0x20)) + let o := add(c, calldataload(add(c, 0x40))) + data.offset := add(o, 0x20) + data.length := calldataload(o) + } + } +} diff --git a/test/Account.t.sol b/test/Account.t.sol index e4b8310b..db31c9cd 100644 --- a/test/Account.t.sol +++ b/test/Account.t.sol @@ -68,6 +68,123 @@ contract AccountTest is BaseTest { } } + struct _TestExecuteWithCallSansToTemps { + TargetFunctionPayload[] targetFunctionPayloads; + ERC7821.CallSansTo[] calls; + address to; + uint256 n; + uint256 nonce; + bytes opData; + bytes executionData; + } + + function testExecuteWithCallSansTo(bytes32) public { + DelegatedEOA memory d = _randomEIP7702DelegatedEOA(); + vm.deal(d.eoa, 100 ether); + + _TestExecuteWithCallSansToTemps memory t; + t.n = _bound(_randomUniform(), 1, 5); + t.targetFunctionPayloads = new TargetFunctionPayload[](t.n); + t.calls = new ERC7821.CallSansTo[](t.n); + t.to = address(this); + + for (uint256 i; i < t.n; ++i) { + uint256 value = _random() % 0.1 ether; + bytes memory data = _truncateBytes(_randomBytes(), 0xff); + t.calls[i] = ERC7821.CallSansTo(value, abi.encodeWithSignature("targetFunction(bytes)", data)); + t.targetFunctionPayloads[i].value = value; + t.targetFunctionPayloads[i].data = data; + } + + t.nonce = d.d.getNonce(0); + bytes memory signature = _sig(d, d.d.computeDigest(t.calls, t.to, t.nonce)); + t.opData = abi.encodePacked(t.nonce, signature); + t.executionData = abi.encode(t.to, t.calls, t.opData); + + // Negative test: wrong signature (32/256 chance) + if (_randomChance(32)) { + bytes memory wrongSignature = _sig(_randomEIP7702DelegatedEOA(), d.d.computeDigest(t.calls, t.to, t.nonce)); + t.opData = abi.encodePacked(t.nonce, wrongSignature); + t.executionData = abi.encode(t.to, t.calls, t.opData); + vm.expectRevert(bytes4(keccak256("Unauthorized()"))); + d.d.execute(_ERC7821_BATCH_SANS_TO_EXECUTION_MODE, t.executionData); + return; + } + + d.d.execute(_ERC7821_BATCH_SANS_TO_EXECUTION_MODE, t.executionData); + + assertEq(targetFunctionPayloads.length, t.n); + for (uint256 i; i < t.n; ++i) { + assertEq(targetFunctionPayloads[i].by, d.eoa); + assertEq(targetFunctionPayloads[i].value, t.targetFunctionPayloads[i].value); + assertEq(targetFunctionPayloads[i].data, t.targetFunctionPayloads[i].data); + } + } + + function testExecuteWithCallSansToWrongNonce() public { + DelegatedEOA memory d = _randomEIP7702DelegatedEOA(); + vm.deal(d.eoa, 100 ether); + + _TestExecuteWithCallSansToTemps memory t; + t.n = 1; + t.targetFunctionPayloads = new TargetFunctionPayload[](t.n); + t.calls = new ERC7821.CallSansTo[](t.n); + t.to = address(this); + + t.calls[0] = ERC7821.CallSansTo(0, abi.encodeWithSignature("targetFunction(bytes)", "test")); + + t.nonce = d.d.getNonce(0); + uint256 wrongNonce = t.nonce + 1; + bytes memory signature = _sig(d, d.d.computeDigest(t.calls, t.to, wrongNonce)); + t.opData = abi.encodePacked(wrongNonce, signature); + t.executionData = abi.encode(t.to, t.calls, t.opData); + + vm.expectRevert(); // Should revert due to invalid nonce + d.d.execute(_ERC7821_BATCH_SANS_TO_EXECUTION_MODE, t.executionData); + } + + function testExecuteWithCallSansToWrongDigest() public { + DelegatedEOA memory d = _randomEIP7702DelegatedEOA(); + vm.deal(d.eoa, 100 ether); + + _TestExecuteWithCallSansToTemps memory t; + t.n = 1; + t.targetFunctionPayloads = new TargetFunctionPayload[](t.n); + t.calls = new ERC7821.CallSansTo[](t.n); + t.to = address(this); + + t.calls[0] = ERC7821.CallSansTo(0, abi.encodeWithSignature("targetFunction(bytes)", "test")); + + t.nonce = d.d.getNonce(0); + // Sign with wrong to address + address wrongTo = address(0x123); + bytes memory signature = _sig(d, d.d.computeDigest(t.calls, wrongTo, t.nonce)); + t.opData = abi.encodePacked(t.nonce, signature); + t.executionData = abi.encode(t.to, t.calls, t.opData); + + vm.expectRevert(bytes4(keccak256("Unauthorized()"))); + d.d.execute(_ERC7821_BATCH_SANS_TO_EXECUTION_MODE, t.executionData); + } + + function testExecuteWithCallSansToInvalidOpData() public { + DelegatedEOA memory d = _randomEIP7702DelegatedEOA(); + vm.deal(d.eoa, 100 ether); + + _TestExecuteWithCallSansToTemps memory t; + t.n = 1; + t.calls = new ERC7821.CallSansTo[](t.n); + t.to = address(this); + + t.calls[0] = ERC7821.CallSansTo(0, abi.encodeWithSignature("targetFunction(bytes)", "test")); + + // Test with opData too short (less than 32 bytes for nonce) + t.opData = hex"1234"; // Only 2 bytes + t.executionData = abi.encode(t.to, t.calls, t.opData); + + vm.expectRevert(bytes4(keccak256("OpDataError()"))); + d.d.execute(_ERC7821_BATCH_SANS_TO_EXECUTION_MODE, t.executionData); + } + function testSignatureCheckerApproval(bytes32) public { DelegatedEOA memory d = _randomEIP7702DelegatedEOA(); PassKey memory k = _randomSecp256k1PassKey(); diff --git a/test/Base.t.sol b/test/Base.t.sol index 1765a651..4e353a9a 100644 --- a/test/Base.t.sol +++ b/test/Base.t.sol @@ -4,7 +4,6 @@ pragma solidity ^0.8.4; import "./utils/SoladyTest.sol"; import {EIP7702Proxy} from "solady/accounts/EIP7702Proxy.sol"; import {LibEIP7702} from "solady/accounts/LibEIP7702.sol"; -import {ERC7821} from "solady/accounts/ERC7821.sol"; import {LibERC7579} from "solady/accounts/LibERC7579.sol"; import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol"; import {EfficientHashLib} from "solady/utils/EfficientHashLib.sol"; @@ -24,6 +23,8 @@ import {IOrchestrator} from "../src/interfaces/IOrchestrator.sol"; import {Simulator} from "../src/Simulator.sol"; import {ICommon} from "../src/interfaces/ICommon.sol"; +import {ERC7821Ithaca as ERC7821} from "../src/libraries/ERC7821Ithaca.sol"; + contract BaseTest is SoladyTest { using LibRLP for LibRLP.List; @@ -55,6 +56,9 @@ contract BaseTest is SoladyTest { bytes32 internal constant _ERC7821_BATCH_EXECUTION_MODE = 0x0100000000007821000100000000000000000000000000000000000000000000; + bytes32 internal constant _ERC7821_BATCH_SANS_TO_EXECUTION_MODE = + 0x0100000000007821000300000000000000000000000000000000000000000000; + bytes32 internal constant _ERC7579_DELEGATE_CALL_MODE = 0xff00000000000000000000000000000000000000000000000000000000000000; From 444e99223ccfd06ffccd4b704dfd801924292ccb Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Wed, 3 Sep 2025 19:49:10 -0400 Subject: [PATCH 2/8] chore: improvements and fixes to ERC7821Ithaca --- src/libraries/ERC7821Ithaca.sol | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/libraries/ERC7821Ithaca.sol b/src/libraries/ERC7821Ithaca.sol index 120d82e8..041c71bf 100644 --- a/src/libraries/ERC7821Ithaca.sol +++ b/src/libraries/ERC7821Ithaca.sol @@ -60,6 +60,7 @@ contract ERC7821Ithaca is Receiver { /// - `0x01000000000000000000...`: Single batch. Does not support optional `opData`. /// - `0x01000000000078210001...`: Single batch. Supports optional `opData`. /// - `0x01000000000078210002...`: Batch of batches. + /// - `0x01000000000078210003...`: Single batch with common `to` address and optional `opData`. /// /// For the "batch of batches" mode, each batch will be recursively passed into /// `execute` internally with mode `0x01000000000078210001...`. @@ -82,7 +83,6 @@ contract ERC7821Ithaca is Receiver { Call[] calldata calls; bytes calldata opData; - // /// @solidity memory-safe-assembly assembly { if iszero(id) { @@ -138,13 +138,16 @@ contract ERC7821Ithaca is Receiver { } } + /// @dev For execution of a batch of batches with a common `to` address. + /// @dev if to == address(0), it will be replaced with address(this) + /// Execution Data: abi.encode(address to, CallSansTo[] calls, bytes opData) function _executeBatchCommonTo(bytes32 mode, bytes calldata executionData) internal virtual { - // Execution Data: abi.encode(address to, CallSansTo[] calls, bytes opData) address to; CallSansTo[] calldata calls; bytes calldata opData; - assembly ("memory-safe") { + /// @solidity memory-safe-assembly + assembly { to := calldataload(executionData.offset) let callOffset := @@ -156,7 +159,7 @@ contract ERC7821Ithaca is Receiver { // Otherwise the compiler complains. opData.length := 0 // If the offset of `executionData` allows for `opData`, and the mode supports it. - if not(gt(0x60, calldataload(add(0x20, executionData.offset)))) { + if gt(calldataload(add(0x20, executionData.offset)), 0x40) { let opDataOffset := add(executionData.offset, calldataload(add(0x40, executionData.offset))) opData.offset := add(opDataOffset, 0x20) @@ -228,6 +231,9 @@ contract ERC7821Ithaca is Receiver { revert(); // In your override, replace this with logic to operate on `opData`. } + /// @dev Executes the calls. + /// Reverts and bubbles up error if any call fails. + /// The `mode` and `executionData` are passed along in case there's a need to use them. function _execute( bytes32 mode, bytes calldata executionData, @@ -262,9 +268,15 @@ contract ERC7821Ithaca is Receiver { } } + /// @dev Executes the calls. + /// Reverts and bubbles up error if any call fails. + /// `extraData` can be any supplementary data (e.g. a memory pointer, some hash). function _execute(CallSansTo[] calldata calls, address to, bytes32 keyHash) internal virtual { unchecked { uint256 i; + if (to == address(0)) { + to = address(this); + } if (calls.length == uint256(0)) return; do { (uint256 value, bytes calldata data) = _get(calls, i); @@ -293,6 +305,7 @@ contract ERC7821Ithaca is Receiver { } } + /// @dev Convenience function for getting `calls[i]`, without bounds checks. function _get(CallSansTo[] calldata calls, uint256 i) internal view From c39286e0c0ea975d7426e272c5f6aeba4b8ff37c Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Wed, 3 Sep 2025 20:07:16 -0400 Subject: [PATCH 3/8] chore: add address(0) replacement to new compute digest --- src/IthacaAccount.sol | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/IthacaAccount.sol b/src/IthacaAccount.sol index 765998c0..211ff3d5 100644 --- a/src/IthacaAccount.sol +++ b/src/IthacaAccount.sol @@ -491,6 +491,12 @@ contract IthacaAccount is IIthacaAccount, EIP712, GuardedExecutor { virtual returns (bytes32 result) { + // If to is 0, it will be replaced with address(this) + assembly ("memory-safe") { + let t := shr(96, shl(96, to)) + to := or(mul(address(), iszero(t)), t) + } + bytes32[] memory a = EfficientHashLib.malloc(calls.length); for (uint256 i; i < calls.length; ++i) { (uint256 value, bytes calldata data) = _get(calls, i); From cb5b88a7befedf37182c3b41a7957a6ff89d28a9 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Wed, 3 Sep 2025 20:07:36 -0400 Subject: [PATCH 4/8] fix: sanitize upper bits before checking replacement + tests --- src/libraries/ERC7821Ithaca.sol | 7 +++-- test/Account.t.sol | 47 ++++++++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/libraries/ERC7821Ithaca.sol b/src/libraries/ERC7821Ithaca.sol index 041c71bf..5f726b42 100644 --- a/src/libraries/ERC7821Ithaca.sol +++ b/src/libraries/ERC7821Ithaca.sol @@ -274,8 +274,11 @@ contract ERC7821Ithaca is Receiver { function _execute(CallSansTo[] calldata calls, address to, bytes32 keyHash) internal virtual { unchecked { uint256 i; - if (to == address(0)) { - to = address(this); + // If `to` is address(0), it will be replaced with address(this) + /// @solidity memory-safe-assembly + assembly { + let t := shr(96, shl(96, to)) + to := or(mul(address(), iszero(t)), t) } if (calls.length == uint256(0)) return; do { diff --git a/test/Account.t.sol b/test/Account.t.sol index db31c9cd..69c81c99 100644 --- a/test/Account.t.sol +++ b/test/Account.t.sol @@ -87,11 +87,12 @@ contract AccountTest is BaseTest { t.targetFunctionPayloads = new TargetFunctionPayload[](t.n); t.calls = new ERC7821.CallSansTo[](t.n); t.to = address(this); - + for (uint256 i; i < t.n; ++i) { uint256 value = _random() % 0.1 ether; bytes memory data = _truncateBytes(_randomBytes(), 0xff); - t.calls[i] = ERC7821.CallSansTo(value, abi.encodeWithSignature("targetFunction(bytes)", data)); + t.calls[i] = + ERC7821.CallSansTo(value, abi.encodeWithSignature("targetFunction(bytes)", data)); t.targetFunctionPayloads[i].value = value; t.targetFunctionPayloads[i].data = data; } @@ -103,7 +104,8 @@ contract AccountTest is BaseTest { // Negative test: wrong signature (32/256 chance) if (_randomChance(32)) { - bytes memory wrongSignature = _sig(_randomEIP7702DelegatedEOA(), d.d.computeDigest(t.calls, t.to, t.nonce)); + bytes memory wrongSignature = + _sig(_randomEIP7702DelegatedEOA(), d.d.computeDigest(t.calls, t.to, t.nonce)); t.opData = abi.encodePacked(t.nonce, wrongSignature); t.executionData = abi.encode(t.to, t.calls, t.opData); vm.expectRevert(bytes4(keccak256("Unauthorized()"))); @@ -130,7 +132,7 @@ contract AccountTest is BaseTest { t.targetFunctionPayloads = new TargetFunctionPayload[](t.n); t.calls = new ERC7821.CallSansTo[](t.n); t.to = address(this); - + t.calls[0] = ERC7821.CallSansTo(0, abi.encodeWithSignature("targetFunction(bytes)", "test")); t.nonce = d.d.getNonce(0); @@ -152,7 +154,7 @@ contract AccountTest is BaseTest { t.targetFunctionPayloads = new TargetFunctionPayload[](t.n); t.calls = new ERC7821.CallSansTo[](t.n); t.to = address(this); - + t.calls[0] = ERC7821.CallSansTo(0, abi.encodeWithSignature("targetFunction(bytes)", "test")); t.nonce = d.d.getNonce(0); @@ -174,7 +176,7 @@ contract AccountTest is BaseTest { t.n = 1; t.calls = new ERC7821.CallSansTo[](t.n); t.to = address(this); - + t.calls[0] = ERC7821.CallSansTo(0, abi.encodeWithSignature("targetFunction(bytes)", "test")); // Test with opData too short (less than 32 bytes for nonce) @@ -471,4 +473,37 @@ contract AccountTest is BaseTest { uint256 keysCount137 = IthacaAccount(eoaAddress).keyCount(); assertEq(keysCount137, 2, "Keys should be added on chain 137"); } + + function testCommonToZeroAddressReplacement() public { + DelegatedEOA memory d = _randomEIP7702DelegatedEOA(); + vm.deal(d.eoa, 100 ether); + + // Test that address(0) gets replaced with address(this) by comparing digest computation + // First, create calls with explicit address(this) + ERC7821.CallSansTo[] memory calls = new ERC7821.CallSansTo[](1); + calls[0] = ERC7821.CallSansTo(1 ether, ""); + + uint256 nonce = d.d.getNonce(0); + + // Compute digest with explicit address(this) + bytes32 digestExplicit = d.d.computeDigest(calls, address(d.d), nonce); + + // Compute digest with address(0) - should be the same due to replacement + bytes32 digestZero = d.d.computeDigest(calls, address(0), nonce); + + // If address(0) replacement is working, these digests should be identical + assertEq( + digestExplicit, + digestZero, + "Digest with address(0) should equal digest with address(this)" + ); + + // Additionally, test that the execution works + bytes memory signature = _sig(d, digestZero); + bytes memory opData = abi.encodePacked(nonce, signature); + bytes memory executionData = abi.encode(address(0), calls, opData); + + // This should succeed without reverting (proving the replacement works in execution too) + d.d.execute(_ERC7821_BATCH_SANS_TO_EXECUTION_MODE, executionData); + } } From 3597669dab43495df83e3bdb07dc8ef215e88b01 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 30 Sep 2025 17:28:49 +0530 Subject: [PATCH 5/8] fix: rebase issues --- snapshots/BenchmarkTest.json | 102 +++++++++++++++++------------------ test/MultiSigSigner.t.sol | 1 - 2 files changed, 51 insertions(+), 52 deletions(-) diff --git a/snapshots/BenchmarkTest.json b/snapshots/BenchmarkTest.json index 82f7d2ae..b90c184a 100644 --- a/snapshots/BenchmarkTest.json +++ b/snapshots/BenchmarkTest.json @@ -1,53 +1,53 @@ { - "testERC20Transfer_AlchemyModularAccount": "179494", - "testERC20Transfer_AlchemyModularAccount_AppSponsor": "176436", - "testERC20Transfer_AlchemyModularAccount_ERC20SelfPay": "207779", - "testERC20Transfer_Batch100_AlchemyModularAccount_AppSponsor": "8897167", - "testERC20Transfer_Batch100_CoinbaseSmartWallet": "9952203", - "testERC20Transfer_Batch100_CoinbaseSmartWallet_AppSponsor": "8787327", - "testERC20Transfer_Batch100_CoinbaseSmartWallet_ERC20SelfPay": "11335605", - "testERC20Transfer_Batch100_Safe4337": "11685484", - "testERC20Transfer_Batch100_Safe4337_AppSponsor": "10198375", - "testERC20Transfer_Batch100_Safe4337_ERC20SelfPay": "12757523", - "testERC20Transfer_Batch100_ZerodevKernel_AppSponsor": "11427583", - "testERC20Transfer_CoinbaseSmartWallet": "177855", - "testERC20Transfer_CoinbaseSmartWallet_AppSponsor": "175259", - "testERC20Transfer_CoinbaseSmartWallet_ERC20SelfPay": "204919", - "testERC20Transfer_ERC4337MinimalAccount": "171509", - "testERC20Transfer_ERC4337MinimalAccount_AppSponsor": "168500", - "testERC20Transfer_ERC4337MinimalAccount_ERC20SelfPay": "199831", - "testERC20Transfer_IthacaAccount": "128195", - "testERC20Transfer_IthacaAccountWithSpendLimits": "193658", - "testERC20Transfer_IthacaAccount_AppSponsor": "138709", - "testERC20Transfer_IthacaAccount_AppSponsor_ERC20": "144010", - "testERC20Transfer_IthacaAccount_ERC20SelfPay": "128684", - "testERC20Transfer_Safe4337": "197561", - "testERC20Transfer_Safe4337_AppSponsor": "191725", - "testERC20Transfer_Safe4337_ERC20SelfPay": "221464", - "testERC20Transfer_ZerodevKernel": "207117", - "testERC20Transfer_ZerodevKernel_AppSponsor": "204120", - "testERC20Transfer_ZerodevKernel_ERC20SelfPay": "235489", - "testERC20Transfer_batch100_AlchemyModularAccount": "10109066", - "testERC20Transfer_batch100_AlchemyModularAccount_ERC20SelfPay": "11609298", - "testERC20Transfer_batch100_IthacaAccount": "7545392", - "testERC20Transfer_batch100_IthacaAccount_AppSponsor": "8151496", - "testERC20Transfer_batch100_IthacaAccount_AppSponsor_ERC20": "7977508", - "testERC20Transfer_batch100_IthacaAccount_ERC20SelfPay": "7366652", - "testERC20Transfer_batch100_ZerodevKernel": "12631318", - "testERC20Transfer_batch100_ZerodevKernel_ERC20SelfPay": "14149937", - "testNativeTransfer_AlchemyModularAccount": "180829", - "testNativeTransfer_CoinbaseSmartWallet": "178916", - "testNativeTransfer_IthacaAccount": "129551", - "testNativeTransfer_IthacaAccount_AppSponsor": "140096", - "testNativeTransfer_IthacaAccount_ERC20SelfPay": "137340", - "testNativeTransfer_Safe4337": "198595", - "testNativeTransfer_ZerodevKernel": "208635", - "testUniswapV2Swap_AlchemyModularAccount": "238647", - "testUniswapV2Swap_CoinbaseSmartWallet": "237451", - "testUniswapV2Swap_ERC4337MinimalAccount": "230691", - "testUniswapV2Swap_IthacaAccount": "187339", - "testUniswapV2Swap_IthacaAccount_AppSponsor": "197817", - "testUniswapV2Swap_IthacaAccount_ERC20SelfPay": "192628", - "testUniswapV2Swap_Safe4337": "257333", - "testUniswapV2Swap_ZerodevKernel": "266367" + "testERC20Transfer_AlchemyModularAccount": "159052", + "testERC20Transfer_AlchemyModularAccount_AppSponsor": "134278", + "testERC20Transfer_AlchemyModularAccount_ERC20SelfPay": "160169", + "testERC20Transfer_Batch100_AlchemyModularAccount_AppSponsor": "7053863", + "testERC20Transfer_Batch100_CoinbaseSmartWallet": "9801043", + "testERC20Transfer_Batch100_CoinbaseSmartWallet_AppSponsor": "6483523", + "testERC20Transfer_Batch100_CoinbaseSmartWallet_ERC20SelfPay": "8487729", + "testERC20Transfer_Batch100_Safe4337": "11165400", + "testERC20Transfer_Batch100_Safe4337_AppSponsor": "7525827", + "testERC20Transfer_Batch100_Safe4337_ERC20SelfPay": "9540939", + "testERC20Transfer_Batch100_ZerodevKernel_AppSponsor": "8956539", + "testERC20Transfer_CoinbaseSmartWallet": "150133", + "testERC20Transfer_CoinbaseSmartWallet_AppSponsor": "126009", + "testERC20Transfer_CoinbaseSmartWallet_ERC20SelfPay": "150241", + "testERC20Transfer_ERC4337MinimalAccount": "148602", + "testERC20Transfer_ERC4337MinimalAccount_AppSponsor": "123885", + "testERC20Transfer_ERC4337MinimalAccount_ERC20SelfPay": "149776", + "testERC20Transfer_IthacaAccount": "91824", + "testERC20Transfer_IthacaAccountWithSpendLimits": "113325", + "testERC20Transfer_IthacaAccount_AppSponsor": "99331", + "testERC20Transfer_IthacaAccount_AppSponsor_ERC20": "104432", + "testERC20Transfer_IthacaAccount_ERC20SelfPay": "92125", + "testERC20Transfer_Safe4337": "163675", + "testERC20Transfer_Safe4337_AppSponsor": "136323", + "testERC20Transfer_Safe4337_ERC20SelfPay": "160610", + "testERC20Transfer_ZerodevKernel": "175447", + "testERC20Transfer_ZerodevKernel_AppSponsor": "150734", + "testERC20Transfer_ZerodevKernel_ERC20SelfPay": "176663", + "testERC20Transfer_batch100_AlchemyModularAccount": "10438358", + "testERC20Transfer_batch100_AlchemyModularAccount_ERC20SelfPay": "9221814", + "testERC20Transfer_batch100_IthacaAccount": "6217728", + "testERC20Transfer_batch100_IthacaAccount_AppSponsor": "6769528", + "testERC20Transfer_batch100_IthacaAccount_AppSponsor_ERC20": "6576728", + "testERC20Transfer_batch100_IthacaAccount_ERC20SelfPay": "6020128", + "testERC20Transfer_batch100_ZerodevKernel": "12332690", + "testERC20Transfer_batch100_ZerodevKernel_ERC20SelfPay": "11134785", + "testNativeTransfer_AlchemyModularAccount": "168453", + "testNativeTransfer_CoinbaseSmartWallet": "159248", + "testNativeTransfer_IthacaAccount": "101222", + "testNativeTransfer_IthacaAccount_AppSponsor": "108748", + "testNativeTransfer_IthacaAccount_ERC20SelfPay": "101523", + "testNativeTransfer_Safe4337": "172763", + "testNativeTransfer_ZerodevKernel": "184855", + "testUniswapV2Swap_AlchemyModularAccount": "210111", + "testUniswapV2Swap_CoinbaseSmartWallet": "201623", + "testUniswapV2Swap_ERC4337MinimalAccount": "199690", + "testUniswapV2Swap_IthacaAccount": "142886", + "testUniswapV2Swap_IthacaAccount_AppSponsor": "150357", + "testUniswapV2Swap_IthacaAccount_ERC20SelfPay": "143187", + "testUniswapV2Swap_Safe4337": "215353", + "testUniswapV2Swap_ZerodevKernel": "226591" } \ No newline at end of file diff --git a/test/MultiSigSigner.t.sol b/test/MultiSigSigner.t.sol index 497e0618..1bbaecec 100644 --- a/test/MultiSigSigner.t.sol +++ b/test/MultiSigSigner.t.sol @@ -4,7 +4,6 @@ pragma solidity ^0.8.23; import "./Base.t.sol"; import {MultiSigSigner} from "../src/MultiSigSigner.sol"; import {IthacaAccount} from "../src/IthacaAccount.sol"; -import {ERC7821} from "solady/accounts/ERC7821.sol"; contract MultiSigSignerTest is BaseTest { MultiSigSigner multiSigSigner; From 6941b1d906300db365981e02aa55450d3fb35c8b Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 30 Sep 2025 17:50:41 +0530 Subject: [PATCH 6/8] feat: hyperoptimized calldata execution mode --- src/IthacaAccount.sol | 31 +++++++++--------- src/libraries/ERC7821Ithaca.sol | 56 +++++++++++---------------------- 2 files changed, 33 insertions(+), 54 deletions(-) diff --git a/src/IthacaAccount.sol b/src/IthacaAccount.sol index 211ff3d5..c6b6c204 100644 --- a/src/IthacaAccount.sol +++ b/src/IthacaAccount.sol @@ -188,6 +188,11 @@ contract IthacaAccount is IIthacaAccount, EIP712, GuardedExecutor { "Execute(bool multichain,Call[] calls,uint256 nonce)Call(address to,uint256 value,bytes data)" ); + /// @dev For EIP712 signature digest calculation for the `execute` function. + bytes32 public constant OPTIMIZED_EXECUTE_TYPEHASH = keccak256( + "OptimizedExecute(bool multichain,address to,bytes[] datas,uint256 nonce)" + ); + /// @dev For EIP712 signature digest calculation for the `execute` function. bytes32 public constant CALL_TYPEHASH = keccak256("Call(address to,uint256 value,bytes data)"); @@ -485,7 +490,7 @@ contract IthacaAccount is IIthacaAccount, EIP712, GuardedExecutor { return isMultichain ? _hashTypedDataSansChainId(structHash) : _hashTypedData(structHash); } - function computeDigest(CallSansTo[] calldata calls, address to, uint256 nonce) + function computeDigest(bytes[] calldata datas, address to, uint256 nonce) public view virtual @@ -497,22 +502,16 @@ contract IthacaAccount is IIthacaAccount, EIP712, GuardedExecutor { to := or(mul(address(), iszero(t)), t) } - bytes32[] memory a = EfficientHashLib.malloc(calls.length); - for (uint256 i; i < calls.length; ++i) { - (uint256 value, bytes calldata data) = _get(calls, i); + bytes32[] memory a = EfficientHashLib.malloc(datas.length); + for (uint256 i; i < datas.length; ++i) { a.set( i, - EfficientHashLib.hash( - CALL_TYPEHASH, - bytes32(uint256(uint160(to))), - bytes32(value), - EfficientHashLib.hashCalldata(data) - ) + EfficientHashLib.hashCalldata(datas[i]) ); } bool isMultichain = nonce >> 240 == MULTICHAIN_NONCE_PREFIX; bytes32 structHash = EfficientHashLib.hash( - uint256(EXECUTE_TYPEHASH), LibBit.toUint(isMultichain), uint256(a.hash()), nonce + uint256(OPTIMIZED_EXECUTE_TYPEHASH), LibBit.toUint(isMultichain), uint256(uint160(to)), uint256(a.hash()), nonce ); return isMultichain ? _hashTypedDataSansChainId(structHash) : _hashTypedData(structHash); } @@ -705,7 +704,7 @@ contract IthacaAccount is IIthacaAccount, EIP712, GuardedExecutor { bytes32, bytes calldata, address to, - CallSansTo[] calldata calls, + bytes[] calldata datas, bytes calldata opData ) internal virtual override { // Orchestrator workflow. @@ -716,7 +715,7 @@ contract IthacaAccount is IIthacaAccount, EIP712, GuardedExecutor { bytes32 _keyHash = LibBytes.loadCalldata(opData, 0x00); LibTStack.TStack(_KEYHASH_STACK_TRANSIENT_SLOT).push(_keyHash); - _execute(calls, to, _keyHash); + _execute(datas, to, _keyHash); LibTStack.TStack(_KEYHASH_STACK_TRANSIENT_SLOT).pop(); return; @@ -725,7 +724,7 @@ contract IthacaAccount is IIthacaAccount, EIP712, GuardedExecutor { // Simple workflow without `opData`. if (opData.length == uint256(0)) { if (msg.sender != address(this)) revert Unauthorized(); - return _execute(calls, to, bytes32(0)); + return _execute(datas, to, bytes32(0)); } // Simple workflow with `opData`. @@ -735,12 +734,12 @@ contract IthacaAccount is IIthacaAccount, EIP712, GuardedExecutor { emit NonceInvalidated(nonce); (bool isValid, bytes32 keyHash) = unwrapAndValidateSignature( - computeDigest(calls, to, nonce), LibBytes.sliceCalldata(opData, 0x20) + computeDigest(datas, to, nonce), LibBytes.sliceCalldata(opData, 0x20) ); if (!isValid) revert Unauthorized(); LibTStack.TStack(_KEYHASH_STACK_TRANSIENT_SLOT).push(keyHash); - _execute(calls, to, keyHash); + _execute(datas, to, keyHash); LibTStack.TStack(_KEYHASH_STACK_TRANSIENT_SLOT).pop(); } diff --git a/src/libraries/ERC7821Ithaca.sol b/src/libraries/ERC7821Ithaca.sol index 5f726b42..40926c7b 100644 --- a/src/libraries/ERC7821Ithaca.sol +++ b/src/libraries/ERC7821Ithaca.sol @@ -25,11 +25,6 @@ contract ERC7821Ithaca is Receiver { bytes data; // Calldata to send with the call. } - struct CallSansTo { - uint256 value; // Amount of native currency (i.e. Ether) to send. - bytes data; // Calldata to send with the call. - } - /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ /* CUSTOM ERRORS */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ @@ -79,7 +74,7 @@ contract ERC7821Ithaca is Receiver { function execute(bytes32 mode, bytes calldata executionData) public payable virtual { uint256 id = _executionModeId(mode); if (id == 3) return _executeBatchOfBatches(mode, executionData); - if (id == 4) return _executeBatchCommonTo(mode, executionData); + if (id == 4) return _executeCalldataOptimal(mode, executionData); Call[] calldata calls; bytes calldata opData; @@ -140,20 +135,20 @@ contract ERC7821Ithaca is Receiver { /// @dev For execution of a batch of batches with a common `to` address. /// @dev if to == address(0), it will be replaced with address(this) - /// Execution Data: abi.encode(address to, CallSansTo[] calls, bytes opData) - function _executeBatchCommonTo(bytes32 mode, bytes calldata executionData) internal virtual { + /// Execution Data: abi.encode(address to, bytes[] datas, bytes opData) + function _executeCalldataOptimal(bytes32 mode, bytes calldata executionData) internal virtual { address to; - CallSansTo[] calldata calls; + bytes[] calldata datas; bytes calldata opData; /// @solidity memory-safe-assembly assembly { to := calldataload(executionData.offset) - let callOffset := + let dataOffset := add(executionData.offset, calldataload(add(0x20, executionData.offset))) - calls.offset := add(callOffset, 0x20) - calls.length := calldataload(callOffset) + datas.offset := add(dataOffset, 0x20) + datas.length := calldataload(dataOffset) // This line is needed to ensure that opdata is valid in all code paths. // Otherwise the compiler complains. @@ -167,7 +162,7 @@ contract ERC7821Ithaca is Receiver { } } - _execute(mode, executionData, to, calls, opData); + _execute(mode, executionData, to, datas, opData); } /// @dev For execution of a batch of batches. @@ -238,7 +233,7 @@ contract ERC7821Ithaca is Receiver { bytes32 mode, bytes calldata executionData, address to, - CallSansTo[] calldata calls, + bytes[] calldata datas, bytes calldata opData ) internal virtual { // Silence compiler warning on unused variables. @@ -249,7 +244,7 @@ contract ERC7821Ithaca is Receiver { if (opData.length == uint256(0)) { require(msg.sender == address(this)); // Remember to return `_execute(calls, extraData)` when you override this function. - return _execute(calls, to, bytes32(0)); + return _execute(datas, to, bytes32(0)); } revert(); // In your override, replace this with logic to operate on `opData`. } @@ -268,10 +263,12 @@ contract ERC7821Ithaca is Receiver { } } - /// @dev Executes the calls. + /// @dev Executes the datas, with a common `to` address. + /// @dev if to == address(0), it will be replaced with address(this) + /// @dev value for all calls is set to 0 /// Reverts and bubbles up error if any call fails. /// `extraData` can be any supplementary data (e.g. a memory pointer, some hash). - function _execute(CallSansTo[] calldata calls, address to, bytes32 keyHash) internal virtual { + function _execute(bytes[] calldata datas, address to, bytes32 keyHash) internal virtual { unchecked { uint256 i; // If `to` is address(0), it will be replaced with address(this) @@ -280,11 +277,11 @@ contract ERC7821Ithaca is Receiver { let t := shr(96, shl(96, to)) to := or(mul(address(), iszero(t)), t) } - if (calls.length == uint256(0)) return; + if (datas.length == uint256(0)) return; do { - (uint256 value, bytes calldata data) = _get(calls, i); - _execute(to, value, data, keyHash); - } while (++i != calls.length); + + _execute(to, 0, datas[i], keyHash); + } while (++i != datas.length); } } @@ -308,23 +305,6 @@ contract ERC7821Ithaca is Receiver { } } - /// @dev Convenience function for getting `calls[i]`, without bounds checks. - function _get(CallSansTo[] calldata calls, uint256 i) - internal - view - virtual - returns (uint256 value, bytes calldata data) - { - /// @solidity memory-safe-assembly - assembly { - let c := add(calls.offset, calldataload(add(calls.offset, shl(5, i)))) - value := calldataload(c) - let o := add(c, calldataload(add(c, 0x20))) - data.offset := add(o, 0x20) - data.length := calldataload(o) - } - } - /// @dev Convenience function for getting `calls[i]`, without bounds checks. function _get(Call[] calldata calls, uint256 i) internal From cc9389d27b01d334f0f53fb1f111d7e2cee821e9 Mon Sep 17 00:00:00 2001 From: Tanishk Goyal Date: Tue, 30 Sep 2025 18:01:33 +0530 Subject: [PATCH 7/8] chore: update tests --- snapshots/BenchmarkTest.json | 30 ++++++------- test/Account.t.sol | 84 ++++++++++++++++++------------------ test/Base.t.sol | 2 +- 3 files changed, 57 insertions(+), 59 deletions(-) diff --git a/snapshots/BenchmarkTest.json b/snapshots/BenchmarkTest.json index b90c184a..527d82a1 100644 --- a/snapshots/BenchmarkTest.json +++ b/snapshots/BenchmarkTest.json @@ -16,11 +16,11 @@ "testERC20Transfer_ERC4337MinimalAccount": "148602", "testERC20Transfer_ERC4337MinimalAccount_AppSponsor": "123885", "testERC20Transfer_ERC4337MinimalAccount_ERC20SelfPay": "149776", - "testERC20Transfer_IthacaAccount": "91824", - "testERC20Transfer_IthacaAccountWithSpendLimits": "113325", - "testERC20Transfer_IthacaAccount_AppSponsor": "99331", - "testERC20Transfer_IthacaAccount_AppSponsor_ERC20": "104432", - "testERC20Transfer_IthacaAccount_ERC20SelfPay": "92125", + "testERC20Transfer_IthacaAccount": "91780", + "testERC20Transfer_IthacaAccountWithSpendLimits": "113281", + "testERC20Transfer_IthacaAccount_AppSponsor": "99287", + "testERC20Transfer_IthacaAccount_AppSponsor_ERC20": "104388", + "testERC20Transfer_IthacaAccount_ERC20SelfPay": "92081", "testERC20Transfer_Safe4337": "163675", "testERC20Transfer_Safe4337_AppSponsor": "136323", "testERC20Transfer_Safe4337_ERC20SelfPay": "160610", @@ -29,25 +29,25 @@ "testERC20Transfer_ZerodevKernel_ERC20SelfPay": "176663", "testERC20Transfer_batch100_AlchemyModularAccount": "10438358", "testERC20Transfer_batch100_AlchemyModularAccount_ERC20SelfPay": "9221814", - "testERC20Transfer_batch100_IthacaAccount": "6217728", - "testERC20Transfer_batch100_IthacaAccount_AppSponsor": "6769528", - "testERC20Transfer_batch100_IthacaAccount_AppSponsor_ERC20": "6576728", - "testERC20Transfer_batch100_IthacaAccount_ERC20SelfPay": "6020128", + "testERC20Transfer_batch100_IthacaAccount": "6213328", + "testERC20Transfer_batch100_IthacaAccount_AppSponsor": "6765128", + "testERC20Transfer_batch100_IthacaAccount_AppSponsor_ERC20": "6572328", + "testERC20Transfer_batch100_IthacaAccount_ERC20SelfPay": "6015728", "testERC20Transfer_batch100_ZerodevKernel": "12332690", "testERC20Transfer_batch100_ZerodevKernel_ERC20SelfPay": "11134785", "testNativeTransfer_AlchemyModularAccount": "168453", "testNativeTransfer_CoinbaseSmartWallet": "159248", - "testNativeTransfer_IthacaAccount": "101222", - "testNativeTransfer_IthacaAccount_AppSponsor": "108748", - "testNativeTransfer_IthacaAccount_ERC20SelfPay": "101523", + "testNativeTransfer_IthacaAccount": "101178", + "testNativeTransfer_IthacaAccount_AppSponsor": "108704", + "testNativeTransfer_IthacaAccount_ERC20SelfPay": "101479", "testNativeTransfer_Safe4337": "172763", "testNativeTransfer_ZerodevKernel": "184855", "testUniswapV2Swap_AlchemyModularAccount": "210111", "testUniswapV2Swap_CoinbaseSmartWallet": "201623", "testUniswapV2Swap_ERC4337MinimalAccount": "199690", - "testUniswapV2Swap_IthacaAccount": "142886", - "testUniswapV2Swap_IthacaAccount_AppSponsor": "150357", - "testUniswapV2Swap_IthacaAccount_ERC20SelfPay": "143187", + "testUniswapV2Swap_IthacaAccount": "142842", + "testUniswapV2Swap_IthacaAccount_AppSponsor": "150313", + "testUniswapV2Swap_IthacaAccount_ERC20SelfPay": "143143", "testUniswapV2Swap_Safe4337": "215353", "testUniswapV2Swap_ZerodevKernel": "226591" } \ No newline at end of file diff --git a/test/Account.t.sol b/test/Account.t.sol index 69c81c99..9e7f7723 100644 --- a/test/Account.t.sol +++ b/test/Account.t.sol @@ -68,9 +68,9 @@ contract AccountTest is BaseTest { } } - struct _TestExecuteWithCallSansToTemps { + struct _TestExecuteWithCalldataOptimalTemps { TargetFunctionPayload[] targetFunctionPayloads; - ERC7821.CallSansTo[] calls; + bytes[] datas; address to; uint256 n; uint256 nonce; @@ -78,42 +78,40 @@ contract AccountTest is BaseTest { bytes executionData; } - function testExecuteWithCallSansTo(bytes32) public { + function testExecuteWithCalldataOptimal(bytes32) public { DelegatedEOA memory d = _randomEIP7702DelegatedEOA(); vm.deal(d.eoa, 100 ether); - _TestExecuteWithCallSansToTemps memory t; + _TestExecuteWithCalldataOptimalTemps memory t; t.n = _bound(_randomUniform(), 1, 5); t.targetFunctionPayloads = new TargetFunctionPayload[](t.n); - t.calls = new ERC7821.CallSansTo[](t.n); + t.datas = new bytes[](t.n); t.to = address(this); for (uint256 i; i < t.n; ++i) { - uint256 value = _random() % 0.1 ether; bytes memory data = _truncateBytes(_randomBytes(), 0xff); - t.calls[i] = - ERC7821.CallSansTo(value, abi.encodeWithSignature("targetFunction(bytes)", data)); - t.targetFunctionPayloads[i].value = value; + t.datas[i] = abi.encodeWithSignature("targetFunction(bytes)", data); + t.targetFunctionPayloads[i].value = 0; // value is always 0 in hyperoptimized mode t.targetFunctionPayloads[i].data = data; } t.nonce = d.d.getNonce(0); - bytes memory signature = _sig(d, d.d.computeDigest(t.calls, t.to, t.nonce)); + bytes memory signature = _sig(d, d.d.computeDigest(t.datas, t.to, t.nonce)); t.opData = abi.encodePacked(t.nonce, signature); - t.executionData = abi.encode(t.to, t.calls, t.opData); + t.executionData = abi.encode(t.to, t.datas, t.opData); // Negative test: wrong signature (32/256 chance) if (_randomChance(32)) { bytes memory wrongSignature = - _sig(_randomEIP7702DelegatedEOA(), d.d.computeDigest(t.calls, t.to, t.nonce)); + _sig(_randomEIP7702DelegatedEOA(), d.d.computeDigest(t.datas, t.to, t.nonce)); t.opData = abi.encodePacked(t.nonce, wrongSignature); - t.executionData = abi.encode(t.to, t.calls, t.opData); + t.executionData = abi.encode(t.to, t.datas, t.opData); vm.expectRevert(bytes4(keccak256("Unauthorized()"))); - d.d.execute(_ERC7821_BATCH_SANS_TO_EXECUTION_MODE, t.executionData); + d.d.execute(_ERC7821_BATCH_CALLDATA_OPTIMAL_EXECUTION_MODE, t.executionData); return; } - d.d.execute(_ERC7821_BATCH_SANS_TO_EXECUTION_MODE, t.executionData); + d.d.execute(_ERC7821_BATCH_CALLDATA_OPTIMAL_EXECUTION_MODE, t.executionData); assertEq(targetFunctionPayloads.length, t.n); for (uint256 i; i < t.n; ++i) { @@ -123,68 +121,68 @@ contract AccountTest is BaseTest { } } - function testExecuteWithCallSansToWrongNonce() public { + function testExecuteWithCalldataOptimalWrongNonce() public { DelegatedEOA memory d = _randomEIP7702DelegatedEOA(); vm.deal(d.eoa, 100 ether); - _TestExecuteWithCallSansToTemps memory t; + _TestExecuteWithCalldataOptimalTemps memory t; t.n = 1; t.targetFunctionPayloads = new TargetFunctionPayload[](t.n); - t.calls = new ERC7821.CallSansTo[](t.n); + t.datas = new bytes[](t.n); t.to = address(this); - t.calls[0] = ERC7821.CallSansTo(0, abi.encodeWithSignature("targetFunction(bytes)", "test")); + t.datas[0] = abi.encodeWithSignature("targetFunction(bytes)", "test"); t.nonce = d.d.getNonce(0); uint256 wrongNonce = t.nonce + 1; - bytes memory signature = _sig(d, d.d.computeDigest(t.calls, t.to, wrongNonce)); + bytes memory signature = _sig(d, d.d.computeDigest(t.datas, t.to, wrongNonce)); t.opData = abi.encodePacked(wrongNonce, signature); - t.executionData = abi.encode(t.to, t.calls, t.opData); + t.executionData = abi.encode(t.to, t.datas, t.opData); vm.expectRevert(); // Should revert due to invalid nonce - d.d.execute(_ERC7821_BATCH_SANS_TO_EXECUTION_MODE, t.executionData); + d.d.execute(_ERC7821_BATCH_CALLDATA_OPTIMAL_EXECUTION_MODE, t.executionData); } - function testExecuteWithCallSansToWrongDigest() public { + function testExecuteWithCalldataOptimalWrongDigest() public { DelegatedEOA memory d = _randomEIP7702DelegatedEOA(); vm.deal(d.eoa, 100 ether); - _TestExecuteWithCallSansToTemps memory t; + _TestExecuteWithCalldataOptimalTemps memory t; t.n = 1; t.targetFunctionPayloads = new TargetFunctionPayload[](t.n); - t.calls = new ERC7821.CallSansTo[](t.n); + t.datas = new bytes[](t.n); t.to = address(this); - t.calls[0] = ERC7821.CallSansTo(0, abi.encodeWithSignature("targetFunction(bytes)", "test")); + t.datas[0] = abi.encodeWithSignature("targetFunction(bytes)", "test"); t.nonce = d.d.getNonce(0); // Sign with wrong to address address wrongTo = address(0x123); - bytes memory signature = _sig(d, d.d.computeDigest(t.calls, wrongTo, t.nonce)); + bytes memory signature = _sig(d, d.d.computeDigest(t.datas, wrongTo, t.nonce)); t.opData = abi.encodePacked(t.nonce, signature); - t.executionData = abi.encode(t.to, t.calls, t.opData); + t.executionData = abi.encode(t.to, t.datas, t.opData); vm.expectRevert(bytes4(keccak256("Unauthorized()"))); - d.d.execute(_ERC7821_BATCH_SANS_TO_EXECUTION_MODE, t.executionData); + d.d.execute(_ERC7821_BATCH_CALLDATA_OPTIMAL_EXECUTION_MODE, t.executionData); } - function testExecuteWithCallSansToInvalidOpData() public { + function testExecuteWithCalldataOptimalInvalidOpData() public { DelegatedEOA memory d = _randomEIP7702DelegatedEOA(); vm.deal(d.eoa, 100 ether); - _TestExecuteWithCallSansToTemps memory t; + _TestExecuteWithCalldataOptimalTemps memory t; t.n = 1; - t.calls = new ERC7821.CallSansTo[](t.n); + t.datas = new bytes[](t.n); t.to = address(this); - t.calls[0] = ERC7821.CallSansTo(0, abi.encodeWithSignature("targetFunction(bytes)", "test")); + t.datas[0] = abi.encodeWithSignature("targetFunction(bytes)", "test"); // Test with opData too short (less than 32 bytes for nonce) t.opData = hex"1234"; // Only 2 bytes - t.executionData = abi.encode(t.to, t.calls, t.opData); + t.executionData = abi.encode(t.to, t.datas, t.opData); vm.expectRevert(bytes4(keccak256("OpDataError()"))); - d.d.execute(_ERC7821_BATCH_SANS_TO_EXECUTION_MODE, t.executionData); + d.d.execute(_ERC7821_BATCH_CALLDATA_OPTIMAL_EXECUTION_MODE, t.executionData); } function testSignatureCheckerApproval(bytes32) public { @@ -474,22 +472,22 @@ contract AccountTest is BaseTest { assertEq(keysCount137, 2, "Keys should be added on chain 137"); } - function testCommonToZeroAddressReplacement() public { + function testCalldataOptimalZeroAddressReplacement() public { DelegatedEOA memory d = _randomEIP7702DelegatedEOA(); vm.deal(d.eoa, 100 ether); // Test that address(0) gets replaced with address(this) by comparing digest computation - // First, create calls with explicit address(this) - ERC7821.CallSansTo[] memory calls = new ERC7821.CallSansTo[](1); - calls[0] = ERC7821.CallSansTo(1 ether, ""); + // First, create datas with explicit address(this) + bytes[] memory datas = new bytes[](1); + datas[0] = ""; uint256 nonce = d.d.getNonce(0); // Compute digest with explicit address(this) - bytes32 digestExplicit = d.d.computeDigest(calls, address(d.d), nonce); + bytes32 digestExplicit = d.d.computeDigest(datas, address(d.d), nonce); // Compute digest with address(0) - should be the same due to replacement - bytes32 digestZero = d.d.computeDigest(calls, address(0), nonce); + bytes32 digestZero = d.d.computeDigest(datas, address(0), nonce); // If address(0) replacement is working, these digests should be identical assertEq( @@ -501,9 +499,9 @@ contract AccountTest is BaseTest { // Additionally, test that the execution works bytes memory signature = _sig(d, digestZero); bytes memory opData = abi.encodePacked(nonce, signature); - bytes memory executionData = abi.encode(address(0), calls, opData); + bytes memory executionData = abi.encode(address(0), datas, opData); // This should succeed without reverting (proving the replacement works in execution too) - d.d.execute(_ERC7821_BATCH_SANS_TO_EXECUTION_MODE, executionData); + d.d.execute(_ERC7821_BATCH_CALLDATA_OPTIMAL_EXECUTION_MODE, executionData); } } diff --git a/test/Base.t.sol b/test/Base.t.sol index 4e353a9a..d52cd8ac 100644 --- a/test/Base.t.sol +++ b/test/Base.t.sol @@ -56,7 +56,7 @@ contract BaseTest is SoladyTest { bytes32 internal constant _ERC7821_BATCH_EXECUTION_MODE = 0x0100000000007821000100000000000000000000000000000000000000000000; - bytes32 internal constant _ERC7821_BATCH_SANS_TO_EXECUTION_MODE = + bytes32 internal constant _ERC7821_BATCH_CALLDATA_OPTIMAL_EXECUTION_MODE = 0x0100000000007821000300000000000000000000000000000000000000000000; bytes32 internal constant _ERC7579_DELEGATE_CALL_MODE = From 8f19c2461f8971dca3f3f3dcfd613a851762872f Mon Sep 17 00:00:00 2001 From: legion2002 <64212892+legion2002@users.noreply.github.com> Date: Tue, 30 Sep 2025 12:37:15 +0000 Subject: [PATCH 8/8] chore: bump contract versions due to bytecode changes - Contracts updated: IthacaAccount --- src/IthacaAccount.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/IthacaAccount.sol b/src/IthacaAccount.sol index c6b6c204..0132b4ad 100644 --- a/src/IthacaAccount.sol +++ b/src/IthacaAccount.sol @@ -819,6 +819,6 @@ contract IthacaAccount is IIthacaAccount, EIP712, GuardedExecutor { returns (string memory name, string memory version) { name = "IthacaAccount"; - version = "0.5.10"; + version = "0.5.11"; } }