Developer’s Guide to ERC-4337 #2 | Testing Simple Account

nikbhintade

Nikhil

Posted on October 9, 2024

Developer’s Guide to ERC-4337 #2 | Testing Simple Account

This is part 2 of the “Developer’s Guide to ERC-4337” series. If you haven’t read the last article, check it out here: Developer's Guide to ERC-4337: Developing a Simple Account.


Many tutorials focus on how to use or work with new technologies, but often skip the important part—testing. Figuring out how to test on your own can be a valuable learning opportunity, solidifying your knowledge and clearing up any confusion you might have had before.

In the last article of this series, we developed a simple ERC-4337-compliant account contract. Now, in this article, we’re going to write unit tests for it. I'll first outline the test cases, and then you can either write the tests yourself or follow along with this guide.

What Should We Test For?

The approach I recommend is to start with basic checks, like verifying state variables and simple functions, then move on to more complex functions with multiple components. Here are the test cases:

  • Check Initialization of i_entryPoint and i_owner: Verify that the contract initializes and stores i_entryPoint and i_owner correctly after deployment.
  • Access control check for the execute function: Ensure the execute function only works when called by the correct i_entryPoint and reverts otherwise.
  • Check successful execution of the execute function: Ensure the execute function successfully forwards a call to the destination when invoked by the correct entry point.
  • Check failure handling in the execute function: Verify that the execute function reverts with SimpleAccount__CallFailed when the destination call fails.
  • Signature validation with correct owner signature: Confirm that the _validateSignature function returns SIG_VALIDATION_SUCCESS for a valid owner signature.
  • Signature validation with incorrect owner signature: Ensure the _validateSignature function returns SIG_VALIDATION_FAILED for an invalid owner signature.

Are all of these important? Yes and no. Our contract isn’t very complex, so there aren’t many ways things can go wrong. However, writing tests for as much as possible will build good habits. By the time you start working on more complex contracts, the habit of thorough testing will stick with you, and you'll be less likely to overlook mistakes.

We’ll aim for 100% test coverage for this contract, which is feasible in our case. In general, achieving 90% coverage is a good goal to have.

Unit Testing

Let’s start writing our tests. First, we need to set up our testing environment. If you’re unfamiliar with testing in Foundry, I recommend reading the relevant section of the Foundry documentation. We’ll write our tests in a file called SimpleAccountTest.t.sol, so go ahead and create that file in your test folder.

All tests will be placed in a contract, which we’ll call SimpleAccountTest.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

contract SimpleAccountTest {}
Enter fullscreen mode Exit fullscreen mode

This contract will inherit the Test contract from the forge-std library, which includes helpful functions for testing. Let’s import the Test contract and extend it in our SimpleAccountTest contract.

import {Test} from "forge-std/Test.sol";

contract SimpleAccountTest is Test {}
Enter fullscreen mode Exit fullscreen mode

Testing Internal Functions

Before we dive into the tests, let’s briefly discuss testing internal functions. Testing external and public functions is straightforward because they’re designed for external interaction. However, testing internal or private functions can be tricky.

For private functions, testing isn’t possible unless you copy-paste the code into the test, which isn’t ideal. But internal functions are inheritable, meaning we can create a contract that inherits the original contract and exposes the internal functions as public for testing purposes. These are called "harness" contracts.

Let’s create a harness contract for SimpleAccount that exposes the _validateSignature function. We’ll call this contract SimpleAccountHarness, and it will be the contract we use for testing since it includes all the functions from SimpleAccount, plus some additional ones. Add the following contract to the same file:

import {SimpleAccount} from "src/SimpleAccount.sol";
import {PackedUserOperation} from "account-abstraction/interfaces/PackedUserOperation.sol";

contract SimpleAccountHarness is SimpleAccount {
    constructor(address entryPoint, address owner) SimpleAccount(entryPoint, owner) {}

    // exposes `_validateSignature` for testing
    function validateSignature(PackedUserOperation calldata userOp, bytes32 userOpHash)
        external
        view
        returns (uint256)
    {
        return _validateSignature(userOp, userOpHash);
    }
} 
Enter fullscreen mode Exit fullscreen mode

To use SimpleAccount and expose its methods, we need to import both SimpleAccount and PackedUserOperation. We inherit from SimpleAccount, meaning we need to satisfy its constructor. Just like SimpleAccount, we pass the entry point contract’s address and the account owner’s address to the constructor.

Next, we create the validateSignature function, which has almost the same definition as _validateSignature but with external visibility. This allows us to expose the internal function for testing. We then pass the function’s arguments to _validateSignature and return its result.

setUp Function

To start writing tests, we first need a setUp function. This is a function that Forge automatically runs before each test to set up the necessary conditions, like deploying contracts or creating dummy users. In our setUp function, we will:

  • Create an instance of the EntryPoint contract.
  • Create two accounts: one for the owner and one for random users.
  • Create an instance of SimpleAccountHarness.

To deploy EntryPoint, we first need to import it from account-abstraction:

import {EntryPoint} from "account-abstraction/core/EntryPoint.sol";
Enter fullscreen mode Exit fullscreen mode

We'll store these contract instances and accounts in state variables so they can be accessed by the test functions:

contract SimpleAccountTest is Test {
    SimpleAccountHarness private simpleAccountHarness;
    EntryPoint private entryPoint;

    Account owner;
    Account randomUser;
}
Enter fullscreen mode Exit fullscreen mode

Next, we'll create instances of the contracts and two accounts using Foundry’s cheat code makeAccount. This cheat code generates an address and private key for each account, which we’ll use in our tests:

function setUp() external {
    owner = makeAccount("owner");
    randomUser = makeAccount("randomUser");

    entryPoint = new EntryPoint();
    simpleAccountHarness = new SimpleAccountHarness(address(entryPoint), owner.addr);
    vm.deal(address(simpleAccountHarness), 10 ether);
}
Enter fullscreen mode Exit fullscreen mode

The makeAccount function takes a string to generate a wallet and returns an Account struct. This struct has two fields: addr (the account address) and key (the private key). Both Account and makeAccount are available via StdCheats.sol, which Foundry automatically imports.

For contract instances, we use the new keyword, passing the required constructor arguments. The EntryPoint contract doesn’t require any arguments, but SimpleAccountHarness requires both the entryPoint address and the owner's address, which we get from the addr field of the owner account.

Finally, we add some balance to SimpleAccountHarness using the deal function, which is part of the Forge standard library. This function takes an address and an amount, adding the specified balance to that address. We give SimpleAccountHarness 10 ether, so it can execute actions like sending ether or paying back the entry point for user operations.

Here's what the test contract looks like at this point:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import {Test} from "forge-std/Test.sol";
import {SimpleAccount} from "src/SimpleAccount.sol";
import {PackedUserOperation} from "account-abstraction/interfaces/PackedUserOperation.sol";
import {EntryPoint} from "account-abstraction/core/EntryPoint.sol";

contract SimpleAccountHarness is SimpleAccount {
    constructor(address entryPoint, address owner) SimpleAccount(entryPoint, owner) {}

    // exposes `_validateSignature` for testing
    function validateSignature(PackedUserOperation calldata userOp, bytes32 userOpHash)
        external
        view
        returns (uint256)
    {
        return _validateSignature(userOp, userOpHash);
    }
}

contract SimpleAccountTest is Test {
    SimpleAccountHarness private simpleAccountHarness;
    EntryPoint private entryPoint;

    Account owner;
    Account randomUser;

    function setUp() external {
        owner = makeAccount("owner");
        randomUser = makeAccount("randomUser");

        entryPoint = new EntryPoint();
        simpleAccountHarness = new SimpleAccountHarness(address(entryPoint), owner.addr);
    }
}
Enter fullscreen mode Exit fullscreen mode

State Variable Check

Let's write our first test to verify if the state variables initialized by the SimpleAccount constructor are set correctly. In the SimpleAccount constructor, we are simply setting the values for entryPoint and owner. Both of these variables have getter functions, so in our test, we'll retrieve their values and compare them to the expected values.

function testStateVariables() public view {
    // Arrange
    // Act
    address contractOwner = simpleAccountHarness.getOwner();
    address contractEntryPoint = address(simpleAccountHarness.entryPoint());
    // Assert
    vm.assertEq(owner.addr, contractOwner);
    vm.assertEq(address(entryPoint), contractEntryPoint);
}
Enter fullscreen mode Exit fullscreen mode

We've written our first test case. To run this test, use the following command:

forge test --mt testStateVariables
Enter fullscreen mode Exit fullscreen mode

execute Function & Access Control Checks

In SimpleAccount, the execute function which interact with other accounts and contract. The entryPoint contract directly executes calldata on the account contract, with the calldata being sent through a PackedUserOperation. The dapp will construct calldata with execute function selector and required function arguments. The execute function can only be called by the entryPoint; if anyone else tries to call it, the call should revert.

Let’s test the execute function by sending 1 ether to randomUser and checking if randomUser's balance increases and the account contract's balance decreases. To do this, we need the initial balance for both accounts.

Since only the entryPoint can call the execute function, we will use the cheat code prank to make the call in the test originate from the entryPoint address.

 function testExecuteFunction() public {
    // Arrange
    uint256 initalBalanceOfRandomUser = randomUser.addr.balance;
    uint256 initalBalanceOfAccountContract = address(simpleAccountHarness).balance;

    uint256 valueToSend = 1 ether;
    // Act
    vm.prank(address(entryPoint));
    simpleAccountHarness.execute(randomUser.addr, valueToSend, "");
    // Assert
    vm.assertEq(randomUser.addr.balance, initalBalanceOfRandomUser + valueToSend);
    vm.assertEq(address(simpleAccountHarness).balance, initalBalanceOfAccountContract - valueToSend);
}
Enter fullscreen mode Exit fullscreen mode

Now, let's ensure the function reverts if someone other than the entryPoint calls it. The access control is handled by _requireFromEntryPoint, which reverts with the message "account: not from EntryPoint" if an unauthorized address tries to call it. We'll also check if the correct revert message is returned.

function testExecuteRevertsWithCorrectError() public {
    // Arrange
    uint256 valueToSend = 1 ether;

    // Act + Assert
    vm.prank(randomUser.addr);
    vm.expectRevert(bytes("account: not from EntryPoint")); 
    simpleAccountHarness.execute(randomUser.addr, valueToSend, "");
}
Enter fullscreen mode Exit fullscreen mode

Testing execute When Call Fails

Finally, we need to test what happens if the call made by the execute function fails. How do we simulate this?

We’ll create a contract with a fallback function that reverts on every call. You can add this contract below the SimpleAccountHarness contract.

contract RevertsOnEthTransfer {
    fallback() external {
        revert("");
    }
}
Enter fullscreen mode Exit fullscreen mode

To test this, we’ll create an instance of RevertsOnEthTransfer in our test and try to send ether to it, which will cause the call to revert and return the custom error SimpleAccount__CallFailed.

function testCallFromExecuteFails() public {
    // Arrange
    RevertsOnEthTransfer revertsOnEthTransfer = new RevertsOnEthTransfer();

    uint256 valueToSend = 1 ether;

    // Act + Assert
    vm.prank(address(entryPoint)); // execute function needs to be executes from EntryPoint
    vm.expectRevert(SimpleAccount.SimpleAccount__CallFailed.selector);
    simpleAccountHarness.execute(address(revertsOnEthTransfer), valueToSend, "");
}
Enter fullscreen mode Exit fullscreen mode

In the previous test, we passed a string to expectRevert, but here we pass the error selector since SimpleAccount reverts with a custom error when the call fails.

With this, we've covered testing the execute function. Now, we can move on to the final part: testing the validateSignature function, which exposes _validateSignature from SimpleAccount. This part is a bit more complicated but essential to complete the test coverage.

Signature Validation Checks

Before we dive into writing the tests, we first need to create a function that generates a dummy UserOperation and signs it using the private key of the account. Let’s call this function generateAndSignUserOperation.

To get started, import MessageHashUtils from OpenZeppelin, which we'll use to generate the message the user will sign.

import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
Enter fullscreen mode Exit fullscreen mode

This function will take an argument of type Account, which we need to sign the UserOperation, and return two values: the signed UserOperation of type PackedUserOperation and the UserOperation hash of type bytes32.

function generateSigAndSignedUserOp(Account memory user)
    public
    view
    returns (PackedUserOperation memory, bytes32)
{}
Enter fullscreen mode Exit fullscreen mode

Inside this function, we’ll create a dummy UserOperation with random values. Since we are unit testing _validateSignature, we only care about working with the signature field.

function generateSigAndSignedUserOp(Account memory user)
    public
    view
    returns (PackedUserOperation memory, bytes32)
{

    PackedUserOperation memory userOp = PackedUserOperation({
        sender: address(simpleAccountHarness),
        nonce: 1,
        initCode: hex"",
        callData: hex"",
        accountGasLimits: hex"",
        preVerificationGas: type(uint64).max,
        gasFees: hex"",
        paymasterAndData: hex"",
        signature: hex""
    });

    // Next part
}
Enter fullscreen mode Exit fullscreen mode

The signature field will contain the signature generated by signing the UserOperation hash. This hash is generated using the getUserOpHash function from the EntryPoint contract, which takes a PackedUserOperation as an argument. We’ll pass the UserOperation with an empty signature field. Once the UserOpHash is signed, the signature is added and then sent to the EntryPoint contract for validation and execution.

function generateSigAndSignedUserOp(Account memory user)
    public
    view
    returns (PackedUserOperation memory, bytes32)
{

    PackedUserOperation memory userOp = PackedUserOperation({
        sender: address(simpleAccountHarness),
        nonce: 1,
        initCode: hex"",
        callData: hex"",
        accountGasLimits: hex"",
        preVerificationGas: type(uint64).max,
        gasFees: hex"",
        paymasterAndData: hex"",
        signature: hex""
    });

    bytes32 userOpHash = entryPoint.getUserOpHash(userOp);
    bytes32 formattedUserOpHash = MessageHashUtils.toEthSignedMessageHash(userOpHash);

        // Next Part

}
Enter fullscreen mode Exit fullscreen mode

Next, we need to sign the formattedUserOpHash using the sign function from the Forge standard library. This function takes the message and private key used to sign the message. It returns the v, r, and s components of the signature, which we then encode in r, s, and v order using encodePacked. The signature is finally added to the signature field of userOp. The function will then return the modified userOp and the userOpHash.

function generateSigAndSignedUserOp(Account memory user)
    public
    view
    returns (PackedUserOperation memory, bytes32)
{

    PackedUserOperation memory userOp = PackedUserOperation({
        sender: address(simpleAccountHarness),
        nonce: 1,
        initCode: hex"",
        callData: hex"",
        accountGasLimits: hex"",
        preVerificationGas: type(uint64).max,
        gasFees: hex"",
        paymasterAndData: hex"",
        signature: hex""
    });

    bytes32 userOpHash = entryPoint.getUserOpHash(userOp);
    bytes32 formattedUserOpHash = MessageHashUtils.toEthSignedMessageHash(userOpHash);

    (uint8 v, bytes32 r, bytes32 s) = vm.sign(user.key, formattedUserOpHash);

    userOp.signature = abi.encodePacked(r, s, v);

    return (userOp, userOpHash);
}
Enter fullscreen mode Exit fullscreen mode

Now that we have the generateSigAndSignedUserOp function, let’s write our test cases for _validateSignature. The first test will check if the owner has signed the UserOperation and that the function returns the correct value. The expected success value is SIG_VALIDATION_SUCCESS, which we first need to import.

import { SIG_VALIDATION_SUCCESS} from "account-abstraction/core/Helpers.sol";
Enter fullscreen mode Exit fullscreen mode

In this test, we will use the generateSigAndSignedUserOp function to create a UserOperation, passing in the owner. We’ll then pass the returned userOp and userOpHash to the validateSignature function (which exposes _validateSignature via SimpleAccountHarness). Finally, we’ll check if the return value matches the expected value. Since no state modification happens here, we can make this a view function.

function testValidateSignature() public view {
    // Arrange
    (PackedUserOperation memory userOp, bytes32 userOpHash) = generateSigAndSignedUserOp(owner);
    // Act
    uint256 result = simpleAccountHarness.validateSignature(userOp, userOpHash);
    // Assert
    vm.assertEq(result, SIG_VALIDATION_SUCCESS);
}
Enter fullscreen mode Exit fullscreen mode

The last test case will verify the failure scenario, ensuring that the function returns SIG_VALIDATION_FAILED when the UserOperation is signed with an incorrect user. We need to import the SIG_VALIDATION_FAILED constant from account-abstraction.

import {SIG_VALIDATION_SUCCESS, SIG_VALIDATION_FAILED} from "account-abstraction/core/Helpers.sol";
Enter fullscreen mode Exit fullscreen mode

To test this, we’ll pass randomUser to the generateSigAndSignedUserOp function, and the rest will be similar to the previous test.

function testValidateSignatureWithWrongSignature() public view {
    // Arrange
    (PackedUserOperation memory userOp, bytes32 userOpHash) = generateSigAndSignedUserOp(randomUser);
    // Act
    uint256 result = simpleAccountHarness.validateSignature(userOp, userOpHash);
    // Assert
    vm.assertEq(result, SIG_VALIDATION_FAILED);
}
Enter fullscreen mode Exit fullscreen mode

With these test cases, we have covered the _validateSignature function. Here’s how the final test file should look:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import {Test} from "forge-std/Test.sol";
import {SimpleAccount} from "src/SimpleAccount.sol";
import {PackedUserOperation} from "account-abstraction/interfaces/PackedUserOperation.sol";
import {EntryPoint} from "account-abstraction/core/EntryPoint.sol";
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
import {SIG_VALIDATION_SUCCESS, SIG_VALIDATION_FAILED} from "account-abstraction/core/Helpers.sol";

contract SimpleAccountHarness is SimpleAccount {
    constructor(address entryPoint, address owner) SimpleAccount(entryPoint, owner) {}

    // exposes `_validateSignature` for testing
    function validateSignature(PackedUserOperation calldata userOp, bytes32 userOpHash)
        external
        view
        returns (uint256)
    {
        return _validateSignature(userOp, userOpHash);
    }
}

contract RevertsOnEthTransfer {
    fallback() external {
        revert("");
    }
}

contract SimpleAccountTest is Test {
    SimpleAccountHarness private simpleAccountHarness;
    EntryPoint private entryPoint;

    Account owner;
    Account randomUser;

    function setUp() external {
        owner = makeAccount("owner");
        randomUser = makeAccount("randomUser");

        entryPoint = new EntryPoint();
        simpleAccountHarness = new SimpleAccountHarness(address(entryPoint), owner.addr);
        vm.deal(address(simpleAccountHarness), 10 ether);
    }

    function testStateVariables() public view {
        // Arrange

        // Act
        address contractOwner = simpleAccountHarness.getOwner();
        address contractEntryPoint = address(simpleAccountHarness.entryPoint());

        // Assert
        vm.assertEq(owner.addr, contractOwner);
        vm.assertEq(address(entryPoint), contractEntryPoint);
    }

    function testExecuteFunction() public {
        // Arrange
        uint256 initalBalanceOfRandomUser = randomUser.addr.balance;
        uint256 initalBalanceOfAccountContract = address(simpleAccountHarness).balance;

        uint256 valueToSend = 1 ether;

        // Act
        vm.prank(address(entryPoint));
        simpleAccountHarness.execute(randomUser.addr, valueToSend, "");

        // Assert
        vm.assertEq(randomUser.addr.balance, initalBalanceOfRandomUser + valueToSend);
        vm.assertEq(address(simpleAccountHarness).balance, initalBalanceOfAccountContract - valueToSend);
    }

    function testExecuteRevertsWithCorrectError() public {
        // Arrange
        uint256 valueToSend = 1 ether;

        // Act + Assert
        vm.prank(randomUser.addr);
        vm.expectRevert(bytes("account: not from EntryPoint"));
        simpleAccountHarness.execute(randomUser.addr, valueToSend, "");
    }

    function testCallFromExecuteFails() public {
        // Arrange
        RevertsOnEthTransfer revertsOnEthTransfer = new RevertsOnEthTransfer();

        uint256 valueToSend = 1 ether;

        // Act + Assert
        vm.prank(address(entryPoint)); // execute function needs to be executes from EntryPoint
        vm.expectRevert(SimpleAccount.SimpleAccount__CallFailed.selector);
        simpleAccountHarness.execute(address(revertsOnEthTransfer), valueToSend, "");
    }

    function generateSigAndSignedUserOp(Account memory user)
        public
        view
        returns (PackedUserOperation memory, bytes32)
    {
        PackedUserOperation memory userOp = PackedUserOperation({
            sender: address(simpleAccountHarness),
            nonce: 1,
            initCode: hex"",
            callData: hex"",
            accountGasLimits: hex"",
            preVerificationGas: type(uint64).max,
            gasFees: hex"",
            paymasterAndData: hex"",
            signature: hex""
        });

        bytes32 userOpHash = entryPoint.getUserOpHash(userOp);
        bytes32 formattedUserOpHash = MessageHashUtils.toEthSignedMessageHash(userOpHash);

        (uint8 v, bytes32 r, bytes32 s) = vm.sign(user.key, formattedUserOpHash);

        userOp.signature = abi.encodePacked(r, s, v);

        return (userOp, userOpHash);
    }

    function testValidateSignature() public view {
        // Arrange
        (PackedUserOperation memory userOp, bytes32 userOpHash) = generateSigAndSignedUserOp(owner);
        // Act
        uint256 result = simpleAccountHarness.validateSignature(userOp, userOpHash);
        // Assert
        vm.assertEq(result, SIG_VALIDATION_SUCCESS);
    }

    function testValidateSignatureWithWrongSignature() public view {
        // Arrange
        (PackedUserOperation memory userOp, bytes32 userOpHash) = generateSigAndSignedUserOp(randomUser);
        // Act
        uint256 result = simpleAccountHarness.validateSignature(userOp, userOpHash);
        // Assert
        vm.assertEq(result, SIG_VALIDATION_FAILED);
    }
}

Enter fullscreen mode Exit fullscreen mode

As we’ve finished writing our unit tests, let’s check the test coverage. You can do this by running the following command in the terminal:

forge coverage
Enter fullscreen mode Exit fullscreen mode

Final Thoughts

Throughout the unit tests, we learned a lot, like creating PackedUserOperation and working with EntryPoint to generate the user hash. We also explored many other concepts related to testing with Forge. While this isn't a Foundry basics guide, working through it will help improve our skills and understanding.

In our unit tests, we created a dummy PackedUserOperation, but it was quite basic—there was no gas or calldata involved. Obviously, this won’t be enough for production use. In the next part, which will focus on integration testing, we’ll attempt to execute a real user operation through the entry point.

I hope you’re as excited as I am for the next part. If you’ve enjoyed the series so far, stay tuned for the upcoming content.

If you think there’s room for improvement in the code or explanation, please feel free to let me know in the comments!

Who Am I?

My name is Nikhil. I’m a full-time technical writer focused on Web3 tech, especially Ethereum and Solidity. If you’re looking for a technical writer, either freelance or full-time, feel free to reach out—I’d love to explore any opportunities.

💖 💪 🙅 🚩
nikbhintade
Nikhil

Posted on October 9, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related