diff --git a/snapshots/BenchmarkTest.json b/snapshots/BenchmarkTest.json index 82f7d2ae..527d82a1 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": "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", + "testERC20Transfer_ZerodevKernel": "175447", + "testERC20Transfer_ZerodevKernel_AppSponsor": "150734", + "testERC20Transfer_ZerodevKernel_ERC20SelfPay": "176663", + "testERC20Transfer_batch100_AlchemyModularAccount": "10438358", + "testERC20Transfer_batch100_AlchemyModularAccount_ERC20SelfPay": "9221814", + "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": "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": "142842", + "testUniswapV2Swap_IthacaAccount_AppSponsor": "150313", + "testUniswapV2Swap_IthacaAccount_ERC20SelfPay": "143143", + "testUniswapV2Swap_Safe4337": "215353", + "testUniswapV2Swap_ZerodevKernel": "226591" } \ No newline at end of file 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..0132b4ad 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,6 +490,32 @@ contract IthacaAccount is IIthacaAccount, EIP712, GuardedExecutor { return isMultichain ? _hashTypedDataSansChainId(structHash) : _hashTypedData(structHash); } + function computeDigest(bytes[] calldata datas, address to, uint256 nonce) + public + view + 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(datas.length); + for (uint256 i; i < datas.length; ++i) { + a.set( + i, + EfficientHashLib.hashCalldata(datas[i]) + ); + } + bool isMultichain = nonce >> 240 == MULTICHAIN_NONCE_PREFIX; + bytes32 structHash = EfficientHashLib.hash( + uint256(OPTIMIZED_EXECUTE_TYPEHASH), LibBit.toUint(isMultichain), uint256(uint160(to)), 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 +700,49 @@ contract IthacaAccount is IIthacaAccount, EIP712, GuardedExecutor { // ERC7821 //////////////////////////////////////////////////////////////////////// + function _execute( + bytes32, + bytes calldata, + address to, + bytes[] calldata datas, + 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(datas, 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(datas, 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(datas, to, nonce), LibBytes.sliceCalldata(opData, 0x20) + ); + + if (!isValid) revert Unauthorized(); + LibTStack.TStack(_KEYHASH_STACK_TRANSIENT_SLOT).push(keyHash); + _execute(datas, 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 +779,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(); @@ -747,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"; } } diff --git a/src/libraries/ERC7821Ithaca.sol b/src/libraries/ERC7821Ithaca.sol new file mode 100644 index 00000000..40926c7b --- /dev/null +++ b/src/libraries/ERC7821Ithaca.sol @@ -0,0 +1,327 @@ +// 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. + } + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* 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. + /// - `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...`. + /// 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 _executeCalldataOptimal(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. + } + } + + /// @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, bytes[] datas, bytes opData) + function _executeCalldataOptimal(bytes32 mode, bytes calldata executionData) internal virtual { + address to; + bytes[] calldata datas; + bytes calldata opData; + + /// @solidity memory-safe-assembly + assembly { + to := calldataload(executionData.offset) + + let dataOffset := + add(executionData.offset, calldataload(add(0x20, executionData.offset))) + 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. + opData.length := 0 + // If the offset of `executionData` allows for `opData`, and the mode supports it. + if gt(calldataload(add(0x20, executionData.offset)), 0x40) { + let opDataOffset := + add(executionData.offset, calldataload(add(0x40, executionData.offset))) + opData.offset := add(opDataOffset, 0x20) + opData.length := calldataload(opDataOffset) + } + } + + _execute(mode, executionData, to, datas, 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`. + } + + /// @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, + address to, + bytes[] calldata datas, + 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(datas, 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); + } + } + + /// @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(bytes[] calldata datas, address to, bytes32 keyHash) internal virtual { + unchecked { + uint256 i; + // 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 (datas.length == uint256(0)) return; + do { + + _execute(to, 0, datas[i], keyHash); + } while (++i != datas.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()) + } + } + } + + /// @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..9e7f7723 100644 --- a/test/Account.t.sol +++ b/test/Account.t.sol @@ -68,6 +68,123 @@ contract AccountTest is BaseTest { } } + struct _TestExecuteWithCalldataOptimalTemps { + TargetFunctionPayload[] targetFunctionPayloads; + bytes[] datas; + address to; + uint256 n; + uint256 nonce; + bytes opData; + bytes executionData; + } + + function testExecuteWithCalldataOptimal(bytes32) public { + DelegatedEOA memory d = _randomEIP7702DelegatedEOA(); + vm.deal(d.eoa, 100 ether); + + _TestExecuteWithCalldataOptimalTemps memory t; + t.n = _bound(_randomUniform(), 1, 5); + t.targetFunctionPayloads = new TargetFunctionPayload[](t.n); + t.datas = new bytes[](t.n); + t.to = address(this); + + for (uint256 i; i < t.n; ++i) { + bytes memory data = _truncateBytes(_randomBytes(), 0xff); + 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.datas, t.to, t.nonce)); + t.opData = abi.encodePacked(t.nonce, signature); + 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.datas, t.to, t.nonce)); + t.opData = abi.encodePacked(t.nonce, wrongSignature); + t.executionData = abi.encode(t.to, t.datas, t.opData); + vm.expectRevert(bytes4(keccak256("Unauthorized()"))); + d.d.execute(_ERC7821_BATCH_CALLDATA_OPTIMAL_EXECUTION_MODE, t.executionData); + return; + } + + d.d.execute(_ERC7821_BATCH_CALLDATA_OPTIMAL_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 testExecuteWithCalldataOptimalWrongNonce() public { + DelegatedEOA memory d = _randomEIP7702DelegatedEOA(); + vm.deal(d.eoa, 100 ether); + + _TestExecuteWithCalldataOptimalTemps memory t; + t.n = 1; + t.targetFunctionPayloads = new TargetFunctionPayload[](t.n); + t.datas = new bytes[](t.n); + t.to = address(this); + + 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.datas, t.to, wrongNonce)); + t.opData = abi.encodePacked(wrongNonce, signature); + t.executionData = abi.encode(t.to, t.datas, t.opData); + + vm.expectRevert(); // Should revert due to invalid nonce + d.d.execute(_ERC7821_BATCH_CALLDATA_OPTIMAL_EXECUTION_MODE, t.executionData); + } + + function testExecuteWithCalldataOptimalWrongDigest() public { + DelegatedEOA memory d = _randomEIP7702DelegatedEOA(); + vm.deal(d.eoa, 100 ether); + + _TestExecuteWithCalldataOptimalTemps memory t; + t.n = 1; + t.targetFunctionPayloads = new TargetFunctionPayload[](t.n); + t.datas = new bytes[](t.n); + t.to = address(this); + + 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.datas, wrongTo, t.nonce)); + t.opData = abi.encodePacked(t.nonce, signature); + t.executionData = abi.encode(t.to, t.datas, t.opData); + + vm.expectRevert(bytes4(keccak256("Unauthorized()"))); + d.d.execute(_ERC7821_BATCH_CALLDATA_OPTIMAL_EXECUTION_MODE, t.executionData); + } + + function testExecuteWithCalldataOptimalInvalidOpData() public { + DelegatedEOA memory d = _randomEIP7702DelegatedEOA(); + vm.deal(d.eoa, 100 ether); + + _TestExecuteWithCalldataOptimalTemps memory t; + t.n = 1; + t.datas = new bytes[](t.n); + t.to = address(this); + + 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.datas, t.opData); + + vm.expectRevert(bytes4(keccak256("OpDataError()"))); + d.d.execute(_ERC7821_BATCH_CALLDATA_OPTIMAL_EXECUTION_MODE, t.executionData); + } + function testSignatureCheckerApproval(bytes32) public { DelegatedEOA memory d = _randomEIP7702DelegatedEOA(); PassKey memory k = _randomSecp256k1PassKey(); @@ -354,4 +471,37 @@ contract AccountTest is BaseTest { uint256 keysCount137 = IthacaAccount(eoaAddress).keyCount(); assertEq(keysCount137, 2, "Keys should be added on chain 137"); } + + 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 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(datas, address(d.d), nonce); + + // Compute digest with address(0) - should be the same due to replacement + bytes32 digestZero = d.d.computeDigest(datas, 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), datas, opData); + + // This should succeed without reverting (proving the replacement works in execution too) + d.d.execute(_ERC7821_BATCH_CALLDATA_OPTIMAL_EXECUTION_MODE, executionData); + } } diff --git a/test/Base.t.sol b/test/Base.t.sol index 1765a651..d52cd8ac 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_CALLDATA_OPTIMAL_EXECUTION_MODE = + 0x0100000000007821000300000000000000000000000000000000000000000000; + bytes32 internal constant _ERC7579_DELEGATE_CALL_MODE = 0xff00000000000000000000000000000000000000000000000000000000000000; 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;