1/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)
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