Elea Notes.

原生 UTXO:把永久状态成本从「创建」搬到「消费」,只留 0.125 字节

以太坊上收一笔钱就永久放大一次状态。这份提案把存在性推到只增不减的历史里,状态只留一个「已花费」比特,均摊每个 UTXO 0.125 字节——以及为什么加一条操作码做不到这件事。

在以太坊上,收一笔钱会让这条链永久变大一点。某个地址第一次收到 ETH,链上就多出一个账户叶子;第一次持有某个 ERC-20,就多出一个存储槽。这笔账没有还款日:哪怕这个地址此后余额归零、再也不动,那个叶子还在,全世界每个全节点都要一直存着它。

比特币不是这样。一笔比特币支付是个一次性的东西——被创建一次,被花掉一次,然后从状态里删掉。链只记得「这个 UTXO 花过了」,不记账户余额。

Toni Wahrstätter 七月初在 ethresear.ch 上提的这份提案,想把比特币的这个性质搬到以太坊,同时不放弃账户模型。它给出的数字是:对于不需要持久状态的支付类负载,永久状态占用降低约 99.8%。这个数字是怎么来的、代价是什么,比数字本身有意思。

先说结论

  • 核心不是「加个 UTXO 类型」,是一条更一般的规则:状态里只留下一笔交易的合法性所依赖的那个最小事实,通常就是一个比特——「这东西用过了吗」;存在性和内容推到只增不减的历史里,需要时再证明。
  • 省下来的量级来自一个很朴素的技巧:UTXO 的编号由协议单调分配,所以可以拿编号的低 8 位去选中一个字里的某一位。一个 32 字节的字覆盖 256 个连续编号,均摊到每个 UTXO 是 32/256=0.12532/256 = 0.125 字节。对照今天以太坊一个账户/存储条目的 100~150 字节,这就是 99.8% 的来路。
  • 花掉一个 UTXO 必须做到两件事,否则这套东西只对已有余额的账户有用:收款方兜里没有 ETH 也能花(自付手续费),以及别人替它垫 gas 且能被无信任地偿还。提案里大半篇幅在论证为什么加一条 SPEND_UTXO 操作码做不到这两件事。
  • 做不到的原因是结构性的:普通交易在执行之前就把 gas 付掉了,而操作码要到执行中才能解锁价值——钱来得太晚。EIP-8141 的「帧」把花费和付费写成同一个带签名的守恒转换,才让二者同时成立。
  • 达不到比特币的「零残留」。历史无法反写,所以每个 UTXO 都得留一个「已消费」标记防重放。比特币的标记就是删除本身,这里是一个留下来的比特。

机制

一个 UTXO 由一个开封值和一个协议分配的编号描述:

opening = (source, value, recipient)
index   : uint64

source 是创建它的账户,value 是 wei 数额,recipient 是有权花它的地址,index 是创建时分配的全局单调计数器,用来防双花。花费是全额的,没有部分花费;余额以新的 UTXO 输出或账户转账的形式回来。

创建走系统合约:任何人(包括合约)带着 calldata 里的开封值向 UTXO_VAULT 转账,金库读 msg.value、分配下一个编号、发出创建事件日志。金库持有全部未花费的 UTXO 价值,只有协议能把钱移出去。

关键在于:开封值本身不进状态。它只出现在创建事件日志里(让 UTXO 可被发现),并被承诺进一个逐块的 openings root(让它可被证明)。每个块结束时,协议把本块创建的所有开封值按 leaf = hash(index, source, recipient, value) 哈希进一棵小二叉树,把根存进一个环形缓冲区。

链上真正留下的状态只有这几项:

名字作用大小
next_utxo_index下一个待分配编号1 个槽,反复覆写
spent_bits[index >> 8]已花费标记每个 UTXO 1 比特
openings_root_ring[N mod 8192]近期的 openings root固定 256 KB
batches[N / 8192]封存的旧根约 10 KB/年
UTXO_VAULT锁定的 ETH一个保留地址

编号直接寻址到某一位:

word = index >> 8
bit  = index & 0xff

这里用的是打包位域,不是 mapping(uint => bool)。一个 bool 映射每个槽浪费 255 个比特,位域把 256 个标记塞进一个字。这就是前面 0.125 字节的算法。

线程里 Alberto 一上来就质疑过这一点:那个「已花费比特」不还是 Merkle 树里的一个叶子吗,跟一个 256 位的账户余额有多大区别?答案是:因为编号由协议分配且密集,等于把查找键的低 log2256=8\log_2 256 = 8 位挪去在字内选位——从 Merkle 树里砍掉 8 层。他随后自己认了这笔省得不小。这也是它相对 nullifier 式集合的优势所在:nullifier 是哈希出来的,稀疏,没法这样打包。

花费时要拿开封值去对某个 openings root 做证明。近期的根从系统合约里的环形缓冲区读,更老的根通过封存的批次恢复。为什么不直接复用已有的收据根?因为一个收据证明必须带上创建交易的整个收据,包括它发出的每一条日志——一笔批量付款创建 500 个 UTXO,就会让这 500 个花费方各自都得把那整个收据当见证数据运过来。在 openings 树里每个 UTXO 自己就是一个叶子,证明是几百字节的纯哈希,验证方完全不用碰 RLP 或 MPT 证明。

为什么这样设计

提案里最值得抄走的一句是对「这东西还能花吗」这个检查的拆解。它其实同时回答了两个问题:这东西存在过吗,以及它被用掉了吗

比特币把两个答案合并成一个——在不在 UTXO 集合里。这很优雅,但代价是整个对象必须进状态,而回收空间的唯一办法是花费时把它删掉。以太坊账户更糟:创建时付费,永不回收。两者的永久成本都落在创建这一刻。

这份提案把两个问题拆开:存在性从只增不减的历史里证明,状态里只留一个「已花费」比特。于是永久成本从创建搬到了消费。一个创建了但从没被花掉的 UTXO,永久状态写入是零。

模型存在性存在哪消费性存在哪创建时写入的状态
比特币状态(UTXO 集合)历史(已花费的 UTXO)每个开封值约 50~100+ 字节
今天的以太坊状态(账户/存储)状态(账户/存储更新)账户或存储条目约 100~150+ 字节
原生 UTXO历史(日志 + openings root)状态(已花费比特)0 字节

创建是容易的那一半。难的一半是花费,而且它非要协议原生支持不可,原因很具体:一个刚收到钱的地址可能一个 wei 的 ETH 都没有。给它转点 ETH 去付 gas,就又把那个本来要避免的账户叶子写回来了——ERC-5564 隐身地址是最极端的例子,但凡是新地址都吃这个问题。

所以真正的验收标准是两条:UTXO 能不能从自己的价值里掏出手续费;有没有人能替它垫 gas 并被无信任地偿还。

加一条操作码为什么不够。 设想 SPEND_UTXO(index, source, value, creation_block, opening_path):解析 openings root、验证开封值、检查已花费比特未置位、置位、把价值记给 msg.sender。对已有余额的账户这能用。对新地址两条都过不去:

  • 自付失败:普通交易在执行开始之前就要扣掉 gas。零余额的收款方连执行都启动不了——内在 gas 的余额预检在 SPEND_UTXO 有机会运行之前就失败了。就算放过这一步,解锁出来的价值也来得太晚:操作码在执行移动价值,而 gas 已经预付。执行中途的钱付不了执行前的账。根子上的原因是,协议在跑之前看不见这笔交易打算干什么。
  • 无信任代付失败:代付方需要在付钱之前知道自己会被偿还。普通交易的结构里没有这个位置——只有一个在执行前就固定的 gas 付款人,没有协议级的付款人步骤,也没有结构化的偿还字段。合约可以内省别的帧,但没法模拟 EVM 代码来判断某次花费到底会不会还它钱。
  • 把操作码塞进 EIP-8141 交易里也不行:付款人是在 VERIFY 帧里被批准的,而 VERIFY 以 STATICCALL 运行;SPEND_UTXO 要改状态(置位、移动金库余额),不能在 VERIFY 里跑。它只能在付款已经确定之后才运行,钱还是来得太晚。

换成一个守恒的帧就都成立了。 把花费直接表达成一个带签名、价值守恒的转换:UTXO 输入进来,UTXO 输出、账户支付和 gas 出去。帧的字段是 actorsinputsutxo_outsaccount_outschange_indexpayer 加三个费用上限。协议强制这条守恒式:

inputs.value    utxo_outs.value+account_outs.value+max_cost\sum \text{inputs.value} \;\ge\; \sum \text{utxo\_outs.value} + \sum \text{account\_outs.value} + \text{max\_cost}

max_cost 是整笔交易的最大费用,按 max_fee_per_gas 计全部 gas,含内在成本、每帧成本、calldata 和签名成本。gas 在守恒式里面,不在转换外面——这一句就是全部差别,UTXO 由此付得起自己的手续费,包含给出块者的优先费,哪怕签名者账户里一分钱没有。

代付也顺势变成普通的帧:付款人替交易付协议 gas,UTXO 那边去掉 max_cost 项,改成携带一个签过名的偿还输出给付款人,额度不小于其 gas 成本。付款人的帧内省下一帧,看见有个输出的收款方是自己才批准。预留额与实际费用之间的差价就是它垫钱的报酬。payer 字段本身被签名覆盖,所以提交者没法把代付帧剥掉、把这一帧当自付重放。

两个细节值得单独点出来。一是每个 actor 签的是整个帧除了 opening_path 见证数据——所以见证可以被任何人刷新(比如创建记录滚出环形缓冲区之后补上批次路径),签名照旧有效,提交者只需把外层交易重新包一遍。替换的见证要么证明的是同一条被签名的创建记录,要么验证失败,因为每个输入都被全局唯一的编号钉住了。二是验证要求每个输入被证明出的 recipient 是 actors 之一,所以一个帧可以合并多个地址持有的 UTXO:十笔隐身支付在一次花费里并成一笔,只付一次手续费。

边界

这份提案发布后线程里的反驳比提案本身更有信息量,几处都还没有结论:

「0 字节」是热状态的账,不是全部的账。 CPerezz 给了一版更诚实的记法:创建时 0 字节热状态,未花费期间约 100 字节冷归档,花掉之后永久约 0.3 字节热状态。冷归档那部分谁来存是真问题——他的建议是把 openings 做成独立的保留域(只保留未花费 UTXO 的开封值,花掉的可以丢),那么强制数据集的形状恰好就是比特币的 UTXO 集合,只是变成了可删除的冷文件而不是热状态。

历史过期会打断证明路径。 Po 指出用户要自己持续扫描每个块过滤创建日志,还要自己保管开封值和 Merkle 路径。等 EIP-4444 落地、历史区块体和收据被剪掉之后,「从链上重建」这条退路就没了。Toni 的回应是可以把 openings 与一般历史区别对待、由客户端自动落盘并通过 eth/ 提供,但这需要新的客户端行为,不是提案自带的。SanLeo461 把成本说得更具体:要重建一棵批次树得取回那个块(可能是几年前)的日志,外加周边 8192 个 openings root 的历史存储槽——前者在多数免费 RPC 上会直接报错,后者基本没有免费 RPC 支持。他的结论是这需要标准化的 RPC 方法,且应该早想。

openings root 到底承诺在哪没定。 提案的状态表里它只是金库存储中的一个环形槽位,会被覆写;如果它只活在环里,重建一个旧根就严格需要创建块的区块体。封存的时间表也还没写,而它需要一条不变式:batches[b] 必须在第 b 期的环形槽位被首次覆写之前就能从状态里读到,否则早期 UTXO 会有一段时间无法证明。Toni 确认了打算放在环里而非区块头,为的是让见证数据更小。

位域的最坏情况比均值差约 100 倍。 0.3 字节是密集花费下的均摊值;最坏情况是每 256 个编号的字里只花掉一个,那就得为它开一个新的 32 字节叶子加上 stem 开销。Toni 认为这个攻击不划算:用户无法影响自己被分配到的编号,要做到一个字只花一位,得先发 256 笔创建交易再花其中一笔。

所有权是「持钥方」而不是「以太坊账户」。 kt lee 追问的这一点提案没写明:因为花费靠协议可验证的签名方案,合约账户没有对应私钥,所以一个以合约地址为 recipient 的 UTXO 是花不出去的。对反事实的 CREATE2 地址尤其危险——创建时那里根本没有代码可以用来警告。这是个需要在规范里明说并给缓解措施的陷阱。

还有一处提案自己承认没想完:找零语义在代付场景下可能有逻辑缺口。目前的原则是找零要么送到指定的找零地址、要么烧掉。

来源

  1. Native UTXOs on Ethereumethresear.ch