这篇文章是一个详尽的指南,介绍了如何审计Solana程序,涵盖了Solana的基本概念、账户模型、数据读写、跨程序调用、代币管理等多个主题。作者结合自己的学习经历,提供了丰富的实践示例和重要概念,为读者奠定了坚实的基础,并推荐了一些学习资源。
与大多数没有专业软件开发背景的 web3 安全研究人员一样,我的旅程始于 Ethereum、EVM 和 Solidity。
自从我进入无尽的 黑暗森林 已过去十六个月。
我最近决定研究 Solana,以扩展我在可编程区块链方面的知识。并不是因为 EVM 无聊(我看得出你要说什么)。
但随着 Solana 生态系统的影响力不断增加,我觉得能够应对这股新需求是个好主意。
由于我以前对 Rust 和 Solana 一无所知,我花了大约 7 天时间学习基础知识。
我的学习过程得益于许多优秀的资源,尤其是 Rareskills 的这个非常好的课程:60 Days of Solana。
我也尝试找到一本指南,以帮助我快速掌握进入代码库所需的最低要求,但没有找到——这就是我写这本指南的理由。
我的目标是帮助你轻松导航 Solana 项目。这不需要你对 Solana 有先前的了解,因为我将尽量解释跟进本文所需的必要和重要概念。
虽然这将帮助你快速开始 Solana 部分,但你仍然需要学习一点 Rust,才能 真正 感到舒适地使用代码库,因为一些编程概念对这门语言非常特定。
然而,不要感到压力去单独掌握 Rust - 你可以在审计时学习。Solana 生态系统仅使用了 Rust 功能的一小部分,并且在 LLM 的帮助下理解特定代码段,学习体验应该是相当可控的。
Solana 是一种高吞吐量区块链,就 TVL 而言是第三重要的(仅次于比特币和以太坊),并且是紧随以太坊之后的第二种可编程区块链。
可编程区块链 可以被视为一个 分布式世界计算机,存储数据并执行为任何愿意支付的人命令。
Solana 很有趣,因为它走了一条与以太坊截然不同的道路,从头开始创建自己的层。它的架构优先考虑可扩展性和速度,通过特定机制实现。
但这里真正让我们感兴趣的是 Solana 的执行层,也被称为 Solana 虚拟机 (SVM)。这个层负责接收交易并依据区块链的状态执行它们。
在计算机上,数据存储在文件中——在 Solana 上,数据存储在 账户 中。那些数据可以是信息数据,或者是可执行数据,但归根结底,这无非是存储在“账户”里的零和一。
在我看来,账户类似于有特定属性的文件,因为它们存在不同类型(文件扩展名),其中一些具有“原生扩展名”(数据账户、可执行账户),它们可以包含任何东西,从 DEX 程序到用户数据(例如,存储用户代币余额的“数据账户”)。
此外,像操作系统上的文件一样,账户也有访问控制机制,确保只有授权地址才能对其执行操作。
账户模型 可能是我花时间最多的部分,可能是因为模型与代码之间的关系并不是那么明显。
但实际上,这很简单理解。
来自我提前提到的 Rareskills 课程的一个很好类比将 Solana 比作众所周知的 Linux 原则:“一切都是文件。”
对于 Solana,我们可以说,“一切都是账户。”
账户实际上是在区块链上一个专用的“空间”(最多 10MB),可以包含不同种类的信息,并可以通过代表其“位置”的地址进行访问。
虽然在 EVM 上,账户可以同时保存逻辑和可变数据,但在 Solana 上,账户只能保存数据或可执行逻辑,而不能同时保存两者。
如果 Solana 账户无法同时存储数据和逻辑,这是否意味着一个程序没有直接访问数据的权限?那程序如何 动态 地在执行其逻辑时读写数据呢?
程序如何创建与用户及其状态有关的信息并保持更新?
一个程序(一般意义上)——通常——需要保持状态变量,以存储其操作所必需的重要信息。考虑自动贩卖机程序作为示例——它需要存储各类信息,例如:每种产品的剩余数量、价格、位置、技术人员的访问码等。由于其值会随着时间的推移而变化,在各种操作(接收资金、交付产品等)响应中,故被称为变量。
与大多数传统系统不同,可执行账户程序 在 Solana 中不能将数据与它们的可执行代码存储在一起。相反,它们必须利用替代存储解决方案。这就是 数据账户 作为主要存储机制发挥作用的地方。
下图来自 Solana 文档:表示了两个账户:一个程序和一个数据账户。程序账户拥有数据账户,并使用它来存储与其程序相关的信息。
对于程序需要的每一个新变量,都可以创建一个新数据账户。一旦创建,要访问存储在新创建账户中的变量,程序只需记住其地址即可进行读写。
正如你可能想到的那样,为每个所需变量创建一个新的唯一账户将是非常低效的。相反,Solana 允许开发者创建结构化存储解决方案,多个值可以存储在单个账户中。
为了以结构化方式写入数据账户,程序使用 序列化器,而读取则需要 反序列化器(在使用常用的 Solana 框架和库时,此过程是透明的)。
但是,如何创建这些数据账户呢?
账户(因此数据账户)在区块链上都有各自的地址,我们可以用它们进行访问。虽然对于程序而言,要求读取外部数据所需的地址是有意义的,但如果程序必须存储或请求用户作为输入来访问其自身状态变量的地址,这显然是非常低效的。
幸运的是,情况并非如此,程序创建的数据账户(由程序创建)有一个特殊类型的地址,称为 程序派生地址(或 PDA)。
PDA 是通过 哈希 程序自身地址和任何选择性种子的字节值以确定的方式生成的,还有一个 bump(你可以稍后搜索,但现在不是重点)。
由于这个过程是确定性的,程序现在有办法创建新变量,并自动检索这些变量。再次重申,所有这些过程借助现有的 Solana 框架和库是透明的。
Solana 程序 是执行区块链逻辑的基础。虽然 Solana 提供几个基本的 原生程序(如账户部署工具),但生态系统主要由用户部署的程序组成,扩展了区块链的功能。
与 EVM 智能合约不同,Solana 程序 是无状态的,不能存储可变数据(它们可以有常量,因为这是嵌入在程序的字节码中)。
相反,程序必须在被调用时接收数据账户的地址作为参数,从而允许它们外部访问和操作数据。
要访问一个程序并执行其逻辑,我们需要调用程序的 指令。
一个 指令 需要三个基本组成部分:
为了说明这些概念,让我们看看一个简化示例(要注意,我为了清晰性删除了代码中的许多重要部分——我们将在第 7 部分探索包含所有必要组件的完整实现):
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
// 将 `amount` 的代币从 user_token_account 转移到 vault...
}
// 指令所需的上下文
pub struct Deposit<'info> {
// 用户的钱包,必须签署交易
pub user: Signer<'info>,
// 我们将从中提取的用户代币账户
pub user_token_account: Account<'info, TokenAccount>,
// 我们将存入代币的保险库
pub vault: Account<'info, TokenAccount>,
// 我们将用以转账代币的程序
pub token_program: Program<'info, Token>,
}
上面是一个 deposit
指令,以多个以 Deposit
结构描述的账户作为输入:
user
是调用账户(Signer
类型是特殊类型,因为它会自动存储调用者信息)user_token_account
和 vault
是两个数据账户,其中代币将被提取/存入。正如我们在第 2 部分所说,数据通常以结构体方式组织,对于这两个账户而言即 TokenAccount
token_program
是我们将调用以执行转账的程序跨程序调用 是执行来自另一个程序的指令。为此,需提供(1)程序地址(2)程序指令所需的数据;我们称之为 CPI 上下文。
以下是我借用自这一节的示例:
pub fn send_sol(ctx: Context<SendSol>, amount: u64) -> Result<()> {
let cpi_context = CpiContext::new( // 构建 CPI 上下文:
ctx.accounts.system_program.to_account_info(), //(1) 程序地址
system_program::Transfer { //(2) 程序所需数据
from: ctx.accounts.signer.to_account_info(), //(2)
to: ctx.accounts.recipient.to_account_info(),//(2)
}
);
// 使用上下文调用程序(以及额外参数 `amount`)
let result = system_program::transfer(cpi_context, amount);
if result.is_ok() {
return Ok(());
} else {
return err!(Errors::TransferFailed);
}
}
该指令将 SOL(Solana 的本地加密货币/代币)从签署者转移到接收者。
我们需要做的第一件事是创建指令将被执行的上下文:
system_program.to_account_info
表示Transfer
打包,包含两个信息:from
(发送 SOL 的地址)和 to
(接收 SOL 的地址)。随后,使用上下文(和附加参数 amount
)执行 CPI。
在这个示例中,被调用的程序是 Solana 系统程序,但如果我们想调用另一个用户部署的程序,它将是这样的:
external_program::cpi::instruction_name(cpi_ctx, additional_param1, ...)
Solana 的代币生态系统与我们在 EVM 中的了解非常不同。
在 EVM 中,每个代币都有自己的合约(即在 Solana 中是程序),而在 Solana 中,有一个叫 SPL Token program 的程序,代币可以从中派生 Mint Accounts。
每个代币实例将有其唯一的 Mint 账户:例如 USDC Mint Account、USDT Mint Account,等等。
然后,从这些 Mint 账户,用户可以创建 Token Accounts - 专用于持有特定代币的账户。
考虑一个用户,如 Alice,她持有多种代币(USDC、USDT 和 DEGEN)。她需要为每种资产创建一个单独的 Token Account,所有这些都派生自各自的 Mint Accounts。此结构在 Solana 生态系统的所有用户中是统一的。
下面是一幅展示代币程序组织结构的示例:
在此架构中,唯一的程序账户是 SPL Token Program。其他账户为数据账户,可以被 SPL Token Program 修改。
SPL Token Program 是这些账户的拥有者,因为只有该程序有权修改这些账户。这意味着要更新它们,我们需要对 SPL Token Program 执行 CPI,并提供我们希望更新的账户(如上文 第 4 节 所述)。
显然,SPL Token Program 实施了访问控制逻辑,以防止用户从他们不拥有的账户转移代币。
如果你记得我们讨论过的 PDAs(程序派生账户),只有部署这些账户的程序才能修改其状态。
但这些数据账户(Mint Accounts 和 Token Accounts)有许多字段,其中一个称为 owner
,不同于我们在第二段中提到的 程序拥有者。该字段不是数据的一部分,而是执行在更高层的“Solana 元数据”。
SPL Token Program 会检查账户内部的 owner
字段,并与调用者签名(类似于 EVM 中的 tx.origin)进行比较,以确保只允许合法访问。
请查看这张来自 Solana 文档 的周到布局:
如果你刚接触 Solana 项目架构或 Rust 项目,结构可能看起来有些压倒性。让我们通过检查每个文件的目的来分解这个简单项目(在讨论更复杂的项目之前):
## 1. 基本项目 (没有指令文件夹)
my-solana-project/
├── Anchor.toml # Anchor 配置文件
├── Cargo.toml # Rust 配置文件
├── programs/ # 所有程序的文件夹
│ └── my-program/ # 每个程序将有自己的文件夹
│ └── src/
│ ├── instructions/ # 包含 lib.rs 调用的每个指令的文件夹
│ │ ├── mod.rs # lib.rs 导入所有指令
│ │ ├── initialize.rs # 每个指令
│ │ ├── deposit.rs
│ │ └── withdraw.rs
│ ├── lib.rs # 主程序文件
│ ├── state.rs # 程序状态结构
│ └── errors.rs # 自定义错误
└── tests/
└── my-program.ts
项目特定文件:
Anchor.toml
:这是 Anchor 工作的配置文件,具体设置可以查看 这里Cargo.toml
:Rust 的工作区配置文件,里面埋藏着一些非常重要的东西:默认情况下,Rust 不会检查溢出,但可以在此文件中设置密钥 overflow-checks = true
在 [profile.release]
部分
Cargo.toml 文件的示例 在这里程序特定文件:
programs/
:该文件夹存储所有程序my-program/
:这里应该是程序名称,根据项目的不同,在 programs
父文件夹内可以有多个程序文件夹instructions/
:包含可以从 lib.rs
中调用的所有指令的逻辑lib.rs
:程序的入口点,包含公开可访问的函数(受任何实现的授权逻辑限制)。它通常作为一个包装,将调用路由到 instructions
文件夹中适当的指令处理程序state.rs
:通常存放不同的数据结构位置,这些结构将被指令使用error.rs
:自定义错误代码定义的集中文件tests/
:测试目录。虽然你可以用 TypeScript 和 Rust 编写测试,但由于 TypeScript 对测试库的更全面支持,因此更为可取现在让我们看看另一个项目(我只讨论差异):
my-solana-project/
├── Anchor.toml
├── Cargo.toml
├── package.json
├── programs/
│ ├── my-program/ # 第一个程序
│ │ ├── Cargo.toml
│ │ └── src/
│ │ ├── lib.rs # 每个程序都有自己的入口文件
│ │ ├── instructions/
│ │ │ ├── mod.rs
│ │ │ ├── deposit.rs
│ │ │ └── withdraw.rs
│ │ ├── state.rs
│ │ └── errors.rs
│ └── my-other-program/ # 第二个程序
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs # 每个程序都有自己的入口文件
│ ├── instructions/ # 指令按其共同特征分组到不同文件夹下
│ │ ├── mod.rs
│ │ ├── utils.rs # 包含指令使用的公共工具变量的非指令文件
│ │ ├── user_management/ # 用户相关指令
│ │ │ ├── mod.rs
│ │ │ ├── create_user.rs
│ │ │ └── update_user.rs
│ │ └── token_management/ # 代币相关指令
│ │ ├── mod.rs
│ │ ├── mint.rs
│ │ └── burn.rs
│ ├── state/ # 我们更改为包含多个状态文件的状态文件夹
│ │ ├── mod.rs
│ │ ├── user.rs # 对于每组指令
│ │ └── token.rs # 对于每组指令
│ └── errors.rs
└── tests/
此处的主要差异是:
my-other-program/
实施更复杂的逻辑时,开发人员决定根据共同特征将指令分组到单独文件夹中。
utils.rs
文件位于指令组之外,包含在多个指令中使用的共享工具函数(例如,数学运算)instructions/
的根目录和每个指令组文件夹中都可以看到一个 mod.rs
文件:这样有助于在 lib.rs
中简化指令的导入state.rs
文件演变为一个专用的 state/
目录,包含各自指令组结构的单独状态文件这就是你将遇到的大多数 Solana 项目看起来的基本样子。
Anchor 是一个用 Rust(和 Typescript 用于测试)开发的框架,由 Coral 提供,提供帮助开发人员更快地编写安全程序的工具。
该框架包含众多 宏 和 特性(Rust 概念),旨在实现许多重要组件,例如我们在 第 2 节 中提到的账户数据 序列化器和反序列化器、账户的 自动访问控制检查、或者一个将指令调用路由到合适的逻辑部分的 指令调度器。
正如你所见,所有这些工具真的很重要,你可能不想从头重新开发这些组件。因此,你将遇到的大多数 Solana 程序使用 Anchor,而你将需要了解基本知识。
让我们从你将始终遇到的两个开始,declare_id!()
和 #[program]
。
它们位于 lib.rs
,可以视为程序的“主函数”,因为这里是所有程序入口点所在的位置。
请看这个示例代码:
use anchor_lang::prelude::*;
declare_id!("ABC123xyz...");
##[program]
pub mod basic_program {
use super::*;
pub fn entrypoint_to_instruction_1(ctx: Context<InstructionOne>) -> Result<()> {
instructions::instruction_1::handler(ctx)
}
pub fn entrypoint_to_instruction_2(ctx: Context<InstructionTwo>) -> Result<()> {
instructions::instruction_2::handler(ctx)
}
}
declare_id!()
是一个 类函数宏(基本上是一个强大的工具,在编译之前注入新代码)用于定义程序的链上地址。你无需了解更多,因为这是在使用 Anchor CLI 创建程序时自动填充的。
#[program]
是一个 属性宏(类似于其他宏,但作用在结构上),它创建一个低级调度器,处理事务/CPI 并将它们重定向到程序代码中的正确位置以执行请求的函数。
调度程序确保授权、验证输入以及许多其他 安全检查,借助接下来我们将看到的一些其他宏。
你还记得我们在第 4 部分看到的存款指令吗?
下面是一个更完整的实现:
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
// 将 `amount` 的代币从 user_token_account 转移到 vault...
}
##[derive(Accounts)]
pub struct Deposit<'info> {
// 用户的钱包,必须签署交易
#[account(mut)]
pub user: Signer<'info>,
// 我们将从中提取的用户代币账户
#[account(
mut,
constraint = user_token_account.owner == user.key()
)]
pub user_token_account: Account<'info, TokenAccount>,
// 程序的保险库,我们将向其中存入代币
#[account(
mut,
seeds = [b"vault"],
bump
)]
pub vault: Account<'info, TokenAccount>,
// 我们将用以转账代币的 SPL Token 程序
pub token_program: Program<'info, Token>,
}
需要观察的第一件事是该指令的输入:
Context<Deposit>
是一个包装器,包含该指令所需的所有账户amount: u64
是存入的代币数量下方描述了 Deposit
结构,我们可以看到涉及许多宏:
#[derive(Accounts)]
宏,Anchor 用于定义和验证指令执行时将要与之交互的账户,以实现 Solana 的并行交易处理。该宏还根据账户的 类型(账户、签名者、未检查账户、程序)实现对账户的有效性检查。#[account()]
宏:如你所见,该宏可以附加不同参数。这些参数称为 账户约束,因为它们将为每次程序访问这些账户添加额外的检查。让我们梳理一下其中一些约束:
user
账户具有 mut
约束,指示程序该账户是可变的。如果没有此约束,当账户在任何时间被修改时,程序将会 revert。constraint = user_token_account.owner == user.key()
。这个约束确保 user_token_account
的所有者字段和 user
的公共地址具有相同的值。这实际上是一个访问控制,确保只有交易签署者能够使用这个特定的 user_token_account
调用指令。seeds = [b"vault"]
:这是特定于 PDA(程序派生账户)。这增加了检查,以确认提供的 vault
账户是由我们当前正在调用的程序创建的,并且已使用以下种子派生。bump
只是派生 PDA 时的一个附加参数。你可以在 Anchor 文档 或 Ackee Blockchain 提供的 这门课程 中找到所有 Anchor 约束。
感谢你抽时间阅读这本指南!
我希望它为你探索 Solana 项目提供了扎实的基础。请记住,这只是一个开始,学习的最好方法是深入实际的代码库,并将我们在这里覆盖的概念与实际实施相连接。
如果你觉得这本指南有帮助,请随时与其他研究人员分享。我也将非常感激任何 反馈或建议,因为这有助于使资源对每个人都更好。
对于那些希望深入研究 Solana 审计的人,我收集了一些有趣的资源:
祝你审计顺利!
- 原文链接: github.com/InfectedIsm/B...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!