Smart Accounts
OpenZeppelin provides a simple Account
implementation including only the basic logic to handle user operations in compliance with ERC-4337. Developers who want to build their own account can leverage it to bootstrap custom implementations.
User operations are validated using an AbstractSigner
, which requires to implement the internal _rawSignatureValidation
function, of which we offer a set of implementations to cover a wide customization range. This is the lowest-level signature validation layer and is used to wrap other validation methods like the Account’s validateUserOp
.
Setting up an account
To setup an account, you can either start configuring it using our Wizard and selecting a predefined validation scheme, or bring your own logic and start by inheriting Account
from scratch.
Accounts don’t support ERC-721 and ERC-1155 tokens natively since these require the receiving address to implement an acceptance check. It is recommended to inherit ERC721Holder, ERC1155Holder to include these checks in your account. |
Selecting a signer
Since the minimum requirement of Account
is to provide an implementation of _rawSignatureValidation
, the library includes specializations of the AbstractSigner
contract that use custom digital signature verification algorithms. Some examples that you can select from include:
-
SignerECDSA
: Verifies signatures produced by regular EVM Externally Owned Accounts (EOAs). -
SignerP256
: Validates signatures using the secp256r1 curve, common for World Wide Web Consortium (W3C) standards such as FIDO keys, passkeys or secure enclaves. -
SignerRSA
: Verifies signatures of traditional PKI systems and X.509 certificates. -
SignerERC7702
: Checks EOA signatures delegated to this signer using EIP-7702 authorizations -
SignerERC7913
: Verifies generalized signatures following ERC-7913. -
SignerZKEmail
: Enables email-based authentication for smart contracts using zero knowledge proofs of email authority signatures. -
MultiSignerERC7913
: Allows using multiple ERC-7913 signers with a threshold-based signature verification system. -
MultiSignerERC7913Weighted
: Overrides the threshold mechanism ofMultiSignerERC7913
, offering different weights per signer.
Given SignerERC7913 provides a generalized standard for signature validation, you don’t need to implement your own AbstractSigner for different signature schemes, consider bringing your own ERC-7913 verifier instead.
|
Accounts factory
The first time you send an user operation, your account will be created deterministically (i.e. its code and address can be predicted) using the the initCode
field in the UserOperation. This field contains both the address of a smart contract (the factory) and the data required to call it and create your smart account.
Suggestively, you can create your own account factory using the Clones library from OpenZeppelin Contracts, taking advantage of decreased deployment costs and account address predictability.
// contracts/MyFactoryAccount.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {Address} from "@openzeppelin/contracts/utils/Address.sol";
/**
* @dev A factory contract to create accounts on demand.
*/
contract MyFactoryAccount {
using Clones for address;
using Address for address;
address private immutable _impl;
constructor(address impl_) {
_impl = impl_;
}
/// @dev Predict the address of the account
function predictAddress(bytes32 salt, bytes calldata callData) public view returns (address, bytes32) {
bytes32 calldataSalt = _saltedCallData(salt, callData);
return (_impl.predictDeterministicAddress(calldataSalt, address(this)), calldataSalt);
}
/// @dev Create clone accounts on demand
function cloneAndInitialize(bytes32 salt, bytes calldata callData) public returns (address) {
return _cloneAndInitialize(salt, callData);
}
/// @dev Create clone accounts on demand and return the address. Uses `callData` to initialize the clone.
function _cloneAndInitialize(bytes32 salt, bytes calldata callData) internal returns (address) {
(address predicted, bytes32 _calldataSalt) = predictAddress(salt, callData);
if (predicted.code.length == 0) {
_impl.cloneDeterministic(_calldataSalt);
predicted.functionCall(callData);
}
return predicted;
}
function _saltedCallData(bytes32 salt, bytes calldata callData) internal pure returns (bytes32) {
// Scope salt to the callData to avoid front-running the salt with a different callData
return keccak256(abi.encodePacked(salt, callData));
}
}
Account factories should be carefully implemented to ensure the account address is deterministically tied to the initial owners. This prevents frontrunning attacks where a malicious actor could deploy the account with their own owners before the intended owner does. The factory should include the owner’s address in the salt used for address calculation.
Handling initialization
Most smart accounts are deployed by a factory, the best practice is to create minimal clones of initializable contracts. These signer implementations provide an initializable design by default so that the factory can interact with the account to set it up right after deployment in a single transaction.
import {Account} from "@openzeppelin/community-contracts/account/Account.sol";
import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";
import {SignerECDSA} from "@openzeppelin/community-contracts/utils/cryptography/SignerECDSA.sol";
contract MyAccount is Initializable, Account, SignerECDSA, ... {
// ...
function initializeECDSA(address signer) public initializer {
_setSigner(signer);
}
}
Note that some account implementations may be deployed directly and therefore, won’t require a factory.
Leaving an account uninitialized may leave it unusable since no public key was associated with it. |
Signature validation
Regularly, accounts implement ERC-1271 to enable smart contract signature verification given its wide adoption. To be compliant means that smart contract exposes an isValidSignature(bytes32 hash, bytes memory signature)
method that returns 0x1626ba7e
to identify whether the signature is valid.
The benefit of this standard is that it allows to receive any format of signature
for a given hash
. This generalized mechanism fits very well with the account abstraction principle of bringing your own validation mechanism.
This is how you enable ERC-1271 using an AbstractSigner
:
function isValidSignature(bytes32 hash, bytes calldata signature) public view override returns (bytes4) {
return _rawSignatureValidation(hash, signature) ? IERC1271.isValidSignature.selector : bytes4(0xffffffff);
}
We recommend using ERC7739 to avoid replayability across accounts. This defensive rehashing mechanism that prevents signatures for this account to be replayed in another account controlled by the same signer. See ERC-7739 signatures. |
Batched execution
Batched execution allows accounts to execute multiple calls in a single transaction, which is particularly useful for bundling operations that need to be atomic. This is especially valuable in the context of account abstraction where you want to minimize the number of user operations and associated gas costs. ERC-7821
standard provides a minimal interface for batched execution.
The library implementation supports a single batch mode (0x01000000000000000000
) and allows accounts to execute multiple calls atomically. The standard includes access control through the _erc7821AuthorizedExecutor
function, which by default only allows the contract itself to execute batches.
Here’s an example of how to use batched execution:
import {Account} from "@openzeppelin/community-contracts/account/Account.sol";
import {ERC7821} from "@openzeppelin/community-contracts/account/extensions/ERC7821.sol";
contract MyAccount is Account, ERC7821 {
// Override to allow the entrypoint to execute batches
function _erc7821AuthorizedExecutor(
address caller,
bytes32 mode,
bytes calldata executionData
) internal view virtual override returns (bool) {
return caller == address(entryPoint()) || super._erc7821AuthorizedExecutor(caller, mode, executionData);
}
}
The batched execution data follows a specific format that includes the calls to be executed. This format follows the same format as ERC-7579 execution but only supports 0x01
call type (i.e. batched call
) and default execution type (i.e. reverts if at least one subcall does).
To encode an ERC-7821 batch, you can use viem's utilities:
// CALL_TYPE_BATCH, EXEC_TYPE_DEFAULT, ..., selector, payload
const mode = encodePacked(
["bytes1", "bytes1", "bytes4", "bytes4", "bytes22"],
["0x01", "0x00", "0x00000000", "0x00000000", "0x00000000000000000000000000000000000000000000"]
);
const entries = [
{
target: "0x000...0001",
value: 0n,
data: "0x000...000",
},
{
target: "0x000...0002",
value: 0n,
data: "0x000...000",
}
];
const batch = encodeAbiParameters(
[parseAbiParameter("(address,uint256,bytes)[]")],
[
entries.map<[Address, bigint, Hex]>((entry) =>
[entry.target, entry.value ?? 0n, entry.data ?? "0x"]
),
]
);
const userOpData = encodeFunctionData({
abi: account.abi,
functionName: "execute",
args: [mode, batch]
});
Bundle a UserOperation
UserOperations are a powerful abstraction layer that enable more sophisticated transaction capabilities compared to traditional Ethereum transactions. To get started, you’ll need to an account, which you can get by deploying a factory for your implementation.
Preparing a UserOp
A UserOperation is a struct that contains all the necessary information for the EntryPoint to execute your transaction. You’ll need the sender
, nonce
, accountGasLimits
and callData
fields to construct a PackedUserOperation
that can be signed later (to populate the signature
field).
Specify paymasterAndData with the address of a paymaster contract concatenated to data that will be passed to the paymaster’s validatePaymasterUserOp function to support sponsorship as part of your user operation.
|
Here’s how to prepare one using viem:
import { getContract, createWalletClient, http, Hex } from 'viem';
const walletClient = createWalletClient({
account, // See Viem's `privateKeyToAccount`
chain, // import { ... } from 'viem/chains';
transport: http(),
})
const entrypoint = getContract({
abi: [/* ENTRYPOINT ABI */],
address: '0x<ENTRYPOINT_ADDRESS>',
client: walletClient,
});
const userOp = {
sender: '0x<YOUR_ACCOUNT_ADDRESS>',
nonce: await entrypoint.read.getNonce([sender, 0n]),
initCode: "0x" as Hex,
callData: '0x<CALLDATA_TO_EXECUTE_IN_THE_ACCOUNT>',
accountGasLimits: encodePacked(
["uint128", "uint128"],
[
100_000n, // verificationGasLimit
300_000n, // callGasLimit
]
),
preVerificationGas: 50_000n,
gasFees: encodePacked(
["uint128", "uint128"],
[
0n, // maxPriorityFeePerGas
0n, // maxFeePerGas
]
),
paymasterAndData: "0x" as Hex,
signature: "0x" as Hex,
};
In case your account hasn’t been deployed yet, make sure to provide the initCode
field as abi.encodePacked(factory, factoryData)
to deploy the account within the same UserOp:
const deployed = await publicClient.getCode({ address: predictedAddress });
if (!deployed) {
userOp.initCode = encodePacked(
["address", "bytes"],
[
'0x<ACCOUNT_FACTORY_ADDRESS>',
encodeFunctionData({
abi: [/* ACCOUNT ABI */],
functionName: "<FUNCTION NAME>",
args: [...],
}),
]
);
}
Estimating gas
To calculate gas parameters of a UserOperation
, developers should carefully consider the following fields:
-
verificationGasLimit
: This covers the gas costs for signature verification, paymaster validation (if used), and account validation logic. While a typical value is around 100,000 gas units, this can vary significantly based on the complexity of your signature validation scheme in both the account and paymaster contracts. -
callGasLimit
: This parameter accounts for the actual execution of your account’s logic. It’s recommended to useeth_estimateGas
for each subcall and add additional buffer for computational overhead. -
preVerificationGas
: This compensates for the EntryPoint’s execution overhead. While 50,000 gas is a reasonable starting point, you may need to increase this value based on your UserOperation’s size and specific bundler requirements.
The maxFeePerGas and maxPriorityFeePerGas values are typically provided by your bundler service, either through their SDK or a custom RPC method.
|
A penalty of 10% (UNUSED_GAS_PENALTY_PERCENT ) is applied on the amounts of callGasLimit and paymasterPostOpGasLimit gas that remains unused if the amount of remaining unused gas is greater than or equal to 40,000 (PENALTY_GAS_THRESHOLD ).
|
Signing the UserOp
To sign a UserOperation, you’ll need to first calculate its hash using the EntryPoint’s getUserOpHash
function, then sign this hash using your account’s signature scheme, and finally encode the resulting signature in the format that your account contract expects for verification.
const userOpHash = await entrypoint.read.getUserOpHash([userOp]);
userOp.signature = await eoa.sign({ hash: userOpHash });
The previous example assumes that the account is owned by a single ECDSA signer without any particular signature format. |
Sending the UserOp
Finally, to send the user operation you can call handleOps
on the Entrypoint contract and set yourself as the beneficiary
.
// Send the UserOperation
const userOpReceipt = await walletClient
.writeContract({
abi: [/* ENTRYPOINT ABI */],
address: '0x<ENTRYPOINT_ADDRESS>',
functionName: "handleOps",
args: [[userOp], eoa.address],
})
.then((txHash) =>
publicClient.waitForTransactionReceipt({
hash: txHash,
})
);
// Print receipt
console.log(userOpReceipt);
Since you’re bundling your user operations yourself, you can safely specify preVerificationGas and maxFeePerGas in 0.
|
Using a Bundler
For better reliability, consider using a bundler service. Bundlers provide several key benefits: they automatically handle gas estimation, manage transaction ordering, support bundling multiple operations together, and generally offer higher transaction success rates compared to self-bundling.
Further notes
ERC-7739 Signatures
A common security practice to prevent user operation replayability across smart contract accounts controlled by the same private key (i.e. multiple accounts for the same signer) is to link the signature to the address
and chainId
of the account. This can be done by asking the user to sign a hash that includes these values.
The problem with this approach is that the user might be prompted by the wallet provider to sign an obfuscated message, which is a phishing vector that may lead to a user losing its assets.
To prevent this, developers may use ERC7739Signer
, a utility that implements IERC1271
for smart contract signatures with a defensive rehashing mechanism based on a nested EIP-712 approach to wrap the signature request in a context where there’s clearer information for the end user.