如何用 OpenZeppelin Contracts 编写 ERC-4337 智能账户合约并通过 Factory 部署账户?
【免费下载链接】openzeppelin-contractsOpenZeppelin Contracts is a library for secure smart contract development.项目地址: https://gitcode.com/GitHub_Trending/op/openzeppelin-contracts
本文面向要基于 OpenZeppelin Contracts 开发 ERC-4337 智能账户的开发者。你要完成的任务是:编写一个Account合约(定义签名验证逻辑),编写一个基于Clones的 Factory(确定性创建账户实例),并把二者串起来——首次发送UserOperation时通过initCode字段在同一笔用户操作里部署账户,再通过handleOps完成提交与验证。资料来源是仓库内的 accounts.adoc、account-abstraction.adoc 以及 contracts/account/ 下的源码。
先明确各组件的角色
ERC-4337 通过替代 mempool 处理用户操作,核心组件在 account-abstraction.adoc 中有定义:
- UserOperation:伪交易对象,v0.8 起为
PackedUserOperation结构:
struct PackedUserOperation { address sender; uint256 nonce; bytes initCode; // concatenation of factory address and factoryData (or empty) bytes callData; bytes32 accountGasLimits; // verificationGasLimit (16 bytes) + callGasLimit (16 bytes) uint256 preVerificationGas; bytes32 gasFees; // maxPriorityFeePerGas (16 bytes) + maxFeePerGas (16 bytes) bytes paymasterAndData; bytes signature; }- EntryPoint:
UserOperation的执行入口,是跨网络部署在同一地址的 singleton 合约。ERC4337Utils.sol 中记录了 v0.7/v0.8/v0.9 的地址,其中ENTRYPOINT_V08 = 0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108,ENTRYPOINT_V09 = 0x433709009B8330FDa32311DF1C2AFA402eD8D009。注意:Account.sol 的entryPoint()默认返回ENTRYPOINT_V09,而文档里的签名示例 domain 用的是 v0.8 地址——两处版本不一致是文档现状,你的 EIP-712 domain 必须与账户entryPoint()实际指向的 EntryPoint 一致,使用前请先对齐版本。 - Account Contract:实现
IAccount的validateUserOp(PackedUserOperation calldata, bytes32, uint256)来验证操作;执行侧可以走fallback或实现可选的IAccountExecute接口,文档也提到可以用 ERC-7821 作为最小批量执行接口。 - Factory Contract:由账户开发者定义,接收任意字节
initData,返回账户逻辑部署到的address。账户地址是确定性的——代码与地址可预测,这正是initCode机制的前提。
编写账户合约:继承 Account 并选择 Signer
accounts.adoc 指出,Account的最小要求是提供 _rawSignatureValidation 的实现。库提供了现成的Signer特化合约可复用:SignerECDSA(EOA 签名)、SignerP256(secp256r1,适用于 FIDO/passkey)、SignerRSA、SignerEIP7702、SignerERC7913及多签器MultiSignerERC7913/MultiSignerERC7913Weighted等。若你使用自定义签名方案,文档建议优先考虑自带 ERC-7913 verifier 而非自行实现AbstractSigner。
以 ECDSA 为例,文档给出的账户模板(initializable 设计,方便 Factory 部署后立即初始化):
import {Account} from "@openzeppelin/community-contracts/account/Account.sol"; import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; import {SignerECDSA} from "@openzeppelin/contracts/utils/cryptography/signers/SignerECDSA.sol"; contract MyAccount is Initializable, Account, SignerECDSA, ... { // ... function initializeECDSA(address signer) public initializer { _setSigner(signer); } }两个必须注意的限制:
_setSigner必须调用。SignerECDSA.sol 的注释明确警告:如果在构造(独立部署时)或初始化(clone 时)期间未调用_setSigner,signer 可能处于可被抢跑或不可用状态。同时 accounts.adoc 也有 WARNING:账户保持未初始化状态会使其不可用,因为没有公钥与之关联。- 核心
Account不包含任意外部调用机制。Account.sol 的注释说明这是每个账户都应具备的能力,留给你自行实现,常见选择包括 ERC-7579、ERC-7821 等。另外账户不原生支持 ERC-721 / ERC-1155 代币(接收方需要接受检查),文档建议继承ERC721Holder/ERC1155Holder。
编写 Factory:用 Clones 做确定性部署
文档推荐用 Clones 库 自建工厂,利用最小化克隆降低成本并利用地址可预测性。示例合约在 MyFactoryAccount.sol(accounts.adoc中引用的就是这份代码)。下面是同一份代码,import 路径按文档中初始化示例的 npm 包风格改写(若在项目内使用,保持原仓库相对路径即可):
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; import {Address} from "@openzeppelin/contracts/utils/Address.sol"; contract MyFactoryAccount { using Clones for address; using Address for address; address private immutable _impl; constructor(address impl_) { require(impl_.code.length > 0); _impl = impl_; } /// @dev Predict the address of the account function predictAddress(bytes calldata callData) public view returns (address) { return _impl.predictDeterministicAddress(keccak256(callData), address(this)); } /// @dev Create clone accounts on demand function cloneAndInitialize(bytes calldata callData) public returns (address) { address predicted = predictAddress(callData); if (predicted.code.length == 0) { _impl.cloneDeterministic(keccak256(callData)); predicted.functionCall(callData); } return predicted; } }工作方式:cloneAndInitialize(callData)先用keccak256(callData)作为 salt 预测地址;若该地址还没有代码,就执行确定性克隆并立即functionCall(callData)——这就是文档说的"Factory 部署后立即在同一笔交易中初始化账户"。callData通常就是initializeECDSA(signer)的编码数据。
文档特别强调:工厂必须确保账户地址与初始 owners 确定性绑定,防止恶意者抢跑部署账户。具体做法是把 owner 地址纳入地址计算的 salt(上述示例中 owner 包含在callData里,因此已体现在 salt 中)。
部署顺序:先实现、后工厂
文档没有给出具体部署命令,但工厂构造器constructor(address impl_)与initializeECDSA的初始化流程决定了顺序:
- 部署
MyAccount实现合约(注意实现合约本身不做初始化,_setSigner留给工厂在 clone 后调用)。 - 部署
MyFactoryAccount,构造参数传入MyAccount实现地址;构造器会require(impl_.code.length > 0),所以实现必须先存在。 - 记录工厂地址——它后面会出现在
UserOperation的initCode里。
构建第一个 UserOperation 并触发部署
用 viem 准备UserOperation(sender、nonce、accountGasLimits、callData是必填项,signature之后签名填入)。代码中0x<...>形式的是你必须替换的值:<YOUR_ACCOUNT_ADDRESS>是工厂预测出的账户地址,<ENTRYPOINT_ADDRESS>是目标链的 EntryPoint 地址,<CALLDATA_TO_EXECUTE_IN_THE_ACCOUNT>是账户要执行的调用数据(如execute的编码数据):
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, };关键一步:账户尚未部署时,把initCode设为abi.encodePacked(factory, factoryData),文档建议先检查预测地址是否已有代码,未部署时才填入:
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>", // 例如 initializeECDSA args: [signer], }), ] ); }文档示例中encodeFunctionData的functionName/args是占位写法,对应你的工厂callData应为initializeECDSA及其 signer 参数。
Gas 参数取值
文档给出的估算指引(这些是文档标注的典型值,不是硬性上限,随签名验证复杂度变化):
verificationGasLimit:覆盖签名验证、paymaster 校验(如有)与账户验证逻辑,文档称典型值约 100,000 gas;callGasLimit:覆盖账户实际执行,建议对每个子调用用eth_estimateGas再加缓冲;preVerificationGas:补偿 EntryPoint 执行开销,文档称 50,000 是合理起点,视 UserOperation 大小调整;maxFeePerGas/maxPriorityFeePerGas:通常由 bundler 服务通过 SDK 或自定义 RPC 提供。
另有一条必须知道的惩罚规则:当callGasLimit或paymasterPostOpGasLimit的未用 gas 达到 40,000(PENALTY_GAS_THRESHOLD)以上时,未用部分会被收取 10%(UNUSED_GAS_PENALTY_PERCENT)罚金。
签名与提交
签名分三步:用 EntryPoint 的 domain 计算 EIP-712 typed data 哈希 → 用账户的签名方案签名 → 把签名按账户合约期望的格式编码进signature字段。文档示例(基于 EntryPoint v0.8 domain):
import { signTypedData } from 'viem/actions'; // EntryPoint v0.8 EIP-712 domain const domain = { name: 'ERC4337', version: '1', chainId: 1, // Your target chain ID verifyingContract: '0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108', // v08 }; // EIP-712 types for PackedUserOperation const types = { PackedUserOperation: [ { name: 'sender', type: 'address' }, { name: 'nonce', type: 'uint256' }, { name: 'initCode', type: 'bytes' }, { name: 'callData', type: 'bytes' }, { name: 'accountGasLimits', type: 'bytes32' }, { name: 'preVerificationGas', type: 'uint256' }, { name: 'gasFees', type: 'bytes32' }, { name: 'paymasterAndData', type: 'bytes' }, ], } as const; // Sign the UserOperation using EIP-712 userOp.signature = await eoa.signTypedData({ domain, types, primaryType: 'PackedUserOperation', message: { sender: userOp.sender, nonce: userOp.nonce, initCode: userOp.initCode, callData: userOp.callData, accountGasLimits: userOp.accountGasLimits, preVerificationGas: userOp.preVerificationGas, gasFees: userOp.gasFees, paymasterAndData: userOp.paymasterAndData, }, });替代路径:调用 EntryPoint 的getUserOpHash拿到原始哈希后直接签名。文档 IMPORTANT 提示这种方式用户体验较差——用户看到的是不可读哈希,且许多链下签名器不支持签原始哈希:
const userOpHash = await entrypoint.read.getUserOpHash([userOp]); userOp.signature = await eoa.sign({ hash: userOpHash });最后调用handleOps提交,并以waitForTransactionReceipt拿到回执作为验证方式(args第二项beneficiary是接收 gas 费的地址,文档建议设为自己):
const userOpReceipt = await walletClient .writeContract({ abi: [/* ENTRYPOINT ABI */], address: '0x<ENTRYPOINT_ADDRESS>', functionName: "handleOps", args: [[userOp], eoa.address], }) .then((txHash) => publicClient.waitForTransactionReceipt({ hash: txHash, }) ); console.log(userOpReceipt);成功判据按文档给出的检查方式:userOpReceipt返回回执说明交易已上链;对首次部署场景,回执后可再次对predictedAddress执行getCode确认账户代码已存在(部署前!deployed分支正是用同一方法判断的)。
如果你是自打包(自己调用handleOps),文档 TIP 指出preVerificationGas和maxFeePerGas可以安全地填 0。若追求更高可靠性,文档建议改用 bundler 服务:它们自动处理 gas 估算、交易排序与多操作打包,成功率更高。
限制与后续安全项
- 未初始化即不可用:工厂部署 clone 后必须走
initializeECDSA之类的 initializer,否则账户没有关联公钥、无法使用(accounts.adoc 的 WARNING)。 - 防跨账户重放:文档 IMPORTANT 推荐配合 ERC7739 做防御性重哈希,避免同一签名者在多个账户间的签名重放,同时避免让用户签不可读的混淆哈希(钓鱼风险)。
- ERC-7562 验证规则:账户在验证阶段会读取自身存储(如公钥),可能以间接方式违反 ERC-7562 的存储访问限制;文档指出违反规则仍可能由私有 bundler 处理,但存在中心化取舍,开发时需注意。
- 版本对齐:再次强调签名示例 domain 用 v0.8 EntryPoint,而 Account.sol 的
entryPoint()默认指向 v0.9;两者必须一致,否则validateUserOp的调用方校验(onlyEntryPoint)会失败。
完成上述流程后,你得到的是一条可核对的链路:实现合约 → 工厂 → 预测地址 →initCode触发部署 →handleOps回执,且每一环都有对应的检查手段(getCode、getUserOpHash、回执)。若后续要给账户加批量执行,可看accounts.adoc中 ERC-7821 小节;要支持模块化管理则看 AccountERC7579。
【免费下载链接】openzeppelin-contractsOpenZeppelin Contracts is a library for secure smart contract development.项目地址: https://gitcode.com/GitHub_Trending/op/openzeppelin-contracts
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考