Web3 中级实战:基于 EIP-4337 实现智能账户与 Gas 代付的钱包接入方案
EIP-4337 这两年几乎成了“钱包体验升级”的代名词。很多团队想做的事情其实很朴素:让用户别再一上来就被 Gas、助记词、原生币充值这些问题劝退。而 EIP-4337 提供了一条不改共识层、能渐进接入的路线:把账户逻辑从传统 EOA 外置账户,迁移到更可编程的智能账户(Smart Account)。
这篇文章我不打算只讲概念,而是从接入方视角来做一遍:
你会看到一个最小可运行方案,完成这几件事:
- 创建智能账户
- 通过
UserOperation发起交易 - 使用
Paymaster实现 Gas 代付 - 理解 Bundler / EntryPoint / Factory 的协作关系
- 排查最常见的 4337 接入问题
如果你已经会写基础 Solidity,了解 ethers.js,并且知道 ERC-20 / 合约调用是怎么回事,这篇内容会比较适合你。
前置知识
在开始之前,先确认你至少熟悉这些概念:
- EOA 与合约账户的区别
- Solidity 基础合约开发
ethers.js基本调用- Ethereum 交易的 gas、nonce、签名
- JSON-RPC 的基本使用
如果你没接触过 EIP-4337,也没关系,我会先把它拆开讲。
背景与问题
传统 Web3 钱包体验,问题经常卡在第一步:
- 用户必须持有原生币(如 ETH)支付 Gas
- 新用户要先创建 EOA,再妥善保存助记词
- 钱包逻辑固定,难以做权限控制、社交恢复、批量执行
- dApp 接入时,常常只能“让用户自己处理链上细节”
这些限制不是产品想象力不够,而是EOA 天生功能有限。
EOA 本质上就是“由私钥控制的账户”,签什么、发什么,全靠外部客户端拼交易。
而智能账户的思路是:
把账户本身做成一个合约,让验证规则、执行逻辑、权限体系都变成可编程。
EIP-4337 的价值就在这里:
它不要求你修改以太坊底层协议,而是在应用层引入一套新交易流程,让“账户抽象”能先跑起来。
核心原理
先给一个全景图。
flowchart LR
U[用户 / 钱包前端] --> A[Smart Account SDK]
A --> B[UserOperation]
B --> C[Bundler]
C --> D[EntryPoint]
D --> E[Smart Account]
D --> F[Paymaster]
E --> G[目标合约]
1. 关键角色
Smart Account
一个智能合约账户。
它不再像 EOA 那样只能由私钥直接发交易,而是由合约中的验证逻辑决定:谁能签、怎么验、能不能批量执行、是否允许 session key 等。
UserOperation
4337 不是直接发传统交易,而是提交一个 UserOperation。
你可以把它理解成“用户意图 + 执行参数 + 验证材料”的结构体。
Bundler
Bundler 类似“4337 交易打包服务”。
它收集多个 UserOperation,调用 EntryPoint.handleOps() 上链。
EntryPoint
4337 的核心入口合约。
负责统一校验与执行 UserOperation。
Paymaster
Gas 代付服务方。
它可以为用户支付 gas,常见场景有:
- 新用户免首笔 Gas
- 平台代付
- 用 ERC-20 代替原生币支付费用
- 风控后有条件放行
Factory
用于按需部署智能账户。
很多实现会用“Counterfactual Address”思路:用户账户地址先算出来,第一次操作时再真正部署。
2. 一次调用是怎么发生的
sequenceDiagram
participant User as 用户
participant App as dApp/前端
participant SDK as 4337 SDK
participant PM as Paymaster
participant Bundler as Bundler
participant EP as EntryPoint
participant SA as Smart Account
participant Target as 目标合约
User->>App: 点击发起操作
App->>SDK: 构造 UserOperation
SDK->>PM: 请求 paymasterData
PM-->>SDK: 返回签名/额度许可
SDK->>Bundler: eth_sendUserOperation
Bundler->>EP: handleOps()
EP->>SA: validateUserOp()
EP->>PM: validatePaymasterUserOp()
EP->>SA: execute()
SA->>Target: 调用目标合约
Bundler-->>App: 返回 userOpHash
3. 验证与执行分离
EIP-4337 的一个重要思想是:
先验证,再执行。
- 验证阶段:校验签名、nonce、余额、Paymaster 资助资格等
- 执行阶段:真正调用账户或目标合约
这带来的好处是:
- 钱包逻辑可以灵活定义
- Gas 赞助逻辑可以单独扩展
- Bundler 能提前模拟,降低失败率
环境准备
下面我给一个“最小可运行”的示例工程思路。为了尽量贴近真实接入,我采用:
- Solidity:实现简单智能账户、Factory、Paymaster
- Node.js + ethers:构造并发送调用
- Hardhat:本地开发与测试
依赖安装
mkdir eip4337-smart-wallet-demo
cd eip4337-smart-wallet-demo
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install ethers dotenv
初始化 Hardhat:
npx hardhat
选择一个基础 JavaScript 项目即可。
实战代码(可运行)
说明:
为了让示例更容易理解,下面的代码实现的是简化版智能账户模型。
它体现的是接入思路与关键机制,不是生产级完整 4337 钱包。
真正接入主网或测试网时,建议基于成熟实现,例如 ZeroDev、Biconomy、Stackup、Alchemy AA SDK,或者参考 eth-infinitism 的账户实现。
第一步:实现一个最小智能账户
新建 contracts/SimpleSmartAccount.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SimpleSmartAccount {
address public owner;
uint256 public nonce;
address public entryPoint;
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;
}
function validateUserOp(
bytes32 userOpHash,
bytes calldata signature,
uint256 expectedNonce
) external view onlyEntryPoint returns (bool) {
require(expectedNonce == nonce, "bad nonce");
bytes32 ethSigned = keccak256(
abi.encodePacked("\x19Ethereum Signed Message:\n32", userOpHash)
);
return recoverSigner(ethSigned, signature) == owner;
}
function execute(
address target,
uint256 value,
bytes calldata data
) external onlyEntryPoint {
nonce++;
(bool ok, ) = target.call{value: value}(data);
require(ok, "call failed");
emit Executed(target, value, data);
}
function executeByOwner(
address target,
uint256 value,
bytes calldata data
) external onlyOwner {
nonce++;
(bool ok, ) = target.call{value: value}(data);
require(ok, "call failed");
emit Executed(target, value, data);
}
function changeOwner(address newOwner) external onlyOwner {
require(newOwner != address(0), "zero addr");
emit OwnerChanged(owner, newOwner);
owner = newOwner;
}
receive() external payable {}
function recoverSigner(bytes32 hash, bytes memory sig) internal pure returns (address) {
require(sig.length == 65, "bad sig length");
bytes32 r;
bytes32 s;
uint8 v;
assembly {
r := mload(add(sig, 32))
s := mload(add(sig, 64))
v := byte(0, mload(add(sig, 96)))
}
if (v < 27) {
v += 27;
}
require(v == 27 || v == 28, "bad v");
return ecrecover(hash, v, r, s);
}
}
这个账户做了三件事:
- 记录
owner - 校验签名和
nonce - 由
entryPoint触发执行
这里我保留了 executeByOwner(),便于本地验证。生产里通常会更加严格。
第二步:实现账户工厂
新建 contracts/SimpleAccountFactory.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "./SimpleSmartAccount.sol";
contract SimpleAccountFactory {
event AccountCreated(address indexed owner, address account);
function createAccount(address owner, address entryPoint) external returns (address) {
SimpleSmartAccount account = new SimpleSmartAccount(owner, entryPoint);
emit AccountCreated(owner, address(account));
return address(account);
}
}
Factory 的作用很直观:帮你部署账户。
第三步:实现一个简化 EntryPoint
新建 contracts/SimpleEntryPoint.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface ISimpleSmartAccount {
function validateUserOp(
bytes32 userOpHash,
bytes calldata signature,
uint256 expectedNonce
) external view returns (bool);
function execute(
address target,
uint256 value,
bytes calldata data
) external;
}
interface ISimplePaymaster {
function validateSponsor(
address sender,
address target,
bytes calldata data
) external view returns (bool);
function postOp(address bundler, uint256 gasCost) external;
}
contract SimpleEntryPoint {
struct UserOperation {
address sender;
address target;
uint256 value;
bytes data;
uint256 nonce;
bytes signature;
address paymaster;
}
event UserOperationHandled(bytes32 indexed userOpHash, address indexed sender, bool success);
function getUserOpHash(UserOperation calldata op) public pure returns (bytes32) {
return keccak256(
abi.encode(
op.sender,
op.target,
op.value,
keccak256(op.data),
op.nonce,
op.paymaster
)
);
}
function handleOp(UserOperation calldata op) external {
bytes32 userOpHash = getUserOpHash(op);
bool valid = ISimpleSmartAccount(op.sender).validateUserOp(
userOpHash,
op.signature,
op.nonce
);
require(valid, "invalid userop");
if (op.paymaster != address(0)) {
bool sponsorOk = ISimplePaymaster(op.paymaster).validateSponsor(
op.sender,
op.target,
op.data
);
require(sponsorOk, "paymaster rejected");
}
ISimpleSmartAccount(op.sender).execute(op.target, op.value, op.data);
if (op.paymaster != address(0)) {
ISimplePaymaster(op.paymaster).postOp(msg.sender, 0);
}
emit UserOperationHandled(userOpHash, op.sender, true);
}
}
这里我们模拟了最核心的流程:
- 校验签名
- 可选调用 Paymaster 审核
- 执行目标调用
- 触发
postOp
注意:真实 EIP-4337 的
EntryPoint比这个复杂很多,包含 gas accounting、prefund、simulation、aggregator 等。这个版本的目标是“把机制跑通”。
第四步:实现一个最小 Paymaster
新建 contracts/SimplePaymaster.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SimplePaymaster {
address public owner;
mapping(address => bool) public whitelist;
event Sponsored(address indexed sender, address indexed bundler, uint256 gasCost);
modifier onlyOwner() {
require(msg.sender == owner, "only owner");
_;
}
constructor() {
owner = msg.sender;
}
function setWhitelist(address user, bool allowed) external onlyOwner {
whitelist[user] = allowed;
}
function validateSponsor(
address sender,
address,
bytes calldata
) external view returns (bool) {
return whitelist[sender];
}
function postOp(address bundler, uint256 gasCost) external {
emit Sponsored(tx.origin, bundler, gasCost);
}
}
这个 Paymaster 做得很简单:
白名单用户可以被代付。
真实业务里,你可能会加这些策略:
- 每日额度
- 首笔免 Gas
- 仅允许指定合约调用
- ERC-20 扣费结算
- 风控签名校验
第五步:部署一个目标合约用于验证
新建 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);
}
}
第六步:编写部署脚本
新建 scripts/deploy.js:
const hre = require("hardhat");
async function main() {
const [deployer, user] = await hre.ethers.getSigners();
const EntryPoint = await hre.ethers.getContractFactory("SimpleEntryPoint");
const entryPoint = await EntryPoint.deploy();
await entryPoint.waitForDeployment();
const Factory = await hre.ethers.getContractFactory("SimpleAccountFactory");
const factory = await Factory.deploy();
await factory.waitForDeployment();
const Paymaster = await hre.ethers.getContractFactory("SimplePaymaster");
const paymaster = await Paymaster.deploy();
await paymaster.waitForDeployment();
const Counter = await hre.ethers.getContractFactory("Counter");
const counter = await Counter.deploy();
await counter.waitForDeployment();
const tx = await factory.createAccount(user.address, await entryPoint.getAddress());
await tx.wait();
console.log("deployer:", deployer.address);
console.log("user:", user.address);
console.log("entryPoint:", await entryPoint.getAddress());
console.log("factory:", await factory.getAddress());
console.log("paymaster:", await paymaster.getAddress());
console.log("counter:", await counter.getAddress());
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
执行:
npx hardhat compile
npx hardhat run scripts/deploy.js
第七步:构造并提交一个“用户操作”
为了方便本地演示,我们不引入真正 Bundler,而是直接由一个脚本模拟 Bundler 调用 EntryPoint.handleOp()。
新建 scripts/sendUserOp.js:
const hre = require("hardhat");
const { ethers } = hre;
async function main() {
const [bundler, user] = await ethers.getSigners();
const entryPointAddr = process.env.ENTRY_POINT;
const accountAddr = process.env.ACCOUNT;
const counterAddr = process.env.COUNTER;
const paymasterAddr = process.env.PAYMASTER;
const entryPoint = await ethers.getContractAt("SimpleEntryPoint", entryPointAddr);
const counter = await ethers.getContractAt("Counter", counterAddr);
const paymaster = await ethers.getContractAt("SimplePaymaster", paymasterAddr);
// 白名单代付
let tx = await paymaster.connect(bundler).setWhitelist(accountAddr, true);
await tx.wait();
const data = counter.interface.encodeFunctionData("increment");
const userOp = {
sender: accountAddr,
target: counterAddr,
value: 0,
data: data,
nonce: 0,
signature: "0x",
paymaster: paymasterAddr,
};
const userOpHash = await entryPoint.getUserOpHash(userOp);
const signature = await user.signMessage(ethers.getBytes(userOpHash));
userOp.signature = signature;
tx = await entryPoint.connect(bundler).handleOp(userOp);
const receipt = await tx.wait();
const number = await counter.number();
console.log("userOpHash:", userOpHash);
console.log("txHash:", receipt.hash);
console.log("counter.number:", number.toString());
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
这里有一个关键细节:
SimpleSmartAccount 的 owner 必须是 user.address,而不是 accountAddr 本身。所以创建账户时,owner 应该传入用户 EOA 地址。
第八步:拿到账户地址并执行
由于上面的 Factory 没有直接返回部署结果到脚本变量,实际操作中最方便的方法是:
- 从部署日志或链上事件里取出账户地址
- 设置环境变量
创建 .env:
ENTRY_POINT=0xYourEntryPointAddress
ACCOUNT=0xYourSmartAccountAddress
COUNTER=0xYourCounterAddress
PAYMASTER=0xYourPaymasterAddress
执行:
npx hardhat run scripts/sendUserOp.js
如果一切正常,你会看到:
userOpHash: 0x...
txHash: 0x...
counter.number: 1
这就表示:
- 用户没有直接发传统交易
- 而是签了一个
UserOperation - 由 EntryPoint 统一执行
- Paymaster 参与了“代付许可”
接入真实 4337 SDK 的思路
上面的示例偏“机制演示”。如果你要给产品做正式钱包接入,通常不会自己从零实现 EntryPoint,而是走成熟基础设施。
更现实的架构通常长这样:
flowchart TD
FE[前端 dApp] --> SDK[4337 钱包 SDK]
SDK --> RPC[普通 RPC 节点]
SDK --> BDL[Bundler API]
SDK --> PM[Paymaster API]
BDL --> EP[官方 EntryPoint]
EP --> SA[智能账户实现]
PM --> EP
前端典型接入步骤
- 用户登录
- EOA
- 社交登录
- MPC / Passkey
- 生成或绑定 smart account
- 构造目标调用数据
- 请求 Paymaster sponsorship
- 发送
eth_sendUserOperation - 轮询
userOpHash执行状态 - 在 UI 中展示“已提交 / 打包中 / 已上链 / 失败原因”
用 SDK 的伪代码示例
下面这个例子是抽象写法,用来帮助你理解接口层次,不绑定特定厂商:
import { ethers } from "ethers";
async function sendWith4337({
provider,
ownerSigner,
smartAccount,
bundlerClient,
paymasterClient,
target,
data,
}) {
const nonce = await smartAccount.getNonce();
let userOp = {
sender: await smartAccount.getAddress(),
callData: await smartAccount.encodeExecute(target, 0, data),
nonce,
callGasLimit: 300000,
verificationGasLimit: 200000,
preVerificationGas: 50000,
maxFeePerGas: ethers.parseUnits("20", "gwei"),
maxPriorityFeePerGas: ethers.parseUnits("1", "gwei"),
paymasterAndData: "0x",
signature: "0x",
};
const sponsored = await paymasterClient.sponsorUserOperation(userOp);
userOp.paymasterAndData = sponsored.paymasterAndData;
const userOpHash = await smartAccount.getUserOpHash(userOp);
userOp.signature = await ownerSigner.signMessage(ethers.getBytes(userOpHash));
const res = await bundlerClient.sendUserOperation(userOp);
return res.userOpHash;
}
真正接入时,你要重点看 SDK 是否封装了以下能力:
- 自动估算 gas
- 自动构造 initCode
- 多链 EntryPoint 管理
- paymasterAndData 填充
- session key / batched calls
- userOp 状态查询
逐步验证清单
我建议你按这个顺序验,不容易乱:
验证 1:目标合约能被普通交易调用
先确认 Counter.increment() 本身没问题。
验证 2:智能账户 owner 配置正确
最常见错误就是部署账户时 owner 传错,导致签名永远过不了。
验证 3:userOpHash 前后端计算一致
如果前端签的是 A,合约验的是 B,那一定失败。
验证 4:nonce 从 0 开始、单调递增
重复 nonce 会直接失败。
验证 5:Paymaster 放行逻辑正确
白名单、调用目标、额度限制要逐项验证。
验证 6:最终目标调用 data 编码正确
很多失败看起来像签名错,实际上是 abi.encodeFunctionData 编错了。
常见坑与排查
这部分非常重要。我自己第一次调 4337 时,真正花时间的地方不是“写代码”,而是“到底哪一步没对上”。
1. 签名正确但仍然校验失败
现象
validateUserOp() 返回 false,或者 EntryPoint 报 invalid userop
常见原因
- 用了不同的
userOpHash计算方式 signMessage()自动加了 EIP-191 前缀,但合约没按同样方式恢复nonce不一致- owner 地址不是实际签名人
排查建议
- 把合约侧
userOpHash打印出来 - 把脚本侧 hash 打印出来
- 明确是否使用
eth_sign/personal_sign/ EIP-712 - 用最简单签名路径先跑通,再升级到 typed data
2. Paymaster 明明白名单了,但还是拒绝
现象
报错 paymaster rejected
常见原因
- 你白名单的是 EOA,但
sender实际上是 Smart Account 地址 - Paymaster 策略限制了目标合约或 calldata
- 前端构造
paymasterAndData时数据版本不对
排查建议
- 明确 sponsor 的对象是谁:
owner还是smart account - 把校验条件拆开记录日志
- 先去掉复杂风控,只保留白名单判断
这个坑我很想强调:
4337 里的sender是智能账户地址,不是用户 EOA。
很多团队第一次做代付时,白名单配错对象,结果怎么看都“不应该失败”。
3. Bundler 模拟通过,但链上执行失败
现象
本地/预执行正常,上链却 revert
常见原因
- 链上状态变化,模拟时的条件失效
- gas 估算不足
- Paymaster 额度在两个区块间被其他请求消耗
- 目标合约内部有依赖
msg.sender、block.timestamp、余额等敏感逻辑
排查建议
- 记录 simulation 时的区块号
- 给关键逻辑留更保守的 gas buffer
- 避免 sponsor 额度被并发抢占
- 检查目标合约是否假定调用方一定是 EOA
4. 智能账户首次使用时部署失败
现象
第一次发操作失败,后续地址也不对
常见原因
initCode编码不对- Factory 地址错
- create2 salt 不一致
- 预计算地址和实际部署参数不一致
排查建议
- 先不做 counterfactual,直接部署一个账户跑通
- 再加入 Factory + initCode
- 每一步都对比预期地址和链上地址
安全/性能最佳实践
4337 的灵活性很强,但也意味着“你可以很容易把系统做复杂”。下面这些建议比较实用。
安全最佳实践
1. 不要自己魔改官方 EntryPoint 语义
生产环境建议尽量贴近主流实现。
EntryPoint 是整个安全边界的核心,随意改动会让 SDK、Bundler、审计经验全部失效。
2. 签名域必须明确
推荐逐步升级到更规范的签名方案,例如 EIP-712。
简单的 signMessage 虽然便于演示,但生产中容易出现跨链、跨合约重放风险。
3. nonce 设计不要过于单一
如果账户支持批量操作、并行 session、插件执行,单一 nonce 很快会成为性能瓶颈。
可以考虑 key-based nonce 或多维 nonce 结构。
4. Paymaster 必须做额度与目标限制
不要只凭“用户在白名单”就无限代付。
至少限制:
- 每日/每周额度
- 允许调用的合约范围
- 允许的方法签名
- 单笔 gas 上限
- 黑名单风控
5. 谨慎开放 execute
账户合约里如果执行入口过于宽松,很容易被构造恶意调用。
建议:
- 所有入口都走统一验证
- 明确只允许 EntryPoint 触发
- 变更 owner / guardian 的操作单独做更严格保护
性能最佳实践
1. 减少验证阶段的存储读取
validateUserOp() 越重,Bundler 越不喜欢,gas 也越高。
尽量减少复杂逻辑和外部调用。
2. 批量操作适合合并
智能账户很适合做 batched calls。
如果用户一次要授权 + swap + stake,尽量合并,提升体验并减少总成本。
3. 代付审核尽量前置到链下
Paymaster 很多风控应在链下完成,只把必要校验信息上链。
否则 sponsor 成本和复杂度都会膨胀。
4. 为失败设计观测性
至少打通这些日志:
- userOpHash
- sender
- paymaster decision
- simulation result
- bundler response
- on-chain revert reason
没有这些日志时,4337 问题真的很难追。
方案落地建议
如果你是 dApp 团队,不一定要自己造整套钱包基础设施。更现实的分层思路是:
适合自研的部分
- 钱包产品体验
- 智能账户权限模型
- Paymaster 赞助策略
- 风控与计费体系
适合复用基础设施的部分
- Bundler 服务
- 标准 EntryPoint 对接
- 多链 gas 估算
- userOp 状态追踪
- 兼容性测试
一个常见组合
- 前端:自定义登录和交易体验
- 智能账户:基于成熟实现扩展权限
- Bundler:采购现成服务
- Paymaster:自研,绑定你的业务规则
这通常是成本、风险、可控性比较均衡的路线。
总结
EIP-4337 最值得你抓住的,不是“它很新”,而是它真正解决了钱包接入中的几个老问题:
- 用户不必先准备原生币
- 账户能力从固定变成可编程
- dApp 可以更主动地设计交易体验
- Gas 代付、批量执行、权限分级都有了统一入口
这篇文章我们完成了一个从零到一的最小实践:
- 写了一个简化版 Smart Account
- 用 EntryPoint 模拟 4337 调度流程
- 用 Paymaster 做白名单式 Gas 代付
- 用脚本构造并执行
UserOperation - 复盘了接入中最容易踩的坑
如果你接下来准备上真实测试网,我的建议是:
- 先用成熟 SDK 跑通端到端流程
- 再把 Paymaster 策略替换成你的业务规则
- 最后再考虑账户权限扩展,比如 session key、批量执行、恢复机制
边界条件也要明确:
- 如果你的产品只需要普通签名和简单转账,4337 不一定是第一优先级
- 如果你关注新用户转化、免 Gas、复杂权限,4337 非常值得投入
- 如果你要上生产,不要把本文的简化合约直接用于主网
一句话总结:
EIP-4337 不是单纯换一种钱包,而是在把“账户”变成你的产品能力。
如果你正在做钱包、游戏、社交、交易聚合器,越早理解这一点,后面的架构选择就越不容易走弯路。