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

掌握 Solana 编程只需这一篇文章!涵盖从环境搭建、合约逻辑到程序测试的所有内容。

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

引言

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_idaccounts

在第 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)。如下图所示,此类型实现了 BorshSerializeBorshDeserialize trait,允许它以 Borsh 格式进行序列化和反序列化:

第 23 至 24 行计算了存储账户数据所需的空间以及创建账户所需的最低租金。

第 27 至 37 行从 SystemProgram 创建了一个 create_account 指令,并使用 invoke_signed 方法调用它。在一个程序内调用另一个程序的指令的行为称为 CPI(跨程序调用)。在 Solana 中,你可以使用 invokeinvoke_signed 这两个函数来执行 CPI。

invoke 函数直接处理给定的指令,其函数签名如下:

pub fn invoke(
    instruction: &Instruction,
    account_infos: &[AccountInfo<'_>]
) -> Result<(), ProgramError>

invoke_signedinvoke 的唯一区别在于,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_signedindex_pda 提供签名。

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

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

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

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

list_articles

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

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

在程序末尾,它遍历每个账户,并使用 solana-programmsg! 宏逐一输出反序列化的文章内容。

交易测试

为了测试我们的合约,我们使用了存储在仓库 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 ProgramCreateAccount 指令的调用。

同样,我们可以检查 列出所有文章的第三笔交易。在 Program Logs 部分,我们可以找到打印出的两篇文章的索引、标题和内容。

结论

在本文中,我们首先解释了如何在本地设置 Solana 编程和运行时环境。然后,我们详细介绍了 Solana 合约的实现逻辑。最后,我们使用 TypeScript 脚本测试了合约的功能。通过这个循序渐进的教程,我们相信你已经学会了如何编写一个简单的 Solana 智能合约 🥳。

在下一篇文章中,我们将提供关于如何使用 Phalcon Explorer(Phalcon Explorer 现在支持 Solana)查看和分析 Solana 交易的详细指南。敬请关注!

阅读本系列的其他文章:

Sign up for the latest updates