
Anchor 入门:构建 Solana 程序的初学者指南
目录
- 本文介绍什么?
- 前置知识
- 安装 Anchor
- 安装 Rust
- 安装 Solana 工具套件
- 安装 Yarn
- 使用 AVM 安装 Anchor
- 使用二进制文件安装 Anchor 以及从源代码构建
- Solana Playground
- Hello, World!
- 使用本地 Anchor 环境创建新项目
- 使用 Solana Playground 创建新项目
- 编写 Hello, World!
- 在本地构建和部署
- 部署到 Devnet
- 在 Solana Playground 上构建和部署
- 有效抽象:IDL 与宏
- Anchor 程序结构
- 账户类型
- 账户约束
- 分析程序约束
- 账户空间
- 错误
- 跨程序调用(CPI)
- 权限提升
- 执行 CPI
- 程序派生地址(PDA)
- 总结
- 其他资源
衷心感谢 Noah、Mike、Jonas、Ryan、Prames 和 bl0ckpain 审阅本文。
本文介绍什么?
Rust 通常被称为 Solana 程序开发的通用语言。不过,用这个说法形容 Anchor 更准确,因为大多数 Rust 开发都会使用这个框架。Anchor 是一个约定明确且功能强大的框架,旨在帮助开发者快速构建安全的 Solana 程序。它减少了账户序列化和反序列化、指令数据等方面的样板代码,并能执行必要的安全检查、自动生成客户端库以及提供全面的测试环境,从而简化开发流程。
本文将介绍如何开发 Anchor 程序,包括安装 Anchor、使用 Solana Playground,以及创建、构建和部署一个简单的 Hello, World! 程序。随后,我们将深入研究 IDL、宏、Anchor 程序结构、账户类型与约束以及错误处理,了解 Anchor 如何简化开发流程。我们还会简要介绍跨程序调用和程序派生地址。本文将提供你现在开始使用 Anchor 所需的全部知识。
前置知识
本文假设你已了解 Solana 的编程模型。如果你刚开始在 Solana 上开发,建议阅读我之前的博文 Solana 编程模型:Solana 开发入门。
如果你刚接触 Rust,不必担心——开始 Anchor 开发并不需要高深的知识。Anchor 文档指出,开发者只需掌握 Rust 基础知识(即 Rust Book 的前九章)。建议观看 Rust 生存指南,它清晰梳理了 Rust 编程的核心概念。理解 Rust 的内存、所有权和借用规则 也非常重要。
为降低学习难度,建议不熟悉底层编程语言的开发者复习一些 Rust 资料通常会略过的系统编程概念。例如,可以了解变量大小、指针和内存泄漏等主题。我还推荐 Rust 实例教程以及我的使用 Rust 编写各种数据结构和算法的代码库,从中可以了解 Rust 的实际用法。
想改用 TypeScript?了解如何使用 Poseidon 框架用 TypeScript 编写 Solana 程序,将 TypeScript 转译为 Rust 并生成有效的 Anchor 程序。
本文只专注于 Anchor 开发。我们不会介绍如何使用原生 Rust 开发程序,也不假设你具备这方面的知识。此外,本文不会介绍使用 Anchor 进行客户端开发——我们将在后续文章中讲解如何通过 TypeScript 测试 Anchor 程序并与之交互。
话不多说,让我们开始使用 Anchor!
安装 Anchor
设置 Anchor 只需几个简单步骤,即可安装必要的工具和软件包。本节将介绍如何安装这些工具和软件包,包括 Rust、Solana 工具套件、Yarn 和 Anchor 版本管理器。
安装 Rust
你可以从 Rust 官方网站安装 Rust,也可以通过命令行安装:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh安装 Solana 工具套件
Anchor 还需要 Solana 工具套件。在 macOS 和 Linux 上,可以使用以下命令安装最新版本(本文撰写时为 1.17.16):
sh -c "$(curl -sSfL https://release.solana.com/v1.17.16/install)"Windows 用户可以使用以下命令安装 Solana 工具套件:
cmd /c "curl https://release.solana.com/v1.17.16/solana-install-init-x86_64-pc-windows-msvc.exe --output C:\solana-install-tmp\solana-install-init.exe --create-dirs"不过,强烈建议改用 Windows 适用于 Linux 的子系统(WSL)。这样无需双系统启动或运行单独的虚拟机,即可在 Windows 计算机上运行 Linux 环境。如果采用这种方式,请参照前面的 Linux 安装说明(即 curl 命令)。
开发者也可以将 v1.17.16 替换为所需版本的发布标签,或者使用 stable、beta 或 edge 渠道名称。安装后,运行 solana –-version,确认已安装所需的 solana 版本。
安装 Yarn
Anchor 还需要 Yarn。你可以使用 Corepack 进行安装。Node.js 14.9 / 16.9 起的所有官方 Node.js 版本都包含 Corepack。不过,它目前仍处于实验阶段,需要主动启用。因此,我们需要先运行 corepack enable 才能激活它。某些第三方发行版可能默认不包含 Corepack,所以你可能需要先运行 npm install -g corepack,然后再运行 corepack enable。
使用 AVM 安装 Anchor
Anchor 文档建议通过 Anchor 版本管理器(AVM)安装 Anchor。AVM 简化了多个 anchor-cli 二进制安装版本的管理和选择。生成可验证构建,或为不同程序使用不同版本时,可能需要此功能。你可以通过 Cargo 安装它,命令为:cargo install --git [https://github.com/coral-xyz/anchor](https://github.com/coral-xyz/anchor) avm --locked --force。然后安装并使用最新版本:
avm install latest
avm use latest
# Verify the installation
avm --version要查看 anchor-cli 的可用版本列表,请使用 avm list 命令。开发者可以使用 avm use <version> 指定版本。该版本会一直沿用,直到你进行更改。开发者可以使用 avm uninstall <version> 命令卸载指定版本。
使用二进制文件安装 Anchor 以及从源代码构建
在 Linux 上,可以通过 npm 软件包 @coral-xyz/anchor-cli 获取 Anchor 二进制文件。目前仅支持 x86_64 Linux,因此其他操作系统的开发者必须从源代码构建。开发者可以使用 Cargo 直接安装 CLI。例如:
cargo install --git https://github.com/coral-xyz/anchor --tag v0.29.0 anchor-cli --locked修改 --tag 参数即可安装其他所需的 Anchor 版本。如果 Cargo 安装失败,可能需要安装额外的依赖项。例如,在 Ubuntu 上:
sudo apt-get update && sudo apt-get upgrade && sudo apt-get install -y pkg-config build-essential libudev-dev然后,开发者可以使用 anchor --version 命令验证 Anchor 是否安装成功。
Solana Playground
开发者也可以使用 Solana Playground(Solpg)开始体验 Anchor。Solana Playground 是一个基于浏览器的 IDE,可用于快速开发、测试和部署 Solana 程序。
首次使用 Solana Playground 时,开发者必须创建一个 Playground 钱包。点击屏幕左下角标有 未连接 的红色状态指示器。随后会弹出以下窗口:
建议先保存钱包的密钥对文件作为备份,然后再点击继续。这是因为 Playground 钱包保存在浏览器的本地存储中。清除浏览器缓存会删除该钱包。
点击继续,创建一个可在 IDE 中使用的 devnet 钱包。
要为钱包充值,开发者可以在 Playground 终端中运行以下命令 solana airdrop <amount>,并将 <amount> 替换为所需的 devnet SOL 数量。也可以访问这个水龙头获取 devnet SOL。建议查看这篇如何获取 devnet SOL 的指南。
请注意,你可能会遇到以下错误:
Error: unable to confirm transaction. This can happen in situations such as transaction expiration and insufficient fee-payer funds这通常是因为 devnet 水龙头资金耗尽和/或请求的 SOL 过多。目前上限为 5 SOL,部署此程序绰绰有余。因此,建议从水龙头请求 5 SOL,或执行命令 solana airdrop 5。多次请求较小的金额可能会触发速率限制。
Hello, World!
Hello, World! 程序被视为学习新框架或编程语言的绝佳起点。它非常简单,各种水平的开发者都能理解。此类程序无需引入复杂逻辑或函数,就能清楚展示新编程模型的基本结构和语法。它早已成为非常标准的编程入门程序,因此我们自然也要为 Anchor 编写一个。本节将介绍如何通过本地 Anchor 环境以及 Solana Playground 构建和部署 Hello, World! 程序。
使用本地 Anchor 环境创建新项目
安装 Anchor 后,创建新项目非常简单:
anchor init hello-world
cd hello-world这些命令会初始化一个名为 hello-world 的新 Anchor 项目,并进入其目录。在该目录中,打开 hello-world/programs/hello-world/src/lib.rs。此文件包含以下初始代码:
use anchor_lang::prelude::*;
declare_id!("HZfVb1ohL1TejhZNkgFSKqGsyTznYtrwLV6GpA8BwV5Q");
#[program]
pub mod hello-world {
use super::*;
pub fn initialize(ctx: Context) -> Result<()> {
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize {}Anchor 已为我们准备了许多文件和目录,包括:
- 一个用于程序客户端的空 app
- 一个存放所有 Solana 程序的 programs 文件夹
- 一个用于 JavaScript 测试的 tests 文件夹,其中包含一个根据初始代码自动生成的测试文件
- 一个 Anchor.toml 配置文件。如果你刚接触 Rust,TOML 文件是一种语义清晰、易于阅读的精简配置文件格式。Anchor.toml 文件用于配置 Anchor 与程序交互的方式,例如程序应部署到哪个集群。
使用 Solana Playground 创建新项目
在 Solana Playground 上创建新项目非常简单。前往左上角,点击创建新项目:
随后会弹出以下窗口:
为程序命名,选择 Anchor(Rust),然后点击创建。这样就能直接在浏览器中创建新的 Anchor 项目。在左侧的程序部分,你会看到一个 src 目录,其中包含 lib.rs,其初始代码如下:
use anchor_lang::prelude::*;
// This is your program's public key and it will update
// automatically when you build the project.
declare_id!("11111111111111111111111111111111");
#[program]
mod hello_anchor {
use super::*;
pub fn initialize(ctx: Context, data: u64) -> Result<()> {
ctx.accounts.new_account.data = data;
msg!("Changed data to: {}!", data); // Message will show up in the tx logs
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize<'info> {
// We must specify the space in order to initialize an account.
// First 8 bytes are default account discriminator,
// next 8 bytes come from NewAccount.data being type u64.
// (u64 = 64 bits unsigned integer = 8 bytes)
#[account(init, payer = signer, space = 8 + 8)]
pub new_account: Account<'info, NewAccount>,
#[account(mut)]
pub signer: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[account]
pub struct NewAccount {
data: u64
}请注意,Solana Playground 只会生成 client.ts 和 anchor.test.ts 文件。建议阅读关于在本地使用 Anchor 创建程序的部分,了解新 Anchor 项目通常会生成哪些内容。
编写 Hello, World!
无论你是在本地使用 Anchor,还是通过 Solana Playground 使用它,要创建一个非常简单的 Hello, World! 程序,只需将初始代码替换为以下内容:
use anchor_lang::prelude::*;
declare_id!("HZfVb1ohL1TejhZNkgFSKqGsyTznYtrwLV6GpA8BwV5Q");
#[program]
mod hello_world {
use super::*;
pub fn hello(_ctx: Context<Hello>) -> Result<()> {
msg!("Hello, World!");
Ok(())
}
#[derive(Accounts)]
pub struct Hello {}
}后续章节会详细介绍每个部分。目前需要注意的是,这里使用宏和 trait 简化了开发流程。declare_id! 宏用于设置程序的公钥。在本地开发中,用于设置程序的 anchor init 命令会在 target/deploy 目录中生成密钥对,并填充此宏。Solana Playground 也会自动完成这项工作。
在主 hello_world 模块中,我们创建了一个记录 Hello, World! 日志的函数。它还会返回 Ok(()),表示程序执行成功。请注意,我们为 ctx 添加了下划线前缀,以避免控制台中出现未使用变量的警告。Hello 是一个账户结构体,无需传入任何账户,因为该程序只会记录一条新消息。
就是这样!无需接收任何账户,也无需执行复杂逻辑。以上代码创建了一个会记录 Hello, World! 的程序。
在本地构建和部署
本节将重点介绍如何部署到本地主机。尽管 Solana Playground 默认使用 devnet,但本地开发环境可以显著改善开发者体验。它不仅速度更快,还能规避使用 devnet 测试时常见的多种问题,例如交易所需的 SOL 不足、部署缓慢,以及 devnet 宕机时无法测试。相比之下,本地开发可以保证每次测试都从全新状态开始,从而提供更可控、更高效的开发环境。
配置工具
首先,我们需要确保 Solana 工具套件已针对本地主机开发正确配置。运行 solana config set --url localhost 命令,确保所有配置都指向本地主机 URL。
还要确保你拥有可在本地与 Solana 交互的本地密钥对。要使用 Solana CLI 部署程序,你必须拥有一个带有 SOL 余额的 Solana 钱包。运行 solana address 命令,检查是否已有本地密钥对。如果遇到错误,请运行 solana-keygen new 命令。默认情况下,系统会在 ~/.config/solana/id.json 路径创建一个新的文件系统钱包,并提供可用于恢复公钥和私钥的助记词。即使仅在本地使用,也建议保存此密钥对。另请注意,如果默认位置已有文件系统钱包,除非同时指定 --force 命令,否则 solana-keygen new 命令不会覆盖它。
配置 Anchor.toml
接下来,我们需要确保 Anchor.toml 文件正确指向本地主机。请确认其中包含以下代码:
...
[programs.localnet]
hello-world = "EJTW6qsbfya86xeLRQpKLM8qhn11cJXmU35QbJwE11R8"
...
[provider]
cluster = "Localnet"
wallet = '~config/solana/id.json'这里的 [programs.localnet] 表示程序在 localnet(即本地主机)上的 ID。程序 ID 始终基于集群指定,因为同一个程序可以部署到不同集群中的不同地址。从开发者体验的角度来看,为部署在不同集群上的程序声明新的程序 ID 可能很麻烦。
程序 ID 是公开的,但其密钥对存储在 target/deploy 文件夹中,并根据程序名称遵循特定的命名规则。例如,如果程序名为 hello_world,Anchor 会在 target/deploy/hello-world-keypair.json 中查找密钥对。如果部署时未找到此文件,Anchor 会生成新的密钥对,从而产生新的程序 ID。因此,首次部署后更新程序 ID 至关重要。hello-world-keypair.json 文件是程序所有权的证明。如果密钥对泄露,恶意行为者就能对程序进行未经授权的修改。
通过 [provider],我们指示 Anchor 使用本地主机和指定的钱包支付存储及交易费用。
构建、部署和运行本地账本
使用 anchor build 命令构建程序。要按名称构建特定程序,请使用 anchor build -p <program name> 命令,并将 <program name> 替换为程序名称。由于我们在 localnet 上开发,可以使用 Anchor CLI 的 localnet 命令简化开发流程。例如,anchor localnet --skip-build 特别适合跳过工作区中的程序构建。如果程序代码没有更改,这可以节省测试运行时间。
如果现在尝试运行 anchor deploy 命令,会收到错误。这是因为我们的计算机上没有正在运行且可用于测试的 Solana 集群。我们可以运行本地账本,在计算机上模拟集群。Solana CLI 内置了一个测试验证器。运行 solana-test-validator 命令,会在你的工作站上启动一个功能完整的单节点集群。这样做有许多好处,例如不受 RPC 速率限制和空投限制,可直接部署链上程序、从文件加载账户,以及从公共集群克隆账户。测试验证器必须在单独打开的终端窗口中运行并保持运行,本地主机集群才能保持在线并可供交互。
现在,我们可以成功运行 anchor deploy,将程序部署到本地账本。传输到本地账本的所有数据都会保存在当前工作目录中生成的 test-ledger 文件夹内。建议将该文件夹添加到 .gitignore 文件,避免将其提交到代码库。此外,退出本地账本(即在终端中按 Ctrl + C)不会删除已发送到集群的任何数据。删除 test-ledger 文件夹或运行 solana-test-validator --reset 则会删除这些数据。
恭喜!你刚刚将自己的第一个 Solana 程序部署到了本地主机!
Solana Explorer
开发者还可以配置 Solana Explorer,使其连接本地账本。前往 Solana Explorer。在导航栏中,点击显示当前集群的绿色按钮:
这会打开一个侧边栏,供你选择集群。点击自定义 RPC URL。系统应该会自动填入 http://localhost:8899。如果没有,请手动填写,让浏览器连接到你计算机的 8899 端口:
这在多个方面都非常有用:
- 开发者可以实时检查本地账本中的交易,获得与分析 devnet 或 mainnet 的区块浏览器相同的能力
- 可以更直观地查看账户、代币和程序的状态,就像它们运行在实时集群上一样
- 它能提供有关错误和交易失败的详细信息
- 它采用熟悉的界面,可在不同集群间提供一致的开发体验
部署到 Devnet
虽然我们建议使用本地主机进行开发,但如果开发者希望专门针对 devnet 集群进行测试,也可以部署到 devnet。整个流程基本相同,只是不需要运行本地账本,因为我们已有一个功能完整、可以交互的 Solana 集群。
运行命令 solana config set --url devnet,将选定的集群更改为 devnet。此后,在终端中运行的所有 solana 命令都会在 devnet 上执行。然后,在 Anchor.toml 文件中复制 [programs.localnet] 部分,并将其重命名为 [programs.devnet]。还要修改 [provider],使其指向 devnet:
...
[programs.localnet]
hello-world = "EJTW6qsbfya86xeLRQpKLM8qhn11cJXmU35QbJwE11R8"
[programs.devnet]
hello-world = "EJTW6qsbfya86xeLRQpKLM8qhn11cJXmU35QbJwE11R8"
...
[provider]
cluster = "Devnet"
wallet = '~config/solana/id.json'开发者必须确保拥有足够的 devnet SOL 来部署程序。使用 solana airdrop <amount> 命令,将 SOL 空投到 ~/.config/solana/id.json 的默认密钥对位置。也可以使用 solana aidrop <amount> <wallet address> 指定钱包地址。或者,访问这个水龙头获取 devnet SOL。建议查看这篇如何获取 devnet SOL 的指南。
你可能会遇到以下错误:无法确认交易。这可能发生在交易过期、费用支付方资金不足等情况下
这通常是因为 devnet 水龙头资金耗尽和/或一次请求的 SOL 过多。目前上限为 5 SOL,部署此程序绰绰有余。因此,建议从水龙头请求 5 SOL,或执行命令 solana airdrop 5。多次请求较小的金额可能会触发速率限制。
现在,使用以下命令构建并部署程序:
anchor build
anchor deploy恭喜!你刚刚在本地将自己的第一个 Solana 程序部署到了 devnet!
在 Solana Playground 上构建和部署
在 Solana Playground 上,前往左侧边栏中的工具图标。点击构建。控制台中应该会显示以下内容:
Building...
Build successful. Completed in 2.20s..请注意,declare_id! 宏中的 ID 已被覆盖。我们会将程序部署到这个新地址。现在点击部署。控制台中应该会显示类似以下的内容:
Deploying... This could take a while depending on the program size and network conditions.
Warning: 41 transactions not confirmed, retrying...
Deployment successful. Completed in 17s恭喜!你刚刚通过 Solana Playground 将自己的第一个 Solana 程序部署到了 devnet!
有效抽象:IDL 与宏
Anchor 通过有效抽象简化程序开发。也就是说,Anchor 简化了复杂的区块链编程概念,使其更易理解和使用。例如,Anchor 使用接口定义语言(IDL)定义程序接口。构建程序时,Anchor 会生成一个表示程序 IDL 的 JSON 文件。实际上,这一结构可用于客户端,定义如何与程序的函数和数据结构交互。Anchor 还为状态管理提供了更高层级的抽象。开发者可以使用 Rust 结构体定义程序状态,这比直接处理原始字节数组或手动序列化更直观。因此,开发者可以像使用任何常规 Rust 数据结构一样定义状态,再由 Anchor 处理底层序列化并将其存储到账户中。
将 IDL 发布到链上也非常简单。开发者可以使用以下命令发布 IDL:
anchor idl init --filepath --provider.cluster --provider.wallet请确保所提供的钱包是程序的 authority,并且有足够的 SOL 支付交易费用。开发者现在可以在 Orb 等区块浏览器中查看自己的 IDL。
例如,这是 Orb 上的 DFlow aggregator v4 IDL。
Anchor 的宏即使不是最重要的抽象,也是最重要的抽象之一。在 Rust 中,宏是一段用于生成另一段代码的代码。这是元编程的一种形式。声明宏是 Rust 中使用最广泛的宏形式。开发者可以通过 macro_rules! 构造编写类似 match 表达式的内容。过程宏的行为更像函数:接收一些代码作为输入,对代码进行处理并生成输出。例如在 Anchor 中,#[account] 宏用于定义并强制执行 Solana 账户约束。这有助于降低账户管理的复杂度和潜在错误。介绍 Anchor 的宏时,也必然要讨论 Anchor 的程序结构。
Anchor 程序结构
Anchor 的程序结构旨在结合使用宏和 trait,以生成样板代码并强制执行程序逻辑。这一设计理念在简化开发流程,以及确保程序行为的一致性和可靠性方面发挥着重要作用。
use 声明位于文件顶部。请注意,它们属于 Rust 语言的通用语义,并非 Anchor 特有。这些声明会创建一个或多个本地名称绑定,作为其他路径的同义名称——use 声明可缩短引用模块项所需的路径。它们可以出现在模块或代码块中。此外,self 关键字可以绑定一组具有相同前缀和共同父模块的路径。例如,以下都是有效的 use 声明:
use anchor_lang::prelude::*;
use std::collections::hash_map::{self, HashMap};
use a::b::{c, d, e::f, g::h::i};
use a::b::{self, c, d::e};开发者遇到的第一个 Anchor 宏是 declare_id!。它用于声明程序地址(程序 ID),确保所有交互都被正确路由到该程序。开发者首次构建 Anchor 程序时,Anchor 会生成一个新的密钥对。除非另有指定,否则部署程序时将使用该密钥对。应将该密钥对的公钥作为程序 ID 提供给 declare_id! 宏:
declare_id!("HZfVb1ohL1TejhZNkgFSKqGsyTznYtrwLV6GpA8BwV5Q");#[program] 属性宏用于标记程序的指令逻辑模块。它充当入口点,定义程序如何解释和执行传入的指令。该宏简化了将这些指令路由到程序内相应函数的过程,使程序代码更有条理、更易管理。此模块中的每个函数都被视为一条独立指令。每个函数的第一个参数都是 Context 类型的上下文参数(ctx)。开发者可以访问账户、执行中程序的程序 ID,以及其余账户。
Context 类型的定义如下:
pub struct Context<'a, 'b, 'c, 'info, T: Bumps> {
pub program_id: &'a Pubkey,
pub accounts: &'b mut T,
pub remaining_accounts: &'c [AccountInfo<'info>],
pub bumps: T::Bumps,
}这有助于向给定程序提供非参数输入。program_id 字段的类型为 Pubkey,表示当前执行的程序 ID。accounts 指序列化后的账户,而 remaining_accounts 指已提供但未反序列化或验证的其余账户——直接使用时务必谨慎。bumps 字段的类型为由 #[derive(Accounts)] 生成的 Bumps。它表示在约束验证期间找到的 bump seed。我们将在后续章节介绍账户约束。现在只需知道,提供此字段是为了方便处理程序,无需重新计算 bump seed 或将其作为参数传入。
请注意,Context 是泛型类型。在 Rust 中,泛型让开发者能够编写适用于任何数据类型的灵活、可复用代码。开发者可以为结构体、枚举、函数和方法定义类型,而无需指定它们实际处理的确切类型。相反,这些类型会使用占位符,通常表示为 T。泛型有助于减少重复代码并提升清晰度。例如,可以定义一个容纳泛型数据类型的枚举:
enum Option<T> {
Some(T),
None,
}上面的代码片段展示了 Option<T> 枚举。它是一个标准 Rust 枚举,可以封装任意类型的值(即 Some(T)),也可以不包含值(None)。
在这里,Context 是一个泛型类型,其中 T 指定指令所需的账户(即开发者希望创建用于存储数据的任何类型)。使用 Context 时,开发者可以将 T 定义为实现 Accounts trait 的结构体。例如,Context<SetData>。开发者可以使用点表示法访问 Context 类型中的字段。例如,ctx.accounts 会访问 Context 结构体的 accounts 字段。
如前所述,#[account] 宏用于定义自定义账户类型。在接下来的章节中,我们将使用 #[account(...)] 研究账户类型和约束。现在需要注意的是,开发者会在 Accounts 结构体中定义一条指令应接收哪些账户,以及这些账户应遵循哪些约束。
账户类型
当指令需要访问账户的反序列化数据时,会使用 Account 类型。Account 结构体是基于 T 的泛型,其定义如下:
pub struct Account<'info, T: AccountSerialize + AccountDeserialize + Clone> { /* private fields */ }它是 AccountInfo 的包装器,用于验证程序所有权,并将底层数据反序列化为 Rust 类型。它通过 Account.info.owner == T::owner() 检查程序所有权。也就是说,它会检查数据所有者是否与使用 #[account] 的 crate 的 ID(之前使用 declare_id! 创建)相同。这意味着 Account 包装的数据类型(=T)必须实现 Owner trait。#[account] 属性使用同一程序中由 declare_id! 声明的 crate::ID,为结构体实现该 trait。大多数情况下,开发者只需使用 #[account] 属性,即可为数据添加必要的 trait 和实现。#[account] 属性会为以下 trait 生成实现:
实现账户序列化 trait 时,开头 8 个字节会分配给唯一的账户 discriminator。该 discriminator 取自账户 Rust 标识符的 SHA-256 哈希值的前 8 个字节。任何对 AccountDeserialize 的 try_deserialize 的调用都会检查此 discriminator;如果提供的账户无效,则会报错并退出账户反序列化。
开发者有时需要与非 Anchor 程序交互。在这种情况下,可以创建自己的自定义包装器类型,而不是使用 #[account],从而获得 Account 的全部优势。以下面的代码片段为例:
use anchor_lang::prelude::*;
use anchor_spl::token::TokenAccount;
// Rest of the program
#[derive(Accounts)]
pub struct SetData<'info> {
#[account(mut)]
pub my_account: Account<'info, MyAccount>,
#[account(
constraint = my_account.mint == token_account.mint,
has_one = owner
)]
pub token_account: Account<'info, TokenAccount>,
pub owner: Signer<'info>
}大多数账户验证都通过账户约束完成,我们将在下一节介绍。但现在先看看如何使用 TokenAccount 类型确保传入账户归 token 程序所有。TokenAccount 包装 token 程序的 Account 结构体,并添加必要的函数。这可确保 Anchor 能够反序列化账户,并让开发者在账户约束和指令函数中使用其字段。
还要注意,在上面的代码片段中,derive 宏封装了整个结构体。它会在 SetData 上实现 Accounts 反序列化器,用于验证传入的账户。
账户验证结构体中可以使用多种 Account 类型,包括:
- Account<’info, T>:在反序列化时检查所有权的账户容器
- AccountInfo<’info>:可用作类型的未检查账户。不过应改用 UncheckedAccount,因为 AccountInfo 可能会在未来版本中移除
- AccountLoader<’info, T>:支持按需零拷贝反序列化的类型。它与使用
Account不同:开发者必须在初始化账户后调用load_init,在账户不可变时调用load,在账户可变时调用load_mut - Box<Account<’info, T>> 或 Box<InterfaceAccount<’info, T>>:用于节省栈空间的 box 类型。有时账户对于栈而言过大,可能导致栈违规;对账户进行装箱有助于解决这一问题
- Interface<’info, T>:包装
Program的类型,用于验证账户是否属于一组给定程序之一。它会检查预期程序是否包含该账户的密钥,以及账户是否可执行 - InterfaceAccount<’info, T>:检查程序所有权并将底层数据反序列化为 Rust 类型的账户容器
- Option<Account<’info, T>>:用于可选账户的 option 类型
- Program<’info, T>:验证账户是否为给定程序的类型
- Signer<’info>:验证账户是否签署交易的类型
- SystemAccount<’info>:验证账户是否归 System Program 所有的类型
- Sysvar<’info, T>:验证账户是否为 sysvar 的类型。也就是说,验证账户是否为包含网络集群、区块链历史记录和执行中交易相关动态更新数据的特殊类型。
clock、epoch_schedule、instructions和rentsysvar 对程序开发非常有用 - UncheckedAccount<’info>:明确强调不会对指定账户执行任何检查的账户容器
账户约束
账户约束对于开发安全的 Anchor 程序至关重要。在后续文章中,我们将更深入地介绍 Solana 程序安全性和 Anchor 程序攻击。不过,这里有必要先介绍约束。约束让开发者能够验证特定账户或其中的数据是否满足某些预定义要求。可以使用 #[account(...)] 属性应用多种不同类型的约束,该属性也可以引用其他数据结构。格式如下:
#[account(constraint goes here)]
pub account: AccountType另一个要点是,在 Accounts 宏中,开发者可以使用 #[instruction(...)] 属性访问指令参数。开发者需要按照参数在指令中的顺序列出它们,但可以省略所需的最后一个参数之后的所有参数。例如,以下示例来自 Anchor 文档:
...
pub fn initialize(ctx: Context, bump: u8, authority: Pubkey, data: u64) -> anchor_lang::Result<()> {
...
Ok(())
}
...
#[derive(Accounts)]
#[instruction(bump: u8)]
pub struct Initialize<'info> {
...
}账户约束可以分为普通约束和 SPL 约束。本文余下部分将介绍具体约束。在这些示例中,<expr> 表示可传入的任意表达式,只要其求值结果属于预期类型即可。例如,owner = token_program.key()。
分析程序约束
建议查看 Anchor 关于账户的文档,其中提供了更完整的可用约束列表。逐一遍历每个约束并以表格等形式给出正式定义会过于繁琐。对我们来说,分析以下程序并实际了解账户约束的工作方式会更有帮助:
use anchor_lang::prelude::*;
#[cfg(not(feature = "no-entrypoint"))]
use {default_env::default_env, solana_security_txt::security_txt};
declare_id!("fanqeMu3fw8R4LwKNbahPtYXJsyLL6NXyfe2BqzhfB6");
pub mod errors;
pub mod instructions;
pub mod state;
pub use instructions::*;
pub use state::*;
#[cfg(not(feature = "no-entrypoint"))]
security_txt! {
name: "Fanout",
project_url: "http://helium.com",
contacts: "email:hello@helium.foundation",
policy: "https://github.com/helium/helium-program-library/tree/master/SECURITY.md",
// Optional Fields
preferred_languages: "en",
source_code: "https://github.com/helium/helium-program-library/tree/master/programs/fanout",
source_revision: default_env!("GITHUB_SHA", ""),
source_release: default_env!("GITHUB_REF_NAME", ""),
auditors: "Sec3"
}
#[program]
pub mod fanout {
use super::*;
pub fn initialize_fanout_v0(
ctx: Context<InitializeFanoutV0>,
args: InitializeFanoutArgsV0,
) -> Result<()> {
instructions::initialize_fanout_v0::handler(ctx, args)
}
pub fn stake_v0(ctx: Context<StakeV0>, args: StakeArgsV0) -> Result<()> {
instructions::stake_v0::handler(ctx, args)
}
pub fn unstake_v0(ctx: Context<UnstakeV0>) -> Result<()> {
instructions::unstake_v0::handler(ctx)
}
pub fn distribute_v0(ctx: Context<DistributeV0>) -> Result<()> {
instructions::distribute_v0::handler(ctx)
}
}这是 Helium 的 Fanout 程序。这是一个相当复杂的程序,用于根据 token 持有量按比例向持有者分发 token。目前,这个项目看起来对我们没有太大帮助,因为其中没有任何约束。不过,如果分析 stake_v0 指令的 StakeV0 结构体,就会发现许多可供研究的约束。
mut
此指令中的第一个约束是 mut 账户约束。mut 定义为 #[account(mut)] 或 #[account(mut @ <custom_error>)],并通过 @ 表示法支持自定义错误。此约束检查给定账户是否可变,并让 Anchor 持久化任何状态变更。在 Helium 的程序中,该约束确保 payer 账户可变:
...
pub struct StakeV0<'info> {
#[account(mut)]
pub payer: Signer<'info>,
pub staker: Signer<'info>,
/// CHECK: Just needed to receive nft
pub recipient: AccountInfo<'info>,
...has_one
has_one 约束定义为 #[account(has_one = <target_account)] 或 #[account(has_one = <target_account> @ <custom_error>)]。它会检查 target_account 字段,确认账户是否与 Accounts 结构体中 target_account 字段的密钥匹配。通过 @ 注解可支持自定义错误。
在 StakeV0 结构体的上下文中,has_one 约束用于检查账户是否具有 membership_mint、token_account 和 membership_collection:
...
#[account(
mut,
has_one = membership_mint,
has_one = token_account,
has_one = membership_collection
)]
pub fanout: Box<Account<'info, FanoutV0>>,
pub membership_mint: Box<Account<'info, Mint>>,
pub token_account: Box<Account<'info, TokenAccount>>,
pub membership_collection: Box<Account<'info, Mint>>,
...请注意,这里使用了多个 has_one 约束,同时还使用了 mut 约束。对于账户约束,可以同时在一个账户上使用多个约束。
seeds, bump
seeds 和 bump 约束用于检查给定账户是否为通过当前执行的程序、seed 以及所提供 bump 派生出的 PDA:
#[account(seeds = <seeds>, bump)]#[account(seeds = <seeds>, bump, seeds::program = <expr>)]#[account(seeds = <seeds>, bump = <expr>)]#[account(seeds = <seeds>, bump = <expr>, seeds::program = <expr>)]
如果未提供 bump,Anchor 将使用规范 bump。可以使用 Seeds::program = <expr>,通过当前执行程序之外的其他程序派生 PDA。
在 Helium 的 Fanout 程序中,seeds 约束会检查文本“metadata”、token_metadata_program 密钥、membership_collection 密钥和文本“edition”是否为派生此 PDA 时使用的 seed。seeds::program 约束确保使用 token_metadata_program 而不是当前程序派生 PDA:
...
#[account(
mut,
seeds = ["metadata".as_bytes(), token_metadata_program.key().as_ref(), membership_collection.key().as_ref()],
seeds::program = token_metadata_program.key(),
bump,
)]
pub collection_metadata: UncheckedAccount<'info>,
...token::mint, token::authority
token::mint 和 token::authority 约束的定义如下:
#[account(token::mint = <target account>, token::authority = <target account>)]#[account(token::mint = <target account>, token::authority = <target account>, token::token_program = <target account>)]
mint 和 authority token 约束用于验证 TokenAccount 的 mint 地址和 authority。这些约束可用于检查,也可以与 init 约束一起使用,以给定的 mint 地址和 authority 创建 token 账户。用于检查时,可以只指定其中一部分约束。
在 Helium 程序的上下文中,这些约束用于检查 associated_token 的 mint 是否等于 membership_mint,以及 token 的 authority 是否设为 staker:
...
#[account(
mut,
associated_token::mint = membership_mint,
associated_token::authority = staker,
)]
pub from_account: Box<Account<'info, TokenAccount>>,
...init, payer, space
现在可以在代码中稍微向后跳转,分析 init、payer 和 space 约束。init 约束定义为 [#account(init, payer = <target_account>, space = <num_bytes>)]。该约束通过对 System Program 执行 CPI 来创建账户,并通过设置账户 discriminator 对其进行初始化。这会将账户标记为可变,并且与 mut 互斥。对于大于 10 Kibibyte 的账户,请使用 #[account(zero)]。
init 约束必须与一些额外约束一起使用。它需要 payer 约束来指定支付账户创建费用的账户。它还要求结构体中存在 System Program,并且名称必须为 system_program。还必须定义 space 约束。我们将在“账户空间”一节中深入介绍此约束和空间要求。
在 Helium 的 Fanout 程序中,init 命令会创建新账户。payer 被设为 payer,后者之前在结构体中定义为 pub payer: Signer<'info>。账户空间设置为 FanoutVoucherV0 的大小,加上用于 discriminator 的 8 个字节和额外的 61 个字节:
...
#[account(
init,
payer = payer,
space = 60 + 8 + std::mem::size_of::<FanoutVoucherV0>() + 1,
seeds = ["fanout_voucher".as_bytes(), mint.key().as_ref()],
bump,
)]
pub voucher: Box<Account<'info, FanoutVoucherV0>>,
...init_if_needed
init_if_needed 约束定义为 #[account(init_if_nedded, payer = <target_Account>)] 或 #[account(init)if_needed, payer = <target_account>, space = <num_bytes>)]。此约束的功能与 init 完全相同,但只在账户尚不存在时运行。如果账户已存在,init_if_needed 仍会验证是否满足所有初始化约束,例如账户是否分配了正确的空间,或 PDA 是否使用了正确的 seed。
使用 init_if_needed 时应保持谨慎,因为它存在潜在风险,受到 feature flag 的限制。要启用它,请使用 init-if-needed cargo feature 导入 anchor-lang。使用 init_if_needed 时,防范重新初始化攻击至关重要。开发者必须确保代码中包含检查,防止账户在初始化后被重置为初始状态,除非这是预期行为。保持指令执行路径简单直接被视为缓解此类攻击的最佳实践。可以考虑将指令划分为一条初始化指令和用于后续操作的其他指令。
Helium 的 Fanout 程序使用 init_if_needed 约束,在 recipient_account 账户尚不存在时对其进行初始化:
...
#[account(
init_if_needed,
payer = payer,
associated_token::mint = mint,
associated_token::authority = recipient,
)]
pub receipt_account: Box<Account<'info, TokenAccount>>,
...constraint
constraint 约束定义为 #[account(constraint = <expr>)] 或 #[account(constraint = <expr> @ <custom_error>)]。它会检查所提供表达式的求值结果是否为 true。当其他约束都不适合预期用例时,这非常有用。它还通过 @ 注解支持自定义错误。
Fanout 程序使用 constraint 检查 mint 的供应量是否设为零:
...
#[account(
mut,
constraint = mint.supply == 0,
mint::decimals = 0,
mint::authority = voucher,
mint::freeze_authority = voucher,
)]
pub mint: Box<Account<'info, Mint>>,
...mint::authority, mint::decimals, mint::freeze_authority
在上面的代码片段中,mint::decimals、mint::authority 和 mint::freeze_authority 约束用于检查 mint 的小数位数是否设为零,以及 voucher 是否具有 authority 和 freeze authority。
作为补充说明,mint::authority、mint::decimals 和 mint::freeze_authority 约束的定义如下:
#[account(mint::authority = <target account>, mint::decimals = <expr>)]#[account(mint::authority = <target account>, mint::decimals = <expr>, mint::freeze_authority = <target account>)]
这些约束的含义一目了然:它们分别检查 token 的 authority、小数位数和 freeze authority。它们可用于检查,也可以与 init 一起使用,以给定的 mint 小数位数和 mint authority 创建 mint 账户。与 init 一起使用时,freeze authority 完全可选。用于检查时,可以只指定其中一部分约束。
账户空间
Solana 上由程序使用的每个账户都必须显式分配存储空间。此分配对高效管理资源至关重要,可确保链上只存储必要的数据。它还能让交易成本更可预测,并提高交易执行效率,因为处理交易时无需动态分配账户存储空间或调整其大小。此外,预先分配数据空间可确保账户有足够空间存储所有必要数据,降低交易失败或出现潜在安全漏洞的风险。
确定变量大小
不同数据类型的空间要求不同。以下是一份用于估算空间要求的简化指南:
- 基本类型:bool、u8、i8、u16、i16、u32、i32、u64、i64、u128 和 i128 等简单数据类型都有固定大小。其范围从
bool的 1 字节(尽管它只使用 1 位)到u128/i128的 16 字节 - 数组:对于数组
[T;amount],其空间等于T的大小乘以元素数量(即amount)。例如,由 16 个u16组成的数组需要 32 字节 - Pubkey:公钥在 Solana 上始终占用 32 字节
- 动态类型:需要谨慎考虑
String和Vec<T>。两者都需要 4 字节存储长度,此外还需要空间存储实际内容。必须为预期的最大大小分配足够空间。对于String,所需空间为 4 字节加上String的字节长度。对于Vec<T>,所需空间为 4 字节加上给定类型的空间乘以预期元素数量(即 4 + space(T) * amount) - Option 和枚举:
Option<T>类型需要 1 字节,再加上类型T所需的空间。枚举需要 1 字节存储枚举鉴别器,再加上最大变体所需的空间 - 浮点数:
f32和f64等类型分别占用 4 字节和 8 字节。请谨慎处理 NaN 值,因为它们可能导致序列化失败
以下指南仅适用于不使用 zero-copy 序列化的账户。零拷贝序列化由 #[zero_copy] 属性表示。它利用 repr(c) 属性定义内存布局,从而可以通过直接转换指针来访问数据。这是一种处理链上数据的高效方式,无需承担传统反序列化的开销。#[zero_copy] 是应用 #[derive(Copy, Clone)]、#[derive(bytemuck::Zeroable)]、#[derive(bytemuck::Pod)] 和 #[repr(C)] 的简写。这些属性确保账户可以安全地被视为字节序列,并兼容零拷贝反序列化。对于需要极大空间的账户,零拷贝反序列化至关重要——这类账户使用 Borsh 或 Anchor 的默认序列化机制时,会触及堆或栈限制,因而无法高效序列化。
Anchor 的内部鉴别器
开发者必须在 space 约束中额外加上 8,以容纳 Anchor 的内部鉴别器。例如,如果账户需要 32 字节,就必须分配 40 字节。将空间约束设置为 space = 8 + <account size> 被视为一种良好做法,可明确表示空间计算已考虑内部鉴别器。
顺带一提,鉴别器是一种用于区分不同数据类型的唯一标识符。它可用于在运行时区分不同类型的账户数据结构,也可作为指令的前缀,帮助将这些指令路由到 Anchor 程序中的对应方法。鉴别器是一个 8 字节数组,表示数据类型的唯一标识符。
计算初始空间
计算账户的初始空间要求可能很困难。InitSpace 宏会添加一个可用于账户结构体的 INIT_SPACE 常量。结构体无需包含 #[account] 宏即可生成该常量。Anchor 文档提供了以下示例:
#[account]
#[derive(InitSpace)]
pub struct ExampleAccount {
pub data: u64,
// max_len represents the length of the structure
#[max_len(50)]
pub string_one: String,
#[max_len(10, 5)]
pub nested: Vec<Vec<u8>>,
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(mut)]
pub payer: Signer<'info>,
pub system_program: Program<'info, System>,
#[account(init, payer = payer, space = 8 + ExampleAccount::INIT_SPACE)]
pub data: Account<'info, ExampleAccount>,
}在此示例中,ExampleAccount::INIT_SPACE 会自动计算 ExampleAccount 所需的空间。计算空间时,它还会考虑 Anchor 的内部鉴别器。
调整程序空间大小
realloc 约束用于在指令开始执行时调整程序账户的空间。它要求账户可变(即 mut),并适用于 Account 或 AccountLoader 类型。其定义为 #[account(realloc = <space>, realloc::payer = <target>, realloc::zero = <bool>)]。增加账户数据长度时,lamport 会从 realloc::payer 转入程序账户,以维持免租状态。如果数据长度减小,lamport 则会从程序账户转回 realloc::payer。realloc::zero 约束决定是否应将新分配的内存初始化为零。零初始化可确保新内存干净,不含任何残留或不需要的数据。
与 realloc 约束相比,不建议手动使用 AccountInfo::realloc。因为它缺少运行时检查,无法确保重新分配不会超出 MAX_PERMITTED_DATA_INCREASE 限制,这可能导致覆盖其他账户中的数据。该约束还会检查并阻止在一条指令中重复重新分配。
例如:
#[derive(Accounts)]
pub struct Data {
#[account(mut)]
pub payer: Signer<'info>,
#[account(
mut,
seeds = [b"data"],
bump,
realloc = 8 + std::mem::size_of::<()>() + 48,
realloc::payer = payer,
realloc::zero = false
)]
pub update_account: Account<'info, NewData>,
system_program: Program<'info, System>,
}错误
错误处理是程序开发中的关键环节。它是一种识别和管理可能中止程序执行的错误的机制。为了确保代码质量、可维护性和功能正常,必须有意识、有计划地处理错误。Anchor 通过强大的错误处理机制简化了这一过程。Anchor 程序中的错误可分为 AnchorErrors 和非 Anchor 错误。本节将重点介绍 AnchorErrors,因为非 Anchor 错误涵盖了大量 Rust 错误。对于非 Anchor 错误,建议阅读 Rust Book 的错误处理章节和 Rust By Example 的错误处理部分。
以下 struct 定义了 AnchorError:
pub struct AnchorError {
pub error_name: String,
pub error_code_number: u32,
pub error_msg: String,
pub error_origin: Option<ErrorOrigin>,
pub compared_values: Option<ComparedValues>,
}这些字段相对直观。error_name 是表示错误名称的字符串。error_code_number 是错误的唯一标识符(即占用 32 位空间的唯一无符号整数)。error_msg 是解释错误的描述性消息。error_origin 是可选字段,提供错误来源信息,例如涉及的源文件或账户。compared_values 是可选字段,详细说明发生错误时正在比较的值。这对调试极其有用。
AnchorError 实现了一个日志方法。它包含错误来源和相关值的信息,有助于调试和解决错误。此方法使用 error_origin 和 compared_values 提供这些信息。
AnchorErrors 还可进一步分为 Anchor 内部错误和自定义错误。Anchor 提供了可返回的大量内部错误代码。这些内部错误并非供用户使用,但了解代码与原因之间的映射关系很有帮助。它们通常在违反约束时抛出。内部错误代码遵循以下模式:
- >= 100 是指令错误代码
- >= 1000 是 IDL 错误代码
- >= 2000 是约束错误代码
- >= 3000 是账户错误代码
- >= 4100 是其他错误代码
- = 5000 是已弃用的错误代码。
自定义错误从 ERROR_CODE_OFFSET(即 6000)开始。
开发者可以使用 error_code 属性实现自己的自定义错误。该属性用于枚举,枚举的变体可以在整个程序中作为错误使用。你可以为每个变体添加一条消息。发生错误时,客户端可以显示该消息。例如:
#[error_code]
pub enum HeliusError {
#[msg(“This RPC provider is too good”)]
RPCTooGood
}可以使用 err! 和 error! 宏抛出这些错误。例如:
require!(rpc.speed > 9000, HeliusError::RPCTooGood);需要特别注意的是,有多个 require 宏可供选择。其中绝大多数宏用于处理非公钥值。例如,require_gte 宏会检查第一个非公钥值是否大于或等于第二个非公钥值:
pub fn set_data(ctx: Context<SetData>, data: u64) -> Result<()> {
require_gte!(ctx.accounts.data.data, 1);
ctx.accounts.data.data = data;
Ok(());
}比较公钥时也有一些注意事项。例如,开发者应使用 require_keys_eq,而不是 require_eq,因为后者成本更高。
所有程序都会返回 ProgramError。该错误类型包含一个专门用于自定义错误编号的字段,Anchor 用它存储内部和自定义错误代码。但它只是一个数字,因此作用有限。Anchor 前述通过 AnchorErrors 进行的日志记录更有帮助。Anchor 客户端专门设计为可解析这些日志,但在某些情况下,这可能很困难。例如,关闭预检时,获取已处理交易的日志并不直接。同样,对于未以标准方式记录 AnchorErrors 的非 Anchor 程序或旧版程序,Anchor 也会采用回退机制。此时,Anchor 会检查交易返回的错误编号是否对应 Anchor 内部错误代码,或程序 IDL 中定义的错误编号。找到匹配项后,Anchor 会丰富错误信息,提供更多上下文。Anchor 还会尽可能解析程序错误堆栈,以追溯程序错误的最初原因。ProgramError 是一种基础错误类型,Anchor 的日志记录和解析机制增强了它的实用性,可提供详细的错误信息。
跨程序调用(CPI)
本文已多次提及跨程序调用(CPI),因此有必要用专门一节进行介绍。CPI 允许程序直接调用其他程序,是 Solana 可组合性的基础。可以说,它为开发者将 Solana 生态系统变成了一个庞大且互联的 API。为简洁起见,建议阅读 Anchor 的 CPI 文档,其中通过木偶和木偶操纵者程序提供了一个实用的 CPI 示例。
不过,CPI 可以定义为从一个程序对另一个程序的调用,并以被调用程序中的特定指令为目标。调用方程序会暂停,直到被调用程序完成该指令的处理。
权限提升
CPI 允许调用方程序将其签名者权限扩展给被调用方。权限扩展很方便,但也可能非常危险。如果 CPI 意外指向恶意程序,该程序将获得与调用方相同的权限。Anchor 通过两项防护措施降低此风险:
Program<’info, T>类型确保指定账户与预期程序(T)匹配- 即使未使用
Program类型,自动生成的 CPI 函数也会验证cpi_program参数是否对应预期程序
执行 CPI
程序可以使用 solana_program crate 中的 invoke 或 invoke_signed 执行 CPI。Anchor 还提供 CpiContext 结构体,用于指定 CPI 的非参数输入。
invoke
不需要 PDA 作为签名时,可使用 invoke 函数。在这种情况下,运行时会将调用方程序的原始签名扩展给被调用方。该函数定义如下:
pub fn invoke(
instruction: &Instruction,
account_infos: &[AccountInfo<'_>]
) -> ProgramResult调用另一个程序时,需要创建一个 Instruction,其中包含程序 ID、被调用方程序的指令数据,以及被调用方将访问的账户列表。程序只能在其程序入口点从运行时接收 AccountInfo 值。被调用方程序在调用过程中需要的任何账户,都必须由调用它的程序纳入并提供。例如,如果被调用方程序需要修改某个特定账户,调用方程序必须将该账户添加到 AccountInfo 值列表中。这同样适用于被调用方的程序 ID(即调用方必须通过包含被调用方的程序 ID,明确指定要调用哪个程序)。
Instruction 通常在调用方程序中构造,但也可以从外部输出中反序列化。
如果被调用方程序遇到错误或中止,整个交易将立即失败。这是因为 invoke 函数只有在成功时才会返回。可使用 set_return_data 或 get_return_data 函数将数据作为 CPI 的结果返回。请注意,返回的类型必须实现 AnchorSerialize 和 AnchorDeserialize trait。也可以让被调用方将数据写入专用账户进行存储
虽然程序可以递归调用自身,但由另一个程序进行的间接递归调用(即重入)会立即导致交易失败。
例如,如果有一个通过 CPI 转移代币的程序,可以按如下方式使用 invoke:
pub fn set_data(ctx: Context<SetData>, data: u64) -> Result<()> {
require_gte!(ctx.accounts.data.data, 1);
ctx.accounts.data.data = data;
Ok(());
}invoke_signed
invoke_signed 用于需要 PDA 作为签名者的 CPI。它允许调用方程序通过提供派生 PDA 所需的种子,代表该 PDA 执行操作:
pub fn invoke_signed(
instruction: &Instruction,
account_infos: &[AccountInfo<'_>],
signers_seeds: &[&[&[u8]]]
) -> ProgramResultPDA 也可以在 CPI 中充当签名者。运行时会使用提供的种子和调用方程序的 program_id,通过 create_program_address 在内部生成 PDA。随后,系统会将 PDA 与指令传入的地址(即 account_infos)进行验证,以确认它是有效的签名者。
借助此函数,一次调用可以代表调用方程序控制的一个或多个 PDA 进行签名。这让被调用方能够与给定账户交互,就像这些账户已通过加密方式签名一样。signer_seeds 由用于派生 PDA 的种子切片组成。调用期间,运行时会将 account_info 中任何匹配的账户视为“已签名”。例如,如果有一个为 PDA 创建账户的程序,可以按如下方式调用 invoke_signed:
invoke_signed(
&system_instruction::create_account(
&payer.key,
&vault_pda.key,
lamports,
vault_size,
&program_id,
),
&[
payer.clone(),
vault_pda.clone(),
],
&[
&[
b"vault",
payer.key.as_ref(),
&[vault_bump_seed],
],
]
)?;CpiContext
除了使用 invoke 或 invoke_signed,Anchor 还提供 CpiContext,以更简单的方式发起 CPI。此结构体用于指定 CPI 所需的非参数输入,其功能与 Context 非常相似。它提供指令所需账户、涉及的任何其他账户、被调用程序 ID,以及必要时用于派生 PDA 的种子等信息。对于不含 PDA 的 CPI,使用 CpiContext::new;对于需要 PDA 签名者的 CPI,使用 CpiContext::new_with_signer。
CpiContext 的定义如下,其中 T 是一个泛型类型,可包含实现 ToAccountMetas 和 ToAccountInfos<’info> trait 的任何对象:
pub struct CpiContext<'a, 'b, 'c, 'info, T>where
T: ToAccountMetas + ToAccountInfos<'info>,{
pub accounts: T,
pub remaining_accounts: Vec>,
pub program: AccountInfo<'info>,
pub signer_seeds: &'a [&'b [&'c [u8]]],
}Accounts 是一个泛型类型,可接受任何实现 ToAccountMetas 和 ToAccountInfos<’info> trait 的对象。#[derive(Accounts)] 属性宏实现了这一点,有助于组织代码并增强类型安全。
CpiContext 简化了对 Anchor 和非 Anchor 程序的调用。对于 Anchor 程序,只需在项目的 Cargo.toml 文件中声明依赖项,并使用 Anchor 生成的 cpi 模块:
[dependencies]
callee = { path = "../callee", features = ["cpi"]}设置 features = [“cpi”] 后,程序即可访问 callee::cpi 模块。Anchor 会自动生成此模块,并将程序指令公开为 Rust 函数。该函数接收一个 CpiContext 和任何其他指令数据。其格式与 Anchor 程序中的常规指令函数一致,但以 CpiContext 取代 Context。cpi 模块还提供调用指令所需的账户结构体。
例如,如果被调用方程序有一条名为 hello_there 的指令,并且需要 GeneralKenobi 结构体中定义的特定账户,可按如下方式调用:
// We assume "jedi" is an Anchor program with a published crate
use jedi::cpi::accounts::GeneralKenobi;
use jedi::cpi::hello_there;
use anchor_lang::prelude::*;
#[program]
pub mod fight_on_utapau {
use super::*;
pub fn call_hello_there(ctx: Context<CallGeneralKenobi>, data: GreetingParams) -> Result<()> {
let cpi_accounts = GeneralKenobi {
jedi: ctx.accounts.jedi.to_account_info(),
// Other account infos needed for the GeneralKenobi struct go here
};
let cpi_program = ctx.accounts.jedi_program.to_account_info();
let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);
hello_there(cpi_ctx, data);
}
#[derive(Accounts)]
pub struct CallGeneralKenobi<'info> {
pub jedi: UncheckedAccount<'info>,
pub jedi_program: Program<'info, Jedi>,
// Other required accounts
}
pub struct GreetingParams {
// Params required for the hello_there function
}在 fight_on_utapau 模块中,使用 CpiContext 执行 CPI。call_hello_there 函数用于与 jedi 程序交互。它创建一个 CpiContext,其中包含 jedi 程序的 GeneralKenobi 账户结构体所需的账户信息,以及 jedi 程序的账户信息。此上下文会调用 hello_there,并传入 GreetingParams 结构体指定的任何其他必要参数。CallGeneralKenobi 结构体定义了此函数所需的账户,从而简化了流程。
最后,在调用非 Anchor 程序的指令时,请检查程序维护者是否已发布自己的 crate,并提供调用其程序的辅助函数。如果必须调用其指令的程序没有辅助函数,则回退到使用 invoke 和 invoke_signer 来组织和准备 CPI。
程序派生地址(PDA)
请记住,PDA 位于曲线之外,没有关联的私钥。它允许程序签署指令,也允许开发者在链上构建类似哈希映射的结构。PDA 使用可选种子列表、bump 种子和程序 ID 派生。
再次说明,以下约束用于检查给定账户是否为根据当前执行的程序、种子以及提供的 bump 派生出的 PDA:
#[account(seeds = <seeds>, bump)]#[account(seeds = <seeds>, bump, seeds::program = <expr>)]#[account(seeds = <seeds>, bump = <expr>)]#[account(seeds = <seeds>, bump = <expr>, seeds::program = <expr>)]
如果未提供 bump,Anchor 将使用规范 bump。可以使用 Seeds::program = <expr> 从当前执行程序之外的其他程序派生 PDA。
使用 seeds 和 bump 约束可简化派生过程:
#[derive(Accounts)]
struct ExamplePDA<'info> {
#[account(seeds = [b"example"], bump)]
pub example_pda: Account<'info, AccountType>,
}这里使用 seeds 约束派生 PDA。Anchor 会自动验证传入指令的账户是否与根据种子派生出的 PDA 匹配。当使用 bump 约束但未指定具体值时,Anchor 默认使用规范 bump。
Anchor 还支持基于其他账户字段或指令数据的动态种子。你可以引用结构体中的其他字段,或使用 #[instruction(...)] 属性宏包含反序列化后的指令数据。例如,在以下结构体中,example_pda 被约束为结合使用静态种子、指令数据和签名者公钥:
#[derive(Accounts)]
#[instruction(instruction_data: String)]
pub struct ExamplePDA<'info> {
#[account(seeds = [b"example", signor.key().as_ref(), instruction_data.as_bytes()], bump)]
pub example_pda: Account<'info, AccountType>,
#[account(mut)]
pub signoooorrr: Signer<'info>
}总结
仅用“强大”来形容 Anchor,远不足以体现它的实力。通过分析 Anchor 如何利用各种宏和 trait 减少代码,我们可以清楚地看到它简化开发流程的能力。Anchor 不仅拥有维护良好的文档,还有由相关教程和 crate 构成的强大生态系统。绝大多数 Solana 开发者都喜爱并使用 Anchor。
本文是一份非常、非常全面的 Anchor 程序开发指南。内容涵盖 Anchor 的安装、Solana Playground 的使用,以及 Hello, World! 程序的创建、构建和部署。随后,我们探讨了 Anchor 实现高效抽象的方法、典型 Anchor 程序的结构,以及众多可用的账户类型和约束。本文还介绍了分配账户空间和错误处理的重要性。最后,我们探讨了 CPI 和 PDA。可以说,这就是关于 Anchor 的终极文章——你今天开始在 Solana 上开发程序所需的一切,都能在这里找到。
如果你已经读到这里,感谢你,匿名朋友!请务必在下方输入你的电子邮件地址,这样就不会错过 Solana 的任何最新动态。准备好深入探索了吗?加入我们的 Discord,开始开发 Anchor 程序。
其他资源
相关文章
订阅 Helius
及时了解 Solana 开发的最新动态,并在我们发布新内容时收到更新


