Anchor 简介:构建 Solana 程序的初学者指南

在本指南中,我们将了解如何获取 USDC 等可替代代币的所有持有者。当您想要跟踪代币持有者或通过空投奖励持有者的情况下,这非常有用。

Anchor 简介:构建 Solana 程序的初学者指南

非常感谢NoahMikeJonasRyanPramesbl0ckpain审阅本文。

这篇文章是关于什么的?

Rust通常被描述为Solana 程序开发的通用语言。相反,用这种方式描述 Anchor 更准确,因为大多数 Rust 开发都使用这个框架。Anchor是一个固执己见且功能强大的框架,旨在快速构建安全的 Solana 程序。它通过减少帐户(反)序列化和指令数据等领域的样板文件、进行必要的安全检查、自动生成客户端库以及提供广泛的测试环境来简化开发流程。

本文探讨了如何开发 Anchor 程序。它涵盖了安装 Anchor、使用 Solana Playground 以及创建、构建和部署一个简单的 Hello, World!程序。然后,我们将深入探讨 Anchor 如何通过检查 IDL、宏、Anchor 程序的结构、帐户类型和约束以及错误处理来简化开发流程。我们还将简要介绍跨程序调用和程序派生地址。本文将提供您今天开始使用 Anchor 所需了解的所有信息。

必备知识

本文假设您了解 Solana 的编程模型。如果您不熟悉 Solana 构建 我建议您阅读我之前的博客文章Solana 编程模型:Solana 开发简介。

如果您是 Rust 新手,请不要担心 - 开始 Anchor 开发不需要高级知识。 Anchor 文档指出,开发人员只需要熟悉 Rust 的基础知识(即Rust Book的前九章)。我建议观看《Rust 生存指南》,了解 Rust 编程基本概念的详细分析。了解Rust 的内存、所有权和借用规则也至关重要。

为了简化学习曲线,我建议刚接触低级编程语言的开发人员查看 Rust 资源往往会跳过的不同系统编程特定概念。例如,我建议研究诸如变量大小指针内存泄漏等主题。我还推荐Rust By Examples和我的关于用 Rust 编写的各种数据结构和算法的存储库,以获取 Rust 的实际示例。

本文主要关注 Anchor 开发,并且仅关注Anchor 开发。我们不会介绍如何使用 Native Rust 开发程序,本文也不会假设您具备相关知识。此外,本文不会介绍使用 Anchor 进行客户端开发 - 我们将在以后的文章中介绍如何通过 TypeScript 测试 Anchor 程序并与之交互。

让我们开始使用 Anchor。

安装锚栓

设置 Anchor 需要几个简单的步骤来安装必要的工具和软件包。本节介绍安装这些工具和软件包(即 Rust、Solana 工具套件、Yarn 和 Anchor Version Manager)。

安装 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 Tool Suite:


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 Subsystem for Linux (WSL)。这将允许您在 Windows 计算机上运行 Linux 环境,而无需双重启动或启动单独的虚拟机。通过采用此方法,请参阅 Linux 的安装说明(即,curl 命令)。

开发人员还可以将v1.17.16替换为他们想要下载的版本的发布标签。或者,使用stablebetaEdge通道名称。安装后,运行solana --version以确认已安装所需版本的solana 。

安装纱线

Anchor 还需要Yarn。它可以使用Corepack,它包含在从 Node.js 14.9 / 16.9 开始的所有官方Node.js版本中。然而,它目前处于实验阶段。因此,我们需要在corepack enable激活之前运行它。某些第三方发行商可能默认不包含 Corepack。因此,您可能需要在corepack enable之前运行npm install -g corepack

使用 AVM 安装锚点

Anchor 文档建议通过 Anchor Version Manager (AVM) 安装 Anchor。 AVM 简化了anchor-cli二进制文件的多个安装的管理和选择。这可能需要生成可验证的版本,或者跨不同程序使用替代版本。它可以使用Cargo并使用以下命令进行安装:cargo install --git 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 上,Anchor 二进制文件可通过 npm 包@coral-xyz/anchor-cli获得。目前仅支持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 钱包创建模式

建议在点击继续之前将钱包的密钥对文件保存为备份。这是因为 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 空投 5。逐渐请求较小的金额可能会导致速率限制。

你好世界!

你好世界!程序被认为是新框架或编程语言的绝佳介绍。这是因为它们很简单,所有技能水平的开发人员都可以理解它们。这些程序还阐明了新编程模型的基本结构和语法,而无需引入复杂的逻辑或功能。它很快就成为一个相当标准的编码初学者程序,因此我们自己为 Anchor 编写一个程序是很自然的。本节介绍如何构建和部署 Hello, World!具有本地 Anchor 设置以及 Solana Playground 的程序。

使用本地锚点设置创建新项目

创建安装了 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为我们准备了一些文件和目录。即,

  • 程序客户端的空应用程序
  • 一个程序文件夹,将容纳我们所有的 Solana 程序
  • 用于 JavaScript 测试的测试文件夹。它附带了一个为入门代码自动生成的测试文件
  • Anchor.toml配置文件。如果您是 Rust 新手,TOML 文件是一种最小的配置文件格式,由于其语义而易于阅读。 Anchor.toml文件用于配置 Anchor 如何与程序交互例如,程序应该部署到哪个集群。

使用 Solana Playground 创建新项目

在 Solana Playground 上创建新项目非常简单。导航到左上角并单击“创建新项目”

在 Solana Playground 上创建一个新项目

将弹出以下模式:

项目创建模式

为您的程序命名,选择Anchor(Rust),然后单击Create。这将直接在您的浏览器中创建一个新的 Anchor 项目。在左侧的Program部分下,您将看到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.tsanchor.test.ts文件。我建议阅读有关在本地使用 Anchor 创建程序的部分,以了解通常为新 Anchor 项目生成的内容的详细信息。

写你好,世界!

无论您是在本地使用 Anchor 还是通过 Solana Playground 使用 Anchor,都可以实现非常简单的 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 {}
}

我们将在后续部分中详细介绍每个部分的具体细节。目前,重要的是要注意使用宏和特征来简化开发过程。声明_id!宏设置程序的公钥。对于本地开发,设置程序的anchor init命令将在target/deploy目录中生成密钥对并填充此宏。 Solana Playground 也会自动为我们执行此操作。

在我们的主hello_world模块中,我们创建了一个记录Hello, World!的函数。它还返回Ok(())以表示程序执行成功。请注意,我们在ctx前面加上下划线,以避免控制台中出现未使用的变量警告。Hello是一个帐户结构,不需要传递任何帐户,因为程序只记录一条新消息。

就是这样!无需记入任何账目或执行一些复杂的逻辑。上面的代码创建了一个记录 Hello, World! 的程序。

本地构建和部署

本节将重点介绍部署到Localhost。尽管 Solana Playground 默认使用 devnet,但本地开发环境可显着改善开发人员体验。它不仅速度更快,而且还规避了针对 devnet 进行测试时常见的几个问题。例如,用于事务的 SOL 不足、部署缓慢以及在 devnet 关闭时无法进行测试。相比之下,本地开发可以保证每次测试都有新鲜的状态。这可以提供更加受控和高效的开发环境。

配置我们的工具

首先,我们要确保 Solana 工具套件针对本地主机开发进行了正确配置。运行solana config set --url localhost命令以确保所有配置都指向本地主机 URL。 

另外,请确保您有本地密钥对来与 Solana 进行本地交互。您必须拥有带有 SOL 余额的 Solana 钱包才能使用 Solana CLI 部署程序。运行solana address命令检查您是否已有本地密钥对。如果遇到错误,请运行solana-keygen new命令。默认情况下,将在~/.config/solana/id.json路径中创建一个新的文件系统钱包。它还将提供可用于恢复公钥和私钥的恢复短语。建议保存此密钥对,即使它正在本地使用。另请注意,如果您已在默认位置保存了文件系统钱包,则solana-keygen new命令不会覆盖它,除非使用--force命令指定。

配置 Anchor.toml

接下来,我们要确保Anchor.toml文件正确指向 Localhost。确保它包含以下代码:


...
[programs.localnet]
hello-world = "EJTW6qsbfya86xeLRQpKLM8qhn11cJXmU35QbJwE11R8"
...
[provider]
cluster = "Localnet"
wallet = '~config/solana/id.json'

这里,[programs.localnet]指的是本地网络(即Localhost)上的程序ID。程序 ID 始终是与集群相关的。这是因为同一个程序可以部署到不同集群上的不同地址。从开发人员体验的角度来看,为跨不同集群部署的程序声明新的程序 ID 可能很烦人。 

程序 ID 是公开的。但是,其密钥对存储在target/deploy文件夹中。它遵循基于程序名称的特定命名约定。例如,如果程序名为hello_world ,Anchor 将在target/deploy/hello-world-keypair.json查找密钥对。如果 Anchor 在部署过程中没有找到该文件,将会生成一个新的密钥对。这将产生一个新的程序 ID。因此,在首次部署后更新程序 ID 至关重要。 hello -world-keypair.json文件用作程序的所有权证明。如果密钥对泄露,恶意行为者就可以对程序进行未经授权的更改。 

通过[provider],我们告诉 Anchor 使用Localhost和指定的钱包来支付存储和交易费用。

构建、部署和运行本地账本

使用anchor build命令来构建程序。要按名称构建特定程序,请使用anchor build -p <程序名称>命令,将<程序名称>替换为程序名称。由于我们是在 localnet 上进行开发,因此我们可以使用 Anchor CLI 的 localnet 命令来简化开发过程。例如,锚点 localnet --skip-build对于在工作区中跳过构建程序特别有用。这可以节省运行测试的时间,并且程序的代码没有被改变。

如果我们现在尝试运行锚点部署命令,我们将收到错误。这是因为我们没有在自己的计算机上运行可供测试的 Solana 集群。我们可以运行本地账本来模拟我们机器上的集群。 Solana CLI 附带了一个内置的测试验证器。运行solana-test-validator命令将在您的工作站上启动一个功能齐全的单节点集群。这有很多好处,例如没有 RPC 速率限制、没有空投限制、直接链上程序部署、从文件加载帐户以及从公共集群克隆帐户。测试验证器必须在单独的打开终端窗口中运行,并保持运行以使本地主机集群保持在线并可进行交互。 

现在,我们可以成功运行锚点部署,将程序部署到本地账本。传输到本地分类帐的任何数据都将保存在当前工作目录中生成的测试分类帐文件夹中。建议将此文件夹添加到您的.gitignore文件中,以避免将此文件夹提交到您的存储库。此外,退出本地账本(即在终端中按Ctrl + C)不会删除发送到集群的任何数据。删除test-ledger文件夹或运行solana-test-validator --reset即可。

恭喜!您刚刚将第一个 Solana 程序部署到本地主机!

索拉纳探索者

开发人员还可以使用本地账本配置 Solana Explorer。导航至Solana Explorer。在导航栏中,单击指示当前集群的绿色按钮:

索拉纳探索者

这将打开一个侧边栏,允许您选择一个集群。单击“自定义 RPC URL”。这应该会自动填充http://localhost:8899。如果没有,请填写它以使资源管理器指向您计算机的端口 8899:

选择集群模式

由于以下几个原因,这是无价的:

  • 它允许开发人员实时检查本地分类账上的交易,反映他们通常使用分析开发网或主网的区块浏览器所具有的功能
  • 更容易可视化帐户、令牌和程序的状态,就像它们在实时集群上运行一样
  • 它提供有关错误和事务失败的详细信息
  • 它提供了跨集群一致的开发体验,因为它是一个熟悉的界面

部署到 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>命令将空投到默认密钥对位置~/.config/solana/id.json。还可以使用solana aidrop <amount> <wallet address>指定钱包地址。或者,访问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 空投 5。逐渐请求较小的金额可能会导致速率限制。

现在,使用以下命令构建和部署程序:


anchor build
anchor deploy

恭喜!您刚刚将第一个 Solana 程序部署到本地开发网络!

在 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 和宏

XRAY 上的锚点 IDL 示例
XRAY 上的 IDL 示例

Anchor 通过有效的抽象简化了程序开发。也就是说,Anchor 简化了复杂的区块链编程概念,使它们更易于访问和使用。例如,Anchor 使用接口定义语言(IDL)来定义程序的接口。构建程序时,Anchor 会生成代表程序 IDL 的 JSON 文件。本质上,这个结构可以在客户端使用,定义如何与程序的函数和数据结构交互。 Anchor 还提供了处理状态管理的更高级别的抽象。 Anchor 允许开发人员使用 Rust 结构定义程序的状态,这比使用原始字节数组或手动序列化更直观。因此,开发人员可以像通常使用任何典型 Rust 数据结构一样定义状态,然后 Anchor 处理底层序列化并将其存储到帐户中。

在链上发布 IDL 也非常简单。开发者可以使用以下命令发布IDL:


anchor idl init --filepath   --provider.cluster  --provider.wallet 

确保提供的钱包是程序的权威,并且有足够的SOL用于交易。开发人员无法在XRAYSolana Explorer等区块浏览器上查看其 IDL 。

Anchor 的宏即使不是最重要的抽象,也是其中之一。在 Rust 中,是生成另一段代码的一段代码。这是元编程的一种形式。声明性宏是 Rust 中使用最广泛的宏形式。它们允许开发人员通过宏规则编写类似于匹配表达式的东西!构造。过程宏更像是一个函数,接受一些代码作为输入,对该代码进行操作,并产生一些输出。例如,在 Anchor 中,#[account]宏定义并强制执行对 Solana 帐户的约束。这有助于降低帐户管理的复杂性和潜在错误。涵盖 Anchor 的宏不可避免地需要讨论 Anchor 的程序结构。

Anchor计划结构

基本锚定程序结构

Anchor 的程序结构旨在利用宏和特征的组合来生成样板代码并强制执行程序逻辑。这种设计理念在简化开发过程并确保程序行为的一致性和可靠性方面发挥了重要作用。

使用声明位于文件顶部。请注意,它们是通用的 Rust 语言语义,并不特定于 Anchor。这些声明创建一个或多个与其他路径同义的本地名称绑定 - use声明缩短了引用模块项所需的路径。它们可以出现在模块中。此外,self关键字可以绑定具有公共前缀的路径列表及其公共父模块。例如,这些都是有效的使用声明: 


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 会生成一个新的密钥对。除非另有说明,这是用于部署程序的密钥对。密钥对的公钥应作为declare_id 的程序 ID 提供!宏:


declare_id!("HZfVb1ohL1TejhZNkgFSKqGsyTznYtrwLV6GpA8BwV5Q");

# [program]属性宏表示程序的指令逻辑模块。它充当入口点,定义程序如何解释和执行传入的指令。该宏简化了这些指令到程序内适当函数的路由,使程序的代码更有组织性和可管理性。该模块中的每个函数都被视为单独的指令。每个函数都将采用Context类型的上下文参数 (ctx)作为其第一个参数。开发人员可以访问帐户、正在执行的程序的程序ID以及剩余帐户。

上下文类型定义为:


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。它表示在约束验证期间找到的凹凸种子。我们将在后面的部分中介绍帐户限制。目前,重要的是要知道这是为了方便而提供的,因此处理程序不必重新计算凹凸种子或将它们作为参数传递。

请注意,Context是一个泛型类型。在 Rust 中,泛型允许开发人员编写适用于任何数据类型的灵活、可重用的代码。它们支持结构、枚举、函数和方法的类型定义,而无需指定它们将使用的确切类型。相反,这些类型使用占位符,通常表示为T。泛型有助于减少重复代码并提高清晰度。例如,可以定义枚举来保存通用数据类型:


enum Option<T> {
  Some(T),
  None,
}

上面的代码片段展示了Option<T>枚举。它是一个标准的 Rust 枚举,可以封装任何类型(即Some(T))或无类型(None)的值。

出于我们的目的,Context是一个通用类型,其中T指定指令所需的帐户(即开发人员想要创建来存储数据的任何类型)。开发人员可以在使用Context时将T定义为实现Accounts特征的结构。例如,Context<SetData>。开发人员可以使用点表示法访问Context类型中的字段。例如,ctx.accounts访问Context结构的帐户字段。

如前所述,#[account]宏定义自定义帐户类型。在接下来的部分中,我们将使用#[account(...)]研究帐户类型和约束。目前,需要注意的是,Accounts 结构是开发人员定义指令应期望哪些帐户以及这些帐户应遵守哪些约束的地方。

账户类型

当指令想要访问帐户的反序列化数据时,将使用帐户类型。Account结构体在T上是通用的,定义为: 


pub struct Account<'info, T: AccountSerialize + AccountDeserialize + Clone> { /* private fields */ }

它是AccountInfo的包装器,用于验证程序所有权并将底层数据反序列化为 Rust 类型。它检查程序所有权,以便Account.info.owner == T::owner()。也就是说,它检查数据所有者是否与#[account]所使用的crate 的ID (之前使用declare_id!创建的 ID )相同。这意味着 Account 包装的数据类型 ( =T ) 必须实现所有者特质#[account]属性使用由 declare_id 声明的crate::ID来实现结构体的特征在同一个程序中。大多数时候,开发人员可以简单地使用#[account]属性向其数据添加必要的特征和实现。#[account]属性生成以下特征的实现:

在实现账户序列化特征时,最初的 8 个字节被分配给唯一的账户鉴别器。该鉴别器由帐户 Rust 标识符的 SHA-256 哈希值的前 8 个字节确定。对AccountDeserializetry_deserialize的任何调用都将检查此鉴别器,如果提供了无效帐户,则退出帐户反序列化并显示错误。

在某些情况下,开发人员需要与非锚定程序进行交互。在这里,如果开发人员创建自己的自定义包装类型而不是使用#[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类型来确保传入的帐户归令牌程序所有。TokenAccount包装了令牌程序的Account结构并添加了必要的函数。这确保了 Anchor 可以反序列化帐户,并且开发人员可以在帐户约束内和指令功能中使用其字段。

另请注意,在上面的代码片段中,派生宏封装了整个结构。这在SetData上实现了帐户反序列化器,并用于验证传入帐户。

帐户验证结构中可以使用多种帐户类型,包括:

账户限制

帐户限制对于开发安全的 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 程序。这是一个相当复杂的程序,根据代币持有者的持有量按比例分配代币。目前,该项目对我们来说看起来不太有用,因为没有任何限制。然而,如果我们分析stake_v0指令StakeV0结构体,我们有很多限制需要探索。

穆特

该指令中的第一个约束是mut帐户约束。mut定义为#[account(mut)]#[account(mut @ <custom_error>)],支持使用@表示法的自定义错误。此约束检查给定帐户是否可变,并使 Anchor 保留任何状态更改。在 Helium 的程序中,约束确保付款人帐户是可变的:


...
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约束定义为#[account(has_one = <target_account)]#[account(has_one = <target_account> @ <custom_error>)]。它检查target_account字段以查看帐户是否与 Accounts 结构中的target_account字段的键匹配。通过@注释支持自定义错误。

在StakeV0 struct的上下文中has_one约束用于检查帐户是否具有membership_minttoken_accountmembership_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约束。通过帐户约束,可以在一个帐户上同时使用多个帐户约束。

种子、肿块

种子和碰撞约束用于检查给定帐户是否是从当前执行的程序、种子以及碰撞(如果提供)派生的 PDA

  • #[帐户(种子 = <种子>, 凹凸)]
  • #[帐户(种子 = <种子>, 凹凸, 种子::程序 = <表达式>)]
  • #[帐户(种子 = <种子>, 凹凸 = <表达式>)]
  • #[帐户(种子 = <种子>, 凹凸 = <表达式>, 种子::程序 = <表达式>)]

如果未提供凹凸,Anchor 将使用规范凹凸。Seeds::program = <expr>可用于从与当前正在执行的程序不同的程序派生 PDA。

Helium 的扇出程序中,种子约束检查文本“metadata”、token_metadata_program key、membership_collection key 和文本“edition”是否是用于导出此 PDA 的种子。 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 ::minttoken::authority约束定义如下:

  • #[account(token::mint = <目标账户>, token::authority = <目标账户>)]
  • #[account(token::mint = <目标账户>, token::authority = <目标账户>, token::token_program = <目标账户>)]

铸造和授权令牌约束用于验证TokenAccount的铸造地址和授权。这些约束可以用作检查或与init约束一起使用,以创建具有给定铸币地址和权限的代币帐户。当用作检查时,可以仅指定约束的子集。 

在 Helium 程序的上下文中,这些约束用于检查关联代币的铸币是否等于会员铸币,以及代币的权限是否设置为质押者


...
#[account(
  mut,
  associated_token::mint = membership_mint,
  associated_token::authority = staker,
)]
pub from_account: Box<Account<'info, TokenAccount>>,
...

初始化、付款人、空间

此时,在代码中向前跳一点来分析initpayer空间约束是有意义的。初始化约束定义为[#account(init, payer = <target_account>, space = <num_bytes>)]。此约束通过系统程序的 CPI 创建帐户,并通过设置其帐户标识符对其进行初始化。这会将帐户标记为可变,并且与mut互斥。对于大于 10 Kibibytes 的帐户,请使用#[account(zero)]

初始化约束必须与一些附加约束一起使用它需要付款人约束,该约束指定将为帐户创建付费的帐户。它还要求系统程序存在于结构中并称为system_program。还必须定义空间约束。在帐户空间部分,我们将更深入地探讨此约束和空间要求。

关于Helium的扇出程序init命令创建一个新帐户。付款人设置为payer,之前在结构中建立为pub payer: Signer<'info>。帐户的空间设置为FanoutVoucherV0的大小,加上用于鉴别器的 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约束定义为#[account(init_if_nedded, payer = <target_Account>)]#[account(init)if_needed, payer = <target_account>, space = <num_bytes>)]。此约束具有与init完全相同的功能。但是,它仅在帐户尚不存在时运行。如果帐户存在,init_if_needed仍会验证是否满足所有初始化约束,例如帐户分配了正确的空间量,或者在 PDA 的情况下具有正确的种子。

init_if_needed应谨慎使用,因为由于潜在风险,它被限制在功能标志后面。要启用它,请使用init-if-need- cargo 功能导入anchor-lang 。使用init_if_needed时,防止重新初始化攻击至关重要。开发人员必须确保其代码包含检查,以防止帐户在初始化后重置为初始状态,除非有意为之。保持指令执行路径简单以减轻这些攻击被认为是最佳实践。考虑将指令分为一个用于初始化,所有其他指令用于后续操作。

Helium 的 Fanout 程序使用init_if_needed约束来初始化收件人帐户(如果该帐户尚不存在):


...
#[account(
  init_if_needed,
  payer = payer,
  associated_token::mint = mint,
  associated_token::authority = recipient,
)]
pub receipt_account: Box<Account<'info, TokenAccount>>,
...

约束

约束条件定义为#[account(constraint = <expr>)]#[account(constraint = <expr> @ <custom_error>)]。它检查提供的表达式的计算结果是否为 true。当没有其他约束适合预期用例时,这非常有用。它还支持通过@注释自定义错误。

Fanout 程序使用约束来检查铸币厂的供应是否设置为零:


...
#[account(
  mut,
  constraint = mint.supply == 0,
  mint::decimals = 0,
  mint::authority = voucher,
  mint::freeze_authority = voucher,
)]
pub mint: Box<Account<'info, Mint>>,
...

mint::权威、mint::小数、mint::freeze_authority 

在上面的代码片段中,mint::decimalsmint::authoritymint::freeze_authority约束用于检查薄荷的小数是否设置为零、凭证是否具有权限和冻结权限。

对于上下文,mint::authoritymint::decimalsmint::freeze_authority约束定义为:

  • #[account(mint::authority = <目标帐户>, mint::decimals = <expr>)]
  • #[account(mint::authority = <目标账户>, mint::decimals = <expr>, mint::freeze_authority = <目标账户>)]

这些约束是不言而喻的——也就是说,它们分别检查代币的权限、小数点和冻结权限。它们可以用作支票或与init一起使用来创建具有给定的铸币小数和铸币权限的铸币帐户。与init一起使用时,冻结权限完全是可选的。当用作检查时,可以仅指定这些约束的子集。 

账户空间 

程序使用的 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 个字节
  • 动态类型StringVec<T>需要仔细考虑。它们都需要 4 个字节来存储其长度,加上实际内容的空间。为最大预期大小分配足够的空间至关重要。对于String ,这看起来像是 4 个字节加上String的长度(以字节为单位)。对于Vec<T>,这看起来像是 4 个字节加上给定类型的空间乘以预期元素的数量(即 4 + space(T) * amount)
  • 选项和枚举Option<T>类型需要 1 个字节加上类型T的空间。枚举需要 1 个字节用于枚举鉴别器加上最大变体所需的空间
  • 浮点数: f32f64等类型分别占用4个和8个字节。请小心NaN值,因为它们可能导致序列化失败

以下指南仅适用于不使用零拷贝序列化的帐户。零拷贝序列化由#[zero_copy]属性表示。它利用repr(c)属性进行内存布局,从而支持直接指针转换来访问数据。这是一种处理链上数据的有效方法,无需传统反序列化的开销。#[zero_copy]是应用#[derive(Copy, Clone)]#[derive(bytemuck::Zeroable)]#[derive(bytemuck::Pod)]#[repr(C)]的简写。这些属性确保帐户可以被安全地视为字节序列,并且与零拷贝反序列化兼容。零拷贝反序列化对于需要非常大的大小的帐户至关重要- 无法使用 Borsh 或 Anchor 的默认序列化机制有效序列化的帐户,而不会遇到堆或堆栈限制。

Anchor 的内部鉴别器

开发人员必须为 Anchor 内部鉴别器的空间限制添加 8 。例如,如果帐户需要 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)并且适用于AccountAccountLoader类型。它定义为#[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 程序中的错误可以分为AnchorError和非 Anchor 错误。本节将重点关注AnchorErrors,因为非 Anchor 错误包含多种 Rust 错误。对于非锚定错误,我建议查看Rust Book 中有关错误处理的章节Rust By Examples 中有关错误处理的部分

以下结构定义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是一个可选字段,提供有关错误起源的信息,例如涉及的源文件或帐户。Comparison_values是一个可选字段,详细说明发生错误时正在比较的值。这对于调试非常有用。

AnchorError实现了一个log 方法。这包括有关错误来源和所涉及值的信息,这对于调试和错误解决非常有用。此方法使用error_originComparison_values来提供此信息。

AnchorError可以进一步细分为 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
}

错误错误!可以使用宏来引发这些错误。例如:


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 前面提到的使用AnchorError进行日志记录会更有帮助。锚客户端旨在解析这些日志。然而,在某些情况下这可能具有挑战性。例如,在关闭预检检查的情况下检索已处理事务的日志并不那么简单。类似地,Anchor 还对不以标准方式记录AnchorError的非 Anchor 或遗留程序采用回退机制。这里,Anchor 检查事务返回的错误号是否对应于 Anchor 内部错误代码或程序 IDL 中定义的错误号。锚点丰富了错误信息,以便在找到匹配项时提供更多上下文。 Anchor 还会尽可能尝试解析程序错误堆栈,以追溯到程序错误的原始原因。ProgramError是一种基本错误类型,其实用性通过 Anchor 的日志记录和解析机制增强,可提供详细的错误信息。

跨程序调用 (CPI)

跨程序调用 (CPI)
改编自 Solana 的Solana Bytes - 跨程序调用 YouTube 视频

本文通篇都提到了跨程序调用 (CPI) ,因此我们有一个专门的章节来讨论它们是正确的。 CPI 是 Solana 可组合性的基础,因为它们有助于程序直接调用其他程序。如果您愿意的话,这会将 Solana 生态系统变成一个庞大的、互连的 API,供开发人员使用。为了简洁起见,我建议阅读有关 CPI 的 Anchor 文档,因为它们提供了 CPI 在木偶和木偶主程序中的实际应用示例。

然而,CPI 可以定义为从一个程序到另一个程序的调用,以被调用程序中的特定指令为目标。调用程序将暂停,直到被调用程序完成对指令的处理。 

权限提升

CPI 使调用程序能够将其签名者权限扩展到被调用者。特权扩展很方便,但也有可能非常危险。如果 CPI 意外地针对恶意程序,该程序将获得与调用者相同的权限。 Anchor 通过两项保障措施来降低这种风险:

  • Program <'info, T>类型确保指定的帐户与预期的程序 ( T )匹配
  • 即使未使用程序类型,自动生成的 CPI 函数也会验证cpi_program参数是否对应于预期的程序

执行 CPI

程序可以使用solana_program箱中的invokeinvoke_signed来执行 CPI 。 Anchor 还提供CpiContext 结构来指定 CPI 的非参数输入。

调用

当不需要 PDA 作为签名时,使用调用功能。在这种情况下,运行时将原始签名从调用程序扩展到被调用者。该函数定义为:


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

调用另一个程序涉及创建一个指令,其中包括程序的 ID、被调用者程序的指令数据以及被调用者将访问的帐户列表。程序仅在其程序入口点从运行时接收AccountInfo值。被调用程序调用所需的任何帐户都必须包含在调用它的程序中并由调用它的程序提供。例如,如果被调用程序需要修改特定帐户,则调用程序必须将该帐户包含在AccountInfo值列表中。这也适用于被调用者的程序ID(即,调用者必须通过包括被调用者的程序ID来显式指定它正在调用哪个程序)。

尽管可以从外部输出反序列化指令,但该指令通常在调用程序内构造。

如果被调用程序遇到错误或中止,整个事务将立即失败。这是因为调用函数除了成功之外不会返回任何内容。使用set_return_dataget_return_data函数返回数据作为 CPI 结果。请注意,返回的类型必须实现AnchorSerializeAnchorDeserialize特征。或者,让被调用者写入专用帐户来存储数据

虽然程序可以递归地调用自身,但另一个程序的间接递归调用(即重入)将立即导致事务失败。

例如,如果我们有一个通过 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用于需要 PDA 作为签名者的 CPI。它允许调用程序通过提供派生 PDA 所需的种子来代表 PDA 进行操作:


pub fn invoke_signed(
	instruction: &Instruction,
	account_infos: &[AccountInfo<'_>],
  signers_seeds: &[&[&[u8]]]
) -> ProgramResult

PDA 还可以充当 CPI 中的签名者。运行时将使用提供的种子和调用程序的program_id通过create_program_address在内部生成PDA。然后根据随指令传入的地址(即account_infos )验证 PDA,以确认其是有效的签名者。

利用此功能,调用可以代表调用程序控制的一个或多个 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],
  	],
  ]
)?;

Cpi上下文

Anchor 提供CpiContext作为一种更简化的 CPI 方式,而不是使用invokeinvoke_signed。该结构指定 CPI 所需的非参数输入,密切反映Context的功能。它提供了有关指令所需的帐户、涉及的任何其他帐户、调用的程序 ID 以及用于派生 PDA 的种子(如果需要)的信息。对于没有 PDA 的 CPI使用CpiContext::new ,对于需要 PDA 签名者的 CPI使用CpiContext::new_with_signer 。

CpiContext定义如下,其中T是泛型类型,包含实现 ToAccountMetasToAccountInfos <'info>特征的任何对象:


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是通用类型,允许任何实现 ToAccountMetasToAccountInfos <'info>特征的对象。这是通过#[derive(Accounts)]属性宏启用的,以促进代码组织和增强类型安全性。 

CpiContext简化了锚点和非锚点程序的调用。对于 Anchor 程序,只需在项目的Cargo.toml文件中声明依赖项并使用Anchor 生成的cpi模块即可:


[dependencies]
callee = { path = "../callee", features = ["cpi"]}

设置features = [“cpi”]授予程序访问被调用者::cpi模块的权限。 Anchor 自动生成此模块并将程序的指令公开为 Rust 函数。该函数接受CpiContext和任何附加指令数据,镜像 Anchor 程序中常规指令函数的格式,但用CpiContext替换Contextcpi模块还提供了调用指令所需的帐户结构。

例如,如果被调用程序有一条名为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 ,其中包含绝地程序中的GeneralKenobi帐户结构所需的必要帐户信息以及绝地程序的帐户信息。此上下文调用hello_there,传入由GreetingParams结构指定的任何其他必需参数。 CallGeneralKenobi结构定义了此功能所需的帐户,从而简化了流程

最后,当从非 Anchor 程序调用指令时,请检查程序维护者是否发布了自己的包,其中包含用于调用其程序的辅助函数。如果程序中没有任何辅助函数(其指令必须被调用),则回退到使用invokeinvoke_signer来组织和准备 CPI。

程序派生地址 (PDA)

资料来源:基本 Ping 计数器程序示例,由Solana 基金会提供

请记住,PDA 是偏离曲线的,并且没有关联的私钥。它们允许程序签署指令,并允许开发人员在链上构建类似哈希图的结构。 PDA 是使用可选种子列表、凹凸种子和程序 ID 派生的。 

重申一下,以下约束用于检查给定帐户是否是从当前执行的程序、种子以及凹凸(如果提供)派生的 PDA:

  • #[帐户(种子 = <种子>, 凹凸)]
  • #[帐户(种子 = <种子>, 凹凸, 种子::程序 = <表达式>)]
  • #[帐户(种子 = <种子>, 凹凸 = <表达式>)]
  • #[帐户(种子 = <种子>, 凹凸 = <表达式>, 种子::程序 = <表达式>)]

如果未提供凹凸,Anchor 将使用规范凹凸。Seeds::program = <expr>可用于从与当前正在执行的程序不同的程序派生 PDA。

使用种子凹凸约束可以简化推导过程:


#[derive(Accounts)]
struct ExamplePDA<'info> {
	#[account(seeds = [b"example"], bump)]
	pub example_pda: Account<'info, AccountType>,
}

这里,种子约束用于导出 PDA。 Anchor 自动验证传入指令的帐户是否与从种子派生的 PDA 相匹配。当凹凸约束在没有特定值的情况下使用时,锚点默认为规范凹凸。

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所使用的各种宏和特性的探索,可以明显看出它简化开发过程的能力。它有完善的文档支持和强大的相关教程和库生态系统。Anchor受到绝大多数Solana开发者的喜爱和使用。

本文是一个关于在Anchor中开发程序的非常全面的指南。内容涵盖了安装Anchor、使用Solana Playground,以及创建、构建和部署Hello, World!程序。然后,我们探讨了Anchor的有效抽象方法、典型Anchor程序的结构、各种账户类型和约束。还介绍了分配账户空间和错误处理的重要性。最后,我们探讨了CPIs和PDAs。这就是Anchor的文章——它包含了您今天开始在Solana上开发程序所需的一切。

💡
💡原文链接:An Introduction to Anchor: A Beginner’s Guide to Building Solana Programs
本文由SlerfTools翻译,转载请注明出处。

SlerfTools专为Solana设计的工具箱,致力于简化区块链操作,提供无编程全可视化界面,使发币管理流动性无代码创建Dapp等复杂过程变得安全简单。