跳转到内容
123xiao | 无名键客

《Web3 中账户抽象(Account Abstraction)实战:基于 ERC-4337 设计与落地智能合约钱包》

字数: 0 阅读时长: 1 分钟

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:钱包合约地址
  • nonce
  • callData
  • callGasLimit
  • verificationGasLimit
  • maxFeePerGas
  • signature
  • 以及 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,我的建议很明确:

  1. 先做单签 + execute + EntryPoint 接入
  2. 再加批量执行
  3. 再评估是否接入 Paymaster
  4. 最后再上 社交恢复 / 会话密钥 / 模块化验证

不要一上来做全家桶,否则很容易在模拟验证和 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 被重新编码导致哈希变化

排查建议:

  1. 前端打印原始 userOp
  2. 前端打印待签名 hash
  3. 合约里暴露 getUserOpHash
  4. 比较前后 hash 是否完全一致
  5. 确认是否加了 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:严格限制敏感入口

像这些函数必须做访问控制:

  • validateUserOp
  • execute
  • 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 运营侧,至少要估算:

  1. UserOperation 峰值提交量
  2. 平均模拟耗时
  3. 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 建立统一执行路径

如果你要实战落地,我建议记住这三条:

  1. 先做最小闭环:单签钱包 + EntryPoint + 基础 execute
  2. 再逐步加能力:Bundler、Paymaster、批处理、恢复机制逐层引入
  3. 把调试能力当成正式需求:哈希计算、错误冒泡、模拟日志、余额监控一个都不能少

一句更直接的话:
ERC-4337 真正难的不是“写出钱包”,而是“让钱包在完整链路里稳定运行”。

只要你按模块拆开,一段一段验证,这件事并没有看起来那么玄学。对于中级开发者来说,最有效的学习方式不是继续看十篇概念文章,而是把上面的最小账户跑起来,然后亲手让一次 UserOperation 成功落链。做到这一步,你对账户抽象的理解会立刻从“知道”变成“会用”。


分享到:

上一篇
《前端开发中的微前端落地实践:基于 Module Federation 的应用拆分、共享依赖与部署优化》
下一篇
《自动化测试中的测试数据管理实战:从环境隔离到数据构造与回收策略》