SOON 上的 SVM Merklization

进阶1/27/2025, 12:53:09 AM
SOON Network 在 Solana 虚拟机(SVM)中引入 Merkle 树结构,解决了其全局状态根缺失的问题。此举增强了 SVM 在汇总系统中的完整性验证、欺诈证明和跨层操作能力。通过将状态根直接嵌入 SVM 区块链,SOON 提升了安全性和可扩展性,为 SVM 汇总提供了更强的支持。

Solana 虚拟机(SVM)正在成为多个 Layer-2(L2)解决方案的执行层。 然而,SVM 原始设计中的一个关键限制是其全局状态根的模糊性。这对汇总系统构成了重大挑战,因为全局状态根对于确保完整性、启用欺诈证明以及支持跨层操作至关重要。

在汇总系统中,提议者定期向 Layer-1(L1)提交 L2 状态根(Merkle 根),为 L2 链建立检查点。这些检查点支持对任何账户状态的包含证明,从一个检查点到另一个检查点实现无状态执行。欺诈证明依赖于这一机制,参与者可以提供包含证明来验证争议中的有效输入。此外,Merkle 树通过允许用户为提现交易生成包含证明,增强了规范化桥接的安全性,从而确保了 L2 和 L1 之间的无信任交互。

为了解决这些挑战,SOON Network 在 SVM 执行层中引入了 Merkle 树,使得客户端可以提供包含证明。SOON 通过使用独特的条目将状态根直接嵌入基于 SVM 的区块链,结合历史证明(Proof-of-History)。这一创新使得 SOON 技术栈能够支持新的 SVM 汇总系统,提升了安全性、可扩展性和实用性。

Solana 的 Merklization 问题

Solana 一直以高吞吐量为核心目标进行设计,在早期开发中,必须进行设计上的权衡以实现其创新的性能。在这些权衡中,其中一个最具影响力的决定是 Solana 在何时、如何将其状态进行 Merklization。

最终,这一决定给客户端在证明全局状态、交易包含性和简单支付验证(SPV)方面带来了重大挑战。由于缺乏一个持续哈希的状态根来代表 Merklized 的 SVM 状态,这为像轻客户端和汇总系统这样的项目带来了相当大的困难。

接下来,我们将探讨其他区块链如何进行 Merklization,并进一步分析 Solana 协议架构带来的挑战。

比特币上的 Merkle 树

在比特币网络中,交易通过 Merkle 树存储在区块中,Merkle 树的根存储在区块头中。比特币协议会将交易的输入和输出(以及其他一些元数据)进行哈希运算,生成交易 ID(TxID)。为了在比特币上证明状态,用户只需计算 Merkle 证明,将 TxID 与区块的 Merkle 根进行验证。

这一验证过程也验证了状态,因为 TxID 是唯一对应某一组输入和输出的,且这两者都反映了地址状态的变化。需要注意的是,比特币交易也可以包含 Taproot 脚本,这些脚本会生成交易输出,在验证时可以通过重新执行脚本,使用交易的输入和脚本的见证数据,并与输出进行验证。

以太坊上的 Merkle 树

类似于比特币,以太坊使用一种自定义的数据结构(源自 Merkle 树),称为 Merkle Patricia Trie(MPT)来存储交易。该数据结构旨在支持快速更新并优化大规模数据集。自然地,这是因为以太坊需要管理的输入和输出远比比特币多。

以太坊虚拟机(EVM)充当全局状态机。EVM 本质上是一个巨大的分布式计算环境,支持可执行的智能合约,每个合约在全局内存中保留自己的地址空间。因此,想要验证以太坊上的状态的客户端,不仅需要考虑交易的结果(如日志、返回代码等),还需要考虑交易所引发的全局状态变化。

幸运的是,EVM 巧妙地利用了三种重要的 Trie 结构,它们的根存储在每个区块头中:

  • 状态 Trie:以太坊上所有状态的巨型键值存储,包括每个以太坊地址和该地址下存储的数据。对于智能合约,这些账户还存储另一个叫做 Storage Trie 的 Trie,这是智能合约地址空间中所有数据的另一个键值映射。
  • 交易 Trie:存储区块中所有交易的键值存储,其中键是交易 ID,值是交易数据。
  • 收据 Trie:存储区块中每个交易的收据信息(状态、事件)的 Trie,按每个交易在区块中的索引进行哈希。每个收据包含有关交易执行的信息,包括状态 Trie 的交易后状态哈希。

给定一笔交易,客户端可以通过评估交易 Trie 的根(类似于比特币)来证明其包含在区块中,通过评估收据 Trie 来验证交易结果,并通过评估状态 Trie 来确认全局状态的变化。

Solana 上的 Merkle 树

Solana 高吞吐量的一个原因是,它没有像以太坊那样的多层树结构。虽然 Solana 的领导节点在创建区块时确实计算 Merkle 树,但它们的结构与 EVM 中的 Merkle 树不同。不幸的是,这正是 SVM 基础汇总系统的问题所在。

Solana 将交易 Merklize 为所谓的条目(entries),每个插槽中可以有多个条目,因此每个区块中可以有多个交易根。此外,Solana 仅在每个时代(大约 2.5 天)计算一次账户状态的 Merkle 根,而该根并不会存储在账本中。

事实上,Solana 区块头根本不包含任何 Merkle 根。相反,它们包含前一个和当前区块的区块哈希,这些哈希是通过 Solana 的历史证明(Proof of History,PoH)算法计算得出的。PoH 要求验证者通过递归哈希区块条目(这些条目可以为空或包含一批交易)来不断注册“刻度”(ticks)。PoH 算法的最终刻度(哈希)就是该区块的区块哈希。

历史证明的问题在于,它使得从区块哈希中证明状态变得非常困难。Solana 被设计为流式传输 PoH 哈希,以维持其时间流逝的概念。交易根仅在 PoH 刻度中包含了一个带有交易的条目时才可用,而不是整个区块,而且没有状态根存储在任何条目中。

然而,存在另一种哈希,客户端可以使用:银行哈希(bank hash)。有时被称为插槽哈希(slot hash),银行哈希存储在 SlotHashes 系统变量账户中,客户端可以查询。银行哈希是从一组输入创建的,具体包括:

  • 上一个银行哈希
  • 账户状态差异哈希(account state delta hash)
  • 银行中交易签名的数量(以字节为单位)
  • 该银行的区块哈希
  • 每个时代仅一次,所有账户的哈希(账户数据库中的所有账户)
  • 当集群重启时,由集群重启引起的任何硬分叉的哈希

如上所示,银行哈希包含多个哈希输入,这增加了客户端在尝试证明交易或状态信息时的复杂性。此外,只有执行了“时代账户哈希”(每个时代一次计算所有账户的哈希)的银行哈希,才会在其中包含该特定根。另外,SlotHashes 系统变量账户只保留最新的 512 个银行哈希。

SOON 的 Merklization 解决方案

SOON 网络是一个基于以太坊的 SVM L2。在将 Merklization 集成到 SOON 时,我们优先考虑使用经过验证的、成熟的解决方案,以保证稳定性,而不是重新发明轮子。在决定使用哪种数据结构或哈希算法时,我们考虑了它们与 Optimism Stack 的 L1 合约的兼容性、与 Solana 架构的无缝集成能力,以及它们是否能够在 Solana 的账户模型下实现高性能。

我们发现,随着账户数量的增加,基于 LSM-Tree 的 Merkle Patricia Trie(MPT)存储模型会导致更多的磁盘读写放大,从而导致性能下降。因此,我们决定通过基于 rETH 团队在 Rust 上做出的出色工作,并增加对 Solana 账户模型的支持,来集成 Erigon MPT

架构

如前所述,SOON 的状态 Trie 是一个支持 Solana 账户的 MPT。因此,我们定义了一种兼容 SVM 的账户类型,用于作为每个叶子节点的数据:

struct TrieSolanaAccount {
    lamports: u64,
    data: Vec<u8>,
    executable: bool,
    rent_epoch: u64,
    owner: Pubkey,
}

为了使 MPT 模块能够实时订阅 SVM 账户的最新状态,我们引入了一个账户通知器(account notifier)。在银行阶段(Banking Stage),账户通知器会通知 MPT 模块账户状态的变化,MPT 模块则会在 Trie 结构中增量更新这些变化。

需要注意的是,MPT 模块仅在每 50 个插槽(slot)时更新其子树,而不会在每个插槽结束时计算状态根。采取这种方式有两个原因:首先,计算每个插槽的状态根会显著影响性能;其次,状态根只有在提议者向 L1 提交 outputRoot 时才需要。因此,它只需要周期性计算,基于提议者的提交频率。

outputRoot = keccak256(version, state_root, withdraw_root, l2_block_hash)

SOON 的 MPT 模块同时维护两种类型的 Trie 结构:一个用于全局状态的状态 Trie,另一个用于提现交易的提现 Trie。提议者会定期生成由状态根和提现根组成的 outputRoot,并将其提交给 L1。

目前,SOON 每 450 个插槽计算一次状态根和提现根,并将其附加到 L2 区块链中。这样可以确保网络中其他节点的 MPT 数据的一致性。然而,Solana 的区块结构不包括区块头,这意味着没有地方存储状态根。让我们先来看一下 Solana 区块链的基本结构,然后探讨 SOON 如何引入 UniqueEntry 来存储状态根。

Solana 区块链由插槽(slots)组成,插槽由 PoH 模块生成。一个插槽包含多个条目(entries),每个条目包括刻度(ticks)和交易。在网络层和存储层,插槽使用 shreds 作为最小单元进行存储和传输。shreds 可以转换为条目,也可以从条目中转换回来。

#[derive(Serialize, Deserialize, Debug, Default, PartialEq, Eq, Clone)]
pub struct Entry {
   /// 前一个条目 ID 以来的哈希数。
   pub num_hashes: u64,
   /// 在前一个条目 ID 之后的 SHA-256 哈希。
   pub hash: Hash,
   /// 在生成条目 ID 之前观察到的未排序交易列表。它们可能在之前的条目 ID 之前被观察到,但为了确保账本的确定性解释,它们被推回到这个列表中。
   pub transactions: Vec<VersionedTransaction>,
}

我们遵循了 PoH 生成的区块链结构,并保留了 shred 结构,这样我们可以重用 Solana 现有的存储层、网络层和 RPC 框架。为了在 L2 区块链上存储额外的数据,我们引入了 UniqueEntry。这个特性允许我们自定义条目负载,并为数据定义独立的验证规则。

pub const UNIQUE_ENTRY_NUM_HASHES_FLAG: u64 = 0x8000_0000_0000_0000;
/// Unique entry 是一种特殊的条目类型。当我们需要将一些数据存储在区块存储中,但不想验证它时,它非常有用。
///
/// `num_hashes` 的布局是:
/// |...1 bit...|...63 bit...|
///      \      \_____ _____/
///       \           \
///      flag     custom field
pub trait UniqueEntry: Sized {
   fn encode_to_entries(&self) -> Vec<Entry>;

   fn decode_from_entries(
       entries: impl IntoIterator<Item = Entry>,
   ) -> Result<Self, UniqueEntryError>;
}
pub fn unique_entry(custom_field: u64, hash: Hash) -> Entry {
   Entry {
       num_hashes: num_hashes(custom_field),
       hash,
       transactions: vec![],
   }
}
pub fn num_hashes(custom_field: u64) -> u64 {
   assert!(custom_field < (1 << 63));
   UNIQUE_ENTRY_NUM_HASHES_FLAG | custom_field
}
pub fn is_unique(entry: &Entry) -> bool {
   entry.num_hashes & UNIQUE_ENTRY_NUM_HASHES_FLAG != 0
}

在 UniqueEntry 中,num_hashes 被用作位布局,其中第一个位标志用于区分条目(Entry)和 Unique Entry,后面 63 位用于定义负载类型。hash 字段作为负载,包含所需的自定义数据。

让我们看一个示例,使用三个 UniqueEntry 来存储插槽哈希、状态根和提现根。

/// MPT 根的 UniqueEntry。
#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub struct MptRoot {
   pub slot: Slot,
   pub state_root: B256,
   pub withdrawal_root: B256,
}

impl UniqueEntry for MptRoot {
   fn encode_to_entries(&self) -> Vec<Entry> {
       let slot_entry = unique_entry(0, slot_to_hash(self.slot));
       let state_root_entry = unique_entry(1, self.state_root.0.into());
       let withdrawal_root_entry = unique_entry(2, self.withdrawal_root.0.into());
       vec![slot_entry, state_root_entry, withdrawal_root_entry]
   }
   fn decode_from_entries(
       entries: impl IntoIterator<Item = Entry>,
   ) -> Result<Self, UniqueEntryError> {
       let mut entries = entries.into_iter();
       let entry = entries.next().ok_or(UniqueEntryError::NoMoreEntries)?;
       let slot = hash_to_slot(entry.hash);
       let entry = entries.next().ok_or(UniqueEntryError::NoMoreEntries)?;
       let state_root = B256::from(entry.hash.to_bytes());
       let entry = entries.next().ok_or(UniqueEntryError::NoMoreEntries)?;
       let withdrawal_root = B256::from(entry.hash.to_bytes());
       Ok(MptRoot {
           slot,
           state_root,
           withdrawal_root,
       })
   }
}

由于 UniqueEntry 重新定义了 num_hashes 的语义,它不能在 PoH 验证阶段处理。因此,在验证过程的开始,我们首先筛选出 unique entries,并根据其负载类型将它们引导到自定义的验证流程中。其余的常规条目则继续通过原始的 PoH 验证过程。

提现与本地桥接

本地桥接是 Layer 2 解决方案中的关键基础设施,负责 L1 与 L2 之间的信息传输。从 L1 到 L2 的消息称为存款交易(deposit transactions),而从 L2 到 L1 的消息称为提现交易(withdrawal transactions)。

存款交易通常由派生管道处理,用户只需要在 L1 上发送一次交易即可。提现交易则更为复杂,用户需要发送三次交易才能完成整个流程:

  1. 用户必须首先在 L2 上发送初始提现交易。
  2. 一旦包含该初始提现交易的 outputRoot 被提议者提交到 L1,用户需要向 L1 提交该提现交易的包含证明(inclusion proof)。验证成功后,质疑期开始。
  3. 质疑期结束后,用户发送最终确认交易(finalize transaction),以完成整个提现过程。

提现交易的包含证明确保了提现确实发生在 L2 上,从而大大增强了规范桥接的安全性。

在 OP Stack 中,用户的提现交易的哈希存储在与 OptimismPortal 合约对应的状态变量中。然后,eth_getProof RPC 接口被用来提供用户某个特定提现交易的包含证明。

与 EVM 不同,SVM 合约数据存储在账户的 data 字段中,所有类型的账户都处于同一层级。

SOON 引入了本地桥接程序:Bridge1111111111111111111111111111111111111。每当用户发起提现交易时,桥接程序会为每个提现交易生成一个全局唯一的索引,并使用该索引作为种子,创建一个新的程序派生账户(Program Derived Account,PDA)来存储对应的提现交易。

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct WithdrawalTransaction {
   /// 提现计数器
   pub nonce: U256,
   /// 申请提现的用户
   pub sender: Pubkey,
   /// 用户在 L1 上的地址
   pub target: Address,
   /// 提现金额,单位为 lamports
   pub value: U256,
   /// L1 上的 gas 限制
   pub gas_limit: U256,
   /// L1 上的提现 calldata
   pub data: L1WithdrawalCalldata,
}

我们定义了 WithdrawalTransaction 结构体,用于在 PDA 的数据字段中存储提现交易。

这一设计与 OP Stack 类似。一旦包含提现交易的 outputRoot 被提交到 L1,用户可以向 L1 提交提现交易的包含证明,启动质疑期。

故障证明

当提议者提交 outputRoot 到 L1 后,意味着 L2 的状态已经结算。如果质疑者发现提议者提交了错误的状态,他们可以发起质疑,以保护桥接中的资金。

构建故障证明时最关键的方面之一是确保区块链能够以无状态的方式从状态 S1 转换到状态 S2。这确保了 L1 上的仲裁合约能够无状态地重放程序指令,从而执行仲裁。

然而,在此过程的开始阶段,质疑者必须证明状态 S1 中的所有初始输入是有效的。这些初始输入包括参与账户的状态(例如,lamports、数据、所有者等)。与 EVM 不同,SVM 自然将账户状态与计算分离。然而,Anza 的 SVM API 允许通过 TransactionProcessingCallback 特征将账户传递到 SVM,如下所示:

pub trait TransactionProcessingCallback {
   fn account_matches_owners(&self, account: &Pubkey, owners: &[Pubkey]) -> Option<usize>;
   fn get_account_shared_data(&self, pubkey: &Pubkey) -> Option<AccountSharedData>;
   fn add_builtin_account(&self, _name: &str, _program_id: &Pubkey) {}
}

因此,我们只需要使用状态 S1 和输入账户的包含证明来验证质疑程序输入的有效性。

结语

SOON 在 SVM 生态系统的发展中标志着一个关键的里程碑。通过整合 Merklization,SOON 解决了 Solana 缺乏全局状态根的问题,从而实现了故障证明的包含证明、安全提现和无状态执行等关键功能。

此外,SOON 在 SVM 内部的 Merklization 设计可以支持基于 SVM 的链上的轻客户端,尽管 Solana 本身目前不支持轻客户端。甚至可以设想,这些设计中的一些可能有助于将轻客户端引入主链。

通过使用 Merkle Patricia Tries(MPT)进行状态管理,SOON 与以太坊的基础设施对齐,增强了互操作性,并推动了基于 SVM 的 L2 解决方案。这一创新通过提高安全性、可扩展性和兼容性,加强了 SVM 生态系统,同时促进了去中心化应用程序的发展。

免责声明:

  1. 本文转载自【Medium】,所有版权归原作者【@0xandrewz@realbuffalojoe】所有。若对本次转载有异议,请联系 Gate Learn 团队,他们会及时处理。
  2. 免责声明:本文表达的观点和意见仅代表作者个人观点,不构成投资建议。
  3. Gate Learn 团队将该文章翻译成其他语言。除非另有说明,否则禁止复制、分发或抄袭翻译文章。

SOON 上的 SVM Merklization

进阶1/27/2025, 12:53:09 AM
SOON Network 在 Solana 虚拟机(SVM)中引入 Merkle 树结构,解决了其全局状态根缺失的问题。此举增强了 SVM 在汇总系统中的完整性验证、欺诈证明和跨层操作能力。通过将状态根直接嵌入 SVM 区块链,SOON 提升了安全性和可扩展性,为 SVM 汇总提供了更强的支持。

Solana 虚拟机(SVM)正在成为多个 Layer-2(L2)解决方案的执行层。 然而,SVM 原始设计中的一个关键限制是其全局状态根的模糊性。这对汇总系统构成了重大挑战,因为全局状态根对于确保完整性、启用欺诈证明以及支持跨层操作至关重要。

在汇总系统中,提议者定期向 Layer-1(L1)提交 L2 状态根(Merkle 根),为 L2 链建立检查点。这些检查点支持对任何账户状态的包含证明,从一个检查点到另一个检查点实现无状态执行。欺诈证明依赖于这一机制,参与者可以提供包含证明来验证争议中的有效输入。此外,Merkle 树通过允许用户为提现交易生成包含证明,增强了规范化桥接的安全性,从而确保了 L2 和 L1 之间的无信任交互。

为了解决这些挑战,SOON Network 在 SVM 执行层中引入了 Merkle 树,使得客户端可以提供包含证明。SOON 通过使用独特的条目将状态根直接嵌入基于 SVM 的区块链,结合历史证明(Proof-of-History)。这一创新使得 SOON 技术栈能够支持新的 SVM 汇总系统,提升了安全性、可扩展性和实用性。

Solana 的 Merklization 问题

Solana 一直以高吞吐量为核心目标进行设计,在早期开发中,必须进行设计上的权衡以实现其创新的性能。在这些权衡中,其中一个最具影响力的决定是 Solana 在何时、如何将其状态进行 Merklization。

最终,这一决定给客户端在证明全局状态、交易包含性和简单支付验证(SPV)方面带来了重大挑战。由于缺乏一个持续哈希的状态根来代表 Merklized 的 SVM 状态,这为像轻客户端和汇总系统这样的项目带来了相当大的困难。

接下来,我们将探讨其他区块链如何进行 Merklization,并进一步分析 Solana 协议架构带来的挑战。

比特币上的 Merkle 树

在比特币网络中,交易通过 Merkle 树存储在区块中,Merkle 树的根存储在区块头中。比特币协议会将交易的输入和输出(以及其他一些元数据)进行哈希运算,生成交易 ID(TxID)。为了在比特币上证明状态,用户只需计算 Merkle 证明,将 TxID 与区块的 Merkle 根进行验证。

这一验证过程也验证了状态,因为 TxID 是唯一对应某一组输入和输出的,且这两者都反映了地址状态的变化。需要注意的是,比特币交易也可以包含 Taproot 脚本,这些脚本会生成交易输出,在验证时可以通过重新执行脚本,使用交易的输入和脚本的见证数据,并与输出进行验证。

以太坊上的 Merkle 树

类似于比特币,以太坊使用一种自定义的数据结构(源自 Merkle 树),称为 Merkle Patricia Trie(MPT)来存储交易。该数据结构旨在支持快速更新并优化大规模数据集。自然地,这是因为以太坊需要管理的输入和输出远比比特币多。

以太坊虚拟机(EVM)充当全局状态机。EVM 本质上是一个巨大的分布式计算环境,支持可执行的智能合约,每个合约在全局内存中保留自己的地址空间。因此,想要验证以太坊上的状态的客户端,不仅需要考虑交易的结果(如日志、返回代码等),还需要考虑交易所引发的全局状态变化。

幸运的是,EVM 巧妙地利用了三种重要的 Trie 结构,它们的根存储在每个区块头中:

  • 状态 Trie:以太坊上所有状态的巨型键值存储,包括每个以太坊地址和该地址下存储的数据。对于智能合约,这些账户还存储另一个叫做 Storage Trie 的 Trie,这是智能合约地址空间中所有数据的另一个键值映射。
  • 交易 Trie:存储区块中所有交易的键值存储,其中键是交易 ID,值是交易数据。
  • 收据 Trie:存储区块中每个交易的收据信息(状态、事件)的 Trie,按每个交易在区块中的索引进行哈希。每个收据包含有关交易执行的信息,包括状态 Trie 的交易后状态哈希。

给定一笔交易,客户端可以通过评估交易 Trie 的根(类似于比特币)来证明其包含在区块中,通过评估收据 Trie 来验证交易结果,并通过评估状态 Trie 来确认全局状态的变化。

Solana 上的 Merkle 树

Solana 高吞吐量的一个原因是,它没有像以太坊那样的多层树结构。虽然 Solana 的领导节点在创建区块时确实计算 Merkle 树,但它们的结构与 EVM 中的 Merkle 树不同。不幸的是,这正是 SVM 基础汇总系统的问题所在。

Solana 将交易 Merklize 为所谓的条目(entries),每个插槽中可以有多个条目,因此每个区块中可以有多个交易根。此外,Solana 仅在每个时代(大约 2.5 天)计算一次账户状态的 Merkle 根,而该根并不会存储在账本中。

事实上,Solana 区块头根本不包含任何 Merkle 根。相反,它们包含前一个和当前区块的区块哈希,这些哈希是通过 Solana 的历史证明(Proof of History,PoH)算法计算得出的。PoH 要求验证者通过递归哈希区块条目(这些条目可以为空或包含一批交易)来不断注册“刻度”(ticks)。PoH 算法的最终刻度(哈希)就是该区块的区块哈希。

历史证明的问题在于,它使得从区块哈希中证明状态变得非常困难。Solana 被设计为流式传输 PoH 哈希,以维持其时间流逝的概念。交易根仅在 PoH 刻度中包含了一个带有交易的条目时才可用,而不是整个区块,而且没有状态根存储在任何条目中。

然而,存在另一种哈希,客户端可以使用:银行哈希(bank hash)。有时被称为插槽哈希(slot hash),银行哈希存储在 SlotHashes 系统变量账户中,客户端可以查询。银行哈希是从一组输入创建的,具体包括:

  • 上一个银行哈希
  • 账户状态差异哈希(account state delta hash)
  • 银行中交易签名的数量(以字节为单位)
  • 该银行的区块哈希
  • 每个时代仅一次,所有账户的哈希(账户数据库中的所有账户)
  • 当集群重启时,由集群重启引起的任何硬分叉的哈希

如上所示,银行哈希包含多个哈希输入,这增加了客户端在尝试证明交易或状态信息时的复杂性。此外,只有执行了“时代账户哈希”(每个时代一次计算所有账户的哈希)的银行哈希,才会在其中包含该特定根。另外,SlotHashes 系统变量账户只保留最新的 512 个银行哈希。

SOON 的 Merklization 解决方案

SOON 网络是一个基于以太坊的 SVM L2。在将 Merklization 集成到 SOON 时,我们优先考虑使用经过验证的、成熟的解决方案,以保证稳定性,而不是重新发明轮子。在决定使用哪种数据结构或哈希算法时,我们考虑了它们与 Optimism Stack 的 L1 合约的兼容性、与 Solana 架构的无缝集成能力,以及它们是否能够在 Solana 的账户模型下实现高性能。

我们发现,随着账户数量的增加,基于 LSM-Tree 的 Merkle Patricia Trie(MPT)存储模型会导致更多的磁盘读写放大,从而导致性能下降。因此,我们决定通过基于 rETH 团队在 Rust 上做出的出色工作,并增加对 Solana 账户模型的支持,来集成 Erigon MPT

架构

如前所述,SOON 的状态 Trie 是一个支持 Solana 账户的 MPT。因此,我们定义了一种兼容 SVM 的账户类型,用于作为每个叶子节点的数据:

struct TrieSolanaAccount {
    lamports: u64,
    data: Vec<u8>,
    executable: bool,
    rent_epoch: u64,
    owner: Pubkey,
}

为了使 MPT 模块能够实时订阅 SVM 账户的最新状态,我们引入了一个账户通知器(account notifier)。在银行阶段(Banking Stage),账户通知器会通知 MPT 模块账户状态的变化,MPT 模块则会在 Trie 结构中增量更新这些变化。

需要注意的是,MPT 模块仅在每 50 个插槽(slot)时更新其子树,而不会在每个插槽结束时计算状态根。采取这种方式有两个原因:首先,计算每个插槽的状态根会显著影响性能;其次,状态根只有在提议者向 L1 提交 outputRoot 时才需要。因此,它只需要周期性计算,基于提议者的提交频率。

outputRoot = keccak256(version, state_root, withdraw_root, l2_block_hash)

SOON 的 MPT 模块同时维护两种类型的 Trie 结构:一个用于全局状态的状态 Trie,另一个用于提现交易的提现 Trie。提议者会定期生成由状态根和提现根组成的 outputRoot,并将其提交给 L1。

目前,SOON 每 450 个插槽计算一次状态根和提现根,并将其附加到 L2 区块链中。这样可以确保网络中其他节点的 MPT 数据的一致性。然而,Solana 的区块结构不包括区块头,这意味着没有地方存储状态根。让我们先来看一下 Solana 区块链的基本结构,然后探讨 SOON 如何引入 UniqueEntry 来存储状态根。

Solana 区块链由插槽(slots)组成,插槽由 PoH 模块生成。一个插槽包含多个条目(entries),每个条目包括刻度(ticks)和交易。在网络层和存储层,插槽使用 shreds 作为最小单元进行存储和传输。shreds 可以转换为条目,也可以从条目中转换回来。

#[derive(Serialize, Deserialize, Debug, Default, PartialEq, Eq, Clone)]
pub struct Entry {
   /// 前一个条目 ID 以来的哈希数。
   pub num_hashes: u64,
   /// 在前一个条目 ID 之后的 SHA-256 哈希。
   pub hash: Hash,
   /// 在生成条目 ID 之前观察到的未排序交易列表。它们可能在之前的条目 ID 之前被观察到,但为了确保账本的确定性解释,它们被推回到这个列表中。
   pub transactions: Vec<VersionedTransaction>,
}

我们遵循了 PoH 生成的区块链结构,并保留了 shred 结构,这样我们可以重用 Solana 现有的存储层、网络层和 RPC 框架。为了在 L2 区块链上存储额外的数据,我们引入了 UniqueEntry。这个特性允许我们自定义条目负载,并为数据定义独立的验证规则。

pub const UNIQUE_ENTRY_NUM_HASHES_FLAG: u64 = 0x8000_0000_0000_0000;
/// Unique entry 是一种特殊的条目类型。当我们需要将一些数据存储在区块存储中,但不想验证它时,它非常有用。
///
/// `num_hashes` 的布局是:
/// |...1 bit...|...63 bit...|
///      \      \_____ _____/
///       \           \
///      flag     custom field
pub trait UniqueEntry: Sized {
   fn encode_to_entries(&self) -> Vec<Entry>;

   fn decode_from_entries(
       entries: impl IntoIterator<Item = Entry>,
   ) -> Result<Self, UniqueEntryError>;
}
pub fn unique_entry(custom_field: u64, hash: Hash) -> Entry {
   Entry {
       num_hashes: num_hashes(custom_field),
       hash,
       transactions: vec![],
   }
}
pub fn num_hashes(custom_field: u64) -> u64 {
   assert!(custom_field < (1 << 63));
   UNIQUE_ENTRY_NUM_HASHES_FLAG | custom_field
}
pub fn is_unique(entry: &Entry) -> bool {
   entry.num_hashes & UNIQUE_ENTRY_NUM_HASHES_FLAG != 0
}

在 UniqueEntry 中,num_hashes 被用作位布局,其中第一个位标志用于区分条目(Entry)和 Unique Entry,后面 63 位用于定义负载类型。hash 字段作为负载,包含所需的自定义数据。

让我们看一个示例,使用三个 UniqueEntry 来存储插槽哈希、状态根和提现根。

/// MPT 根的 UniqueEntry。
#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub struct MptRoot {
   pub slot: Slot,
   pub state_root: B256,
   pub withdrawal_root: B256,
}

impl UniqueEntry for MptRoot {
   fn encode_to_entries(&self) -> Vec<Entry> {
       let slot_entry = unique_entry(0, slot_to_hash(self.slot));
       let state_root_entry = unique_entry(1, self.state_root.0.into());
       let withdrawal_root_entry = unique_entry(2, self.withdrawal_root.0.into());
       vec![slot_entry, state_root_entry, withdrawal_root_entry]
   }
   fn decode_from_entries(
       entries: impl IntoIterator<Item = Entry>,
   ) -> Result<Self, UniqueEntryError> {
       let mut entries = entries.into_iter();
       let entry = entries.next().ok_or(UniqueEntryError::NoMoreEntries)?;
       let slot = hash_to_slot(entry.hash);
       let entry = entries.next().ok_or(UniqueEntryError::NoMoreEntries)?;
       let state_root = B256::from(entry.hash.to_bytes());
       let entry = entries.next().ok_or(UniqueEntryError::NoMoreEntries)?;
       let withdrawal_root = B256::from(entry.hash.to_bytes());
       Ok(MptRoot {
           slot,
           state_root,
           withdrawal_root,
       })
   }
}

由于 UniqueEntry 重新定义了 num_hashes 的语义,它不能在 PoH 验证阶段处理。因此,在验证过程的开始,我们首先筛选出 unique entries,并根据其负载类型将它们引导到自定义的验证流程中。其余的常规条目则继续通过原始的 PoH 验证过程。

提现与本地桥接

本地桥接是 Layer 2 解决方案中的关键基础设施,负责 L1 与 L2 之间的信息传输。从 L1 到 L2 的消息称为存款交易(deposit transactions),而从 L2 到 L1 的消息称为提现交易(withdrawal transactions)。

存款交易通常由派生管道处理,用户只需要在 L1 上发送一次交易即可。提现交易则更为复杂,用户需要发送三次交易才能完成整个流程:

  1. 用户必须首先在 L2 上发送初始提现交易。
  2. 一旦包含该初始提现交易的 outputRoot 被提议者提交到 L1,用户需要向 L1 提交该提现交易的包含证明(inclusion proof)。验证成功后,质疑期开始。
  3. 质疑期结束后,用户发送最终确认交易(finalize transaction),以完成整个提现过程。

提现交易的包含证明确保了提现确实发生在 L2 上,从而大大增强了规范桥接的安全性。

在 OP Stack 中,用户的提现交易的哈希存储在与 OptimismPortal 合约对应的状态变量中。然后,eth_getProof RPC 接口被用来提供用户某个特定提现交易的包含证明。

与 EVM 不同,SVM 合约数据存储在账户的 data 字段中,所有类型的账户都处于同一层级。

SOON 引入了本地桥接程序:Bridge1111111111111111111111111111111111111。每当用户发起提现交易时,桥接程序会为每个提现交易生成一个全局唯一的索引,并使用该索引作为种子,创建一个新的程序派生账户(Program Derived Account,PDA)来存储对应的提现交易。

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct WithdrawalTransaction {
   /// 提现计数器
   pub nonce: U256,
   /// 申请提现的用户
   pub sender: Pubkey,
   /// 用户在 L1 上的地址
   pub target: Address,
   /// 提现金额,单位为 lamports
   pub value: U256,
   /// L1 上的 gas 限制
   pub gas_limit: U256,
   /// L1 上的提现 calldata
   pub data: L1WithdrawalCalldata,
}

我们定义了 WithdrawalTransaction 结构体,用于在 PDA 的数据字段中存储提现交易。

这一设计与 OP Stack 类似。一旦包含提现交易的 outputRoot 被提交到 L1,用户可以向 L1 提交提现交易的包含证明,启动质疑期。

故障证明

当提议者提交 outputRoot 到 L1 后,意味着 L2 的状态已经结算。如果质疑者发现提议者提交了错误的状态,他们可以发起质疑,以保护桥接中的资金。

构建故障证明时最关键的方面之一是确保区块链能够以无状态的方式从状态 S1 转换到状态 S2。这确保了 L1 上的仲裁合约能够无状态地重放程序指令,从而执行仲裁。

然而,在此过程的开始阶段,质疑者必须证明状态 S1 中的所有初始输入是有效的。这些初始输入包括参与账户的状态(例如,lamports、数据、所有者等)。与 EVM 不同,SVM 自然将账户状态与计算分离。然而,Anza 的 SVM API 允许通过 TransactionProcessingCallback 特征将账户传递到 SVM,如下所示:

pub trait TransactionProcessingCallback {
   fn account_matches_owners(&self, account: &Pubkey, owners: &[Pubkey]) -> Option<usize>;
   fn get_account_shared_data(&self, pubkey: &Pubkey) -> Option<AccountSharedData>;
   fn add_builtin_account(&self, _name: &str, _program_id: &Pubkey) {}
}

因此,我们只需要使用状态 S1 和输入账户的包含证明来验证质疑程序输入的有效性。

结语

SOON 在 SVM 生态系统的发展中标志着一个关键的里程碑。通过整合 Merklization,SOON 解决了 Solana 缺乏全局状态根的问题,从而实现了故障证明的包含证明、安全提现和无状态执行等关键功能。

此外,SOON 在 SVM 内部的 Merklization 设计可以支持基于 SVM 的链上的轻客户端,尽管 Solana 本身目前不支持轻客户端。甚至可以设想,这些设计中的一些可能有助于将轻客户端引入主链。

通过使用 Merkle Patricia Tries(MPT)进行状态管理,SOON 与以太坊的基础设施对齐,增强了互操作性,并推动了基于 SVM 的 L2 解决方案。这一创新通过提高安全性、可扩展性和兼容性,加强了 SVM 生态系统,同时促进了去中心化应用程序的发展。

免责声明:

  1. 本文转载自【Medium】,所有版权归原作者【@0xandrewz@realbuffalojoe】所有。若对本次转载有异议,请联系 Gate Learn 团队,他们会及时处理。
  2. 免责声明:本文表达的观点和意见仅代表作者个人观点,不构成投资建议。
  3. Gate Learn 团队将该文章翻译成其他语言。除非另有说明,否则禁止复制、分发或抄袭翻译文章。
即刻开始交易
注册并交易即可获得
$100
和价值
$5500
理财体验金奖励!
It seems that you are attempting to access our services from a Restricted Location where Gate is unable to provide services. We apologize for any inconvenience this may cause. Currently, the Restricted Locations include but not limited to: the United States of America, Canada, Cambodia, Thailand, Cuba, Iran, North Korea and so on. For more information regarding the Restricted Locations, please refer to the User Agreement. Should you have any other questions, please contact our Customer Support Team.