Skip to content

describe wallet initialising and upgrade logic #66

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# 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<br>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`). This is typically done via a script that uses a CREATE2 factory for a deterministic address.

_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`).
- 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.
28 changes: 28 additions & 0 deletions src/contracts/interfaces/erc4337/IAccount.sol
Original file line number Diff line number Diff line change
@@ -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);
}
33 changes: 32 additions & 1 deletion src/contracts/modules/MainModuleDynamicAuth.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";


/**
Expand All @@ -20,7 +21,8 @@ contract MainModuleDynamicAuth is
ModuleAuthDynamic,
ModuleCalls,
ModuleReceivers,
ModuleUpdate
ModuleUpdate,
IAccount
{

// solhint-disable-next-line no-empty-blocks
Expand All @@ -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;
}
Expand Down
Loading
Loading