lucifer

先说一个很多人都遇到过的尴尬:钱包里躺着几百 USDC,想转一点给朋友,界面却弹出一行字——“ETH 余额不足,无法支付 gas”。你明明有钱,却连手续费都交不起,像揣着一沓现金站在只收硬币的售货机前。更绝望的是另一种场景:抄助记词的那张纸丢了。没有”忘记密码”,没有客服,没有申诉入口,链上资产从此变成一座纪念碑。

这些尴尬不怪钱包 App 做得烂,怪以太坊账户模型的”出厂设置”。《Web3 的入口 - 区块链钱包》里介绍过的私钥账户叫 EOA(Externally Owned Account,外部账户),你可以把它理解成一把钥匙管一切的旧式机械门锁:一把钥匙就是全部,丢了就完了,没有挂失,没有备用方案。而账户抽象(Account Abstraction,AA)想干的事,说白了就一件:把这把旧门锁换成智能门锁——账户从”一把私钥”升级成”一段可编程的合约”,”怎么验身份、怎么付 gas、怎么执行”,全由代码说了算。

熟悉我的朋友应该知道,我经常从零实现一些东西帮助理解,本文照例代码驱动:先用 Python 模拟 ERC-4337 里一个 UserOperation 从提交到结算的完整生命周期,再模拟社交恢复与 guardian 串通攻击,中间穿插 EIP-7702 的机制讲解,所有输出均为真实运行结果,纯标准库,复制即可跑。

EOA:一把钥匙管一切的旧门锁

EOA 就是”一把私钥对应一个地址”,链上最常见的账户形态,也是绝大多数 web3 事故的案发现场。它到底哪里不行?拿你家那把老式机械门锁对比一下,你立刻就懂了。三宗罪:

罪一:钥匙丢了,家就没了。 现实里银行卡丢了能挂失补办,门锁钥匙丢了能找师傅换锁芯。EOA 没有这套服务——地址是由私钥推导出来的,换钥匙等于换账户、等于搬家。所以私钥一丢,资产永久冻结;一被偷,瞬间搬空。谁捡到钥匙谁就是主人,进门无声、不可逆,连”改密码”这个选项都不存在。

罪二:交物业费只收现金。 协议规定,手续费只能从交易发起者自己的 ETH 余额里扣,gas 的来龙去脉见《Gas:链上一切行为的价格标签》。就像一个只收现金、还不收人民币以外任何东西的物业——你手机里有的是余额,没用,人家不认。所以”一堆 USDC 加零 ETH”在链上等于寸步难行,微信代缴?没这功能。

罪三:进门规则只有一种。 EOA 的验证逻辑就一条:签名对,放行。你想加一条”每天最多转 100””只许转白名单地址””小孩只能进书房”?没地方写,这把锁出厂时就没留这个接口。

三宗罪的病根是同一个:EOA 的账户逻辑焊死在协议层,谁也改不了。而智能合约的逻辑是代码,想怎么写就怎么写。把账户从”一把私钥”变成”一段合约”,上面这些限制就都变成了”几行代码的事”——这就是智能合约账户。两边摆在一起看更直观:

EOA 与智能合约账户对比:左边一把钥匙配一扇死门,右边智能门锁自带恢复、限额、代付

换上智能门锁,日子能过成什么样

先别急着看机制,看看换锁之后的具体好处——每一条,都对应旧锁的一宗罪:

  • 社交恢复:钥匙丢了?让几个你信任的人投票,配一把新的,资产原地不动。丢钥匙 ≠ 丢钱,怎么做到的下一节细讲。
  • 批量调用:approve + swap 一次签名、原子执行,成则全成,败则全败。还记得《Web3 中的授权与撤销授权》里那笔单独的 approve 交易吗?没了——进门、开灯、撤防盗一个动作全办完。
  • gas 代付:应用方替你垫 ETH,你用 USDC 结账,甚至完全免费——物业费有人代缴,新用户零门槛入场。
  • 消费限额 / 白名单:单日限额、大额延迟到账、只许调用指定合约。”小孩只能进书房”这句话,终于可以写进锁里了。私钥就算被钓鱼,损失也被框死。
  • session key:给游戏发一把”临时小钥匙”:只能调用这个游戏合约、最多花 5 USDC、两小时后自动过期——像给钟点工办的临时门禁卡,到点自动作废。再也不用每砍一刀怪就签一次名。

好,愿景画完了。问题是怎么实现——这就引出了本文的主角之一。

ERC-4337:不改协议,先点一份外卖

最直接的思路是改协议,让合约账户也能发起交易。这条路社区从 2017 年就开始铺(Vitalik 的 EIP-86,后来的 EIP-2938 等一批提案),但全部阵亡——动共识层就是硬分叉,所有客户端一起改,协调成本高到没人敢接盘。

2021 年提出的 ERC-4337 换了个思路:协议一行不改,在应用层再造一套”交易系统”。它不依赖任何硬分叉,靠部署一组合约实现,2023 年 3 月在主网生效。

这套系统一共五个角色,第一次读文档的人十个有九个绕晕。别慌,用点外卖串一遍,五分钟记牢:

  • UserOperation(UserOp)=你写的订单。它不是交易,是一张”意图单”:sender 是你家门牌号(你的账户合约地址),callData 写着”要一份鱼香肉丝”(想执行的调用),nonce 是订单号、防重复下单,signature 是你的亲笔签名。
  • Bundler =跑腿小哥。蹲在接单大厅(专用内存池 alt mempool)里抢单,把一批订单合并成一次配送,塞进一笔真正的交易上链。小哥先垫配送费(gas),事后报销——他自己就是个普通 EOA。
  • EntryPoint =外卖平台。全局唯一的入口合约,整套系统的裁判。平台负责核验订单真伪:逐单验签、查订单号防重放、确认付得起配送费,然后调度你家门锁开工。
  • Account =你家的智能门锁。订单送到后,门锁自己判断”这单是不是主人下的”——判断逻辑写在你的合约里(validateUserOp 函数),单签、多签、passkey、社交恢复,随便你定义。
  • Paymaster =会员免运费。可选角色,声明”这单配送费我出”;平台核验时还会问它一句”这单你认吗”,事后再向用户收 USDC 当运费——相当于代金券抵扣。

一张订单的一生

一张 UserOp 从提交到结算,就是一次外卖的旅程:

  1. 钱包构造订单(UserOp),你签字,扔进接单大厅 alt mempool——它独立于普通交易池,带严格的准入规则(限制验证阶段可访问的状态和操作码),防止有人用”模拟通过、上链必挂”的订单白嫖小哥,也就是防 DoS。
  2. 跑腿小哥抢单,先在本地把每张订单模拟跑一遍,会失败或亏本的一律丢掉,剩下的打包成一笔交易,调用 EntryPoint.handleOps 送单。
  3. 平台核验订单:EntryPoint 逐个调用账户的 validateUserOp(验签、检查 nonce 防重放、确认付得起 gas);带了 Paymaster 的,再问它一句”这单你认吗”。
  4. 门锁收货开工:EntryPoint 逐个把 callData 交给账户合约执行,真正的转账、swap 在这一步发生。
  5. 结账:有 Paymaster 的调用它的 postOp 做事后结算(比如向你收 USDC);EntryPoint 从账户或 Paymaster 的预存款里扣掉实际 gas 付给小哥,并发出事件回执。

两个细节值得记住:第一,Paymaster(以及多数账户)的 gas 钱是预存在 EntryPoint 合约里的,所以你的”ETH 余额”可以一直是零——运费早就在平台充值过了;第二,验证失败会让整笔 bundle 回滚、小哥白付 gas——这就是小哥抢单前必须先模拟的原因,赔本的配送他不接。

ERC-4337 的点外卖流程:你写订单、跑腿小哥抢单合并、平台验单、会员代付运费、智能门锁收货

钥匙丢了怎么办:五个保管人里三个点头

智能合约账户最有杀伤力的能力,值得单独拆开讲。

先想一个现实场景:手机丢了,你不会崩溃——去运营商门店验个身份,补一张卡,原来的号码照用。社交恢复干的就是这件事,只不过运营商换成了你自己的亲友团。用家门钥匙的语言说:账户合约里除了 owner(你天天揣着的签名钥匙),还登记一组 guardian(守护人)和一个阈值,比如 3-of-5——家门钥匙丢了,5 个保管人里 3 个点头,就能给你配把新钥匙。

但这里有一层精妙的设计,画个重点:保管人凑再多人,也只能帮你换锁,进不了你家门。转账入口只认 owner 的签名,guardian 的签名在那里一文不值——5 个保管人加起来,也搬不走你家一台电视。

恢复流程走一遍:钥匙丢了 → 生成一把新钥匙 → 请 guardian 对”把 owner 换成新地址”这件事签名 → 凑够 3 个 → 合约进入宽限期(timelock) → 到期生效,旧钥匙作废。全程资产不动、地址不变,变的只是”谁有权签名”。常见的实现还会给现任 owner 一个否决权,防止 guardian 趁你失联时偷换。

guardian 怎么选?原则是分散、类型混合:亲友可能被社工,自己的另一台设备可能和手机一起丢,机构服务可能倒闭,各有各的失效模式,混着配才不会一锅端。全选同一类,等于把鸡蛋放回同一个篮子。

那 guardian 串通怎么办?对比一下两条路的”作案体验”就明白了。guardian 想摸钱,必须先走”换 owner”这条路,而这条路是公开的(链上可见)、慢的(有宽限期)、可否决的(现任 owner 能取消);私钥被盗呢?瞬间、无声、不可逆。但也别把这读成”guardian 串通无害”——如果宽限期内没人发现,多数 guardian 确实能夺走账户,所以阈值、宽限期、异常提醒三样缺一不可。这套取舍,下面的代码会完整演示。

社交恢复示意:5 个 guardian 中 3 个签名即可换新钥匙,但 guardian 只能换锁、搬不走家里的资产

EIP-7702:老房子不动产权证,请个专业管家

4337 很优雅,但有个尴尬:它的账户是一个新的合约地址。你用了五年的 EOA,上面的资产、授权、NFT、链上履历,全得搬家。让存量用户换地址,就像让用户换手机号,阻力巨大。

EIP-7702 专治这个,2025 年 5 月随 Pectra 升级上线主网。打个比方:老房子不动产权证不变,给房子请了个专业管家。它新增了一种交易类型(0x04),交易里带一个授权列表:EOA 的主人用私钥签一条授权——“把我的账户代码指向合约 X”。授权生效后,EOA 的代码位置会被写入一个委托标记:0xef0100 || 合约地址(共 23 字节)。此后任何人调用这个 EOA,跑的都是合约 X 的代码,但读写的是 EOA 自己的存储和余额——门牌号没变,房子还是你的房子,只是日常打理换成了专业管家。

再强调一遍我在 approve 那篇里特别指出的事实:这个管家是长期聘用,不是干一单就走人的钟点工。”交易期间临时获得合约能力、用完自动恢复”这种流传甚广的说法,与最终上线的行为不符——委托在交易结束后持久存在,会一直挂在账户上,直到你再签一笔 7702 授权把它改掉或清空(指向零地址)。所以签 7702 授权 ≈ 把账户的执行逻辑长期交给那个合约,委托给恶意合约等同于把家门钥匙连同规章制度一起交出去,钱包对这类签名必须给出醒目提示。

还有个容易被忽略的事实:原私钥依然有效,而且权限最高——它随时可以换管家、发普通交易。所以 7702 给了你批量调用、gas 代付、限额这些功能,却治不了私钥单点的病根:钥匙被偷,小偷照样能把管家换掉。想根治,终点站还是带社交恢复的合约账户。

7702 与 4337 不是竞争关系,而是上下游:委托指向的合约完全可以是一个兼容 4337 的账户实现,于是老 EOA 也能发订单、用免运费。一句话:7702 解决”存量房子怎么请管家”,4337 提供”请来管家之后的基础设施”。

4337 vs 7702:一张表看懂怎么选

机制都讲完了,选型是道送分题:

维度 ERC-4337 EIP-7702
账户形态 独立的合约账户 EOA 上挂一个委托标记
要不要换地址 要,新账户是新合约地址 不要,原地址原地升级
初始成本 要部署账户合约(可用工厂延迟到第一笔 UserOp) 一笔带授权的交易,委托到已部署的合约,无需部署
私钥单点 可消除:owner 可换,旧钥匙作废 治不了:原私钥始终是最高权限
与现有钱包生态兼容性 合约地址,部分 DApp 需适配(如签名验证走 ERC-1271) 最好:地址、资产、历史授权全部保留
适合场景 新用户、应用内嵌钱包、需要社交恢复与多因子 存量 EOA 想要批量调用、代付、限额

我的粗暴总结:新开户直接上 4337 合约账户;老账户想尝鲜用 7702;但只要”私钥绝对不能丢”还是你的第一诉求,终点站都是带社交恢复的合约账户。

AA vs MPC:拆钥匙片和换门锁是两码事

讲到”解决私钥单点”,你可能会想起《更加安全的区块链钱包:MPC 钱包》。MPC 和 AA 常被放在一起比,其实根本不在一个维度:

  • MPC 是密码学手段:把一把钥匙拆成三片,三方各藏一片,签名时三方各算各的、凑出一个完整签名——完整私钥从头到尾没出现过。但你琢磨一下就发现:锁没换。链上看到的仍是一个普通 EOA 的普通签名,账户还是那个”笨”账户,不能批量、不能代付、不能限额。
  • AA 是账户形态升级:钥匙还是一把(或者几把),但锁换成智能的了,规则写在合约里,由链上强制执行。

所以二者解决的是不同层的问题:MPC 管”钥匙怎么保管“,AA 管”门锁怎么运作“,完全可以叠加——用 MPC 保管合约账户的 owner 钥匙,钥匙层没有单点,锁上还有限额和恢复兜底。顺带一提,MPC 那篇里讲的多签钱包,本质上就是智能合约账户的老前辈。

Python 模拟

老规矩,代码说话。两段都只用标准库;为了不引第三方依赖,签名用”私钥拼消息再哈希”来模拟(真实链上是 ECDSA 验签,且链上从不保存私钥,代码里的 PRIVATE_KEYS 字典纯属模拟道具)。第一段模拟 UserOperation 的一生,第二段模拟社交恢复与 guardian 串通攻击。

模拟一:UserOperation 的一生

还是外卖那个局:Alice、Bob、Carol 各有 1000 USDC、0 ETH——搁旧门锁时代,这仨人谁也别想动账。现在每人都装了智能门锁(部署了自己的账户合约):Alice 和 Carol 想转账,勾选了会员免运费,请 Paymaster 代付;Bob 想 approve + swap 批量执行,走自费通道,用自己的预存款付 gas;另外还有个 Mallory 伪造 Alice 的签名,想浑水摸鱼下一单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# ERC-4337 UserOperation 生命周期模拟(纯标准库):UserOp -> alt mempool -> bundler
# 预检打包 -> EntryPoint 验签/执行/结算 -> 事件回执
import hashlib
import json

PRIVATE_KEYS = {} # 地址 -> 私钥(真实链上不存私钥,这里只为本地验签能跑)
NAMES = {} # 地址 -> 昵称
USDC_BALANCE = {} # 地址 -> USDC 余额
ETH_BALANCE = {} # 地址 -> ETH 余额(见证奇迹:用户全程为 0)
ALT_MEMPOOL = [] # 另类内存池:只收严格格式的 UserOp,与普通交易池隔离(防 DoS)

def sign(pk, msg): # 模拟 ECDSA:私钥 + 消息 -> 签名(真实世界是椭圆曲线,这里用哈希代替)
return hashlib.sha256(f"{pk}|{msg}".encode()).hexdigest()

def new_account(name): # 注册新身份:生成私钥与地址
pk = hashlib.sha256(f"seed:{name}".encode()).hexdigest()
addr = "0x" + hashlib.sha256(f"addr:{name}".encode()).hexdigest()[:40]
PRIVATE_KEYS[addr], NAMES[addr] = pk, name
return addr

def label(a): return NAMES.get(a, a[:10])
def usdc_of(a): return USDC_BALANCE.get(a, 0.0)

def post_op(paymaster, user, fee=0.5): # Paymaster 的 postOp:垫付后向用户收 0.5 USDC 服务费
USDC_BALANCE[user] = usdc_of(user) - fee
USDC_BALANCE[paymaster] = usdc_of(paymaster) + fee
return fee

class UserOp: # 仿交易的"意图对象":描述想做什么 + 如何证明"我是本人"
def __init__(self, sender, call_data, nonce, paymaster=None):
self.sender = sender # 账户合约地址,不再是裸 EOA
self.call_data = call_data # 想执行的调用列表
self.nonce = nonce # 防重放序号
self.paymaster = paymaster # 可选:代付 gas 的 Paymaster
self.signature = None # owner 对整个 UserOp 的签名

def canonical(self): # 规范序列化:签名与验签必须基于完全相同的字节
return json.dumps([self.sender, self.call_data, self.nonce, self.paymaster],
ensure_ascii=False)

def sign_with(self, owner):
self.signature = sign(PRIVATE_KEYS[owner], self.canonical())
return self

class Account: # 每个用户自己的账户合约:地址即账户,owner 是唯一的日常钥匙
def __init__(self, owner):
self.owner = owner
self.addr = "0x" + hashlib.sha256(f"acct:{owner}".encode()).hexdigest()[:40]
NAMES[self.addr] = f"{label(owner)}的账户合约"

def validate_user_op(self, op, expected_nonce): # EntryPoint 回调:合约自己验签
if op.sender != self.addr or op.nonce != expected_nonce:
return False
return sign(PRIVATE_KEYS[self.owner], op.canonical()) == op.signature

def execute(acct, call): # 迷你 EVM:只认三种调用(真实实现是合约代码在 EVM 里执行)
if call[0] == "usdc_transfer":
_, to, amt = call
assert usdc_of(acct.owner) >= amt, "USDC 余额不足"
USDC_BALANCE[acct.owner] -= amt
USDC_BALANCE[to] = usdc_of(to) + amt
return f"转 {amt} USDC 给 {label(to)}"
if call[0] == "approve":
return f"授权 {label(call[1])} 可动用 {call[2]} USDC"
if call[0] == "swap":
return f"在 DEX {call[1]}"
raise ValueError(f"未知调用 {call[0]}")

def entry_point_handle_ops(bundle, accounts, bundler_addr, deposits, nonces): # 全局唯一入口合约
events, gas_price = [], 2e-9 # 2 gwei,简化处理
print(f"\n>>> EntryPoint.handleOps() 收到 bundle:{len(bundle)} 个 UserOp")
for idx, op in enumerate(bundle, 1):
acct = accounts[op.sender]
gas = 21000 + len(op.canonical().encode()) * 50 # 简化的 gas 模型
cost = gas * gas_price
expected = nonces.get(op.sender, 0)
if not acct.validate_user_op(op, expected): # 第 1 步:验签 + 防重放
raise RuntimeError(f"FailedOp(#{idx}):验证失败,整笔 bundle 回滚,Bundler 白付 gas")
print(f" #{idx} [1] validateUserOp 通过:{label(op.sender)} 签名有效,nonce={expected}")
results = [execute(acct, c) for c in op.call_data] # 第 2 步:执行意图
nonces[op.sender] = expected + 1 # 成功后才递增 nonce
print(f" #{idx} [2] 执行:{';'.join(results)}")
# 第 3 步:gas 结算。有 Paymaster 用 Paymaster,否则扣账户自己的预存款
payer = op.paymaster or op.sender
deposits[payer] = deposits.get(payer, 0.0) - cost
# EntryPoint 把实际消耗补偿给 Bundler(它垫付了整笔 bundle 交易的手续费)
deposits[bundler_addr] = deposits.get(bundler_addr, 0.0) + cost
extra = ""
if op.paymaster:
extra = f";postOp 向用户收 {post_op(op.paymaster, acct.owner)} USDC 服务费"
print(f" #{idx} [3] gas={gas}({cost:.9f} ETH)由 {label(payer)} 支付{extra}")
events.append(f"UserOperationEvent(sender={label(op.sender)}, success=True, "
f"gas 付款方={label(payer)}, 实际费用={cost:.9f} ETH)")
print("\n--- 本 bundle 产生的事件回执 ---")
for e in events:
print(" " + e)

def bundler_pack(limit, accounts): # 打包者预检:真实 Bundler 会先本地模拟,防赔本防 DoS
cands, ALT_MEMPOOL[:] = ALT_MEMPOOL[:limit], ALT_MEMPOOL[limit:]
picked, dropped = [], []
for op in cands:
ok = sign(PRIVATE_KEYS[accounts[op.sender].owner], op.canonical()) == op.signature
(picked if ok else dropped).append(op)
print(f"[bundler] Bundler-01 预检 alt mempool 中的 {len(cands)} 个 UserOp:")
for op in picked:
print(f" [通过] {label(op.sender)}:{op.call_data[0][0]} 等调用")
for op in dropped:
print(f" [丢弃] {label(op.sender)}:签名预检失败,赔本买卖不做")
print(f"[bundler] 将 {len(picked)} 个 UserOp 打包成一笔 bundle 交易(calldata = handleOps)")
return picked

def main():
alice, bob, carol = new_account("Alice"), new_account("Bob"), new_account("Carol")
dex, bundler = new_account("UniswapV2Router"), new_account("Bundler-01")
for u in (alice, bob, carol): # 三个用户全是 USDC,ETH 余额为 0(EOA 时代的经典窘境)
USDC_BALANCE[u] = 1000.0
a, b, c = Account(alice), Account(bob), Account(carol) # 每人部署一个自己的账户合约
accounts = {x.addr: x for x in (a, b, c)}
pm = "0x" + hashlib.sha256(b"pm:GaslessSwapPaymaster").hexdigest()[:40]; NAMES[pm] = "GaslessSwapPaymaster"
deposits = {pm: 0.05, b.addr: 0.01} # gas 专款预存在 EntryPoint:Paymaster 一份,Bob 自付一份
ops = [
UserOp(a.addr, [("usdc_transfer", bob, 30)], 0, pm).sign_with(alice),
UserOp(b.addr, [("approve", dex, 500), ("swap", "用 100 USDC 换 ETH")], 0).sign_with(bob),
UserOp(c.addr, [("usdc_transfer", alice, 10)], 0, pm).sign_with(carol),
]
op_evil = UserOp(a.addr, [("usdc_transfer", new_account("Mallory"), 999)], 1)
op_evil.signature = "0x" + "66" * 32 # Mallory 伪造的签名
ALT_MEMPOOL.extend(ops + [op_evil]) # 用户把签好的 UserOp 扔进 alt mempool

entry_point_handle_ops(bundler_pack(4, accounts), accounts, bundler, deposits, {})
print("\n--- 结账时刻:费用流全景 ---")
for u in (alice, bob, carol):
print(f" {label(u)}:ETH={ETH_BALANCE.get(u, 0.0):.9f},USDC={usdc_of(u):.2f}")
print(f" Paymaster 结余:EntryPoint 预存 {deposits[pm]:.9f} ETH,收到服务费 {usdc_of(pm):.2f} USDC")
print(f" Bob 的账户合约 gas 预存结余:{deposits[b.addr]:.9f} ETH")
print(f" Bundler-01 垫付获补偿:{deposits[bundler]:.9f} ETH")

main()

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
[bundler] Bundler-01 预检 alt mempool 中的 4 个 UserOp:
[通过] Alice的账户合约:usdc_transfer 等调用
[通过] Bob的账户合约:approve 等调用
[通过] Carol的账户合约:usdc_transfer 等调用
[丢弃] Alice的账户合约:签名预检失败,赔本买卖不做
[bundler] 将 3 个 UserOp 打包成一笔 bundle 交易(calldata = handleOps)

>>> EntryPoint.handleOps() 收到 bundle:3 个 UserOp
#1 [1] validateUserOp 通过:Alice的账户合约 签名有效,nonce=0
#1 [2] 执行:转 30 USDC 给 Bob
#1 [3] gas=29300(0.000058600 ETH)由 GaslessSwapPaymaster 支付;postOp 向用户收 0.5 USDC 服务费
#2 [1] validateUserOp 通过:Bob的账户合约 签名有效,nonce=0
#2 [2] 执行:授权 UniswapV2Router 可动用 500 USDC;在 DEX 用 100 USDC 换 ETH
#2 [3] gas=28750(0.000057500 ETH)由 Bob的账户合约 支付
#3 [1] validateUserOp 通过:Carol的账户合约 签名有效,nonce=0
#3 [2] 执行:转 10 USDC 给 Alice
#3 [3] gas=29300(0.000058600 ETH)由 GaslessSwapPaymaster 支付;postOp 向用户收 0.5 USDC 服务费

--- 本 bundle 产生的事件回执 ---
UserOperationEvent(sender=Alice的账户合约, success=True, gas 付款方=GaslessSwapPaymaster, 实际费用=0.000058600 ETH)
UserOperationEvent(sender=Bob的账户合约, success=True, gas 付款方=Bob的账户合约, 实际费用=0.000057500 ETH)
UserOperationEvent(sender=Carol的账户合约, success=True, gas 付款方=GaslessSwapPaymaster, 实际费用=0.000058600 ETH)

--- 结账时刻:费用流全景 ---
Alice:ETH=0.000000000,USDC=979.50
Bob:ETH=0.000000000,USDC=1030.00
Carol:ETH=0.000000000,USDC=989.50
Paymaster 结余:EntryPoint 预存 0.049882800 ETH,收到服务费 1.00 USDC
Bob 的账户合约 gas 预存结余:0.009942500 ETH
Bundler-01 垫付获补偿:0.000174700 ETH

对着外卖把几个看点串起来:

  • 4 张订单进接单大厅,跑腿小哥预检时把伪造签名的那张丢了——真实世界里小哥不做赔本买卖,恶意订单根本进不了配送箱。
  • 运费流对得上账:Paymaster 垫付 2 × 0.0000586 ETH,Bob 自付 0.0000575 ETH,合计 0.0001747 ETH,正好是跑腿小哥收到的报销;Paymaster 另收了 1 USDC”运费”。
  • 最关键的还是那句:三个用户的 ETH 自始至终是 0,事全办成了——“用 USDC 付 gas”,”只收现金”的死规矩就这么破掉了。
  • 诚实的简化说明:gas 模型和 0.5 USDC 费率是随手定的;迷你 EVM 里的 approve 和 swap 只返回描述、不真动余额(所以 Bob 的 USDC 没少 100);真实的 EntryPoint 是两个循环——先跑完所有 UserOp 的验证、再统一执行——这里为了好读合成了一个循环。

模拟二:社交恢复与 guardian 串通

换到门锁视角:Alice 的账户有 5 个 guardian(闺蜜、弟弟、旧手机备份、两家机构),阈值 3/5,宽限期 3 天。三个场景:日常转账;钥匙丢了走补钥匙流程;Mallory 串通 3 个 guardian 发起攻击——看看”只能换锁、搬不走东西”这条设计是不是真的扛得住。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
# 社交恢复模拟(纯标准库)
# 5 个 guardian、3 签通过 -> 换新 owner;恶意 guardian 串通也只能换 owner,不能直接搬资产
import hashlib

PRIVATE_KEYS = {} # 地址 -> 私钥(模拟用,真实链上不存)
NAMES = {} # 地址 -> 昵称

def sign(pk, msg): # 模拟 ECDSA 签名
return hashlib.sha256(f"{pk}|{msg}".encode()).hexdigest()

def new_account(name):
pk = hashlib.sha256(f"seed:{name}".encode()).hexdigest()
addr = "0x" + hashlib.sha256(f"addr:{name}".encode()).hexdigest()[:40]
PRIVATE_KEYS[addr], NAMES[addr] = pk, name
return addr

def label(a): return NAMES.get(a, a[:10])
def sig_by(who, msg): return sign(PRIVATE_KEYS[who], msg) # 用某人的钥匙对消息签名

class VaultAccount:
"""带社交恢复的智能合约账户。权限设计(关键):
- execute_transfer():只认"当前 owner"的签名 —— 资产动用权
- request_recovery():凑够阈值个 guardian 签名 —— 只能换 owner,碰不到资产
- cancel_recovery():owner 可一票否决进行中的恢复 —— 防恶意 guardian 偷换"""
def __init__(self, owner, guardians, threshold=3, delay=3):
self.owner, self.guardians, self.threshold = owner, list(guardians), threshold
self.delay = delay # 恢复宽限期(天):换 owner 不是瞬间生效
self.usdc = 1000.0 # 资产挂在账户合约名下,不挂在任何私钥名下
self.sent = {} # 转出记录:收款地址 -> 累计金额
self.recovery_nonce = 0 # 防重放:每次恢复流程序号递增
self.pending = None # 进行中的恢复:(new_owner, 剩余宽限天数)

def execute_transfer(self, signature, message, amount, to): # 转账:只认当前 owner 的签名
if signature != sign(PRIVATE_KEYS[self.owner], message):
return f"[拒绝] 签名不是当前 owner({label(self.owner)})出的,转账被拒"
self.usdc -= amount
self.sent[to] = self.sent.get(to, 0.0) + amount
return f"[成功] 转 {amount:.0f} USDC 给 {label(to)},账户余额 {self.usdc:.0f} USDC"

def recovery_message(self, new_owner):
return f"RECOVERY|nonce={self.recovery_nonce}|new_owner={new_owner}"

def request_recovery(self, new_owner, votes): # guardians 投票:凑够阈值才进入宽限期
msg = self.recovery_message(new_owner)
valid = {g for (g, sig) in votes
if g in self.guardians and sign(PRIVATE_KEYS[g], msg) == sig}
if len(valid) < self.threshold:
return f"[拒绝] 有效 guardian 签名 {len(valid)}/{self.threshold},恢复请求不生效"
self.pending = (new_owner, self.delay)
return f"[受理] {len(valid)} 名 guardian 投票通过,换 owner 进入 {self.delay} 天宽限期(链上公开可查)"

def tick(self, days=1): # 时间流逝:宽限期倒计时
if not self.pending:
return "[无事发生] 当前没有进行中的恢复"
new_owner, remaining = self.pending[0], self.pending[1] - days
if remaining > 0:
self.pending = (new_owner, remaining)
return f"[等待] 宽限期还剩 {remaining} 天"
old, self.owner, self.pending = self.owner, new_owner, None
self.recovery_nonce += 1
return f"[生效] owner 已从 {label(old)} 换为 {label(new_owner)},旧钥匙从此作废"

def cancel_recovery(self, signature, message): # owner 一票否决进行中的恢复
if signature == sign(PRIVATE_KEYS[self.owner], message):
self.pending = None
return "[成功] 当前 owner 否决了这次恢复,恶意换钥匙失败"
return "[拒绝] 只有当前 owner 能取消恢复"

alice, alice_new, mallory = new_account("Alice"), new_account("Alice-新手机"), new_account("Mallory")
# guardian 阵容:亲友 + 设备 + 机构混合
g1, g2, g3 = new_account("闺蜜小王"), new_account("弟弟"), new_account("旧手机备份")
g4, g5 = new_account("钱包服务商恢复服务"), new_account("机构托管商B")
vault = VaultAccount(alice, [g1, g2, g3, g4, g5], threshold=3, delay=3)
print(f"账户部署完成:owner = {label(vault.owner)},资产 = {vault.usdc:.0f} USDC(挂在账户合约名下)")
print(f" guardians = {[label(g) for g in vault.guardians]}(阈值 3/5,宽限期 3 天)")

print("\n[场景1] Alice 日常转账:")
msg1 = f"transfer 100 USDC to {label(g2)}"
print(" " + vault.execute_transfer(sig_by(alice, msg1), msg1, 100, g2))

print("\n[场景2] Alice 存私钥的手机摔坏了,钥匙没了(但她人还在)")
msg_r = vault.recovery_message(alice_new)
votes = [(g, sig_by(g, msg_r)) for g in (g1, g2, g4)] # 闺蜜 + 弟弟 + 机构
print(" 发起恢复:" + vault.request_recovery(alice_new, votes))
print(" 第 1 天:" + vault.tick())
print(" 第 2~3 天:" + vault.tick(2))
msg_old = f"transfer 900 USDC to {label(mallory)}"
print(" 旧钥匙垂死挣扎:" + vault.execute_transfer(sig_by(alice, msg_old), msg_old, 900, mallory))
msg2 = f"transfer 50 USDC to {label(g1)}"
print(" 新钥匙接管转账:" + vault.execute_transfer(sig_by(alice_new, msg2), msg2, 50, g1))

print("\n[场景3] 攻击视角:Mallory 串通了 3 个 guardian(弟弟/旧手机/机构B)")
gang = (g2, g3, g5)
msg_evil = f"transfer 900 USDC to {label(mallory)}"
tries = [vault.execute_transfer(sig_by(g, msg_evil), msg_evil, 900, mallory) for g in gang]
print(f" 3 人各签一次直冲转账入口:{sum(t.startswith('[拒绝]') for t in tries)}/3 被拒")
print(" 拒绝原因:" + tries[0])
msg_bad = vault.recovery_message(mallory)
print(" 改走合法路径换 owner:" + vault.request_recovery(mallory, [(g, sig_by(g, msg_bad)) for g in gang]))
print(" 第 1 天:" + vault.tick())
cancel_msg = f"CANCEL|nonce={vault.recovery_nonce}"
print(" Alice 发现这笔不是自己发起的恢复,一票否决:" +
vault.cancel_recovery(sig_by(vault.owner, cancel_msg), cancel_msg))
print(" 第 2 天:" + vault.tick())
print(f"\n[结局] 账户余额 {vault.usdc:.0f} USDC,Mallory 到手 {vault.sent.get(mallory, 0.0):.0f} USDC;"
f"当前 owner:{label(vault.owner)}")

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
账户部署完成:owner = Alice,资产 = 1000 USDC(挂在账户合约名下)
guardians = ['闺蜜小王', '弟弟', '旧手机备份', '钱包服务商恢复服务', '机构托管商B'](阈值 3/5,宽限期 3 天)

[场景1] Alice 日常转账:
[成功] 转 100 USDC 给 弟弟,账户余额 900 USDC

[场景2] Alice 存私钥的手机摔坏了,钥匙没了(但她人还在)
发起恢复:[受理] 3 名 guardian 投票通过,换 owner 进入 3 天宽限期(链上公开可查)
第 1 天:[等待] 宽限期还剩 2 天
第 2~3 天:[生效] owner 已从 Alice 换为 Alice-新手机,旧钥匙从此作废
旧钥匙垂死挣扎:[拒绝] 签名不是当前 owner(Alice-新手机)出的,转账被拒
新钥匙接管转账:[成功] 转 50 USDC 给 闺蜜小王,账户余额 850 USDC

[场景3] 攻击视角:Mallory 串通了 3 个 guardian(弟弟/旧手机/机构B)
3 人各签一次直冲转账入口:3/3 被拒
拒绝原因:[拒绝] 签名不是当前 owner(Alice-新手机)出的,转账被拒
改走合法路径换 owner:[受理] 3 名 guardian 投票通过,换 owner 进入 3 天宽限期(链上公开可查)
第 1 天:[等待] 宽限期还剩 2 天
Alice 发现这笔不是自己发起的恢复,一票否决:[成功] 当前 owner 否决了这次恢复,恶意换钥匙失败
第 2 天:[无事发生] 当前没有进行中的恢复

[结局] 账户余额 850 USDC,Mallory 到手 0 USDC;当前 owner:Alice-新手机

三个场景对应三个结论:

  • 场景 2:3 个 guardian 点头,把 owner 换成新钥匙,宽限期过后生效,旧钥匙连”垂死挣扎”的机会都没有——丢钥匙从”家没了”降级成”跑一趟补卡”,补完照用。
  • 场景 3 前半:3 个串通的 guardian 各签一次想直接搬走 900 USDC,3/3 全被拒。guardian 的签名只对”换锁”有效,搬家通道根本不看它——保管人凑再多人,也进不了你家门。
  • 场景 3 后半:他们只能改走”换锁”这条合法路径,而这会进入 3 天公开宽限期,现任 owner 一票否决,攻击失败。慢、公开、可撤销——这就是智能门锁对”串通攻击”的降维打击。
  • 泼盆冷水:如果宽限期内 Alice 没看链上记录、没设提醒,3 个串通的 guardian 是真能夺走账户的。社交恢复把”钥匙丢了”从绝症治成了感冒,但没把人性变成防弹衣。

总结

把这一篇用一把锁、一份外卖收个尾:

  • 账户抽象 = 把账户的验证、付费、执行逻辑从协议层挪到合约层——把”一把钥匙管一切的旧门锁”换成可编程的智能门锁;
  • ERC-4337 不改协议,在应用层点了一份外卖:你写订单(UserOperation)、跑腿小哥抢单合并(Bundler)、平台核验验签(EntryPoint)、会员免运费(Paymaster)、智能门锁收货执行(Account),2023 年 3 月上线主网;
  • EIP-7702 随 2025 年 5 月的 Pectra 上线:老房子不动产权证,签一条授权请来长期管家(0xef0100 || 地址 的持久委托标记,不换人就一直聘着),原地址直接获得合约能力;但原私钥仍是最高权限,私钥单点没有被根治;
  • 社交恢复是 AA 最杀手级的应用:guardian 只能换锁、不能搬东西,配合宽限期和否决权,把单点故障变成多人治理——钥匙丢了等于跑一趟补卡,而不是家没了;
  • MPC 与 AA 正交:一个管钥匙怎么保管(拆成三片)、一个管门锁怎么运作(换智能锁),可以组合使用。

旧门锁用了十年,早就该换了。以太坊的账户升级就是一场全网的”换锁工程”,4337 和 7702 是两把不同型号的智能锁——至于装了智能锁之后还能玩出什么花样(批量、限额、无 gas、passkey),你自己就是产品经理。

相关阅读


 评论


博客内容遵循 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 协议

本站使用 Material X 作为主题 。
载入天数...载入时分秒...