引言
2024年,Solana 迅速崛起,成为按总锁定价值 (TVL) 排名的第四大公共区块链,吸引了投资者和开发者的目光。
BlockSec 推出了“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 代币。您可以执行 solana airdrop 2 或直接从公共 Web 水龙头请求代币。
编写程序
我们将介绍的程序允许用户发布文章并列出所有已发布的文章。它处理三种类型的指令:
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 行使用 invoke_signed 方法创建了来自 SystemProgram 的 create_account 指令并调用了它。在程序内部调用另一个程序的指令的行为称为 CPI(跨程序调用)。在 Solana 中,您可以使用两个函数来执行 CPI:invoke 和 invoke_signed。
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 地址,它们将被标记为签名者,如同它们已提供签名一样。
pub fn invoke_signed(
instruction: &Instruction,
account_infos: &[AccountInfo<'_>],
signers_seeds: &[&[&[u8]]]
) -> Result<(), ProgramError>
这里,由于 create_account 指令要求被创建的账户是一个签名者,我们必须使用 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 交易的详细指南。敬请期待!



