├── audits ├── Cantina-March-2025.pdf ├── Cantina-February-2025.pdf └── Cantina-Competition-April-2025.pdf ├── remappings.txt ├── foundry.toml ├── .gitignore ├── test ├── mocks │ ├── MockERC20.sol │ ├── MockERC721.sol │ ├── MockERC1155.sol │ ├── MockInvalidValidator.sol │ ├── MockRevertingValidator.sol │ ├── MockMaliciousImplementation.sol │ ├── MockValidator.sol │ └── MockImplementation.sol ├── EIP7702Proxy │ ├── IAccountStateValidator.t.sol │ ├── constructor.t.sol │ ├── upgradeToAndCall.t.sol │ ├── TokenReceive.t.sol │ ├── delegate.t.sol │ ├── coinbaseImplementation.t.sol │ ├── isValidSignature.t.sol │ └── setImplementation.t.sol ├── NonceTracker.t.sol ├── base │ └── EIP7702ProxyBase.sol └── CoinbaseSmartWalletValidator.t.sol ├── .env.example ├── .gitmodules ├── src ├── DefaultReceiver.sol ├── NonceTracker.sol ├── interfaces │ └── IAccountStateValidator.sol ├── validators │ └── CoinbaseSmartWalletValidator.sol └── EIP7702Proxy.sol ├── LICENSE └── README.md /audits/Cantina-March-2025.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/base/eip-7702-proxy/HEAD/audits/Cantina-March-2025.pdf -------------------------------------------------------------------------------- /audits/Cantina-February-2025.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/base/eip-7702-proxy/HEAD/audits/Cantina-February-2025.pdf -------------------------------------------------------------------------------- /audits/Cantina-Competition-April-2025.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/base/eip-7702-proxy/HEAD/audits/Cantina-Competition-April-2025.pdf -------------------------------------------------------------------------------- /remappings.txt: -------------------------------------------------------------------------------- 1 | smart-wallet/=lib/smart-wallet/src/ 2 | forge-std/=lib/forge-std/src/ 3 | openzeppelin-contracts/=lib/openzeppelin-contracts/ 4 | solady/=lib/solady/src/ -------------------------------------------------------------------------------- /foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | src = "src" 3 | out = "out" 4 | libs = ["lib"] 5 | via_ir = true 6 | 7 | # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiler files 2 | cache/ 3 | out/ 4 | 5 | # Ignores development broadcast logs 6 | /broadcast/ 7 | /broadcast/*/31337/ 8 | /broadcast/**/dry-run/ 9 | 10 | # Docs 11 | docs/ 12 | 13 | # Dotenv file 14 | .env 15 | 16 | # Dependency directory 17 | lib/ 18 | # Keep track of .gitmodules 19 | !lib/.gitmodules 20 | 21 | -------------------------------------------------------------------------------- /test/mocks/MockERC20.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.23; 3 | 4 | import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 5 | 6 | contract MockERC20 is ERC20 { 7 | constructor() ERC20("Mock Token", "MOCK") {} 8 | 9 | function mint(address to, uint256 amount) external { 10 | _mint(to, amount); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/mocks/MockERC721.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.23; 3 | 4 | import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; 5 | 6 | contract MockERC721 is ERC721 { 7 | constructor() ERC721("Mock NFT", "MOCK") {} 8 | 9 | function mint(address to, uint256 tokenId) external { 10 | _mint(to, tokenId); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/mocks/MockERC1155.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.23; 3 | 4 | import {ERC1155} from "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; 5 | 6 | contract MockERC1155 is ERC1155 { 7 | constructor() ERC1155("") {} 8 | 9 | function mint(address to, uint256 id, uint256 amount, bytes memory data) external { 10 | _mint(to, id, amount, data); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Private key of the EOA to be upgraded to a smart contract wallet 2 | EOA_PRIVATE_KEY= 3 | 4 | # Private key of the account that will deploy contracts and perform the upgrade 5 | DEPLOYER_PRIVATE_KEY= 6 | 7 | # Private key of the new owner to be added to the smart wallet 8 | NEW_OWNER_PRIVATE_KEY= 9 | 10 | # Address of the deployed proxy template on Odyssey (output from UpgradeEOA.s.sol) 11 | PROXY_TEMPLATE_ADDRESS_ODYSSEY= 12 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/forge-std"] 2 | path = lib/forge-std 3 | url = https://github.com/foundry-rs/forge-std 4 | [submodule "lib/openzeppelin-contracts"] 5 | path = lib/openzeppelin-contracts 6 | url = https://github.com/OpenZeppelin/openzeppelin-contracts 7 | [submodule "lib/smart-wallet"] 8 | path = lib/smart-wallet 9 | url = https://github.com/coinbase/smart-wallet 10 | [submodule "lib/solady"] 11 | path = lib/solady 12 | url = https://github.com/vectorized/solady 13 | -------------------------------------------------------------------------------- /test/EIP7702Proxy/IAccountStateValidator.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.23; 3 | 4 | import {ACCOUNT_STATE_VALIDATION_SUCCESS} from "../../src/interfaces/IAccountStateValidator.sol"; 5 | import {Test} from "forge-std/Test.sol"; 6 | 7 | contract IAccountStateValidatorTest is Test { 8 | function testMagicValue() public { 9 | assertEq(ACCOUNT_STATE_VALIDATION_SUCCESS, bytes4(keccak256("validateAccountState(address,address)"))); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test/mocks/MockInvalidValidator.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.23; 3 | 4 | import {IAccountStateValidator} from "../../src/interfaces/IAccountStateValidator.sol"; 5 | 6 | /// @title MockInvalidValidator 7 | /// @dev Mock validator that returns an invalid magic value for testing 8 | contract MockInvalidValidator is IAccountStateValidator { 9 | function validateAccountState(address, address) external view returns (bytes4) { 10 | return bytes4(keccak256("invalid()")); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/mocks/MockRevertingValidator.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.23; 3 | 4 | import {IAccountStateValidator} from "../../src/interfaces/IAccountStateValidator.sol"; 5 | 6 | /// @title MockRevertingValidator 7 | /// @dev Mock validator that always reverts 8 | contract MockRevertingValidator is IAccountStateValidator { 9 | error InvalidValidation(); 10 | 11 | function validateAccountState(address, address) external pure returns (bytes4) { 12 | revert InvalidValidation(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/DefaultReceiver.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.23; 3 | 4 | import {Receiver} from "solady/accounts/Receiver.sol"; 5 | 6 | /// @title DefaultReceiver 7 | /// 8 | /// @notice Accepts native, ERC-721, and ERC-1155 token transfers 9 | /// 10 | /// @dev Simply inherits Solady abstract Receiver, providing all necessary functionality: 11 | /// - receive() for native token 12 | /// - fallback() with receiverFallback modifier for ERC-721 and ERC-1155 13 | /// - _useReceiverFallbackBody() returns true 14 | /// - _beforeReceiverFallbackBody() empty implementation 15 | /// - _afterReceiverFallbackBody() empty implementation 16 | contract DefaultReceiver is Receiver {} 17 | -------------------------------------------------------------------------------- /test/mocks/MockMaliciousImplementation.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.23; 3 | 4 | import {MockImplementation} from "./MockImplementation.sol"; 5 | 6 | import {ERC1967Utils} from "openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Utils.sol"; 7 | 8 | /// @dev Mock implementation that tries to change its own implementation during initialization 9 | contract MockMaliciousImplementation is MockImplementation { 10 | address public immutable targetImplementation; 11 | 12 | constructor(address _targetImplementation) { 13 | targetImplementation = _targetImplementation; 14 | } 15 | 16 | function initialize(address _owner) public override { 17 | // First do normal initialization 18 | super.initialize(_owner); 19 | 20 | // Then try to change implementation to something else 21 | ERC1967Utils.upgradeToAndCall(targetImplementation, ""); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /test/EIP7702Proxy/constructor.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.23; 3 | 4 | import {EIP7702Proxy} from "../../src/EIP7702Proxy.sol"; 5 | import {NonceTracker} from "../../src/NonceTracker.sol"; 6 | import {DefaultReceiver} from "../../src/DefaultReceiver.sol"; 7 | 8 | import {EIP7702ProxyBase} from "../base/EIP7702ProxyBase.sol"; 9 | 10 | contract ConstructorTest is EIP7702ProxyBase { 11 | function test_succeeds_whenAllArgumentsValid() public { 12 | new EIP7702Proxy(address(_nonceTracker), address(_receiver)); 13 | } 14 | 15 | function test_reverts_whenNonceTrackerAddressZero() public { 16 | vm.expectRevert(EIP7702Proxy.ZeroAddress.selector); 17 | new EIP7702Proxy(address(0), address(_receiver)); 18 | } 19 | 20 | function test_reverts_whenReceiverAddressZero() public { 21 | vm.expectRevert(EIP7702Proxy.ZeroAddress.selector); 22 | new EIP7702Proxy(address(_nonceTracker), address(0)); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/NonceTracker.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.23; 3 | 4 | /// @title NonceTracker 5 | /// 6 | /// @notice A singleton contract for EIP-7702 accounts to manage nonces for ERC-1967 implementation overrides 7 | /// 8 | /// @dev Separating nonce storage from EIP-7702 accounts mitigates other arbitrary delegates from unexpectedly reversing state 9 | /// 10 | /// @author Coinbase (https://github.com/base/eip-7702-proxy) 11 | contract NonceTracker { 12 | /// @notice Track nonces per-account to mitigate signature replayability 13 | mapping(address account => uint256 nonce) public nonces; 14 | 15 | /// @notice An account's nonce has been used 16 | event NonceUsed(address indexed account, uint256 nonce); 17 | 18 | /// @notice Consume a nonce for the caller 19 | /// 20 | /// @return nonce The nonce just used 21 | function useNonce() external returns (uint256 nonce) { 22 | nonce = nonces[msg.sender]++; 23 | emit NonceUsed(msg.sender, nonce); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Coinbase 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /src/interfaces/IAccountStateValidator.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.23; 3 | 4 | /// @dev Magic value returned by validateAccountState on success 5 | bytes4 constant ACCOUNT_STATE_VALIDATION_SUCCESS = 0xccd10cc8; // bytes4(keccak256("validateAccountState(address,address)")) 6 | 7 | /// @title IAccountStateValidator 8 | /// @notice Interface for account-specific validation logi 9 | /// @dev This interface is used to validate the state of a account after an upgrade 10 | /// @author Coinbase (https://github.com/base/eip-7702-proxy) 11 | interface IAccountStateValidator { 12 | /// @notice Error thrown when the implementation provided to `validateAccountState` is not a supported implementation 13 | error InvalidImplementation(address actual); 14 | 15 | /// @notice Validates that an account is in a valid state 16 | /// @dev Should return ACCOUNT_STATE_VALIDATION_SUCCESS if account state is valid, otherwise revert 17 | /// @dev Should validate that the provided implementation is a supported implementation for this validator 18 | /// @param account The address of the account to validate 19 | /// @param implementation The address of the implementation being validated 20 | /// @return The magic value ACCOUNT_STATE_VALIDATION_SUCCESS if validation succeeds 21 | function validateAccountState(address account, address implementation) external view returns (bytes4); 22 | } 23 | -------------------------------------------------------------------------------- /test/mocks/MockValidator.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.23; 3 | 4 | import { 5 | IAccountStateValidator, ACCOUNT_STATE_VALIDATION_SUCCESS 6 | } from "../../src/interfaces/IAccountStateValidator.sol"; 7 | import {MockImplementation} from "./MockImplementation.sol"; 8 | 9 | /** 10 | * @title MockValidator 11 | * @dev Mock validator that checks if the MockImplementation wallet is initialized 12 | */ 13 | contract MockValidator is IAccountStateValidator { 14 | error WalletNotInitialized(); 15 | 16 | MockImplementation public immutable expectedImplementation; 17 | 18 | constructor(MockImplementation _expectedImplementation) { 19 | expectedImplementation = _expectedImplementation; 20 | } 21 | 22 | /** 23 | * @dev Validates that the wallet is initialized 24 | * @param wallet Address of the wallet to validate 25 | * @param implementation Address of the expected implementation 26 | */ 27 | function validateAccountState(address wallet, address implementation) external view returns (bytes4) { 28 | if (implementation != address(expectedImplementation)) { 29 | revert InvalidImplementation(implementation); 30 | } 31 | 32 | bool isInitialized = MockImplementation(wallet).initialized(); 33 | if (!isInitialized) revert WalletNotInitialized(); 34 | return ACCOUNT_STATE_VALIDATION_SUCCESS; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/validators/CoinbaseSmartWalletValidator.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.23; 3 | 4 | import {MultiOwnable} from "smart-wallet/MultiOwnable.sol"; 5 | import {CoinbaseSmartWallet} from "smart-wallet/CoinbaseSmartWallet.sol"; 6 | import {IAccountStateValidator, ACCOUNT_STATE_VALIDATION_SUCCESS} from "../interfaces/IAccountStateValidator.sol"; 7 | 8 | /// @title CoinbaseSmartWalletValidator 9 | /// 10 | /// @notice Validates account state against invariants specific to CoinbaseSmartWallet 11 | contract CoinbaseSmartWalletValidator is IAccountStateValidator { 12 | /// @notice Error thrown when an account's nextOwnerIndex is 0 13 | error Unintialized(); 14 | 15 | /// @notice The implementation of the CoinbaseSmartWallet this validator expects 16 | CoinbaseSmartWallet internal immutable _supportedImplementation; 17 | 18 | constructor(CoinbaseSmartWallet supportedImplementation) { 19 | _supportedImplementation = supportedImplementation; 20 | } 21 | 22 | /// @inheritdoc IAccountStateValidator 23 | /// 24 | /// @dev Mimics the exact logic used in `CoinbaseSmartWallet.initialize` for consistency 25 | function validateAccountState(address account, address implementation) external view override returns (bytes4) { 26 | if (implementation != address(_supportedImplementation)) { 27 | revert InvalidImplementation(implementation); 28 | } 29 | if (MultiOwnable(account).nextOwnerIndex() == 0) revert Unintialized(); 30 | return ACCOUNT_STATE_VALIDATION_SUCCESS; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/NonceTracker.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.23; 3 | 4 | import {Test} from "forge-std/Test.sol"; 5 | import {NonceTracker} from "../src/NonceTracker.sol"; 6 | 7 | contract NonceTrackerTest is Test { 8 | NonceTracker public nonceTracker; 9 | address public account; 10 | uint256 constant ACCOUNT_PK = 0xA11CE; 11 | 12 | event NonceUsed(address indexed account, uint256 nonce); 13 | 14 | function setUp() public { 15 | nonceTracker = new NonceTracker(); 16 | account = vm.addr(ACCOUNT_PK); 17 | } 18 | 19 | function test_nonces_initialNonceIsZero() public view { 20 | assertEq(nonceTracker.nonces(account), 0, "Initial nonce should be zero"); 21 | } 22 | 23 | function test_useNonce_incrementsNonce_afterVerification() public { 24 | uint256 nonce = nonceTracker.nonces(account); 25 | 26 | vm.prank(account); 27 | nonceTracker.useNonce(); 28 | assertEq(nonceTracker.nonces(account), nonce + 1, "Nonce should increment after use"); 29 | } 30 | 31 | function test_useNonce_emitsEvent_whenNonceUsed() public { 32 | uint256 nonce = nonceTracker.nonces(account); 33 | 34 | vm.expectEmit(true, false, false, true); 35 | emit NonceUsed(account, nonce); 36 | vm.prank(account); 37 | nonceTracker.useNonce(); 38 | } 39 | 40 | function test_nonces_maintainsCorrectNonce_afterMultipleIncrements(uint8 incrementCount) public { 41 | uint256 expectedNonce = 0; 42 | 43 | for (uint256 i = 0; i < incrementCount; i++) { 44 | assertEq(nonceTracker.nonces(account), expectedNonce, "Incorrect nonce before increment"); 45 | 46 | vm.prank(account); 47 | nonceTracker.useNonce(); 48 | 49 | expectedNonce++; 50 | } 51 | 52 | assertEq(nonceTracker.nonces(account), expectedNonce, "Final nonce incorrect"); 53 | } 54 | 55 | function test_nonces_tracksNoncesIndependently_forDifferentAccounts(address otherAccount) public { 56 | vm.assume(otherAccount != account); 57 | 58 | // Use account's nonce 59 | vm.prank(account); 60 | nonceTracker.useNonce(); 61 | 62 | // Other account's nonce should still be 0 63 | assertEq(nonceTracker.nonces(otherAccount), 0, "Other account's nonce should be independent"); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /test/EIP7702Proxy/upgradeToAndCall.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.23; 3 | 4 | import {EIP7702Proxy} from "../../src/EIP7702Proxy.sol"; 5 | 6 | import {IERC1967} from "openzeppelin-contracts/contracts/interfaces/IERC1967.sol"; 7 | 8 | import {EIP7702ProxyBase} from "../base/EIP7702ProxyBase.sol"; 9 | import {MockImplementation} from "../mocks/MockImplementation.sol"; 10 | 11 | /** 12 | * @title UpgradeToAndCallTest 13 | * @dev Tests ERC-1967 upgradeability functionality of EIP7702Proxy 14 | */ 15 | contract UpgradeToAndCallTest is EIP7702ProxyBase { 16 | MockImplementation newImplementation; 17 | 18 | function setUp() public override { 19 | super.setUp(); 20 | _initializeProxy(); 21 | newImplementation = new MockImplementation(); 22 | } 23 | 24 | function test_succeeds_withValidOwnerAndImplementation() public { 25 | address oldImpl = _getERC1967Implementation(address(_eoa)); 26 | 27 | vm.prank(_newOwner); 28 | 29 | // Expect the Upgraded event 30 | vm.expectEmit(true, false, false, false, address(_eoa)); 31 | emit IERC1967.Upgraded(address(newImplementation)); 32 | 33 | MockImplementation(payable(_eoa)).upgradeToAndCall( 34 | address(newImplementation), abi.encodeWithSelector(MockImplementation.mockFunction.selector) 35 | ); 36 | 37 | // Verify implementation was upgraded 38 | address newImpl = _getERC1967Implementation(address(_eoa)); 39 | assertNotEq(newImpl, oldImpl, "Implementation should have changed"); 40 | assertEq(newImpl, address(newImplementation), "Implementation should be set to new address"); 41 | } 42 | 43 | function test_emitsUpgradedEvent_afterSuccess() public { 44 | vm.prank(_newOwner); 45 | 46 | vm.expectEmit(true, false, false, false, address(_eoa)); 47 | emit IERC1967.Upgraded(address(newImplementation)); 48 | 49 | MockImplementation(payable(_eoa)).upgradeToAndCall(address(newImplementation), ""); 50 | } 51 | 52 | function test_reverts_whenCalledByNonOwner(address nonOwner) public { 53 | vm.assume(nonOwner != address(0)); 54 | vm.assume(nonOwner != _newOwner); 55 | assumeNotPrecompile(nonOwner); 56 | 57 | vm.prank(nonOwner); 58 | vm.expectRevert(MockImplementation.Unauthorized.selector); // From MockImplementation 59 | MockImplementation(payable(_eoa)).upgradeToAndCall(address(newImplementation), ""); 60 | 61 | // Verify implementation was not changed 62 | assertEq( 63 | _getERC1967Implementation(address(_eoa)), 64 | address(_implementation), 65 | "Implementation should not change on failed upgrade" 66 | ); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /test/EIP7702Proxy/TokenReceive.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.23; 3 | 4 | import {EIP7702Proxy} from "../../src/EIP7702Proxy.sol"; 5 | 6 | import {MockERC721} from "../mocks/MockERC721.sol"; 7 | import {MockERC1155} from "../mocks/MockERC1155.sol"; 8 | import {MockERC20} from "../mocks/MockERC20.sol"; 9 | import {EIP7702ProxyBase} from "../base/EIP7702ProxyBase.sol"; 10 | 11 | contract TokenReceiveTest is EIP7702ProxyBase { 12 | MockERC721 public nft; 13 | MockERC1155 public multiToken; 14 | MockERC20 public token; 15 | uint256 constant TOKEN_ID = 1; 16 | uint256 constant AMOUNT = 1; 17 | uint256 constant TOKEN_AMOUNT = 1 ether; 18 | 19 | function setUp() public override { 20 | super.setUp(); 21 | nft = new MockERC721(); 22 | multiToken = new MockERC1155(); 23 | token = new MockERC20(); 24 | } 25 | 26 | function test_succeeds_ERC721Transfer_afterInitialization() public { 27 | nft.mint(address(this), TOKEN_ID); 28 | nft.safeTransferFrom(address(this), _eoa, TOKEN_ID); 29 | assertEq(nft.ownerOf(TOKEN_ID), _eoa); 30 | } 31 | 32 | function test_succeeds_ERC1155Transfer_afterInitialization() public { 33 | address regularAddress = makeAddr("regularHolder"); 34 | multiToken.mint(regularAddress, TOKEN_ID, AMOUNT, ""); 35 | 36 | vm.prank(regularAddress); 37 | multiToken.safeTransferFrom(regularAddress, _eoa, TOKEN_ID, AMOUNT, ""); 38 | assertEq(multiToken.balanceOf(_eoa, TOKEN_ID), AMOUNT); 39 | } 40 | 41 | function test_succeeds_ERC20Transfer_afterInitialization() public { 42 | token.mint(address(this), TOKEN_AMOUNT); 43 | 44 | token.transfer(_eoa, TOKEN_AMOUNT); 45 | assertEq(token.balanceOf(_eoa), TOKEN_AMOUNT); 46 | } 47 | 48 | function test_succeeds_ERC721Transfer_beforeInitialization() public { 49 | // Deploy proxy without initializing 50 | address payable uninitProxy = payable(makeAddr("uninitProxy")); 51 | _deployProxy(uninitProxy); 52 | 53 | nft.mint(address(this), TOKEN_ID); 54 | nft.safeTransferFrom(address(this), uninitProxy, TOKEN_ID); 55 | assertEq(nft.ownerOf(TOKEN_ID), uninitProxy); 56 | } 57 | 58 | function test_succeeds_ERC1155Transfer_beforeInitialization() public { 59 | // Deploy proxy without initializing 60 | address payable uninitProxy = payable(makeAddr("uninitProxy")); 61 | _deployProxy(uninitProxy); 62 | 63 | address regularAddress = makeAddr("regularHolder"); 64 | multiToken.mint(regularAddress, TOKEN_ID, AMOUNT, ""); 65 | 66 | vm.prank(regularAddress); 67 | multiToken.safeTransferFrom(regularAddress, uninitProxy, TOKEN_ID, AMOUNT, ""); 68 | assertEq(multiToken.balanceOf(uninitProxy, TOKEN_ID), AMOUNT); 69 | } 70 | 71 | function test_succeeds_ERC20Transfer_beforeInitialization() public { 72 | // Deploy proxy without initializing 73 | address payable uninitProxy = payable(makeAddr("uninitProxy")); 74 | _deployProxy(uninitProxy); 75 | 76 | token.mint(address(this), TOKEN_AMOUNT); 77 | token.transfer(uninitProxy, TOKEN_AMOUNT); 78 | assertEq(token.balanceOf(uninitProxy), TOKEN_AMOUNT); 79 | } 80 | 81 | function test_succeeds_ETHTransfer_afterInitialization() public { 82 | // Fund test contract 83 | vm.deal(address(this), 1 ether); 84 | 85 | // Send ETH to initialized wallet 86 | (bool success,) = _eoa.call{value: 1 ether}(""); 87 | assertTrue(success); 88 | assertEq(_eoa.balance, 1 ether); 89 | } 90 | 91 | function test_succeeds_ETHTransfer_beforeInitialization() public { 92 | // Deploy proxy without initializing 93 | address payable uninitProxy = payable(makeAddr("uninitProxy")); 94 | _deployProxy(uninitProxy); 95 | 96 | // Fund test contract 97 | vm.deal(address(this), 1 ether); 98 | 99 | // Send ETH to uninitialized wallet 100 | (bool success,) = uninitProxy.call{value: 1 ether}(""); 101 | assertTrue(success); 102 | assertEq(uninitProxy.balance, 1 ether); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EIP-7702 Proxy 2 | 3 | A secure ERC-1967 proxy implementation for EIP-7702 smart accounts. 4 | 5 | ## Overview 6 | 7 | The EIP-7702 Proxy provides a secure way to upgrade EOAs to smart contract wallets through EIP-7702 delegation. It solves critical security challenges in the EIP-7702 design space while allowing the use of existing smart account implementations. 8 | 9 | ## Key Features 10 | 11 | ### 🔒 Secure Initialization 12 | - Signature-based authorization from the EOA for initial implementation setting and initialization 13 | - Atomic implementation setting + initialization to prevent front-running 14 | - Account state validation through implementation-specific configurable validator 15 | - Reliable protection against signature replay through external nonce tracking 16 | 17 | ### 💾 Storage Management 18 | - ERC-1967 compliant implementation storage 19 | - Ability to set the ERC-1967 storage slot via the proxy itself 20 | - Built-in token receiver for uninitialized state 21 | - Safe handling of A → B → A delegation patterns 22 | 23 | ### 🔄 Upgradeability 24 | - Implementation-agnostic design 25 | - Compatible with any ERC-1967 implementation 26 | 27 | ## Deployment Addresses 28 | 29 | ### Deployed on all [networks](https://docs.base.org/smart-wallet/concepts/features/built-in/networks) supported by Coinbase Smart Wallet 30 | 31 | | Contract | Address | 32 | |----------|---------| 33 | | EIP7702Proxy | [`0x7702cb554e6bFb442cb743A7dF23154544a7176C`](https://basescan.org/address/0x7702cb554e6bFb442cb743A7dF23154544a7176C#code)| 34 | | NonceTracker | [`0xD0Ff13c28679FDd75Bc09c0a430a0089bf8b95a8`](https://basescan.org/address/0xD0Ff13c28679FDd75Bc09c0a430a0089bf8b95a8#code)| 35 | | DefaultReceiver | [`0x2a8010A9D71D2a5AEA19D040F8b4797789A194a9`](https://basescan.org/address/0x2a8010A9D71D2a5AEA19D040F8b4797789A194a9#code)| 36 | | CoinbaseSmartWalletValidator | [`0x79A33f950b90C7d07E66950daedf868BD0cDcF96`](https://basescan.org/address/0x79A33f950b90C7d07E66950daedf868BD0cDcF96#code)| 37 | 38 | ## Core Components 39 | 40 | ### EIP7702Proxy 41 | - Manages safe implementation upgrades through `setImplementation` 42 | - Validates EOA signatures for all state changes 43 | - Provides fallback to `DefaultReceiver` when uninitialized 44 | - Overrides `isValidSignature` to provide a final fallback `ecrecover` check 45 | 46 | ### NonceTracker 47 | - External nonce management for signature validation in storage-safe location 48 | - Prevents signature replay attacks 49 | - Maintains nonce integrity across delegations 50 | 51 | ### IAccountStateValidator 52 | - Interface for implementation-specific state validation 53 | - Called to ensure correct initialization or other account state 54 | - Reverts invalid state transitions 55 | 56 | ### DefaultReceiver 57 | - Inherits from Solady's `Receiver` 58 | - Provides a default implementation for token compatibility 59 | 60 | ## Usage 61 | 62 | 1. Deploy singleton instance of `EIP7702Proxy` with immutable parameters: 63 | - `NonceTracker` for signature security 64 | - `DefaultReceiver` for token compatibility 65 | 66 | 2. Sign an EIP-7702 authorization with the EOA to delegate to the `EIP7702Proxy` 67 | 3. Sign a payload for `setImplementation` with the EOA, which includes the new implementation address, initialization calldata, and the address of an account state validator 68 | 4. Submit transaction with EIP-7702 authorization and call to `setImplementation(bytes args, bytes signature)` with: 69 | - `address newImplementation`: address of the new implementation 70 | - `bytes calldata callData`: initialization calldata 71 | - `address validator`: address of the account state validator 72 | - `bytes calldata signature`: ECDSA signature over the initialization hash from the EOA 73 | - `bool allowCrossChainReplay`: whether to allow cross-chain replay 74 | 75 | Now the EOA has been upgraded to the smart account implementation and had its state initialized. 76 | 77 | If the smart account implementation supports UUPS upgradeability, it will work as designed by submitting upgrade calls to the account. 78 | 79 | ## Security 80 | 81 | Audited by [Spearbit](https://spearbit.com/). 82 | 83 | | Audit | Date | Report | 84 | |--------|---------|---------| 85 | | First private audit | 02/03/2025 | [Report](audits/Cantina-February-2025.pdf) | 86 | | Second private audit | 03/05/2025 | [Report](audits/Cantina-March-2025.pdf) | 87 | | Public competition | 04/13/2025| [Report](audits/Cantina-Competition-April-2025.pdf) | 88 | -------------------------------------------------------------------------------- /test/mocks/MockImplementation.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.23; 3 | 4 | import {UUPSUpgradeable} from "solady/utils/UUPSUpgradeable.sol"; 5 | 6 | /** 7 | * @title MockImplementation 8 | * @dev Base mock implementation for testing EIP7702Proxy 9 | */ 10 | contract MockImplementation is UUPSUpgradeable { 11 | bytes4 constant ERC1271_MAGIC_VALUE = 0x1626ba7e; 12 | 13 | address public owner; 14 | bool public initialized; 15 | bool public mockFunctionCalled; 16 | 17 | event Initialized(address owner); 18 | event MockFunctionCalled(); 19 | 20 | error Unauthorized(); 21 | error AlreadyInitialized(); 22 | 23 | /// @dev Modifier to restrict access to owner 24 | modifier onlyOwner() { 25 | if (msg.sender != owner) revert Unauthorized(); 26 | _; 27 | } 28 | 29 | /// @dev Modifier to prevent multiple initializations 30 | modifier initializer() { 31 | if (initialized) revert AlreadyInitialized(); 32 | initialized = true; 33 | _; 34 | } 35 | 36 | /** 37 | * @dev Initializes the contract with an owner 38 | * @param _owner Address to set as owner 39 | */ 40 | function initialize(address _owner) public virtual initializer { 41 | owner = _owner; 42 | emit Initialized(_owner); 43 | } 44 | 45 | /** 46 | * @dev Mock function for testing delegate calls 47 | */ 48 | function mockFunction() public onlyOwner { 49 | mockFunctionCalled = true; 50 | emit MockFunctionCalled(); 51 | } 52 | 53 | function isValidSignature(bytes32, bytes calldata) external pure virtual returns (bytes4) { 54 | return ERC1271_MAGIC_VALUE; 55 | } 56 | 57 | /** 58 | * @dev Implementation of UUPS upgrade authorization 59 | */ 60 | function _authorizeUpgrade(address) internal view virtual override onlyOwner {} 61 | 62 | /** 63 | * @dev Mock function that returns arbitrary bytes data 64 | * @param data The data to return 65 | * @return The input data (to verify delegation preserves data) 66 | */ 67 | function returnBytesData(bytes memory data) public pure returns (bytes memory) { 68 | return data; 69 | } 70 | 71 | /** 72 | * @dev Mock function that always reverts 73 | */ 74 | function revertingFunction() public pure { 75 | revert("MockRevert"); 76 | } 77 | } 78 | 79 | /** 80 | * @title FailingSignatureImplementation 81 | * @dev Mock implementation that always fails signature validation 82 | */ 83 | contract FailingSignatureImplementation is MockImplementation { 84 | /// @dev Always returns failure for signature validation 85 | function isValidSignature(bytes32, bytes calldata) external pure override returns (bytes4) { 86 | return 0xffffffff; 87 | } 88 | } 89 | 90 | /** 91 | * @title RevertingIsValidSignatureImplementation 92 | * @dev Mock implementation that always reverts during signature validation 93 | */ 94 | contract RevertingIsValidSignatureImplementation is MockImplementation { 95 | /// @dev Always reverts during signature validation 96 | function isValidSignature(bytes32, bytes calldata) external pure override returns (bytes4) { 97 | revert("SignatureValidationFailed"); 98 | } 99 | } 100 | 101 | /** 102 | * @title RevertingInitializerMockImplementation 103 | * @dev Mock implementation that always reverts on initialization 104 | */ 105 | contract RevertingInitializerMockImplementation is MockImplementation { 106 | /// @dev Always reverts on initialization 107 | function initialize(address) public pure override { 108 | revert("InitializerReverted"); 109 | } 110 | } 111 | 112 | /** 113 | * @dev Mock implementation that returns ERC1271_MAGIC_VALUE with extra data 114 | */ 115 | contract MockImplementationWithExtraData is MockImplementation { 116 | function isValidSignature(bytes32, bytes memory) public pure override returns (bytes4) { 117 | // Return magic value (0x1626ba7e) followed by extra data 118 | bytes32 returnValue = bytes32(bytes4(ERC1271_MAGIC_VALUE)) | bytes32(uint256(0xdeadbeef) << 32); 119 | assembly { 120 | // Need assembly to return more than 4 bytes from a function declared to return bytes4 121 | mstore(0x00, returnValue) 122 | return(0x00, 32) 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /test/EIP7702Proxy/delegate.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.23; 3 | 4 | import {EIP7702Proxy} from "../../src/EIP7702Proxy.sol"; 5 | import {DefaultReceiver} from "../../src/DefaultReceiver.sol"; 6 | import {EIP7702ProxyBase} from "../base/EIP7702ProxyBase.sol"; 7 | import {MockImplementation} from "../mocks/MockImplementation.sol"; 8 | import {MockValidator} from "../mocks/MockValidator.sol"; 9 | 10 | contract DelegateTest is EIP7702ProxyBase { 11 | function setUp() public override { 12 | super.setUp(); 13 | _initializeProxy(); 14 | } 15 | 16 | function test_succeeds_whenReadingState() public view { 17 | assertEq(MockImplementation(payable(_eoa)).owner(), _newOwner, "Delegated read call should succeed"); 18 | } 19 | 20 | function test_succeeds_whenWritingState() public { 21 | vm.prank(_newOwner); 22 | MockImplementation(payable(_eoa)).mockFunction(); 23 | } 24 | 25 | function test_preservesReturnData_whenReturningBytes(bytes memory testData) public view { 26 | bytes memory returnedData = MockImplementation(payable(_eoa)).returnBytesData(testData); 27 | 28 | assertEq(returnedData, testData, "Complex return data should be correctly delegated"); 29 | } 30 | 31 | function test_reverts_whenReadReverts() public { 32 | vm.expectRevert("MockRevert"); 33 | MockImplementation(payable(_eoa)).revertingFunction(); 34 | } 35 | 36 | function test_reverts_whenWriteReverts(address unauthorized) public { 37 | vm.assume(unauthorized != address(0)); 38 | vm.assume(unauthorized != _newOwner); // Not the owner 39 | 40 | vm.prank(unauthorized); 41 | vm.expectRevert(MockImplementation.Unauthorized.selector); 42 | MockImplementation(payable(_eoa)).mockFunction(); 43 | 44 | assertFalse(MockImplementation(payable(_eoa)).mockFunctionCalled(), "State should not change when write fails"); 45 | } 46 | 47 | function test_continues_delegating_afterUpgrade() public { 48 | assertEq(MockImplementation(payable(_eoa)).owner(), _newOwner, "Owner should be set"); 49 | 50 | // Deploy a new implementation 51 | MockImplementation newImplementation = new MockImplementation(); 52 | MockValidator newImplementationValidator = new MockValidator(newImplementation); 53 | // Create signature for upgrade 54 | bytes memory signature = _signSetImplementationData( 55 | _EOA_PRIVATE_KEY, 56 | address(newImplementation), 57 | 0, // chainId 0 for cross-chain 58 | "", 59 | address(newImplementationValidator) 60 | ); 61 | 62 | // Upgrade to the new implementation 63 | EIP7702Proxy(_eoa).setImplementation( 64 | address(newImplementation), 65 | "", // no init data needed 66 | address(newImplementationValidator), 67 | type(uint256).max, 68 | signature, 69 | true 70 | ); 71 | 72 | // Verify the implementation was changed 73 | assertEq(_getERC1967Implementation(_eoa), address(newImplementation), "Implementation should be updated"); 74 | 75 | // Try to make a call through the proxy 76 | vm.prank(_newOwner); 77 | MockImplementation(_eoa).mockFunction(); 78 | 79 | // Verify the call succeeded (new implementation shares ownership state with original implementation) 80 | assertTrue(MockImplementation(_eoa).mockFunctionCalled(), "Should be able to call through proxy after upgrade"); 81 | } 82 | 83 | function test_allows_ethTransfersBeforeInitialization() public { 84 | // Deploy a fresh proxy without initializing it 85 | address payable uninitProxy = payable(makeAddr("uninitProxy")); 86 | _deployProxy(uninitProxy); 87 | 88 | // Should succeed with empty calldata and ETH value 89 | (bool success,) = uninitProxy.call{value: 1 ether}(""); 90 | assertTrue(success, "ETH transfer should succeed"); 91 | assertEq(address(uninitProxy).balance, 1 ether); 92 | } 93 | 94 | function test_reverts_whenCallingWithArbitraryDataBeforeInitialization(bytes calldata data) public { 95 | // Skip empty calls or pure ETH transfers 96 | vm.assume(data.length > 0); 97 | 98 | // Deploy a fresh proxy without initializing it 99 | address payable uninitProxy = payable(makeAddr("uninitProxy")); 100 | _deployProxy(uninitProxy); 101 | 102 | // Try to make the call and capture the result 103 | (bool success,) = uninitProxy.call(data); 104 | 105 | // The call should fail since the proxy is uninitialized and the data is non-empty 106 | assertFalse(success, "Call with arbitrary data should fail on uninitialized proxy"); 107 | 108 | vm.expectRevert(); 109 | (success,) = uninitProxy.call(data); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /test/base/EIP7702ProxyBase.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.23; 3 | 4 | import {EIP7702Proxy} from "../../src/EIP7702Proxy.sol"; 5 | import {NonceTracker} from "../../src/NonceTracker.sol"; 6 | import {DefaultReceiver} from "../../src/DefaultReceiver.sol"; 7 | import {MockValidator} from "../mocks/MockValidator.sol"; 8 | 9 | import {ECDSA} from "openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sol"; 10 | import {Test} from "forge-std/Test.sol"; 11 | import {MockImplementation} from "../mocks/MockImplementation.sol"; 12 | 13 | /** 14 | * @title EIP7702ProxyBase 15 | * @dev Base contract containing shared setup and utilities for EIP7702Proxy tests. 16 | */ 17 | abstract contract EIP7702ProxyBase is Test { 18 | /// @dev Storage slot with the address of the current implementation (ERC1967) 19 | bytes32 internal constant IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; 20 | 21 | bytes32 internal constant _IMPLEMENTATION_SET_TYPEHASH = keccak256( 22 | "EIP7702ProxyImplementationSet(uint256 chainId,address proxy,uint256 nonce,address currentImplementation,address newImplementation,bytes callData,address validator,uint256 expiry)" 23 | ); 24 | 25 | /// @dev Test account private keys and addresses 26 | uint256 internal constant _EOA_PRIVATE_KEY = 0xA11CE; 27 | address payable internal _eoa; 28 | 29 | uint256 internal constant _NEW_OWNER_PRIVATE_KEY = 0xB0B; 30 | address payable internal _newOwner; 31 | 32 | /// @dev Core contract instances 33 | EIP7702Proxy internal _proxy; 34 | MockImplementation internal _implementation; 35 | NonceTracker internal _nonceTracker; 36 | DefaultReceiver internal _receiver; 37 | MockValidator internal _validator; 38 | 39 | /// @dev "deploy" the proxy at the EOA but don't initialize 40 | function setUp() public virtual { 41 | // Set up test accounts 42 | _eoa = payable(vm.addr(_EOA_PRIVATE_KEY)); 43 | _newOwner = payable(vm.addr(_NEW_OWNER_PRIVATE_KEY)); 44 | 45 | // Deploy core contracts 46 | _implementation = new MockImplementation(); 47 | _nonceTracker = new NonceTracker(); 48 | _receiver = new DefaultReceiver(); 49 | _validator = new MockValidator(_implementation); 50 | 51 | // Deploy proxy with receiver and nonce tracker 52 | _proxy = new EIP7702Proxy(address(_nonceTracker), address(_receiver)); 53 | 54 | // Get the proxy's runtime code 55 | bytes memory proxyCode = address(_proxy).code; 56 | 57 | // Etch the proxy code at the EOA's address to simulate EIP-7702 upgrade 58 | vm.etch(_eoa, proxyCode); 59 | } 60 | 61 | /// @dev Initialize the proxy with the new owner 62 | function _initializeProxy() internal { 63 | bytes memory initArgs = _createInitArgs(_newOwner); 64 | bytes memory signature = _signSetImplementationData( 65 | _EOA_PRIVATE_KEY, 66 | address(_implementation), 67 | 0, // chainId 0 for cross-chain 68 | initArgs, 69 | address(_validator) 70 | ); 71 | 72 | EIP7702Proxy(_eoa).setImplementation( 73 | address(_implementation), 74 | initArgs, 75 | address(_validator), 76 | type(uint256).max, 77 | signature, 78 | true // Allow cross-chain replay for tests 79 | ); 80 | } 81 | 82 | /** 83 | * @dev Helper to generate initialization signature 84 | * @param signerPk Private key of the signer 85 | * @param newImplementationAddress New implementation contract address 86 | * @param chainId Chain ID for the signature 87 | * @param callData Initialization data for the implementation 88 | * @param validator Validator contract address 89 | * @return Signature bytes 90 | */ 91 | function _signSetImplementationData( 92 | uint256 signerPk, 93 | address newImplementationAddress, 94 | uint256 chainId, 95 | bytes memory callData, 96 | address validator 97 | ) internal view returns (bytes memory) { 98 | uint256 nonce = _nonceTracker.nonces(_eoa); 99 | address currentImpl = _getERC1967Implementation(_eoa); 100 | 101 | bytes32 initHash = keccak256( 102 | abi.encode( 103 | _IMPLEMENTATION_SET_TYPEHASH, 104 | chainId, 105 | _proxy, 106 | nonce, 107 | currentImpl, 108 | newImplementationAddress, 109 | keccak256(callData), 110 | validator, 111 | type(uint256).max // default to max expiry 112 | ) 113 | ); 114 | 115 | (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, initHash); 116 | return abi.encodePacked(r, s, v); 117 | } 118 | 119 | /** 120 | * @dev Helper to create initialization args with a single owner 121 | * @param owner Address to set as owner 122 | * @return Encoded initialization arguments 123 | */ 124 | function _createInitArgs(address owner) internal pure returns (bytes memory) { 125 | // Encode the complete function call: initialize(address) 126 | return abi.encodeWithSelector(MockImplementation.initialize.selector, owner); 127 | } 128 | 129 | /** 130 | * @dev Helper to read the implementation address from ERC1967 storage slot 131 | * @param proxy Address of the proxy contract to read from 132 | * @return The implementation address stored in the ERC1967 slot 133 | */ 134 | function _getERC1967Implementation(address proxy) internal view returns (address) { 135 | return address(uint160(uint256(vm.load(proxy, IMPLEMENTATION_SLOT)))); 136 | } 137 | 138 | /** 139 | * @dev Helper to deploy a proxy and etch its code at a target address 140 | * @param target The address where the proxy code should be etched 141 | * @return The target address (for convenience) 142 | */ 143 | function _deployProxy(address target) internal returns (address) { 144 | // Get the proxy's runtime code 145 | bytes memory proxyCode = address(_proxy).code; 146 | 147 | // Etch the proxy code at the target address 148 | vm.etch(target, proxyCode); 149 | 150 | return target; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/EIP7702Proxy.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.23; 3 | 4 | import {Proxy} from "openzeppelin-contracts/contracts/proxy/Proxy.sol"; 5 | import {ERC1967Utils} from "openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Utils.sol"; 6 | import {ECDSA} from "openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sol"; 7 | import {Receiver} from "solady/accounts/Receiver.sol"; 8 | 9 | import {NonceTracker} from "./NonceTracker.sol"; 10 | import {DefaultReceiver} from "./DefaultReceiver.sol"; 11 | import {IAccountStateValidator, ACCOUNT_STATE_VALIDATION_SUCCESS} from "./interfaces/IAccountStateValidator.sol"; 12 | 13 | /// @title EIP7702Proxy 14 | /// 15 | /// @notice Proxy contract designed for EIP-7702 smart accounts 16 | /// 17 | /// @dev Implements ERC-1967 with a signature-based initialization process 18 | /// 19 | /// @author Coinbase (https://github.com/base/eip-7702-proxy) 20 | contract EIP7702Proxy is Proxy { 21 | /// @notice ERC-1271 interface constants 22 | bytes4 internal constant _ERC1271_MAGIC_VALUE = 0x1626ba7e; 23 | bytes4 internal constant _ERC1271_FAIL_VALUE = 0xffffffff; 24 | 25 | /// @notice Typehash for setting implementation 26 | bytes32 internal constant _IMPLEMENTATION_SET_TYPEHASH = keccak256( 27 | "EIP7702ProxyImplementationSet(uint256 chainId,address proxy,uint256 nonce,address currentImplementation,address newImplementation,bytes callData,address validator,uint256 expiry)" 28 | ); 29 | 30 | /// @notice Address of the global nonce tracker for initialization 31 | NonceTracker public immutable nonceTracker; 32 | 33 | /// @notice A default implementation that allows this address to receive tokens before initialization 34 | address internal immutable _receiver; 35 | 36 | /// @notice Address of this proxy contract delegate 37 | address internal immutable _proxy; 38 | 39 | /// @notice Constructor arguments are zero 40 | error ZeroAddress(); 41 | 42 | /// @notice EOA signature is invalid 43 | error InvalidSignature(); 44 | 45 | /// @notice Validator did not return ACCOUNT_STATE_VALIDATION_SUCCESS 46 | error InvalidValidation(); 47 | 48 | /// @notice Signature has expired 49 | error SignatureExpired(); 50 | 51 | /// @notice Initializes the proxy with a default receiver implementation and an external nonce tracker 52 | /// 53 | /// @param nonceTracker_ The address of the nonce tracker contract 54 | /// @param receiver The address of the receiver contract 55 | constructor(address nonceTracker_, address receiver) { 56 | if (nonceTracker_ == address(0)) revert ZeroAddress(); 57 | if (receiver == address(0)) revert ZeroAddress(); 58 | 59 | nonceTracker = NonceTracker(nonceTracker_); 60 | _receiver = receiver; 61 | _proxy = address(this); 62 | } 63 | 64 | /// @notice Sets the ERC-1967 implementation slot after signature verification and executes `callData` on the `newImplementation` if provided 65 | /// 66 | /// @dev Validates resulting wallet state after upgrade by calling `validateAccountState` on the supplied validator contract 67 | /// @dev Signature must be from the EOA's address 68 | /// 69 | /// @param newImplementation The implementation address to set 70 | /// @param callData Optional calldata to call on new implementation 71 | /// @param validator The address of the validator contract 72 | /// @param signature The EOA signature authorizing this change 73 | /// @param allowCrossChainReplay use a chain-agnostic or chain-specific hash 74 | function setImplementation( 75 | address newImplementation, 76 | bytes calldata callData, 77 | address validator, 78 | uint256 expiry, 79 | bytes calldata signature, 80 | bool allowCrossChainReplay 81 | ) external { 82 | if (block.timestamp >= expiry) revert SignatureExpired(); 83 | 84 | // Construct hash using typehash to prevent signature collisions 85 | bytes32 hash = keccak256( 86 | abi.encode( 87 | _IMPLEMENTATION_SET_TYPEHASH, 88 | allowCrossChainReplay ? 0 : block.chainid, 89 | _proxy, 90 | nonceTracker.useNonce(), 91 | ERC1967Utils.getImplementation(), 92 | newImplementation, 93 | keccak256(callData), 94 | validator, 95 | expiry 96 | ) 97 | ); 98 | 99 | // Verify signature is from this address (the EOA) 100 | address signer = ECDSA.recover(hash, signature); 101 | if (signer != address(this)) revert InvalidSignature(); 102 | 103 | // Reset the implementation slot and call initialization if provided 104 | ERC1967Utils.upgradeToAndCall(newImplementation, callData); 105 | 106 | // Validate wallet state after upgrade, reverting if invalid 107 | bytes4 validationResult = 108 | IAccountStateValidator(validator).validateAccountState(address(this), ERC1967Utils.getImplementation()); 109 | if (validationResult != ACCOUNT_STATE_VALIDATION_SUCCESS) revert InvalidValidation(); 110 | } 111 | 112 | /// @notice Handles ERC-1271 signature validation by enforcing a final `ecrecover` check if signatures fail `isValidSignature` check 113 | /// 114 | /// @dev This ensures EOA signatures are considered valid regardless of the implementation's `isValidSignature` implementation 115 | /// @dev When calling `isValidSignature` from the implementation contract, note that calling `this.isValidSignature` will invoke this 116 | /// function and make an `ecrecover` check, whereas calling a public `isValidSignature` directly from the implementation contract will not 117 | /// @dev Cannot be declared as `view` given delegatecall to implementation contract 118 | /// 119 | /// @param hash The hash of the message being signed 120 | /// @param signature The signature of the message 121 | /// 122 | /// @return The result of the `isValidSignature` check 123 | function isValidSignature(bytes32 hash, bytes calldata signature) external returns (bytes4) { 124 | // Delegatecall to implementation with received data 125 | (bool success, bytes memory result) = _implementation().delegatecall(msg.data); 126 | 127 | // Early return magic value if delegatecall returned magic value 128 | if (success && result.length == 32 && bytes4(result) == _ERC1271_MAGIC_VALUE) { 129 | return _ERC1271_MAGIC_VALUE; 130 | } 131 | 132 | // Validate signature against EOA as fallback 133 | (address recovered, ECDSA.RecoverError error,) = ECDSA.tryRecover(hash, signature); 134 | if (error == ECDSA.RecoverError.NoError && recovered == address(this)) { 135 | return _ERC1271_MAGIC_VALUE; 136 | } 137 | 138 | // Default return failure value 139 | return _ERC1271_FAIL_VALUE; 140 | } 141 | 142 | /// @notice Returns the ERC-1967 implementation address, or the default receiver if the implementation is not set 143 | /// 144 | /// @return implementation The implementation address for this proxy 145 | function _implementation() internal view override returns (address implementation) { 146 | implementation = ERC1967Utils.getImplementation(); 147 | if (implementation == address(0)) implementation = _receiver; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /test/CoinbaseSmartWalletValidator.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.23; 3 | 4 | import {Test} from "forge-std/Test.sol"; 5 | import {CoinbaseSmartWallet} from "../lib/smart-wallet/src/CoinbaseSmartWallet.sol"; 6 | import {EIP7702Proxy} from "../src/EIP7702Proxy.sol"; 7 | import {NonceTracker} from "../src/NonceTracker.sol"; 8 | import {DefaultReceiver} from "../src/DefaultReceiver.sol"; 9 | import {CoinbaseSmartWalletValidator} from "../src/validators/CoinbaseSmartWalletValidator.sol"; 10 | import {IAccountStateValidator} from "../src/interfaces/IAccountStateValidator.sol"; 11 | 12 | contract CoinbaseSmartWalletValidatorTest is Test { 13 | uint256 constant _EOA_PRIVATE_KEY = 0xA11CE; 14 | address payable _eoa; 15 | 16 | uint256 constant _NEW_OWNER_PRIVATE_KEY = 0xB0B; 17 | address payable _newOwner; 18 | 19 | // Core contracts 20 | EIP7702Proxy _proxy; 21 | CoinbaseSmartWallet _implementation; 22 | NonceTracker _nonceTracker; 23 | DefaultReceiver _receiver; 24 | CoinbaseSmartWalletValidator _validator; 25 | 26 | // Storage slot with the address of the current implementation (ERC1967) 27 | bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; 28 | 29 | bytes32 _IMPLEMENTATION_SET_TYPEHASH = keccak256( 30 | "EIP7702ProxyImplementationSet(uint256 chainId,address proxy,uint256 nonce,address currentImplementation,address newImplementation,bytes callData,address validator,uint256 expiry)" 31 | ); 32 | 33 | function setUp() public { 34 | // Set up test accounts 35 | _eoa = payable(vm.addr(_EOA_PRIVATE_KEY)); 36 | _newOwner = payable(vm.addr(_NEW_OWNER_PRIVATE_KEY)); 37 | 38 | // Deploy core contracts 39 | _implementation = new CoinbaseSmartWallet(); 40 | _nonceTracker = new NonceTracker(); 41 | _receiver = new DefaultReceiver(); 42 | _validator = new CoinbaseSmartWalletValidator(_implementation); 43 | 44 | // Deploy proxy with receiver and nonce tracker 45 | _proxy = new EIP7702Proxy(address(_nonceTracker), address(_receiver)); 46 | 47 | // Get the proxy's runtime code 48 | bytes memory proxyCode = address(_proxy).code; 49 | 50 | // Etch the proxy code at the target address 51 | vm.etch(_eoa, proxyCode); 52 | } 53 | 54 | function test_succeeds_whenWalletHasOwner() public { 55 | // Initialize proxy with an owner 56 | bytes memory initArgs = _createInitArgs(_newOwner); 57 | bytes memory signature = 58 | _signSetImplementationData(_EOA_PRIVATE_KEY, initArgs, address(_implementation), address(_validator)); 59 | 60 | // Should not revert 61 | EIP7702Proxy(_eoa).setImplementation( 62 | address(_implementation), initArgs, address(_validator), type(uint256).max, signature, true 63 | ); 64 | } 65 | 66 | function test_succeeds_whenWalletHasMultipleOwners() public { 67 | // Initialize with multiple owners 68 | address[] memory owners = new address[](3); 69 | owners[0] = makeAddr("owner1"); 70 | owners[1] = makeAddr("owner2"); 71 | owners[2] = makeAddr("owner3"); 72 | 73 | bytes memory initArgs = _createInitArgsMulti(owners); 74 | bytes memory signature = 75 | _signSetImplementationData(_EOA_PRIVATE_KEY, initArgs, address(_implementation), address(_validator)); 76 | 77 | // Should not revert 78 | EIP7702Proxy(_eoa).setImplementation( 79 | address(_implementation), initArgs, address(_validator), type(uint256).max, signature, true 80 | ); 81 | } 82 | 83 | function test_reverts_whenWalletHasNoOwners() public { 84 | // Try to initialize with empty owners array 85 | bytes[] memory emptyOwners = new bytes[](0); 86 | bytes memory initArgs = abi.encodePacked(CoinbaseSmartWallet.initialize.selector, abi.encode(emptyOwners)); 87 | bytes memory signature = 88 | _signSetImplementationData(_EOA_PRIVATE_KEY, initArgs, address(_implementation), address(_validator)); 89 | 90 | vm.expectRevert(CoinbaseSmartWalletValidator.Unintialized.selector); 91 | EIP7702Proxy(_eoa).setImplementation( 92 | address(_implementation), initArgs, address(_validator), type(uint256).max, signature, true 93 | ); 94 | } 95 | 96 | function test_succeeds_whenWalletHadOwnersButLastOwnerRemoved() public { 97 | // First initialize the wallet with an owner 98 | bytes memory initArgs = _createInitArgs(_newOwner); 99 | bytes memory signature = 100 | _signSetImplementationData(_EOA_PRIVATE_KEY, initArgs, address(_implementation), address(_validator)); 101 | EIP7702Proxy(_eoa).setImplementation( 102 | address(_implementation), initArgs, address(_validator), type(uint256).max, signature, true 103 | ); 104 | 105 | // Now remove the owner through the wallet interface 106 | vm.prank(_newOwner); 107 | CoinbaseSmartWallet(payable(_eoa)).removeLastOwner( 108 | 0, // index of the owner to remove 109 | abi.encode(_newOwner) // encoded owner data 110 | ); 111 | 112 | // Direct validation call should succeed since nextOwnerIndex is still non-zero 113 | _validator.validateAccountState(address(_eoa), address(_implementation)); 114 | 115 | // Verify that nextOwnerIndex is indeed still non-zero 116 | assertGt(CoinbaseSmartWallet(payable(_eoa)).nextOwnerIndex(), 0); 117 | } 118 | 119 | function test_reverts_whenImplementationDoesNotMatch() public { 120 | // Deploy a different implementation 121 | CoinbaseSmartWallet differentImpl = new CoinbaseSmartWallet(); 122 | 123 | // Create validator with specific implementation 124 | CoinbaseSmartWalletValidator validator = new CoinbaseSmartWalletValidator(differentImpl); 125 | 126 | // Initialize proxy with an owner but using wrong implementation 127 | bytes memory initArgs = _createInitArgs(_newOwner); 128 | bytes memory signature = 129 | _signSetImplementationData(_EOA_PRIVATE_KEY, initArgs, address(_implementation), address(validator)); 130 | 131 | vm.expectRevert( 132 | abi.encodeWithSelector(IAccountStateValidator.InvalidImplementation.selector, address(_implementation)) 133 | ); 134 | EIP7702Proxy(_eoa).setImplementation( 135 | address(_implementation), initArgs, address(validator), type(uint256).max, signature, true 136 | ); 137 | } 138 | 139 | // Helper functions from coinbaseImplementation.t.sol 140 | function _createInitArgs(address owner) internal pure returns (bytes memory) { 141 | bytes[] memory owners = new bytes[](1); 142 | owners[0] = abi.encode(owner); 143 | bytes memory ownerArgs = abi.encode(owners); 144 | return abi.encodePacked(CoinbaseSmartWallet.initialize.selector, ownerArgs); 145 | } 146 | 147 | function _createInitArgsMulti(address[] memory owners) internal pure returns (bytes memory) { 148 | bytes[] memory encodedOwners = new bytes[](owners.length); 149 | for (uint256 i = 0; i < owners.length; i++) { 150 | encodedOwners[i] = abi.encode(owners[i]); 151 | } 152 | bytes memory ownerArgs = abi.encode(encodedOwners); 153 | return abi.encodePacked(CoinbaseSmartWallet.initialize.selector, ownerArgs); 154 | } 155 | 156 | function _signSetImplementationData( 157 | uint256 signerPk, 158 | bytes memory initArgs, 159 | address implementation, 160 | address validator 161 | ) internal view returns (bytes memory) { 162 | bytes32 initHash = keccak256( 163 | abi.encode( 164 | _IMPLEMENTATION_SET_TYPEHASH, 165 | 0, // chainId 0 for cross-chain 166 | _proxy, 167 | _nonceTracker.nonces(_eoa), 168 | _getERC1967Implementation(address(_eoa)), 169 | address(implementation), 170 | keccak256(initArgs), 171 | address(validator), 172 | type(uint256).max // default to max expiry 173 | ) 174 | ); 175 | (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, initHash); 176 | return abi.encodePacked(r, s, v); 177 | } 178 | 179 | function _getERC1967Implementation(address proxy) internal view returns (address) { 180 | return address(uint160(uint256(vm.load(proxy, _IMPLEMENTATION_SLOT)))); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /test/EIP7702Proxy/coinbaseImplementation.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.23; 3 | 4 | import {CoinbaseSmartWallet} from "../../lib/smart-wallet/src/CoinbaseSmartWallet.sol"; 5 | 6 | import {EIP7702Proxy} from "../../src/EIP7702Proxy.sol"; 7 | import {NonceTracker} from "../../src/NonceTracker.sol"; 8 | import {DefaultReceiver} from "../../src/DefaultReceiver.sol"; 9 | import {CoinbaseSmartWalletValidator} from "../../src/validators/CoinbaseSmartWalletValidator.sol"; 10 | 11 | import {Test} from "forge-std/Test.sol"; 12 | 13 | /** 14 | * @title CoinbaseImplementationTest 15 | * @dev Tests specific to the CoinbaseSmartWallet implementation 16 | */ 17 | contract CoinbaseImplementationTest is Test { 18 | uint256 constant _EOA_PRIVATE_KEY = 0xA11CE; 19 | address payable _eoa; 20 | 21 | uint256 constant _NEW_OWNER_PRIVATE_KEY = 0xB0B; 22 | address payable _newOwner; 23 | 24 | CoinbaseSmartWallet _wallet; 25 | CoinbaseSmartWallet _cbswImplementation; 26 | 27 | // core contracts 28 | EIP7702Proxy _proxy; 29 | NonceTracker _nonceTracker; 30 | DefaultReceiver _receiver; 31 | CoinbaseSmartWalletValidator _cbswValidator; 32 | 33 | // constants 34 | bytes4 constant ERC1271_MAGIC_VALUE = 0x1626ba7e; 35 | bytes4 constant ERC1271_FAIL_VALUE = 0xffffffff; 36 | 37 | /// @dev Storage slot with the address of the current implementation (ERC1967) 38 | bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; 39 | 40 | bytes32 _IMPLEMENTATION_SET_TYPEHASH = keccak256( 41 | "EIP7702ProxyImplementationSet(uint256 chainId,address proxy,uint256 nonce,address currentImplementation,address newImplementation,bytes callData,address validator,uint256 expiry)" 42 | ); 43 | 44 | function setUp() public virtual { 45 | // Set up test accounts 46 | _eoa = payable(vm.addr(_EOA_PRIVATE_KEY)); 47 | _newOwner = payable(vm.addr(_NEW_OWNER_PRIVATE_KEY)); 48 | 49 | // Deploy core contracts 50 | _cbswImplementation = new CoinbaseSmartWallet(); 51 | _nonceTracker = new NonceTracker(); 52 | _receiver = new DefaultReceiver(); 53 | _cbswValidator = new CoinbaseSmartWalletValidator(_cbswImplementation); 54 | 55 | // Deploy proxy with receiver and nonce tracker 56 | _proxy = new EIP7702Proxy(address(_nonceTracker), address(_receiver)); 57 | 58 | // Get the proxy's runtime code 59 | bytes memory proxyCode = address(_proxy).code; 60 | 61 | // Etch the proxy code at the target address 62 | vm.etch(_eoa, proxyCode); 63 | } 64 | 65 | // ======== Utility Functions ======== 66 | function _initializeProxy() internal { 67 | // Initialize with implementation 68 | bytes memory initArgs = _createInitArgs(_newOwner); 69 | bytes memory signature = _signSetImplementationData(_EOA_PRIVATE_KEY, initArgs); 70 | 71 | EIP7702Proxy(_eoa).setImplementation( 72 | address(_cbswImplementation), 73 | initArgs, 74 | address(_cbswValidator), 75 | type(uint256).max, 76 | signature, 77 | true // Allow cross-chain replay for tests 78 | ); 79 | 80 | _wallet = CoinbaseSmartWallet(payable(_eoa)); 81 | } 82 | 83 | /** 84 | * @dev Creates initialization arguments for CoinbaseSmartWallet 85 | * @param owner Address to set as the initial owner 86 | * @return Encoded initialization arguments for CoinbaseSmartWallet 87 | */ 88 | function _createInitArgs(address owner) internal pure returns (bytes memory) { 89 | bytes[] memory owners = new bytes[](1); 90 | owners[0] = abi.encode(owner); 91 | bytes memory ownerArgs = abi.encode(owners); 92 | return abi.encodePacked(CoinbaseSmartWallet.initialize.selector, ownerArgs); 93 | } 94 | 95 | /** 96 | * @dev Signs initialization data for CoinbaseSmartWallet that will be verified by the proxy 97 | * @param signerPk Private key of the signer 98 | * @param initArgs Initialization arguments to sign 99 | * @return Signature bytes 100 | */ 101 | function _signSetImplementationData(uint256 signerPk, bytes memory initArgs) internal view returns (bytes memory) { 102 | bytes32 initHash = keccak256( 103 | abi.encode( 104 | _IMPLEMENTATION_SET_TYPEHASH, 105 | 0, // chainId 0 for cross-chain 106 | _proxy, 107 | _nonceTracker.nonces(_eoa), 108 | _getERC1967Implementation(address(_eoa)), 109 | address(_cbswImplementation), 110 | keccak256(initArgs), 111 | address(_cbswValidator), 112 | type(uint256).max // default to max expiry 113 | ) 114 | ); 115 | (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, initHash); 116 | return abi.encodePacked(r, s, v); 117 | } 118 | 119 | /** 120 | * @dev Helper to read the implementation address from ERC1967 storage slot 121 | * @param proxy Address of the proxy contract to read from 122 | * @return The implementation address stored in the ERC1967 slot 123 | */ 124 | function _getERC1967Implementation(address proxy) internal view returns (address) { 125 | return address(uint160(uint256(vm.load(proxy, _IMPLEMENTATION_SLOT)))); 126 | } 127 | 128 | /** 129 | * @dev Helper to create ECDSA signatures 130 | * @param pk Private key to sign with 131 | * @param hash Message hash to sign 132 | * @return signature Encoded signature bytes 133 | */ 134 | function _sign(uint256 pk, bytes32 hash) internal pure returns (bytes memory signature) { 135 | (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, hash); 136 | return abi.encodePacked(r, s, v); 137 | } 138 | 139 | /** 140 | * @dev Creates a signature from a wallet owner for CoinbaseSmartWallet validation 141 | * @param message Message to sign 142 | * @param smartWallet Address of the wallet contract 143 | * @param ownerPk Private key of the owner 144 | * @param ownerIndex Index of the owner in the wallet's owner list 145 | * @return Wrapped signature bytes 146 | */ 147 | function _createOwnerSignature(bytes32 message, address smartWallet, uint256 ownerPk, uint256 ownerIndex) 148 | internal 149 | view 150 | returns (bytes memory) 151 | { 152 | bytes32 replaySafeHash = CoinbaseSmartWallet(payable(smartWallet)).replaySafeHash(message); 153 | bytes memory signature = _sign(ownerPk, replaySafeHash); 154 | return _applySignatureWrapper(ownerIndex, signature); 155 | } 156 | 157 | /** 158 | * @dev Wraps a signature with owner index for CoinbaseSmartWallet validation 159 | * @param ownerIndex Index of the owner in the wallet's owner list 160 | * @param signatureData Raw signature bytes to wrap 161 | * @return Encoded signature wrapper 162 | */ 163 | function _applySignatureWrapper(uint256 ownerIndex, bytes memory signatureData) 164 | internal 165 | pure 166 | returns (bytes memory) 167 | { 168 | return abi.encode(CoinbaseSmartWallet.SignatureWrapper(ownerIndex, signatureData)); 169 | } 170 | 171 | // ======== Tests ======== 172 | function test_initialize_setsOwner() public { 173 | _initializeProxy(); 174 | assertTrue(_wallet.isOwnerAddress(_newOwner), "New owner should be owner after initialization"); 175 | } 176 | 177 | function test_isValidSignature_succeeds_withValidOwnerSignature(bytes32 message) public { 178 | _initializeProxy(); 179 | assertTrue(_wallet.isOwnerAddress(_newOwner), "New owner should be owner after initialization"); 180 | assertEq(_wallet.ownerAtIndex(0), abi.encode(_newOwner), "Owner at index 0 should be new owner"); 181 | 182 | bytes memory signature = _createOwnerSignature( 183 | message, 184 | address(_wallet), 185 | _NEW_OWNER_PRIVATE_KEY, 186 | 0 // First owner 187 | ); 188 | 189 | bytes4 result = _wallet.isValidSignature(message, signature); 190 | assertEq(result, ERC1271_MAGIC_VALUE, "Should accept valid contract owner signature"); 191 | } 192 | 193 | function test_execute_transfersEth_whenCalledByOwner(address recipient, uint256 amount) public { 194 | vm.assume(recipient != address(0)); 195 | vm.assume(recipient != address(_eoa)); 196 | assumeNotPrecompile(recipient); 197 | assumePayable(recipient); 198 | vm.assume(amount > 0 && amount <= 100 ether); 199 | 200 | _initializeProxy(); 201 | 202 | vm.deal(address(_eoa), amount); 203 | vm.deal(recipient, 0); 204 | 205 | vm.prank(_newOwner); 206 | _wallet.execute( 207 | payable(recipient), 208 | amount, 209 | "" // empty calldata for simple transfer 210 | ); 211 | 212 | assertEq(recipient.balance, amount, "Coinbase wallet execute should transfer ETH"); 213 | } 214 | 215 | function test_upgradeToAndCall_reverts_whenCalledByNonOwner(address nonOwner) public { 216 | _initializeProxy(); 217 | 218 | vm.assume(nonOwner != address(0)); 219 | vm.assume(nonOwner != _newOwner); // Ensure caller isn't the actual owner 220 | vm.assume(nonOwner != _eoa); // Ensure caller isn't the EOA address 221 | 222 | address newImpl = address(new CoinbaseSmartWallet()); 223 | 224 | vm.prank(nonOwner); 225 | vm.expectRevert(); // Coinbase wallet specific access control 226 | _wallet.upgradeToAndCall(newImpl, ""); 227 | } 228 | 229 | function test_initialize_reverts_whenCalledTwice() public { 230 | _initializeProxy(); 231 | 232 | // Try to initialize again with fresh signature 233 | bytes memory initArgs = _createInitArgs(_newOwner); 234 | bytes memory signature = _signSetImplementationData(_EOA_PRIVATE_KEY, initArgs); 235 | 236 | vm.expectRevert(CoinbaseSmartWallet.Initialized.selector); 237 | EIP7702Proxy(_eoa).setImplementation( 238 | address(_cbswImplementation), initArgs, address(_cbswValidator), type(uint256).max, signature, true 239 | ); 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /test/EIP7702Proxy/isValidSignature.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.23; 3 | 4 | import {ECDSA} from "openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sol"; 5 | 6 | import {EIP7702Proxy} from "../../src/EIP7702Proxy.sol"; 7 | import {NonceTracker} from "../../src/NonceTracker.sol"; 8 | import {DefaultReceiver} from "../../src/DefaultReceiver.sol"; 9 | 10 | import {EIP7702ProxyBase} from "../base/EIP7702ProxyBase.sol"; 11 | import { 12 | MockImplementation, 13 | FailingSignatureImplementation, 14 | RevertingIsValidSignatureImplementation, 15 | MockImplementationWithExtraData 16 | } from "../mocks/MockImplementation.sol"; 17 | import {MockValidator} from "../mocks/MockValidator.sol"; 18 | 19 | /** 20 | * @title IsValidSignatureTestBase 21 | * @dev Base contract for testing ERC-1271 isValidSignature behavior 22 | */ 23 | abstract contract IsValidSignatureTestBase is EIP7702ProxyBase { 24 | bytes4 constant ERC1271_MAGIC_VALUE = 0x1626ba7e; 25 | bytes4 constant ERC1271_FAIL_VALUE = 0xffffffff; 26 | 27 | bytes32 testHash; 28 | address wallet; 29 | 30 | function setUp() public virtual override { 31 | super.setUp(); 32 | 33 | testHash = keccak256("test message"); 34 | wallet = _eoa; 35 | } 36 | 37 | function test_succeeds_withValidEOASignature(bytes32 message) public virtual { 38 | (uint8 v, bytes32 r, bytes32 s) = vm.sign(_EOA_PRIVATE_KEY, message); 39 | bytes memory signature = abi.encodePacked(r, s, v); 40 | 41 | bytes4 result = MockImplementation(payable(wallet)).isValidSignature(message, signature); 42 | assertEq(result, ERC1271_MAGIC_VALUE, "Should accept valid EOA signature"); 43 | } 44 | 45 | function test_returnsExpectedValue_withInvalidEOASignature(uint128 wrongPk, bytes32 message) public virtual { 46 | vm.assume(wrongPk != 0); 47 | vm.assume(wrongPk != _EOA_PRIVATE_KEY); 48 | 49 | (uint8 v, bytes32 r, bytes32 s) = vm.sign(wrongPk, testHash); 50 | bytes memory signature = abi.encodePacked(r, s, v); 51 | 52 | bytes4 result = MockImplementation(payable(wallet)).isValidSignature(message, signature); 53 | assertEq( 54 | result, 55 | expectedInvalidSignatureResult(), 56 | "Should handle invalid signature according to whether `isValidSignature` succeeds or fails" 57 | ); 58 | } 59 | 60 | /** 61 | * @dev Abstract function that each implementation test must define 62 | * @return Expected result for invalid signature tests 63 | */ 64 | function expectedInvalidSignatureResult() internal pure virtual returns (bytes4); 65 | } 66 | 67 | /** 68 | * @dev Tests isValidSignature behavior when returning failure value from implementation isValidSignature 69 | */ 70 | contract FailingImplementationTest is IsValidSignatureTestBase { 71 | function setUp() public override { 72 | // Deploy core contracts first 73 | _implementation = new FailingSignatureImplementation(); 74 | _nonceTracker = new NonceTracker(); 75 | _receiver = new DefaultReceiver(); 76 | _validator = new MockValidator(_implementation); 77 | 78 | _eoa = payable(vm.addr(_EOA_PRIVATE_KEY)); 79 | _newOwner = payable(vm.addr(_NEW_OWNER_PRIVATE_KEY)); 80 | 81 | // Deploy proxy with receiver and nonce tracker 82 | _proxy = new EIP7702Proxy(address(_nonceTracker), address(_receiver)); 83 | bytes memory proxyCode = address(_proxy).code; 84 | vm.etch(_eoa, proxyCode); 85 | 86 | // Initialize with implementation 87 | bytes memory initArgs = _createInitArgs(_newOwner); 88 | bytes memory signature = _signSetImplementationData( 89 | _EOA_PRIVATE_KEY, 90 | address(_implementation), 91 | 0, // chainId 0 for cross-chain 92 | initArgs, 93 | address(_validator) 94 | ); 95 | 96 | EIP7702Proxy(_eoa).setImplementation( 97 | address(_implementation), 98 | initArgs, 99 | address(_validator), 100 | type(uint256).max, 101 | signature, 102 | true // Allow cross-chain replay for tests 103 | ); 104 | 105 | super.setUp(); 106 | } 107 | 108 | function expectedInvalidSignatureResult() internal pure override returns (bytes4) { 109 | return ERC1271_FAIL_VALUE; 110 | } 111 | 112 | function test_returnsFailureValue_withEmptySignature(bytes32 message) public view { 113 | bytes4 result = MockImplementation(payable(wallet)).isValidSignature(message, ""); 114 | assertEq(result, ERC1271_FAIL_VALUE, "Should reject empty signature"); 115 | } 116 | 117 | function test_returnsFailureValue_withInvalidS(bytes32 message) public view { 118 | // Create a signature with obviously invalid s value 119 | // Valid s values must be < n/2 where n is the curve order 120 | // Using max uint256 value which is clearly too large 121 | bytes32 r = bytes32(uint256(1)); 122 | bytes32 s = bytes32(type(uint256).max); // 2^256 - 1, way above valid range 123 | uint8 v = 27; 124 | bytes memory signature = abi.encodePacked(r, s, v); 125 | 126 | // We can use tryRecover directly to verify the exact error 127 | (address recovered, ECDSA.RecoverError error, bytes32 errorArg) = ECDSA.tryRecover(message, signature); 128 | assertEq(recovered, address(0), "Recovered address should be zero for invalid signature"); 129 | assertEq(uint8(error), uint8(ECDSA.RecoverError.InvalidSignatureS), "Should be InvalidSignatureS error"); 130 | assertEq(errorArg, s, "Error arg should be the invalid s value"); 131 | 132 | bytes4 result = MockImplementation(payable(wallet)).isValidSignature(message, signature); 133 | assertEq(result, ERC1271_FAIL_VALUE, "Should reject signature with invalid s value"); 134 | } 135 | 136 | function test_returnsFailureValue_withInvalidV(bytes32 message) public view { 137 | // Create signature with invalid v value (only 27 and 28 are valid) 138 | bytes32 r = bytes32(uint256(1)); 139 | bytes32 s = bytes32(uint256(1)); 140 | uint8 v = 26; 141 | bytes memory signature = abi.encodePacked(r, s, v); 142 | 143 | // Verify the exact error from tryRecover 144 | (address recovered, ECDSA.RecoverError error, bytes32 errorArg) = ECDSA.tryRecover(message, signature); 145 | assertEq(recovered, address(0), "Recovered address should be zero for invalid signature"); 146 | assertEq(uint8(error), uint8(ECDSA.RecoverError.InvalidSignature), "Should be InvalidSignature error"); 147 | assertEq(errorArg, bytes32(0), "Error arg should be zero for invalid signature"); 148 | 149 | bytes4 result = MockImplementation(payable(wallet)).isValidSignature(message, signature); 150 | assertEq(result, ERC1271_FAIL_VALUE, "Should reject signature with invalid v value"); 151 | } 152 | 153 | function test_returnsFailureValue_withInvalidR(bytes32 message) public view { 154 | // Create signature with invalid r value (using max uint256 which is above the curve order) 155 | bytes32 r = bytes32(type(uint256).max); 156 | bytes32 s = bytes32(uint256(1)); 157 | uint8 v = 27; 158 | bytes memory signature = abi.encodePacked(r, s, v); 159 | 160 | // Verify the exact error from tryRecover 161 | (address recovered, ECDSA.RecoverError error, bytes32 errorArg) = ECDSA.tryRecover(message, signature); 162 | assertEq(recovered, address(0), "Recovered address should be zero for invalid signature"); 163 | assertEq(uint8(error), uint8(ECDSA.RecoverError.InvalidSignature), "Should be InvalidSignature error"); 164 | assertEq(errorArg, bytes32(0), "Error arg should be zero for invalid signature"); 165 | 166 | bytes4 result = MockImplementation(payable(wallet)).isValidSignature(message, signature); 167 | assertEq(result, ERC1271_FAIL_VALUE, "Should reject signature with invalid r value"); 168 | } 169 | } 170 | 171 | /** 172 | * @dev Tests isValidSignature behavior when returning success value from implementation isValidSignature 173 | */ 174 | contract SucceedingImplementationTest is IsValidSignatureTestBase { 175 | function setUp() public override { 176 | // Deploy core contracts first 177 | _implementation = new MockImplementation(); 178 | _nonceTracker = new NonceTracker(); 179 | _receiver = new DefaultReceiver(); 180 | _validator = new MockValidator(_implementation); 181 | 182 | _eoa = payable(vm.addr(_EOA_PRIVATE_KEY)); 183 | _newOwner = payable(vm.addr(_NEW_OWNER_PRIVATE_KEY)); 184 | 185 | // Deploy proxy with receiver and nonce tracker 186 | _proxy = new EIP7702Proxy(address(_nonceTracker), address(_receiver)); 187 | bytes memory proxyCode = address(_proxy).code; 188 | vm.etch(_eoa, proxyCode); 189 | 190 | // Initialize with implementation 191 | bytes memory initArgs = _createInitArgs(_newOwner); 192 | bytes memory signature = _signSetImplementationData( 193 | _EOA_PRIVATE_KEY, 194 | address(_implementation), 195 | 0, // chainId 0 for cross-chain 196 | initArgs, 197 | address(_validator) 198 | ); 199 | 200 | EIP7702Proxy(_eoa).setImplementation( 201 | address(_implementation), initArgs, address(_validator), type(uint256).max, signature, true 202 | ); 203 | super.setUp(); 204 | } 205 | 206 | function expectedInvalidSignatureResult() internal pure override returns (bytes4) { 207 | return ERC1271_MAGIC_VALUE; // Implementation always returns success 208 | } 209 | 210 | function test_returnsSuccessValue_withEmptySignature(bytes32 message) public view { 211 | bytes4 result = MockImplementation(payable(wallet)).isValidSignature(message, ""); 212 | assertEq(result, ERC1271_MAGIC_VALUE, "Should return success for any EOA signature"); 213 | } 214 | } 215 | 216 | /** 217 | * @dev Tests isValidSignature behavior when reverting in implementation isValidSignature 218 | */ 219 | contract RevertingImplementationTest is IsValidSignatureTestBase { 220 | function setUp() public override { 221 | // Deploy core contracts first 222 | _implementation = new RevertingIsValidSignatureImplementation(); 223 | _nonceTracker = new NonceTracker(); 224 | _receiver = new DefaultReceiver(); 225 | _validator = new MockValidator(_implementation); 226 | 227 | _eoa = payable(vm.addr(_EOA_PRIVATE_KEY)); 228 | _newOwner = payable(vm.addr(_NEW_OWNER_PRIVATE_KEY)); 229 | 230 | // Deploy proxy with receiver and nonce tracker 231 | _proxy = new EIP7702Proxy(address(_nonceTracker), address(_receiver)); 232 | bytes memory proxyCode = address(_proxy).code; 233 | vm.etch(_eoa, proxyCode); 234 | 235 | // Initialize with implementation 236 | bytes memory initArgs = _createInitArgs(_newOwner); 237 | bytes memory signature = _signSetImplementationData( 238 | _EOA_PRIVATE_KEY, 239 | address(_implementation), 240 | 0, // chainId 0 for cross-chain 241 | initArgs, 242 | address(_validator) 243 | ); 244 | 245 | EIP7702Proxy(_eoa).setImplementation( 246 | address(_implementation), initArgs, address(_validator), type(uint256).max, signature, true 247 | ); 248 | 249 | super.setUp(); 250 | } 251 | 252 | function expectedInvalidSignatureResult() internal pure override returns (bytes4) { 253 | return ERC1271_FAIL_VALUE; 254 | } 255 | } 256 | 257 | /** 258 | * @dev Tests isValidSignature behavior when implementation returns ERC1271_MAGIC_VALUE with extra data 259 | */ 260 | contract ExtraDataTest is IsValidSignatureTestBase { 261 | function test_mockReturnsExtraData() public { 262 | MockImplementationWithExtraData mock = new MockImplementationWithExtraData(); 263 | 264 | // Call isValidSignature and capture the raw return data 265 | (bool success, bytes memory returnData) = 266 | address(mock).staticcall(abi.encodeWithSelector(mock.isValidSignature.selector, bytes32(0), new bytes(0))); 267 | 268 | require(success, "Call failed"); 269 | require(returnData.length == 32, "Should return 32 bytes"); 270 | 271 | // Log the full return data 272 | emit log_named_bytes("Return data", returnData); 273 | 274 | // Also log as bytes32 for easier reading 275 | bytes32 returnDataAs32 = abi.decode(returnData, (bytes32)); 276 | emit log_named_bytes32("Return data as bytes32", returnDataAs32); 277 | } 278 | 279 | function setUp() public override { 280 | // Deploy core contracts first 281 | _implementation = new MockImplementationWithExtraData(); 282 | _nonceTracker = new NonceTracker(); 283 | _receiver = new DefaultReceiver(); 284 | _validator = new MockValidator(_implementation); 285 | 286 | _eoa = payable(vm.addr(_EOA_PRIVATE_KEY)); 287 | _newOwner = payable(vm.addr(_NEW_OWNER_PRIVATE_KEY)); 288 | 289 | // Deploy proxy with receiver and nonce tracker 290 | _proxy = new EIP7702Proxy(address(_nonceTracker), address(_receiver)); 291 | bytes memory proxyCode = address(_proxy).code; 292 | vm.etch(_eoa, proxyCode); 293 | 294 | // Initialize with implementation 295 | bytes memory initArgs = _createInitArgs(_newOwner); 296 | bytes memory signature = _signSetImplementationData( 297 | _EOA_PRIVATE_KEY, 298 | address(_implementation), 299 | 0, // chainId 0 for cross-chain 300 | initArgs, 301 | address(_validator) 302 | ); 303 | 304 | EIP7702Proxy(_eoa).setImplementation( 305 | address(_implementation), initArgs, address(_validator), type(uint256).max, signature, true 306 | ); 307 | 308 | super.setUp(); 309 | } 310 | 311 | function expectedInvalidSignatureResult() internal pure override returns (bytes4) { 312 | return ERC1271_MAGIC_VALUE; // Implementation always returns success (with extra data) 313 | } 314 | 315 | function test_succeeds_withExtraReturnData(bytes32 message) public view { 316 | (uint8 v, bytes32 r, bytes32 s) = vm.sign(_EOA_PRIVATE_KEY, message); 317 | bytes memory signature = abi.encodePacked(r, s, v); 318 | 319 | bytes4 result = MockImplementation(payable(wallet)).isValidSignature(message, signature); 320 | assertEq(result, ERC1271_MAGIC_VALUE, "Should accept signature even with extra return data"); 321 | } 322 | } 323 | -------------------------------------------------------------------------------- /test/EIP7702Proxy/setImplementation.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.23; 3 | 4 | import {EIP7702Proxy} from "../../src/EIP7702Proxy.sol"; 5 | import {NonceTracker} from "../../src/NonceTracker.sol"; 6 | 7 | import {IERC1967} from "openzeppelin-contracts/contracts/interfaces/IERC1967.sol"; 8 | 9 | import {EIP7702ProxyBase} from "../base/EIP7702ProxyBase.sol"; 10 | import {MockImplementation} from "../mocks/MockImplementation.sol"; 11 | import {MockRevertingValidator} from "../mocks/MockRevertingValidator.sol"; 12 | import { 13 | IAccountStateValidator, ACCOUNT_STATE_VALIDATION_SUCCESS 14 | } from "../../src/interfaces/IAccountStateValidator.sol"; 15 | import {MockValidator} from "../mocks/MockValidator.sol"; 16 | import {MockInvalidValidator} from "../mocks/MockInvalidValidator.sol"; 17 | import {MockMaliciousImplementation} from "../mocks/MockMaliciousImplementation.sol"; 18 | 19 | contract SetImplementationTest is EIP7702ProxyBase { 20 | MockImplementation _newImplementation; 21 | 22 | function setUp() public override { 23 | super.setUp(); 24 | _newImplementation = new MockImplementation(); 25 | } 26 | 27 | function test_succeeds_whenImplementationSlotIsEmpty() public { 28 | assertEq(_getERC1967Implementation(_eoa), address(0), "Implementation should start empty"); 29 | 30 | bytes memory initArgs = _createInitArgs(_newOwner); 31 | bytes memory signature = _signSetImplementationData( 32 | _EOA_PRIVATE_KEY, 33 | address(_implementation), 34 | 0, // chainId 0 for cross-chain 35 | initArgs, 36 | address(_validator) 37 | ); 38 | EIP7702Proxy(_eoa).setImplementation( 39 | address(_implementation), 40 | initArgs, 41 | address(_validator), 42 | type(uint256).max, 43 | signature, 44 | true // Allow cross-chain replay for tests 45 | ); 46 | assertEq( 47 | _getERC1967Implementation(_eoa), address(_implementation), "Implementation should be set to new address" 48 | ); 49 | } 50 | 51 | function test_succeeds_whenImplementationSlotAlreadySet() public { 52 | _initializeProxy(); // initialize the proxy with implementation 53 | assertEq( 54 | _getERC1967Implementation(_eoa), 55 | address(_implementation), 56 | "Implementation should be set to standard implementation" 57 | ); 58 | MockValidator newImplementationValidator = new MockValidator(_newImplementation); 59 | 60 | bytes memory signature = _signSetImplementationData( 61 | _EOA_PRIVATE_KEY, 62 | address(_newImplementation), 63 | 0, // chainId 0 for cross-chain 64 | "", // empty calldata 65 | address(newImplementationValidator) 66 | ); 67 | 68 | EIP7702Proxy(_eoa).setImplementation( 69 | address(_newImplementation), 70 | "", 71 | address(newImplementationValidator), // same validator 72 | type(uint256).max, 73 | signature, 74 | true // allow cross-chain replay 75 | ); 76 | 77 | assertEq( 78 | _getERC1967Implementation(_eoa), address(_newImplementation), "Implementation should be set to new address" 79 | ); 80 | } 81 | 82 | function test_emitsUpgradedEvent() public { 83 | bytes memory initArgs = _createInitArgs(_newOwner); 84 | bytes memory signature = _signSetImplementationData( 85 | _EOA_PRIVATE_KEY, 86 | address(_implementation), 87 | 0, // chainId 0 for cross-chain 88 | initArgs, 89 | address(_validator) 90 | ); 91 | 92 | vm.expectEmit(true, false, false, false, address(_eoa)); 93 | emit IERC1967.Upgraded(address(_implementation)); 94 | 95 | EIP7702Proxy(_eoa).setImplementation( 96 | address(_implementation), 97 | initArgs, 98 | address(_validator), 99 | type(uint256).max, 100 | signature, 101 | true // Allow cross-chain replay for tests 102 | ); 103 | } 104 | 105 | function test_succeeds_withChainIdZero() public { 106 | assertEq(_getERC1967Implementation(_eoa), address(0), "Implementation should start empty"); 107 | bytes memory initArgs = _createInitArgs(_newOwner); 108 | bytes memory signature = _signSetImplementationData( 109 | _EOA_PRIVATE_KEY, 110 | address(_implementation), 111 | 0, // chainId 0 for cross-chain 112 | initArgs, 113 | address(_validator) 114 | ); 115 | EIP7702Proxy(_eoa).setImplementation( 116 | address(_implementation), 117 | initArgs, 118 | address(_validator), 119 | type(uint256).max, 120 | signature, 121 | true // Allow cross-chain replay 122 | ); 123 | assertEq( 124 | _getERC1967Implementation(_eoa), address(_implementation), "Implementation should be set to new address" 125 | ); 126 | } 127 | 128 | function test_succeeds_withNonzeroChainId() public { 129 | assertEq(_getERC1967Implementation(_eoa), address(0), "Implementation should start empty"); 130 | bytes memory initArgs = _createInitArgs(_newOwner); 131 | bytes memory signature = _signSetImplementationData( 132 | _EOA_PRIVATE_KEY, 133 | address(_implementation), 134 | block.chainid, // non-zero chainId 135 | initArgs, 136 | address(_validator) 137 | ); 138 | 139 | EIP7702Proxy(_eoa).setImplementation( 140 | address(_implementation), initArgs, address(_validator), type(uint256).max, signature, false 141 | ); 142 | assertEq( 143 | _getERC1967Implementation(_eoa), address(_implementation), "Implementation should be set to new address" 144 | ); 145 | } 146 | 147 | function test_reverts_whenPastSignatureExpiry(uint256 expiry) public { 148 | vm.assume(expiry < type(uint256).max); 149 | 150 | bytes memory initArgs = _createInitArgs(_newOwner); 151 | uint256 nonce = _nonceTracker.nonces(_eoa); 152 | address currentImpl = _getERC1967Implementation(_eoa); 153 | 154 | bytes32 initHash = keccak256( 155 | abi.encode( 156 | _IMPLEMENTATION_SET_TYPEHASH, 157 | block.chainid, 158 | _proxy, 159 | nonce, 160 | currentImpl, 161 | address(_implementation), 162 | keccak256(initArgs), 163 | address(_validator), 164 | expiry 165 | ) 166 | ); 167 | 168 | (uint8 v, bytes32 r, bytes32 s) = vm.sign(_EOA_PRIVATE_KEY, initHash); 169 | bytes memory signature = abi.encodePacked(r, s, v); 170 | 171 | vm.warp(expiry + 1); // warp past the expiry 172 | vm.expectRevert(EIP7702Proxy.SignatureExpired.selector); 173 | EIP7702Proxy(_eoa).setImplementation( 174 | address(_implementation), initArgs, address(_validator), expiry, signature, false 175 | ); 176 | } 177 | 178 | function test_reverts_whenChainIdMismatch(uint256 wrongChainId) public { 179 | assertEq(_getERC1967Implementation(_eoa), address(0), "Implementation should start empty"); 180 | 181 | vm.assume(wrongChainId != block.chainid); 182 | vm.assume(wrongChainId != 0); 183 | 184 | bytes memory initArgs = _createInitArgs(_newOwner); 185 | 186 | bytes32 initHash = keccak256( 187 | abi.encode( 188 | _IMPLEMENTATION_SET_TYPEHASH, 189 | wrongChainId, 190 | _proxy, 191 | _nonceTracker.nonces(_eoa), 192 | _getERC1967Implementation(_eoa), 193 | address(_implementation), 194 | keccak256(initArgs), 195 | address(_validator), 196 | type(uint256).max 197 | ) 198 | ); 199 | 200 | (uint8 v, bytes32 r, bytes32 s) = vm.sign(_EOA_PRIVATE_KEY, initHash); 201 | bytes memory signature = abi.encodePacked(r, s, v); 202 | 203 | vm.expectRevert(EIP7702Proxy.InvalidSignature.selector); 204 | EIP7702Proxy(_eoa).setImplementation( 205 | address(_implementation), initArgs, address(_validator), type(uint256).max, signature, false 206 | ); 207 | } 208 | 209 | function test_succeeds_whenSettingToSameImplementation() public { 210 | _initializeProxy(); // initialize the proxy with implementation 211 | assertEq( 212 | _getERC1967Implementation(_eoa), 213 | address(_implementation), 214 | "Implementation should be set to standard implementation" 215 | ); 216 | bytes memory signature = _signSetImplementationData( 217 | _EOA_PRIVATE_KEY, 218 | address(_implementation), // same implementation 219 | 0, // chainId 0 for cross-chain 220 | "", // empty calldata 221 | address(_validator) 222 | ); 223 | 224 | EIP7702Proxy(_eoa).setImplementation( 225 | address(_implementation), 226 | "", 227 | address(_validator), // same validator 228 | type(uint256).max, 229 | signature, 230 | true // allow cross-chain replay 231 | ); 232 | 233 | assertEq( 234 | _getERC1967Implementation(_eoa), 235 | address(_implementation), 236 | "Implementation should be set to same original address" 237 | ); 238 | } 239 | 240 | function test_nonceIncrements_afterSuccessfulSetImplementation(uint8 numResets) public { 241 | vm.assume(numResets > 0 && numResets < 10); 242 | 243 | _initializeProxy(); // initialize the proxy with owner 244 | 245 | uint256 initialNonce = _nonceTracker.nonces(_eoa); 246 | 247 | for (uint8 i = 0; i < numResets; i++) { 248 | MockImplementation nextImplementation = new MockImplementation(); 249 | MockValidator nextImplementationValidator = new MockValidator(nextImplementation); 250 | bytes memory signature = _signSetImplementationData( 251 | _EOA_PRIVATE_KEY, address(nextImplementation), block.chainid, "", address(nextImplementationValidator) 252 | ); 253 | EIP7702Proxy(_eoa).setImplementation( 254 | address(nextImplementation), 255 | "", 256 | address(nextImplementationValidator), 257 | type(uint256).max, 258 | signature, 259 | false 260 | ); 261 | 262 | assertEq(_nonceTracker.nonces(_eoa), initialNonce + i + 1, "Nonce should increment by one after each reset"); 263 | } 264 | } 265 | 266 | function test_reverts_whenCalldataReverts() public { 267 | _initializeProxy(); // initialize the proxy with owner 268 | bytes memory reinitArgs = _createInitArgs(_newOwner); 269 | bytes memory signature = _signSetImplementationData( 270 | _EOA_PRIVATE_KEY, 271 | address(_implementation), 272 | 0, // chainId 0 for cross-chain 273 | reinitArgs, // attempt to reinitialize already-initialized implementation 274 | address(_validator) 275 | ); 276 | 277 | vm.expectRevert(); 278 | EIP7702Proxy(_eoa).setImplementation( 279 | address(_implementation), 280 | reinitArgs, 281 | address(_validator), 282 | type(uint256).max, 283 | signature, 284 | true // allow cross-chain replay 285 | ); 286 | } 287 | 288 | function test_reverts_whenValidatorReverts() public { 289 | MockRevertingValidator revertingValidator = new MockRevertingValidator(); 290 | 291 | bytes memory reinitArgs = _createInitArgs(_newOwner); 292 | bytes32 hash = keccak256( 293 | abi.encode( 294 | _IMPLEMENTATION_SET_TYPEHASH, 295 | 0, 296 | _proxy, 297 | _nonceTracker.nonces(_eoa), 298 | _getERC1967Implementation(_eoa), 299 | address(_implementation), 300 | keccak256(reinitArgs), 301 | address(revertingValidator), // validator that always reverts 302 | type(uint256).max 303 | ) 304 | ); 305 | (uint8 v, bytes32 r, bytes32 s) = vm.sign(_EOA_PRIVATE_KEY, hash); 306 | bytes memory signature = abi.encodePacked(r, s, v); 307 | 308 | vm.expectRevert(EIP7702Proxy.InvalidValidation.selector); 309 | EIP7702Proxy(_eoa).setImplementation( 310 | address(_implementation), reinitArgs, address(revertingValidator), type(uint256).max, signature, true 311 | ); 312 | } 313 | 314 | function test_reverts_whenSignatureEmpty() public { 315 | bytes memory signature = new bytes(0); 316 | 317 | vm.expectRevert(abi.encodeWithSignature("ECDSAInvalidSignatureLength(uint256)", 0)); 318 | EIP7702Proxy(_eoa).setImplementation( 319 | address(_implementation), "", address(_validator), type(uint256).max, signature, false 320 | ); 321 | } 322 | 323 | function test_reverts_whenSignatureLengthInvalid(uint8 length) public { 324 | vm.assume(length != 0); 325 | vm.assume(length != 65); 326 | 327 | bytes memory signature = new bytes(length); 328 | 329 | vm.expectRevert(abi.encodeWithSignature("ECDSAInvalidSignatureLength(uint256)", length)); 330 | EIP7702Proxy(_eoa).setImplementation( 331 | address(_implementation), "", address(_validator), type(uint256).max, signature, false 332 | ); 333 | } 334 | 335 | function test_reverts_whenSignatureInvalid(bytes32 r, bytes32 s, uint8 v) public { 336 | vm.assume(v != 27 && v != 28); 337 | 338 | bytes memory signature = abi.encodePacked(r, s, v); 339 | 340 | assertEq(signature.length, 65, "Signature should be 65 bytes"); 341 | 342 | vm.expectRevert(); 343 | EIP7702Proxy(_eoa).setImplementation( 344 | address(_implementation), "", address(_validator), type(uint256).max, signature, false 345 | ); 346 | } 347 | 348 | function test_reverts_whenSignerWrong(uint128 wrongPk) public { 349 | vm.assume(wrongPk != 0); 350 | vm.assume(wrongPk != _EOA_PRIVATE_KEY); 351 | 352 | bytes32 messageHash = keccak256( 353 | abi.encode( 354 | _IMPLEMENTATION_SET_TYPEHASH, 355 | 0, 356 | _proxy, 357 | _nonceTracker.nonces(_eoa), 358 | _getERC1967Implementation(_eoa), 359 | address(_implementation), 360 | keccak256(""), 361 | address(_validator), 362 | type(uint256).max 363 | ) 364 | ); 365 | (uint8 v, bytes32 r, bytes32 s) = vm.sign(wrongPk, messageHash); 366 | bytes memory signature = abi.encodePacked(r, s, v); 367 | 368 | vm.expectRevert(EIP7702Proxy.InvalidSignature.selector); 369 | EIP7702Proxy(_eoa).setImplementation( 370 | address(_implementation), "", address(_validator), type(uint256).max, signature, true 371 | ); 372 | } 373 | 374 | function test_reverts_whenSignatureReplayedWithDifferentProxy(uint128 secondProxyPk) public { 375 | // Deploy and initialize second proxy 376 | vm.assume(secondProxyPk != 0); 377 | vm.assume(secondProxyPk != uint128(_EOA_PRIVATE_KEY)); 378 | 379 | address payable secondProxy = payable(vm.addr(secondProxyPk)); 380 | vm.assume(address(secondProxy) != address(_eoa)); 381 | assumeNotPrecompile(address(secondProxy)); 382 | 383 | bytes memory proxyCode = address(_proxy).code; 384 | vm.etch(secondProxy, proxyCode); 385 | bytes memory initArgs = _createInitArgs(_newOwner); 386 | 387 | bytes32 messageHash = keccak256( 388 | abi.encode( 389 | _IMPLEMENTATION_SET_TYPEHASH, 390 | 0, 391 | _proxy, 392 | _nonceTracker.nonces(secondProxy), 393 | _getERC1967Implementation(secondProxy), 394 | address(_implementation), 395 | keccak256(initArgs), 396 | address(_validator), 397 | type(uint256).max 398 | ) 399 | ); 400 | (uint8 v, bytes32 r, bytes32 s) = vm.sign(secondProxyPk, messageHash); 401 | bytes memory initSecondProxySignature = abi.encodePacked(r, s, v); 402 | EIP7702Proxy(secondProxy).setImplementation( 403 | address(_implementation), initArgs, address(_validator), type(uint256).max, initSecondProxySignature, true 404 | ); 405 | 406 | // create signature for original proxy 407 | bytes memory signature = _signSetImplementationData( 408 | _EOA_PRIVATE_KEY, address(_newImplementation), block.chainid, "", address(_validator) 409 | ); 410 | 411 | // attempt to play signature on second proxy 412 | vm.expectRevert(EIP7702Proxy.InvalidSignature.selector); 413 | EIP7702Proxy(secondProxy).setImplementation( 414 | address(_newImplementation), "", address(_validator), type(uint256).max, signature, false 415 | ); 416 | } 417 | 418 | function test_reverts_whenSignatureReplayedWithDifferentImplementation(address differentImpl) public { 419 | vm.assume(differentImpl != address(0)); 420 | vm.assume(differentImpl != address(_implementation)); 421 | assumeNotPrecompile(differentImpl); 422 | 423 | bytes memory initArgs = _createInitArgs(_newOwner); 424 | bytes memory signature = _signSetImplementationData( 425 | _EOA_PRIVATE_KEY, 426 | address(_implementation), // sign over standard implementation 427 | block.chainid, 428 | initArgs, 429 | address(_validator) 430 | ); 431 | 432 | vm.expectRevert(EIP7702Proxy.InvalidSignature.selector); 433 | EIP7702Proxy(_eoa).setImplementation( 434 | differentImpl, // different implementation than signed over 435 | initArgs, 436 | address(_validator), 437 | type(uint256).max, 438 | signature, 439 | false 440 | ); 441 | } 442 | 443 | function test_reverts_whenSignatureReplayedWithDifferentArgs(bytes memory differentInitArgs) public { 444 | bytes memory initArgs = _createInitArgs(_newOwner); 445 | vm.assume(keccak256(differentInitArgs) != keccak256(initArgs)); 446 | bytes memory signature = _signSetImplementationData( 447 | _EOA_PRIVATE_KEY, address(_implementation), block.chainid, initArgs, address(_validator) 448 | ); 449 | 450 | vm.expectRevert(EIP7702Proxy.InvalidSignature.selector); 451 | EIP7702Proxy(_eoa).setImplementation( 452 | address(_implementation), differentInitArgs, address(_validator), type(uint256).max, signature, false 453 | ); 454 | } 455 | 456 | function test_reverts_whenSignatureReplayedWithDifferentValidator(address differentValidator) public { 457 | vm.assume(differentValidator != address(_validator)); 458 | vm.assume(differentValidator != address(0)); 459 | 460 | bytes memory initArgs = _createInitArgs(_newOwner); 461 | bytes memory signature = _signSetImplementationData( 462 | _EOA_PRIVATE_KEY, address(_implementation), block.chainid, initArgs, address(_validator) 463 | ); 464 | 465 | vm.expectRevert(EIP7702Proxy.InvalidSignature.selector); 466 | EIP7702Proxy(_eoa).setImplementation( 467 | address(_implementation), initArgs, differentValidator, type(uint256).max, signature, false 468 | ); 469 | } 470 | 471 | function test_reverts_whenSignatureUsesWrongNonce(uint256 wrongNonce) public { 472 | uint256 currentNonce = _nonceTracker.nonces(_eoa); 473 | 474 | vm.assume(wrongNonce != currentNonce); 475 | 476 | bytes memory initArgs = _createInitArgs(_newOwner); 477 | bytes32 initHash = keccak256( 478 | abi.encode( 479 | _IMPLEMENTATION_SET_TYPEHASH, 480 | block.chainid, 481 | _proxy, 482 | wrongNonce, // wrong nonce 483 | _getERC1967Implementation(_eoa), 484 | address(_implementation), 485 | keccak256(initArgs), 486 | address(_validator), 487 | type(uint256).max 488 | ) 489 | ); 490 | (uint8 v, bytes32 r, bytes32 s) = vm.sign(_EOA_PRIVATE_KEY, initHash); 491 | bytes memory signature = abi.encodePacked(r, s, v); 492 | 493 | vm.expectRevert(EIP7702Proxy.InvalidSignature.selector); 494 | EIP7702Proxy(_eoa).setImplementation( 495 | address(_implementation), initArgs, address(_validator), type(uint256).max, signature, false 496 | ); 497 | } 498 | 499 | function test_reverts_whenSignatureReplayedWithSameNonce() public { 500 | bytes memory initArgs = _createInitArgs(_newOwner); 501 | bytes memory signature = _signSetImplementationData( 502 | _EOA_PRIVATE_KEY, address(_implementation), block.chainid, initArgs, address(_validator) 503 | ); 504 | EIP7702Proxy(_eoa).setImplementation( 505 | address(_implementation), initArgs, address(_validator), type(uint256).max, signature, false 506 | ); 507 | assertEq( 508 | _getERC1967Implementation(_eoa), 509 | address(_implementation), 510 | "Implementation should be set to standard implementation" 511 | ); 512 | 513 | // attempt to replay signature with same nonce 514 | vm.expectRevert(EIP7702Proxy.InvalidSignature.selector); 515 | EIP7702Proxy(_eoa).setImplementation( 516 | address(_implementation), initArgs, address(_validator), type(uint256).max, signature, false 517 | ); 518 | } 519 | 520 | function test_reverts_whenSignatureUsesWrongCurrentImplementation() public { 521 | assertEq(_getERC1967Implementation(_eoa), address(0), "Implementation should start empty"); 522 | 523 | MockImplementation wrongCurrentImpl = new MockImplementation(); 524 | 525 | bytes memory initArgs = _createInitArgs(_newOwner); 526 | bytes32 initHash = keccak256( 527 | abi.encode( 528 | _IMPLEMENTATION_SET_TYPEHASH, 529 | block.chainid, 530 | _proxy, 531 | _nonceTracker.nonces(_eoa), 532 | address(wrongCurrentImpl), // wrong current implementation 533 | address(_implementation), 534 | keccak256(initArgs), 535 | address(_validator), 536 | type(uint256).max 537 | ) 538 | ); 539 | (uint8 v, bytes32 r, bytes32 s) = vm.sign(_EOA_PRIVATE_KEY, initHash); 540 | bytes memory signature = abi.encodePacked(r, s, v); 541 | 542 | vm.expectRevert(EIP7702Proxy.InvalidSignature.selector); 543 | EIP7702Proxy(_eoa).setImplementation( 544 | address(_implementation), initArgs, address(_validator), type(uint256).max, signature, false 545 | ); 546 | } 547 | 548 | function test_reverts_whenImplementationDoesNotMatchValidator() public { 549 | MockImplementation expectedImpl = new MockImplementation(); 550 | MockImplementation actualImpl = new MockImplementation(); 551 | 552 | // Create mock validator expecting a specific implementation 553 | MockValidator validator = new MockValidator(expectedImpl); 554 | 555 | bytes memory signature = 556 | _signSetImplementationData(_EOA_PRIVATE_KEY, address(actualImpl), 0, "", address(validator)); 557 | 558 | vm.expectRevert( 559 | abi.encodeWithSelector(IAccountStateValidator.InvalidImplementation.selector, address(actualImpl)) 560 | ); 561 | EIP7702Proxy(_eoa).setImplementation( 562 | address(actualImpl), "", address(validator), type(uint256).max, signature, true 563 | ); 564 | } 565 | 566 | function test_succeeds_whenImplementationMatchesValidator() public { 567 | // Create mock validator with matching implementation 568 | MockValidator validator = new MockValidator(_implementation); 569 | 570 | bytes memory initArgs = _createInitArgs(_newOwner); 571 | bytes memory signature = 572 | _signSetImplementationData(_EOA_PRIVATE_KEY, address(_implementation), 0, initArgs, address(validator)); 573 | 574 | // Should not revert 575 | EIP7702Proxy(_eoa).setImplementation( 576 | address(_implementation), initArgs, address(validator), type(uint256).max, signature, true 577 | ); 578 | 579 | assertEq( 580 | _getERC1967Implementation(_eoa), 581 | address(_implementation), 582 | "Implementation should be set to expected address" 583 | ); 584 | } 585 | 586 | function test_reverts_whenValidatorReturnsWrongMagicValue() public { 587 | MockInvalidValidator invalidValidator = new MockInvalidValidator(); 588 | 589 | bytes memory initArgs = _createInitArgs(_newOwner); 590 | bytes memory signature = _signSetImplementationData( 591 | _EOA_PRIVATE_KEY, address(_implementation), 0, initArgs, address(invalidValidator) 592 | ); 593 | 594 | vm.expectRevert(EIP7702Proxy.InvalidValidation.selector); 595 | EIP7702Proxy(_eoa).setImplementation( 596 | address(_implementation), initArgs, address(invalidValidator), type(uint256).max, signature, true 597 | ); 598 | } 599 | 600 | function test_reverts_whenValidatorIsEOA() public { 601 | address eoaValidator = makeAddr("eoaValidator"); 602 | 603 | bytes memory initArgs = _createInitArgs(_newOwner); 604 | bytes memory signature = 605 | _signSetImplementationData(_EOA_PRIVATE_KEY, address(_implementation), 0, initArgs, eoaValidator); 606 | 607 | vm.expectRevert(); 608 | EIP7702Proxy(_eoa).setImplementation( 609 | address(_implementation), initArgs, eoaValidator, type(uint256).max, signature, true 610 | ); 611 | } 612 | 613 | function test_reverts_whenValidatorIsNonCompliantContract() public { 614 | // Deploy a contract that doesn't implement IAccountStateValidator 615 | MockImplementation nonCompliantValidator = new MockImplementation(); 616 | 617 | bytes memory initArgs = _createInitArgs(_newOwner); 618 | bytes memory signature = _signSetImplementationData( 619 | _EOA_PRIVATE_KEY, address(_implementation), 0, initArgs, address(nonCompliantValidator) 620 | ); 621 | 622 | vm.expectRevert(); 623 | EIP7702Proxy(_eoa).setImplementation( 624 | address(_implementation), initArgs, address(nonCompliantValidator), type(uint256).max, signature, true 625 | ); 626 | } 627 | 628 | function test_reverts_whenImplementationChangesItsOwnImplementation() public { 629 | // Create a chain of implementations 630 | MockImplementation finalImpl = new MockImplementation(); 631 | MockMaliciousImplementation maliciousImpl = new MockMaliciousImplementation(address(finalImpl)); 632 | 633 | // Create validator expecting the malicious implementation 634 | MockValidator validator = new MockValidator(maliciousImpl); 635 | 636 | // Try to initialize with the malicious implementation 637 | bytes memory initArgs = _createInitArgs(_newOwner); 638 | bytes memory signature = 639 | _signSetImplementationData(_EOA_PRIVATE_KEY, address(maliciousImpl), 0, initArgs, address(validator)); 640 | 641 | // Should revert because after initialization, the implementation 642 | // will be finalImpl but validator expects maliciousImpl 643 | vm.expectRevert( 644 | abi.encodeWithSelector(IAccountStateValidator.InvalidImplementation.selector, address(finalImpl)) 645 | ); 646 | EIP7702Proxy(_eoa).setImplementation( 647 | address(maliciousImpl), initArgs, address(validator), type(uint256).max, signature, true 648 | ); 649 | } 650 | } 651 | --------------------------------------------------------------------------------