Web3 中账户抽象(Account Abstraction)实战:基于 ERC-4337 设计与落地智能合约钱包
账户抽象这件事,很多人第一次接触时会觉得“概念很高级,代码很绕”。我一开始也是这样:看懂了 ERC-4337 的流程图,不代表真能把一个智能合约钱包跑起来;能部署一个 Demo,也不代表你知道为什么 validateUserOp 会失败、为什么 bundler 死活不打包、为什么 paymaster 看起来能省 gas,最后却把系统复杂度拉满。
这篇文章我不打算只讲概念,而是从架构视角带你把 ERC-4337 的核心链路串起来:为什么它出现、解决了什么问题、系统中有哪些角色、如何写一个可运行的钱包合约、如何接入 EntryPoint、Bundler、Paymaster,以及上线时最容易踩的坑和对应排查思路。
背景与问题
在传统以太坊账户模型里,主要有两类账户:
- EOA(Externally Owned Account):私钥控制,发起交易靠签名
- Contract Account:合约控制,但不能像 EOA 一样原生主动发交易
这个模型有几个长期痛点:
1. 用户体验差
普通用户进入 Web3,经常会遇到这些问题:
- 需要保管助记词
- 必须持有原生代币支付 gas
- 多签、社交恢复、限额控制这些能力都要靠外围系统拼装
- 一旦私钥丢失,资产基本不可恢复
这些问题本质上不是前端问题,而是账户模型能力不足。
2. 钱包能力和协议能力割裂
很多钱包功能,比如:
- 批量执行
- 会话密钥
- 设备级权限隔离
- 自动 gas 赞助
- 社交恢复
如果基于 EOA 做,往往需要依赖中心化服务,或者通过中间合约曲线救国,流程复杂且不统一。
3. 协议升级门槛高
如果直接修改以太坊底层交易类型来支持“合约账户像 EOA 一样发交易”,链级改造成本很高。ERC-4337 的价值就在这里:不改共识层,通过一套合约与链下基础设施实现账户抽象。
ERC-4337 解决什么问题
ERC-4337 的核心思想可以概括成一句话:
不是让用户直接发交易,而是让用户提交
UserOperation,由 Bundler 打包,最终通过EntryPoint合约统一执行。
这样一来,账户逻辑不再被 EOA 的私钥模型锁死,而是可以由智能合约定义:
- 谁能签名
- 什么条件下可执行
- gas 由谁支付
- 是否支持批处理
- 是否支持恢复机制
这让钱包从“签名器”变成了“可编程账户”。
核心原理
ERC-4337 的关键角色通常有四个:
- Smart Contract Account:用户的钱包合约
- EntryPoint:统一验证并执行
UserOperation - Bundler:收集用户操作并打包上链
- Paymaster:代付 gas 的可选模块
一张总览图先看全局
flowchart LR
U[User / dApp] --> OP[UserOperation]
OP --> B[Bundler]
B --> E[EntryPoint]
E --> A[Smart Contract Account]
E --> P[Paymaster]
A --> T[Target Contract]
UserOperation 不是交易,而是“待执行意图”
用户不直接广播普通交易,而是提交一个结构体,里面一般包含:
sender:钱包合约地址noncecallDatacallGasLimitverificationGasLimitmaxFeePerGassignature- 以及 Paymaster 相关字段
Bundler 收集这些 UserOperation 后,调用 EntryPoint.handleOps() 一次性处理多个用户操作。
执行顺序
ERC-4337 的典型执行顺序如下:
sequenceDiagram
participant U as User
participant B as Bundler
participant E as EntryPoint
participant A as Smart Account
participant P as Paymaster
participant T as Target Contract
U->>B: 发送 UserOperation
B->>E: simulateValidation
E->>A: validateUserOp
A-->>E: 验签/nonce/权限校验
E->>P: validatePaymasterUserOp
P-->>E: 确认是否赞助 gas
B->>E: handleOps
E->>A: execute / validate
A->>T: 调用目标合约
E-->>B: 结算 gas
EntryPoint 为什么重要
EntryPoint 是 ERC-4337 的“交通枢纽”:
- 统一入口,避免每个钱包各搞一套执行规范
- 在执行前做验证
- 执行后统一 gas 结算
- 与 Bundler、Paymaster 协作
你可以把它理解为:
ERC-4337 世界里的“交易调度器 + 验证网关 + 结算中心”。
智能合约钱包最核心的接口
对钱包合约来说,最关键的是实现验证逻辑。典型最小能力包括:
- 校验调用来自 EntryPoint
- 校验签名是否有效
- 校验 nonce
- 执行目标调用
从架构上看,钱包合约通常会拆成几层:
classDiagram
class EntryPoint {
+handleOps()
+depositTo()
+balanceOf()
}
class SmartAccount {
+validateUserOp()
+execute(dest, value, func)
+owner()
+nonce()
}
class Paymaster {
+validatePaymasterUserOp()
+postOp()
}
class TargetContract {
+businessMethod()
}
EntryPoint --> SmartAccount
EntryPoint --> Paymaster
SmartAccount --> TargetContract
方案对比与取舍分析
在真正落地前,我建议先想清楚:你到底是要一个“能跑的 AA 钱包”,还是一个“可运营的产品级钱包”。
方案一:最小可用钱包
特点:
- 单签 owner
- 不接 Paymaster
- 只支持基础 execute
- Bundler 用第三方服务
优点:
- 实现简单
- 上线速度快
- 调试成本低
缺点:
- 用户体验提升有限
- 不支持 gasless 场景
- 恢复机制不足
适合:
- PoC
- 内部工具
- 教学 Demo
方案二:产品级钱包
特点:
- 模块化签名验证
- 社交恢复/多签
- Paymaster 赞助
- 批处理与权限系统
- 会话密钥
优点:
- 用户体验显著提升
- 商业化空间大
- 可按业务扩展
缺点:
- 安全面更大
- 系统角色更多
- 监控与风控要求更高
适合:
- 面向 C 端的钱包产品
- 游戏、社交、支付类 Web3 应用
取舍建议
如果你是第一次做 ERC-4337,我的建议很明确:
- 先做单签 + execute + EntryPoint 接入
- 再加批量执行
- 再评估是否接入 Paymaster
- 最后再上 社交恢复 / 会话密钥 / 模块化验证
不要一上来做全家桶,否则很容易在模拟验证和 gas 结算上卡住。
实战代码(可运行)
下面给一个最小可运行版本的智能合约钱包示例。为了让代码聚焦核心逻辑,我会做适度简化,但保证结构上符合 ERC-4337 的基本思路。
说明:
- 使用 Solidity
- 依赖 OpenZeppelin 的 ECDSA
- 假设已知 EntryPoint 地址
- 实现单签 owner 验证与基础 execute
1)最小钱包合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
interface IEntryPoint {
function depositTo(address account) external payable;
function balanceOf(address account) external view returns (uint256);
}
struct UserOperation {
address sender;
uint256 nonce;
bytes initCode;
bytes callData;
uint256 callGasLimit;
uint256 verificationGasLimit;
uint256 preVerificationGas;
uint256 maxFeePerGas;
uint256 maxPriorityFeePerGas;
bytes paymasterAndData;
bytes signature;
}
contract Simple4337Account {
using ECDSA for bytes32;
address public owner;
address public immutable entryPoint;
uint256 public nonce;
event Executed(address indexed target, uint256 value, bytes data);
event OwnerChanged(address indexed oldOwner, address indexed newOwner);
modifier onlyEntryPoint() {
require(msg.sender == entryPoint, "only entryPoint");
_;
}
modifier onlyOwner() {
require(msg.sender == owner, "only owner");
_;
}
constructor(address _owner, address _entryPoint) {
owner = _owner;
entryPoint = _entryPoint;
}
receive() external payable {}
function getUserOpHash(UserOperation calldata userOp) public view returns (bytes32) {
return keccak256(
abi.encode(
block.chainid,
address(this),
userOp.sender,
userOp.nonce,
keccak256(userOp.initCode),
keccak256(userOp.callData),
userOp.callGasLimit,
userOp.verificationGasLimit,
userOp.preVerificationGas,
userOp.maxFeePerGas,
userOp.maxPriorityFeePerGas,
keccak256(userOp.paymasterAndData)
)
);
}
function validateUserOp(
UserOperation calldata userOp,
bytes32,
uint256 missingAccountFunds
) external onlyEntryPoint returns (uint256 validationData) {
require(userOp.sender == address(this), "invalid sender");
require(userOp.nonce == nonce, "invalid nonce");
bytes32 hash = getUserOpHash(userOp).toEthSignedMessageHash();
address recovered = hash.recover(userOp.signature);
require(recovered == owner, "invalid signature");
nonce++;
if (missingAccountFunds > 0) {
(bool success, ) = payable(entryPoint).call{value: missingAccountFunds}("");
require(success, "fund entryPoint failed");
}
return 0;
}
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 executeByOwner(address dest, uint256 value, bytes calldata func) external onlyOwner {
(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 changeOwner(address newOwner) external onlyOwner {
require(newOwner != address(0), "zero address");
emit OwnerChanged(owner, newOwner);
owner = newOwner;
}
function addDeposit() external payable {
IEntryPoint(entryPoint).depositTo{value: msg.value}(address(this));
}
function getDeposit() external view returns (uint256) {
return IEntryPoint(entryPoint).balanceOf(address(this));
}
}
2)一个被调用的测试合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Counter {
uint256 public number;
event Increased(uint256 newNumber, address caller);
function increment() external {
number += 1;
emit Increased(number, msg.sender);
}
function setNumber(uint256 newNumber) external {
number = newNumber;
}
}
3)Hardhat 部署脚本
const { ethers } = require("hardhat");
async function main() {
const [deployer, owner] = await ethers.getSigners();
// 这里换成你实际网络上的 EntryPoint 地址
const ENTRY_POINT = "0x0000000000000000000000000000000000000001";
const Counter = await ethers.getContractFactory("Counter");
const counter = await Counter.deploy();
await counter.waitForDeployment();
const Account = await ethers.getContractFactory("Simple4337Account");
const account = await Account.deploy(owner.address, ENTRY_POINT);
await account.waitForDeployment();
console.log("Counter:", await counter.getAddress());
console.log("Simple4337Account:", await account.getAddress());
console.log("Owner:", owner.address);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
4)本地直接验证 executeByOwner
因为完整跑通 ERC-4337 还需要 bundler 与 entryPoint 环境,第一步建议先验证钱包合约本身可执行。下面这个脚本直接通过 owner 调用钱包,再由钱包调用 Counter。
const { ethers } = require("hardhat");
async function main() {
const [deployer, owner] = await ethers.getSigners();
const counterAddr = "你的Counter地址";
const accountAddr = "你的Simple4337Account地址";
const counter = await ethers.getContractAt("Counter", counterAddr);
const account = await ethers.getContractAt("Simple4337Account", accountAddr, owner);
const iface = new ethers.Interface([
"function increment()"
]);
const data = iface.encodeFunctionData("increment");
const tx = await account.executeByOwner(counterAddr, 0, data);
await tx.wait();
console.log("counter.number =", (await counter.number()).toString());
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
5)如何构造 callData
当 bundler 发送 UserOperation 时,真正传给钱包的 callData,通常是对 execute() 的编码。
const { ethers } = require("ethers");
const accountAbi = [
"function execute(address dest, uint256 value, bytes func)"
];
const counterAbi = [
"function increment()"
];
const accountInterface = new ethers.Interface(accountAbi);
const counterInterface = new ethers.Interface(counterAbi);
const counterCall = counterInterface.encodeFunctionData("increment");
const walletCallData = accountInterface.encodeFunctionData("execute", [
"0xCounterAddress",
0,
counterCall
]);
console.log(walletCallData);
如果你接的是现成 bundler SDK,比如 Stackup、Pimlico、ZeroDev 一类服务,通常这一步由 SDK 帮你完成,但理解底层编码非常重要,排查时特别有用。
一条推荐的落地路径
很多团队失败不是因为写不出钱包合约,而是同时引入太多变量。我更推荐这样分阶段推进:
阶段 1:链上账户可执行
目标:
- 钱包合约部署成功
- owner 可直接调用
executeByOwner - 可完成 ERC20 转账或简单合约调用
验收标准:
- nonce 逻辑正确
- 执行失败时能返回原始 revert
- 可充值 ETH 到钱包
阶段 2:接入标准 EntryPoint
目标:
- 实现
validateUserOp - 通过模拟验证
- 可完成
handleOps
验收标准:
- 签名校验稳定
missingAccountFunds处理正确- EntryPoint 余额可查询
阶段 3:接入 Bundler
目标:
- 用户提交
UserOperation - bundler 可以接收并模拟
- 操作成功上链
验收标准:
simulateValidation不报错- bundler 不拒单
- 交易回执可追踪到目标调用
阶段 4:引入 Paymaster
目标:
- 支持 gas sponsor
- 为指定用户或业务场景补贴手续费
验收标准:
- 白名单或签名策略有效
- postOp 能处理异常
- sponsor 成本可监控
常见坑与排查
这一节我会写得更“实战”一点,因为 ERC-4337 真正难的往往不是代码本身,而是链上合约、链下 bundler、签名格式、gas 模拟几者之间的耦合。
坑 1:签名明明对了,validateUserOp 还是失败
常见原因:
userOpHash计算方式不一致- 前端签的是 EIP-191,合约按别的格式验
chainId不一致sender地址填错callData被重新编码导致哈希变化
排查建议:
- 前端打印原始
userOp - 前端打印待签名 hash
- 合约里暴露
getUserOpHash - 比较前后 hash 是否完全一致
- 确认是否加了
toEthSignedMessageHash()
如果你用的是不同 SDK 混搭,这个问题尤其常见。我当时踩过一次坑:前端用某 SDK 构造 op,后端自己重算 hash,结果字段顺序不同,签名永远不匹配。
坑 2:Bundler 拒绝打包
常见现象:
- RPC 返回
FailedOp AAxx类错误码simulation failed- mempool 不接受
常见原因:
verificationGasLimit太低- 钱包未给 EntryPoint 充值
- nonce 不正确
initCode部署逻辑有问题- Paymaster 验证失败
排查顺序建议:
flowchart TD
A[Bundler 拒单] --> B{先看错误类型}
B -->|签名相关| C[核对 userOpHash 与签名格式]
B -->|gas 相关| D[提高 verificationGasLimit / callGasLimit]
B -->|资金相关| E[检查 EntryPoint deposit]
B -->|nonce 相关| F[读取钱包 nonce]
B -->|paymaster 相关| G[单独关闭 Paymaster 验证]
一个很实用的方法是:先去掉 Paymaster,再调通裸钱包链路。因为 Paymaster 一旦加入,失败面会翻倍。
坑 3:钱包调用目标合约失败,但看不到真实原因
原因通常是钱包 execute 没把底层 revert 原样抛出。
上面的示例用了这段 assembly:
assembly {
revert(add(result, 32), mload(result))
}
它的作用是把目标合约的错误原样冒泡。没有这段的话,你只能看到一个模糊的 call failed,调试体验会非常糟糕。
坑 4:missingAccountFunds 处理错误
validateUserOp 中 EntryPoint 可能要求钱包补足资金。如果你没有处理:
- bundler 模拟可能通过
- 但正式执行时 gas 结算失败
建议:
- 钱包实现自动向 EntryPoint 补款
- 定期检查
balanceOf(account) - 对余额不足做链下告警
坑 5:nonce 设计过于简单
示例里用的是单一递增 nonce,这适合最小 Demo,但产品级钱包常常不够用。
为什么?
- 批量并发能力差
- 不同模块之间互相阻塞
- 会话密钥和管理员操作容易冲突
更稳妥的做法:
- 使用分段 nonce
- 为不同“key / module / lane”分配独立空间
- 或直接采用成熟钱包实现中的 nonce 设计
安全/性能最佳实践
账户抽象钱包的安全面比普通合约更大,因为它实际上在“代理用户发起一切操作”。下面这些实践我认为是上线前至少要做到的。
安全实践 1:严格限制敏感入口
像这些函数必须做访问控制:
validateUserOpexecute- owner 管理函数
- 模块安装/卸载函数
- 恢复流程相关函数
最低要求:
validateUserOp/execute只允许 EntryPoint 调用- 管理函数仅 owner 或治理模块可调用
安全实践 2:签名域隔离
签名不要只对 callData 做哈希,至少要纳入:
chainId- 钱包地址
- nonce
- gas 参数
- paymaster 相关字段
否则会有:
- 跨链重放
- 跨账户重放
- 参数替换攻击
安全实践 3:对模块化扩展保持克制
很多团队一开始就想做插件化钱包,这没问题,但插件化意味着:
- 权限边界复杂
- 审计成本暴涨
- 升级面扩大
我的建议是:
- 核心执行层尽量小
- 验签、恢复、权限模块可插拔
- 每个模块独立审计
- 明确模块间可调用边界
安全实践 4:Paymaster 不只是“帮用户付 gas”
Paymaster 是高风险组件,因为它直接绑定成本与风控。
必须考虑:
- 谁可以获得赞助
- 赞助额度上限
- 单地址频率限制
- 失败交易是否继续补贴
postOp异常如何处理
如果没有风控能力,不要轻易开放公用 Paymaster。
性能实践 1:降低验证路径复杂度
Bundler 会先模拟验证,因此验证阶段越复杂,越容易:
- 超 gas
- 不稳定
- 被 bundler 拒收
建议:
validateUserOp只做必要校验- 不做复杂外部调用
- 不在验证阶段依赖高波动状态
性能实践 2:批量执行优于多次链上交互
账户抽象的一大价值就是批处理。比如:
- 一次授权 + 一次 swap
- 一次 approve + 一次 stake
- 一次 mint + 一次委托
如果能合并成一次 UserOperation,通常会更省用户心智成本,也更利于产品体验。
性能实践 3:做容量估算时关注三个指标
对于钱包服务端或 bundler 运营侧,至少要估算:
- UserOperation 峰值提交量
- 平均模拟耗时
- Paymaster 补贴成本
一个简化估算公式:
日补贴成本 ≈ 日均成功 UserOp 数 × 单次平均 gasUsed × 平均 gasPrice
如果你的业务是活动型增长,补贴成本会随着 gas 波动放大,不能只按平时均值算。
生产落地建议
如果你准备把 ERC-4337 真正用到业务里,我建议按下面这套组合来建设:
最小生产架构
- 链上:
- Smart Account
- EntryPoint
- 可选 Paymaster
- 链下:
- Bundler 接入层
- UserOp 构造服务
- 签名服务或客户端签名 SDK
- 监控与告警
监控重点
必须监控:
- bundler 接单失败率
simulateValidation失败率handleOps成功率- paymaster 日消耗
- 单用户失败重试次数
- EntryPoint deposit 余额阈值
适用边界
ERC-4337 非常适合:
- 新用户引导
- gasless onboarding
- 游戏钱包
- 企业托管钱包
- 复杂权限管理场景
但如果你的场景只是:
- 高净值用户单地址转账
- 极简硬件钱包需求
- 对协议依赖最少的冷存储
那未必需要账户抽象,EOA 反而更简单直接。
总结
ERC-4337 的意义,不只是“让钱包变成合约”,而是把账户从固定规则升级成可编程系统。它带来的最大变化有三个:
- 用户体验可重构:gas sponsor、社交恢复、批量操作都成为一等能力
- 钱包能力可编程:签名、权限、恢复、限额都能按业务设计
- 协议接入更标准化:通过 EntryPoint、Bundler、Paymaster 建立统一执行路径
如果你要实战落地,我建议记住这三条:
- 先做最小闭环:单签钱包 + EntryPoint + 基础 execute
- 再逐步加能力:Bundler、Paymaster、批处理、恢复机制逐层引入
- 把调试能力当成正式需求:哈希计算、错误冒泡、模拟日志、余额监控一个都不能少
一句更直接的话:
ERC-4337 真正难的不是“写出钱包”,而是“让钱包在完整链路里稳定运行”。
只要你按模块拆开,一段一段验证,这件事并没有看起来那么玄学。对于中级开发者来说,最有效的学习方式不是继续看十篇概念文章,而是把上面的最小账户跑起来,然后亲手让一次 UserOperation 成功落链。做到这一步,你对账户抽象的理解会立刻从“知道”变成“会用”。