Web3 中级实战:基于 EIP-4337 的账户抽象钱包接入与 Gas 代付方案落地
EIP-4337 这两年几乎成了“钱包体验升级”的代名词:免助记词、批量执行、社交恢复、Gas 代付……这些以前只能靠中心化托管钱包才能勉强做到的能力,现在可以在不改以太坊共识层的前提下落地。
这篇文章我不打算只讲概念,而是从**“怎么真正接进去”**的角度,带你做一套最小可运行方案:
- 一个基于 EIP-4337 的智能账户
- 一个 Bundler 接入流程
- 一个 Paymaster 做 Gas 代付
- 一个可运行的前端/脚本示例
- 一套排坑与上线建议
读完你应该能回答这几个实际问题:
- 用户没有原生 ETH,怎么发起链上交易?
UserOperation到底和普通交易差在哪?- Paymaster 到底代付了什么,风险在哪里?
- 做产品时,应该怎样控制代付成本和滥用风险?
背景与问题
传统 EOA 钱包(Externally Owned Account)的问题,你大概率已经很熟了:
- 用户必须先有 ETH 才能发第一笔交易
- 签名和交易发起强绑定,无法灵活编排
- 很难做权限控制、会话密钥、批量调用
- 新用户 onboarding 体验差,尤其是非加密原生用户
而账户抽象的核心目标,就是把“账户逻辑”从单一私钥控制,升级成可编程账户。
为什么 EIP-4337 特别关键
EIP-4337 的妙处在于:不需要修改以太坊共识协议。它通过一套链下/链上协作机制,把“用户操作”打包成特殊流程执行。
参与者通常有:
- Smart Account:智能合约钱包
- UserOperation:用户操作对象,不是普通交易
- Bundler:收集并打包 UserOperation
- EntryPoint:统一入口合约
- Paymaster:为用户代付 Gas 的合约
简单说:
用户签的是
UserOperation,Bundler 帮你上链,EntryPoint 统一执行,Paymaster 可以帮你付 Gas。
前置知识
建议你至少具备这些基础:
- 会写 Solidity 合约
- 熟悉 ERC-20 转账
- 会用
ethers.js - 知道 Ethereum 中
gasLimit / maxFeePerGas / calldata的基本意义 - 大概理解签名与 nonce
如果你之前已经做过普通合约交互,那进入 4337 最难的部分通常不是代码,而是系统角色变多后,调试路径变长。这篇我会重点把这一层讲透。
环境准备
本文示例采用:
- Node.js 18+
- Hardhat
- Solidity 0.8.19
- ethers v6
- 一个本地或测试网 EIP-4337 环境
- EntryPoint 合约地址
- Bundler RPC
- Paymaster 服务端签发接口(或链上白名单逻辑)
安装依赖
mkdir aa-wallet-demo
cd aa-wallet-demo
npm init -y
npm install hardhat @nomicfoundation/hardhat-toolbox ethers dotenv
npx hardhat
前端/脚本侧如果你想直接构造 UserOperation,可以再加:
npm install viem
目录建议
aa-wallet-demo/
├─ contracts/
│ ├─ SimpleAccount.sol
│ ├─ SimplePaymaster.sol
│ └─ MockToken.sol
├─ scripts/
│ ├─ deploy.js
│ └─ send-userop.js
├─ hardhat.config.js
└─ .env
核心原理
先别急着写代码。EIP-4337 真正要搞清楚的是“调用链路”。
1. UserOperation 不是普通交易
普通交易是用户直接广播到链上;而 UserOperation 是发给 Bundler 的一种“意图描述”。
它通常包含:
sendernonceinitCodecallDataaccountGasLimitspreVerificationGasgasFeespaymasterAndDatasignature
Bundler 会做模拟校验,确认能执行后,再调用 EntryPoint.handleOps() 打包上链。
2. EntryPoint 是统一执行入口
所有账户抽象钱包并不是随便执行,而是通过统一的 EntryPoint。
EntryPoint 主要负责:
- 验证账户签名
- 验证 nonce
- 验证 Paymaster 资助逻辑
- 执行账户调用
- 结算 Gas
3. Paymaster 的本质
Paymaster 其实就是:
我愿意为这笔 UserOperation 付钱,但我要加条件。
常见条件包括:
- 仅限白名单地址
- 仅限特定方法
- 仅限新用户首次操作
- 仅限某个 dApp 场景
- 需要服务端签发授权
这也是产品落地时最实用的一部分:你可以精准控制补贴策略,而不是无脑为所有用户买单。
一张图看懂 EIP-4337 调用链
flowchart LR
U[用户/前端] --> S[签名 UserOperation]
S --> B[Bundler RPC]
B --> E[EntryPoint.handleOps]
E --> A[Smart Account.validateUserOp]
E --> P[Paymaster.validatePaymasterUserOp]
E --> X[执行 callData]
X --> T[dApp 合约/Token]
E --> G[Gas 结算]
账户抽象执行时序
sequenceDiagram
participant User as 用户前端
participant Wallet as Smart Account
participant Server as Paymaster服务端
participant Bundler as Bundler
participant Entry as EntryPoint
participant PM as Paymaster
participant Dapp as 目标合约
User->>Wallet: 构造 callData
User->>Server: 请求代付授权
Server-->>User: paymasterAndData
User->>Bundler: eth_sendUserOperation
Bundler->>Entry: handleOps()
Entry->>Wallet: validateUserOp()
Entry->>PM: validatePaymasterUserOp()
Entry->>Dapp: execute(callData)
Entry-->>Bundler: 执行结果
实战方案设计
为了让示例足够聚焦,我们做一个最小闭环:
SimpleAccount.sol:一个最基础的智能账户SimplePaymaster.sol:一个只允许白名单 sponsor 的 PaymasterMockToken.sol:用于验证账户执行能力send-userop.js:构造并提交 UserOperation
我们要实现的目标
用户没有 ETH,但持有或不持有代币都没关系。只要符合规则:
- 用户签名一笔
UserOperation - Paymaster 允许这笔操作代付
- Bundler 将其打包
- 智能账户完成 Token 转账或目标合约调用
实战代码(可运行)
说明:下面代码是“教学版最小实现”,重点帮助你跑通链路,不是生产级完整钱包。
1)SimpleAccount.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
interface IEntryPoint {
function depositTo(address account) external payable;
}
contract SimpleAccount {
address public owner;
address public entryPoint;
uint256 public nonce;
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 execute(address target, uint256 value, bytes calldata data) external onlyEntryPoint {
(bool ok, bytes memory ret) = target.call{value: value}(data);
require(ok, string(ret));
}
function validateUserOp(
bytes32 userOpHash,
bytes calldata signature,
uint256 _nonce
) external onlyEntryPoint returns (bool) {
require(_nonce == nonce, "bad nonce");
bytes32 ethSigned = prefixed(userOpHash);
address recovered = recoverSigner(ethSigned, signature);
require(recovered == owner, "bad sig");
nonce++;
return true;
}
function addDeposit() external payable {
IEntryPoint(entryPoint).depositTo{value: msg.value}(address(this));
}
function prefixed(bytes32 hash) internal pure returns (bytes32) {
return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
}
function recoverSigner(bytes32 message, bytes memory sig) internal pure returns (address) {
require(sig.length == 65, "bad sig len");
bytes32 r;
bytes32 s;
uint8 v;
assembly {
r := mload(add(sig, 32))
s := mload(add(sig, 64))
v := byte(0, mload(add(sig, 96)))
}
return ecrecover(message, v, r, s);
}
}
这个账户合约只做了三件事:
- 保存
owner - 只允许
EntryPoint调用执行入口 - 在
validateUserOp中验签并递增 nonce
在真实项目里,你通常会接入标准实现,比如:
eth-infinitism/account-abstraction- ZeroDev / Biconomy / Stackup 等 SDK
但先自己写一个最小版本,理解会快很多。
2)SimplePaymaster.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
interface IEntryPointDeposit {
function depositTo(address account) external payable;
}
contract SimplePaymaster {
address public owner;
address public entryPoint;
mapping(address => bool) public whitelist;
modifier onlyOwner() {
require(msg.sender == owner, "only owner");
_;
}
modifier onlyEntryPoint() {
require(msg.sender == entryPoint, "only entrypoint");
_;
}
constructor(address _entryPoint) {
owner = msg.sender;
entryPoint = _entryPoint;
}
receive() external payable {}
function setWhitelist(address account, bool allowed) external onlyOwner {
whitelist[account] = allowed;
}
function validatePaymasterUserOp(address sender) external view onlyEntryPoint returns (bool) {
return whitelist[sender];
}
function addDeposit() external payable {
IEntryPointDeposit(entryPoint).depositTo{value: msg.value}(address(this));
}
}
这个版本极简到只校验白名单。生产中你一般不会这么简单,而是:
- 校验
target是否为指定 dApp - 限制
function selector - 加上时间窗口
- 增加服务端签名
- 增加单用户补贴额度
3)MockToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract MockToken {
string public name = "MockToken";
string public symbol = "MTK";
uint8 public decimals = 18;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
event Transfer(address indexed from, address indexed to, uint256 value);
constructor() {
_mint(msg.sender, 1_000_000 ether);
}
function transfer(address to, uint256 value) external returns (bool) {
require(balanceOf[msg.sender] >= value, "insufficient");
balanceOf[msg.sender] -= value;
balanceOf[to] += value;
emit Transfer(msg.sender, to, value);
return true;
}
function mint(address to, uint256 value) external {
_mint(to, value);
}
function _mint(address to, uint256 value) internal {
totalSupply += value;
balanceOf[to] += value;
emit Transfer(address(0), to, value);
}
}
4)部署脚本 deploy.js
const { ethers } = require("hardhat");
async function main() {
const entryPoint = process.env.ENTRY_POINT_ADDRESS;
if (!entryPoint) {
throw new Error("missing ENTRY_POINT_ADDRESS");
}
const [deployer, walletOwner] = await ethers.getSigners();
const Account = await ethers.getContractFactory("SimpleAccount");
const account = await Account.deploy(walletOwner.address, entryPoint);
await account.waitForDeployment();
const Paymaster = await ethers.getContractFactory("SimplePaymaster");
const paymaster = await Paymaster.deploy(entryPoint);
await paymaster.waitForDeployment();
const Token = await ethers.getContractFactory("MockToken");
const token = await Token.deploy();
await token.waitForDeployment();
await (await paymaster.setWhitelist(await account.getAddress(), true)).wait();
await (await token.mint(await account.getAddress(), ethers.parseEther("100"))).wait();
console.log("SimpleAccount:", await account.getAddress());
console.log("SimplePaymaster:", await paymaster.getAddress());
console.log("MockToken:", await token.getAddress());
console.log("Owner:", walletOwner.address);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
执行:
ENTRY_POINT_ADDRESS=0xYourEntryPoint npx hardhat run scripts/deploy.js --network sepolia
5)构造 UserOperation 并提交 send-userop.js
这里为了兼顾可读性,我用
ethers直接调用 Bundler RPC。不同 Bundler 对字段格式要求可能略有差异,测试时请以对应文档为准。
require("dotenv").config();
const { ethers } = require("ethers");
async function main() {
const bundlerUrl = process.env.BUNDLER_RPC_URL;
const privateKey = process.env.OWNER_PRIVATE_KEY;
const accountAddress = process.env.ACCOUNT_ADDRESS;
const tokenAddress = process.env.TOKEN_ADDRESS;
const recipient = process.env.RECIPIENT_ADDRESS;
const paymasterAndData = process.env.PAYMASTER_AND_DATA || "0x";
const entryPoint = process.env.ENTRY_POINT_ADDRESS;
const provider = new ethers.JsonRpcProvider(process.env.RPC_URL);
const bundler = new ethers.JsonRpcProvider(bundlerUrl);
const owner = new ethers.Wallet(privateKey, provider);
const accountAbi = [
"function nonce() view returns (uint256)"
];
const tokenAbi = [
"function transfer(address to, uint256 value) returns (bool)"
];
const account = new ethers.Contract(accountAddress, accountAbi, provider);
const token = new ethers.Interface(tokenAbi);
const tokenCallData = token.encodeFunctionData("transfer", [
recipient,
ethers.parseEther("1")
]);
const accountExecuteAbi = [
"function execute(address target, uint256 value, bytes data)"
];
const accountInterface = new ethers.Interface(accountExecuteAbi);
const callData = accountInterface.encodeFunctionData("execute", [
tokenAddress,
0,
tokenCallData
]);
const nonce = await account.nonce();
const userOp = {
sender: accountAddress,
nonce: ethers.toBeHex(nonce),
initCode: "0x",
callData,
accountGasLimits: ethers.concat([
ethers.zeroPadValue(ethers.toBeHex(150000), 16),
ethers.zeroPadValue(ethers.toBeHex(300000), 16),
]),
preVerificationGas: ethers.toBeHex(50000),
gasFees: ethers.concat([
ethers.zeroPadValue(ethers.toBeHex(ethers.parseUnits("20", "gwei")), 16),
ethers.zeroPadValue(ethers.toBeHex(ethers.parseUnits("1", "gwei")), 16),
]),
paymasterAndData,
signature: "0x"
};
const userOpHash = await bundler.send("eth_estimateUserOperationGas", [
userOp,
entryPoint
]).then(() => {
return bundler.send("eth_sendUserOperation", [userOp, entryPoint]).catch(() => null);
});
const packedHash = ethers.keccak256(
ethers.AbiCoder.defaultAbiCoder().encode(
["address", "uint256", "bytes"],
[userOp.sender, nonce, callData]
)
);
const signature = await owner.signMessage(ethers.getBytes(packedHash));
userOp.signature = signature;
const opHash = await bundler.send("eth_sendUserOperation", [userOp, entryPoint]);
console.log("UserOperation hash:", opHash);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
这段脚本主要完成:
- 编码目标 Token 的
transfer - 再编码到账户的
execute - 读取账户 nonce
- 构造
UserOperation - 用账户 owner 对哈希签名
- 发送到 Bundler
注意:真实 4337 标准里的
userOpHash计算比这里复杂得多,通常包含 EntryPoint、chainId 和完整结构体哈希。教学里我故意简化了哈希流程,便于你理解签名路径。生产一定要使用标准实现或成熟 SDK。
验证清单:跑通最小闭环
你可以按这个顺序检查:
- EntryPoint 地址正确
- Smart Account 已部署
- Paymaster 已有 deposit
- Paymaster 已将 Smart Account 加白
- Smart Account 已有可转出的 Token
- Bundler RPC 支持对应 EntryPoint 版本
- 用户签名地址与账户 owner 一致
callData编码目标方法无误
如果都没问题,用户即使没有 ETH,也应能完成 Token 转账。
状态流转图:一笔代付操作如何被接受
stateDiagram-v2
[*] --> Draft
Draft --> Signed: 用户签名
Signed --> Simulated: Bundler模拟通过
Simulated --> Sponsored: Paymaster校验通过
Sponsored --> Included: handleOps上链
Included --> Success: execute成功
Included --> Reverted: execute回滚
Reverted --> [*]
Success --> [*]
常见坑与排查
这一部分很重要。我自己第一次接 4337 时,时间基本都花在“不是报错,就是没响应”,而且问题常常不在你以为的地方。
1. Bundler 接受了请求,但迟迟不上链
现象:
eth_sendUserOperation返回了 opHash- 但迟迟查不到执行结果
常见原因:
maxFeePerGas太低- Bundler 的 mempool 策略过滤了你的请求
- Paymaster 余额不足
- 模拟通过,但链上状态变化后实际失败
排查建议:
- 先看 Bundler 的
eth_getUserOperationReceipt - 对比当前链上 base fee
- 检查 Paymaster 和账户在 EntryPoint 的 deposit
- 重新做一次
eth_estimateUserOperationGas
2. 签名总是失败
现象:
validateUserOprevert:bad sig
常见原因:
- 哈希计算和合约验签不一致
- 用了
signTypedData,合约却按signMessage验 - nonce 取错
- 签名地址不是 owner
排查建议:
- 把链下 hash 和链上 hash 打印出来逐个对比
- 明确你使用的是 EIP-191 还是 EIP-712
- 检查签名前后是否修改过
callData - 本地先用
ethers.verifyMessage()反推签名地址
3. Paymaster 校验失败
现象:
- Bundler 模拟时报 paymaster validation failed
常见原因:
paymasterAndData格式不符合目标 Bundler/EntryPoint 版本- Paymaster 白名单未放行
- sponsor 策略与当前
callData不匹配 - Paymaster deposit 不足
排查建议:
- 先把逻辑简化成“只对白名单 sender 放行”
- 用固定
callData跑通后再增加签名校验 - 明确你使用的是哪一版 EntryPoint(0.6/0.7)及字段格式
4. callData 编码错误
现象:
execute调用了,但目标方法 revert- Token 没转出去
常见原因:
- 把目标合约 ABI 写错
execute(target, value, data)中 target 填错- 代币精度错误
- 实际是账户里没 Token
排查建议:
- 先在普通 EOA 下直接调用目标方法,确认目标调用没问题
- 再把同样的
data包进账户execute - 用
interface.encodeFunctionData()不要手写 selector
5. EntryPoint 版本不一致
这是非常容易踩的坑。
症状:
- 字段名对不上
- Bundler 报格式错
- 合约接口调用不兼容
建议:
- 在项目启动阶段就固定一套版本组合:
- EntryPoint 版本
- Bundler 版本
- SDK 版本
- Paymaster 适配逻辑
不要混搭。很多“玄学问题”本质就是版本不一致。
安全最佳实践
账户抽象看起来比 EOA 灵活很多,但攻击面也明显更大。下面这些建议非常实际。
1. 不要让 Paymaster 无条件代付
最危险的做法就是:
- 任意 sender
- 任意 target
- 任意方法
- 任意 gas
这基本等于给攻击者发了张不限额信用卡。
更稳妥的策略:
- 只对白名单 dApp sponsor
- 只允许固定 selector
- 限制单用户每日额度
- 服务端签名里带过期时间和 nonce
- 对高风险调用单独审核
2. 签名域必须绑定链和入口
如果你的签名没有绑定:
chainIdentryPointaccountnonce
就可能被重放到别的环境。
生产中建议:
- 用标准化哈希
- 用 EIP-712 typed data
- 每次签名都包含完整上下文
3. 增加重放保护与会话边界
对于中级项目,我通常建议至少做到:
- 独立 nonce 管理
- 分模块 nonce(如果支持批量/插件)
- sponsor 授权过期时间
- 针对会话密钥的调用范围限制
尤其是做游戏、社交、交易机器人时,会话密钥很好用,但一定要限制:
- 可调合约
- 可调方法
- 金额上限
- 过期时间
4. 账户执行入口要最小化
execute() 是非常敏感的函数,建议:
- 只允许 EntryPoint 调用
- 不要暴露多余的低级调用入口
- 批量执行时限制长度和目标白名单
- 尽量避免随意
delegatecall
如果你要做插件化钱包,那就更要小心存储冲突、权限穿透和升级风险。
性能与成本最佳实践
1. 先优化补贴策略,再谈大规模推广
很多团队一上来就做“新用户 Gas 全免”,最后发现不是用户增长,而是脚本增长。
我更推荐:
- 首笔免费
- 指定任务免费
- 金额门槛免费
- 限定活动期免费
- 对高价值行为补贴,对低价值噪音限流
这类策略最能平衡增长和成本。
2. 减少 callData 和验证复杂度
4337 里的成本不只是执行成本,还包括验证成本。
可优化点:
- 精简账户验签逻辑
- 减少不必要存储读写
- Paymaster 验证尽量轻量
- 避免超长
paymasterAndData
如果验证阶段太重,Bundler 可能也不愿意接你的单。
3. 批量操作不是越多越好
批量调用能提升体验,但也会带来:
- 模拟复杂度提升
- 失败回滚成本上升
- Gas 预估更难
- Paymaster 风险更大
经验上:
- 将强依赖步骤放一起
- 将高失败率步骤拆开
- 对外部依赖多的调用减少批量化
生产落地建议
如果你准备把本文方案推进到真实业务环境,我建议按这个分层思路做。
钱包层
- 直接使用成熟 Smart Account 模板
- 加入模块化权限控制
- 做好升级/不可升级边界设计
基础设施层
- 至少准备两个 Bundler 供应商
- Paymaster 服务端做熔断和限流
- 全链路埋点:签名、模拟、入池、上链、回执
风控层
- 地址画像
- 调用白名单
- 每用户预算控制
- 黑名单与异常流量识别
产品层
- 不要把“免 Gas”当成永久默认能力
- 明确告诉用户哪些操作由平台代付
- 在高成本链和高峰时段动态调整策略
一个更务实的落地架构建议
很多团队会问:到底是全链上 Paymaster 逻辑,还是服务端签名更好?
我的建议通常是:
- 链上做最小校验
- 复杂风控放服务端
- 签名授权带短期有效期
- 链上只验证服务端授权结果
因为复杂策略全写链上,成本高、迭代慢;全放服务端,又会失去可信边界。两者结合通常最稳。
flowchart TB
FE[前端] --> API[Paymaster服务端]
API --> Risk[风控策略]
Risk --> Sign[签发代付授权]
Sign --> FE
FE --> Bundler[Bundler RPC]
Bundler --> Entry[EntryPoint]
Entry --> PM[链上Paymaster最小校验]
Entry --> SA[Smart Account]
逐步验证清单
如果你想把接入过程拆成可控步骤,我建议这样推进:
第一步:先不用 Paymaster
目标是验证:
- Smart Account 能否验签
- Bundler 能否收单
- EntryPoint 能否执行
第二步:再接最简 Paymaster
目标是验证:
- sponsor 通路是否打通
- deposit 是否生效
- paymaster validation 是否符合版本规范
第三步:最后接服务端风控签名
目标是验证:
- 服务端签名是否可过链上校验
- 授权是否能过期
- 白名单/额度限制是否有效
第四步:压测和滥用测试
目标是验证:
- 高频 userOp 是否触发限流
- 异常 target 是否被拦截
- 单地址刷补贴是否能识别
总结
EIP-4337 真正改变的,不只是“用户不用自己付 Gas”,而是把钱包从“私钥工具”变成了“可编排账户系统”。
如果你是中级开发者,我建议你把落地路径记成这三步:
- 先跑通最小 Smart Account
- 再接通 Bundler + EntryPoint
- 最后做 Paymaster 的策略化代付
同时记住几个边界条件:
- 教学版代码能帮助理解,但生产必须用标准实现
- EntryPoint/Bundler/SDK 版本要严格对齐
- Gas 代付不是简单补贴,而是风控问题
- 最重要的不是“能不能代付”,而是“代付给谁、代付什么、代付多久”
如果你的业务目标是提升 Web2 用户首转化,4337 非常值得做;但如果你的业务还没建立清晰的风控和预算机制,贸然开放 Gas 代付,成本和风险都会迅速放大。
一句话收尾:
账户抽象的价值,不在于把复杂性藏起来,而在于把复杂性变成你可控的产品能力。