新消息:Helius 收购 Light Protocol
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 端点估算租金。Test Drive 通过接收以 usize 表示的账户数据长度简化了这一过程。也可以使用 Solana rent CLI 子命令估算账户免租所需的最低 SOL 数量。例如,在本文撰写时,运行命令 solana rent 20000 将返回 免租最低金额: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 运行时执行,该运行时专为处理交易和程序逻辑而设计。

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 运行时会执行该程序。

什么是交易?

交易是链上活动的基础。它们是调用程序和执行状态变更的机制。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 位用于确定从版本 0 开始序列化的 Message 版本。如果第一位未设置,则使用所有字节对旧版 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 开发的最新动态,并在我们发布新内容时收到更新

放大图片