5/5
## Correctly Signing ERC-4337 UserOperations for the EntryPoint This lesson details the process of correctly signing a `PackedUserOperation` in accordance with ERC-4337 standards. Our primary focus is on generating the precise hash (`userOpHash`) that the `EntryPoint` contract expects, which is a critical step before the signature can be created and applied to the `PackedUserOperation`'s `signature` field. We'll also explore how to deploy a mock `EntryPoint` for local development and testing, and address the nuances of signing within Foundry scripts versus tests. ## The Crucial `userOpHash`: What the EntryPoint Expects To sign a `PackedUserOperation`, we first need to compute the exact hash that the target `EntryPoint` contract will use for verification. This hash is commonly referred to as `userOpHash`. The `EntryPoint` contract itself defines the function responsible for generating this hash. Let's examine the `getUserOpHash` function, typically found in an `EntryPoint.sol` implementation (e.g., `lib/account-abstraction/contracts/core/EntryPoint.sol`): ```solidity // From EntryPoint.sol function getUserOpHash(PackedUserOperation calldata userOp) public view returns (bytes32) { return keccak256(abi.encode(userOp.hash(), address(this), block.chainid)); } ``` This function constructs the `userOpHash` by performing a `keccak256` hash on the ABI-encoded concatenation of three key components: 1. **`userOp.hash()`**: This is an internal hash of the core fields of the `PackedUserOperation` itself, *excluding* the `signature` field. This internal hash is typically generated by a library function within the `EntryPoint`'s ecosystem, such as `UserOperationLib.hash()`. It ensures the integrity of the operation's fundamental parameters (sender, nonce, callData, gas limits, etc.). 2. **`address(this)`**: This is the address of the `EntryPoint` contract instance being interacted with. Including the `EntryPoint`'s address in the hash is a vital security measure that prevents replay attacks across different `EntryPoint` contract deployments or versions. A signature valid for one `EntryPoint` will not be valid for another. 3. **`block.chainid`**: This is the chain ID of the network where the transaction is being processed. This component prevents cross-chain replay attacks, ensuring that a `UserOperation` signed for one blockchain (e.g., Ethereum Mainnet) cannot be maliciously replayed on another (e.g., Polygon). **Key Takeaway:** It is absolutely crucial to use the hashing mechanism provided by the *specific* `EntryPoint` contract you intend to interact with. Different `EntryPoint` versions or custom implementations might have slight variations in their `getUserOpHash` logic. ## Deploying a Mock EntryPoint for Local Testing For local development and testing using tools like Anvil (Foundry's local testnet), it's essential to have an `EntryPoint` contract deployed. We can manage network-specific configurations, including the `EntryPoint` address, using a script like `HelperConfig.s.sol`. Within the `getOrCreateAnvilEthConfig` function (or a similar function for local network setup) in `HelperConfig.s.sol`, we can deploy a mock `EntryPoint`: ```solidity // In HelperConfig.s.sol, inside getOrCreateAnvilEthConfig() console2.log("Deploying mocks for Anvil..."); vm.startBroadcast(FOUNDRY_DEFAULT_WALLET); // Or your chosen deployer address EntryPoint entryPoint = new EntryPoint(); // Deploying the EntryPoint contract vm.stopBroadcast(); // Store the deployed EntryPoint address in our configuration localNetworkConfig = NetworkConfig({ entryPoint: address(entryPoint), account: FOUNDRY_DEFAULT_WALLET // Example EOA/signer // ... other config fields }); networkConfigs[LOCAL_CHAIN_ID] = localNetworkConfig; ``` This deployed `EntryPoint`'s address is then stored in a `NetworkConfig` struct, making it easily accessible to other scripts and tests that need to interact with it. ## Generating a Signed PackedUserOperation We'll now create a Foundry script, `SendPackedUserOp.s.sol`, to encapsulate the logic for constructing and signing a `PackedUserOperation`. **Core Imports for `SendPackedUserOp.s.sol`:** ```solidity import {PackedUserOperation} from "lib/account-abstraction/contracts/interfaces/PackedUserOperation.sol"; import {HelperConfig, NetworkConfig} from "script/HelperConfig.s.sol"; // Assuming NetworkConfig is defined or imported here import {IEntryPoint} from "lib/account-abstraction/contracts/interfaces/IEntryPoint.sol"; import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; ``` The central piece of this script will be a function, `generateSignedUserOperation`: ```solidity // In SendPackedUserOp.s.sol // Make sure MessageHashUtils is available for bytes32 using MessageHashUtils for bytes32; contract SendPackedUserOp is Script { // Or your preferred base contract HelperConfig public helperConfig; function setUp() public { helperConfig = new HelperConfig(); } function generateSignedUserOperation( bytes memory callData, // The target call data for the smart account's execution HelperConfig.NetworkConfig memory config // Network config containing EntryPoint address and signer ) internal returns (PackedUserOperation memory) { // Step 1: Generate the Unsigned UserOperation // Fetch the nonce for the sender (smart account address) from the EntryPoint // For simplicity, we'll assume the 'config.account' is the smart account for now, // though in reality, this would be the smart account address, and config.account the EOA owner. // Nonce would be: IEntryPoint(config.entryPoint).getNonce(config.account, nonceKey); // For this example, let's use a placeholder nonce or assume it's passed in. uint256 nonce = IEntryPoint(config.entryPoint).getNonce(config.account, 0); // Simplified nonce retrieval PackedUserOperation memory userOp = _generateUnsignedUserOperation( callData, config.account, // This should be the smart account address nonce ); // Step 2: Get the userOpHash from the EntryPoint // We need to cast the config.entryPoint address to the IEntryPoint interface bytes32 userOpHash = IEntryPoint(config.entryPoint).getUserOpHash(userOp); // Prepare the hash for EIP-191 signing (standard Ethereum signed message) // This prepends "\x19Ethereum Signed Message:\n32" and re-hashes. bytes32 digest = userOpHash.toEthSignedMessageHash(); // Step 3: Sign the digest // 'config.account' here is the EOA that owns/controls the smart account. // This EOA must be unlocked for vm.sign to work without a private key. (uint8 v, bytes32 r, bytes32 s) = vm.sign(config.account, digest); // Construct the final signature. // IMPORTANT: The order is R, S, V (abi.encodePacked(r, s, v)). // This differs from vm.sign's return order (v, r, s). userOp.signature = abi.encodePacked(r, s, v); return userOp; } // Helper function to populate the UserOperation fields (excluding signature) function _generateUnsignedUserOperation( bytes memory callData, address sender, // Smart account address uint256 nonce ) internal pure returns (PackedUserOperation memory) { // Placeholder gas values; these should be estimated or configured properly uint256 verificationGasLimit = 200000; uint256 callGasLimit = 300000; uint256 maxFeePerGas = 100 gwei; uint256 maxPriorityFeePerGas = 2 gwei; return PackedUserOperation({ sender: sender, nonce: nonce, initCode: hex"", // Assuming account is already deployed. Provide if deploying. callData: callData, accountGasLimits: bytes32(uint256(verificationGasLimit) << 128 | callGasLimit), preVerificationGas: verificationGasLimit + 50000, // Needs proper estimation gasFees: bytes32(uint256(maxPriorityFeePerGas) << 128 | maxFeePerGas), paymasterAndData: hex"", // No paymaster for this example signature: hex"" // Left empty, to be filled after hashing and signing }); } } ``` **Explanation of `generateSignedUserOperation`:** 1. **Generate Unsigned Data:** * The `_generateUnsignedUserOperation` helper function is called. It populates a `PackedUserOperation` struct with all necessary fields *except* `signature`. This includes the `sender` (smart account address), `nonce`, `callData` (the action the smart account will perform), gas parameters, etc. `initCode` would be used if the smart account itself needs to be deployed. 2. **Get the `userOpHash`:** * Using the `EntryPoint` address obtained from the `config` (which we deployed earlier or got from `HelperConfig`), we call `getUserOpHash(userOp)` on an `IEntryPoint` interface. This returns the canonical hash as defined by that `EntryPoint`. * Crucially, this `userOpHash` is then processed by `toEthSignedMessageHash()`. This utility function (from OpenZeppelin's `MessageHashUtils`) prepends the standard Ethereum signed message prefix (`\x19Ethereum Signed Message:\n32`) to the `userOpHash` and then re-hashes it. This makes the signature EIP-191 compliant, which is standard for off-chain message signing and recovery. 3. **Sign It:** * Foundry's `vm.sign(signer_address, digest)` cheatcode is used to sign the EIP-191 compliant `digest`. * **Important Tip:** `vm.sign` can accept an `address` (like `config.account`, which represents the EOA owner of the smart account) *if* that account is "unlocked." In Foundry scripts, this can be achieved by using flags like `forge script --account <name_of_account_in_foundry_toml_or_keystore>` or by using default Anvil accounts which are unlocked by default in script execution contexts. This method conveniently avoids embedding private keys directly in your script code. * `vm.sign` returns the signature components `v`, `r`, and `s`. * The final signature is constructed using `abi.encodePacked(r, s, v)`. **Note the specific order: R, then S, then V.** This (RSV) is a common convention for Ethereum signatures when concatenated, but it's important to be aware that `vm.sign` returns them in VRS order. Getting this order wrong is a common source of signature validation errors. * The resulting packed signature is then assigned to `userOp.signature`. The function returns the `PackedUserOperation`, now complete with its signature. ## The "No Wallets Available" Error in Foundry Tests A common pitfall arises when attempting to use `vm.sign(config.account, digest)` directly within a `forge test` environment in the same way it's used in scripts. You might encounter an error: "Reason: no wallets are available." This occurs because `forge test` does not automatically unlock the default Anvil accounts (or other accounts specified via `config.account`) in the same manner that `forge script --account <name>` does. The test environment is more sandboxed by default regarding wallet access. The presenter mentioned creating Foundry GitHub Issue #8225, requesting a feature to allow default Anvil accounts to be optionally unlocked during local tests to simplify this workflow. **Workaround for Local Anvil Tests:** To sign messages in tests when using default Anvil accounts (like the one with `chainId == 31337`), you need to explicitly use the corresponding private key with `vm.sign`. ```solidity // Example of how to handle signing in tests for Anvil uint256 ANVIL_DEFAULT_PRIVATE_KEY = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80; // Default Anvil key 0 // ... inside your test or signing logic ... bytes32 digest = userOpHash.toEthSignedMessageHash(); (uint8 v, bytes32 r, bytes32 s); if (block.chainid == 31337) { // Anvil's default chain ID (v, r, s) = vm.sign(ANVIL_DEFAULT_PRIVATE_KEY, digest); } else { // For scripts or other networks where config.account is unlocked (v, r, s) = vm.sign(config.account, digest); } userOp.signature = abi.encodePacked(r, s, v); ``` This workaround allows tests to proceed by directly providing the known private key for Anvil's default accounts. ## Verifying the Signature: `testRecoverSignedOp` To ensure our signing process is correct, we can write a test that recovers the signer's address from the generated signature and compares it to the expected signer (the owner of the `MinimalAccount`). This will be done in `MinimalAccountTest.t.sol`. **Core Imports for `MinimalAccountTest.t.sol` (for signature recovery):** ```solidity import {SendPackedUserOp, PackedUserOperation} from "script/SendPackedUserOp.s.sol"; // Assuming PackedUserOperation is exported or accessible import {IEntryPoint} from "lib/account-abstraction/contracts/interfaces/IEntryPoint.sol"; import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; // Plus existing imports: HelperConfig, MinimalAccount, ERC20Mock, Test, etc. ``` **Setting up `testRecoverSignedOp`:** ```solidity // In MinimalAccountTest.t.sol // Ensure MessageHashUtils is available for bytes32 using MessageHashUtils for bytes32; contract MinimalAccountTest is Test { // ... (setUp function with MinimalAccount, HelperConfig, ERC20Mock deployment) ... SendPackedUserOp sendPackedUserOpScript; HelperConfig.NetworkConfig activeNetworkConfig; MinimalAccount minimalAccount; address owner; // EOA owner of minimalAccount EntryPoint mockEntryPoint; // Deployed mock EntryPoint instance function setUp() public { // ... (deploy MinimalAccount, get owner, deploy mock EntryPoint) // owner = address(0x...); // Your EOA // minimalAccount = new MinimalAccount(owner); // mockEntryPoint = new EntryPoint(); sendPackedUserOpScript = new SendPackedUserOp(); // Configure activeNetworkConfig for local testing // This typically comes from a HelperConfig instance // For this example, we'll manually set it up assuming Anvil. // In a real HelperConfig, this would be fetched. HelperConfig helperConfig = new HelperConfig(); activeNetworkConfig = helperConfig.getOrCreateAnvilEthConfig(); // Or however you get your local config // If EntryPoint wasn't deployed via HelperConfig for the test, assign it: // activeNetworkConfig.entryPoint = address(mockEntryPoint); // activeNetworkConfig.account = owner; // The EOA signing for the smart account // Initialize the SendPackedUserOp script's helperConfig if it relies on it internally sendPackedUserOpScript.helperConfig = helperConfig; } function testRecoverSignedOp() public { // Arrange: // 1. Define the target call data (e.g., minting USDC through the MinimalAccount) uint256 AMOUNT = 100e6; // Example amount // Assume 'usdc' is an ERC20Mock instance deployed in setUp bytes memory functionDataForUSDCMint = abi.encodeWithSelector( usdc.mint.selector, address(minimalAccount), // Mint to the smart account itself AMOUNT ); // 2. Define the callData for MinimalAccount.execute // This is what the EntryPoint will use to call our smart account. bytes memory executeCallData = abi.encodeWithSelector( minimalAccount.execute.selector, address(usdc), // dest: the USDC contract 0, // value: no ETH sent with this call functionDataForUSDCMint // data: the encoded call to usdc.mint ); // 3. Generate the signed PackedUserOperation // Note: If generateSignedUserOperation needs the private key for testing as discussed, // that logic would be inside it or passed appropriately. // Here, we assume config.account (owner) is usable by vm.sign IF workaround is applied // or if this test itself is run as a script with --account. // For pure 'forge test', the private key workaround inside generateSignedUserOperation is needed. PackedUserOperation memory packedUserOp = sendPackedUserOpScript.generateSignedUserOperation( executeCallData, activeNetworkConfig // Contains EntryPoint address and EOA signer (owner) ); // 4. Get the userOpHash again (as the EntryPoint would calculate it) // Ensure we use the same EntryPoint address as used during signing. bytes32 userOperationHash = IEntryPoint(activeNetworkConfig.entryPoint) .getUserOpHash(packedUserOp); // Act: // Recover the signer's address from the EIP-191 compliant digest and the signature. // The digest MUST match what was signed. address actualSigner = ECDSA.recover( userOperationHash.toEthSignedMessageHash(), // Re-apply EIP-191 for recovery packedUserOp.signature ); // Assert: // Check if the recovered signer is the owner of the MinimalAccount. assertEq(actualSigner, minimalAccount.owner(), "Signer recovery failed"); } } ``` **Breakdown of `testRecoverSignedOp`:** 1. **Arrange:** * `functionDataForUSDCMint`: This is the innermost payload—the encoded call to `usdc.mint(...)`. * `executeCallData`: This is the `callData` field for the `PackedUserOperation`. It encodes the call to `minimalAccount.execute(...)`, with its parameters being the target contract (`address(usdc)`), value (`0`), and the `functionDataForUSDCMint`. * `packedUserOp`: We call the `generateSignedUserOperation` function (from our `SendPackedUserOp.s.sol` script, instantiated) to get the fully signed operation. The `activeNetworkConfig` provides the `EntryPoint` address and the signer EOA. * `userOperationHash`: We re-calculate the `userOpHash` by calling `getUserOpHash` on our mock `EntryPoint` instance, passing the *unsigned* fields of the `packedUserOp` (which is what `getUserOpHash` internally uses from the `userOp` struct). This simulates what the actual `EntryPoint` would do. 2. **Act:** * `ECDSA.recover(digest, signature)` is used. Crucially, the `digest` passed to `ECDSA.recover` must be the EIP-191 compliant hash (`userOperationHash.toEthSignedMessageHash()`), identical to what was originally signed. The `packedUserOp.signature` is the RSV-packed signature we generated. 3. **Assert:** * We assert that the `actualSigner` recovered by `ECDSA.recover` is equal to `minimalAccount.owner()`. If they match, our signing and hash generation logic is correct. ## Understanding Nested Call Data It's important to recognize the layered nature of `callData` in ERC-4337: 1. The `PackedUserOperation.callData` field contains the ABI-encoded data for the first call initiated by the `EntryPoint` into the smart account. This is typically a call to a function like `execute(address dest, uint256 value, bytes calldata func)` on the smart account. 2. The `func` parameter *within* that `execute` call (i.e., part of `PackedUserOperation.callData`) then contains the ABI-encoded data for the *actual* operation the user wants to perform, such as `USDC.mint(...)` or `NFT.transferFrom(...)`. This nested structure allows the smart account to act as a general-purpose executor for arbitrary calls, controlled by the signed `UserOperation`. By following these steps, you can reliably generate the correct hash for an ERC-4337 `UserOperation`, sign it appropriately, and build robust tests to verify your implementation. Understanding the `EntryPoint`'s hashing mechanism, EIP-191 compliance, and the nuances of Foundry's signing tools are key to successful ERC-4337 development.
A practical guide to Correctly Signing ERC-4337 UserOperations for the EntryPoint - Learn to generate the precise `userOpHash` for ERC-4337 `EntryPoint` interactions and create EIP-191 compliant signatures for `PackedUserOperations`. You'll deploy mock `EntryPoints`, use `vm.sign` in Foundry, handle test signing, and verify signatures for reliable ERC-4337 development.
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 10, 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 10, 2025