0/5
## Debugging ERC-4337 UserOperations in Foundry: A Step-by-Step Guide Debugging failing tests is a critical skill in smart contract development, especially when working with complex systems like ERC-4337 Account Abstraction. This guide provides a detailed walkthrough of how to use Foundry's powerful built-in debugger to pinpoint and resolve issues in your UserOperations, ensuring your smart contract accounts behave as expected. We'll follow a practical example, starting from a failing test and systematically working our way to a solution. ### Setting Up the Debugger for a Failing Test Our journey begins with a common scenario: a failing test. To investigate the root cause, we'll leverage Foundry's integrated debugger. If you have a specific test function failing, such as `testEntryPointCanExecuteCommands`, you can invoke the debugger with increased verbosity using the following command: ```bash forge test --debug testEntryPointCanExecuteCommands -vvv ``` *(Initially, you might run `forge test --mt testEntryPointCanExecuteCommands -vvv` to match the test name, and then add the `--debug` flag to dive deeper.)* Executing this command launches a low-level debugger interface. This interface provides a wealth of information, including EVM opcodes, the current call stack, memory contents, and, importantly, the corresponding Solidity source code context when available. ### Tip 1: Instantly Navigate to the Revert Location When a transaction reverts, your first goal is to find out *where* it reverted. Foundry's debugger offers a handy shortcut for this: * **Keyboard Shortcut:** `Shift + G` Pressing `Shift + G` instructs the debugger to jump directly to the EVM instruction that caused the revert. If source mapping is available, it will also highlight the corresponding line in your Solidity code. In our example, using `Shift + G` might show the revert occurring at an opcode like `1869 REVERT`. The debugger would then highlight the specific Solidity line in our test file, `MinimalAccountTest.t.sol`, that triggered the failure: ```solidity // In MinimalAccountTest.t.sol IEntryPoint(helperConfig.getConfig().entryPoint).handleOps(ops, payable(randomUser)); ``` This tells us the `handleOps` call to the `EntryPoint` contract is the source of the revert. ### Tip 2: Understanding the Pre-Revert State by Stepping Backwards Knowing where the revert happened is useful, but to understand *why*, we often need to inspect the state and execution path leading up to it. The debugger allows us to step backward through the execution trace. * **Keyboard Shortcut:** `J` (repeatedly press to step to the previous EVM opcode) * The on-screen help often shows `[k/j]: prev/next op`, where `k` steps forward (next opcode) and `j` steps backward (previous opcode). As you step backward, particularly when entering external contract calls like `handleOps`, you might encounter messages like "No source map for contract EntryPoint." This means the debugger doesn't have the source code mapping for that specific part of the dependency. However, by continuing to step back, you will eventually land on a relevant Solidity line within the `EntryPoint.sol` contract itself, if its source is available in your project's dependencies (e.g., in `lib/`). ### Identifying the First Culprit: Incorrect `sender` in `EntryPoint.sol` By meticulously stepping backward from the revert point within the `handleOps` call, the debugger will eventually highlight the problematic area in the `EntryPoint.sol` contract. A common issue in ERC-4337 implementations arises during the `validateUserOp` call: ```solidity // Located in: lib/account-abstraction/contracts/core/EntryPoint.sol // (Line number may vary, e.g., around 421 in standard implementations) try IAccount(sender).validateUserOp{gas: verificationGasLimit}(op, opInfo.userOpHash, missingAccountFunds) returns (uint256 validationData) { // ... } catch { revert FailedOpWithRevert(opIndex, "AA23 reverted", /* ... */); // This is where the revert likely originates from initially } ``` The investigation, aided by observing stack variables or memory in the debugger, reveals that the `sender` variable passed to `IAccount(sender).validateUserOp` is the issue. **The Bug:** The `sender` field within the `UserOperation` struct was incorrectly populated. Instead of using the address of the smart contract account (`minimalAccount`), it was set to `config.account`, which typically represents the deployer EOA (Externally Owned Account). The `EntryPoint` expects `sender` to be the smart contract account that will validate the UserOperation. ### Tracing and Rectifying the Sender Mismatch With the bug identified, the next step is to correct the code. 1. **Exit the Debugger:** Press `Q` to quit the Foundry debugger. 2. **Navigate to the Script:** Open the file responsible for creating the `UserOperation`, in this case, `script/SendPackedUserOp.s.sol`. 3. **Locate the Flaw:** Examining the `generateSignedUserOperation` function, we find that it passes `config.account` to `_generateUnsignedUserOperation`. This latter function then assigns this EOA address to `userOp.sender`. ```solidity // In script/SendPackedUserOp.s.sol function generateSignedUserOperation(bytes memory callData, HelperConfig.NetworkConfig memory config) // ... { // The nonce source is also an issue, addressed later as Bug 2 uint256 nonce = vm.getNonce(config.account); PackedUserOperation memory userOp = _generateUnsignedUserOperation(callData, config.account, nonce); // ... } function _generateUnsignedUserOperation(bytes memory callData, address sender, uint256 nonce) // ... { return PackedUserOperation({ sender: sender, // Problem: 'sender' was config.account (EOA) // ... }); } ``` 4. **Implement the Fix for Bug 1:** * First, modify the `generateSignedUserOperation` function in `script/SendPackedUserOp.s.sol` to accept the actual smart contract account address (`minimalAccount`) as a parameter. This address will then be used to fetch the nonce and populate the `sender` field of the `UserOperation`. ```solidity // In script/SendPackedUserOp.s.sol function generateSignedUserOperation( bytes memory callData, HelperConfig.NetworkConfig memory config, address minimalAccount // New parameter for the smart contract account ) public view returns (PackedUserOperation memory) { // ... // Use minimalAccount for nonce and for the UserOperation's sender uint256 nonce = vm.getNonce(minimalAccount); // Note: This nonce logic will be further refined PackedUserOperation memory userOp = _generateUnsignedUserOperation(callData, minimalAccount, nonce); // ... return userOp; } ``` * Next, update the test file (`test/ethereum/MinimalAccountTest.t.sol`) where `generateSignedUserOperation` is called. Ensure you pass the correct smart contract account address (`address(minimalAccount)`) to this function. This change needs to be applied in `testEntryPointCanExecuteCommands` and any other relevant test functions that prepare UserOperations. ```solidity // In test/ethereum/MinimalAccountTest.t.sol // (Inside testEntryPointCanExecuteCommands and other relevant tests) packedUserOp = sendPackedUserOp.generateSignedUserOperation( executeCallData, helperConfig.getConfig(), address(minimalAccount) // Pass the correct smart contract account address ); ``` ### Quick Debugging with `console.log` While the Foundry debugger is powerful, sometimes a quick `console.log` (or `console2.log` for Solidity in Foundry) can help verify values. For instance, you could have temporarily added logging inside `EntryPoint.sol` (if you're working with a local copy or have it as a submodule) to print the `sender` address. To use `console2.log` in a contract like `EntryPoint.sol` (which is often a dependency), you would need to add an import statement. The path must be relative to the contract file, or use remappings if configured: ```solidity // Example import in EntryPoint.sol (adjust path as necessary) import {console2} from "../../../../lib/forge-std/src/console2.sol"; // Or a more robust way using remappings: // import "forge-std/console2.sol"; ``` Then, you could use `console2.log("EntryPoint sender:", sender);` before the `try...catch` block. Remember to remove such debug statements from production or dependency code afterwards. ### Uncovering and Correcting the Second Bug: Invalid Account Nonce After applying the fix for the `sender` address, re-running the test (e.g., `forge test --mt testEntryPointCanExecuteCommands -vvv`) might reveal a new error message. In our scenario, the test now fails with a revert reason like: `[FAIL. Reason: Revert] EvmError: Revert AA25 invalid account nonce`. This indicates that while the `sender` is now correct, the `nonce` associated with the `UserOperation` is still problematic. **The Bug:** The nonce for the `UserOperation` was fetched using `vm.getNonce(minimalAccount)`. For smart contract accounts, especially when they are newly deployed or haven't initiated transactions through the `EntryPoint` yet, the `EntryPoint` typically expects the first nonce to be `0`. The `vm.getNonce()` cheatcode might return `1` if it's tracking nonces similarly to EOAs or based on other contract creations/interactions in the test environment for an account that has not yet had a UserOperation processed. **The Fix for Bug 2:** To align with the `EntryPoint`'s expected nonce for a fresh smart contract account's first UserOperation, we adjust the nonce calculation in `script/SendPackedUserOp.s.sol`. If `vm.getNonce(minimalAccount)` returns `1` for an account that should have a nonce of `0` for its first UserOperation, subtracting `1` rectifies this. ```solidity // In script/SendPackedUserOp.s.sol, inside generateSignedUserOperation // uint256 nonce = vm.getNonce(minimalAccount); // Original line after the first fix uint256 nonce = vm.getNonce(minimalAccount) - 1; // Corrected nonce for the first UserOp ``` *Note: For subsequent UserOperations from the same account, the nonce management would need to increment correctly, typically handled by the `EntryPoint` or by careful tracking off-chain or within your scripting logic. This fix specifically addresses the common "first nonce is 0" expectation for an account's initial UserOperation.* ### Achieving Success: The Test Passes With both the `sender` and `nonce` fields correctly populated in the `UserOperation`, we run our target test one final time: ```bash forge test --mt testEntryPointCanExecuteCommands -vvv ``` This time, the test should **PASS**. The console output might also show logs from your setup or test execution, such as "Deploying mocks" and, crucially, evidence of the correct nonce being used, like "NONCE 0" if you logged it or if it's part of other transaction traces. ### Key Takeaways from This Debugging Session This detailed debugging exercise highlights several crucial aspects of developing and testing ERC-4337 Account Abstraction solutions: * **Foundry Debugging Proficiency:** Effective use of `forge test --debug`, the `Shift + G` shortcut to jump to reverts, and the `J`/`K` keys for stepping through EVM opcodes are invaluable. * **ERC-4337 Core Components:** A solid understanding of the `EntryPoint` contract's role, the structure of a `UserOperation`, the significance of the `sender` (which must be the smart contract account), and the correct `nonce` management is essential. * **Common Account Abstraction Pitfalls:** * Incorrectly setting the `UserOperation.sender` (e.g., using an EOA instead of the Smart Contract Account address). * Mismatched nonces for Smart Contract Accounts, especially the initial nonce. * **Systematic Code Refactoring:** Debugging often involves modifying function signatures (like adding the `minimalAccount` parameter) and ensuring all call sites are updated accordingly. * **Strategic Revert Troubleshooting:** The process of systematically tracing execution backward from the point of revert is a universal debugging technique to uncover the root cause of errors. ### Conclusion: Diligence in UserOperation Construction Successfully debugging this failing test underscores the critical importance of meticulously constructing `UserOperation` structs. Fields like `sender` and `nonce` are fundamental to the ERC-4337 Account Abstraction flow, and errors in these values are common sources of reverts within the `EntryPoint`. By leveraging Foundry's debugging tools and a systematic approach, developers can efficiently identify and resolve such issues, leading to robust and reliable smart contract account implementations.
A hands-on guide to Debugging ERC-4337 UserOperations in Foundry - Troubleshoot failing ERC-4337 tests by mastering Foundry's debugger to fix `UserOperation` `sender` and `nonce` errors. You'll use `Shift + G` to locate reverts, step through EVM opcodes, and ensure your smart contract accounts behave as expected.
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