From ade15c7658fcbf031df66cb8ec83473b4cf9994a Mon Sep 17 00:00:00 2001 From: Shine Li Date: Wed, 30 Jul 2025 15:15:04 +1000 Subject: [PATCH 1/3] describe wallet initialising and upgrade logic --- ARCHITECTURE.md | 103 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 ARCHITECTURE.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..5e00254a --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,103 @@ +# Wallet Architecture + +This document outlines the smart contract architecture for the wallet, detailing the initial deployment process, how upgrades are handled for existing wallets, and the role of the `LatestWalletImplLocator`. The architecture is designed to be both secure and flexible, using a combination of a minimalist proxy and upgradeable logic modules. + +## Core Components + +The system is composed of several key contracts that work together: + +1. **`WalletProxy.yul` (The Proxy)**: A minimal, gas-efficient transparent proxy written in Yul. This contract is the user-facing entry point for every wallet. Its only job is to forward all calls to a logic contract using `delegatecall`. It is immutable and its code never changes. The address of the logic contract is stored in the proxy's storage. + +2. **`StartupWalletImpl.sol` (The Bootloader)**: A one-time setup contract that acts as the _initial_ implementation for a newly deployed `WalletProxy`. Its sole purpose is to initialize the proxy with the latest version of the main wallet logic during the first transaction. + +3. **`LatestWalletImplLocator.sol` (The Locator)**: A simple, centralized contract that stores the address of the most current `MainModuleUpgradable` implementation. This contract acts as a pointer, allowing the bootloader to find the correct logic address for new wallets. + +4. **`MainModuleUpgradable.sol` (The Logic)**: The primary implementation contract containing the wallet's core business logic. It integrates various modules for features like authentication (`ModuleAuthUpgradable`), call execution (`ModuleCalls`), and upgrades (`ModuleUpdate`). This contract is designed to be upgradeable. + +5. **`Factory.sol` (The Factory)**: The contract responsible for deploying new wallet proxies. It only needs to know the addresses of `WalletProxy.yul` and `StartupWalletImpl.sol`, making it stable and rarely needing updates. + +--- + +## Flow 1: Wallet Initialization (Deployment & First Transaction) + +This two-step process ensures that newly created wallets always start with the latest and most secure code, without requiring changes to the factory contract. + +```mermaid +graph TD; + subgraph "Step 1: Deployment" + A[Factory.sol] -- "deploys" --> B{WalletProxy}; + B -- "points to" --> C[StartupWalletImpl]; + end + subgraph "Step 2: First Transaction" + D[User] -- "sends first tx to" --> B; + B -- "delegatecall" --> C; + C -- "reads from" --> E[LatestWalletImplLocator]; + E -- "returns latest_address" --> C; + C -- "1. updates proxy storage with latest_address
2. delegatecalls tx to final destination" --> F[MainModuleUpgradable]; + end + + classDef proxy fill:#f66,stroke:#333,stroke-width:2px; + classDef bootloader fill:#f9f,stroke:#333,stroke-width:2px; + classDef logic fill:#9cf,stroke:#333,stroke-width:2px; + + class B proxy; + class C bootloader; + class F logic; +``` + +1. **Deployment**: A user calls the `Factory.sol` contract to create a new wallet. The factory deploys a new `WalletProxy` instance and sets its implementation address to a standard, fixed `StartupWalletImpl` contract. + +2. **First Transaction**: + - The user sends the first transaction to their new wallet's address (the `WalletProxy`). + - The proxy, still pointing to the bootloader, forwards the call to `StartupWalletImpl.sol`. + - The bootloader's fallback function executes. It calls the `LatestWalletImplLocator` to fetch the address of the most recent `MainModuleUpgradable` implementation. + - It then **updates the proxy's storage**, replacing its own address with the new logic address. + - Finally, it forwards the original transaction to the new logic contract (`MainModuleUpgradable`) using another `delegatecall`. + +From this point on, the `StartupWalletImpl` is never used again for this wallet. The proxy now permanently points to the main logic contract. + +--- + +## Flow 2: Upgrading Existing Wallets + +Upgrading a wallet that is already active is a separate process that **does not** involve the `StartupWalletImpl` or `LatestWalletImplLocator`. Upgrades are performed on a per-wallet basis, ensuring user sovereignty. + +```mermaid +graph TD; + subgraph "Upgrade Process" + A[Wallet Owner] -- "sends tx: updateImplementation(V2_addr)" --> B{WalletProxy}; + B -- "delegatecall" --> C[MainModuleUpgradable V1]; + C -- "updates proxy storage with" --> D[MainModuleUpgradable V2]; + B -. "now points to" .-> D; + end + + classDef proxy fill:#f66,stroke:#333,stroke-width:2px; + classDef logic fill:#9cf,stroke:#333,stroke-width:2px; + + class B proxy; + class C,D logic; +``` + +1. **Deploy New Logic**: A new, improved version of the `MainModuleUpgradable` contract is deployed (e.g., `MainModuleUpgradableV2`). + +2. **Initiate Upgrade**: The owner of a wallet sends a transaction to their `WalletProxy` address, calling the `updateImplementation(address newImplementation)` function with the address of the new logic contract. + +3. **Execute Upgrade**: + - The proxy `delegatecall`s to the current implementation (`V1`). + - The `ModuleUpdate` logic within `V1` verifies that the caller is authorized (i.e., the wallet itself). + - It then updates the implementation address in the proxy's storage to point to the `V2` address. + +All subsequent transactions to the proxy will be handled by the `V2` logic. + +--- + +## When to Update the `LatestWalletImplLocator` + +The `LatestWalletImplLocator` should be updated only when a new, definitive version of the `MainModuleUpgradable` logic contract has been deployed and is ready for all **newly created wallets**. + +**Update this address when:** + +- A new version of `MainModuleUpgradable` is deployed to production. +- This new version represents the new "gold standard" for wallets that should be used by default. + +Changing the address in the locator **does not affect existing wallets**. It only determines which logic contract new wallets will use after their first transaction. The update must be performed by the authorized owner of the `LatestWalletImplLocator` contract. From c44dc8249b37d7c0aa4ba2ee30e690ba374658e6 Mon Sep 17 00:00:00 2001 From: Shine Li Date: Fri, 1 Aug 2025 11:50:16 +1000 Subject: [PATCH 2/3] Adding code example for upgrade --- ARCHITECTURE.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5e00254a..d50776df 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -78,9 +78,56 @@ graph TD; class C,D logic; ``` -1. **Deploy New Logic**: A new, improved version of the `MainModuleUpgradable` contract is deployed (e.g., `MainModuleUpgradableV2`). +1. **Deploy New Logic**: A new, improved version of the `MainModuleUpgradable` contract is deployed (e.g., `MainModuleUpgradableV2`). This is typically done via a script that uses a CREATE2 factory for a deterministic address. -2. **Initiate Upgrade**: The owner of a wallet sends a transaction to their `WalletProxy` address, calling the `updateImplementation(address newImplementation)` function with the address of the new logic contract. + _Example using hardhat scripts:_ + + ```typescript + // From scripts/step4.ts - deploys a new logic contract + import { deployContractViaCREATE2 } from './contract'; + + // ... inside an async function ... + const newLogicContract = await deployContractViaCREATE2( + env, // Environment details + wallets, // Wallet options + 'MainModuleDynamicAuth', // Or your new contract name + [factory.address, startupWalletImpl.address] // Constructor arguments + ); + + console.log(`New logic contract deployed at: ${newLogicContract.address}`); + ``` + +2. **Initiate Upgrade**: The owner of a wallet sends a transaction to their `WalletProxy` address, calling the `updateImplementation(address newImplementation)` function with the address of the new logic contract. Because of the `onlySelf` modifier on the function, this must be sent as a meta-transaction from the wallet owner. + + _Example of building and sending the upgrade transaction:_ + + ```typescript + import { ethers } from 'ethers'; + + // Assume 'wallet' is the ethers contract instance of the user's wallet proxy + // and 'owner' is the signer object for the wallet owner. + // 'newLogicContractAddress' is the address from step 1. + + // Encode the function call to 'updateImplementation' + const updateData = wallet.interface.encodeFunctionData('updateImplementation', [newLogicContractAddress]); + + // Construct the meta-transaction + const transaction = { + delegateCall: false, + revertOnError: true, + gasLimit: 1000000, // Set an appropriate gas limit + target: wallet.address, // The call is to the wallet itself + value: 0, + data: updateData + }; + + // Sign and execute the meta-transaction + // (This is a simplified example; see tests/utils/helpers.ts for a full implementation) + const receipt = await signAndExecuteMetaTx(wallet, owner, [transaction]); + await receipt.wait(); + + console.log('Wallet implementation updated successfully.'); + ``` 3. **Execute Upgrade**: - The proxy `delegatecall`s to the current implementation (`V1`). From fd174fb4c7089fcd1e1cd735990443a3d82ca5d6 Mon Sep 17 00:00:00 2001 From: Shine Li Date: Wed, 6 Aug 2025 16:10:57 +1000 Subject: [PATCH 3/3] add ERC4337 method --- src/contracts/interfaces/erc4337/IAccount.sol | 28 +++ .../modules/MainModuleDynamicAuth.sol | 33 ++- tests/MainModule.spec.ts | 212 +++++++++++++++++- 3 files changed, 266 insertions(+), 7 deletions(-) create mode 100644 src/contracts/interfaces/erc4337/IAccount.sol diff --git a/src/contracts/interfaces/erc4337/IAccount.sol b/src/contracts/interfaces/erc4337/IAccount.sol new file mode 100644 index 00000000..a5af4fad --- /dev/null +++ b/src/contracts/interfaces/erc4337/IAccount.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.17; + +interface IAccount { + struct UserOperation { + address sender; + uint256 nonce; + bytes initCode; + bytes callData; + uint256 callGasLimit; + uint256 verificationGasLimit; + uint256 preVerificationGas; + uint256 maxFeePerGas; + uint256 maxPriorityFeePerGas; + bytes paymasterAndData; + bytes signature; + } + + /// @param userOp The userOp to validate. + /// @param userOpHash The hash of the userOp. + /// @param missingAccountFunds The amount of funds missing from the account to pay for the userOp. + /// @return validationData For valid signatures, returns a packed value of authorizer, validUntil, and validAfter. + function validateUserOp( + UserOperation calldata userOp, + bytes32 userOpHash, + uint256 missingAccountFunds + ) external returns (uint256 validationData); +} \ No newline at end of file diff --git a/src/contracts/modules/MainModuleDynamicAuth.sol b/src/contracts/modules/MainModuleDynamicAuth.sol index 149ce806..5b6dc020 100644 --- a/src/contracts/modules/MainModuleDynamicAuth.sol +++ b/src/contracts/modules/MainModuleDynamicAuth.sol @@ -6,6 +6,7 @@ import "./commons/ModuleAuthDynamic.sol"; import "./commons/ModuleReceivers.sol"; import "./commons/ModuleCalls.sol"; import "./commons/ModuleUpdate.sol"; +import "../interfaces/erc4337/IAccount.sol"; /** @@ -20,7 +21,8 @@ contract MainModuleDynamicAuth is ModuleAuthDynamic, ModuleCalls, ModuleReceivers, - ModuleUpdate + ModuleUpdate, + IAccount { // solhint-disable-next-line no-empty-blocks @@ -43,9 +45,38 @@ contract MainModuleDynamicAuth is ModuleReceivers, ModuleUpdate ) pure returns (bool) { + if (_interfaceID == type(IAccount).interfaceId) { + return true; + } return super.supportsInterface(_interfaceID); } + function validateUserOp( + UserOperation calldata userOp, + bytes32 userOpHash, + uint256 missingAccountFunds + ) external override returns (uint256 validationData) { + // Check if there are missing funds. + // This is a basic check, a full implementation would require more logic. + if (missingAccountFunds > 0) { + revert("Not enough funds to cover transaction costs"); + } + + // Use the existing internal signature validation function. + // The nonce from the userOp is part of the userOpHash. + // Per ERC-4337, return 1 on signature failure. This is interpreted by the + // EntryPoint as a packed value where the `authorizer` field is 1, and the + // timestamp fields are 0. + if (!_signatureValidation(userOpHash, userOp.signature)) { + return 1; + } + + // Return 0 for a standard signature validation. This is interpreted by the + // EntryPoint as a packed value where the `authorizer`, `validUntil`, and + // `validAfter` fields are all 0. + return 0; + } + function version() external pure virtual returns (uint256) { return 1; } diff --git a/tests/MainModule.spec.ts b/tests/MainModule.spec.ts index 75e7eb07..c61a9727 100644 --- a/tests/MainModule.spec.ts +++ b/tests/MainModule.spec.ts @@ -27,6 +27,7 @@ import { Factory__factory, MainModule__factory, MainModuleUpgradable__factory, + MainModuleDynamicAuth__factory, RequireUtils__factory, HookCallerMock__factory, CallReceiverMock__factory, @@ -45,11 +46,28 @@ import { DelegateCallMock, GasBurnerMock, RequireUtils, - GasBurnerMock__factory + GasBurnerMock__factory, + MainModuleDynamicAuth, + StartupWalletImpl__factory, + LatestWalletImplLocator__factory } from '../src' const CallReceiverMockArtifact = artifacts.require('CallReceiverMock') +type UserOperation = { + sender: string + nonce: ethers.BigNumberish + initCode: ethers.BytesLike + callData: ethers.BytesLike + callGasLimit: ethers.BigNumberish + verificationGasLimit: ethers.BigNumberish + preVerificationGas: ethers.BigNumberish + maxFeePerGas: ethers.BigNumberish + maxPriorityFeePerGas: ethers.BigNumberish + paymasterAndData: ethers.BytesLike + signature: ethers.BytesLike +} + const optimalGasLimit = ethers.constants.Two.pow(21) ethers.utils.Logger.setLogLevel(ethers.utils.Logger.levels.ERROR) @@ -3999,9 +4017,191 @@ contract('MainModule', (accounts: string[]) => { expect(await callReceiver3.lastValB()).to.equal(expected3) }) }) -}) -// 0x000102010b1f05b9dd385e2683500bdfec03b53b0d9acb8f004700010001d4c175bfe46abb1138ad97ec9560aae4b745f0b2957986b3367dbaa3486e40497096518c964bad007597f625e2e978d1d6f5096ed2783cb8ceeb828088e81a811b020303 -// 0x000102010b1f05b9dd385e2683500bdfec03b53b0d9acb8f004700010001d4c175bfe46abb1138ad97ec9560aae4b745f0b2957986b3367dbaa3486e40497096518c964bad007597f625e2e978d1d6f5096ed2783cb8ceeb828088e81a811b0203 -// 00010001d4c175bfe46abb1138ad97ec9560aae4b745f0b2957986b3367dbaa3486e40497096518c964bad007597f625e2e978d1d6f5096ed2783cb8ceeb828088e81a811b02 -// d4c175bfe46abb1138ad97ec9560aae4b745f0b2957986b3367dbaa3486e40497096518c964bad007597f625e2e978d1d6f5096ed2783cb8ceeb828088e81a811b02 + describe('ValidateUserOp', () => { + let dynamicAuth: MainModuleDynamicAuth + let startupWalletImpl: any + let moduleLocator: any + + beforeEach(async () => { + owner = new ethers.Wallet(ethers.utils.randomBytes(32)) + + // Deploy StartupWalletImpl and LatestWalletImplLocator + moduleLocator = await new LatestWalletImplLocator__factory(signer).deploy(await signer.getAddress(), await signer.getAddress()) + startupWalletImpl = await new StartupWalletImpl__factory(signer).deploy(moduleLocator.address) + + const moduleFactory = new MainModuleDynamicAuth__factory(signer) + const logic = await moduleFactory.deploy(factory.address, startupWalletImpl.address) + + const salt = encodeImageHash(1, [{ weight: 1, address: owner.address }]) + await factory.deploy(logic.address, salt) + + const walletAddress = addressOf(factory.address, logic.address, salt) + dynamicAuth = MainModuleDynamicAuth__factory.connect(walletAddress, signer) + + // Fund the wallet for tests that might require it + await signer.sendTransaction({ + to: dynamicAuth.address, + value: ethers.utils.parseEther('1.0') + }) + }) + + it('Should return 0 for a valid signature', async () => { + const userOp: UserOperation = { + sender: dynamicAuth.address, + nonce: 0, + initCode: [], + callData: [], + callGasLimit: 100000, + verificationGasLimit: 150000, + preVerificationGas: 50000, + maxFeePerGas: 10, + maxPriorityFeePerGas: 2, + paymasterAndData: [], + signature: [] + } + + const userOpHash = ethers.utils.keccak256( + ethers.utils.defaultAbiCoder.encode( + [ + 'address', + 'uint256', + 'bytes32', + 'bytes32', + 'uint256', + 'uint256', + 'uint256', + 'uint256', + 'uint256', + 'bytes32' + ], + [ + userOp.sender, + userOp.nonce, + ethers.utils.keccak256(userOp.initCode), + ethers.utils.keccak256(userOp.callData), + userOp.callGasLimit, + userOp.verificationGasLimit, + userOp.preVerificationGas, + userOp.maxFeePerGas, + userOp.maxPriorityFeePerGas, + ethers.utils.keccak256(userOp.paymasterAndData) + ] + ) + ) + + const signature = await walletSign(owner, userOpHash) + userOp.signature = signature + + const result = await dynamicAuth.validateUserOp(userOp, userOpHash, 0) + expect(result).to.equal(0) + }) + + it('Should return 1 for an invalid signature', async () => { + const userOp: UserOperation = { + sender: dynamicAuth.address, + nonce: 0, + initCode: [], + callData: [], + callGasLimit: 100000, + verificationGasLimit: 150000, + preVerificationGas: 50000, + maxFeePerGas: 10, + maxPriorityFeePerGas: 2, + paymasterAndData: [], + signature: [] + } + + const userOpHash = ethers.utils.keccak256( + ethers.utils.defaultAbiCoder.encode( + [ + 'address', + 'uint256', + 'bytes32', + 'bytes32', + 'uint256', + 'uint256', + 'uint256', + 'uint256', + 'uint256', + 'bytes32' + ], + [ + userOp.sender, + userOp.nonce, + ethers.utils.keccak256(userOp.initCode), + ethers.utils.keccak256(userOp.callData), + userOp.callGasLimit, + userOp.verificationGasLimit, + userOp.preVerificationGas, + userOp.maxFeePerGas, + userOp.maxPriorityFeePerGas, + ethers.utils.keccak256(userOp.paymasterAndData) + ] + ) + ) + + const impostor = ethers.Wallet.createRandom() + const signature = await walletSign(impostor, userOpHash) + userOp.signature = signature + + const result = await dynamicAuth.validateUserOp(userOp, userOpHash, 0) + expect(result).to.equal(1) + }) + + it('Should revert if missingAccountFunds is greater than 0', async () => { + const userOp: UserOperation = { + sender: dynamicAuth.address, + nonce: 0, + initCode: [], + callData: [], + callGasLimit: 100000, + verificationGasLimit: 150000, + preVerificationGas: 50000, + maxFeePerGas: 10, + maxPriorityFeePerGas: 2, + paymasterAndData: [], + signature: [] + } + + const userOpHash = ethers.utils.keccak256( + ethers.utils.defaultAbiCoder.encode( + [ + 'address', + 'uint256', + 'bytes32', + 'bytes32', + 'uint256', + 'uint256', + 'uint256', + 'uint256', + 'uint256', + 'bytes32' + ], + [ + userOp.sender, + userOp.nonce, + ethers.utils.keccak256(userOp.initCode), + ethers.utils.keccak256(userOp.callData), + userOp.callGasLimit, + userOp.verificationGasLimit, + userOp.preVerificationGas, + userOp.maxFeePerGas, + userOp.maxPriorityFeePerGas, + ethers.utils.keccak256(userOp.paymasterAndData) + ] + ) + ) + + const signature = await walletSign(owner, userOpHash) + userOp.signature = signature + + const missingFunds = 1 + const tx = dynamicAuth.validateUserOp(userOp, userOpHash, missingFunds) + + await expect(tx).to.be.rejectedWith( + RevertError('Not enough funds to cover transaction costs') + ) + }) + }) +})