智能账户
OpenZeppelin 提供了一个简单的 Account
实现,仅包含符合 ERC-4337 的处理用户操作的基本逻辑。希望构建自己的账户的开发者可以利用它来引导自定义实现。
用户操作使用 AbstractSigner
进行验证,这需要实现内部 _rawSignatureValidation
函数,我们提供了一组实现来覆盖广泛的自定义范围。这是最低级别的签名验证层,用于包装其他验证方法,例如 Account 的 validateUserOp
。
设置账户
要设置一个账户,你可以使用我们的 Wizard 并选择一个预定义的验证方案来开始配置它,或者自带你的逻辑并从头继承 Account
开始。
账户本身不支持 ERC-721 和 ERC-1155 代币,因为这些代币要求接收地址实现接受检查。建议继承 ERC721Holder,ERC1155Holder 以在你的账户中包含这些检查。 |
选择签名器
由于 Account
的最低要求是提供 _rawSignatureValidation
的实现,因此该库包含 AbstractSigner
合约的特殊化,该合约使用自定义数字签名验证算法。你可以选择的一些示例包括:
-
SignerECDSA
: 验证由常规 EVM 外部拥有账户 (EOA) 生成的签名。 -
SignerP256
: 使用 secp256r1 曲线验证签名,这对于万维网联盟 (W3C) 标准(如 FIDO 密钥、通行密钥或安全 enclave)很常见。 -
SignerRSA
: 验证传统 PKI 系统和 X.509 证书的签名。 -
SignerERC7702
: 使用 EIP-7702 授权检查委托给此签名者的 EOA 签名 -
SignerERC7913
: 验证遵循 ERC-7913 的通用签名。 -
SignerZKEmail
: 使用电子邮件授权签名的零知识证明,为智能合约启用基于电子邮件的身份验证。 -
MultiSignerERC7913
: 允许使用具有基于阈值的签名验证系统的多个 ERC-7913 签名者。 -
MultiSignerERC7913Weighted
: 覆盖MultiSignerERC7913
的阈值机制,为每个签名者提供不同的权重。
鉴于 SignerERC7913 为签名验证提供了一个通用的标准,你无需为不同的签名方案实现你自己的 AbstractSigner ,请考虑自带你自己的 ERC-7913 验证器。
|
账户工厂
第一次发送用户操作时,你的账户将使用 UserOperation 中的 initCode
字段以确定性的方式(即,可以预测其代码和地址)创建。此字段包含智能合约(工厂)的地址以及调用它并创建你的智能账户所需的数据。
建议你可以使用 Clones library 创建你自己的账户工厂,从而利用降低的部署成本和账户地址可预测性。
Unresolved include directive in modules/ROOT/pages/accounts.adoc - include::api:example$account/MyFactoryAccount.sol[]
账户工厂应谨慎实施,以确保账户地址以确定方式与初始所有者相关联。这可以防止抢跑攻击,即恶意行为者可以在预期所有者之前部署具有他们自己的所有者的账户。工厂应在用于地址计算的盐中包含所有者的地址。
处理初始化
大多数智能账户都由工厂部署,最佳实践是创建可初始化合约的 最小克隆。这些签名者实现默认提供可初始化的设计,以便工厂可以在单个交易中与账户交互以在部署后立即设置它。
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);
}
}
请注意,某些账户实现可能会直接部署,因此不需要工厂。
如果未初始化某个账户,可能会使其无法使用,因为没有公钥与之关联。 |
签名验证
通常,账户实现 ERC-1271 以启用智能合约签名验证,因为该标准已被普遍采用。要符合标准,意味着智能合约公开了一个 isValidSignature(bytes32 hash, bytes memory signature)
方法,该方法返回 0x1626ba7e
来识别签名是否有效。
此标准的优点是它允许接收给定 hash
的任何格式的 signature
。这种通用的机制非常符合_自带你自己的验证机制_的账户抽象原则。
这是你如何使用 AbstractSigner
启用 ERC-1271 的方法:
function isValidSignature(bytes32 hash, bytes calldata signature) public view override returns (bytes4) {
return _rawSignatureValidation(hash, signature) ? IERC1271.isValidSignature.selector : bytes4(0xffffffff);
}
我们建议使用 ERC7739 以避免跨账户的可重放性。这种防御性的重新哈希机制可以防止此账户的签名在同一签名者控制的另一个账户中被重放。请参阅 ERC-7739 签名。 |
批量执行
批量执行允许账户在单个交易中执行多个调用,这对于捆绑需要原子性的操作特别有用。这在账户抽象的上下文中尤其有价值,你希望最大程度地减少用户操作的数量和相关的 Gas 成本。ERC-7821
标准为批量执行提供了一个最小的接口。
该库实现支持单个批处理模式 (0x01000000000000000000
),并允许账户原子地执行多个调用。该标准包括通过 _erc7821AuthorizedExecutor
函数进行访问控制,该函数默认仅允许合约本身执行批处理。
以下是如何使用 EIP-7702 使用批量执行的示例:
import {Account} from "@openzeppelin/community-contracts/account/Account.sol";
import {ERC7821} from "@openzeppelin/community-contracts/account/extensions/draft-ERC7821.sol";
import {SignerERC7702} from "@openzeppelin/community-contracts/utils/cryptography/SignerERC7702.sol";
contract MyAccount is Account, SignerERC7702, 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);
}
}
批量执行数据遵循特定的格式,该格式包括要执行的调用。此格式遵循与 ERC-7579 执行 相同的格式,但仅支持 0x01
调用类型(即,批量 call
)和默认执行类型(即,如果至少一个子调用失败,则恢复)。
要编码 ERC-7821 批处理,你可以使用 viem 的实用程序:
// CALL_TYPE_BATCH, EXEC_TYPE_DEFAULT, ..., selector, payload
// CALL_TYPE_BATCH, EXEC_TYPE_DEFAULT, ..., 选择器, 有效负载
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]
});
捆绑 UserOperation
UserOperations 是一个强大的抽象层,与传统的 Ethereum 交易相比,它可以实现更复杂的交易功能。要开始使用,你需要一个账户,你可以通过 部署一个工厂 来实现你的实现。
准备 UserOp
UserOperation 是一个结构体,其中包含 EntryPoint 执行你的交易所需的所有必要信息。你需要 sender
、nonce
、accountGasLimits
和 callData
字段来构造一个可以稍后签名的 PackedUserOperation
(以填充 signature
字段)。
指定带有 paymaster 合约地址的 paymasterAndData ,该合约地址连接到 data ,该数据将传递给 paymaster 的 validatePaymasterUserOp 函数,以支持赞助作为你的用户操作的一部分。
|
以下是如何使用 viem 准备一个的方法:
import { getContract, createWalletClient, http, Hex } from 'viem';
const walletClient = createWalletClient({
account, // See Viem's `privateKeyToAccount`
// 请参阅 Viem 的 `privateKeyToAccount`
chain, // import { ... } from 'viem/chains';
// import { ... } from 'viem/chains';
transport: http(),
})
const entrypoint = getContract({
abi: [/* ENTRYPOINT 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>',
// <要在帐户中执行的 CALLDATA>
accountGasLimits: encodePacked(
["uint128", "uint128"],
[
100_000n, // verificationGasLimit
// 验证GasLimit
300_000n, // callGasLimit
// 调用GasLimit
]
),
preVerificationGas: 50_000n,
gasFees: encodePacked(
["uint128", "uint128"],
[
0n, // maxPriorityFeePerGas
// maxPriorityFeePerGas
0n, // maxFeePerGas
// maxFeePerGas
]
),
paymasterAndData: "0x" as Hex,
signature: "0x" as Hex,
};
如果你的账户尚未部署,请确保提供 initCode
字段作为 abi.encodePacked(factory, factoryData)
,以便在同一 UserOp 中部署该账户:
const deployed = await publicClient.getCode({ address: predictedAddress });
if (!deployed) {
userOp.initCode = encodePacked(
["address", "bytes"],
[
'0x<ACCOUNT_FACTORY_ADDRESS>',
encodeFunctionData({
abi: [/* ACCOUNT ABI */],
/* ACCOUNT ABI */
functionName: "<FUNCTION NAME>",
// <函数名称>
args: [...],
}),
]
);
}
估算 Gas
为了计算 UserOperation
的 Gas 参数,开发人员应仔细考虑以下字段:
-
verificationGasLimit
: 这涵盖了签名验证、赞助商验证(如果使用)和账户验证逻辑的 Gas 成本。虽然一个典型的值约为 100,000 个 Gas 单位,但这可能会根据你的账户和赞助商合约中签名验证方案的复杂性而显着变化。 -
callGasLimit
: 此参数用于计算你的账户逻辑的实际执行。建议对每个子调用使用eth_estimateGas
,并为计算开销添加额外的缓冲区。 -
preVerificationGas
: 这补偿了 EntryPoint 的执行开销。虽然 50,000 Gas 是一个合理的起点,但你可能需要根据你的 UserOperation 的大小和特定的捆绑器要求来增加此值。
maxFeePerGas 和 maxPriorityFeePerGas 值通常由你的捆绑器服务提供,无论是通过他们的 SDK 还是自定义 RPC 方法。
|
如果剩余未使用的 Gas 量大于或等于 40,000 (PENALTY_GAS_THRESHOLD ),则对剩余未使用的 callGasLimit 和 paymasterPostOpGasLimit Gas 金额处以 10% (UNUSED_GAS_PENALTY_PERCENT ) 的罚款。
|
签署 UserOp
要签署 UserOperation,你首先需要使用 EntryPoint 的域将其哈希计算为 EIP-712 类型的数据结构,然后使用你的账户的签名方案签署此哈希,最后以你的账户合约期望用于验证的格式编码生成的签名。
import { signTypedData } from 'viem/actions';
// EntryPoint v0.8 EIP-712 domain
// EntryPoint v0.8 EIP-712 域
const domain = {
name: 'ERC4337',
version: '1',
chainId: 1, // Your target chain ID
// 你的目标链 ID
verifyingContract: '0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108', // v08
};
// EIP-712 types for PackedUserOperation
// PackedUserOperation 的 EIP-712 类型
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
// 使用 EIP-712 签署 UserOperation
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
函数来获取原始用户操作哈希:
const userOpHash = await entrypoint.read.getUserOpHash([userOp]);
userOp.signature = await eoa.sign({ hash: userOpHash });
直接使用 getUserOpHash 可能会提供较差的用户体验,因为用户会看到一个不透明的哈希,而不是结构化的交易数据。在许多情况下,离线签名者将无法选择签署原始哈希。
|
发送 UserOp
最后,要发送用户操作,你可以调用 Entrypoint 合约上的 handleOps
并将自己设置为 beneficiary
。
// Send the UserOperation
// 发送 UserOperation
const userOpReceipt = await walletClient
.writeContract({
abi: [/* ENTRYPOINT ABI */],
/* ENTRYPOINT ABI */
address: '0x<ENTRYPOINT_ADDRESS>',
functionName: "handleOps",
args: [[userOp], eoa.address],
})
.then((txHash) =>
publicClient.waitForTransactionReceipt({
hash: txHash,
})
);
// Print receipt
// 打印收据
console.log(userOpReceipt);
由于你正在自己捆绑你的用户操作,因此你可以安全地在 0 中指定 preVerificationGas 和 maxFeePerGas 。
|
进一步说明
ERC-7739 签名
防止用户操作 跨同一私钥控制的智能合约账户的可重放性(即,同一签名者的多个账户)的常见安全做法是将签名链接到账户的 address
和 chainId
。这可以通过要求用户签署包含这些值的哈希来完成。
这种方法的问题是钱包提供商可能会提示用户签署一个 模糊的消息,这是一种网络钓鱼媒介,可能导致用户丢失其资产。
为防止这种情况,开发人员可以使用 ERC7739Signer
,这是一个实用程序,它实现了 IERC1271
,用于具有防御性重新哈希机制的智能合约签名,该机制基于 嵌套的 EIP-712 方法来在为最终用户提供更清晰信息的上下文中包装签名请求。
EIP-7702 委托
EIP-7702 允许 EOA 委托给智能合约,同时保留其原始签名密钥。这创建了一个混合账户,该账户像 EOA 一样用于签名,但具有智能合约功能。协议不需要进行重大更改即可支持 EIP-7702,因为它们已经处理 EOA 和智能合约(请参阅 SignatureChecker)。
签名验证保持兼容性: 委托的 EOA 被视为使用 ERC-1271 的合约,通过重用账户的验证机制,可以轻松地重新委托给支持 ERC-1271 的合约,而几乎没有开销。
在我们的 EOA 委托 部分中了解更多关于委托给 ERC-7702 账户的信息。 |
ERC-7579 模块
智能账户已经发展到采用模块化作为设计原则,流行的实现(如 Safe, Pimlico, Rhinestone, Etherspot 以及许多其他)同意 ERC-7579 作为模块互操作性的标准。这种标准化使账户能够通过外部合约扩展其功能,同时保持跨不同实现的兼容性。
OpenZeppelin Contracts 提供了用于创建符合 ERC-7579 的模块的构建块,以及支持安装和管理这些模块的 AccountERC7579
实现。
在我们的 账户模块部分中了解更多信息。 |