返回博客

Solana 简化 02:从零开始编写你的第一个 Solana 智能合约

2024年6月21日
阅读约 12 分钟

引言

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 代币。您可以执行 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 行使用 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 交易。敬请期待!

阅读本系列的更多文章: