diff --git a/README.md b/README.md index 1a2652545..3a846248d 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ accounts ├─ ERC6551 — "Simple ERC6551 account implementation" ├─ ERC6551Proxy — "Relay proxy for upgradeable ERC6551 accounts" ├─ ERC7821 — "Minimal batch executor mixin" +├─ LibEIP7702 — "Library for EIP7702 operations" ├─ LibERC6551 — "Library for interacting with ERC6551 accounts" ├─ LibERC7579 — "Library for handling ERC7579 mode and execution data" ├─ Receiver — "Receiver mixin for ETH and safe-transferred ERC721 and ERC1155 tokens" diff --git a/foundry.toml b/foundry.toml index 7a190bae5..a44a0cd8d 100644 --- a/foundry.toml +++ b/foundry.toml @@ -10,14 +10,14 @@ auto_detect_solc = false optimizer = true optimizer_runs = 1_000 gas_limit = 100_000_000 # ETH is 30M, but we use a higher value. -skip = ["*/*Transient*", "*/ext/ithaca/*", "*/ext/zksync/*"] +skip = ["*/*7702*", "*/*Transient*", "*/ext/ithaca/*", "*/ext/zksync/*"] fs_permissions = [{ access = "read", path = "./test/data"}] remappings = [ "forge-std=test/utils/forge-std/" ] [profile.pre_global_structs] -skip = ["*/g/*", "*/*Transient*", "*/ext/ithaca/*", "*/ext/zksync/*"] +skip = ["*/g/*", "*/*7702*", "*/*Transient*", "*/ext/ithaca/*", "*/ext/zksync/*"] [profile.post_cancun] evm_version = "cancun" diff --git a/src/Milady.sol b/src/Milady.sol index d5fe92411..909a475a7 100644 --- a/src/Milady.sol +++ b/src/Milady.sol @@ -1,7 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.4; -import "./accounts/EIP7702Proxy.sol"; import "./accounts/ERC1271.sol"; import "./accounts/ERC4337.sol"; import "./accounts/ERC4337Factory.sol"; diff --git a/src/accounts/EIP7702Proxy.sol b/src/accounts/EIP7702Proxy.sol index e94d6bded..8b370d35e 100644 --- a/src/accounts/EIP7702Proxy.sol +++ b/src/accounts/EIP7702Proxy.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.4; +pragma solidity ^0.8.24; /// @notice Relay proxy for EIP7702 delegations. /// @author Solady (https://github.com/vectorized/solady/blob/main/src/accounts/EIP7702Proxy.sol) @@ -33,6 +33,11 @@ contract EIP7702Proxy { bytes32 internal constant _ERC1967_ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103; + /// @dev The transient storage slot for requesting the proxy to initialize the implementation. + /// `uint256(keccak256("eip7702.proxy.delegation.initialization.request")) - 1`. + bytes32 internal constant _EIP7702_PROXY_DELEGATION_INITIALIZATION_REQUEST_SLOT = + 0x94e11c6e41e7fb92cb8bb65e13fdfbd4eba8b831292a1a220f7915c78c7c078f; + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ /* CONSTRUCTOR */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ @@ -63,8 +68,8 @@ contract EIP7702Proxy { return(calldatasize(), 0x20) } let fnSel := shr(224, calldataload(0x00)) - // `implementation()`. - if eq(0x5c60da1b, fnSel) { + // `implementation()` or `eip7702ProxyImplementation()`. + if or(eq(0x5c60da1b, fnSel), eq(0x7dae87cb, fnSel)) { if staticcall(gas(), address(), calldatasize(), 0x00, 0x00, 0x20) { return(0x00, returndatasize()) } @@ -77,7 +82,7 @@ contract EIP7702Proxy { } // Admin workflow. if eq(caller(), admin) { - let addr := shr(96, shl(96, calldataload(0x04))) + let addr := shr(96, calldataload(0x10)) // `changeAdmin(address)`. if eq(0x8f283970, fnSel) { sstore(_ERC1967_ADMIN_SLOT, addr) @@ -97,17 +102,39 @@ contract EIP7702Proxy { revert(returndatasize(), 0x00) } // Workflow for the EIP7702 authority (i.e. the EOA). - // As the authority's storage may be polluted by previous delegations, - // we should always fetch the latest implementation from the proxy. - calldatacopy(0x00, 0x00, calldatasize()) // Forward calldata into the delegatecall. - if iszero( - and( // The arguments of `and` are evaluated from right to left. - delegatecall( - gas(), mload(calldatasize()), 0x00, calldatasize(), calldatasize(), 0x00 - ), - staticcall(gas(), s, calldatasize(), 0x00, calldatasize(), 0x20) - ) - ) { + let impl := sload(_ERC1967_IMPLEMENTATION_SLOT) // The preferred implementation on the EOA. + calldatacopy(0x00, 0x00, calldatasize()) // Copy the calldata for the delegatecall. + // If the EOA's implementation, perform the initialization workflow. + if iszero(shl(96, impl)) { + if iszero( + and( // The arguments of `and` are evaluated from right to left. + delegatecall( + gas(), mload(calldatasize()), 0x00, calldatasize(), calldatasize(), 0x00 + ), + // Fetch the implementation from the proxy. + staticcall(gas(), s, calldatasize(), 0x00, calldatasize(), 0x20) + ) + ) { + returndatacopy(0x00, 0x00, returndatasize()) + revert(0x00, returndatasize()) + } + // Because we cannot reliably and efficiently tell if the call is made + // via staticcall or call, we shall ask the delegation to make a proxy delegation + // initialization request to signal that we should initialize the storage slot with + // the actual implementation. This also gives flexibility on whether to let the + // proxy auto-upgrade, or let the authority manually upgrade (via 7702 or passkey). + // A non-zero value in the transient storage denotes a initialization request. + if tload(_EIP7702_PROXY_DELEGATION_INITIALIZATION_REQUEST_SLOT) { + let implSlot := _ERC1967_IMPLEMENTATION_SLOT + // The `implementation` is still at `calldatasize()` in memory. + sstore(implSlot, or(shl(160, shr(160, sload(implSlot))), mload(calldatasize()))) + tstore(_EIP7702_PROXY_DELEGATION_INITIALIZATION_REQUEST_SLOT, 0) // Clear. + } + returndatacopy(0x00, 0x00, returndatasize()) + return(0x00, returndatasize()) + } + // Otherwise, just delegatecall and bubble up the results without initialization. + if iszero(delegatecall(gas(), impl, 0x00, calldatasize(), calldatasize(), 0x00)) { returndatacopy(0x00, 0x00, returndatasize()) revert(0x00, returndatasize()) } diff --git a/src/accounts/LibEIP7702.sol b/src/accounts/LibEIP7702.sol new file mode 100644 index 000000000..21ff23d58 --- /dev/null +++ b/src/accounts/LibEIP7702.sol @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @notice Library for EIP7702 operations. +/// @author Solady (https://github.com/vectorized/solady/blob/main/src/accounts/LibEIP7702.sol) +library LibEIP7702 { + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* CUSTOM ERRORS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /// @dev The proxy query has failed. + error ProxyQueryFailed(); + + /// @dev Failed to change the proxy admin. + error ChangeProxyAdminFailed(); + + /// @dev Failed to upgrade the proxy. + error UpgradeProxyFailed(); + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* CONSTANTS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /// @dev The ERC-1967 storage slot for the implementation in the proxy. + /// `uint256(keccak256("eip1967.proxy.implementation")) - 1`. + bytes32 internal constant ERC1967_IMPLEMENTATION_SLOT = + 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + + /// @dev The transient storage slot for requesting the proxy to initialize the implementation. + /// `uint256(keccak256("eip7702.proxy.delegation.initialization.request")) - 1`. + bytes32 internal constant _EIP7702_PROXY_DELEGATION_INITIALIZATION_REQUEST_SLOT = + 0x94e11c6e41e7fb92cb8bb65e13fdfbd4eba8b831292a1a220f7915c78c7c078f; + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* AUTHORITY OPERATIONS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /// @dev Returns the delegation of the account. + /// If the account is not an EIP7702 authority, the `delegation` will be `address(0)`. + function delegation(address account) internal view returns (address result) { + /// @solidity memory-safe-assembly + assembly { + extcodecopy(account, 0x00, 0x00, 0x20) + // Note: Checking that it starts with hex"ef01" is the most general and futureproof. + // 7702 bytecode is `abi.encodePacked(hex"ef01", uint8(version), address(delegation))`. + result := mul(shr(96, mload(0x03)), eq(0xef01, shr(240, mload(0x00)))) + } + } + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* PROXY OPERATIONS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /// @dev Returns the implementation of the proxy. + /// Assumes that the proxy is a proper EIP7702Proxy, if it exists. + function proxyImplementation(address proxy) internal view returns (address result) { + /// @solidity memory-safe-assembly + assembly { + // Although `implementation()` is supported, we'll use a less common + // function selector to avoid accidental collision with other delegations. + mstore(0x00, 0x7dae87cb) // `eip7702ProxyImplementation()`. + let t := staticcall(gas(), proxy, 0x1c, 0x04, 0x00, 0x20) + if iszero(and(gt(returndatasize(), 0x1f), t)) { + mstore(0x00, 0x26ec9b6a) // `ProxyQueryFailed()`. + revert(0x1c, 0x04) + } + result := mload(0x00) + } + } + + /// @dev Returns the admin of the proxy. + /// Assumes that the proxy is a proper EIP7702Proxy, if it exists. + function proxyAdmin(address proxy) internal view returns (address result) { + /// @solidity memory-safe-assembly + assembly { + mstore(0x00, 0xf851a440) // `admin()`. + let t := staticcall(gas(), proxy, 0x1c, 0x04, 0x00, 0x20) + if iszero(and(gt(returndatasize(), 0x1f), t)) { + mstore(0x00, 0x26ec9b6a) // `ProxyQueryFailed()`. + revert(0x1c, 0x04) + } + result := mload(0x00) + } + } + + /// @dev Changes the admin on the proxy. The caller must be the admin. + /// Assumes that the proxy is a proper EIP7702Proxy, if it exists. + function changeProxyAdmin(address proxy, address newAdmin) internal { + /// @solidity memory-safe-assembly + assembly { + mstore(0x00, 0x8f283970) // `changeAdmin(address)`. + mstore(0x20, shr(96, shl(96, newAdmin))) + if iszero(and(eq(mload(0x00), 1), call(gas(), proxy, 0, 0x1c, 0x24, 0x00, 0x20))) { + mstore(0x00, 0xc502e37e) // `ChangeProxyAdminFailed()`. + revert(0x1c, 0x04) + } + } + } + + /// @dev Changes the implementation on the proxy. The caller must be the admin. + /// Assumes that the proxy is a proper EIP7702Proxy, if it exists. + function upgradeProxy(address proxy, address newImplementation) internal { + /// @solidity memory-safe-assembly + assembly { + mstore(0x00, 0x0900f010) // `upgrade(address)`. + mstore(0x20, shr(96, shl(96, newImplementation))) + if iszero(and(eq(mload(0x00), 1), call(gas(), proxy, 0, 0x1c, 0x24, 0x00, 0x20))) { + mstore(0x00, 0xc6edd882) // `UpgradeProxyFailed()`. + revert(0x1c, 0x04) + } + } + } + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* PROXY DELEGATION OPERATIONS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /// @dev Upgrades the implementation. + /// The new implementation will NOT be active until the next UserOp or transaction. + /// To "auto-upgrade" to the latest implementation on the proxy, pass in `address(0)` to reset + /// the implementation slot. This causes the proxy to use the latest default implementation, + /// which may be optionally reinitialized via `requestProxyDelegationInitialization()`. + /// This function is intended to be used on the authority of an EIP7702Proxy delegation. + /// The most intended usage pattern is to wrap this in an access-gated admin function. + function upgradeProxyDelegation(address newImplementation) internal { + /// @solidity memory-safe-assembly + assembly { + let s := ERC1967_IMPLEMENTATION_SLOT + mstore(0x00, sload(s)) + mstore(0x0c, shl(96, newImplementation)) + sstore(s, mload(0x00)) + } + } + + /// @dev Requests the implementation to be initialized to the latest implementation on the proxy. + /// This function is intended to be used on the authority of an EIP7702Proxy delegation. + /// The most intended usage pattern is to place it at the end of an `execute` function. + function requestProxyDelegationInitialization() internal { + /// @solidity memory-safe-assembly + assembly { + if iszero(shl(96, sload(ERC1967_IMPLEMENTATION_SLOT))) { + // Use a dedicated transient storage slot for better Swiss-cheese-model safety. + tstore(_EIP7702_PROXY_DELEGATION_INITIALIZATION_REQUEST_SLOT, address()) + } + } + } +} diff --git a/test/EIP7702Proxy.t.sol b/test/EIP7702Proxy.t.sol index e4d6624c0..25113822f 100644 --- a/test/EIP7702Proxy.t.sol +++ b/test/EIP7702Proxy.t.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.4; import "./utils/SoladyTest.sol"; import {EIP7702Proxy} from "../src/accounts/EIP7702Proxy.sol"; +import {LibEIP7702} from "../src/accounts/LibEIP7702.sol"; interface IEIP7702ProxyWithAdminABI { function implementation() external view returns (address); @@ -12,6 +13,19 @@ interface IEIP7702ProxyWithAdminABI { function bad() external; } +contract Implementation2 { + uint256 public value; + + function version() external pure returns (uint256) { + return 2; + } + + function setValue(uint256 value_) public { + value = value_; + LibEIP7702.requestProxyDelegationInitialization(); + } +} + contract EIP7702ProxyTest is SoladyTest { error CustomError(uint256 currentValue); @@ -25,15 +39,26 @@ contract EIP7702ProxyTest is SoladyTest { function setValue(uint256 value_) public { value = value_; + LibEIP7702.requestProxyDelegationInitialization(); } function revertWithError() public view { revert CustomError(value); } + function version() external pure returns (uint256) { + return 1; + } + + function unsetProxyDelegation() public { + LibEIP7702.upgradeProxyDelegation(address(0)); + } + function _checkBehavesLikeProxy(address instance) internal { assertTrue(instance != address(0)); + assertEq(EIP7702ProxyTest(instance).version(), 1); + uint256 v = _random(); uint256 thisValue = this.value(); if (thisValue == v) { @@ -41,9 +66,10 @@ contract EIP7702ProxyTest is SoladyTest { } EIP7702ProxyTest(instance).setValue(v); assertEq(v, EIP7702ProxyTest(instance).value()); - // assertEq(thisValue, this.value()); - // vm.expectRevert(abi.encodeWithSelector(CustomError.selector, v)); - // EIP7702ProxyTest(instance).revertWithError(); + + assertEq(thisValue, this.value()); + vm.expectRevert(abi.encodeWithSelector(CustomError.selector, v)); + EIP7702ProxyTest(instance).revertWithError(); } function testEIP7702Proxy(bytes32, bool f) public { @@ -53,16 +79,21 @@ contract EIP7702ProxyTest is SoladyTest { IEIP7702ProxyWithAdminABI eip7702Proxy = IEIP7702ProxyWithAdminABI(address(new EIP7702Proxy(address(this), admin))); assertEq(eip7702Proxy.admin(), admin); + assertEq(LibEIP7702.proxyAdmin(address(eip7702Proxy)), admin); assertEq(eip7702Proxy.implementation(), address(this)); + assertEq(LibEIP7702.proxyImplementation(address(eip7702Proxy)), address(this)); if (!f && _randomChance(16)) { address newAdmin = _randomUniqueHashedAddress(); vm.startPrank(admin); - eip7702Proxy.changeAdmin(newAdmin); + if (_randomChance(2)) { + eip7702Proxy.changeAdmin(newAdmin); + } else { + LibEIP7702.changeProxyAdmin(address(eip7702Proxy), newAdmin); + } assertEq(eip7702Proxy.admin(), newAdmin); vm.stopPrank(); admin = newAdmin; - vm.startPrank(_randomUniqueHashedAddress()); vm.expectRevert(); eip7702Proxy.changeAdmin(newAdmin); @@ -72,7 +103,11 @@ contract EIP7702ProxyTest is SoladyTest { if (!f && _randomChance(16)) { address newImplementation = _randomUniqueHashedAddress(); vm.startPrank(admin); - eip7702Proxy.upgrade(newImplementation); + if (_randomChance(2)) { + eip7702Proxy.upgrade(newImplementation); + } else { + LibEIP7702.upgradeProxy(address(eip7702Proxy), newImplementation); + } assertEq(eip7702Proxy.implementation(), newImplementation); eip7702Proxy.upgrade(address(this)); assertEq(eip7702Proxy.implementation(), address(this)); @@ -86,9 +121,23 @@ contract EIP7702ProxyTest is SoladyTest { vm.stopPrank(); } + uint256 r = (_random() >> 160) << 160; + vm.store(address(this), _ERC1967_IMPLEMENTATION_SLOT, bytes32(r)); + + if (!f && _randomChance(16)) { + address newImplementation = _randomUniqueHashedAddress(); + LibEIP7702.upgradeProxyDelegation(newImplementation); + uint256 loaded = uint256(vm.load(address(this), _ERC1967_IMPLEMENTATION_SLOT)); + assertEq(address(uint160(loaded)), newImplementation); + assertEq(loaded >> 160, r >> 160); + } + address authority = _randomUniqueHashedAddress(); + assertEq(LibEIP7702.delegation(authority), address(0)); vm.etch(authority, abi.encodePacked(hex"ef0100", address(eip7702Proxy))); + vm.store(authority, _ERC1967_IMPLEMENTATION_SLOT, bytes32(r)); + emit LogAddress("authority", authority); emit LogAddress("proxy", address(eip7702Proxy)); emit LogAddress("address(this)", address(this)); @@ -101,7 +150,40 @@ contract EIP7702ProxyTest is SoladyTest { // but we give our heuristic some leeway. if (authority.code.length > 0x20) return; + if (!f) assertEq(LibEIP7702.delegation(authority), address(eip7702Proxy)); + + _checkBehavesLikeProxy(authority); + + vm.pauseGasMetering(); + + // Check that upgrading the proxy won't cause the authority's implementation to change. + if (!f && _randomChance(2)) { + vm.startPrank(admin); + eip7702Proxy.upgrade(address(1)); + } + _checkBehavesLikeProxy(authority); + + if (!f && _randomChance(2) && (r >> 160) > 0) { + vm.startPrank(admin); + eip7702Proxy.upgrade(address(new Implementation2())); + vm.stopPrank(); + EIP7702ProxyTest(authority).unsetProxyDelegation(); + assertEq(Implementation2(authority).version(), 2); + + uint256 loaded = uint256(vm.load(authority, _ERC1967_IMPLEMENTATION_SLOT)); + assertEq(address(uint160(loaded)), address(0)); + assertEq(loaded >> 160, r >> 160); + + EIP7702ProxyTest(authority).setValue(123); + assertEq(Implementation2(authority).version(), 2); + + loaded = uint256(vm.load(authority, _ERC1967_IMPLEMENTATION_SLOT)); + assertEq(address(uint160(loaded)), eip7702Proxy.implementation()); + assertEq(loaded >> 160, r >> 160); + } + + vm.resumeGasMetering(); } function testEIP7702Proxy() public {