以太坊原生UTXO:执行层研究
本文提出在以太坊上原生实现UTXO模型,通过将UTXO的存在证明存储在历史日志中仅保留花费位状态,将永久状态降低约99.8%。设计采用EIP-8141的Frame结构,通过值守恒的签名框架实现自费花费和信任委托支付,避免为一次性收款创建永久账户。文章详细对比了操作码与Frame方案的优劣,并讨论了发现、代币支持、状态树存储及成本估算。
以太坊原生 UTXO
特别感谢 Thomas、Ignacio 和 Matt 提供的反馈和审阅,以及 Vitalik 提出这个主题并推动相关讨论。
在以太坊上,接收一笔付款会永久增加状态。当一个地址第一次收到 ETH 时,会获得一个永久的账户叶子节点。当它第一次持有 ERC-20 时,会获得一个永久的存储槽。
比特币的工作方式不同。一笔比特币付款是一个一次性对象:创建一次,花费一次,然后消失,从状态中删除。链只记得一个 UTXO 被花掉了,而不是一个账户余额。
这个特性值得借鉴,并且以太坊可以在不放弃账户的情况下拥有它。对于不需要持久状态的支付工作负载,原生 UTXO 能将永久状态使用量减少约 99.8%。
本文假设使用 Frame Transactions (EIP-8141):一个 frame 是 EIP-8141 交易内部一个自包含、已签名的步骤。只读的 VERIFY 阶段让一个 frame 可以检查下一个 frame,并拒绝批准赞助,从而实现了无需信任的赞助。签名位于一个列表中,每个条目都指明了其方案:目前是 secp256k1 和 P-256,未来是抗量子方案。UTXO 的消费直接复用该列表。
太长不看 一个单一的、已签名的、价值守恒的 EIP-8141 frame 将 UTXO 输入、UTXO 输出、账户流动和 Gas 整合为一次转换。赞助随后变成一个常规的 frame:一个支付者 frame 仅在看到偿还自己的输出后才批准。
一次性支付,无需永久状态
以太坊账户很好,但它们不太适合一次性支付。
大多数简单的支付不需要一个持久存在的账户。它们只需要:
- 一个数值,
- 一个接收者,
- 一种一次性花费该数值的方式(防止双重花费)。
这正是比特币的 UTXO 模型。
对于比特币,未花费的 UTXO 成为状态,只有在被花费时才回收。我们希望同样的一次性价值,但不需要持久状态,因此我们可能希望将 UTXO 放在历史记录中而不是状态中;下面我们将看到如何实现。
一个 UTXO 的接收者就是一个以太坊地址。花费它需要接收者私钥的签名,可以使用签名列表支持的任何方案:今天可以使用 Passkey 持有 UTXO,明天可以使用抗量子密钥。接收者代码永远不会运行,也不需要新的脚本语言。
UTXO 对象
每个 UTXO 由一个 opening 和一个协议分配的索引描述:
opening = (source, value, recipient)
index : uint64
source:创建该 UTXO 的账户。value:以 wei 为单位的金额。recipient:被授权花费该 UTXO 的地址。index:创建时分配的全局单调递增计数器,用于防止双重花费。
UTXO 必须整体花费。 与比特币一样,不支持部分花费:一次花费消耗整个 value,任何找零都以新的 UTXO 输出或账户转账的形式返回。
创建是一个系统合约调用。 任何人(包括合约)都可以将 value 连同 calldata 中的 opening 发送到金库 UTXO_VAULT。金库从 msg.value 中读取金额,分配下一个索引,并发出创建事件日志。金库持有所有未花费的 UTXO 价值,只有协议才能将资金移出。
关键在于,opening 本身不存储在状态中。 它仅在创建事件日志中发出,这使得 UTXO 可以被发现,并承诺到一个按块的 openings root,从而使其可证明。在每个块的末尾,协议将该块中创建的每个 opening 进行哈希,leaf = hash(index, source, recipient, value),形成一个小的二叉树,并将根保存在一个环形缓冲区中。这与比特币不同,比特币节点必须维护未花费 UTXO 的集合;在这里,节点只需要维护系统合约的(小)状态。创建日志包含:
source作为topics[1],recipient作为topics[2],index和value在数据中。
这使得状态足迹最小化,同时保持 UTXO 可发现和可证明。
最小的协议状态
在状态中只为每个 UTXO 保留一个事实——“它是否已被花费?”——其余所有内容都从历史记录中证明。链只保留以下状态:
| 名称 | 作用 | 大小 |
|---|---|---|
next_utxo_index |
下一个要分配的索引 | 1 个槽位,被覆盖 |
spent_bits[index >> 8] |
已花费标志 | 每个 UTXO 1 位 |
openings_root_ring[N mod 8192] |
最近的 openings 根 | 固定 256 KB |
batches[N / 8192] |
已密封的旧根 | 约 10 KB/年 |
UTXO_VAULT |
锁定的 ETH | 一个预留地址 |
index 选择一个已花费位:
word = index >> 8
bit = index & 0xff
已花费集合是一个位字段,而不是
mapping(uint => bool)。布尔映射每个槽位浪费 255 位,而紧凑的位字段将一个 256 标志放入一个词中。一个 32 字节的词覆盖 256 个连续的索引,因此一个已花费的 UTXO 占用约 0.125 字节的原始位字段。
一次花费针对一个 openings root 证明其 opening。 最近的根从系统合约中存储的环中读取,更旧的根通过密封的批次恢复,这些批次也保存在同一个合约中。这避免了为每个 UTXO 存储一个承诺,否则会重新造成状态增长问题。
链已经通过收据根对日志进行了承诺,但一个收据证明必须携带创建交易的完整收据,包括其发出的所有日志。一个在单笔交易中创建 500 个 UTXO 的批量支付将迫使 500 个花费者各自携带整个收据作为见证。在 openings 树中,每个 UTXO 是其自己的叶子:一个证明只有几百字节的简单哈希,无论 UTXO 是如何创建的,验证器从不接触 RLP 或 MPT 证明。创建仍然只为它的日志付费;根由客户端在块边界计算,就像收据根一样。
存储消耗,证明存在
退一步看 UTXO:这里有一个一般原则。一个 “这个还能被花费吗?” 的检查同时回答了两个问题:“这个东西曾经存在过吗?” 和 “它已经被使用了吗?”
比特币将两者合二为一:存在于 UTXO 集合中。 这很优雅,但它迫使整个对象进入状态,而回收该空间的唯一方法是在花费时删除它。以太坊账户更糟:创建时付费,永不回收。两者都在创建时预先支付了永久的成本。
上述的 UTXO 设计将两个问题分开了。 存在性从仅追加的历史记录中证明(opening 针对 openings root),而在状态中唯一保留的是一个“已花费”位。我们不需要通过将所有 UTXO 保存在状态中来让每个人跟踪未花费的 UTXO 集合。 永久成本不再在创建时产生,而是转移到消耗时:一个创建但从未花费的 UTXO 根本不写入任何永久状态,而已花费的只花费一个位。
规则: 在状态中只保留下一个交易有效性所依赖的最小事实,通常只是 “这个是否已被使用?”,并将存在性和内容推送到仅追加的历史记录中,你可以在需要时按需证明。
缺点是这无法达到比特币的零剩余。因为历史记录不能被撤销,每个 UTXO 都需要一个“已消耗”标记以防止重放。比特币的标记就是删除本身:UTXO 从集合中消失;在这里,它是一个永久保留的位。
因此,我们用一个大的可回收足迹换一个小的永久足迹。对于部分有状态的节点,这避免了跟踪所有 UTXO(openings)的需要,它们只需要 UTXO_VAULT 状态,其增长速度可以忽略不计。
| 模型 | 存在性存储位置 | 消耗存储位置 | 创建时写入的状态(Opening 大小) |
|---|---|---|---|
| 比特币 | 状态(UTXO 集) | 历史(已花费 UTXO) | 每个 UTXO opening 约 50–100+ B |
| 今天的以太坊 | 状态(账户/存储) | 状态(账户/存储更新) | 每个账户/存储条目约 100–150+ B |
| 以太坊原生 UTXO | 历史(日志 + openings roots) | 状态(已花费位) | 0 B |
花费必须保证什么
创建,如前所述,是容易的一半:金库(=系统合约)的工作方式类似于信标存款合约,其中带价值的调用发出创建日志并创建 UTXO。花费一个已创建的 UTXO 是另一半,与创建不同,它需要原生协议支持,因为一个新的接收者可能根本没有 ETH。
接收者永远不应需要持有 ETH 才能花费 UTXO。关键在于一次性接收者:一个接收一次价值的地址,可能没有自己的 ETH,也可能永远没有理由持有任何 ETH。这样的地址是全新的,仅仅为了支付 Gas 而为其注资会重新创建 UTXO 旨在避免的账户叶子。像是 ERC-5564 隐身地址这样的一次性接收者是极端情况,但任何新接收者都需要满足这个要求。
一个接收者必须能够在持有零 ETH 的情况下花费 1 ETH 的 UTXO,只有两种可接受的方式:
- 自费花费: UTXO 用自己的价值支付其花费。
- 赞助花费: 其他人垫付 Gas,并从 UTXO 中获得偿还,且无需信任,因此赞助商只有在还款得到保证时才批准。理想情况下,由系统合约扮演这个角色。
这两个属性是真正的考验。一个无法提供这些属性的 UTXO 设计只对已有资金的账户有用,而这并不是 UTXO 发挥最大价值的地方。
设计空间:操作码还是 Frame
有两种方式可以添加这种原生支持:
1. 添加一个花费操作码,
2. 添加一个特殊目标的 EIP-8141 VERIFY frame。
最初,操作码设计看起来更简单:将 UTXO 花费直接暴露给 EVM 代码。Frame 设计看起来更重:暴露账户和 UTXO 之间的完整签名转换。
以下章节将说明为什么 Frame 设计是正确的选择:它能实现自费花费和无需信任的赞助。如果你不关心操作码设计的不足之处,可以跳过下一节。
选项 1:一个花费操作码
操作码设计添加了:
SPEND_UTXO(index, source, value, creation_block, opening_path) -> value
SPEND_UTXO解析 openings root,验证 opening,检查已花费位是否未设置,将其翻转,并将价值记入 msg.sender。
对于已有资金的账户,这可行。一个钱包创建一个 UTXO,一个已注资的接收者花费它,不需要任何 EIP-8141 机制。不幸的是,这个设计在 UTXO 最重要的地方失败了:新接收者。
自费花费失败
正常的以太坊交易在执行开始前就支付 Gas。一个零 ETH 的接收者甚至无法开始执行,因为在 SPEND_UTXO 可以运行之前,内在 Gas 的预先余额检查就已经失败了。
即使执行可以开始,忽略拒绝服务攻击向量,解锁的价值也来得太晚。SPEND_UTXO 在执行期间转移价值,但 Gas 已经预付了。执行期间的价值无法覆盖执行前的费用。
所以 UTXO 无法通过操作码为其自身花费提供资金,根本原因是协议在运行之前无法看到交易将要做什么。
我们可能需要一个中继器:
SPEND_UTXO贷记并授权msg.sender,因此花费必须以接收者为调用者运行。为了让第三方触发它,接收者需要有自己的代码:一个智能钱包或 EIP-7702 委托。这为接收者写入了一个永久的账户叶子,这对于一次性支付来说违背了初衷。
无需信任的赞助失败
一个赞助者(可能是一个合约)需要知道它会在支付之前得到偿还。
用 EIP-8141 的术语来说,支付者应该检查下一个 frame,看到对自己的偿还,并且只有在偿还得到保证时才批准。正常交易没有这样的结构。它有一个在执行前就固定的 Gas 支付者,没有协议级别的支付者步骤,也没有结构化的偿还字段。
例如,合约可以内省其他 frame,但它们无法模拟 EVM 代码来确定一次花费是否会偿还它们。
因此,赞助必须转移到中继器、元交易、ERC-4337 或定制合约代码中。可能有办法使其工作,但偿还逻辑存在于 UTXO 对象之外,并且它不是我们想要的干净原语。
Frame 内部的操作码仍然无法解决
将 SPEND_UTXO 放在 EIP-8141 交易内部并不能解决问题。
支付者在 VERIFY frame 中被批准,该 frame 以 STATICCALL 运行。但 SPEND_UTXO 改变了状态:它翻转已花费位并移动金库余额。它不能在 VERIFY 中运行。它必须在之后运行,在支付已经承诺之后,所以解锁的价值仍然来得太晚。
赞助仍然失败。赞助者可以解析传递给后面 frame 的数据,但无法确定该 frame 是否通过调用操作码实际偿还它。
问题是结构性的: 一个操作码只是 EVM 代码。它在支付者固定后运行,并且它没有给其他 frame 提供任何干净的对象来检查。
选项 2:一个价值守恒的 Frame
一次花费是一个已签名的、价值守恒的转换:输入 UTXO,输出 UTXO 和账户支付,包括 Gas。
EIP-8141 可以直接将该转换暴露为一个规范的 VERIFY-frame 目标,并具有协议定义的结算语义。 目标是 UTXO_VAULT 本身,但其代码从不在此处运行。协议识别该地址,就像规范的 Paymaster 或过期验证器一样,并应用以下语义。金库代码仅用于存款:花费的输出(包括找零)由协议直接创建。
该 frame 如下所示:
actors: [address] // 每个都由交易签名列表中 scheme-tagged 的签名支持
// 这些签名覆盖 frame 摘要
inputs: [\
{ index, creation_block, opening_path }\
]
utxo_outs: [\
{ recipient, value }\
]
account_outs: [\
{ recipient, value }\
]
change_index: uint
payer: address
max_fee_per_gas
max_priority_fee_per_gas
max_gas_limit
每个参与者对整个 frame 签名,但 opening_path 见证数据除外。具体来说,签名覆盖除见证数据外的所有字段。因此,刷新见证数据可以保持签名有效:提交者用新的见证数据重新包装外部交易,而无需触及签名。签名本身是交易签名列表中的普通条目:secp256k1 和 P-256 今天就可工作,抗量子方案只是另一个标签,frame 格式不变。签名绑定输入、utxo 输出、账户流动、费用上限和链 ID。没有提交者可以重定向价值或改变转换,但任何人都可以刷新见证数据,例如在创建离开环后追加批处理路径。替换的见证数据要么证明相同的已签名创建,要么验证失败,因为每个输入由其全局唯一的 index 固定。使用 utxo_outs 可以创建新的 UTXO,使用 account_outs 可以直接花费到账户余额。
一个输入仅携带 index 和 creation_block;source、value 和 recipient 从已证明的 opening 中读取,而不是签署,因为唯一的 index 已经固定它们。验证要求每个输入的已证明 recipient 是参与者之一,因此一个 frame 可以合并由多个地址持有的 UTXO:十个隐蔽支付在一次花费中合并,只需一笔费用。如果只有一个参与者,每个输出都创建为 source = actor;如果有多个参与者,输出携带 source = UTXO_VAULT。
每个 frame 至少消耗一个输入,因此已花费位就是重放保护:一旦设置,该 frame 就无法再次运行。
协议强制执行以下守恒规则:
sum(inputs.value)
>=
sum(utxo_outs.value) + sum(account_outs.value) + max_cost
max_cost 是交易的最高费用(TXPARAM(0x06)):所有 Gas 按 max_fee_per_gas 计算,包括内在、每个 frame、calldata 和签名成本。保留最大值保证输入覆盖费用。change_index 标记一个 utxo_out(或账户输出)作为找零。其签名的值为零,并排除在上述总和之外。一笔交易最多携带一个 UTXO frame,因为费用和 max_cost 是交易范围的。在赞助情况下(payer != 0),max_cost 项被替换为向支付者的已签名偿还输出;请参见下面的无需信任的赞助。
Gas 存在于守恒规则内部,而不是在转换之外,因此 UTXO 支付自己的交易费用。
自费花费
被消耗的 UTXO 输入覆盖:
- 新的 UTXO 输出,
- 账户输出,
- Gas。
没有参与者需要在其账户中有 ETH。金库从被消耗的 UTXO 价值中支付。 Gas 包括优先费,也由 UTXO 支付,因此即使参与者没有 ETH,包含该花费的提议者也能得到支付。
这就是操作码设计无法表达的。使用操作码,Gas 在花费之前就被收取。使用 frame,花费和费用支付是一次经过验证的转换。一个一次性接收者可以接收一个 UTXO,稍后花费它,并且从不持有账户余额。
无需信任的赞助
赞助也可以通过 frame 实现。
在自费花费中,UTXO 自己支付协议 Gas,这是其守恒规则中的 max_cost 项。当支付者赞助交易时,支付者代替支付协议 Gas。然后 UTXO 去掉 max_cost 项,并携带一个普通的已签名偿还输出给支付者,其大小至少等于支付者的 Gas 成本。偿还可以是 utxo_out 或 account_out;赞助者反正是一个有资金的账户,因此贷记到账户可以省去再次花费。参与者无论哪种方式都预留相同的价值;支付者支付实际费用,并在后面的 frame 中得到偿还,因此预留金额与实际费用之间的差额就是支付者垫付 Gas 的补偿。已签名的 payer 字段承诺了模式:提交者不能剥离赞助者 frame 并以自费方式重新运行该 frame,因为非零的 payer 必须批准支付。
支付者 frame 内省下一个 frame 并检查结构化的输出:
exists output in utxo_outs + account_outs where
output.recipient == payer
output.value >= required_payment
仅当存在这样的输出时,支付者才批准,否则拒绝。如果 VERIFY frame 失败,整个交易失败,支付者永远不会被收费。这消除了所有的信任要求,赞助者甚至可以是一个合约。
具体的 Frame 语义
以下是相同转换的更详细描述,分阶段进行:验证、批准、结算。
VERIFY 阶段
VERIFY 是只读的。对于每个 UTXO frame:
- 对于每个参与者,检查其在交易签名列表中针对该 frame 的签名。
- 对于每个输入(必须至少有一个):
- 解析
creation_block的 openings root, - 针对 root 验证 opening 路径,并定位携带
index的叶子, - 从该 opening 中读取
source、value和recipient,并要求recipient是参与者之一, - 要求该
index的已花费位未被设置。
- 解析
- 对于每个输出:
- 要求
recipient != address(0)。
- 要求
- 检查守恒规则,排除由
change_index选择的找零条目。 - 检查费用上限:
- 信封的
max_fee_per_gas和max_priority_fee_per_gas不超过已签名的上限, tx.gas_limit <= max_gas_limit。
- 信封的
已证明的 opening 是创建块的树的一个叶子:
leaf = hash(index, source, recipient, value)
见证数据提供 opening 的字段和到根的路径。
一次花费总是证明 opening -> openings_root;验证器从环中读取根。一段时间后,一旦它被密封到一个批次中,花费者追加一个路径 openings_root -> batch_root,验证器针对状态中的批次根进行检查。基础证明从不改变。离开环只是添加了批次路径,任何人都可以从公共链数据重建。因此,一次花费从不依赖于可变的环槽,可以永远保持有效。
批准
当 frame 被批准时,协议应用一个原子步骤,记录在 frame 回滚日志之外,类似于 EIP-8250 记录已消耗的 nonce,因此后面的 frame 失败无法撤销它:
- 对于每个输入,在验证过的
index处检查并设置其已花费位。如果它已经被设置,frame 失败。 - 如果 frame 的
payer为零,设置payer = UTXO_VAULT。否则,指定的支付者必须批准支付,否则交易无效。
在此处设置已花费位提供了仅花费一次的保证,并消除了重复计数:一个重复的输入,在此 frame 或后面的 frame 中,将会看到已设置的位并失败。因此,一旦批准,花费就是最终的,即使后面的 frame 回滚。
结算
结算对每个已批准的 frame 运行,并且不能失败,因为 VERIFY 已经证明其有偿付能力:
- 从金库中扣除每个输入的价值。
- 创建每个输出:递增
next_utxo_index,贷记金库,发出UtxoCreated事件。找零输出接收剩余价值。 - 应用
account_outs。 - 支付实际费用:燃烧基础费用,并将优先费发送给提议者。
验证只读取协议状态、openings roots 和金库自己的已花费位,从不读取任意账户状态,因此 UTXO frame 验证成本低,并且可以像公共内存池中的任何其他交易一样安全对待。这也是为什么花费是一个签名检查,接收者代码从不运行:如果由代码决定花费是否有效,那么任何存储写入都可能使内存池中待处理的花费无效。
第二条规则:声明转换,而不是执行
Frame 设计之所以有效,是因为它将整个转换声明为可检查的数据。操作码失败是因为它是代码,在 Gas 支付者已经固定后运行,并且它没有给其他方提供任何对象来在他们承诺价值之前检查。Frame 成功是因为它将整个转换表达为已签名的、价值守恒的数据,协议和潜在赞助者可以在任何价值移动之前验证它。
这就是为什么将 Gas 纳入守恒规则很重要,以及为什么赞助变得无需信任。决策点“这个转换是否守恒价值?”和“这个输出是否偿还我?”通过读取 frame 来回答,而不是通过运行任何东西。使转换可以静态推断,正是将这两个成功属性从尴尬的变通方法转变为干净原语的原因。
核心设计至此完成。其余的是实际细节:接收者如何发现他们的 UTXO,代币如何适配,已花费位在状态树中的位置,以及所有成本。
发现
接收者通过扫描 UTXO_VAULT 下以他们的地址作为 topics[2] 的日志来发现他们的 UTXO。必须同时检查发出者和签名主题(topics[0] = UtxoCreated_sig),否则未来金库下的日志可能伪造匹配。
隐蔽接收者是个例外:ERC-5564 的隐蔽地址是每次支付新推导的,因此接收者无法通过
topics[2]预先过滤,而是通过视图标签进行扫描。
这也是设计在隐私方面发挥作用的地方。因为 recipient 只是一个地址,它可以是一个隐蔽地址,并且因为花费是自费的,这个新地址永远不需要持有 ETH 或作为 tx.sender 出现。结果是接收者在不同支付之间不可链接。发送者仍然通过 topics[1] 暴露,因此发送者隐私需要单独的机制,例如混币。
代币
ERC-20 也可以工作,只需一个小的扩展:opening 将包含一个 token_address,对于 ETH 使用 address(0)。全局索引、已花费位字段、opening 证明和发现不变,因为无论资产是什么,索引都是一个索引:代币共享相同的索引空间和相同的约 0.3 字节已花费成本。一个输入的 token_address,就像它的 source 和 value 一样,是从已证明的 opening 中读取的,而不是签名的。
创建时需要存入真实的代币。create(token, value, recipient) 使用 transferFrom 拉取 value,并记录实际收到的金额,因此金库始终恰好持有它欠下的数量。金库成为每个代币的托管账户。花费是相同的 frame,但守恒是按代币强制执行的:对于每个代币,输入必须覆盖该代币的 utxo_outs 和 account_outs,而 max_cost Gas 项仅适用于 ETH 桶。每个代币保留自己的找零输出。一次单次花费可能同时携带多个代币,但绝不会用一种代币交换另一种,因为金库是托管账户,而不是交易所。
关键在于,花费 frame 不进行外部代币调用。结算只更新金库的内部会计:设置已花费位,发出 UtxoCreated 日志,并且对于代币的 account_out,贷记一个内部余额,接收者稍后通过单独的 withdraw 提取。因此,纯 UTXO 到 UTXO 的代币流动只在原始存款时接触一次代币合约,之后再也不接触。这使得结算无法失败,并使 VERIFY 只读取协议状态,因此代币花费继承了与 ETH 花费相同的内存池安全性(请参见结算),并且行为不当的代币只能影响其自身持有者。代币仍然可以自由地阻止账户;被阻止的账户无法向金库存款。
Gas 是代币唯一不同的地方:它用 ETH 支付,因此只有 ETH UTXO 可以自费支付其花费。因此,纯代币的花费必须始终是赞助的:支付者垫付 ETH,并以代币形式接受偿还。
已花费位在状态树中的位置
在 EIP-7864 中,每个键的第一个字节是一个 存储类型,标记其持有的数据类型:账户头、合约代码、存储,以及一些为未来类别保留的类型,例如已花费位字段。因此,已花费集合获得自己的类型,我们按索引而不是哈希来键控它。
index 由协议顺序分配,因此它直接映射到树键中,高位在前,连续的 UTXO 相邻:
index : uint64
index & 0xFF -> 叶子内的哪个位(一个叶子是 256 位)
(index >> 8) & 0xFF -> 茎中的哪个叶子(一个茎持有 256 个叶子)
index >> 16 -> 茎本身,紧跟在类型字节之后
一个 32 字节的叶子是 256 个标志,一个茎是 256 个叶子,因此一个茎覆盖 65,536 个连续的 UTXO,树的顺序就是索引的顺序。这与之前的 spent_bits[index >> 8] 词相同;索引决定哪个茎、哪个叶子和哪个位。
协议状态的其余部分很小:next_utxo_index 是一个单独的计数器,位于金库的账户头中,与其余额相邻。openings-root 环和密封的批次是有界的,可以留在金库的存储中。
放置规则: 一个系统分配的、不断增长的、只写一次的集合应属于其自己的类型字节下,按索引键控,而不是按哈希。索引作为键保持有序,并且批量证明成本低。这也与 VOPS 配合良好,因为它干净地将存储与“特殊”状态(如 nullifier 或已花费位)分开。
成本
永久状态
| 模型 | 每个条目状态 | 10 亿条目时 | 使用后 |
|---|---|---|---|
| 新账户/持有者槽位 | 约 100–150 B | 约 100–150 GB | 叶子保留 |
| 原生 UTXO | 约 0.3 B | 约 300 MB | 已花费位保留 |
对于不需要持久状态的支付工作负载,原生 UTXO 在永久状态方面比账户模型便宜约两个数量级。
示例:Alice 用 UTXO 支付 Bob
Alice 有 10 ETH,想给 Bob 一个 1 ETH 的 UTXO。Bob 是 0xB0。
创建
Alice 调用金库:
to: UTXO_VAULT
value: 1 ETH
data: create(recipient = 0xB0)
执行后:
UtxoCreated(
addr = UTXO_VAULT,
topics = [UtxoCreated_sig, 0xA1, 0xB0],
data = (42, 1 ETH)
)
状态变化:
next_utxo_index: 42 -> 43
Alice: 10 ETH -> 9 ETH - 实际费用
vault: 0 -> 1 ETH
在块末尾:
openings_root_ring[N mod 8192] = openings_root_N
## 提交 leaf = hash(42, 0xA1, 0xB0, 1 ETH)
Bob 的钱包过滤 UTXO_VAULT 下 topics[2] = 0xB0 的日志,并存储 opening。不需要来自 Alice 的带外消息。
花费
三个块后,Bob 花费该 UTXO。他发送 0.4 ETH 给 Charlie,0.5 ETH 给 Dave,并将剩余的价值作为找零 UTXO 收回,扣除费用后:
fee = gas_used * effective_gas_price
完全像比特币一样,找零是一个新的输出,而不是账户余额的剩余部分。其价值在结算时设置。
actors: [0xB0]
inputs: [\
{\
index: 42,\
creation_block: N,\
opening_path: ...\
}\
]
utxo_outs: [\
{ recipient: 0xC4, value: 0.4 ETH },\
{ recipient: 0xD5, value: 0.5 ETH },\
{ recipient: 0xB0, value: 0 }\
]
account_outs: []
change_index: 2
payer: 0
任何节点都可以提交该 frame。Bob 的账户余额从未被触及,因为已花费的 UTXO 为整个转换提供了资金。
结算后:
spent bit 42: 0 -> 1
next_utxo_index: 43 -> 46
vault: 1 ETH -> 1 ETH - 费用
Charlie、Dave 和 Bob 的找零现在是三个独立的 UTXO,由金库支持。没有触及任何账户状态。
account_outs 是可选的。它是回到账户模型的逃生出口。纯 UTXO 到 UTXO 的花费不留下任何账户状态。
- 原文链接: ethresear.ch/t/native-ut...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
