Web3 中级实战:基于 EIP-4337 的账户抽象钱包集成与 Gas 代付方案落地指南
EIP-4337 这几年几乎成了“钱包体验升级”的代名词:免原生 Gas、支持社交恢复、批量执行、自定义签名逻辑……这些能力放在产品里,确实能显著降低普通用户进入 Web3 的门槛。
但很多同学第一次做集成时,往往会卡在一个很现实的问题上:
- 规范看懂了,但代码怎么串起来
- 知道有
EntryPoint、Bundler、Paymaster,但不知道谁先调谁 - 本地能跑通 Demo,一到测试网就遇到:
FailedOpAA21 didn't pay prefundsignature validation failed- sponsorGas 失效
- estimateGas 跟实际完全不一致
这篇文章我会从“真正要交付一个可用的钱包 Gas 代付能力”的角度,带你搭一条中级开发者能落地的路径。不是只讲概念,而是把核心原理、最小可运行代码、常见坑和安全建议一起讲清楚。
背景与问题
传统 EOA 钱包有个很明显的限制:账户逻辑固定,且必须由账户自己支付 Gas。
这会带来几个产品问题:
-
新用户必须先持有链原生代币
- 比如 ETH、MATIC、BNB
- 对非 Crypto 原生用户来说,这一步就是典型流失点
-
钱包能力太死
- 无法天然支持社交恢复
- 无法灵活做多签、限额、会话密钥
- 无法按业务定义验证逻辑
-
交互体验割裂
- 登录、授权、支付被拆成多个步骤
- 用户会反复切回交易确认界面
EIP-4337 的核心价值,就是不修改以太坊共识层的前提下,把账户抽象能力搬到应用层实现。
它引入了一种新的“伪交易对象”——UserOperation,再通过 Bundler 打包给 EntryPoint 合约统一执行,于是账户可以变成一个可编程智能合约钱包,Gas 还可以由第三方 Paymaster 代付。
一句话概括:
EIP-4337 不是让用户直接发交易,而是让用户提交“意图 + 签名”,由基础设施代为上链执行。
前置知识
建议你至少熟悉以下内容:
- Solidity 基础
- Ethers.js 或 Viem 的基本使用
- 智能合约部署与调用
- ERC-20 授权机制
- 签名、nonce、calldata 的基本概念
如果你已经写过合约钱包或 relayer,这篇内容会更容易吸收。
环境准备
本文示例以 Node.js + Solidity + Hardhat + Ethers v6 为主,目标是做一个“最小可运行版”:
- 一个极简智能账户
SimpleAccount - 一个简化版
EntryPoint - 一个简化版
Paymaster - 一个脚本模拟 Bundler 提交
UserOperation
说明:为了让代码能在本地直接跑通,下面会实现“教学版 4337 流程”,保留核心结构,但不会完整覆盖官方生产级实现。真正上线请直接基于成熟实现,例如:
eth-infinitism/account-abstraction- Stackup / Pimlico / Alchemy AA SDK 等
安装依赖:
mkdir aa-demo && cd aa-demo
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install ethers
npx hardhat init
推荐目录结构:
aa-demo/
├─ contracts/
│ ├─ SimpleAccount.sol
│ ├─ SimpleEntryPoint.sol
│ └─ VerifyingPaymaster.sol
├─ scripts/
│ └─ demo.js
├─ hardhat.config.js
└─ package.json
核心原理
先别急着写代码,先把四个角色和调用顺序吃透。
1. EIP-4337 的关键角色
-
Smart Account
- 用户真正控制的合约钱包
- 定义签名验证、nonce 管理、执行逻辑
-
UserOperation
- 用户提交的操作描述
- 类似“我想做什么 + 我如何授权 + 预算多少 Gas”
-
Bundler
- 收集多个
UserOperation - 调用
EntryPoint.handleOps(...)上链执行
- 收集多个
-
EntryPoint
- 统一入口合约
- 负责校验、扣费、调用账户执行
-
Paymaster
- 决定是否赞助某笔操作的 Gas
- 可以做白名单、额度、风控、签名校验
2. 处理流程图
flowchart LR
U[用户/前端] --> A[构造 UserOperation]
A --> B[Smart Account 签名]
B --> C[Bundler RPC]
C --> D[EntryPoint.handleOps]
D --> E[验证账户签名]
D --> F[验证 Paymaster 赞助]
E --> G[执行目标调用]
F --> G
G --> H[结算 Gas]
3. 一次完整调用的时序
sequenceDiagram
participant User as 用户前端
participant Wallet as Smart Account
participant Paymaster as Paymaster服务
participant Bundler as Bundler
participant EP as EntryPoint
participant Target as 目标合约
User->>Wallet: 构造 callData
Wallet->>Paymaster: 请求 sponsor 数据
Paymaster-->>Wallet: paymasterAndData
Wallet->>Wallet: 对 UserOperation 签名
Wallet->>Bundler: eth_sendUserOperation
Bundler->>EP: handleOps(op)
EP->>Wallet: validateUserOp
EP->>Paymaster: validatePaymasterUserOp
EP->>Target: execute(callData)
Target-->>EP: success
EP-->>Bundler: 完成并结算
4. UserOperation 的关键字段
虽然不同 SDK 会帮你封装,但你最好理解这些字段:
sender:智能账户地址nonce:防重放initCode:若账户未部署,可附带部署逻辑callData:要执行的业务调用callGasLimit:业务执行 GasverificationGasLimit:验证阶段 GaspreVerificationGas:打包前开销估算maxFeePerGas/maxPriorityFeePerGaspaymasterAndData:代付信息signature:账户签名
我自己第一次接 4337 时,最容易误解的一点是:
签名不是签“链上交易”,而是签UserOperation的哈希。
实战代码(可运行)
下面我们做一个教学版最小实现,重点是把流程打通。
第一步:编写极简 EntryPoint
contracts/SimpleEntryPoint.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IAccount {
function validateUserOp(bytes32 userOpHash, bytes calldata signature) external view returns (bool);
function execute(address dest, uint256 value, bytes calldata func) external;
}
interface IPaymaster {
function validatePaymasterUserOp(address sender, bytes32 userOpHash, bytes calldata paymasterData) external view returns (bool);
}
contract SimpleEntryPoint {
struct UserOperation {
address sender;
uint256 nonce;
address dest;
uint256 value;
bytes data;
bytes signature;
address paymaster;
bytes paymasterData;
}
mapping(address => uint256) public nonces;
event UserOperationEvent(address indexed sender, bool success, bytes result);
function getUserOpHash(UserOperation calldata op) public pure returns (bytes32) {
return keccak256(
abi.encode(
op.sender,
op.nonce,
op.dest,
op.value,
keccak256(op.data),
op.paymaster,
keccak256(op.paymasterData)
)
);
}
function handleOps(UserOperation[] calldata ops) external {
for (uint256 i = 0; i < ops.length; i++) {
UserOperation calldata op = ops[i];
require(op.nonce == nonces[op.sender], "bad nonce");
bytes32 userOpHash = getUserOpHash(op);
bool sigOk = IAccount(op.sender).validateUserOp(userOpHash, op.signature);
require(sigOk, "account validation failed");
if (op.paymaster != address(0)) {
bool pmOk = IPaymaster(op.paymaster).validatePaymasterUserOp(
op.sender,
userOpHash,
op.paymasterData
);
require(pmOk, "paymaster validation failed");
}
nonces[op.sender]++;
(bool ok, bytes memory result) = address(op.sender).call(
abi.encodeWithSignature(
"execute(address,uint256,bytes)",
op.dest,
op.value,
op.data
)
);
emit UserOperationEvent(op.sender, ok, result);
require(ok, "execution failed");
}
}
}
这个版本做了三件事:
- 校验账户签名
- 校验 paymaster 是否允许代付
- 调用钱包的
execute(...)
生产版 EntryPoint 会复杂得多,包括押金、Gas 结算、聚合签名等;这里先抓核心主线。
第二步:编写智能账户
contracts/SimpleAccount.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract SimpleAccount {
using ECDSA for bytes32;
address public owner;
address public immutable entryPoint;
modifier onlyEntryPoint() {
require(msg.sender == entryPoint, "only entryPoint");
_;
}
constructor(address _owner, address _entryPoint) payable {
owner = _owner;
entryPoint = _entryPoint;
}
function validateUserOp(bytes32 userOpHash, bytes calldata signature) external view returns (bool) {
bytes32 ethSignedHash = userOpHash.toEthSignedMessageHash();
return ethSignedHash.recover(signature) == owner;
}
function execute(address dest, uint256 value, bytes calldata func) external onlyEntryPoint {
(bool success, bytes memory result) = dest.call{value: value}(func);
require(success, string(result));
}
receive() external payable {}
}
还需要安装 OpenZeppelin:
npm install @openzeppelin/contracts
这个账户非常简单:
owner负责签名授权entryPoint是唯一执行入口- 用户无法随便直接调用
execute,必须经由EntryPoint
这就是账户抽象的关键之一:账户本身是可编程的权限系统。
第三步:编写一个签名型 Paymaster
为了本地演示,我们做一个非常轻量的 Paymaster:
后端用一个 sponsor 私钥对 userOpHash 签名,链上合约验证这个签名是否来自 sponsor。
contracts/VerifyingPaymaster.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract VerifyingPaymaster {
using ECDSA for bytes32;
address public verifyingSigner;
constructor(address _verifyingSigner) {
verifyingSigner = _verifyingSigner;
}
function validatePaymasterUserOp(
address sender,
bytes32 userOpHash,
bytes calldata paymasterData
) external view returns (bool) {
bytes32 digest = keccak256(abi.encode(sender, userOpHash)).toEthSignedMessageHash();
return digest.recover(paymasterData) == verifyingSigner;
}
}
这里 paymasterData 本质上就是 sponsor 的签名。
生产环境里你通常不会只签 sender + userOpHash,还会加入:
- 过期时间
- 白名单策略
- token 支付参数
- sponsor 配额
- 链 ID
- entryPoint 地址
否则很容易被重放或跨环境误用。
第四步:增加一个目标合约用于测试
contracts/Counter.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Counter {
uint256 public number;
event Increased(uint256 newNumber);
function increment() external {
number += 1;
emit Increased(number);
}
}
第五步:Hardhat 配置
hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");
module.exports = {
solidity: "0.8.20",
};
第六步:编写部署与调用脚本
scripts/demo.js
const { ethers } = require("hardhat");
async function main() {
const [deployer, owner, sponsor] = await ethers.getSigners();
console.log("deployer:", deployer.address);
console.log("owner:", owner.address);
console.log("sponsor:", sponsor.address);
const EntryPoint = await ethers.getContractFactory("SimpleEntryPoint");
const entryPoint = await EntryPoint.deploy();
await entryPoint.waitForDeployment();
const entryPointAddr = await entryPoint.getAddress();
const Account = await ethers.getContractFactory("SimpleAccount");
const account = await Account.deploy(owner.address, entryPointAddr);
await account.waitForDeployment();
const accountAddr = await account.getAddress();
const Paymaster = await ethers.getContractFactory("VerifyingPaymaster");
const paymaster = await Paymaster.deploy(sponsor.address);
await paymaster.waitForDeployment();
const paymasterAddr = await paymaster.getAddress();
const Counter = await ethers.getContractFactory("Counter");
const counter = await Counter.deploy();
await counter.waitForDeployment();
const counterAddr = await counter.getAddress();
console.log("EntryPoint:", entryPointAddr);
console.log("Account:", accountAddr);
console.log("Paymaster:", paymasterAddr);
console.log("Counter:", counterAddr);
const counterIface = new ethers.Interface([
"function increment() external",
"function number() view returns (uint256)"
]);
const callData = counterIface.encodeFunctionData("increment", []);
const nonce = await entryPoint.nonces(accountAddr);
const userOpForHash = {
sender: accountAddr,
nonce,
dest: counterAddr,
value: 0,
data: callData,
signature: "0x",
paymaster: paymasterAddr,
paymasterData: "0x",
};
const userOpHash = await entryPoint.getUserOpHash(userOpForHash);
console.log("userOpHash:", userOpHash);
const userSig = await owner.signMessage(ethers.getBytes(userOpHash));
const paymasterDigest = ethers.solidityPackedKeccak256(
["address", "bytes32"],
[accountAddr, userOpHash]
);
const paymasterSig = await sponsor.signMessage(ethers.getBytes(paymasterDigest));
const userOp = {
sender: accountAddr,
nonce,
dest: counterAddr,
value: 0,
data: callData,
signature: userSig,
paymaster: paymasterAddr,
paymasterData: paymasterSig,
};
console.log("submitting UserOperation...");
const tx = await entryPoint.handleOps([userOp]);
const receipt = await tx.wait();
console.log("tx hash:", receipt.hash);
const counterContract = await ethers.getContractAt("Counter", counterAddr);
const number = await counterContract.number();
console.log("counter.number =", number.toString());
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
运行命令:
npx hardhat compile
npx hardhat run scripts/demo.js
如果一切正常,你会看到类似输出:
deployer: 0x...
owner: 0x...
sponsor: 0x...
EntryPoint: 0x...
Account: 0x...
Paymaster: 0x...
Counter: 0x...
userOpHash: 0x...
submitting UserOperation...
tx hash: 0x...
counter.number = 1
到这一步,说明我们已经跑通了一个教学版闭环:
- 用户签名
UserOperation - sponsor 签名代付授权
- bundler 角色由脚本直接模拟
- EntryPoint 校验并执行
把教学版映射到真实 EIP-4337
很多人写完本地 Demo 会有个疑问:
“我这个和正式 4337 到底差在哪?”
可以用下面这张图快速对照。
classDiagram
class UserOperation {
+sender
+nonce
+initCode
+callData
+gasLimits
+paymasterAndData
+signature
}
class SmartAccount {
+validateUserOp()
+execute()
}
class EntryPoint {
+handleOps()
+simulateValidation()
+depositTo()
}
class Bundler {
+eth_sendUserOperation()
+eth_estimateUserOperationGas()
}
class Paymaster {
+validatePaymasterUserOp()
+postOp()
}
UserOperation --> SmartAccount
Bundler --> EntryPoint
EntryPoint --> SmartAccount
EntryPoint --> Paymaster
教学版 vs 生产版
教学版里有:
- 账户验证
- paymaster 验证
- 统一入口执行
- nonce 管理
生产版还必须有:
- 押金与质押机制
- 更完整的 Gas 结算
simulateValidation- 未部署账户的
initCode postOp结算/扣款- 更健壮的重放保护
Bundler RPC标准接口- DoS 与 griefing 防护
所以如果你要正式接入某条支持 4337 的网络,推荐方案通常是:
- 直接复用成熟
EntryPoint - 钱包基于
SimpleAccount/ Safe / Kernel 等成熟实现改造 - 自建或接入第三方
Bundler - 自建一个风控型
Paymaster API
逐步验证清单
我建议你按下面顺序验证,不要一上来就前后端全连,否则排错非常痛苦。
验证 1:目标合约单独可调用
先确认 Counter.increment() 没问题:
const tx = await counter.increment();
await tx.wait();
如果这里都失败,后面不要继续。
验证 2:账户签名验证正确
单独验证:
userOpHash是否按预期生成owner.signMessage(...)与链上恢复逻辑一致
最常见错误就是:
- 前端签的是原始对象 JSON
- 链上验的是 ABI 编码哈希
- 两边根本不是同一个消息
验证 3:paymaster 签名是否匹配
确认:
- sponsor 签的是
keccak256(abi.encode(sender, userOpHash)) - 不是
solidityPacked与abi.encode混用导致哈希不一致 - 链上
recover出来的地址等于 sponsor 地址
验证 4:EntryPoint 能成功执行钱包调用
如果签名都通过但执行失败,重点看:
execute()的权限dest地址是否正确data是否编码正确
验证 5:再接真实 Bundler RPC
等本地教学版确认流程没问题,再换到:
eth_sendUserOperationeth_estimateUserOperationGaseth_getUserOperationReceipt
这样你至少能确定问题是“业务逻辑”还是“基础设施差异”。
常见坑与排查
这部分我尽量写得实战一点,很多坑我自己也踩过。
1. 签名总是失败
现象
报错类似:
account validation failedsignature validation failed
排查思路
先看前后端是否对同一个哈希签名:
console.log("userOpHash:", userOpHash);
console.log("sign bytes:", ethers.getBytes(userOpHash));
再确认链上恢复逻辑:
bytes32 ethSignedHash = userOpHash.toEthSignedMessageHash();
return ethSignedHash.recover(signature) == owner;
常见原因
- 前端用了
signTypedData,链上却按signMessage验 nonce取错paymasterData在签名前后被修改,导致userOpHash变了- 地址字段大小写、类型、编码顺序不一致
建议
签名方案定下来后,前端、后端、链上三端都写固定测试向量,别靠肉眼猜。
2. Nonce 错误
现象
bad nonce- bundler 返回重复提交或重放错误
常见原因
- 并发提交多个
UserOperation - 你本地缓存了 nonce,但链上已经被消费
- 不同 key 空间的 nonce 设计没统一
建议
生产钱包最好不要只做单一 uint256 nonce,而是参考更成熟的“多维 nonce”设计,便于并行操作和会话隔离。
3. paymaster 代付不生效
现象
- 本地脚本能跑,接真实网络后 sponsor 失败
- bundler 直接拒收
UserOperation
常见原因
- paymaster 没有足够押金/存款
paymasterAndData编码不符合对应 EntryPoint 实现- sponsor 策略服务返回了过期签名
- bundler 与 paymaster 使用的 EntryPoint 地址不一致
排查建议
重点核对这四项:
chainIdentryPoint addresspaymaster addressvalidUntil / validAfter
我见过最隐蔽的一个坑,是测试环境切链后,后端 sponsor 还是用旧 chainId 在签名,结果链上永远验不过。
4. callData 编码错误
现象
- 验证通过,但执行失败
execution reverted
排查方法
把目标调用拆开单测:
const data = counterIface.encodeFunctionData("increment", []);
console.log(data);
再尝试直接让普通 EOA 调用目标合约,确认业务本身能成功。
常见原因
- 合约 ABI 不匹配
- 目标方法参数顺序错
execute()转发时value不对- 目标合约对
msg.sender有额外限制
5. 本地能跑,接正式 4337 基础设施就失败
这是最常见的一类。
原因通常不在“智能账户逻辑”,而在“规范细节”
例如:
preVerificationGas估算不足verificationGasLimit太小initCode格式不对- 使用的
EntryPoint版本与 bundler 不匹配 simulateValidation没过
建议
接真实 4337 网络时,尽量用成熟 SDK 先跑通,再逐步替换自定义组件。
否则你会把时间浪费在协议边角细节上,而不是业务本身。
安全/性能最佳实践
4337 的可编程能力很强,但同时也意味着“犯错空间更大”。下面这些建议,我认为是上线前必须过一遍的。
安全实践
1. 严格绑定域信息
签名里至少绑定:
chainIdentryPointaccount addressnoncevalidAftervalidUntil
否则存在跨链、跨合约、跨环境重放风险。
2. Paymaster 必须有限额与过期控制
不要做“看见请求就赞助”的裸奔 sponsor。
至少控制:
- 每地址每日次数
- 每会话预算
- 白名单方法
- 白名单目标合约
- 签名过期时间
推荐只赞助你明确允许的调用,比如:
- 某个 dApp 合约的
mint - 某个 router 的固定方法
- 金额上限内的交易
3. 防止恶意 calldata 放大成本
用户提交的 callData 很容易被构造得非常重,导致 sponsor 承担超额 Gas。
建议:
- 后端在 sponsor 前做 ABI 级解析
- 只允许白名单函数选择器
- 对复杂多调用做长度和目标限制
4. 智能账户执行入口要最小化
不要让太多管理函数暴露给任意调用者。
像本文的 execute() 就只允许 EntryPoint 调用,这是非常基础但很关键的限制。
5. 做好 owner 密钥升级/恢复策略
如果你用合约钱包却仍然只有单 EOA owner,本质上只是“EOA + 代付”而已,抽象能力没有完全发挥出来。
中期演进建议:
- owner 可升级
- 增加 guardian
- 增加社交恢复
- 增加 session key
性能实践
1. 尽量减少验证阶段复杂度
validateUserOp() 是每笔操作都会进入的路径。
如果你在里面做复杂状态读取、多签遍历、外部调用,Gas 会迅速上升。
建议:
- 验证逻辑保持纯粹
- 少做 storage 写入
- 优先静态校验
2. sponsor 服务做缓存
如果你有自己的 Paymaster API,建议缓存:
- 用户风控结果
- 白名单检查结果
- token 汇率结果
- 限额配置
否则每次 sponsor 都查一堆外部服务,延迟会很差。
3. 对批量业务优先使用一次 UserOperation 多调用
如果你的账户支持 executeBatch,可以把多个动作合并,减少交互次数。
例如:
- approve + swap
- mint + stake
- create profile + follow
这对产品体验提升非常直接。
一个更贴近生产的落地思路
如果你现在要给 App 或 dApp 落一个“免 Gas 新手钱包”,我建议按这个路线走:
flowchart TD
A[前端创建用户意图] --> B[SDK构造UserOperation]
B --> C[后端Paymaster API风控]
C --> D[返回赞助签名/额度]
D --> E[提交给Bundler]
E --> F[EntryPoint执行]
F --> G[业务合约成功]
F --> H[记录UserOp收据与监控]
推荐拆分
前端负责
- 登录与钱包初始化
- 构造调用参数
- 发起
UserOperation - 展示 UserOp 状态
后端负责
- sponsor 风控
- 额度控制
- 白名单校验
- 行为审计
- 告警与限流
链上负责
- 账户验证
- sponsor 验证
- 统一执行与最终状态落盘
这样职责边界比较清楚,也方便扩展。
边界条件:什么时候不建议上 EIP-4337
虽然 4337 很香,但也不是所有项目都该立刻上。
以下情况建议谨慎:
1. 你的产品交互极少
如果用户只是偶尔签一次消息、偶尔发一笔交易,完整 AA 基础设施可能有点“用力过猛”。
2. 你没有后端风控能力
如果没有 Paymaster API、限额、监控、审计,直接做 Gas 代付很容易被薅。
3. 你所在链的 4337 基础设施不成熟
Bundler 稳定性、节点兼容性、 SDK 完整度会直接影响体验。
4. 你团队还没准备好维护钱包系统
钱包不是普通业务模块,一旦出问题影响的是用户资产和操作可信度。
如果团队经验不足,建议优先接成熟钱包服务,而不是自己从零造所有轮子。
总结
我们这篇文章做了三件核心事情:
-
讲清了 EIP-4337 的角色分工:
- Smart Account
- UserOperation
- Bundler
- EntryPoint
- Paymaster
-
用一套可运行的最小代码跑通了账户抽象 + Gas 代付主流程
-
从落地角度梳理了:
- 签名与哈希一致性
- nonce 管理
- paymaster 风控
- 安全与性能优化
如果你准备真正把方案用到项目里,我给三个可执行建议:
- 先本地做最小闭环:先跑通账户签名、EntryPoint 执行、Paymaster 授权
- 再接成熟基础设施:不要一上来就自研完整 bundler 和生产级 EntryPoint
- 最后补齐风控与监控:Gas 代付不是“能跑就行”,而是“能长期安全跑”
EIP-4337 的真正价值,不只是“帮用户付 Gas”,而是让钱包从“被动存币工具”变成“可编程交互入口”。
当你把这层能力用好,用户看到的就不再是复杂的钱包流程,而是更接近 Web2 的顺滑体验。
如果你现在正在做钱包、游戏、社交、交易聚合器或者新手友好型 dApp,这条路线非常值得投入。