Developer’s Guide to ERC-4337 #2 | Testing Simple Account
Nikhil
Posted on October 9, 2024
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
andi_owner
: Verify that the contract initializes and storesi_entryPoint
andi_owner
correctly after deployment. -
Access control check for the
execute
function: Ensure theexecute
function only works when called by the correcti_entryPoint
and reverts otherwise. -
Check successful execution of the
execute
function: Ensure theexecute
function successfully forwards a call to the destination when invoked by the correct entry point. -
Check failure handling in the
execute
function: Verify that theexecute
function reverts withSimpleAccount__CallFailed
when the destination call fails. -
Signature validation with correct owner signature: Confirm that the
_validateSignature
function returnsSIG_VALIDATION_SUCCESS
for a valid owner signature. -
Signature validation with incorrect owner signature: Ensure the
_validateSignature
function returnsSIG_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 {}
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 {}
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);
}
}
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";
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;
}
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);
}
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);
}
}
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);
}
We've written our first test case. To run this test, use the following command:
forge test --mt testStateVariables
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);
}
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, "");
}
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("");
}
}
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, "");
}
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";
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)
{}
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
}
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
}
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);
}
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";
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);
}
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";
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);
}
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);
}
}
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
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.
Posted on October 9, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.