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.
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.
userOpHash
: What the EntryPoint ExpectsTo 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
):
This function constructs the userOpHash
by performing a keccak256
hash on the ABI-encoded concatenation of three key components:
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.).
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.
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.
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
:
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.
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
:
The central piece of this script will be a function, generateSignedUserOperation
:
Explanation of generateSignedUserOperation
:
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.
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.
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.
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
.
This workaround allows tests to proceed by directly providing the known private key for Anvil's default accounts.
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):
Setting up testRecoverSignedOp
:
Breakdown of testRecoverSignedOp
:
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.
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.
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.
It's important to recognize the layered nature of callData
in ERC-4337:
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.
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 July 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 July 10, 2025