1/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)
Guest lecturers:
Juliette Chevalier
Lead Developer relations at Aragon
Nader Dabit
Director of developer relations at EigenLayer
Ally Haire
Developer relations at Protocol Labs
Harrison
Founder at GasliteGG
Last updated on May 12, 2025
Solidity Developer
Advanced FoundryDuration: 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)
Guest lecturers:
Juliette Chevalier
Lead Developer relations at Aragon
Nader Dabit
Director of developer relations at EigenLayer
Ally Haire
Developer relations at Protocol Labs
Harrison
Founder at GasliteGG
Last updated on May 12, 2025