全新:Solana Privacy Rings 现已上线主网
Solana 编程模型
博客/基础知识

Solana 编程模型:Solana 开发入门

Developer Experience EngineerX 上的 0xIchigoLinkedIn 上的 0xIchigoGitHub 上的 0xIchigo
阅读需 24 分钟

本文介绍什么?

Solana 的去中心化计算方法基于一个简单原则:一切内容都存储在各自称为账户的内存区域中。Solana 以全局键值存储的形式运行,公钥是对应账户的唯一标识符。账户是 Solana 的支柱,因为它们负责存储状态,涵盖从程序到代币余额的所有内容。交易用于更新账户并反映状态变化。

本文将探讨 Solana 架构的复杂性。我们先概述集群和状态的概念,再介绍账户与程序作为 Solana 基础组件所发挥的作用。然后,我们将研究交易如何实现账户与程序之间的动态交互。

读完本文后,你将全面理解 Solana 的编程模型。你将熟悉集群架构、账户在数据存储中的关键作用,以及交易更新账户数据的过程。此外,你还将了解 Solana 独有的功能,例如租金系统和版本化交易。

什么是 Solana 集群?

Solana 架构的核心是集群,即一组协同处理交易并维护单一账本的验证者。Solana 有多个相互独立的集群,每个集群都有特定用途:

  • Localhost:位于默认端口 8899 的本地开发集群。Solana 命令行界面 (CLI)内置了一个测试验证者,开发者可按个人需求进行自定义,无需空投,也不会受到速率限制
  • Devnet:用于在 Solana 上测试和实验且无需承担后果的沙盒环境
  • Testnet:Solana 核心贡献者在更新和功能进入主网前进行试验的测试平台,也可供开发者进行性能测试
  • Mainnet Beta:发生真实交易的实时无许可集群。这是“真正的”Solana,用户、开发者、代币持有者和验证者每天都在这里交互

每个集群都独立运行,彼此完全不感知。发送到错误集群的交易会被拒绝,从而确保各个运行环境的完整性。

可以把集群想象成一个单体数据堆。在计算机科学中,堆是指可以动态存储和修改数据的内存区域。但需要注意的是,集群并非真的使用堆数据结构。这个类比只是一种概念工具,有助于理解集群由多个内存区域组成,这些区域可按需分配和释放。将集群理解为动态堆,是理解网络中如何管理、访问和保护数据的关键。

你也可以把这个单体数据堆想象成某种数字仓库。在这里,数据就像货架上的箱子,每个箱子都有唯一标签,并受到关于移动方式和内容变更的特定规则约束。这样就能建立一个安全有序的系统,只允许经过授权的移动或更改。

智能合约在 Solana 上称为程序,它们会获得仓库或堆中的专属空间,并可自行管理。程序虽然可以读取仓库内任何空间,但要修改不属于自己的空间内容,就需要获得特定权限。唯一普遍允许的操作,是将 Solana 的原生加密货币 lamports 转入仓库内的任意空间。

所有状态都存在于这个堆中,程序也不例外。每个区域都有一个拥有并管理它的程序。例如,程序归 BPFLoader 所有,后者是负责加载、部署和升级链上程序的程序。我们将这些内存区域,也就是数字仓库里的箱子,称为账户。

什么是账户?

Solana 上的一切都是账户。可以把账户看作持久保存数据的容器,类似于计算机上的文件。账户是 Solana 编程模型的构建基础,用于存储状态,例如账户余额、所有权信息、账户是否包含程序以及租金信息。

Solana 上有三类账户:

  • 存储数据的账户
  • 存储可执行程序的账户
  • 存储原生程序的账户

还可以根据功能进一步区分这些账户类型:

  • 可执行账户——能够运行代码的账户
  • 不可执行账户——仅用于存储数据、无法执行代码的账户(因为它们不包含任何代码!)

上图展示了几个可执行账户和不可执行账户的示例。在可执行账户中,Bubblegum 是程序账户的一个示例。它是 Metaplex 用于创建和管理压缩 NFT 的程序。Vote Program 是原生程序账户的一个示例,用于创建和管理跟踪验证者投票状态及奖励的账户。我们将在什么是程序?一节介绍程序账户与原生程序账户之间的区别。目前只需知道,Solana 上存在不同类型的可执行账户。

此外,每个不可执行账户都可以归类为数据账户。数据账户的示例包括:

账户结构

账户按照 AccountInfo 结构体组织:

代码
pub struct AccountInfo<'a> {
    pub key: &'a Pubkey,
    pub lamports: Rc>,
    pub data: Rc>,
    pub owner: &'a Pubkey,
    pub rent_epoch: Epoch,
    pub is_signer: bool,
    pub is_writable: bool,
    pub executable: bool,
}

账户通过其地址 (key) 进行标识,该地址是唯一的 32 字节公钥。

lamports 字段保存该账户拥有的 lamports 数量。一个 lamport 等于十亿分之一 SOL,而 SOL 是 Solana 的原生代币。

data 是该账户存储的原始数据字节数组。它可以存储从数字资产元数据到代币余额的任何内容,并可由程序修改。

owner 字段包含该账户的所有者,以程序账户地址表示。账户所有权需要遵循以下规则:

  • 只有账户所有者可以修改其数据并提取 lamports
  • 任何人都可以向账户存入 lamports
  • 账户所有者可以将所有权转移给新所有者,前提是账户数据已重置为零

is_signer 字段是一个布尔值,表示相关账户的所有者是否已签署交易。换言之,它会告诉交易涉及的程序,该账户是否为签名者。成为签名者意味着该账户持有与公钥对应的私钥,并有权批准拟议交易。

is_writable 字段是一个布尔值,表示账户数据是否可修改。Solana 允许交易将账户指定为只读,以支持并行处理。运行时允许不同程序同时访问只读账户,同时通过交易处理顺序来处理可写账户可能发生的写入冲突。这可确保仅并行处理不存在冲突的交易。

executable 字段是一个布尔值,表示账户是否可以处理指令。没错,这确实意味着程序存储在账户中,我们将在下一节深入介绍。但首先,需要了解租金的概念。

rent_epoch 字段表示该账户下一次需要支付租金的 epoch。epoch 是领导者调度表保持有效的 slot 数量。与操作系统中的传统文件不同,Solana 账户的生命周期以 lamports 数量表示。账户能否持续存在取决于其 lamport 余额,这就引出了租金的概念。

租金

租金是维持 Solana 账户存续并确保账户保存在验证者内存中所产生的存储成本。租金按 epoch 评估收取;epoch 是由领导者调度表有效期间的 slot 定义的时间单位。租金机制的运作方式如下:

  • 租金收取——每个 epoch 收取一次租金。交易引用账户时也可能收取租金
  • 租金分配——部分已收租金会被销毁,即永久退出流通。其余部分会在每个 slot 后分配给投票账户
  • 租金支付——如果账户没有足够的 lamports 支付租金,其数据会被移除,账户也会在称为垃圾回收的过程中被释放
  • 租金豁免——如果账户维持相当于两年租金的最低余额,即可免除租金。所有新账户都必须达到这一租金豁免门槛,具体金额取决于账户大小
  • 租金取回——用户可以关闭账户并收回其中剩余的 lamports,从而取回账户中存储的租金

对于特定账户大小,可以使用 getMinimumBalanceForRentExemption RPC 端点估算租金。Helius API Playground 接受以 usize 表示的账户数据长度,让这个过程更加简单。也可以使用 Solana rent CLI 子命令估算账户获得租金豁免所需的最低 SOL 数量。例如,在撰写本文时,运行命令 solana rent 20000 将返回 Rent-exempt minimum: 0.14009088 SOL。

Solana 地址

Solana 实际上有两种“类型”的地址。Solana 使用 ed25519 创建地址,这是一种采用 SHA-512 (SHA-2) 和 Curve22519 椭圆曲线的 EdDSA 签名方案。由此产生的 32 字节公钥是主要地址格式。由于这些公钥未经哈希处理,因此可以直接使用。

有效地址必须是 ed25519 曲线上的点。不过,并非所有地址都需要从这条曲线派生。程序派生地址 (PDA) 在曲线外生成,这意味着它们没有对应的私钥,也不能用于签名。PDA 通过 System Program 创建,在程序需要管理账户时使用。这里仅作补充说明,让你了解 Solana 上存在不同类型的地址。我们将在后续文章中介绍 PDA。

Solana 账户与 Ethereum 账户有何不同?

Ethereum 有两种主要账户类型:外部拥有账户 (EOA) 和合约账户。EOA 由私钥控制,而合约账户由其合约代码管理,无法自行发起交易。

EOA 和合约账户采用相同的账户结构:

  • 余额——每个账户都有以 Ether 计量的余额
  • Nonce——对于 EOA,这是从该账户发出的交易数量;对于合约,这是该账户创建的合约数量
  • 存储根——Merkle Patricia Trie 根节点的 256 位哈希,该树是账户存储内容的一种编码形式
  • CodeHash——合约的 Ethereum Virtual Machine (EVM) 代码哈希。它不可变,即创建后代码不会改变,但状态可以改变。需要注意的是,Ethereum 合约升级也存在例外,例如使用代理模式,但这不在本文讨论范围内。由于 EOA 不包含代码,因此该字段是空字符串的哈希

Solana 采用更统一的账户模型,任何账户都有可能成为程序。代码与数据分离,营造出更高效、更灵活的环境。Solana 程序无状态,可以与各种数据账户交互,而无需重复部署。这对去中心化金融 (DeFi) 应用尤其有利,因为用户通常希望在不同协议之间交互,而无需跨程序转移资产。相比之下,Ethereum 的编程模型将代码和状态合并为单一实体。由于状态变更需要 gas,这会让交互更加复杂,成本也可能更高。

Solana 账户过去需要支付租金,因此必须持有最低余额才能保持活跃。这确保未使用或资金不足的账户最终会被网络回收,从而减少状态膨胀。最近的更新使主网上不再存在需要支付租金的账户,所有账户都必须获得租金豁免。相比之下,Ethereum 使用 gas 管理资源分配。在这种模型下,除非明确清除,否则合约存储会无限期保留。Solana 的方法为状态存储提供了更可预测的成本结构,而 Ethereum 的成本可能发生变化,并在网络拥堵期间变得难以承受。

下一节将介绍 Solana 如何将程序逻辑与状态分离。与 Ethereum 的编程模型相比,你将看到这种模块化方法如何提高链上操作效率,同时为开发者提供透明且可预测的成本结构。

什么是 Solana 程序?

程序是由 BPF Loader 拥有的可执行账户。它们由专门处理交易和程序逻辑的 Solana Runtime 执行。

Solana 编程模型的一个显著特点是代码与数据分离。程序是无状态的,这意味着它们不会在内部存储任何状态。程序运行所需的全部数据都存储在单独的账户中,并由交易通过引用传递给程序。这种设计让程序只需进行一次通用部署,就能与不同账户交互。

Solana 上的程序可以:

  • 拥有其他账户
  • 读取其他账户或向其存入资金
  • 修改其所拥有账户的数据或扣除其中的资金

程序分为两类:

  • 链上程序——由用户编写并部署到 Solana 的程序。升级权限通常由部署程序的账户持有,该权限可用于升级程序
  • 原生程序——集成到 Solana 核心中的程序,提供验证者运行所需的基础功能。原生程序只能通过全网软件更新进行升级。常见示例包括 System Program、BPF Loader Program 和 Vote Program。

链上程序和原生程序都可以由用户及其他程序调用。它们的主要区别在于升级机制:链上程序可由其升级权限持有者升级,而原生程序只能随集群更新一起升级。

Solana Labs 维护着一组精选链上程序,称为 Solana Program Library。该程序库支持多种链上操作,包括代币借贷和创建质押池。例如,Associated Token Account Program 为用户钱包与对应代币账户的关联建立了标准和机制。此外,SPL 也在不断发展。Token-2022 等程序在 Token Program 提供的功能基础上进行构建和扩展。

Solana 程序通常使用 Rust 开发,并借助 Anchor 这一约定明确的框架减少样板代码、简化序列化和反序列化。虽然 Rust 是首选语言,但开发者并不受其限制,也可以使用 C、C++ 以及任何以 LLVM BPF 后端为目标的语言。该后端是 LLVM 的一个组件,可以将程序编译为 BPF 字节码。Solang 和 Neon Labs 最近的发展也让开发者可以使用 Solidity 开发程序。

程序通常先在 Localhost 和 Devnet 上开发和测试,再部署到 Testnet 或 Mainnet Beta。开发者可以通过 Solana CLI 使用命令 solana program deploy <path to program> 部署程序。程序被编译为包含 BPF 字节码的 ELF 共享对象后,会上传到指定的 Solana 集群。已部署程序存在于标记为 executable 的账户中,账户地址则作为 program_id。

最初,Solana 程序会部署到容量为程序大小两倍的账户中。Solana 1.16 更新引入了对可调整大小账户的支持,为开发者提供更灵活的资源分配方式。现在,开发者可以先使用较小的账户部署程序,之后再扩展其大小。

如上所述,程序被视为无状态,因为与其交互的所有数据都存储在通过引用传递的单独账户中。所有程序都有一个处理指令的单一入口点,它接收一个 program_id、一个账户数组,以及以字节数组形式提供的指令数据。交易调用程序后,程序由 Solana Runtime 执行。

什么是交易?

交易是链上活动的支柱。程序通过交易被调用,状态变更也通过交易生效。Solana 交易是一组指令,它告诉验证者应执行哪些操作、操作哪些账户,以及是否具备执行这些操作所需的权限。

交易由三个主要部分组成:

  • 一个待读取或写入的账户数组
  • 一条或多条指令
  • 一个或多个签名

Solana 交易遵循 Transaction 结构体。该结构体提供网络处理和验证操作所需的信息,其定义如下:

代码
pub struct Transaction {
    pub signatures: Vec,
    pub message: Message,
}

signatures 字段包含一组与序列化 Message 对应的签名。每个签名都与 Message 的 account_keys 列表中的一个账户密钥关联,首先是费用支付者。费用支付者负责承担处理交易时产生的交易费用,通常也是发起交易的账户。所需签名数量等于 num_required_signatures,该值在消息的 MessageHeader 中定义。

message 本身是一个 Message 类型的结构体,其定义如下:

代码
pub struct Message {
    pub header: MessageHeader,
    pub account_keys: Vec,
    pub recent_blockhash: Hash,
    pub instructions: Vec,
}

消息的 header 包含三个无符号 8 位整数:所需签名数量(即 num_required_signatures)、只读签名者数量,以及只读非签名者数量。

account_keys 字段列出交易涉及的所有账户地址。请求读写访问权限的账户排在前面,之后是只读账户。

recent_blockhash 是近期区块哈希,包含一个 32 字节 SHA-256 哈希。它用于表明客户端最近一次观察账本的时间,并充当近期交易的有效期。验证者会拒绝区块哈希过旧的交易。此外,加入近期区块哈希有助于防止重复交易,因为与此前交易完全相同的交易会被拒绝。如果出于任何原因,交易需要在提交到网络前很久签署,可以使用持久交易 nonce 代替近期区块哈希,以确保交易的唯一性。

instructions 字段包含一个或多个 CompiledInstruction 结构体,每个结构体都规定网络验证者要执行的特定操作。

指令

指令是对 Solana 程序进行一次调用的指示。它是程序中最小的执行逻辑单元,也是 Solana 上最基本的操作单元。程序解析指令传入的数据,并对指定账户执行操作。Instruction 结构体定义如下:

代码
pub struct Instruction {
    pub program_id: Pubkey,
    pub accounts: Vec,
    pub data: Vec,
}

program_id 字段指定要执行程序的公钥,也就是处理该指令的程序地址。该公钥所指示的程序账户所有者,会指定负责初始化和执行程序的加载器。链上 Solana Bytecode Format (SBF) 程序部署后,加载器会将其标记为可执行。Solana 运行时会拒绝任何试图调用未标记为可执行账户的交易。

accounts 字段列出指令可能读取或写入的账户。这些账户必须以 AccountMeta 值的形式提供。任何数据可能被指令修改的账户都必须指定为可写,否则交易将失败。这是因为程序无法写入其不拥有或不具备所需权限的账户。这同样适用于修改账户的 lamports:从不属于该程序的账户中扣除 lamports 会导致交易失败,但可以向任何账户添加 lamports。accounts 字段也可以指定程序不会读取或写入的账户。这样做是为了影响运行时对程序执行的调度,否则这些账户会被忽略。

data 是一个通用的 8 位无符号整数向量,作为传递给程序的输入。该字段至关重要,因为它包含程序要执行的编码指令。

Solana 不限定指令数据的格式。不过,它通过 bincode 和 borsh(用于哈希的二进制对象表示序列化器)内置了序列化支持。序列化是将复杂数据结构转换为可传输或存储的扁平字节序列的过程。选择数据编码方式时,应考虑解码开销,因为整个过程都在链上发生。与 bincode 相比,Borsh 序列化通常更受青睐,因为它拥有稳定的规范、JavaScript 实现,而且通常效率更高。

程序使用辅助函数简化受支持指令的构建。例如,System Program 提供了一个辅助函数来构建 SystemInstruction::Assign 指令:

代码
pub fn assign(pubkey: &Pubkey, owner: &Pubkey) -> Instruction {
    let account_metas = vec![AccountMeta::new(*pubkey, true)];
    Instruction::new(
        system_program::id(),
        &SystemInstruction::Assign { owner: *owner },
        account_metas,
    )
}

该函数构建一条指令,在处理该指令时,会将指定账户的所有者更改为所提供的新所有者。

单笔交易可以包含多条指令,这些指令会按照列出顺序依次以原子方式执行。这意味着所有指令要么全部成功,要么全部失败。因此,指令顺序可能至关重要。程序必须经过加固,能够安全处理任何可能的指令序列,从而防止潜在攻击。

例如,在反初始化期间,程序可能会尝试将账户的 lamport 余额设为零,以此反初始化账户。这种做法假设 Solana 运行时会删除该账户。该假设在交易之间成立,但在指令之间或跨程序调用之间并不成立(我们将在后续文章中介绍跨程序调用)。程序应明确将账户数据清零,以防范反初始化过程中这一潜在缺陷。否则,攻击者可能发出后续指令,利用账户被认为已删除这一点,例如在交易完成前重复使用该账户。

什么是版本化交易?

Solana 交易采用 IPv6 最大传输单元 (MTU) 标准,以确保数据在集群中快速可靠地传输。Solana 的网络栈使用较为保守的 1280 字节 MTU。为标头预留空间后,数据包可使用 1232 字节。因此,Solana 交易的大小受此限制。

这一大小限制有助于实现多项网络增强功能,但也限制了单笔交易可执行操作的复杂度。由于每个账户地址占用 32 字节存储空间,一笔不含任何指令的交易最多可以存储 35 个账户。对于需要在单笔交易中使用超过 35 个免签名账户的用例,这项限制会带来挑战。

为解决这个问题,Solana 引入了支持多个交易格式版本的新交易格式。Solana 运行时目前支持两个交易版本:

  • legacy——原始交易格式
  • 0(版本 0)——支持地址查找表的最新交易格式

版本 0 的发布旨在支持地址查找表 (ALT)。它们本质上将账户地址存储在链上的表格型数据结构中。这些表是存储账户地址的独立账户,交易可以通过 1 字节的 u8 索引引用这些地址。由于每个账户只需使用 1 字节而非 32 字节,交易大小会显著减小。ALT 对涉及大量账户的复杂操作尤其有用,例如 DeFi 应用中的常见操作。

此图改编自 Solana Cookbook 的版本化交易一节

“版本化交易”一词是指 Solana 同时支持旧版和版本 0 交易格式的方式。这种方法在采用运行时增强功能的同时,也确保了可组合性。

版本化交易的结构

VersionedTransaction 的定义如下:

代码
pub struct VersionedTransaction {
    pub signatures: Vec,
    pub message: VersionedMessage,
}

signatures 字段是交易签名者的签名列表,用于验证交易身份并维护交易完整性。message 是交易的实际内容。它封装在 VersionedMessage 类型中,后者是一个轻量级枚举包装器,可处理旧版和版本 0 消息:

代码
pub enum VersionedMessage {
    Legacy(Message),
    V0(Message),
}

消息版本由序列化过程中的第一位决定。如果第一位已设置,则剩余 7 位用于确定所序列化的 Message 版本,从版本 0 开始。如果第一位未设置,则使用全部字节对旧版 Message 格式进行编码。这是因为存在两个名称完全相同的 Message 结构体,但它们位于不同的模块中:legacy 和 v0。

Message 表示交易的精简内部格式,用于网络传输和运行时处理。它包含交易指令使用的所有账户的线性列表、详细描述账户数组结构的 MessageHeader、近期区块哈希,以及消息指令的紧凑编码。以下是 v0 Message 结构体的结构:

代码
pub struct Message {
    pub header: MessageHeader,
    pub account_keys: Vec,
    pub recent_blockhash: Hash,
    pub instructions: Vec,
    pub address_table_lookups: Vec,
}

旧版消息与 v0 消息的区别在于后者包含 address_table_lookups 字段。

将编程模型与 Solana 交易流程相结合

Solana 的编程模型与其账户和交易系统深度集成。以下是这些概念之间的关联:

  • 账户即状态——Solana 账户充当程序的状态容器。编程模型的核心是在响应指令时修改这些容器中存储的数据
  • 指令——程序定义如何处理交易中包含的指令。这些指令是与账户数据交互的可执行组件
  • 序列化与处理——交易序列化时,程序指令会规定账户状态的变更。无论使用旧版还是版本 0 交易格式,序列化过程都会遵循程序的设计
  • 原子性——Solana 的编程模型确保以原子方式处理指令。程序设计必须能够安全高效地处理并发交易
  • 可扩展性——Solana 的编程模型通过地址查找表 (ALT) 等功能支持扩展。这些表可以缩小交易大小,并增加交易可引用的账户数量

Solana 的编程模型不只是编写代码,还需要理解代码如何在更广泛的生态系统中交互。账户是该模型的关键组成部分,是网络存储和修改数据的主要方式。交易通过告知验证者需要创建、更新或删除哪些数据来实现链上活动。开发者必须深入理解这些方面,才能构建针对性能进行优化、并与 Solana 生态系统高效协同的应用。

总结

恭喜!本文带你探索了 Solana 系统架构的复杂性,并深入介绍了作为单体数据堆的集群概念。我们了解了这个堆如何划分为称为账户的独立内存区域,而账户正是 Solana 编程模型的支柱。从用户代币到定义网络行为的程序,一切都存储在账户中,并通过交易进行修改。

对开发者而言,理解 Solana 的去中心化计算方法至关重要。只有掌握账户、程序和交易的复杂机制,才能构建充分利用 Solana 能力的应用。关键在于理解代码与状态解耦的系统。由此产生的无状态程序通过账户与数据交互,具备前所未有的可组合性和可升级性。

无论是投资者还是普通用户,理解 Solana 的设计如何构建一个稳健、灵活且高效的生态系统,都有助于认识该平台的可行性,以及它推动 Solana 独有创新应用的能力。

如果你已经读到这里,感谢你!准备继续深入了解吗?立即加入我们的 Discord,开始在 Solana 上编程。

其他资源 / 延伸阅读

订阅 Helius

及时了解 Solana 开发的最新动态,并在我们发布新内容时收到更新

放大图片