0/5
## Streamlining UserOperation Signing for Local and Testnet Development In modern Web3 development, particularly when working with ERC-4337 Account Abstraction, ensuring your smart contracts and scripts behave consistently across different environments is paramount. A common challenge arises when dealing with message signing, specifically for `UserOperation` structs. Local development environments like Anvil (part of Foundry) offer conveniences such as unlocked accounts with known private keys, while testnets and mainnet require more robust private key management. This lesson details how to refactor your Solidity scripts and configurations to seamlessly handle UserOperation signing for local Anvil development while maintaining compatibility with testnets. The core objective is to ensure that `UserOperation`s are signed with the appropriate key based on the network: a predictable Anvil default key for local testing, and a designated (e.g., burner wallet) key for testnets. ## The Core Challenge: Conditional Signing Based on Network The fundamental issue is that the private key used for signing `UserOperation`s locally with Anvil should ideally be one of Anvil's pre-funded, unlocked accounts for ease of use. However, when deploying or interacting on a public testnet (like Sepolia), you'll use a different private key, often managed via environment variables and associated with a burner wallet. To address this, we employ conditional logic based on the `block.chainId`. Anvil typically runs on chain ID `31337`. By checking for this ID, our scripts can differentiate between a local Anvil instance and other networks. Anvil's "unlocked accounts" are a key feature here. These are accounts for which Anvil knows the private keys, allowing Foundry's cheatcodes to sign messages on their behalf without explicit private key exposure in common script setups. ## Key Tool: Foundry's `vm.sign` Cheatcode Foundry provides a powerful cheatcode, `vm.sign`, which is central to this refactoring. This cheatcode can sign a digest (a hash) in two primary ways: 1. Given a `uint256 privateKey`, it will sign the digest using that specific private key. 2. Given an `address account`, if Foundry manages the private key for that account (e.g., because it's an Anvil default account or the address was specified in `vm.startBroadcast` with a corresponding private key configured), it will sign using the associated private key. In the context of ERC-4337, `vm.sign` is used to generate the signature for a `UserOperation` digest, which is then validated by the EntryPoint contract. ## Refactoring `SendPackedUserOp.s.sol`: Implementing Conditional Signing Logic The `SendPackedUserOp.s.sol` script is responsible for constructing and signing UserOperations. Its `generateSignedUserOperation` function is the primary target for our refactoring. **Simplified "Before" Logic:** Previously, the signing logic might have looked something like this, relying on `config.account` which might not always align with Anvil's default accounts for local signing: ```solidity // (Simplified for illustration) // 3. Sign it (uint8 v, bytes32 r, bytes32 s) = vm.sign(config.account, digest); userOp.signature = abi.encodePacked(r, s, v); // Note the order ``` **"After" Refactor with Conditional Logic:** The function is modified to use a specific Anvil private key when on the local chain, and the configured account's key otherwise. ```solidity // contract SendPackedUserOp is Script { // ... function generateSignedUserOperation(bytes memory callData, HelperConfig.NetworkConfig memory config) public view // Function doesn't modify state, safe to be view returns (PackedUserOperation memory) { // 1. Generate the unsigned UserOperation // (Assuming _generateUnsignedUserOperation populates fields like sender, nonce, callData, etc.) // In this setup, config.account (EOA) is used as the UserOp sender. uint256 nonce = vm.getNonce(config.account); PackedUserOperation memory userOp = _generateUnsignedUserOperation(callData, config.account, nonce); // 2. Get the UserOperation Hash from the EntryPoint bytes32 userOpHash = IEntryPoint(config.entryPoint).getUserOpHash(userOp); // Prepare the hash for eth_sign convention (includes prefixing) bytes32 digest = userOpHash.toEthSignedMessageHash(); // 3. Sign the digest conditionally uint8 v; bytes32 r; bytes32 s; // Anvil's first default private key (0xac0974bec39a17e36ba46cd5f4d73f4a94f82f9492b3e9ec6b2a0c50be290690) // This can be found by running `anvil` in your terminal. uint256 constant ANVIL_DEFAULT_KEY = 0xac0974bec39a17e36ba46cd5f4d73f4a94f82f9492b3e9ec6b2a0c50be290690; if (block.chainId == 31337) { // Local Anvil chain ID // Use the known Anvil private key for signing on the local chain (v, r, s) = vm.sign(ANVIL_DEFAULT_KEY, digest); } else { // For testnets (e.g., Sepolia), use the account from config (e.g., BURNER_WALLET). // Foundry will use the private key associated with this address (e.g., from .env via vm.startBroadcast). (v, r, s) = vm.sign(config.account, digest); } // Pack the signature (r, s, v order is common) userOp.signature = abi.encodePacked(r, s, v); return userOp; } // ... // } ``` **Discussion of Changes:** * A constant `ANVIL_DEFAULT_KEY` is introduced, holding the private key of Anvil's first default account. This key is readily available when you start an `anvil` instance. * The `if (block.chainId == 31337)` statement checks if the current network is the local Anvil chain. * If true, `vm.sign(ANVIL_DEFAULT_KEY, digest)` is called, explicitly using Anvil's default private key. * If false (i.e., on a testnet), `vm.sign(config.account, digest)` is called. Here, `config.account` would typically be an address like `BURNER_WALLET`, and Foundry resolves its private key (often set up via an `.env` file and used during `vm.startBroadcast`). * The variables `v, r, s` are declared outside the `if/else` block to be accessible for packing the signature. * The `toEthSignedMessageHash()` utility is used to correctly format the hash before signing, adhering to the `eth_sign` standard, which `vm.sign` and `ECDSA.recover` expect for this type of EOA signature. * The function can be marked as `view` because `vm.sign`, being a cheatcode, does not alter blockchain state during its execution in this context. ## Centralizing Configuration: Enhancing `HelperConfig.s.sol` To maintain consistency, especially for local development, the `HelperConfig.s.sol` contract, which centralizes network-specific configurations, needs to be updated. The goal is to ensure that when operating on the local Anvil chain, the default account used for deployments and ownership aligns with the account whose private key is used for signing. **Key Changes in `HelperConfig.s.sol`:** ```solidity // contract HelperConfig is Script { // ... // uint256 constant LOCAL_CHAIN_ID = 31337; // address constant BURNER_WALLET = ...; // Used for testnets // Anvil's first default account address (corresponds to ANVIL_DEFAULT_KEY) address constant ANVIL_DEFAULT_ACCOUNT = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; // NetworkConfig public localNetworkConfig; // Stores cached local config function getOrCreateAnvilEthConfig() public returns (NetworkConfig memory) { if (localNetworkConfig.account != address(0)) { // Check if already configured return localNetworkConfig; } console2.log("Deploying mocks for local Anvil network..."); // Use Anvil's default account for broadcasting deployments on the local chain vm.startBroadcast(ANVIL_DEFAULT_ACCOUNT); EntryPoint entryPoint = new EntryPoint(); // Example: Deploying a mock EntryPoint // ... (deploy other necessary mock contracts) vm.stopBroadcast(); // Create and cache the local network config using Anvil's default account localNetworkConfig = NetworkConfig({ entryPoint: address(entryPoint), account: ANVIL_DEFAULT_ACCOUNT // This account is used as the default EOA for local dev // ... other local-specific config values }); return localNetworkConfig; } // function getConfigByChainId(uint256 chainId) public returns (NetworkConfig memory) { // if (chainId == LOCAL_CHAIN_ID) { // return getOrCreateAnvilEthConfig(); // } // // ... logic for Sepolia, other testnets, etc. ... // } // ... // } ``` **Discussion of Changes:** * An `ANVIL_DEFAULT_ACCOUNT` constant is added. This address corresponds to the `ANVIL_DEFAULT_KEY` used in `SendPackedUserOp.s.sol`. * The `getOrCreateAnvilEthConfig` function (which is called by `getConfig()` or `getConfigByChainId()` when `block.chainId == 31337`) is updated: * It now uses `ANVIL_DEFAULT_ACCOUNT` for `vm.startBroadcast` when deploying mock contracts or any initial setup on the local Anvil chain. This ensures that the `msg.sender` for these deployments is Anvil's default account. * Crucially, the `account` field in the `NetworkConfig` struct returned (and cached in `localNetworkConfig`) is set to `ANVIL_DEFAULT_ACCOUNT`. This means that whenever `helperConfig.getConfig().account` is accessed within scripts operating on the local chain, it will resolve to Anvil's default EOA. ## Ensuring Consistency: Impact on `DeployMinimal.s.sol` Scripts like `DeployMinimal.s.sol`, which are responsible for deploying smart contract accounts (e.g., `MinimalAccount`) and setting their owners, implicitly benefit from the `HelperConfig` changes. Consider a typical deployment script: ```solidity // contract DeployMinimal is Script { // HelperConfig helperConfig; // MinimalAccount minimalAccount; // function run() external returns (HelperConfig, MinimalAccount) { // helperConfig = new HelperConfig(); // return deployMinimalAccount(); // } // function deployMinimalAccount() public returns (HelperConfig, MinimalAccount) { // HelperConfig.NetworkConfig memory config = helperConfig.getConfig(); // Gets network-specific config // vm.startBroadcast(config.account); // On local, config.account is now ANVIL_DEFAULT_ACCOUNT // minimalAccount = new MinimalAccount(config.entryPoint); // // Set the owner of the smart contract account // minimalAccount.transferOwnership(config.account); // Owner set to ANVIL_DEFAULT_ACCOUNT on local // vm.stopBroadcast(); // return (helperConfig, minimalAccount); // } // ... // } ``` When this script runs on the local Anvil chain: 1. `helperConfig.getConfig()` returns the `localNetworkConfig` where `config.account` is `ANVIL_DEFAULT_ACCOUNT`. 2. `vm.startBroadcast(ANVIL_DEFAULT_ACCOUNT)` is used for deployment. 3. `minimalAccount.transferOwnership(ANVIL_DEFAULT_ACCOUNT)` sets the owner of the `MinimalAccount` to Anvil's default account. This alignment is critical: the entity deploying contracts, the entity designated as the owner of smart contract accounts, and the private key used for signing UserOperations are all consistently `ANVIL_DEFAULT_ACCOUNT` (and its corresponding `ANVIL_DEFAULT_KEY`) in the local development environment. ## Verification Through Testing: `MinimalAccountTest.t.sol` The effectiveness of these changes can be verified using tests, such as `testRecoverSignedOp` in `MinimalAccountTest.t.sol`. This test typically involves generating a signed UserOperation and then recovering the signer's address from the signature and the operation hash. **Relevant Test Logic Snippet:** ```solidity // function testRecoverSignedOp() public { // Arrange // ... (Setup including deploying MinimalAccount, EntryPoint, and generating callData) // Note: MinimalAccount's owner is set during its deployment via DeployMinimal.s.sol. // On the local chain, helperConfig.getConfig().account (used for ownership) will be ANVIL_DEFAULT_ACCOUNT. // Generate the signed UserOperation using the refactored script PackedUserOperation memory packedUserOp = sendPackedUserOp.generateSignedUserOperation(executeCallData, helperConfig.getConfig()); // Get the UserOperation hash (as the EntryPoint would compute it) bytes32 userOperationHash = IEntryPoint(helperConfig.getConfig().entryPoint).getUserOpHash(packedUserOp); bytes32 digestToRecover = userOperationHash.toEthSignedMessageHash(); // Ensure same hashing convention // Act // Recover the signer from the signature and the digest address actualSigner = ECDSA.recover(digestToRecover, packedUserOp.signature); // Assert // Check if the recovered signer matches the owner of the MinimalAccount // On local chain, minimalAccount.owner() should be ANVIL_DEFAULT_ACCOUNT // and actualSigner should also be ANVIL_DEFAULT_ACCOUNT because ANVIL_DEFAULT_KEY was used for signing. assertEq(actualSigner, minimalAccount.owner(), "Recovered signer does not match account owner"); // } ``` **Why the Test Passes:** After the refactoring: 1. When running on the local Anvil chain (`block.chainId == 31337`): * `sendPackedUserOp.generateSignedUserOperation` uses `ANVIL_DEFAULT_KEY` to sign the UserOperation. * `DeployMinimal.s.sol` sets `minimalAccount.owner()` to `ANVIL_DEFAULT_ACCOUNT` because `helperConfig.getConfig().account` resolves to this address locally. 2. `ECDSA.recover` correctly identifies the signer of the UserOperation as `ANVIL_DEFAULT_ACCOUNT` (the address corresponding to `ANVIL_DEFAULT_KEY`). 3. The assertion `assertEq(actualSigner, minimalAccount.owner())` passes because both addresses are `ANVIL_DEFAULT_ACCOUNT`. This confirms that the UserOperation is signed by the correct key locally, and that this key corresponds to the intended owner of the smart contract account within the local testing setup. ## Key Takeaways and Best Practices By implementing conditional signing logic and aligning configurations in `HelperConfig.s.sol`, your Foundry project can achieve: * **Smoother Local Development:** Developers can leverage Anvil's unlocked accounts for signing UserOperations locally without managing separate private keys for this specific purpose. * **Testnet/Mainnet Compatibility:** The same scripts will use the appropriate, securely managed private keys (e.g., from environment variables via `config.account`) when deployed to testnets or mainnet. * **Consistency:** The deploying account, smart contract account owner, and UserOperation signer are consistent in the local environment, simplifying testing and debugging. **Important Reminders:** * **Chain ID `31337`:** This is the standard default chain ID for local development networks like Anvil and Hardhat Network. * **`toEthSignedMessageHash()`:** Always use this (or an equivalent, depending on the signature scheme) when preparing a hash for signing with `eth_sign`-style signatures (which `vm.sign` with a private key produces) and for recovery with `ECDSA.recover`. This prepends the standard Ethereum message prefix. * **Code Organization:** For larger projects, consider placing constants like `ANVIL_DEFAULT_KEY` and `ANVIL_DEFAULT_ACCOUNT` in a dedicated `Constants.sol` or `CodeConstants.s.sol` file, inherited by other scripts and contracts, to improve maintainability. * **`view` Functions:** Functions like `generateSignedUserOperation`, which only read blockchain state or use cheatcodes that don't alter state (like `vm.sign`), can often be marked as `view` or `pure`, improving clarity and potentially enabling certain optimizations or use cases. These refactoring techniques significantly improve the development experience when working with ERC-4337 UserOperations in a Foundry-based project, making local testing more straightforward while ensuring robustness for testnet and production deployments.
A practical refactoring guide to Streamlining UserOperation Signing for Local and Testnet Development - Master conditional ERC-4337 UserOperation signing in Foundry, using Anvil's default key locally and configured keys on testnets. You'll implement `vm.sign` with `block.chainId` checks and update `HelperConfig` for consistency.
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