账户抽象
账户抽象(Account Abstraction)是以太坊的重要升级方向,它将账户的验证逻辑从协议层移至智能合约层,实现了真正的智能合约钱包。本章将深入介绍 ERC-4337 账户抽象的原理和开发实践。
为什么需要账户抽象
传统账户的局限
以太坊目前有两种账户类型:
外部账户(EOA):
- 由私钥控制
- 只能发起交易,不能存储代码
- 验证逻辑固定(ECDSA 签名)
- 无法实现多签、社交恢复等高级功能
合约账户(CA):
- 存储代码和状态
- 只能被动响应交易
- 无法主动发起交易
账户抽象的目标
统一账户类型:让所有账户都是智能合约,具有相同的表达能力。
自定义验证逻辑:支持多种签名方案、多签、社交恢复等。
改善用户体验:
- 无需持有 ETH 即可交易(代付 Gas)
- 批量交易
- 会话密钥
- 无需助记词
ERC-4337 架构
核心概念
┌─────────────────────────────────────────────────────────────┐
│ ERC-4337 架构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 用户 ──> UserOperation ──> Mempool ──> Bundler │
│ │ │
│ ▼ │
│ EntryPoint │
│ │ │
│ ┌─────────────────┼─────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ Smart Account Paymaster Aggregator │
│ │
└─────────────────────────────────────────────────────────────┘
UserOperation:用户操作结构,类似交易但包含更多字段。
EntryPoint:单例合约,处理 UserOperation 的验证和执行。
Smart Account:智能合约钱包,实现自定义验证逻辑。
Paymaster:代付合约,可以替用户支付 Gas。
Bundler:打包器,将多个 UserOperation 打包成一笔交易。
Aggregator:聚合器,允许多个 UserOperation 共享签名验证。
UserOperation 结构
struct UserOperation {
address sender; // 发送者地址
uint256 nonce; // 防重放参数
bytes initCode; // 账户初始化代码
bytes callData; // 执行调用数据
uint256 callGasLimit; // 执行 Gas 限制
uint256 verificationGasLimit; // 验证 Gas 限制
uint256 preVerificationGas; // 预验证 Gas
uint256 maxFeePerGas; // 最大 Gas 费用
uint256 maxPriorityFeePerGas; // 优先费
bytes paymasterAndData; // Paymaster 数据
bytes signature; // 签名
}
智能合约账户实现
基础账户合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
interface IEntryPoint {
function validateUserOp(
PackedUserOperation calldata userOp,
bytes32 userOpHash,
uint256 missingAccountFunds
) external returns (uint256 validationData);
}
struct PackedUserOperation {
address sender;
uint256 nonce;
bytes initCode;
bytes callData;
bytes32 accountGasLimits;
uint256 preVerificationGas;
bytes32 gasFees;
bytes paymasterAndData;
bytes signature;
}
contract SimpleAccount is EIP712 {
using ECDSA for bytes32;
address public owner;
IEntryPoint public immutable entryPoint;
uint256 private constant SIG_VALIDATION_FAILED = 1;
uint256 private constant SIG_VALIDATION_SUCCESS = 0;
bytes32 private constant USER_OP_TYPEHASH = keccak256(
"UserOperation(address sender,uint256 nonce,bytes initCode,bytes callData,uint256 callGasLimit,uint256 verificationGasLimit,uint256 preVerificationGas,uint256 maxFeePerGas,uint256 maxPriorityFeePerGas,bytes paymasterAndData)"
);
event Executed(address indexed target, uint256 value, bytes data);
event OwnerChanged(address indexed oldOwner, address indexed newOwner);
modifier onlyEntryPoint() {
require(msg.sender == address(entryPoint), "Not entry point");
_;
}
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
constructor(address _owner, IEntryPoint _entryPoint) EIP712("SimpleAccount", "1") {
owner = _owner;
entryPoint = _entryPoint;
}
function validateUserOp(
PackedUserOperation calldata userOp,
bytes32 userOpHash,
uint256 missingAccountFunds
) external onlyEntryPoint returns (uint256 validationData) {
_validateNonce(userOp.nonce);
validationData = _validateSignature(userOp, userOpHash);
if (missingAccountFunds > 0) {
(bool success,) = payable(msg.sender).call{value: missingAccountFunds}("");
(success);
}
}
function _validateNonce(uint256 nonce) internal view {
require(nonce == entryPoint.getNonce(address(this), 0), "Invalid nonce");
}
function _validateSignature(
PackedUserOperation calldata userOp,
bytes32 userOpHash
) internal view returns (uint256) {
bytes32 hash = _hashUserOp(userOp);
bytes32 ethSignedHash = hash.toEthSignedMessageHash();
address signer = ethSignedHash.recover(userOp.signature);
if (signer != owner) {
return SIG_VALIDATION_FAILED;
}
return SIG_VALIDATION_SUCCESS;
}
function _hashUserOp(
PackedUserOperation calldata userOp
) internal view returns (bytes32) {
return _hashTypedDataV4(keccak256(abi.encode(
USER_OP_TYPEHASH,
userOp.sender,
userOp.nonce,
keccak256(userOp.initCode),
keccak256(userOp.callData),
uint256(uint128(bytes16(userOp.accountGasLimits))),
uint256(uint128(bytes16(userOp.accountGasLimits << 128))),
userOp.preVerificationGas,
uint256(uint128(bytes16(userOp.gasFees))),
uint256(uint128(bytes16(userOp.gasFees << 128))),
keccak256(userOp.paymasterAndData)
)));
}
function execute(
address dest,
uint256 value,
bytes calldata func
) external onlyEntryPoint {
(bool success, bytes memory result) = dest.call{value: value}(func);
if (!success) {
assembly {
revert(add(result, 32), mload(result))
}
}
emit Executed(dest, value, func);
}
function executeBatch(
address[] calldata dest,
uint256[] calldata value,
bytes[] calldata func
) external onlyEntryPoint {
require(dest.length == value.length && dest.length == func.length, "Length mismatch");
for (uint256 i = 0; i < dest.length; i++) {
(bool success, bytes memory result) = dest[i].call{value: value[i]}(func[i]);
if (!success) {
assembly {
revert(add(result, 32), mload(result))
}
}
}
}
function changeOwner(address _newOwner) external onlyOwner {
require(_newOwner != address(0), "Invalid owner");
address oldOwner = owner;
owner = _newOwner;
emit OwnerChanged(oldOwner, _newOwner);
}
receive() external payable {}
}
多签账户
contract MultiSigAccount {
using ECDSA for bytes32;
IEntryPoint public immutable entryPoint;
address[] public owners;
uint256 public threshold;
mapping(bytes32 => bool) public executedHashes;
event Executed(bytes32 indexed hash, address[] signers);
event OwnersChanged(address[] owners, uint256 threshold);
modifier onlyEntryPoint() {
require(msg.sender == address(entryPoint), "Not entry point");
_;
}
constructor(
address[] memory _owners,
uint256 _threshold,
IEntryPoint _entryPoint
) {
require(_owners.length >= _threshold, "Invalid threshold");
require(_threshold > 0, "Threshold must be positive");
owners = _owners;
threshold = _threshold;
entryPoint = _entryPoint;
}
function validateUserOp(
PackedUserOperation calldata userOp,
bytes32 userOpHash,
uint256 missingAccountFunds
) external onlyEntryPoint returns (uint256) {
if (missingAccountFunds > 0) {
(bool success,) = payable(msg.sender).call{value: missingAccountFunds}("");
(success);
}
return _validateMultiSig(userOp, userOpHash);
}
function _validateMultiSig(
PackedUserOperation calldata userOp,
bytes32 userOpHash
) internal returns (uint256) {
bytes32 ethSignedHash = userOpHash.toEthSignedMessageHash();
address[] memory signers = _recoverSigners(ethSignedHash, userOp.signature);
if (!_validateSigners(signers)) {
return 1;
}
return 0;
}
function _recoverSigners(
bytes32 hash,
bytes memory signatures
) internal pure returns (address[] memory) {
uint256 numSignatures = signatures.length / 65;
address[] memory signers = new address[](numSignatures);
for (uint256 i = 0; i < numSignatures; i++) {
bytes memory sig = _slice(signatures, i * 65, 65);
signers[i] = hash.recover(sig);
}
return signers;
}
function _validateSigners(
address[] memory signers
) internal view returns (bool) {
uint256 validCount = 0;
for (uint256 i = 0; i < signers.length; i++) {
if (_isOwner(signers[i])) {
validCount++;
}
}
return validCount >= threshold;
}
function _isOwner(address _addr) internal view returns (bool) {
for (uint256 i = 0; i < owners.length; i++) {
if (owners[i] == _addr) {
return true;
}
}
return false;
}
function _slice(
bytes memory data,
uint256 start,
uint256 length
) internal pure returns (bytes memory) {
bytes memory result = new bytes(length);
for (uint256 i = 0; i < length; i++) {
result[i] = data[start + i];
}
return result;
}
function execute(
address dest,
uint256 value,
bytes calldata func
) external onlyEntryPoint {
(bool success,) = dest.call{value: value}(func);
require(success, "Execution failed");
}
function changeOwners(
address[] calldata _owners,
uint256 _threshold
) external onlyEntryPoint {
require(_owners.length >= _threshold, "Invalid threshold");
owners = _owners;
threshold = _threshold;
emit OwnersChanged(_owners, _threshold);
}
receive() external payable {}
}
Paymaster 实现
Paymaster 允许第三方为用户支付 Gas 费用。
代币支付 Paymaster
contract TokenPaymaster {
using ECDSA for bytes32;
IEntryPoint public immutable entryPoint;
IERC20 public immutable token;
uint256 public constant PRICE_MARKUP = 110;
mapping(address => uint256) public balances;
event Deposited(address indexed account, uint256 amount);
event Withdrawn(address indexed account, uint256 amount);
event UserOpSponsored(address indexed user, uint256 actualGasCost);
modifier onlyEntryPoint() {
require(msg.sender == address(entryPoint), "Not entry point");
_;
}
constructor(IEntryPoint _entryPoint, IERC20 _token) {
entryPoint = _entryPoint;
token = _token;
}
function validatePaymasterUserOp(
PackedUserOperation calldata userOp,
bytes32,
uint256 maxCost
) external onlyEntryPoint returns (bytes memory context, uint256 validationData) {
(uint256 tokenAmount, uint256 validUntil) = _decodePaymasterData(userOp.paymasterAndData);
require(block.timestamp <= validUntil, "Expired");
uint256 requiredBalance = (maxCost * PRICE_MARKUP) / 100;
require(balances[userOp.sender] >= requiredBalance, "Insufficient balance");
context = abi.encode(userOp.sender, tokenAmount, maxCost);
validationData = _packValidationData(false, validUntil, 0);
}
function postOp(
uint8 mode,
bytes calldata context,
uint256 actualGasCost,
uint256
) external onlyEntryPoint {
(address user, uint256 tokenAmount, uint256 maxCost) =
abi.decode(context, (address, uint256, uint256));
uint256 actualTokenCost = (actualGasCost * PRICE_MARKUP) / 100;
if (mode == 0) {
balances[user] -= actualTokenCost;
token.transferFrom(user, address(this), tokenAmount);
} else {
balances[user] -= maxCost;
}
emit UserOpSponsored(user, actualGasCost);
}
function deposit(address account, uint256 amount) external {
token.transferFrom(msg.sender, address(this), amount);
balances[account] += amount;
emit Deposited(account, amount);
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
token.transfer(msg.sender, amount);
emit Withdrawn(msg.sender, amount);
}
function _decodePaymasterData(
bytes memory data
) internal pure returns (uint256 tokenAmount, uint256 validUntil) {
if (data.length > 20) {
(tokenAmount, validUntil) = abi.decode(
data[20:],
(uint256, uint256)
);
}
}
function _packValidationData(
bool sigFailed,
uint256 validUntil,
uint256 validAfter
) internal pure returns (uint256) {
return (sigFailed ? 1 : 0) | (validUntil << 160) | (validAfter << 192);
}
function addStake(uint32 unstakeDelaySec) external payable {
entryPoint.addStake{value: msg.value}(unstakeDelaySec);
}
function unlockStake() external {
entryPoint.unlockStake();
}
function withdrawStake(address payable withdrawAddress) external {
entryPoint.withdrawStake(withdrawAddress);
}
receive() external payable {}
}
账户工厂
账户工厂用于确定性部署智能合约账户。
contract AccountFactory {
event AccountCreated(
address indexed account,
address indexed owner,
uint256 salt
);
function createAccount(
address owner,
uint256 salt
) external returns (address account) {
bytes memory bytecode = _getAccountBytecode(owner);
assembly {
account := create2(
0,
add(bytecode, 0x20),
mload(bytecode),
salt
)
}
require(account != address(0), "Create2 failed");
emit AccountCreated(account, owner, salt);
}
function getAddress(
address owner,
uint256 salt
) external view returns (address) {
bytes memory bytecode = _getAccountBytecode(owner);
bytes32 hash = keccak256(
abi.encodePacked(
bytes1(0xff),
address(this),
salt,
keccak256(bytecode)
)
);
return address(uint160(uint256(hash)));
}
function _getAccountBytecode(
address owner
) internal pure returns (bytes memory) {
return abi.encodePacked(
type(SimpleAccount).creationCode,
abi.encode(owner, IEntryPoint(0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789))
);
}
}
前端集成
使用 UserOp SDK
import { ethers } from 'ethers';
import { UserOperation } from '@account-abstraction/sdk';
async function sendUserOp(
provider: ethers.JsonRpcProvider,
signer: ethers.Signer,
entryPoint: string,
account: string,
target: string,
data: string
) {
const nonce = await getNonce(provider, entryPoint, account);
const userOp: UserOperation = {
sender: account,
nonce,
initCode: '0x',
callData: encodeExecute(target, 0, data),
callGasLimit: BigInt(100000),
verificationGasLimit: BigInt(100000),
preVerificationGas: BigInt(50000),
maxFeePerGas: BigInt(1000000000),
maxPriorityFeePerGas: BigInt(1000000000),
paymasterAndData: '0x',
signature: '0x',
};
const userOpHash = getUserOpHash(userOp, entryPoint, await provider.getNetwork().then(n => n.chainId));
userOp.signature = await signer.signMessage(ethers.getBytes(userOpHash));
const bundler = new ethers.JsonRpcProvider('https://bundler.example.com/rpc');
const txHash = await bundler.send('eth_sendUserOperation', [
userOp,
entryPoint,
]);
return txHash;
}
使用 permissionless.js
import { createSmartAccountClient } from 'permissionless';
import { signerToSimpleSmartAccount } from 'permissionless/accounts';
import { createPimlicoBundlerClient, createPimlicoPaymasterClient } from 'permissionless/clients/pimlico';
import { createPublicClient, http, parseEther } from 'viem';
import { sepolia } from 'viem/chains';
import { privateKeyToAccount } from 'viem/accounts';
const account = await signerToSimpleSmartAccount(publicClient, {
signer: privateKeyToAccount('0x...'),
factoryAddress: '0x...',
entryPoint: '0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789',
});
const paymasterClient = createPimlicoPaymasterClient({
transport: http('https://api.pimlico.io/v2/sepolia/rpc?apikey=YOUR_API_KEY'),
});
const smartAccountClient = createSmartAccountClient({
account,
chain: sepolia,
bundlerTransport: http('https://api.pimlico.io/v2/sepolia/rpc?apikey=YOUR_API_KEY'),
paymaster: paymasterClient,
userOperation: {
estimateFeesPerGas: async () => {
return (await bundlerClient.getUserOperationGasPrice()).fast;
},
},
});
const txHash = await smartAccountClient.sendTransaction({
to: '0x...',
value: parseEther('0.1'),
data: '0x',
});
账户抽象的优势
用户体验改进
社交登录:用户可以使用 Google、Apple 等账号登录。
无 Gas 交易:应用可以为用户支付 Gas 费用。
批量交易:一次签名执行多个操作。
订阅支付:自动定期支付。
安全性增强
社交恢复:通过信任的联系人恢复账户。
多签验证:大额交易需要多人批准。
交易限制:设置每日交易限额。
白名单:只允许与特定地址交互。
小结
账户抽象是以太坊的重要升级,它让智能合约钱包成为主流成为可能。ERC-4337 提供了无需共识层修改的账户抽象方案,开发者可以利用它构建更安全、更易用的钱包应用。随着生态成熟,账户抽象将彻底改变用户与区块链的交互方式。