本文介绍了智能合约账户的概念,以及如何使用 OpenZeppelin 提供的工具和合约来构建和配置智能账户。内容涵盖了账户的设置、签名验证、批量执行、用户操作的打包和发送等多个方面,并提及了 ERC-7739 签名、EIP-7702 授权和 ERC-7579 模块化等高级特性。
OpenZeppelin 提供了一个简单的 Account
实现,仅包含符合 ERC-4337 的处理用户操作的基本逻辑。想要构建自己的账户的开发者可以利用它来引导自定义实现。
用户操作使用 AbstractSigner
进行验证,这需要实现内部的 _rawSignatureValidation
函数,我们提供了一组实现来覆盖广泛的自定义范围。这是最低级别的签名验证层,用于包装其他验证方法,如 Account 的 validateUserOp
。
要设置账户,你可以使用我们的 Wizard 并选择预定义的验证方案来开始配置,或者自带你的逻辑并从头开始继承 Account
。
OpenZeppelin Contracts Wizard
账户本身不支持 ERC-721 和 ERC-1155 代币,因为这些代币要求接收地址实现接受检查。建议继承 ERC721Holder, ERC1155Holder 以在你的账户中包含这些检查。 |
由于 Account
的最低要求是提供 _rawSignatureValidation
的实现,因此该库包含 AbstractSigner
合约的专门版本,这些版本使用自定义数字签名验证算法。你可以选择的一些示例包括:
SignerECDSA
: 验证由常规 EVM 外部拥有账户 (EOA) 生成的签名。
SignerP256
: 使用 secp256r1 曲线验证签名,这对于万维网联盟 (W3C) 标准(如 FIDO 密钥、Passkey或安全 enclave)很常见。
SignerWebAuthn
: 使用 WebAuthn 身份验证断言来验证签名,从而利用 P256 公钥进行 WebAuthn 和原始 P256 签名验证。
SignerRSA
: 验证传统 PKI 系统和 X.509 证书的签名。
SignerERC7702
: 使用 EIP-7702 授权 检查委托给此签名者的 EOA 签名
SignerERC7913
: 遵循 ERC-7913 验证通用签名。
SignerZKEmail
: 使用电子邮件权限签名的零知识证明,为智能合约启用基于电子邮件的身份验证。
MultiSignerERC7913
: 允许使用具有基于阈值的签名验证系统的多个 ERC-7913 签名者。
MultiSignerERC7913Weighted
: 覆盖 MultiSignerERC7913
的阈值机制,为每个签名者提供不同的权重。
鉴于 SignerERC7913 为签名验证提供了一个通用的标准,你不需要为不同的签名方案实现你自己的 AbstractSigner ,可以考虑自带你自己的 ERC-7913 验证器。 |
首次发送用户操作时,你的账户将使用 UserOperation 中的 initCode
字段以确定性方式创建(即可以预测其代码和地址)。此字段包含智能合约(工厂)的地址以及调用它并创建你的智能账户所需的数据。
建议使用 来自 OpenZeppelin Contracts 的 Clones 库 创建你自己的账户工厂,利用降低的部署成本和账户地址的可预测性。
// 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 一个按需创建账户的工厂合约。
*/
contract MyFactoryAccount {
using Clones for address;
using Address for address;
address private immutable _impl;
constructor(address impl_) {
_impl = impl_;
}
/// @dev 预测账户的地址
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 按需创建克隆账户
function cloneAndInitialize(bytes32 salt, bytes calldata callData) public returns (address) {
return _cloneAndInitialize(salt, callData);
}
/// @dev 创建克隆账户, 并返回地址。 使用 `callData` 初始化克隆。
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) {
// 将 salt 的范围限定为 callData,以避免使用不同的 callData抢先 salt
return keccak256(abi.encodePacked(salt, callData));
}
}
应该仔细实施账户工厂,以确保账户地址以确定性方式与初始所有者相关联。 这可以防止抢跑攻击,在这种攻击中,恶意行为者可能会在预期所有者之前使用他们自己的所有者部署该帐户。 该工厂应在用于地址计算的盐中包含所有者的地址。
大多数智能账户都由工厂部署,最佳实践是创建可初始化合约的最小克隆。 这些签名者实现默认提供可初始化的设计,以便工厂可以在单个事务中与账户交互以在部署后立即进行设置。
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
函数进行访问控制,默认情况下,该函数仅允许合约本身执行批处理。
以下是如何使用批量执行的示例:
import {Account} from "@openzeppelin/community-contracts/account/Account.sol";
import {ERC7821} from "@openzeppelin/community-contracts/account/extensions/ERC7821.sol";
contract MyAccount is Account, ERC7821 {
// 覆盖以允许入口点执行批处理
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
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 是一个强大的抽象层,与传统的以太坊交易相比,它能够提供更复杂的交易功能。 首先,你需要一个账户,你可以通过为你的实现部署工厂 来获得该账户。
UserOperation 是一个结构,其中包含 EntryPoint 执行你的交易所需的所有必要信息。 你需要 sender
、nonce
、accountGasLimits
和 callData
字段来构造一个可以稍后签名的 PackedUserOperation
(以填充 signature
字段)。
使用 paymaster 合约的地址连接到将传递给 paymaster 的 validatePaymasterUserOp 函数的 data 来指定 paymasterAndData ,以支持赞助作为你的用户操作的一部分。 |
以下是如何使用 viem 准备一个:
import { getContract, createWalletClient, http, Hex } from 'viem';
const walletClient = createWalletClient({
account, // 请参阅 Viem 的 `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)
,以便在同一 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: [...],\
}),\
]
);
}
要计算 UserOperation
的 gas 参数,开发人员应仔细考虑以下字段:
verificationGasLimit
:这涵盖了签名验证、paymaster 验证(如果使用)和账户验证逻辑的 gas 成本。 虽然典型值约为 100,000 gas 单位,但根据你的账户和 paymaster 合约中签名验证方案的复杂性,此值可能会有很大差异。
callGasLimit
:此参数说明你的账户逻辑的实际执行情况。 建议为每个子调用使用 eth_estimateGas
,并为计算开销添加额外的缓冲区。
preVerificationGas
:这补偿了 EntryPoint 的执行开销。 虽然 50,000 gas 是一个合理的起点,但你可能需要根据你的 UserOperation 的大小和特定的捆绑器要求来增加此值。
maxFeePerGas 和 maxPriorityFeePerGas 值通常由你的捆绑器服务提供,无论是通过他们的 SDK 还是自定义 RPC 方法。 |
如果剩余未使用的 gas 量大于或等于 40,000 (PENALTY_GAS_THRESHOLD ),则对 callGasLimit 和 paymasterPostOpGasLimit gas 的未使用 gas 量处以 10% 的罚款 (UNUSED_GAS_PENALTY_PERCENT )。 |
要签署 UserOperation,你需要首先使用 EntryPoint 的 getUserOpHash
函数计算其哈希值,然后使用你的账户签名方案签署此哈希值,最后以你的账户合约期望用于验证的格式对生成的签名进行编码。
const userOpHash = await entrypoint.read.getUserOpHash([userOp]);
userOp.signature = await eoa.sign({ hash: userOpHash });
前面的示例假设该账户由没有特定签名格式的单个 ECDSA 签名者拥有。 |
最后,要发送用户操作,你可以在 Entrypoint 合约上调用 handleOps
并将自己设置为 beneficiary
。
// 发送 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,
})
);
// 打印收据
console.log(userOpReceipt);
由于你在自己捆绑用户操作,因此你可以安全地在 0 中指定 preVerificationGas 和 maxFeePerGas 。 |
为了获得更好的可靠性,请考虑使用捆绑器服务。 捆绑器提供多个关键优势:它们自动处理 gas 估算、管理交易排序、支持将多个操作捆绑在一起,并且通常提供比自我捆绑更高的交易成功率。
防止用户操作在由同一私钥控制的智能合约帐户之间重放(即同一签名者的多个帐户)的一种常见安全做法是将签名链接到帐户的 address
和 chainId
。 这可以通过要求用户签署包含这些值的哈希来完成。
这种方法的问题是,钱包提供商可能会提示用户签署一条混淆的消息,这是一种网络钓鱼手段,可能导致用户丢失其资产。
为了防止这种情况,开发人员可以使用 ERC7739Signer
,它是一个实用程序,实现了 IERC1271
,用于智能合约签名,具有基于 嵌套 EIP-712 方法 的防御性重新哈希机制,以将签名请求包装在一个上下文中,其中为最终用户提供更清晰的信息。
EIP-7702 允许 EOA 委托给智能合约,同时保留其原始签名密钥。 这创建了一个混合帐户,该帐户像 EOA 一样用于签名,但具有智能合约功能。 协议不需要进行重大更改即可支持 EIP-7702,因为它们已经处理 EOA 和智能合约(请参阅 SignatureChecker)。
签名验证保持兼容:委托的 EOA 被视为使用 ERC-1271 的合约,通过重用帐户的验证机制,可以轻松地以少量开销重新委托给支持 ERC-1271 的合约。
在我们的 EOA 委托 部分了解更多关于委托给 ERC-7702 帐户的信息。 |
智能账户已经发展为将模块化作为设计原则,流行的实现方式(如 Safe, Pimlico, Rhinestone, Etherspot 和其他许多实现方式)一致认为 ERC-7579 是模块互操作性的标准。 这种标准化使帐户能够通过外部合约扩展其功能,同时保持跨不同实现的兼容性。
OpenZeppelin Contracts 提供了用于创建符合 ERC-7579 的模块的构建块,以及支持安装和管理这些模块的 AccountERC7579
实现。
在我们的 账户模块 部分了解更多信息。 |
- 原文链接: docs.openzeppelin.com/co...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!