5/5
## Testing MinimalAccount Owner Execution with Foundry This lesson guides you through setting up and executing tests for the `MinimalAccount.sol` smart contract, specifically focusing on verifying the owner's ability to directly execute arbitrary commands. We'll use the Foundry testing framework to achieve this, laying a crucial foundation before exploring the full Account Abstraction (AA) flow involving the EntryPoint contract. Our primary objective is to ensure that the designated owner of a `MinimalAccount` can leverage its `execute` function to interact with other smart contracts, such as an ERC20 token contract. ### Core Concepts for Testing Before diving into the code, let's familiarize ourselves with the key tools and concepts we'll be using: * **Foundry Testing Framework:** Foundry is a powerful toolkit for Ethereum application development. For testing, we'll utilize: * **Test Contracts:** Solidity files ending in `.t.sol` that inherit from `forge-std/Test.sol`. * **`setUp()` Function:** A special function executed before each test case, ideal for deploying contracts and initializing state. * **AAA Pattern (Arrange, Act, Assert):** A best practice for structuring tests: 1. **Arrange:** Set up initial conditions and inputs. 2. **Act:** Perform the action or execute the function being tested. 3. **Assert:** Verify the outcome against expectations. * **Cheatcodes (`vm`):** Foundry provides cheatcodes to manipulate the blockchain environment for testing: * `vm.prank(address)`: Executes the subsequent contract call as if it originated from the specified `address`. Essential for testing `msg.sender`-dependent logic. * `vm.expectRevert(bytes memory revertData)`: Asserts that the next contract call *must* fail with a specific revert message or error selector. Used for negative testing. * **Assertions:** Functions like `assertEq(value1, value2)` check if two values are equal. * **`makeAddr(string memory name)`:** A utility function provided by `forge-std` to generate deterministic, human-readable addresses for test accounts. * **Account Abstraction (AA) Context:** While this lesson focuses on direct owner execution, it's part of a larger AA picture. The typical AA flow involves off-chain signing of UserOperations, Bundlers submitting these to an Alt-Mempool, an `EntryPoint.sol` contract validating and executing these operations, which then call the user's Account contract (like our `MinimalAccount.sol`). Here, we test a more direct path: owner -> `MinimalAccount` -> target contract. * **Mock Contracts:** To simulate external dependencies without deploying full-fledged versions, we use mock contracts. For instance, `ERC20Mock` from OpenZeppelin allows us to test ERC20 token interactions (like minting or transferring) in isolation. * **ABI Encoding:** `abi.encodeWithSelector` is a Solidity function used to construct the `calldata` payload. This `calldata` is what the `MinimalAccount`'s `execute` function will use to call a target contract. It combines the target function's selector (the first 4 bytes of the Keccak-256 hash of its signature) with its ABI-encoded arguments. * **Solidity Modifiers:** Modifiers are reusable pieces of code that can alter the behavior of functions, often used for access control (e.g., ensuring only an owner can call a function). We'll see their importance when debugging the `execute` function's access control. ### Setting Up the Test Environment We'll begin by creating a new test file, `test/ethereum/MinimalAccountTest.t.sol`. 1. **Initial Test File Structure:** ```solidity // SPDX-License-Identifier: MIT pragma solidity 0.8.24; import {Test} from "forge-std/Test.sol"; import {MinimalAccount} from "src/ethereum/MinimalAccount.sol"; import {DeployMinimal} from "script/DeployMinimal.s.sol"; import {HelperConfig} from "script/HelperConfig.s.sol"; import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol"; contract MinimalAccountTest is Test { HelperConfig helperConfig; MinimalAccount minimalAccount; ERC20Mock usdc; uint256 constant AMOUNT = 1e18; // Standard amount for minting (1 token with 18 decimals) address randomUser = makeAddr("randomUser"); // A deterministic address for non-owner tests function setUp() public { DeployMinimal deployMinimal = new DeployMinimal(); // Deploy MinimalAccount using our deployment script (helperConfig, minimalAccount) = deployMinimal.deployMinimalAccount(); // Deploy a mock USDC token for interaction usdc = new ERC20Mock(); } // Test functions will be added here } ``` **Explanation:** * We import necessary contracts: `Test` from `forge-std`, our `MinimalAccount`, the `DeployMinimal` script (which handles deployment logic), `HelperConfig` (for network configurations), and `ERC20Mock`. * State variables like `helperConfig`, `minimalAccount`, `usdc`, `AMOUNT`, and `randomUser` are declared to be accessible across different test functions. * The `setUp()` function is critical: * It instantiates `DeployMinimal` and calls `deployMinimalAccount()` to deploy our `MinimalAccount` contract. This script often handles setting the initial owner. * It deploys a new `ERC20Mock` contract, which we'll use as our "USDC" for testing interactions. ### Testing Owner's Execution Capability Now, let's write our first test to verify that the owner can successfully use the `execute` function. 1. **Test: `testOwnerCanExecuteCommands()`** This test ensures the owner of the `MinimalAccount` can call its `execute` function to make the account interact with another contract (e.g., mint mock USDC to itself). ```solidity function testOwnerCanExecuteCommands() public { // Arrange assertEq(usdc.balanceOf(address(minimalAccount)), 0, "Initial USDC balance should be 0"); address dest = address(usdc); // Target contract is the mock USDC uint256 value = 0; // No ETH value sent in the internal call from account to USDC // Prepare calldata for: usdc.mint(address(minimalAccount), AMOUNT) bytes memory functionData = abi.encodeWithSelector( ERC20Mock.mint.selector, // Function selector for mint(address,uint256) address(minimalAccount), // Argument 1: recipient of minted tokens AMOUNT // Argument 2: amount to mint ); // Act // Impersonate the owner of the MinimalAccount for the next call vm.prank(minimalAccount.owner()); minimalAccount.execute(dest, value, functionData); // Owner calls execute // Assert // Check if MinimalAccount now has the minted USDC assertEq(usdc.balanceOf(address(minimalAccount)), AMOUNT, "MinimalAccount should have minted USDC"); } ``` **Explanation:** * **Arrange:** * We assert the `minimalAccount`'s initial USDC balance is zero. * `dest`: Set to the address of our deployed `usdc` (mock ERC20) contract. * `value`: Set to 0 as the `mint` function doesn't require sending ETH. * `functionData`: This is the crucial payload. `abi.encodeWithSelector` constructs the `calldata` for calling `usdc.mint(address(minimalAccount), AMOUNT)`. * `ERC20Mock.mint.selector` provides the 4-byte identifier for the `mint` function. * The arguments (`address(minimalAccount)` and `AMOUNT`) are then ABI-encoded. * **Act:** * `vm.prank(minimalAccount.owner())`: This Foundry cheatcode makes the *subsequent call* (`minimalAccount.execute(...)`) appear as if it's coming from the `owner()` of the `minimalAccount`. This is vital for testing owner-restricted functions. * `minimalAccount.execute(dest, value, functionData)`: The owner (impersonated by `vm.prank`) calls the `execute` function on the `MinimalAccount`. * **Assert:** * `assertEq(usdc.balanceOf(address(minimalAccount)), AMOUNT)`: We verify that the `minimalAccount`'s balance of mock USDC is now equal to `AMOUNT`, confirming the `mint` operation was successful. ### Testing Access Control: Non-Owner Execution It's equally important to test that unauthorized users *cannot* call the `execute` function. 1. **Test: `testNonOwnerCannotExecuteCommands()`** This negative test ensures that a random address (not the owner or the EntryPoint) attempting to call `execute` will be reverted. ```solidity function testNonOwnerCannotExecuteCommands() public { // Arrange address dest = address(usdc); uint256 value = 0; bytes memory functionData = abi.encodeWithSelector( ERC20Mock.mint.selector, address(minimalAccount), AMOUNT ); // Act & Assert (Combined using expectRevert) vm.prank(randomUser); // Impersonate a random, non-owner address // Expect the call to revert with the specific error from the modifier // MinimalAccount__NotFromEntryPointOrOwner is the custom error vm.expectRevert(MinimalAccount.MinimalAccount__NotFromEntryPointOrOwner.selector); minimalAccount.execute(dest, value, functionData); // Attempt to call execute } ``` **Explanation:** * **Arrange:** The setup for `dest`, `value`, and `functionData` is similar to the previous test. The actual operation doesn't matter as much as the expected failure. * **Act & Assert:** * `vm.prank(randomUser)`: We impersonate `randomUser`, an address that is not the owner. * `vm.expectRevert(MinimalAccount.MinimalAccount__NotFromEntryPointOrOwner.selector)`: This cheatcode tells Foundry that the *next* contract call is expected to revert. Crucially, we specify the *exact error selector* we expect (`MinimalAccount__NotFromEntryPointOrOwner.selector`). This ensures the call fails for the correct reason. * `minimalAccount.execute(...)`: The `randomUser` attempts to call `execute`. If the modifier and access controls are set up correctly, this call will fail, and `vm.expectRevert` will catch the specific revert, making the test pass. ### Debugging Journey and Code Refinements During development and testing, issues often arise. Here's a look at common problems encountered and their solutions in this context: 1. **Issue: `OwnableInvalidOwner(address(0))` on Deployment** * **Problem:** The `deployMinimal.s.sol` script typically calls `minimalAccount.transferOwnership(msg.sender)` or sets the owner to `msg.sender` during construction. When `setUp()` in the test contract runs, the default `msg.sender` can sometimes be `address(0)` if not configured, especially if the `HelperConfig.s.sol` script's `getOrCreateAnvilEthConfig` function doesn't provide a default deployer account for local Anvil networks. Calling `transferOwnership(address(0))` is invalid and causes a revert. * **Fix:** In `HelperConfig.s.sol`, ensure a valid default deployer/owner account is used for local Anvil tests. A common practice is to define a constant for Foundry's default wallet: ```solidity // In HelperConfig.s.sol address constant FOUNDRY_DEFAULT_WALLET = 0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38; // Example default function getOrCreateAnvilEthConfig(address deployerKey) internal returns (NetworkConfig memory) { if (activeNetworkConfig.entryPoint != address(0)) { return activeNetworkConfig; } // If account not set or deployerKey not provided effectively, return config with the default foundry wallet // Or use deployerKey if it's valid. address accountToUse = deployerKey == address(0) ? FOUNDRY_DEFAULT_WALLET : deployerKey; activeNetworkConfig = NetworkConfig({entryPoint: address(0), account: accountToUse}); return activeNetworkConfig; } ``` This ensures `deployMinimalAccount()` in `setUp()` uses a valid address when setting up ownership. 2. **Issue: Owner Execution Fails due to Restrictive Modifier** * **Problem:** The `execute` function in `MinimalAccount.sol` might initially have a modifier like `requireFromEntryPoint` which *only* allows the `i_entryPoint` address to call it. This would prevent the owner from directly calling `execute`, causing `testOwnerCanExecuteCommands` to fail, often with an error like `MinimalAccount__NotFromEntryPoint()` (or whatever the custom error is named). * **Fix:** Modify the access control on the `execute` function in `MinimalAccount.sol` to allow calls from *either* the EntryPoint *or* the contract's owner. This usually involves changing the modifier or the internal logic of the modifier. Create or update a modifier: ```solidity // In MinimalAccount.sol error MinimalAccount__NotFromEntryPointOrOwner(); // Custom error modifier requireFromEntryPointOrOwner() { if (msg.sender != owner() && msg.sender != i_entryPoint) { revert MinimalAccount__NotFromEntryPointOrOwner(); } _; } function execute(address dest, uint256 value, bytes calldata functionData) external payable // Make it payable if it can forward ETH requireFromEntryPointOrOwner // Apply the corrected modifier { // ... existing function body ... (bool success, bytes memory result) = dest.call{value: value}(functionData); if (!success) { assembly { revert(add(result, 0x20), mload(result)) } } } ``` The `MinimalAccount__NotFromEntryPointOrOwner` custom error is used in `testNonOwnerCannotExecuteCommands` with `vm.expectRevert`. ### Key Takeaways and Best Practices * **Impersonation is Key:** `vm.prank()` is indispensable for testing functions with `msg.sender` based access control, especially for owner-only functionalities. * **Test for Failure:** `vm.expectRevert()` is crucial for negative testing, ensuring your contract correctly prevents unauthorized actions and reverts with the expected error. * **Isolate with Mocks:** `MockERC20` and similar mock contracts allow you to test contract interactions in a controlled environment without deploying and managing real external dependencies. * **Structured Tests (AAA):** The Arrange, Act, Assert pattern keeps tests organized, readable, and maintainable. * **`setUp()` for Efficiency:** Deploy common contracts and set initial state in `setUp()` to avoid repetition and ensure a clean state for each test. * **Deterministic Addresses:** `makeAddr("name")` provides stable, named addresses for consistent testing scenarios (e.g., `randomUser`). * **Understanding `abi.encodeWithSelector`:** This function is fundamental for crafting `calldata` for low-level calls or calls through proxy-like `execute` functions. For a deeper dive, materials like the Cyfrin Updraft courses cover ABI encoding extensively. By successfully implementing these tests, we've validated that the owner of the `MinimalAccount` can directly control it to execute arbitrary calls. This is a vital step in building robust and secure smart wallet contracts and prepares us for testing more complex Account Abstraction flows involving the EntryPoint contract.
A foundational guide to Testing MinimalAccount Owner Execution with Foundry - Set up robust Foundry tests for `MinimalAccount.sol`, using `vm.prank` and mock contracts to verify owner `execute` capabilities. Solidify access control by ensuring non-owner attempts correctly revert with `vm.expectRevert`.
Previous lesson
Previous
Next lesson
Next
Give us feedback
Course Overview
About the course
Advanced smart contract development
How to develop a stablecoin
How to develop a DeFi protocol
How to develop a DAO
Advanced smart contracts testing
Fuzz testing
Manual verification
Web3 Developer Relations
$85,000 - $125,000 (avg. salary)
Web3 developer
$60,000 - $150,000 (avg. salary)
Smart Contract Engineer
$100,000 - $150,000 (avg. salary)
Smart Contract Auditor
$100,000 - $200,000 (avg. salary)
Security researcher
$49,999 - $120,000 (avg. salary)
Web3 engineer, educator, and Cyfrin co-founder. Patrick's smart contract development and security courses have helped hundreds of thousands of engineers kickstarting their careers into web3.
Guest lecturers:
Last updated on June 6, 2025
Duration: 36min
Duration: 3h 06min
Duration: 5h 02min
Duration: 6h 02min
Duration: 2h 47min
Duration: 1h 23min
Duration: 4h 28min
Duration: 1h 19min
Duration: 1h 10min
Course Overview
About the course
Advanced smart contract development
How to develop a stablecoin
How to develop a DeFi protocol
How to develop a DAO
Advanced smart contracts testing
Fuzz testing
Manual verification
Web3 Developer Relations
$85,000 - $125,000 (avg. salary)
Web3 developer
$60,000 - $150,000 (avg. salary)
Smart Contract Engineer
$100,000 - $150,000 (avg. salary)
Smart Contract Auditor
$100,000 - $200,000 (avg. salary)
Security researcher
$49,999 - $120,000 (avg. salary)
Web3 engineer, educator, and Cyfrin co-founder. Patrick's smart contract development and security courses have helped hundreds of thousands of engineers kickstarting their careers into web3.
Guest lecturers:
Last updated on June 6, 2025