引言
2024年,Solana 崛起成为按总锁仓价值(TVL)排名第四的公链,吸引了投资者和开发者的目光。
BlockSec 推出了“Solana 简化”系列,包含 Solana 基础概念、Solana 智能合约编写教程以及 Solana 交易分析指南。旨在帮助读者理解 Solana 生态系统,并掌握在 Solana 上开发项目和进行交易的必备技能。
在本系列的第一篇文章 《Solana 简化:一文掌握 Solana 核心概念》 中,我们深入探讨了 Solana 网络的核心概念,包括其运行机制、账户模型和交易,为编写正确且高性能的 Solana 合约奠定了坚实的基础。
在本文中,我们将指导你编写一个用于发布和显示文章的 Solana 程序(即 Solana 智能合约)。这将有助于巩固第一篇文章中的概念,并介绍一些我们尚未讨论过的 Solana 特性。相关的程序和测试代码已发布在 GitHub。
设置环境
注意:以下命令仅适用于 Ubuntu 系统。某些命令在 Windows 和 macOS 系统下可能无法正常工作。
👉 你可以使用其他命令或参考“设置本地开发”并安装 Solana CLI 来解决此问题。
我们将在本地环境中编译和部署 Solana 程序。在深入进行 Solana 程序开发之前,我们需要安装一些命令和依赖项。
Rust
Solana 程序主要使用 Rust 编程语言编写,因此我们需要执行以下命令来安装 Rust:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Node.js & TypeScript
测试脚本是用 TypeScript 编写的,因此我们需要安装 Node.js 和 TypeScript 工具链:
curl -fsSL https://deb.nodesource.com/setup_15.x | sudo -E bash -
sudo apt-get install -y nodejs npm
npm install -g typescript
npm install -g ts-node
Solana CLI
接下来,我们需要安装 Solana CLI 工具,它提供了诸如创建钱包、部署合约等任务的命令:
sh -c "$(curl -sSfL https://release.solana.com/stable/install)"
运行以下命令以将更改应用于当前终端:
source ~/.profile
你可以执行 solana --version 来验证 Solana CLI 是否已成功安装:
$ solana --version
solana-cli 1.17.25 (src:d0ed878d; feat:3580551090, client:SolanaLabs)
本地钱包
接下来,执行以下命令生成一个本地文件系统钱包:
solana-keygen new
默认情况下,此命令会在 ~/.config/solana/id.json 处创建一个私钥文件,我们将在后面的测试脚本中使用它。你可以使用 solana address 命令查看生成的地址:
$ solana address
9FcGDHvzs8Czo4B1pQq8GPdXfdGHqNqayvh6bu4hVGfy
Devnet
程序将部署在 devnet 上,因此我们需要执行以下命令将 Solana CLI 切换到 devnet:
solana config set --url https://api.devnet.solana.com
SOL 代币
由于部署合约和发送测试交易都需要花费 SOL,因此我们需要申请一些 SOL 代币。你可以执行 solana airdrop 2 或直接从 公共网络水龙头 获取代币。
编写程序
我们将要介绍的程序允许用户发布文章并列出所有已发布的文章。它处理三种类型的指令:
init指令:通过创建数据账户来初始化程序,用于存储当前文章的最大索引。post指令:将发布的一篇文章存储在一个新的数据账户中。list指令:在日志中打印所有已发布的文章。
以下是程序的结构:
$ tree program/
program/
├── Cargo.lock
├── Cargo.toml
└── src
├── instructions
│ ├── init.rs
│ ├── list.rs
│ ├── mod.rs
│ └── post.rs
├── lib.rs
└── processor.rs
Cargo.toml 指定了项目的外部库:
[dependencies]
borsh = "0.10.0"
borsh-derive = "0.10.0"
solana-program = "=1.17.25"
注意:
solana-program的版本应与安装的 Solana CLI 版本匹配(可以使用solana --version检查)。否则,编译时可能会出现错误。
合约的入口函数定义在 processor.rs 中。该文件以导入所需依赖项开始(其他 Rust 文件中类似的部分我们将跳过):

接下来是合约入口函数的定义:

程序第 10 行,来自 Solana 库的 entrypoint! 宏指定入口函数为 process_instruction。该函数接受三个参数:
program_id:当前程序的部署地址。accounts:指令涉及的所有账户。instruction_data:用于处理指令的字节数组。
process_instruction 提取 instruction_data 的第一个字节以识别指令类型,并调用相应的函数进行处理。接下来,我们将查看这三个函数的实现。
init
三个函数中的每一个都定义在 program/src/instructions 目录下的同名文件中。让我们从 init.rs 开始。

由于 init 指令不需要额外的字节数组,因此它只接受两个参数:program_id 和 accounts。
在第 12 至 15 行之间,程序按顺序提取所需的账户信息,然后调用 find_program_address 函数计算用于存储当前最大文章索引的数据账户 index_pda_key 的地址。然后程序断言 index_pda_key 等于 index_pda 的地址。
这里,index_pda 的地址是一种特殊的地址,称为 PDA(程序派生地址)。与从公钥生成的“钱包”账户地址不同,PDA 是从可选的字节数组、一个称为“bump”的字节以及一个程序的地址派生出来的。字节数组和程序地址由调用者显式提供,而 bump 由 find_program_address 函数自动生成并返回。bump 确保通过这种方式创建的地址不会与公钥生成的地址发生冲突。

确认 index_pda 的地址是预期的地址后,我们使用 SystemProgram 提供的 create_account 指令来创建 index_pda。在第 22 行,程序创建了一个 IndexAccountData 类型的变量,该变量记录了当前文章的最大索引(初始化为 0)。如下图所示,此类型实现了 BorshSerialize 和 BorshDeserialize trait,允许它以 Borsh 格式进行序列化和反序列化:

第 23 至 24 行计算了存储账户数据所需的空间以及创建账户所需的最低租金。
第 27 至 37 行从 SystemProgram 创建了一个 create_account 指令,并使用 invoke_signed 方法调用它。在一个程序内调用另一个程序的指令的行为称为 CPI(跨程序调用)。在 Solana 中,你可以使用 invoke 和 invoke_signed 这两个函数来执行 CPI。
invoke 函数直接处理给定的指令,其函数签名如下:
pub fn invoke(
instruction: &Instruction,
account_infos: &[AccountInfo<'_>]
) -> Result<(), ProgramError>
invoke_signed 与 invoke 的唯一区别在于,invoke_signed 提供了一种为 PDA 账户签名的方式。PDA 账户没有公私钥,因此无法直接提供签名。invoke_signed 解决了这个问题。该函数接受一个额外的参数,称为 signers_seeds。其函数签名如下。
与 find_program_address 函数的工作方式类似,invoke_signed 根据提供的 signers_seeds 和调用者的 program_id 计算出一组 PDA 地址。如果 account_infos 包含这些 PDA 地址,它们将被标记为 signer,就好像它们提供了签名一样。
pub fn invoke_signed(
instruction: &Instruction,
account_infos: &[AccountInfo<'_>],
signers_seeds: &[&[&[u8]]]
) -> Result<(), ProgramError>
在这里,由于 create_account 指令要求被创建的账户成为 signer,因此我们必须使用 invoke_signed 为 index_pda 提供签名。

在程序末尾,合约调用 index_data 上的 serialize 方法将其序列化,并写入 index_pda 的 data 字段。
post_article

接下来我们看看 post 指令的实现。与 init 指令不同,post 指令接受一个已发布的文章,因此它有一个额外的参数 instruction_data_inner,其中存储了文章的序列化数据。
一如既往,程序首先提取指令所需的账户信息。由于我们为每篇文章生成一个单独的 PDA 账户,因此程序在第 23 行接受了一个额外的账户 new_article_pda。
与 init 指令类似,第 27 至 30 行使用 find_program_address 来验证传入的 index_pda 地址是否正确。然后我们反序列化其 data 字段中的数据以读取当前已分配的最大索引。
第 33 至 40 行也验证了 new_article_pda 的地址。程序使用字符串 “ARTICLE_PDA” 和当前索引来生成新地址。每次上传文章时,索引都会递增,这确保了生成的地址是唯一的。

接下来,程序将 instruction_data_inner 反序列化为文章数据,其类型为 PostArticleData:

这个结构只有两个字段:title 和 content。在第 45 至 49 行,程序进行检查以确保文章不会过大,无法容纳在单个账户中。

接下来的步骤也与 init 指令类似:程序首先计算账户所需的空间和租金,然后调用 SystemProgram 的 create_account 指令来创建用于存储文章的 PDA 账户。最后,发布文章被序列化到 new_article_pda 的 data 字段,并将当前最大索引加一并序列化到 index_pda 的 data 字段。
list_articles

最后,让我们看看 list 指令的实现。由于此指令需要列出所有文章,因此在第 15 行使用一个向量来表示存储文章的所有 PDA 账户,并且 index_pda 账户的地址也得到了验证。

之后,程序验证向量的大小是否与当前最大索引相同,并检查每个账户地址的正确性。

在程序末尾,它遍历每个账户,并使用 solana-program 的 msg! 宏逐一输出反序列化的文章内容。
交易测试
为了测试我们的合约,我们使用了存储在仓库 client/main.ts 中的 TypeScript 脚本。

在脚本的顶部,我们导入所有必需的库并定义三个全局常量。KEYPAIR_PATH 指定了使用 solana-keygen new 生成的私钥文件的路径,PROGRAM_ID 是我们在上一节编写的已部署程序的地址,POST_ARTICLE_SCHEMA 是用于文章序列化的对象。


在脚本的主体中,它首先调用 loadKeyFromFile 函数解析私钥文件并获取一个 Keypair 对象作为交易费用的付款人。然后,它使用 @solana/web3.js 提供的 findProgramAddressSync 方法计算 indexPda 的地址。此方法使用与 Rust 合约相同的算法,确保它使用相同的参数计算相同的地址。connection 对象指定我们将使用 devnet 进行测试。

接下来,我们发送第一个初始化交易。我们创建一个包含单个指令的交易。此指令的 data 字段仅包含一个字节“0”,表示这是一个调用 init 指令。然后使用 sendAndConfirmTransaction 发送并确认交易。

第二个交易调用 post 指令来发布一些文章。从第 56 至 63 行,脚本序列化了两篇文章并计算了相应的 PDA 地址。然后将两个 post 指令添加到单个交易中以创建这些文章。

最后一个交易调用 list 指令,然后在交易确认后打印日志。
要使用脚本测试程序,请将 KEYPAIR_PATH 替换为你的私钥的路径(如果不是默认路径)。然后,通过执行以下命令编译和部署 Rust 智能合约:
cd program
cargo build-bpf
solana program deploy ./target/deploy/hello_solana.so
然后,复制部署的地址并粘贴到 PROGRAM_ID 字段中。在项目根目录下,运行 npm install 安装依赖项,然后运行 npm start 来执行脚本。
之后,我们可以使用 Solscan 查看测试交易的执行详情。

让我们直接查看 发布文章的第二笔交易。它包含两个指令,每个指令的 Instruction Data Raw 的第一个字节均为 0x01。此外,每个指令都包含一个内部指令,这是对 System Program 的 CreateAccount 指令的调用。

同样,我们可以检查 列出所有文章的第三笔交易。在 Program Logs 部分,我们可以找到打印出的两篇文章的索引、标题和内容。
结论
在本文中,我们首先解释了如何在本地设置 Solana 编程和运行时环境。然后,我们详细介绍了 Solana 合约的实现逻辑。最后,我们使用 TypeScript 脚本测试了合约的功能。通过这个循序渐进的教程,我们相信你已经学会了如何编写一个简单的 Solana 智能合约 🥳。
在下一篇文章中,我们将提供关于如何使用 Phalcon Explorer(Phalcon Explorer 现在支持 Solana)查看和分析 Solana 交易的详细指南。敬请关注!



