Solana

由 Rareskills 提供的 Solana 课程(DeCert.me 翻译成中文)

本 Solana 课程旨在帮助具有以太坊或 EVM 开发的初学者或中级背景的工程师快速掌握 Solana 程序开发。

跳转到课程

初学者在学习区块链编程时面临的困难是他们必须学习一种新的计算模型、学习一种新的语言和学习一个新的开发框架。

如果你已经在以太坊或兼容以太坊的区块链上开发过,那么你已经对计算模型有了相当好的了解,可以专注于语言和框架。

我们的目标是利用你在以太坊方面的过去经验,更快地学习 Solana。 你无需从零开始。

Solana 与以太坊在如何运行区块链方面有不同的模型,但并不完全不同。

与其直接解释所有的不同之处,本教程试图将关键信息压缩到以下范式中:

“我知道如何在以太坊中做 X,我如何在 Solana 中做 X?

并在某些情况下,

“我无法在 Solana 中做 X,为什么?”

我们采取这种方法是因为如果你能够将概念从你已经掌握的心智模型映射到新事物上,那么开发新事物的心智模型会更容易。

如果你像大多数程序员一样,学习 Solidity 可能很容易。 它几乎是一对一对应的 JavaScript。 但是,设计智能合约可能是一个挑战,因为它与其他应用程序非常不同。

我们希望你能够了解 Solana 和以太坊的相似之处,以及它们之间的关键区别。

(注:在本系列中我们经常提到以太坊,但所有的想法也适用于其他兼容 EVM 的区块链,如 Avalanche 和 Polygon)。

所有区块链都是去中心化状态机

Solana 的架构确实有很大不同,但它基本上与以太坊做的事情相同:

区块链节点图标

它是一个分布式状态机,根据签名交易进行状态转换,执行的成本是用生态系统的原生代币支付(以太坊为 ETH,Solana 为 SOL)。

solana 和 以太坊 logo

我们的目标是利用你对 EVM 的知识,作为你 Solana 开发之旅的跳板。

考虑这个类比:
如果一个前端 Web 开发人员和一个后端 API/数据库工程师都决定学习移动应用开发,大多数工程师会说前端 Web 开发人员比后端工程师有更大的优势,即使 Web 开发和移动开发并不是同一个领域,即使开发经验可能非常相似,使用一些工具链。

根据这种推理,我们 RareSkills 认为,一个称职的 EVM 智能合约工程师应该能够比一个以前没有编写过区块链的工程师更快地掌握 Solana。

这门课程旨在利用这种优势。

我们从 Solana 与以太坊相似的方面开始

如果你查看我们的大纲,你会发现我们似乎更多地涵盖了中级主题(按 Solidity 标准),如 gas 使用,然后再涵盖更基础的内容(如如何更新存储变量)。 这是有意为之的。

从 Solana 中的 EVM 等效开始

我们希望首先介绍那些可以从以太坊概念中进行一对一映射的主题。 我们假设你知道存储是一个重要的主题,并且可以等一会再深入研究它。

通过小巧的练习来简化过渡

使用新框架已经会让人感到尴尬。 给你一堆依赖于熟悉心智模型的小练习将会简化过渡。 同时使用新框架和新心智模型会让人望而却步。 我们希望你在早期体验到许多小胜利,以便在接触更不熟悉的方面时能够保持一些动力。

一个积极的学习之旅

我们在整个教程中都包含了练习,用粗体字练习标记。 这些将是你刚刚学到的知识的实际应用。。 你应该做这些练习!积极的学习总是胜过被动阅读。

我们期望你熟悉 Solidity

如果你从未进行过智能合约开发,那么本教程并不是直接面向你的。 我们假设你对 Solidity 有初学者到中级水平的了解。 如果 Solidity 的示例让你感到陌生,请练习我们的免费 Solidity 教程 一周,然后再回到这里。

我需要了解多少 Rust?

不需要很多。

Rust 是一种庞大的语言,其语法足以超过大多数其他流行语言。 在学习 Solana 之前“先精通 Rust”可能是一个错误。 你可能会走上一个持续几个月的弯路!

本课程仅关注你需要了解的最少 Rust。

如果你对开始使用一种以前未使用过的语言感到不舒服,请完成我们的 Rust 入门课程中的免费视频和练习,然后就此打住。 我们的审阅人员在没有先通过 Rust 入门课程的情况下完成了这里的练习,因此我们认为我们成功地在本课程中平衡了教授恰到好处的 Rust 知识。

为什么是 60 天?

我们发现学习者在将信息分解为可能的最原子位时保持最活跃。 如果教程太长,只有最感兴趣的读者才会完成。 在将教程限制为尽可能原子化之后,我们估计大约需要六十个教程才能对 Solana 开发生态系统有一个舒适的掌握。

我们已经对这些教程进行了测试,并发现审阅人员能够在不到一个小时的时间内轻松完成。 每天不需要花费太多精力使学习 Solana 更具可持续性,并减少烧脑的可能性。

如果有动力的读者可以根据需要更快地完成课程。

对 Solana 只是感兴趣的读者可以以更轻松的步调消化课程,而不会在任何一天花费太多宝贵的时间和精力。

我们的课程旨在让你在编写应用程序时快速查阅所需内容。 例如,如果你忘记如何在 Solana 中获取当前时间,你将很容易找到适当的部分并复制粘贴所需的代码。

请注意,本文中的代码采用 MIT 许可,但未经许可,严禁复制、复制或创建此课程的衍生作品。

致谢

我们要感谢 Faybian Byrd、Devtooligan、Abhi Gulati,他们仔细审查并提供了对这项工作的反馈。

Solana 课程

模块 1 | 入门主题


第 1 天 Hello World(以及解决 Solana 安装问题)

第 2 天 函数参数、数学和算术溢出

第 3 天 Anchor 函数魔法和接口定义语言

第 4 天 Solana 回滚、错误和基本访问控制

第 5 天 构造函数在哪里?关于 anchor 部署

模块 2 | 你需要了解的最低限度的 Rust

第 8-10 天并不重要,它们只是解释了大多数读者可能不熟悉的一些语法。但是,你可以编写 Solana 程序并跟随其中,将不寻常的语法视为样板。可以随意略过那几天(的课程)。

第 6 天 将 Solidity 翻译为 Rust 和 Solana

第 7 天 Rust 的不寻常语法

第 8 天 理解 Rust 中类似函数的宏

第 9 天 Rust 结构体和类似属性以及自定义派生宏

第 10 天 将 Solidity 函数可见性和合约继承翻译为 Solana

模块 3 | Solana 中的重要系统级信息

第 11 天 Solana 中的区块变量:block.timestamp 和 block.number 等

第 12 天 超越区块:sysvars

第 13 天 Solana 日志、事件和交易历史

第 14 天 Solana 中的 tx.origin、msg.sender 和 onlyOwner

第 15 天 交易费用和计算单位

模块 4 | Solana 中的账户和存储

账户是 Solana 开发中最复杂的主题之一,因为它们比以太坊存储变量要灵活得多,因此我们会慢慢介绍它们。每个教程都会逐渐强化概念,所以如果所有新信息没有立即牢记也不用担心。

第 16 天 Solana 中的账户

第 17 天 写入存储

第 18 天 从 Typescript 读取账户 —— 替代公共变量和查看函数

第 19 天 在 Solana 中创建映射和嵌套映射

第 20 天 存储成本、最大存储大小和账户调整大小

第 21 天 在 Rust 中读取账户余额:Solana 中的 address(account).balance

第 22 天 更多区别:Solana 中的修饰符、view pure、payable 和 fallback

第 23 天 构建支付分配器:Solana 中的“payable”和“msg.value”

第 24 天 授权各种钱包写入账户:“Pranking tx.origin”

第 25 天 PDA vs Keypair 账户

第 26 天 理解 Solana 中的账户所有权:将 SOL 转出 PDA

第 27 天 init_if_needed 和重新初始化攻击

第 28 天 Solana 中的 Multicall:批处理交易

第 29 天 所有者 vs 权威

第 30 天 删除账户和关闭程序

第 31 天 #[derive(Accounts)]中的账户类型

第 32 天 在链上读取另一个 Anchor 程序的账户数据

第 33 天 跨程序调用

Solana Hello World(安装和故障排除)

更新日期:2 月 9 日

Solana Hello World

这是一个 Solana Hello World 教程。我们将为你介绍安装 Solana 和解决可能出现的问题的步骤。

如果遇到问题,请查看本文末尾的故障排除部分。

安装 Rust

如果你已经安装了 Rust,请跳过此步骤。

# 安装 Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

安装 Yarn

你需要这个来运行单元测试。如果你已经安装了 yarn,请跳过此步骤。

# 安装 yarn -- 假设已安装 node js
corepack enable # corepack 随 node js 一起安装

安装 Solana 命令行工具

我们强烈建议使用stable版本,而不是latest

# 安装 solana
sh -c "$(curl -sSfL https://release.anza.xyz/stable/install)"

安装 Anchor

Anchor 是 Solana 开发的一个框架。在许多方面,它与 hardhat 非常相似。

# install anchor
cargo install --git https://github.com/coral-xyz/anchor avm --locked --force

avm install latest
avm use latest

初始化并构建一个 Anchor 程序(hello world)

Mac 用户: 我们建议将你的程序命名为day_1而不是day1,因为 Anchor 似乎有时会在 Mac 机器上悄悄插入下划线。

anchor init day1 # use day_1 if you have a mac
cd day1
anchor build

根据你的机器和互联网连接,此步骤可能需要一段时间。这也是你可能遇到安装问题的地方,请在必要时查看故障排除部分。

配置 Solana 在本地主机上运行

solana config set --url localhost

运行测试验证节点

在新的 shell 中运行以下命令,不要在 Anchor 项目中运行。但不要关闭你运行anchor build的 shell。这在你的机器上运行一个本地(测试)Solana 节点实例:

solana-test-validator

确保 program_id 与 Anchor 密钥同步

返回到具有 Anchor 项目的 shell,并运行以下命令:

anchor keys sync

运行测试

在 Anchor 项目中运行此命令

anchor test --skip-local-validator

上面的命令运行我们程序的测试。如果你还没有创建测试钱包,Anchor 将为你提供如何操作的说明。我们在这里不提供这些说明,因为这将取决于你的操作系统和文件结构。你可能还需要通过在终端中运行solana airdrop 100 {YOUR_WALLET_ADDRESS}来向自己空投一些本地 Sol。你可以通过在命令行中运行solana address来获取你的钱包地址。

预期输出如下:

solana anchor 测试通过

Hello World

现在让我们让我们的程序输出“Hello, world!”。将以下行添加到programs/day_1/src/lib.rs中标记为**** NEW LINE HERE ****的位置。

use anchor_lang::prelude::*;

declare_id!("...");

#[program]
pub mod day_1 {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        msg!("Hello, world!"); // **** NEW LINE HERE ****
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize {}

再次运行测试

anchor test --skip-local-validator

通过运行以下命令找到日志文件

ls .anchor/program-logs/

打开该文件以查看记录的“Hello world”

hello world solana 日志

实时 Solana 日志

或者,你可以通过打开第三个 shell 并运行以下命令来查看日志

solana logs

现在再次运行测试,你应该在运行solana logs的终端中看到相同的消息。

问题与答案

为什么 declare_id! 和 msg! 后面有感叹号?

在 Rust 中,感叹号表示这些是宏。我们将在以后的教程中重新讨论宏。

我需要一个 initialize 函数吗?

不需要,这是由 Anchor 框架自动生成的。你可以随意命名它。

在这种情况下,initialize的名称并没有什么特别之处,因此我们可以将名称更改为任何你喜欢的名称。这与其他一些关键字和语言不同,比如在某些语言中main是一个特殊的名称,或者在 Solidity 中constructor是一个特殊的名称。

练习: 尝试将 programs/day_1/src/lib.rs 中的 initialize tests/day_1.ts 中的 initialize 更名为 initialize2 ,然后再次运行测试。请查看下面用橙色圈圈标记的更改。

更改 initialize() 函数名称

为什么我们使用--skip-local-validator 运行测试?

当测试针对一个节点运行时,我们将能够查询节点的状态更改。如果你无法使节点运行,可以在不带--skip-local-validator标志的情况下运行anchor test。但是,你将更难以开发和测试,因此我们建议使本地验证器正常工作。

故障排除

Solana 是一个快速发展的软件,你可能会遇到安装问题。我们已记录了你最有可能遇到的问题,以下是各个部分。

我们的教程系列是使用以下版本编写的:

  • Anchor = 版本 0.29.0
  • Solana = 版本 1.16.25
  • Rustc = 1.77.0-nightly

你可以通过运行以下命令更改 Anchor 版本

avm install 0.29.0
avm use 0.29.0

你可以通过在 curl 命令中指定版本来更改 Solana 版本:

# 安装 solana
sh -c "$(curl -sSfL https://release.solana.com/1.16.25/install)"

error: package `solana-program v1.18.0` cannot be built

error: package `solana-program v1.18.0` cannot be built because it requires rustc 1.72.0 or newer, while the currently active rustc version is 1.68.0-dev
Either upgrade to rustc 1.72.0 or newer, or use
cargo update -p [email protected] --precise ver

使用solana --version检查你正在运行的 Solana 版本。然后将该版本插入上面的ver中。下面显示了一个解决方案示例:

solana 版本安装问题

error[E0658]:use of unstable library feature 'build_hasher_simple_hash_one'

如果你遇到以下错误:

error[E0658]: use of unstable library feature 'build_hasher_simple_hash_one'
--> src/random_state.rs:463:5
|
463 | / fn hash_one<T: Hash>(&self, x: T) -> u64 {
464 | | RandomState::hash_one(self, x)
465 | | }
| |_____^
|
= note: see issue #86161 https://github.com/rust-lang/rust/issues/86161 for more information
= help: add #![feature(build_hasher_simple_hash_one)] to the crate attributes to enable

运行以下命令:cargo update -p [email protected] --precise 0.8.6 资料来源:https://solana.stackexchange.com/questions/8800/cant-build-hello-world

Error: Deploying program failed: Error processing Instruction 1: custom program error: 0x1

Error: Deploying program failed: Error processing Instruction 1: custom program error: 0x1
There was a problem deploying: Output { status: ExitStatus(unix_wait_status(256)), stdout: "", stderr: "" }.

如果遇到此错误,则你的密钥未同步。运行anchor keys sync

Error: failed to send transaction: Transaction simulation failed: Attempt to load a program that does not exist

你的密钥未同步。运行anchor keys sync

Error: Your configured rpc port: 8899 is already in use

你在后台运行验证器的情况下运行了anchor test而没有--skip-local-validator。要么关闭验证器并运行anchor test,要么在运行验证器的情况下运行anchor test --skip-local-validator。跳过本地验证器意味着跳过为项目创建的临时验证器,而不是在后台运行的验证器。

Error: Account J7t...zjK has insufficient funds for spend

运行以下命令向你的开发地址空投 100 SOL

solana airdrop 100 J7t...zjK

Error: RPC request error: cluster version query failed

Error: RPC request error: cluster version query failed: error sending request for url (http://localhost:8899/): error trying to connect: tcp connect error: Connection refused (os error 61)
There was a problem deploying: Output { status: ExitStatus(unix_wait_status(256)), stdout: "", stderr: "" }.

这意味着solana-test-validator未在后台运行。在另一个 shell 中运行solana-test-validator

thread 'main' panicked at 'called `Option::unwrap()` on a `None` value'

thread 'main' panicked at 'called `Option::unwrap()` on a `None` value', /Users/username/.cargo/git/checkouts/anchor-50c4b9c8b5e0501f/347c225/lang/syn/src/idl/file.rs:214:73
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

很可能你尚未运行anchor build

我使用 Mac,出现错误:failed to start validator: Failed to create ledger at test-ledger: blockstore error

按照此 Stack Exchange 线程中的说明操作。

我的 Mac 上没有 corepack,尽管已安装 node.js

运行以下命令

brew install corepack
brew link --overwrite corepack

资料来源:https://stackoverflow.com/questions/70082424/command-not-found-corepack-when-installing-yarn-on-node-v17-0-1

error: not a directory:

BPF SDK: /Users/rareskills/.local/share/solana/install/releases/stable-43daa37937907c10099e30af10a5a0b43e2dd2fe/solana-release/bin/sdk/bpf
cargo-build-bpf child: rustup toolchain list -v
cargo-build-bpf child: rustup toolchain link bpf /Users/rareskills/.local/share/solana/install/releases/stable-43daa37937907c10099e30af10a5a0b43e2dd2fe/solana-release/bin/sdk/bpf/dependencies/bpf-tools/rust
error: not a directory:

清除缓存:运行rm -rf ~/.cache/solana/*

Error: target/idl/day_1.json doesn't exist. Did you run `anchor build`?

创建一个名为 day_1 而不是 day1 的新项目。Anchor 似乎在某些机器上悄悄插入下划线。

通过 RareSkills 了解更多

这个教程是我们免费的 Solana 课程中的第一个。

Solana 和 Rust 中的算术和基本类型

solana 计算器

今天我们将学习如何创建一个 Solana 程序,实现与下面的 Solidity 合约相同的功能。我们还将学习 Solana 如何处理像溢出这样的算术问题。

contract Day2 {

	event Result(uint256);
	event Who(string, address);

	function doSomeMath(uint256 a, uint256 b) public {
		uint256 result = a + b;
		emit Result(result);
	}

	function sayHelloToMe() public {
		emit Who("Hello World", msg.sender);
	}
}

让我们开始一个新项目

anchor init day2
cd day2
anchor build
anchor keys sync

确保在一个终端中运行 Solana 本地验证者节点:

solana-test-validator

在另一个终端中查看 Solana 日志:

solana logs

通过运行测试来确保新创建的程序正常工作

anchor test --skip-local-validator

提供函数参数

在进行任何数学运算之前,让我们将 initialize 函数更改为接收两个整数的函数。以太坊使用 uint256 作为“标准”整数大小。在 Solana 中,它是 u64 —— 这相当于 Solidity 中的 uint64。

传递无符号整数

默认的 initialize 函数如下所示:

#![allow(unused)]
fn main() {
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
    Ok(())
}
}

将 lib.rs 中的 initialize() 函数修改如下:

#![allow(unused)]
fn main() {
pub fn initialize(ctx: Context<Initialize>,
                  a: u64,
                  b: u64) -> Result<()> {
    msg!("You sent {} and {}", a, b);
    Ok(())
}
}

现在我们需要更改./tests/day2.ts中的测试

it("Is initialized!", async () => {
  // Add your test here.
  const tx = await program.methods
    .initialize(new anchor.BN(777), new anchor.BN(888)).rpc();
  console.log("Your transaction signature", tx);
});

现在重新运行anchor test --skip-local-validator

当我们查看日志时,应该看到类似以下内容

solana logging numbers

传递字符串

现在让我们演示如何将字符串作为参数传递。

#![allow(unused)]
fn main() {
pub fn initialize(ctx: Context<Initialize>,
                  a: u64,
                  b: u64,
                  message: String) -> Result<()> {
    msg!("You said {:?}", message);
    msg!("You sent {} and {}", a, b);
    Ok(())
}
}

并更改测试

it("Is initialized!", async () => {
  // Add your test here.
  const tx = await program.methods
    .initialize(
       new anchor.BN(777), new anchor.BN(888), "hello").rpc();
    console.log("Your transaction signature", tx);
});

运行测试后,我们会看到新的日志

数组

接下来,我们添加一个函数(和测试)来演示传递一个数字数组。在 Rust 中,“vector”或Vec是 Solidity 中称为“array”的东西。

#![allow(unused)]
fn main() {
pub fn initialize(ctx: Context<Initialize>,
                  a: u64,
                  b: u64,
                  message: String) -> Result<()> {
    msg!("You said {:?}", message);
    msg!("You sent {} and {}", a, b);
    Ok(())
}

// added this function
pub fn array(ctx: Context<Initialize>,
             arr: Vec<u64>) -> Result<()> {
    msg!("Your array {:?}", arr);
    Ok(())
}
}

并将单元测试更新如下:

it("Is initialized!", async () => {
  // Add your test here.
  const tx = await program.methods.initialize(new anchor.BN(777), new anchor.BN(888), "hello").rpc();
  console.log("Your transaction signature", tx);
});

// added this test
it("Array test", async () => {
  const tx = await program.methods.array([new anchor.BN(777), new anchor.BN(888)]).rpc();
  console.log("Your transaction signature", tx);
});

然后再次运行测试并查看日志以查看数组输出:

solana logging an array

提示: 如果在 Anchor 测试中遇到问题,请尝试搜索与你的错误相关的“Solana web3 js”。Anchor 使用的 Typescript 库是 Solana web3 js 库。

Solana 中的数学

浮点数运算

Solana 对浮点数操作有一些有限的本地支持。

然而,最好避免浮点数运算,因为它们在计算上是非常耗费资源的(稍后我们将看到一个例子)。请注意,Solidity 没有对浮点数操作提供本地支持。

阅读更多关于使用浮点数的限制,请看这里

算术溢出

算术溢出曾是 Solidity 中的一个常见攻击向量,直到版本 0.8.0 默认在语言中构建了溢出保护。在 Solidity 0.8.0 或更高版本中,默认会进行溢出检查。由于这些检查会消耗 gas,有时开发人员会有策略性地使用“unchecked”块来禁用它们。

Solana 如何防范算术溢出?

方法 1:在 Cargo.toml 中设置 overflow-checks = true

如果在 Cargo.toml 文件中将 overflow-checks 设置为 true,那么 Rust 将在编译器级别添加溢出检查。以下为 Cargo.toml 的截图:

cargo.toml

如果 Cargo.toml 文件以这种方式配置,就不用担心溢出了。

然而,添加溢出检查会增加交易的计算成本(我们很快会重新讨论这一点)。因此,在某些情况下,计算成本是一个问题,你可能希望将overflow-checks 设置为 false。为了有策略地检查溢出,你可以在 Rust 中使用checked_*运算符。

方法 2:使用checked_*运算符

让我们看看溢出检查是如何应用于 Rust 内部的算术运算的。考虑下面的 Rust 代码片段。

  • 在第 1 行,我们使用通常的+运算符进行算术运算,它会在溢出时默默地溢出。
  • 在第 2 行,我们使用.checked_add,如果发生溢出,它将抛出错误。请注意,我们还有.checked_*可用于其他操作,如checked_subchecked_mul
#![allow(unused)]
fn main() {
let x: u64 = y + z; // will silently overflow
let xSafe: u64 = y.checked_add(z).unwrap(); // will panic if overflow happens

// checked_sub, checked_mul, etc are also available
}

练习 1: 设置overflow-checks = true,创建一个测试用例,通过执行0 - 1来使u64发生下溢。你需要将这些数字作为参数传递,否则代码将无法编译。会发生什么?

当运行测试时,你会看到交易失败(下面显示了一个相当神秘的错误消息)。这是因为 Anchor 打开了溢出保护:

算术溢出导致的程序错误

练习 2: 现在将overflow-checks 更改为 false,然后再次运行测试。你应该看到一个下溢值为 18446744073709551615。

练习 3: 在 Cargo.toml 中禁用溢出保护后,使用let result = a.checked_sub(b).unwrap();,其中 a = 0,b = 1。会发生什么?

是否应该在 Anchor 项目中 Cargo.toml 文件中设置overflow-checks = true? 一般来说,是的。但是,如果你正在进行一些密集的计算,你可能希望将overflow-checks设置为 false,并在关键时刻有策略地防范溢出,以节省计算成本,我们将在接下来演示。

Solana 计算单元 101

在以太坊中,交易运行直到消耗了交易指定的“gas limit”。Solana 将“gas”称为“计算单元(compute unit)”。默认情况下,交易限制为 200,000 个计算单元。如果消耗了超过 200,000 个计算单元,交易将回滚。

确定 Solana 中交易的计算成本

与以太坊相比,Solana 确实使用起来更便宜,但这并不意味着在以太坊开发中的优化技能是无用的。让我们测试一下这些数学函数需要多少计算单元。

Solana 日志终端还显示了使用了多少计算单元。我们提供了检查和未检查的减法的基准测试,结果如下。

禁用溢出保护时消耗 824 个计算单元:

没有算术溢出保护时的 Solana 计算单元消耗

启用溢出保护时消耗 872 个计算单元:

启用算术溢出保护时的 Solana 计算单元消耗

正如你所看到的,仅进行简单的数学运算就占用了近 1000 个单位。由于我们有 20 万个单位,我们只能在每个交易的 gas 限制内进行几百次简单的算术运算。因此,虽然 Solana 上的交易通常比以太坊上便宜,但我们仍然受到相对较小的计算单元上限的限制,无法在 Solana 链上执行像流体动力学模拟这样的计算密集型任务。

稍后我们将重新讨论交易成本。

指数运算与 Solidity 语法不相同

在 Solidity 中,如果我们想计算 x 的 y 次方,我们会这样做

uint256 result = x ** y;

Rust 不使用这种语法。相反,它使用 .pow

#![allow(unused)]
fn main() {
let x: u64 = 2; // it is important that the base's data type is explicit
let y = 3; // the exponent data type can be inferred
let result = x.pow(y);
}

如果你担心溢出,还有 .checked_pow

浮点数

在智能合约中使用 Rust 的一个好处是,我们不必导入类似 Solmate 或 Solady 这样的库来进行数学运算。Rust 是一种非常复杂的语言,具有许多内置操作,如果我们需要某段代码,我们可以在 Solana 生态系统之外寻找一个 Rust crate(这是 Rust 中称为库的东西)来完成这项工作。

让我们计算 50 的立方根。浮点数的立方根函数内置在 Rust 语言中,使用函数 cbrt()

#![allow(unused)]
fn main() {
// note that we changed `a` to f32 (float 32)
// because `cbrt()` is not available for u64
pub fn initialize(ctx: Context<Initialize>, a: f32) -> Result<()> {
  msg!("You said {:?}", a.cbrt());
  Ok(());
}
}

还记得我们在前面提到的疑问:浮点数可能会消耗大量计算资源吗?在这里,我们看到立方根运算消耗的计算资源是无符号整数简单算术的 5 倍:

立方根的高计算单元成本

练习 4: 构建一个计算器,可以执行 +,-,x 和 ÷,还有 sqrt 和 log10。

Solana Anchor 程序 IDL

img

IDL(接口定义语言)是一个 JSON 文件,描述了如何与 Solana 程序进行交互。它是由 Anchor 框架自动生成的。

名为“initialize”的函数没有什么特别之处——这是 Anchor 选择的名称。在本教程中,我们将学习 TypeScript 单元测试如何“找到”适当的函数。

让我们创建一个名为anchor-function-tutorial的新项目,并将initialize函数中的名称更改为 boaty_mc_boatface,保持其他内容不变。

#![allow(unused)]
fn main() {
pub fn boaty_mc_boatface(ctx: Context<Initialize>) -> Result<()> {
    Ok(())
}
}

现在将测试更改为以下内容:

it("Call boaty mcboatface", async () => {
  // Add your test here.
  const tx = await program.methods.boatyMcBoatface().rpc();
  console.log("Your transaction signature", tx);
});

现在使用anchor test --skip-local-validator运行测试。

测试按预期运行。那么它是如何奇迹般工作的呢?

测试是如何知道 initialize 函数的?

当 Anchor 构建 Solana 程序时,它会创建一个 IDL(接口定义语言)。

这个 IDL 存储在target/idl/anchor_function_tutorial.json中。这个文件只所以被称为anchor_function_tutorial,因为anchor_function_tutorial是程序的名称。请注意,Anchor 将破折号转换为下划线!

查看这个文件:

{
  "version": "0.1.0",
  "name": "anchor_function_tutorial",
  "instructions": [
    {
      "name": "boatyMcBoatface",
      "accounts": [],
      "args": []
    }
  ]
}

“instructions”列表是程序的公共函数,大致相当于以太坊合约上的外部函数和公共函数。在 Solana 中,IDL 文件的作用类似于 Solidity 中的 ABI 文件,指定如何与程序/合约进行交互。

我们之前看到函数没有接受任何参数,这就是为什么args列表为空的原因。我们稍后会解释“accounts”是什么。

有一件事很明显:Rust 中的函数是蛇形命名,但 Anchor 在 JavaScript 中将它们格式化为驼峰式。这是为了遵循不同语言的约定:Rust 倾向于使用蛇形命名,而 JavaScript 通常使用驼峰命名。

通过该 JSON 文件,“methods”对象可以知道了解需要支持哪些功能

当运行测试时,我们期望它通过测试,这意味着测试正确地调用了 Solana 程序:

solana 测试通过

练习: 为 boaty_mc_boatface 函数添加一个接收 u64 类型的参数。再次运行anchor build,然后再次打开target/idl/anchor_function_tutorial.json文件。它有什么变化?

现在创建一个 Solana 程序,其中包含用于基本加法和减法的函数,这些函数会打印结果。Solana 函数无法像 Solidity 那样返回数值,所以必须打印它们。(Solana 有其他传递值的方式,我们稍后讨论)。让我们创建两个类似的函数:

#![allow(unused)]
fn main() {
pub fn add(ctx: Context<Initialize>, a: u64, b: u64) -> Result<()> {
  let sum = a + b;
  msg!("Sum is {}", sum);
	Ok(())
}

pub fn sub(ctx: Context<Initialize>, a: u64, b: u64) -> Result<()> {
  let difference = a - b;
  msg!("Difference is {}", difference);
	Ok(())
}
}

并将单元测试更改为以下内容:

it("Should add", async () => {
  const tx = await program.methods.add(new anchor.BN(1), new anchor.BN(2)).rpc();
  console.log("Your transaction signature", tx);
});

it("Should sub", async () => {
  const tx = await program.methods.sub(
	new anchor.BN(10),
	new anchor.BN(3)).rpc();
  console.log("Your transaction signature", tx);
});

练习:muldivmod实现类似的函数,并编写单元测试来触发每个函数。

Initialize 结构体是什么?

现在还有一个疑问。我们保持Initialize结构体不变,并在函数之间重复使用它。同样,名称并不重要。让我们将结构体名称更改为Empty,然后重新运行测试。

#![allow(unused)]
fn main() {
  // ...
  // Change struct name here
	pub fn add(ctx: Context<Empty>, a: u64, b: u64) -> Result<()> {
	    let sum = a + b;
	    msg!("Sum is {}", sum);
	    Ok(())
	}
//...

// Change struct name here too
#[derive(Accounts)]
pub struct Empty {}
}

同样,这里的名称--Empty是完全任意的。

练习: 将结构体Empty更改为BoatyMcBoatface,然后重新运行测试。

#[derive(Accounts)] 结构体是什么?

这个#语法是 Anchor 框架定义的 Rust 属性 。我们将在后续教程中进一步解释这个内容。现在,我们要关注 IDL 中的accounts以及它如何与程序中定义的结构体相关联。

Accounts IDL

下面是程序的 IDL 的截图。我们就可以看到 Rust 属性#[derive(Accounts)]中的“Accounts”与 IDL 中的“accounts”键之间的关系:

solana anchor idl

在我们的示例中,上面紫色箭头标记的 JSON IDL 中的accounts键是空的。但对于大多数有用的 Solana 交易来说,情况并非如此,我们稍后会学习。

因为BoatyMcBoatface账户结构体是空的,所以 IDL 中的账户列表也是空的。

现在让我们看看当结构体不为空时会发生什么。复制下面的代码,并替换 lib.rs 的内容。

#![allow(unused)]
fn main() {
use anchor_lang::prelude::*;

declare_id!("8PSAL9t1RMb7BcewhsSFrRQDq61Y7YXC5kHUxMk5b39Z");

#[program]
pub mod anchor_function_tutorial {
    use super::*;

    pub fn non_empty_account_example(ctx: Context<NonEmptyAccountExample>) -> Result<()> {
        Ok(())
    }
}

#[derive(Accounts)]
pub struct NonEmptyAccountExample<'info> {
    signer: Signer<'info>,
    another_signer: Signer<'info>,
}
}

现在运行anchor build - 让我们看看新的 IDL 返回了什么。

{
  "version": "0.1.0",
  "name": "anchor_function_tutorial",
  "instructions": [
    {
      "name": "nonEmptyAccountExample",
      "accounts": [
        {
          "name": "signer",
          "isMut": false,
          "isSigner": true
        },
        {
          "name": "anotherSigner",
          "isMut": false,
          "isSigner": true
        }
      ],
      "args": []
    }
  ],
  "metadata": {
    "address": "8PSAL9t1RMb7BcewhsSFrRQDq61Y7YXC5kHUxMk5b39Z"
  }
}

请注意,“accounts”不再为空,并且填充了来自结构体的字段:“signer”和“anotherSigner”(请注意,another_signer 从蛇形命名转换为驼峰命名)。IDL 已经更新以匹配刚刚更改的结构体,特别是我们添加的账户数量。

我们将在即将推出的教程中更深入地探讨“Signer”,但目前你可以将其视为类似于以太坊中的tx.origin

另一个程序和 IDL 的第二个示例

为了总结我们现在学到的一切,再构建一个具有不同函数和账户结构的程序。

#![allow(unused)]
fn main() {
use anchor_lang::prelude::*;

declare_id!("8PSAL9t1RMb7BcewhsSFrRQDq61Y7YXC5kHUxMk5b39Z");

#[program]
pub mod anchor_function_tutorial {
    use super::*;

    pub fn function_a(ctx: Context<NonEmptyAccountExample>) -> Result<()> {
        Ok(())
    }

    pub fn function_b(ctx: Context<Empty>, firstArg: u64) -> Result<()> {
        Ok(())
    }
}

#[derive(Accounts)]
pub struct NonEmptyAccountExample<'info> {
    signer: Signer<'info>,
    another_signer: Signer<'info>,
}

#[derive(Accounts)]
pub struct Empty {}
}

现在使用anchor build构建程序

让我们再次查看 IDL 文件target/idl/anchor_function_tutorial.json,并将这些文件并排放置:

idl 与 solana 程序并排

你能看到 IDL 文件与上面程序之间的关系吗?

函数function_a没有参数,这在 IDL 中显示为args键下的空数组。

Context接受NonEmptyAccountExample结构体。这个结构体NonEmptyAccountExample有两个 Signer 类型的属性:signeranother_signer。请注意,这些在 IDL 中作为function_aaccounts键中的元素重复显示。你可以看到 Anchor 将 Rust 的蛇形命名转换为 IDL 中的驼峰命名。

函数function_b接受一个 u64 类型的参数。它的结构体为空,因此 IDL 中function_baccounts键是一个空数组。

一般来说,我们希望 IDL 中accounts中包含的属性与函数在其ctx参数中接受的账户结构体的属性相匹配。

总结

在本章中:

  • 我们了解到 Solana 使用 IDL(接口定义语言)显示如何与 Solana 程序进行交互以及 IDL 中显示的字段。
  • 我们介绍了由#[derive(Accounts)]修改的结构体及其与函数参数的关系。
  • Anchor 将 Rust 中的蛇形命名函数转化为 TypeScript 测试中的驼峰命名函数。

Solana 中的 Require、Revert 和自定义错误

更新日期:Feb 29

#[error_code] 和 require!() 宏

在以太坊中,我们经常看到一个 require 语句限制函数参数的值。示例如下:

function foobar(uint256 x) public {
	require(x < 100, "I'm not happy with the number you picked");
  // rest of the function logic
}

在上面的代码中,如果 foobar 的值为 100 或更大,交易将会回滚。

在 Solana 中,或者更具体地说,在 Anchor 框架中,我们该如何做到这一点呢?

Anchor 提供了 与 Solidity 的自定义错误和 require 类似的语法。可以查看相关文档,我们也将解释如何在函数参数不符合预期时停止交易。

下面的 Solana 程序有一个名为 limit_range 的函数,只接受 10 到 100 的值:

#![allow(unused)]
fn main() {
use anchor_lang::prelude::*;

declare_id!("8o3ehd3XnyDocd9hG1uz5trbmSRB7gaLaE9BCXDpEnMY");

#[program]
pub mod day4 {
    use super::*;

    pub fn limit_range(ctx: Context<LimitRange>, a: u64) -> Result<()> {
        if a < 10 {
            return err!(MyError::AisTooSmall);
        }
        if a > 100 {
            return err!(MyError::AisTooBig);
        }
        msg!("Result = {}", a);
        Ok(())
    }
}

#[derive(Accounts)]
pub struct LimitRange {}

#[error_code]
pub enum MyError {
    #[msg("a is too big")]
    AisTooBig,
    #[msg("a is too small")]
    AisTooSmall,
}
}

以下为测试代码:

import * as anchor from "@coral-xyz/anchor";
import { Program, AnchorError } from "@coral-xyz/anchor"
import { Day4 } from "../target/types/day4";
import { assert } from "chai";

describe("day4", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.Day4 as Program<Day4>;

  it("Input test", async () => {
    // Add your test here.
    try {
      const tx = await program.methods.limitRange(new anchor.BN(9)).rpc();
      console.log("Your transaction signature", tx);
    } catch (_err) {
      assert.isTrue(_err instanceof AnchorError);
      const err: AnchorError = _err;
      const errMsg =
        "a is too small";
      assert.strictEqual(err.error.errorMessage, errMsg);
      console.log("Error number:", err.error.errorCode.number);
    }

    try {
      const tx = await program.methods.limitRange(new anchor.BN(101)).rpc();
      console.log("Your transaction signature", tx);
    } catch (_err) {
      assert.isTrue(_err instanceof AnchorError);
      const err: AnchorError = _err;
      const errMsg =
        "a is too big";
      assert.strictEqual(err.error.errorMessage, errMsg);
      console.log("Error number:", err.error.errorCode.number);
    }
  });
});

练习:

  1. 你注意到错误编号有什么规律吗?如果更改枚举 MyError 中错误的顺序,错误代码会发生什么变化?

  2. 使用以下代码块将新的函数和错误添加到现有代码中:

#![allow(unused)]
fn main() {
#[program]
pub mod day_4 {
    use super::*;

    pub fn limit_range(ctxThen : Context<LimitRange>, a: u64) -> Result<()> {
        require!(a >= 10, MyError::AisTooSmall);
        require!(a <= 100, MyError::AisTooBig);
        msg!("Result = {}", a);
        Ok(())
    }

    // NEW FUNCTION
    pub fn func(ctx: Context<LimitRange>) -> Result<()> {
        msg!("Will this print?");
        return err!(MyError::AlwaysErrors);
    }
}

#[derive(Accounts)]
pub struct LimitRange {}

#[error_code]
pub enum MyError {
    #[msg("a is too small")]
    AisTooSmall,
    #[msg("a is too big")]
    AisTooBig,
    #[msg("Always errors")]  // NEW ERROR, what do you think the error code will be?
    AlwaysErrors,
}
}

并添加以下测试:

it("Error test", async () => {
    // Add your test here.
    try {
      const tx = await program.methods.func().rpc();
      console.log("Your transaction signature", tx);
    } catch (_err) {
      assert.isTrue(_err instanceof AnchorError);
      const err: AnchorError = _err;
      const errMsg =
        "Always errors";
      assert.strictEqual(err.error.errorMessage, errMsg);
      console.log("Error number:", err.error.errorCode.number);
    }
  });

在运行之前,你认为新的错误代码会是什么?

以太坊和 Solana 在停止具有无效参数的交易方面的显着区别在于,以太坊触发回滚,而 Solana 返回错误。

使用 require 语句

有一个 require! 宏,概念上与 Solidity 中的 require 相同,我们可以使用它来简化代码。从使用需要三行的 if 代码切换到 require! 调用,将之前的代码转换为以下内容:

#![allow(unused)]
fn main() {
pub fn limit_range(ctx: Context<LimitRange>, a: u64) -> Result<()> {
	  require!(a >= 10, Day4Error::AisTooSmall);
		require!(a <= 100, Day4Error::AisTooBig);

    msg!("Result = {}", a);
    Ok(())
}
}

在以太坊中,如果函数回滚,即使回滚发生在日志之后,也不会记录任何内容。例如,在下面的合约中调用 tryToLog 将不会记录任何内容,因为函数回滚了:

contract DoesNotLog {
	event SomeEvent(uint256);

	function tryToLog() public {
		emit SomeEvent(100);
		require(false);
	}
}

练习: 如果在 Solana 程序函数中的返回错误语句之前放置一个 msg! 宏会发生什么?如果将 return err! 替换为 Ok(()) 会发生什么?下面有一个使用 msg! 记录一些内容然后返回错误的函数。看看 msg! 宏的内容是否被记录。

#![allow(unused)]
fn main() {
pub fn func(ctx: Context<ReturnError>) -> Result<()> {
		msg!("Will this print?");
		return err!(Day4Error::AlwaysErrors);
}

#[derive(Accounts)]
pub struct ReturnError {}

#[error_code]
pub enum Day4Error {
    #[msg("AlwaysErrors")]
    AlwaysErrors,
}
}

在底层,require! 宏与返回错误没有任何不同,它只是语法糖。

预期结果是当返回 Ok(()) 时,“Will this print?”将被打印,当你返回错误时将不会打印。

Solana 和 Solidity 在错误处理方面的区别

在 Solidity 中,require 语句使用 revert 操作码终止执行。Solana 不会终止执行,而只是返回一个不同的值。这类似于 Linux 在成功时返回 0 或 1。如果返回 0(等同于返回 Ok(())),则一切顺利进行。

因此,Solana 程序应该始终返回某些内容 — 要么是 Ok(()),要么是错误。

在 Anchor 中,错误是带有 #[error_code] 属性的枚举。

请注意,Solana 中的所有函数的返回类型都是 Result<()>Result 是一种类型,可以是 Ok(()) 或错误。

问题与答案

为什么 Ok(()) 末尾没有分号?

如果添加分号,代码将无法编译。如果 Rust 中的最终语句没有分号,则该行的代码将作为返回值。

为什么 Ok(()) 有额外的括号?

在 Rust 中,() 表示“unit”,你可以将其视为 C 中的 void 或 Haskell 中的 Nothing。这里,Ok 是一个包含单元类型的枚举。这就是 get 返回的内容。在 Rust 中,不返回任何东西的函数隐式返回单元类型。没有分号的 Ok(()) 在语法上等同于 return Ok(());。请注意末尾的分号。

为什么上面的 if 语句 缺少括号?

在 Rust 中,这些是可选的。

通过 RareSkills 了解更多

本教程是我们免费的 Solana 课程的一部分。

Solana 程序可升级且没有构造函数

solana anchor deploy

在本教程中,我们将深入了解 anchor 背后的秘密,看看 Solana 程序是如何部署的。

当运行 anchor init deploy_tutorial 时,anchor 创建的测试文件:

describe("deploy_tutorial", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.DeployTutorial as Program<DeployTutorial>;

  it("Is initialized!", async () => {
    // Add your test here.
    const tx = await program.methods.initialize().rpc();
    console.log("Your transaction signature", tx);
  });
});

它生成的启动程序应该很熟悉:

#![allow(unused)]
fn main() {
use anchor_lang::prelude::*;

declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");

#[program]
pub mod deploy_tutorial {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize {}
}

上面的程序在何时何地部署?

合同可能被部署的唯一可能地方是测试文件中的这一行:

const program = anchor.workspace.DeployTutorial as Program<DeployTutorial>;

但这没有意义,因为我们期望的那是一个异步函数。

Anchor 在后台默默地部署程序。

Solana 程序没有构造函数

对于那些来自其他面向对象语言的人来说,这可能看起来很不寻常。Rust 没有对象或类。

在以太坊智能合约中,构造函数可以配置存储、设置字节码和不可变变量。

那么“部署步骤”究竟在哪里?

(如果你仍在运行 Solana 验证器和 Solana 日志,建议你重新启动并清除两个终端)

让我们进行通常的设置。创建一个名为 program-deploy 的新 Anchor 项目,并确保验证器和日志在其他 shell 中运行。

不要运行 anchor test,而是在终端中运行以下命令:

anchor deploy

solana program deploy visible in the logs

在上面日志的截图中,我们可以看到程序被部署的时刻。

现在到了有趣的部分。再次运行 anchor deploy

solana upgrade instead of deploy

该程序被部署到相同的地址,但这次是升级,而不是部署。

程序 ID 没有改变,程序被覆盖。

Solana 程序默认可变

对以太坊开发人员来说,这可能会让人震惊,因为以太坊合约默认是不可变的。

如果作者可以随意更改程序,那程序还有什么意义呢?Solana 程序也可以是不可更改的。假设作者首先部署可变版本,随着时间的推移且没有发现错误,然后将其重新部署为不可更改的版本。

从功能上讲,这与管理员控制的代理没有什么不同,代理的所有者后来放弃了对零地址的所有权。但可以说,Solana 模式要干净得多,因为以太坊代理可能会出现很多问题。

另一个含义:Solana 没有 delegatecall,因为它不需要。

Solidity 合约中使用 delegatecall 的主要目的是通过向新实现合约发出 delegatecall 来升级代理合约的功能。然而,由于 Solana 中的程序字节码可以升级,所以不需要对实现合约进行 delegatecall。

另一个推论:Solana 没有像 Solidity 那样的不可变变量(只能在构造函数中设置的变量)。

运行测试而不重新部署程序

由于 anchor 默认会重新部署程序,让我们演示如何在不重新部署的情况下运行测试。

将测试更改为以下内容:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";

import fs from 'fs'
let idl = JSON.parse(fs.readFileSync('target/idl/deployed.json', 'utf-8'))

describe("deployed", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());

  // Change this to be your programID
  const programID = "6p29sM4hEK8ZFT5AvsGJQG5nKUtHBKs13iVL6juo5Uqj";
  const program = new Program(idl, programID, anchor.getProvider());

  it("Is initialized!", async () => {
    // Add your test here.
    const tx = await program.methods.initialize().rpc();
    console.log("Your transaction signature", tx);
  });
});

在运行测试之前,我们建议清除 Solana 日志终端并重新启动 solana-test-validator

现在,使用以下命令运行测试:

anchor test --skip-local-validator --skip-deploy

现在查看日志:

anchor skip deploy

我们看到初始化指令已执行,但程序既没有部署也没有升级,因为我们在 anchor test 中使用了 --skip-deploy 参数。

练习: 为了查看程序字节码实际上已更改,请部署两个打印不同 msg! 值的合约。

  1. 更新 lib.rs 中的 initialize 函数,包括写入日志的 msg! 语句。
  2. anchor deploy
  3. anchor test --skip-local-validator --skip-deploy
  4. 检查日志以查看消息记录
  5. 重复 1 - 4,但要更改 msg! 中的字符串
  6. 验证程序 ID 未更改

你应该观察到消息字符串有改动,但程序 ID 保持不变。

总结

  • Solana 没有构造函数,程序“只是被部署”
  • Solana 没有不可变变量
  • Solana 没有 delegatecall,程序可以“只是被更新”

通过 RareSkills 深入了解

本教程是我们免费的 Solana 课程的一部分。

面向 Solidity 开发人员的基本 Rust 知识

更新日期:Feb 29

Rust 基本语法

本教程介绍了 Solidity 中最常用的语法,并演示了 Rust 中的类似语法。

如果你想要了解 Rust vs Solidity 的区别,请参考链接的教程。本教程假定你已经了解 Solidity,如果你对 Solidity 不熟悉,请参阅我们提供的免费 Solidity 教程

创建一个新的 Solana Anchor 项目,名为 tryrust ,并设置环境。

条件语句

在 Solidity 中有两种开发人员可以根据特定条件控制执行流程的方式:

  • If-Else 语句
  • 三元运算符

现在让我们看看在 Solidity 中的如何表示上述内容,以及它们在 Solana 中的语法。

If-Else 语句

在 Solidity 中:

function ageChecker(uint256 age)
	public pure returns (string memory) {

    if (age >= 18) {
        return "You are 18 years old or above";
    } else {
        return "You are below 18 years old";
    }
}

在 Solana 项目中,在 lib.rs 中添加一个名为 age_checker 的新函数:

#![allow(unused)]
fn main() {
pub fn age_checker(ctx: Context<Initialize>,
                   age: u64) -> Result<()> {
    if age >= 18 {
        msg!("You are 18 years old or above");
    } else {
        msg!("You are below 18 years old");
    }
    Ok(())
 }
}

请注意,条件 age >= 18 不需要括号 — if 语句中的括号是可选的。

为了测试,在 ./tests/tryrust.ts 中添加另一个 it 代码块:

it("Age checker", async () => {
    // Add your test here.
    const tx = await program.methods.ageChecker(new anchor.BN(35)).rpc();
    console.log("Your transaction signature", tx);
});

运行测试后,我们应该看到以下日志:

rust 测试控制台

三元运算符

在 Solidity 中将 if-else 语句赋给变量:

function ageChecker(uint256 age) public pure returns (bool a) {
		a = age % 2 == 0 ? true : false;
}

在 Solana 中,我们基本上只是将 if-else 语句赋给一个变量。下面的 Solana 程序与上面的相同:

#![allow(unused)]
fn main() {
pub fn age_checker(ctx: Context<Initialize>,
                   age: u64) -> Result<()> {

	let result = if age >= 18 {"You are 18 years old or above"} else { "You are below 18 years old" };
    msg!("{:?}", result);
    Ok(())
}
}

请注意,在 Rust 中的三元运算符示例中,if/else 块以分号结尾,因为这将被赋给一个变量。

还要注意,内部值结尾没有分号,因为它作为返回值返回给变量,类似于你在 Ok(()) 后面不加分号,因为它是一个表达式而不是语句。

程序在 age 为偶数时输出 true,否则输出 false:

rust 测试控制台布尔值

Rust 还有一个更强大的控制流运算符叫做 match。让我们看一个 match 示例:

#![allow(unused)]
fn main() {
pub fn age_checker(ctx: Context<Initialize>,
                   age: u64) -> Result<()> {
	match age {
        1 => {
            // Code block executed if age equals 1
            msg!("The age is 1");
                    },
        2 | 3 => {
            // Code block executed if age equals 2 or 3
            msg!("The age is either 2 or 3");
                },
        4..=6 => {
            // Code block executed if age is in the
		    // range 4 to 6 (inclusive)
            msg!("The age is between 4 and 6");
                },
        _ => {
            // Code block executed for any other age
            msg!("The age is something else");
            }
        }
	Ok(())
}
}

For 循环

正如我们所知,for 循环允许循环遍历范围、集合和其他可迭代对象,Solidity 中的写法如下:

function loopOverSmth() public {
    for (uint256 i=0; i < 10; i++) {
        // do something...
    }
}

这是 Solana(Rust)中的等效写法:

#![allow(unused)]
fn main() {
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
    for i in 0..10 {
        // do something...
    }

    Ok(())
}
}

是的,就是这么简单,但是如何使用自定义步长迭代范围呢?以下是 Solidity 中预期的行为:

function loopOverSmth() public {
		for (uint256 i=0; i < 10; i+=2) {
				// do something...

				// Increment i by 2
		}
}

这是在 Solana 中使用 step_by 的等效写法:

#![allow(unused)]
fn main() {
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
    for i in (0..10).step_by(2) {
        // do something...

        msg!("{}", i);
    }

    Ok(())
}
}

运行测试后,我们应该看到以下日志:

rust for 循环

数组和 Vector

Rust 在数组支持方面与 Solidity 不同。虽然 Solidity 对固定数组和动态数组都有原生支持,但 Rust 只对固定数组有内置支持。如果你想要一个动态长度的列表,请使用 Vector。

现在,让我们看一些示例,演示如何声明和初始化固定数组和动态数组。

固定数组

#![allow(unused)]
fn main() {
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
    // Declare an array of u32 with a fixed size of 5
    let my_array: [u32; 5] = [10, 20, 30, 40, 50];

    // Accessing elements of the array
    let first_element = my_array[0];
    let third_element = my_array[2];

    // Declare a mutable array of u32 with a fixed size of 3
    let mut mutable_array: [u32; 3] = [100, 200, 300];

    // Change the second element from 200 to 250
    mutable_array[1] = 250;

    // Rest of your program's logic

    Ok(())
}
}

动态数组

在 Solana 中模拟动态数组的方法涉及使用 Rust 标准库中的 Vec(向量)。以下是一个示例:

#![allow(unused)]
fn main() {
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
    // Declare a dynamic array-like structure using Vec
    let mut dynamic_array: Vec<u32> = Vec::new();

    // Add elements to the dynamic array
    dynamic_array.push(10);
    dynamic_array.push(20);
    dynamic_array.push(30);

    // Accessing elements of the dynamic array
    let first_element = dynamic_array[0];
    let third_element = dynamic_array[2];

    // Rest of your program's logic
    msg!("Third element = {}", third_element);

    Ok(())
}
}

dynamic_array 变量必须声明为可变的(mut),以允许变量可以变化(push、pop、在索引处覆盖等)。

运行测试后,程序应该记录如下:

rust 向量

映射

与 Solidity 不同,Solana 缺乏内置的映射数据结构。但是,我们可以通过使用 Rust 标准库中的 HashMap 类型来在 Solana 中复制键值映射功能。与 EVM 链不同,我们在这里演示的映射是在内存中,而不是在存储中。EVM 链没有内存中的哈希映射。 我们将在稍后的教程中演示 Solana 存储中的映射。

让我们看看如何使用 HashMap 在 Solana 中创建映射。将提供的代码片段复制并粘贴到 lib.rs 文件中,并替换程序 ID:

#![allow(unused)]
fn main() {
use anchor_lang::prelude::*;

declare_id!("53hgft52DHUKMPHGu1kusuwxFGk2T8qngwSw2SyGRNrX");

#[program]
pub mod tryrust {
    use super::*;
		// Import HashMap library
    use std::collections::HashMap;

    pub fn initialize(ctx: Context<Initialize>, key: String, value: String) -> Result<()> {
        // Initialize the mapping
        let mut my_map = HashMap::new();

        // Add a key-value pair to the mapping
        my_map.insert(key.to_string(), value.to_string());

        // Log the value corresponding to a key from the mapping
        msg!("My name is {}", my_map[&key]);

        Ok(())
    }
}
}

my_map 变量也被声明为可变的,以便我们可以编辑它(即添加/删除键值对)。还注意到我们是如何导入 HashMap 库的吗?

由于 initialize 函数接收两个参数,测试也需要更新:

it("Is initialized!", async () => {
    // Add your test here.
    const tx = await program.methods.initialize("name", "Bob").rpc();
    console.log("Your transaction signature", tx);
});

运行测试时,我们看到以下日志:

rust 哈希映射

结构体

在 Solidity 和 Solana 中,结构体用于定义可以容纳多个字段的自定义数据结构。让我们看一个在 Solidity 和 Solana 中的结构体示例。

在 Solidity 中:

contract SolidityStructs {

    // Defining a struct in Solidity
    struct Person {
        string my_name;
        uint256 my_age;
    }

    // Creating an instance of the struct
    Person person1;

    function initPerson1(string memory name, uint256 age) public {
        // Accessing and modifying struct fields
        person1.my_name = name;
        person1.my_age = age;
    }
}

在 Solana 中的一一对应:

#![allow(unused)]
fn main() {
pub fn initialize(_ctx: Context<Initialize>, name: String, age: u64) -> Result<()> {
    // Defining a struct in Solana
    struct Person {
        my_name: String,
        my_age: u64,
    }

    // Creating an instance of the struct
    let mut person1: Person = Person {
        my_name: name,
        my_age: age,
    };

    msg!("{} is {} years old", person1.my_name, person1.my_age);

    // Accessing and modifying struct fields
    person1.my_name = "Bob".to_string();
    person1.my_age = 18;

    msg!("{} is {} years old", person1.my_name, person1.my_age);

    Ok(())
}
}

练习: 更新测试文件,将两个参数 Alice 和 20 传递给 initialize 函数并运行测试,你应该得到以下日志:

rust 结构体

在提供的代码片段中,Solidity 将结构体的实例存储在存储中,而 Solana 实现中,一切都发生在 initialize 函数中,没有任何东西存储在链上。存储将在以后的教程中讨论。

Rust 中的常量

在 Rust 中声明常量变量很简单。不使用 let 关键字,而是使用 const 关键字。这些可以在 #[program] 块之外声明。

#![allow(unused)]
fn main() {
use anchor_lang::prelude::*;

declare_id!("EiR8gcMCX11tYMRfoZ2vyheZsZ2NvdUTvYrRAUvTtYnL");

// *** CONSTANT DECLARED HERE ***
const MEANING_OF_LIFE_AND_EXISTENCE: u64 = 42;

#[program]
pub mod tryrust {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        msg!(&format!("Answer to the ultimate question: {}", MEANING_OF_LIFE_AND_EXISTENCE)); // new line here
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize {}
}

usize 类型和类型转换

在 Solana 中,我们大多数时候可以假设无符号整数是 u64 类型,但在测量列表长度时有一个例外:它将是 usize 类型。你需要像下面的 Rust 代码演示的那样对变量进行转换:

#![allow(unused)]
fn main() {
use anchor_lang::prelude::*;

declare_id!("EiR8gcMCX11tYMRfoZ2vyheZsZ2NvdUTvYrRAUvTtYnL");

#[program]
pub mod usize_example {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {

       let mut dynamic_array: Vec<u32> = Vec::from([1,2,3,4,5,6]);
       let len = dynamic_array.len(); // this has type usize

       let another_var: u64 = 5; // this has type u64

       let len_plus_another_var = len as u64 + another_var;

       msg!("The result is {}", len_plus_another_var);

       Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize {}
}

Try Catch

Rust 没有 try catch。失败预期返回错误(就像我们在 Solana 的教程中所做的那样)或对于不可恢复的错误会 panic。

练习: 编写一个接受 u64 向量、循环遍历它并将所有偶数写入到另一个向量,然后打印新向量的 Solana / Rust 程序。

通过 RareSkills 了解更多

本教程是我们免费的 Solana 课程 的一部分。

Rust 的不寻常语法

更新日期:Feb 19

Rust: The Weird Parts

来自 Solidity 或 Javascript 背景的读者可能会觉得 Rust 对&mut<_>unwrap()?的使用和语法很奇怪(甚至丑陋)。本章将解释这些语法的含义。

如果一切没有马上理解,不要担心。如果你忘记了语法定义,随时可以回到本教程。

所有权和借用(引用&和解引用运算符*):

Rust 复制类型

要理解&*,我们首先需要了解 Rust 中的“复制类型”。复制类型是一个数据类型,其大小足够小,使得复制值的开销微不足道。以下值是复制类型:

  • 整数、无符号整数和浮点数
  • 布尔值
  • 字符

它们之所以是“复制类型”,是因为它们具有固定的小尺寸。

另一方面,向量、字符串和结构体可以是任意大的,因此它们不是复制类型。

Rust 为什么区分复制类型和非复制类型

考虑以下 Rust 代码:

pub fn main() {
	let a: u32 = 2;
	let b: u32 = 3;
	println!("{}", add(a, b)); // a and b a are copied to the add function

	let s1 = String::from("hello");
	let s2 = String::from(" world");

	// if s1 and s2 are copied, this could be a huge data transfer
  // if the strings are very long
	println!("{}", concat(s1, s2));
}

// implementations of add() and concat() are not shown for brevity
// this code does not compile

在代码的第一部分中,将ab相加时,只需要从变量复制 64 位数据到函数(32 位* 2 个变量)。

然而,在字符串的情况下,我们并不总是提前知道要复制多少数据。如果字符串长度为 1GB,程序运行速度将会受到严重影响。

Rust 希望我们明确表达希望如何处理大数据。它不会像动态语言那样在后台复制它。

因此,当我们做一些简单的事情,比如将字符串分配给一个新变量时,Rust 会做一些很多人觉得意想不到的事情,我们将在下一节中看到。

Rust 中的所有权

对于非复制类型(字符串、向量、结构体等),一旦将值分配给变量,该变量就“拥有”它。所有权的影响将很快展示。

以下代码将无法编译。注释中有解释:

#![allow(unused)]
fn main() {
// Example of changing ownership on a non-copy datatype (string)
let s1 = String::from("abc");

// s2 becomes the owner of `String::from("abc")`
let s2 = s1;

// The following line will fail to compile because s1 can no longer access its string value.
println!("{}", s1);

// This line compiles successfully because s2 now owns the string value.
println!("{}", s2);
}

要修复上面的代码,我们有两个选项:使用&运算符或克隆s1

选项 1:s2 查看(views1

在下面的代码中,请注意s1前的符号&

pub fn main() {
	let s1 = String::from("abc");

	let s2 = &s1; // s2 can now view `String::from("abc")` but not own it

	println!("{}", s1); // This compiles, s1 still holds its original string value.
	println!("{}", s2); // This compiles, s2 holds a reference to the string value in s1.
}

如果我们希望另一个变量“查看”该值(即获得只读访问权限),我们使用&运算符。

为了让另一个变量或函数查看一个拥有的变量,我们在其前面加上&

&视为非复制类型的“只读”模式可能有所帮助。我们称之为“只读”的技术术语是借用

选项 2:克隆s1

要了解如何克隆一个值,请考虑以下示例:

fn main() {
    let mut message = String::from("hello");
    println!("{}", message);
    message = message + " world";
    println!("{}", message);
}

上面的代码将按预期打印“hello”,然后“hello world”。

然而,如果我们添加另一个变量y来查看message,代码将不再编译:

// Does not compile
fn main() {
    let mut message = String::from("hello");
    println!("{}", message);
    let mut y = &message; // y is viewing message
    message = message + " world";
    println!("{}", message);
    println!("{}", y); // should y be "hello" or "hello world"?
}

Rust 不接受上面的代码,因为在查看message时无法重新分配该变量。

如果我们希望y能够复制message的值而不会干扰后续的message,我们可以选择克隆它:

fn main() {
    let mut message = String::from("hello");
    println!("{:?}", message);
    let mut y = message.clone(); // change this to clone
    message = message + " world";
    println!("{:?}", message);
    println!("{:?}", y);
}

上面的代码将打印:

hello
hello world
hello

所有权仅适用于非复制类型

如果我们用一个复制类型(如整数)替换我们的字符串(这是一个非复制类型),我们将不会遇到上述任何问题。Rust 将愉快地复制 复制类型,因为开销微不足道。

#![allow(unused)]
fn main() {
let s1 = 3;

let s2 = s1;

println!("{}", s1);
println!("{}", s2);
}

mut关键字

在 Rust 中,默认情况下,所有变量都是不可变的,除非指定了mut关键字。

以下代码将无法编译:

pub fn main() {
	let counter = 0;
	counter = counter + 1;

	println!("{}", counter);
}

如果我们尝试编译上面的代码,将会得到以下错误:

Rust mutability compilation error

幸运的是,如果你忘记包含mut关键字,编译器通常会明确指出错误。以下代码插入了mut关键字,使代码能够编译:

pub fn main() {
	let mut counter = 0;
	counter = counter + 1;

	println!("{}", counter);
}

Rust 中的泛型:< >语法

让我们考虑一个接受任意类型值并返回一个包含该值的字段foo的结构体的函数。与其为每种可能的类型编写一堆函数,不如使用泛型

下面的示例结构体可以是i32bool

// derive the debug trait so we can print the struct to the console
#[derive(Debug)]
struct MyValues<T> {
    foo: T,
}

pub fn main() {
    let first_struct: MyValues<i32> = MyValues { foo: 1 }; // foo has type i32
    let second_struct: MyValues<bool> = MyValues { foo: false }; // foo has type bool

    println!("{:?}", first_struct);
    println!("{:?}", second_struct);
}

这很方便的原因在于:当我们在 Solana 中“存储”值时,如果要存储数字、字符串或其他内容,我们希望代码非常灵活。

如果我们的结构体有多个字段,用于参数化类型的语法如下:

#![allow(unused)]
fn main() {
struct MyValues<T, U> {
    foo: T,
	bar: U,
}
}

泛型在 Rust 中是一个非常庞大的主题,因此我们在这里并没有给出完整的讨论。然而,这足以让大多数 Solana 程序有一个很好的理解。

Option、枚举和解引用*

为了展示选项和枚举的重要性,让我们考虑以下示例:

fn main() {
	let v = Vec::from([1,2,3,4,5]);

	assert!(v.iter().max() == 5);
}

该代码无法编译,出现以下错误:

6 |     assert!(v.iter().max() == 5);
  |                               ^ expected `Option<&{integer}>`, found integer

由于向量v可能为空,max()的输出不是整数。

Rust Option

为了处理这种情况,Rust 返回一个 Option。Option 是一个枚举,可以包含预期值,也可以包含“没有内容”的特殊值。

要将选项转换为底层类型,我们使用unwrap()。如果我们收到“没有内容”,unwrap()将导致 panic,因此我们应该仅在希望发生 panic 的情况下使用它,或者我们确信不会得到空值。

为了使代码按预期工作,我们可以执行以下操作:

fn main() {
	let v = Vec::from([1,2,3,4,5]);

	assert!(v.iter().max().unwrap() == 5);
}

解引用*运算符

但它仍然无法工作!这次我们得到一个错误

19 |     assert!(v.iter().max().unwrap() == 5);
   |                                     ^^ no implementation for `&{integer} == {integer}`

等式左侧的术语是整数的视图(即&),右侧的术语是实际整数。

要将整数的“视图”转换为常规整数,我们需要使用“解引用”操作。这是当我们在值前面加上*运算符时发生的。

fn main() {
	let v = Vec::from([1,2,3,4,5]);

	assert!(*v.iter().max().unwrap() == 5);
}

由于数组的元素是复制类型,解引用运算符将默默地复制max().unwrap()返回的 5。

你可以将*视为在不干扰原始值的情况下“撤消”&

对非复制类型使用*运算符是一个复杂的问题。目前,你需要知道的是,如果你收到一个复制类型的视图(借用),并且需要将其转换为“正常”类型,请使用**运算符。

Rust 中的 Result 与 Option

当可能收到“空”内容时,我们使用 option。当我们可能收到错误时,我们使用Result(与Result Anchor 程序一直返回的相同Result)。

Result 枚举

Rust 中的Result<T, E>枚举用于表示函数的操作可能成功并返回类型 T 的值(一种通用类型),或失败并返回类型 E(通用错误类型)的错误。它旨在处理可能导致成功结果或错误条件的操作。

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

在 Rust 中,?运算符用于Result<T, E>枚举,而unwrap()用于Result<T, E>Option<T>枚举。

?运算符

?运算符只能用于返回Result的函数,因为它是一种语法糖,用于返回ErrOk

?运算符用于从Result<T, E>枚举中提取数据,并在函数执行成功时返回OK(T)变体,或者在出现错误时返回错误Err(E)unwrap()方法的工作方式相同,但适用于Result<T, E>Option<T>枚举,但是由于其可能导致程序崩溃,应谨慎使用。

现在,请考虑以下代码:

#![allow(unused)]
fn main() {
pub fn encode_and_decode(_ctx: Context<Initialize>) -> Result<()> {
    // Create a new instance of the `Person` struct
    let init_person: Person = Person {
        name: "Alice".to_string(),
        age: 27,
    };

    // Encode the `init_person` struct into a byte vector
    let encoded_data: Vec<u8> = init_person.try_to_vec().unwrap();

    // Decode the encoded data back into a `Person` struct
    let data: Person = decode(_ctx, encoded_data)?;

    // Logs the decoded person's name and age
    msg!("My name is {:?}, I am {:?} years old.", data.name, data.age);

    Ok(())
}

pub fn decode(_accounts: Context<Initialize>, encoded_data: Vec<u8>) -> Result<Person> {
    // Decode the encoded data back into a `Person` struct
    let decoded_data: Person = Person::try_from_slice(&encoded_data).unwrap();

    Ok(decoded_data)
}
}

try_to_vec()方法将一个结构编码为字节向量,并返回一个Result<T, E>枚举,其中 T 是字节向量,而unwrap()方法用于从OK(T)中提取字节向量的值。如果该方法无法将结构转换为字节向量,程序将崩溃。

通过 RareSkills 了解更多

本教程是我们免费的 Solana 课程的一部分。

Rust 函数式过程宏

rust function-like macros

本教程解释了函数和函数式宏之间的区别。例如,为什么msg!后面有一个感叹号?本教程将解释这种语法。

作为一种强类型语言,Rust 不能接受任意数量的函数参数。

例如,Python 的print函数可以接受任意数量的参数:

print(1)
print(1, 2)
print(1, 2, 3)

! 表示这个“函数”是一个函数式宏。

Rust 函数式宏通过!符号来识别,例如在 Solana 中的println!(...)msg!(...)

在 Rust 中,用于打印内容的常规函数(而不是函数式宏)是std::io::stdout().write,它只接受一个单字节字符串作为参数。

如果你想运行以下代码,Rust Playground 是一个方便的工具,不需要设置开发环境了。

让我们使用以下示例(来自这里 ):

use std::io::Write;

fn main() {
    std::io::stdout().write(b"Hello, world!\n").unwrap();
}

请注意,write 是一个函数,而不是宏,因为它没有!

如果你尝试在 Python 中执行我们上面的操作,代码将无法编译,因为write只接受一个参数:

// this does not compile
use std::io::Write;

fn main() {
    std::io::stdout().write(b"1\n").unwrap();
    std::io::stdout().write(b"1", b"2\n").unwrap();
    std::io::stdout().write(b"1", b"2", b"3\n").unwrap();
}

因此,如果你希望打印任意数量的参数,你需要编写一个自定义打印函数来处理每种情况下的每个参数数量 —— 这是极其低效的!

这样的代码如下所示(这是极不推荐的!):

use std::io::Write;

// print one argument
fn print1(arg1: &[u8]) -> () {
		std::io::stdout().write(arg1).unwrap();
}

// print two arguments
fn print2(arg1: &[u8], arg2: &[u8]) -> () {
    let combined_vec = [arg1, b" ", arg2].concat();
    let combined_slice = combined_vec.as_slice();
		std::io::stdout().write(combined_slice).unwrap();
}

// print three arguments
fn print3(arg1: &[u8], arg2: &[u8], arg3: &[u8]) -> () {
    let combined_vec = [arg1, b" ", arg2, b" ", arg3].concat();
    let combined_slice = combined_vec.as_slice();
		std::io::stdout().write(combined_slice).unwrap();
}

fn main() {
		print1(b"1\n");
		print2(b"1", b"2\n");
		print3(b"1", b"2", b"3\n");
}

如果我们在print1print2print3函数中寻找模式,它只是将参数插入向量中,并在它们之间添加一个空格,然后将向量转换回字节字符串(准确地说是字节切片)。

如果我们能够将类似println!的代码片段自动扩展为一个打印函数,该函数将接受我们需要的参数数量,那不是很好吗?

这就是 Rust 宏的作用。

Rust 宏将 Rust 代码作为输入,并将其程序化地扩展为更多的 Rust 代码。

这有助于我们避免为代码所需的每种打印语句编写打印函数的无聊工作。

扩展宏

要查看 Rust 编译器如何扩展println!宏的示例,可以使用cargo expand 。结果非常冗长,因此我们不在这里展示了。

将宏视为黑盒是可以接受的

当由库提供时,宏非常方便,但手动编写宏非常繁琐,因为它实际上需要解析 Rust 代码。

Rust 中不同类型的宏

我们提供的println!示例是一个函数式宏。Rust 还有其他类型的宏,但我们关心的另外两种是自定义派生宏属性式宏

让我们看一个由 anchor 创建的新程序:

img

我们将在接下来的教程中解释这些是如何工作的。

通过 RareSkills 了解更多

本教程是我们免费的 Solana 课程的一部分。

Rust 结构体和类似属性以及自定义派生宏

更新日期:Feb 20

Rust 属性和自定义派生宏

在 Rust 中,类似属性和自定义派生宏用于在编译时获取一段 Rust 代码并以某种方式修改它,通常是为了添加功能。

为了理解 Rust 中的类属性宏和自定义派生宏,我们首先需要简单介绍一下 Rust 中的实现结构 —— impl

用于结构体的实现:impl

以下结构体应该很容易理解。有趣的是当我们创建在特定结构体上操作的函数时。我们使用 impl 来实现这一点:

#![allow(unused)]
fn main() {
struct Person {
    name: String,
    age: u8,
}
}

关联函数和方法是在 impl 块内为结构体实现的。

关联函数可以类比于 Solidity 中为与结构体交互创建的库的情况。当我们定义 using lib for MyStruct 时,它允许我们使用语法 myStruct.associatedFunction()。这使得函数通过 Self 关键字访问 myStruct

我们建议使用 Rust Playground,但对于更复杂的示例,你可能需要设置你的 IDE。

让我们看一个下面的示例:

struct Person {
    age: u8,
    name: String,
}

// Implement a method `new()` for the `Person` struct, allowing initialization of a `Person` instance
impl Person {
    // Create a new `Person` with the provided `name` and `age`
    fn new(name: String, age: u8) -> Self {
        Person { name, age }
    }

		fn can_drink(&self) -> bool {
			if self.age >= 21 as u8 {
				return true;
			}
			return false;
		}

		fn age_in_one_year(&self) -> u8 {
			return &self.age + 1;
		}
}

fn main() {
    // Usage: Create a new `Person` instance with a name and age
    let person = Person::new(String::from("Jesserc"), 19);

    // use some impl functions
    println!("{:?}", person.can_drink()); // false
    println!("{:?}", person.age_in_one_year()); // 20
    println!("{:?}", person.name);
}

用法:

#![allow(unused)]
fn main() {
// Usage: Create a new `Person` instance with a name and age
let person = Person::new(String::from("Jesserc"), 19);

// use some impl functions
person.can_drink(); // false
person.age_in_one_year(); // 20
}

Rust Traits

Rust Trait 是在不同 impl 之间实现共享行为的一种方式。

将它们视为 Solidity 中的接口或抽象合约 — 使用接口的任何合约必须实现某些函数。

例如,假设我们有一个场景,我们需要定义一个 Car 和 Boat 结构体。我们想要附加一个方法,允许我们以每小时公里数的速度检索它们的速度。在 Rust 中,我们可以通过使用单个 Trait 并在两个结构体之间共享方法来实现这一点。

如下所示:

// Traits are defined with the `trait` keyword followed by their name
trait Speed {
    fn get_speed_kph(&self) -> f64;
}

// Car struct
struct Car {
    speed_mph: f64,
}

// Boat struct
struct Boat {
    speed_knots: f64,
}

// Traits are implemented for a type using the `impl` keyword as shown below
impl Speed for Car {
    fn get_speed_kph(&self) -> f64 {
        // Convert miles per hour to kilometers per hour
        self.speed_mph * 1.60934
    }
}

// We also implement the `Speed` trait for `Boat`
impl Speed for Boat {
    fn get_speed_kph(&self) -> f64 {
        // Convert knots to kilometers per hour
        self.speed_knots * 1.852
    }
}

fn main() {
    // Initialize a `Car` and `Boat` type
    let car = Car { speed_mph: 60.0 };
    let boat = Boat { speed_knots: 30.0 };

    // Get and print the speeds in kilometers per hour
    let car_speed_kph = car.get_speed_kph();
    let boat_speed_kph = boat.get_speed_kph();

    println!("Car Speed: {} km/h", car_speed_kph); // 96.5604 km/h
    println!("Boat Speed: {} km/h", boat_speed_kph); // 55.56 km/h
}

宏如何修改结构体

在我们关于类似函数的宏的教程中,我们看到宏如何在大型 Rust 代码中扩展类似于 println!(...) 和 msg!(...) 的代码。在 Solana 的上下文中,我们关心的另一种宏是 类似属性 宏和 派生 宏。我们可以在 anchor 创建的起始程序中看到这三种(类似函数、类似属性和派生)宏:

img

为了直观地理解类似属性宏正在做什么,我们将创建两个宏:一个用于向结构体添加字段,另一个用于移除它们。

示例 1:类似属性宏,插入字段

为了更好地理解 Rust 属性和宏的工作原理,我们将创建一个类似属性宏 ,该宏:

  1. 接受一个没有类型为 i32 的字段 foobar 的结构体
  2. 将这些字段插入到结构体中
  3. 创建一个带有名为 double_foo 的函数的 impl,该函数返回 foo 持有的整数值的两倍。

设置

首先创建一个新的 Rust 项目:

cargo new macro-demo --lib
cd macro-demo
touch src/main.rs

将以下内容添加到 Cargo.toml 文件中:

[lib]
proc-macro = true

[dependencies]
syn = {version="1.0.57",features=["full","fold"]}
quote = "1.0.8"

创建主程序

将以下代码粘贴到 src/main.rs 中。请务必阅读注释:

// src/main.rs
// Import the macro_demo crate and bring all items into scope with the `*` wildcard
// (basically everything in this crate, including our macro in `src/lib.rs`
use macro_demo::*;

// Apply the `foo_bar_attribute` procedural attribute-like macro we created in `src/lib.rs` to `struct MyStruct`
// The procedural macro will generate a new struct definition with specified fields and methods
#[foo_bar_attribute]
struct MyStruct {
	baz: i32,
}

fn main() {
    // Create a new instance of `MyStruct` using the `default()` method
    // This method is provided by the `Default` trait implementation generated by the macro
    let demo = MyStruct::default();

    // Print the contents of `demo` to the console
    // The `Debug` trait implementation generated by the macro allows formatted output with `println!`
    println!("struct is {:?}", demo);

    // Call the `double_foo()` method on `demo`
    // This method is generated by the macro and returns double the value of the `foo` field
    let double_foo = demo.double_foo();

    // Print the result of calling `double_foo` to the console
    println!("double foo: {}", double_foo);
}

一些观察:

  • 结构体 MyStruct 中 没有 包含字段 foo
  • 函数 double_foo 在上述代码中没有定义,假定它存在。

现在让我们创建类似属性宏,它将在幕后修改 MyStruct。

用以下代码替换 src/lib.rs(请务必阅读注释):

#![allow(unused)]
fn main() {
// src/lib.rs
// Importing necessary external crates
extern crate proc_macro;
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemStruct};

// Declaring a procedural attribute-like macro using the `proc_macro_attribute` directive
// This makes the macro usable as an attribute

#[proc_macro_attribute]
// The function `foo_bar_attribute` takes two arguments:
// _metadata: The arguments provided to the macro (if any)
// _input: The TokenStream the macro is applied to
pub fn foo_bar_attribute(_metadata: TokenStream, _input: TokenStream) -> TokenStream {
    // Parse the input TokenStream into an AST node representing a struct
    let input = parse_macro_input!(_input as ItemStruct);
    let struct_name = &input.ident; // Get the name of the struct

    // Constructing the output TokenStream using the quote! macro
    // The quote! macro allows for writing Rust code as if it were a string,
    // but with the ability to interpolate values
    TokenStream::from(quote! {
        // Derive Debug trait for #struct_name to enable formatted output with `println()`
        #[derive(Debug)]
        // Defining a new struct #struct_name with two fields: foo and bar
        struct #struct_name {
            foo: i32,
            bar: i32,
        }

        // Implementing the Default trait for #struct_name
        // This provides a default() method to create a new instance of #struct_name
        impl Default for #struct_name {
            // The default method returns a new instance of #struct_name
            // with foo set to 10 and bar set to 20
            fn default() -> Self {
                struct_name { foo: 10, bar: 20}
            }
        }

        impl #struct_name {
            // Defining a method double_foo for #struct_name
            // This method returns double the value of foo
            fn double_foo(&self) -> i32 {
                self.foo * 2
            }
        }
    })
}
}

现在,为了测试我们的宏,我们使用 cargo run src/main.rs 运行 main.rs 中的代码。

我们得到以下输出:

struct is MyStruct { foo: 10, bar: 20 }
double foo: 20

示例 2:类似属性宏,移除字段

思考类似属性宏的最佳方法是它们在修改结构体的方式上具有无限的能力。让我们重复上面的示例,但这次类似属性宏将从结构体中移除所有字段。

用以下内容替换 src/lib.rs:

#![allow(unused)]
fn main() {
// src/lib.rs
// Importing necessary external crates
extern crate proc_macro;
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemStruct};

#[proc_macro_attribute]
pub fn destroy_attribute(_metadata: TokenStream, _input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(_input as ItemStruct);
    let struct_name = &input.ident; // Get the name of the struct

    TokenStream::from(quote! {
        // This returns an empty struct with the same name
        #[derive(Debug)]
        struct #struct_name {
        }
    })
}
}

用以下内容替换 src/main.rs:

use macro_demo::*;

#[destroy_attribute]
struct MyStruct {
		baz: i32,
    qux: i32,
}

fn main() {
    let demo = MyStruct { baz: 3, qux: 4 };

    println!("struct is {:?}", demo);
}

当你尝试使用 cargo run src/main.rs 编译时,你将收到以下错误:

img

这可能看起来很奇怪,因为结构体明显具有这些字段。但是,类似属性宏将它们移除了!

#[derive(…)]

#[derive(…)] 宏比类似属性宏弱得多。对于我们的目的,派生宏 增强 了一个结构体,而不是改变它。(这不是一个精确的定义,但现在足够了)。

派生宏可以,除其他事项外,将一个 impl 附加到一个结构体上。

例如,如果我们尝试执行以下操作:

struct Foo {
	bar: i32,
}

pub fn main() {
	let foo = Foo { bar: 3 };
	println!("{:?}", foo);
}

该代码将无法编译,因为结构体不可“打印”。

要使它们可打印,它们需要一个带有返回结构体的字符串表示的函数 fmtimpl

如果我们改为执行以下操作:

#[derive(Debug)]
struct Foo {
	bar: i32,
}

pub fn main() {
	let foo = Foo { bar: 3 };
	println!("{:?}", foo);
}

我们期望它打印:

Foo { bar: 3 }

派生属性“增强”了 Foo,以便 println! 可以为其创建一个字符串表示。

总结

impl 是一组在结构体上操作的函数。它们通过使用与结构体相同的名称“附加”到结构体上。Trait 强制 impl 实现某些函数。在我们的示例中,我们使用 impl Speed for Car 的语法将 Trait Speed 附加到 impl Car 上。

类似属性宏接受一个结构体,并可以完全重写它。

派生宏通过附加额外的函数增强了一个结构体。

宏允许 Anchor 隐藏复杂性

让我们再次看看 anchor 在 anchor init 期间创建的程序:

img

属性 #[program] 在幕后修改了模块。例如,它实现了一个路由器,自动将传入的区块链指令定向到模块内适当的函数。

结构体 Initialize {} 被增强了额外的功能,以在 Solana 框架中使用。

总结

宏是一个非常庞大的主题。我们在这里的目的是让你了解当你看到 #[program]#[derive(Accounts)] 时发生了什么。如果感到陌生,请不要气馁。你不需要能够编写宏来编写 Solana 程序

然而,了解它们的作用将有助于使你看到的程序变得不那么神秘。

通过 RareSkills 进一步学习

本教程是我们免费的 Solana 课程的一部分。

Rust 和 Solana 中的可见性和“继承”

更新日期:3 月 21 日

rust 函数可见性

今天我们将学习如何在 Solana 中概念化 Solidity 的函数可见性和合约继承。Solidity 中有四个级别的函数可见性,它们是:

  • public - 从合约内部和外部都可以访问。
  • external - 仅从合约外部可以访问。
  • internal - 在合约内部和继承的合约中可以访问。
  • private - 仅在合约内部可以访问。

让我们在Solana中实现相同的功能,好吗?

公共函数

自第 1 天开始定义的所有函数都是公共函数:

#![allow(unused)]
fn main() {
pub fn my_public_function(ctx: Context<Initialize>) -> Result<()> {
    // Function logic...

    Ok(())
}
}

在函数声明之前添加 pub 关键字将函数设为公共。

你不能删除#[program]标记的模块内部的函数的pub关键字。这样做将无法编译。

不用过于担心 external 和 public 之间的区别

Solana 程序调用自己的公共函数通常是不方便的。如果 Solana 程序中有一个pub函数,实际上你可以将其视为在 Solidity 上下文中的外部函数。

如果要在同一 Solana 程序中调用公共函数,最好将公共函数包装在内部实现函数中并调用该函数。

私有和内部函数

虽然你不能在带有#[program]宏的模块内部声明没有pub的函数,但可以在文件内声明函数。考虑以下代码:

#![allow(unused)]
fn main() {
use anchor_lang::prelude::*;

declare_id!("F26bvRaY1ut3TD1NhrXMsKHpssxF2PAUQ7SjZtnrLkaM");

#[program]
pub mod func_test {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
				// -------- calling a "private" function --------
        let u = get_a_num();
        msg!("{}", u);
        Ok(())
    }
}

// ------- We declared a non pub function over here -------
fn get_a_num() -> u64 {
    2
}

#[derive(Accounts)]
pub struct Initialize {}
}

这将按预期运行和记录。

如果你想要构建简单的 Solana 程序,这就是你需要了解有关公共和内部函数的全部内容。但是,如果你想更好地组织代码,可以继续阅读。

Rust 和 Solana 没有类似 Solidity 的“类”,因为 Rust 不是面向对象的。因此,“私有”和“内部”的区别在 Rust 中没有直接的类比。

Rust 使用模块来组织代码。有关这些模块内部和外部函数的可见性在 Rust 文档的可见性和隐私部分中有详细讨论,但我们将在下面添加我们自己的与 Solana 相关的内容。

内部函数

可以通过在程序模块内定义函数并确保它在自己的模块以及导入或使用它的其他模块中可访问来实现这一点。让我们看看如何做到这一点:

#![allow(unused)]
fn main() {
use anchor_lang::prelude::*;

declare_id!("53hgft52DHUKMPHGu1kusuwxFGk2T8qngwSw2SyGRNrX");

#[program]
pub mod func_visibility {
    use super::*;

    pub fn initialize(_ctx: Context<Initialize>) -> Result<()> {
        // Call the internal_function from within its parent module
        some_internal_function::internal_function();

        Ok(())
    }

    pub mod some_internal_function {
        pub fn internal_function() {
            // Internal function logic...
        }
    }
}

mod do_something {
    // Import func_visibility module
    use crate::func_visibility;

    pub fn some_func_here() {
        // Call the internal_function from outside its parent module
        func_visibility::some_internal_function::internal_function();

        // Do something else...
    }
}

#[derive(Accounts)]
pub struct Initialize {}
}

构建程序后,如果导航到./target/idl/func_visibility.json文件,你将注意到在some_internal_function模块内定义的函数未包含在构建的程序中。这表明函数some_internal_function是内部函数,只能在程序本身以及导入或使用它的任何程序中访问。

从上面的示例中,我们能够从其“父”模块(func_visibility)内访问internal_function函数,并且还能够从func_visibility模块外部的一个单独模块(do_something)中访问。

私有函数

在特定模块内定义函数并确保它们不会在该范围之外暴露是实现私有可见性的一种方式:

#![allow(unused)]
fn main() {
use anchor_lang::prelude::*;

declare_id!("53hgft52DHUKMPHGu1kusuwxFGk2T8qngwSw2SyGRNrX");

#[program]
pub mod func_visibility {
    use super::*;

    pub fn initialize(_ctx: Context<Initialize>) -> Result<()> {
        // Call the private_function from within its parent module
        some_function_function::private_function();

        Ok(())
    }

    pub mod some_function_function {
        pub(in crate::func_visibility) fn private_function() {
            // Private function logic...
        }
    }
}

#[derive(Accounts)]
pub struct Initialize {}
}

pub(in crate::func_visibility)关键字表示private_function函数仅在func_visibility模块内可见。

我们成功在 initialize 函数中调用了private_function,因为 initialize 函数在func_visibility模块内。让我们尝试从模块外部调用private_function

#![allow(unused)]
fn main() {
use anchor_lang::prelude::*;

declare_id!("53hgft52DHUKMPHGu1kusuwxFGk2T8qngwSw2SyGRNrX");

#[program]
pub mod func_visibility {
    use super::*;

    pub fn initialize(_ctx: Context<Initialize>) -> Result<()> {
        // Call the private_function from within its parent module
        some_private_function::private_function();

        Ok(())
    }

    pub mod some_private_function {
        pub(in crate::func_visibility) fn private_function() {
            // Private function logic...
        }
    }
}

mod do_something {
    // Import func_visibility module
    use crate::func_visibility;

    pub fn some_func_here() {
        // Call the private_function from outside its parent module
        func_visibility::some_private_function::private_function()

        // Do something...
    }
}

#[derive(Accounts)]
pub struct Initialize {}
}

构建程序。发生了什么?我们收到了一个错误:

error[E0624]: associated function private_function is private

这表明private_function不是公开可访问的,不能从其可见的模块之外调用。查看 Rust 文档中关于pub可见性关键字的内容

合约继承

将 Solidity 合约继承直接翻译为 Solana 是不可能的,因为 Rust 没有类。

然而,在 Rust 中的一种解决方法涉及创建定义特定功能的单独模块,然后在我们的主程序中使用这些模块,从而实现类似于 Solidity 合约继承的功能。

从另一个文件获取模块

随着程序变得越来越大,我们通常不希望将所有内容放入一个文件中。以下是如何将逻辑组织到多个文件中。

让我们在src文件夹中创建另一个名为calculate.rs的文件,并将提供的代码复制到其中。

#![allow(unused)]
fn main() {
pub fn add(x: u64, y: u64) -> u64 {
	// Return the sum of x and y
    x + y
}
}

这个 add 函数返回 x 和 y 的和。

然后将其添加到 lib.rs 中。

#![allow(unused)]
fn main() {
use anchor_lang::prelude::*;

// Import `calculate` module or crate
pub mod calculate;

declare_id!("53hgft52DHUKMPHGu1kusuwxFGk2T8qngwSw2SyGRNrX");

#[program]
pub mod func_visibility {
    use super::*;

    pub fn add_two_numbers(_ctx: Context<Initialize>, x: u64, y: u64) -> Result<()> {
        // Call `add` function in calculate.rs
        let result = calculate::add(x, y);

        msg!("{} + {} = {}", x, y, result);
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize {}
}

在上面的程序中,我们导入了之前创建的 calculate 模块,并声明了一个名为add_two_numbers的函数,该函数将两个数字相加并记录结果。add_two_numbers函数调用 calculate 模块中的 add 函数,将xy作为参数传递,然后将返回值存储在 result 变量中。msg! 宏记录了相加的两个数字和结果。

模块不必是单独的文件

以下示例在 lib.rs 中声明了一个模块,而不是在 calculate.rs 中。

#![allow(unused)]
fn main() {
use anchor_lang::prelude::*;

declare_id!("53hgft52DHUKMPHGu1kusuwxFGk2T8qngwSw2SyGRNrX");

#[program]
pub mod func_visibility {
    use super::*;

    pub fn add_two_numbers(_ctx: Context<Initialize>, x: u64, y: u64) -> Result<()> {
        // Call `add` function in calculate.rs
        let result = calculate::add(x, y);

        msg!("{} + {} = {}", x, y, result);

        Ok(())
    }
}

mod calculate {
    pub fn add(x: u64, y: u64) -> u64 {
		// Return the summation of x and y
        x + y
    }
}

#[derive(Accounts)]
pub struct Initialize {}
}

这个程序与前面的示例相同,唯一的区别是 add 函数存在于 lib.rs 文件中并在 calculate 模块内。此外,向函数添加 pub 关键字至关重要,因为它使函数可以公开访问。以下代码将无法编译:

#![allow(unused)]
fn main() {
use anchor_lang::prelude::*;

declare_id!("53hgft52DHUKMPHGu1kusuwxFGk2T8qngwSw2SyGRNrX");

#[program]
pub mod func_visibility {
    use super::*;

    pub fn initialize(_ctx: Context<Initialize>) -> Result<()> {
        // Call the private-like function
        let result2 = do_something::some_func_here();

        msg!("The result is {}", result2);

        Ok(())
    }
}

mod do_something {
    // private-like function. It exists in the code, but not everyone can call it
    fn some_func_here() -> u64 {
        // Do something...

        return 20;
    }
}

#[derive(Accounts)]
pub struct Initialize {}
}

总结

在 Solidity 中,我们非常关注函数的可见性,因为这非常重要。以下是在 Rust 中考虑如何使用它:

  • 公共/外部函数:这些函数在程序内部和外部都可以访问。在 Solana 中,所有声明的函数默认都是公共的。#[program]块中的所有内容都必须声明为pub
  • 内部函数:这些函数在程序内部以及继承它的程序中可以访问。在嵌套的 pub mod 块内部的函数不包括在构建的程序中,但它们仍然可以在父模块内或外部访问。
  • 私有函数:这些函数不是公开可访问的,不能从其模块之外调用。在 Rust/Solana 中实现私有可见性涉及在特定模块内定义一个带有pub(in crate:::<module>)关键字的函数,这使得该函数仅在定义的模块内可见。

Solidity 通过类实现合约继承,而 Rust,Solana 中使用的语言,没有这个特性。尽管如此,仍然可以使用 Rust mod 模块来组织代码。

通过 RareSkills 了解更多

本教程是我们 Solana 课程的一部分。

Solana 时钟和其他“块”变量

更新日期:2 月 20 日

solana 时钟

今天我们将介绍所有与 Solidity 中块变量对应的变量。并非所有变量都有 1 对 1 的对应关系。在 Solidity 中,我们有以下常用的块变量:

  • block.timestamp
  • block.number
  • blockhash()

以及较少人知道的:

  • block.coinbase
  • block.basefee
  • block.chainid
  • block.difficulty / block.prevrandao

我们假设你已经知道它们的作用,但如果需要复习,可以在 Solidity 全局变量文档中找到解释。

Solana 中的 block.timestamp

通过使用 Clock sysvar 中的unix_timestamp字段,我们可以访问 Solana 的块时间戳。

首先,我们初始化一个新的 Anchor 项目:

anchor init sysvar

将初始化函数替换为:

pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
    let clock: Clock = Clock::get()?;
    msg!(
        "Block timestamp: {}",
        // Get block.timestamp
        clock.unix_timestamp,
    );
    Ok(())
}

Anchor 的 prelude 模块包含 Clock 结构,默认情况下会自动导入:

use anchor_lang::prelude::*;

有点令人困惑的是,unix_timestamp返回的类型是i64,而不是u64,这意味着它支持负数,尽管时间本身不可能是负数。但时间差可以是负数。

获取星期几

现在让我们创建一个程序,使用 Clock sysvar 中的unix_timestamp告诉我们当前是星期几。

Rust 中的 chrono 库提供了对日期和时间进行操作的功能。

在程序目录 ./sysvar/Cargo.toml 中将 chrono 库添加为依赖项:

[dependencies]
chrono = "0.4.31"

在 sysvar 模块中导入 chrono 库:

// ...other code

#[program]
pub mod sysvar {
    use super::*;
    use chrono::*;  // new line here

    // ...
}

现在,我们在程序中添加以下函数:

pub fn get_day_of_the_week(
    _ctx: Context<Initialize>) -> Result<()> {

    let clock = Clock::get()?;
    let time_stamp = clock.unix_timestamp; // current timestamp

    let date_time = chrono::NaiveDateTime::from_timestamp_opt(time_stamp, 0).unwrap();
    let day_of_the_week = date_time.weekday();

    msg!("Week day is: {}", day_of_the_week);

    Ok(())
}

我们将从 Clock sysvar 获取的当前 unix 时间戳作为参数传递给from_timestamp_opt函数,该函数返回一个包含日期和时间的NaiveDateTime结构。然后我们调用 weekday 方法,根据我们传递的时间戳获取当前星期几。

并更新我们的测试:

it("Get day of the week", async () => {
    const tx = await program.methods.getDayOfTheWeek().rpc();
    console.log("Your transaction signature", tx);
});

再次运行测试,得到以下日志:

img

注意“Week day is: Wed”日志。

Solana 中的 block.number

Solana 有一个“槽号(slot number)”概念,与“区块号”密切相关但并非相同。关于它们之间的区别将在接下来的教程中介绍,因此我们推迟对如何获取“区块号”的完整讨论。

block.coinbase

在以太坊中,“Block Coinbase”代表成功挖掘工作量证明(PoW)区块的矿工地址。另一方面,Solana 使用基于领导者的共识机制,结合了 Proof of History(PoH)和 Proof of Stake(PoS),消除了挖矿的概念。相反,通过一种称为领导者计划的系统,任命一个区块或槽领导者在特定时间间隔内验证交易并提出区块。这个计划确定了谁将在特定时间成为区块生产者。

然而,目前在 Solana 程序中没有特定的方法来访问区块领导者的地址。

blockhash

我们包含这一部分是为了完整性,但这很快将被弃用。

对于不感兴趣的读者,可以跳过这一部分。

Solana 有一个 RecentBlockhashes sysvar,其中包含活动的最近区块哈希及其相关的费用计算器。然而,这个 sysvar 已经被弃用 ,并且将不会在未来的 Solana 版本中得到支持。RecentBlockhashes sysvar 不像 Clock sysvar 那样提供 get 方法。然而,缺乏此方法的 sysvar 可以使用sysvar_name::from_account_info来访问。

我们还将介绍一些新的语法,稍后会进行解释。目前,请将其视为样板代码:

#[derive(Accounts)]
pub struct Initialize<'info> {
    /// CHECK: readonly
    pub recent_blockhashes: AccountInfo<'info>,
}

以下是如何在 Solana 中获取最新的区块哈希:

use anchor_lang::{prelude::*, solana_program::sysvar::recent_blockhashes::RecentBlockhashes};

// replace program id
declare_id!("H52ppiSyiZyYVn1Yr9DgeUKeChktUiPwDfuuo932Uqxy");

#[program]
pub mod sysvar {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        // RECENT BLOCK HASHES
        let arr = [ctx.accounts.recent_blockhashes.clone()];
        let accounts_iter = &mut arr.iter();
        let sh_sysvar_info = next_account_info(accounts_iter)?;
        let recent_blockhashes = RecentBlockhashes::from_account_info(sh_sysvar_info)?;
        let data = recent_blockhashes.last().unwrap();

        msg!("The recent block hash is: {:?}", data.blockhash);
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    /// CHECK: readonly
    pub recent_blockhashes: AccountInfo<'info>,
}

测试文件:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Sysvar } from "../target/types/sysvar";

describe("sysvar", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.Sysvar as Program<Sysvar>;

  it("Is initialized!", async () => {
    // Add your test here.
    const tx = await program.methods
      .initialize()
      .accounts({
        recentBlockhashes: anchor.web3.SYSVAR_RECENT_BLOCKHASHES_PUBKEY,
      })
      .rpc();

    console.log("Transaction hash:", tx);
  });
});

运行测试,得到以下日志:

img

我们可以看到最新的区块哈希。请注意,因为我们部署到本地节点,所以我们得到的区块哈希是我们本地节点的,而不是 Solana 主网的。

在时间结构方面,Solana 在一个固定的时间线上运行,将时间划分为槽(slot),每个槽是分配给领导者提出区块的时间段。这些槽被进一步组织成纪元(epoch), 纪元是预先定义的时间段,在此期间领导者调度保持不变。

block.gaslimit

Solana 每个块的计算单位限制为 4800 万 。每个交易默认限制为 20 万计算单位,尽管可以将其提高到 140 万计算单位(我们将在以后的教程中讨论,但你可以在这里看到一个示例 )。

无法从 Rust 程序中访问此限制。

block.basefee

在以太坊中,basefee 根据 EIP-1559 是动态的;它是先前区块利用率的函数。在 Solana 中,交易的基本价格是静态的,因此不需要像这样的变量。

block.difficulty

块难度是与工作量证明(PoW)区块链相关的概念。另一方面,Solana 采用 Proof of History(PoH)结合 Proof of Stake(PoS)共识机制,不涉及块难度的概念。

block.chainid

Solana 没有链 ID,因为它不是与以太坊虚拟机兼容的区块链。block.chainid 是 Solidity 智能合约知道它们在测试网、L2、主网或其他以太坊虚拟机兼容链上的方法。

Solana 为 Devnet、Testnet 和 Mainnet 运行单独的集群,但程序没有机制可以知道它们位于哪个集群。但是,你可以在部署时使用 Rust cfg 功能在代码中进行程序化调整,以根据部署到的集群不同而具有不同的功能。这里有一个根据集群更改程序 ID 的示例

了解更多

本教程是我们免费的 Solana 课程的一部分。

Solana Sysvars Explained

更新日期:2 月 29 日

Solana 系统变量

在 Solana 中,sysvars 是只读系统账户,为 Solana 程序提供访问区块链状态和网络信息的权限。它们类似于以太坊的全局变量,也使智能合约能够访问网络或区块链状态信息,但它们具有类似以太坊预编译合约的唯一公钥地址。

在 Anchor 程序中,你可以通过两种方式访问 sysvars:一种是使用 anchor 的 get 方法包装器,另一种是将其视为帐户在你的#[Derive(Accounts)]中,使用其公钥地址。

并非所有 sysvars 都支持get方法,有些已被弃用(有关弃用信息将在本指南中指定)。对于那些没有get方法的 sysvars,我们将使用它们的公钥地址进行访问。

  • Clock:用于执行与时间相关的操作,如获取当前时间或插槽(slot)号。
  • EpochSchedule:包含有关纪元(epoch)调度的信息,包括特定插槽的纪元。
  • Rent:包含租金率和信息,如保持帐户免于租金的最低余额要求。
  • Fees:包含当前插槽的费用计算器。费用计算器提供有关 Solana 交易中每个签名支付多少 lamports 的信息。
  • EpochRewards:EpochRewards sysvar 保存了 Solana 中的纪元奖励分配记录,包括区块奖励和质押奖励。
  • RecentBlockhashes:包含活动的最近区块哈希。
  • SlotHashes:包含最近插槽哈希的历史记录。
  • SlotHistory:保存在 Solana 中最近纪元可用的插槽数组,并在处理新插槽时更新。
  • StakeHistory:按每个纪元基础维护整个网络的质押激活和停用记录,每个纪元开始时更新。
  • Instructions:用于访问当前交易中作为一部分的序列化指令。
  • LastRestartSlot:包含上次重启(Solana 上次重启的时间)的插槽号,如果从未发生过则为零。如果 Solana 区块链崩溃并重新启动,应用程序可以使用此信息确定是否应等待事情稳定下来。

区分 Solana 插槽和区块

插槽(slot)是一个时间窗口(约 400 毫秒),指定的领导者可以在其中生成一个区块。一个插槽包含一个区块(与以太坊上的相同类型的区块,即交易列表)。但是,如果区块领导者在该插槽中未能生成区块,则该插槽可能不包含区块。它们的关系如下图所示:

solana 插槽和区块

尽管每个区块对应一个插槽,但区块哈希与插槽哈希不同。当在资源管理器中单击插槽号时,会打开具有不同哈希的区块详细信息。

让我们以下图中来自 Solana 区块资源管理器的示例为例:

solana 插槽哈希

图像中突出显示的绿色数字是插槽号237240962,突出显示的黄色文本是插槽哈希DYFtWxEdLbos9E6SjZQCMq8z242Yv2bVoj6dzwskd5vZ。下面突出显示的红色区块哈希是FzHwFHDAXJBc55rpjShznGCBnC7DsTCjxf3KKAk6hk9T

(其他区块详细信息已被裁剪):

Solana 区块哈希

我们可以通过它们独特的哈希来区分区块和插槽,即使它们具有相同的数字。

作为测试,点击资源管理器中的任何插槽号这里 ,你会注意到会打开一个区块页面。该区块将具有与插槽哈希不同的哈希。

在 Anchor 中使用 get 方法访问 Solana Sysvars

如前所述,并非所有 sysvars 都可以使用 Anchor 的 get 方法访问。诸如 Clock、EpochSchedule 和 Rent 之类的 sysvars 可以使用此方法访问。

虽然 Solana 文档将 Fees 和 EpochRewards 列为可以使用 get 方法访问的 sysvars,但在最新版本的 Anchor 中已被弃用。因此,它们无法在 Anchor 中使用 get 方法调用。

我们将使用 get 方法访问并记录所有当前支持的 sysvars 的内容。首先,我们创建一个新的 Anchor 项目:

anchor init sysvars
cd sysvars
anchor build

Clock sysvar

要使用 Clock sysvar,我们可以调用 Clock::get()(我们在以前的教程中做过类似的操作)方法,如下所示。

将以下代码添加到我们项目的 initialize 函数中:

pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
    // Get the Clock sysvar
    let clock = Clock::get()?;

    msg!(
        "clock: {:?}",
        // Retrieve all the details of the Clock sysvar
        clock
    );

    Ok(())
}

现在,在本地 Solana 节点上运行测试并检查日志:

Solana 纪元

EpochSchedule sysvar

在 Solana 中,一个纪元是大约两天的时间段。SOL 只能在纪元开始时抵押或赎回。如果在纪元结束之前抵押(或赎回)SOL,则等待纪元结束时,SOL 将被标记为“激活”或“停用”。

Solana 在其委托 SOL 的描述中更详细地描述了这一点。

我们可以使用 get 方法访问 EpochSchedule sysvar,类似于 Clock sysvar。

使用以下代码更新 initialize 函数:

pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
    // Get the Clock sysvar
    let clock = Clock::get()?;

    msg!(
        "clock: {:?}",
        // Retrieve all the details of the Clock sysvar
        clock
    );

    Ok(())
}

再次运行测试,将生成以下日志:

img

从日志中,我们可以观察到 EpochSchedule sysvar 包含以下字段:

  • slots_per_epoch(黄色突出显示)保存每个纪元中的插槽数,这里是 432,000 个插槽。
  • leader_schedule_slot_offset(红色突出显示)确定下一个纪元的领导者计划的时间(我们之前在第 11 天谈到过)。它也设置为 432,000。
  • warmup(紫色突出显示)是一个布尔值,指示 Solana 是否处于热身阶段。在此阶段,纪元开始较小,然后逐渐增加大小。这有助于网络在重置后或在早期运行期间平稳启动。
  • first_normal_epoch(橙色突出显示)标识可以具有其插槽计数的第一个纪元,而 first_normal_slot(蓝色突出显示)是开始此纪元的插槽。在这种情况下,两者都是 0。

我们看到first_normal_epochfirst_normal_slot为 0 是因为测试验证器尚未运行两天。如果我们在主网上运行此命令(在撰写本文时),我们预计first_normal_epoch为 576,first_normal_slot为 248,832,000。

Solana 最近纪元

Rent sysvar

再次,我们使用 get 方法访问 Rent sysvar。

使用以下代码更新 initialize 函数:

pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
    // Previous code...

    // Get the Rent sysvar
    let rent_var = Rent::get()?;
    msg!(
        "Rent {:?}",
        // Retrieve all the details of the Rent sysvar
        rent_var
    );

    Ok(())
}

运行测试,我们得到以下日志:

solana 租金 sysvar

Solana 中的 Rent sysvar 具有三个关键字段:

  • lamports_per_byte_year
  • exemption_threshold
  • burn_percent

黄色突出显示的 lamports_per_byte_year 指示每年每字节所需的 lamports 数量,以获得租金豁免。

红色突出显示的 exemption_threshold 是用于计算租金豁免所需的最低余额的乘数。在此示例中,我们看到我们需要支付 3480 x 2 = 6960 lamports 每字节来创建一个新帐户。

其中 50%被燃烧(紫色突出显示的 burn_percent)以管理 Solana 通胀。

“租金”概念将在后续教程中进行全面解释。

在 Anchor 中使用 Sysvar 公钥地址访问 Solana Sysvars

对于不支持 get 方法的 sysvars,我们可以使用它们的公钥地址访问它们。任何此类例外情况将被指定。

StakeHistory sysvar

回想一下,我们先前提到该 sysvar 按每个纪元基础记录整个网络的质押激活和停用。但是,由于我们运行的是本地验证器节点,因此此 sysvar 将返回空数据。

我们将使用其公钥地址访问此 sysvar SysvarStakeHistory1111111111111111111111111

首先,我们将在项目中的Initialize帐户结构中进行修改,如下所示:

#[derive(Accounts)]
pub struct Initialize<'info> {
    /// CHECK:
    pub stake_history: AccountInfo<'info>, // We create an account for the StakeHistory sysvar
}

请暂时将新语法视为样板。/// CHECK:AccountInfo将在后续教程中解释。对于好奇的人,<'info'>标记是 Rust 生命周期

接下来,我们将以下代码添加到initialize函数中。

(sysvar 帐户的引用将作为事务的一部分传递给我们的测试。之前的示例已内置到 Anchor 框架中)。

pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
    // Previous code...

    // Accessing the StakeHistory sysvar
    // Create an array to store the StakeHistory account
    let arr = [ctx.accounts.stake_history.clone()];

    // Create an iterator for the array
    let accounts_iter = &mut arr.iter();

    // Get the next account info from the iterator (still StakeHistory)
    let sh_sysvar_info = next_account_info(accounts_iter)?;

    // Create a StakeHistory instance from the account info
    let stake_history = StakeHistory::from_account_info(sh_sysvar_info)?;

    msg!("stake_history: {:?}", stake_history);

    Ok(())
}

我们不导入 StakeHistory sysvar,因为我们可以通过使用super::*; import来访问它。如果不是这种情况,我们将导入特定的 sysvar。

并更新测试:

pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
    // Previous code...

    // Accessing the StakeHistory sysvar
    // Create an array to store the StakeHistory account
    let arr = [ctx.accounts.stake_history.clone()];

    // Create an iterator for the array
    let accounts_iter = &mut arr.iter();

    // Get the next account info from the iterator (still StakeHistory)
    let sh_sysvar_info = next_account_info(accounts_iter)?;

    // Create a StakeHistory instance from the account info
    let stake_history = StakeHistory::from_account_info(sh_sysvar_info)?;

    msg!("stake_history: {:?}", stake_history);

    Ok(())
}

现在,重新运行我们的测试:

solana 质押历史

正如之前提到的,对于我们的本地验证器,它返回空数据。

我们还可以通过在 Anchor Typescript 客户端中将StakeHistory_PublicKey变量替换为 anchor.web3.SYSVAR_STAKE_HISTORY_PUBKEY来获取 StakeHistory sysvar 的公钥。

RecentBlockhashes sysvar

如何访问此 sysvar 在我们的先前教程中已经讨论过。作为提醒,它已被弃用,并且将不再受支持。

Fees sysvar

Fees sysvar 也已被弃用。

Instruction sysvar

此 sysvar 可用于访问当前交易的序列化指令,以及该交易的一些元数据。我们将在下面进行演示。

首先,更新我们的导入:

#[program]
pub mod sysvars {
		use super::*;
    use anchor_lang::solana_program::sysvar::{instructions, fees::Fees, recent_blockhashes::RecentBlockhashes};
    // rest of the code
}

接下来,将 Instruction sysvar 帐户添加到Initialize帐户结构中:

#[derive(Accounts)]
pub struct Initialize<'info> {
    /// CHECK:
    pub stake_history: AccountInfo<'info>, // We create an account for the StakeHistory sysvar
    /// CHECK:
    pub recent_blockhashes: AccountInfo<'info>,
    /// CHECK:
    pub instruction_sysvar: AccountInfo<'info>,
}

现在,修改 initialize 函数以接受一个number: u32参数,并将以下代码添加到 initialize 函数中。

pub fn initialize(ctx: Context<Initialize>, number: u32) -> Result<()> {
    // Previous code...

    // Get Instruction sysvar
    let arr = [ctx.accounts.instruction_sysvar.clone()];

    let account_info_iter = &mut arr.iter();

    let instructions_sysvar_account = next_account_info(account_info_iter)?;

    // Load the instruction details from the instruction sysvar account
    let instruction_details =
        instructions::load_instruction_at_checked(0, instructions_sysvar_account)?;

    msg!(
        "Instruction details of this transaction: {:?}",
        instruction_details
    );
    msg!("Number is: {}", number);

    Ok(())
}

与之前的 sysvar 不同,我们在这种情况下使用load_instruction_at_checked()方法从 Instruction sysvar 中检索 sysvar,而不是使用<sysvar_name>::from_account_info()。此方法需要指令数据索引(在本例中为 0)和 Instruction sysvar 帐户作为参数。

更新测试:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Sysvars } from "../target/types/sysvars";

describe("sysvars", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.Sysvars as Program<Sysvars>;

  // Create a StakeHistory PublicKey object
  const StakeHistory_PublicKey = new anchor.web3.PublicKey(
    "SysvarStakeHistory1111111111111111111111111"
  );

  it("Is initialized!", async () => {
    // Add your test here.
    const tx = await program.methods
      .initialize(3) // Call the initialze function with the number `3`
      .accounts({
        stakeHistory: StakeHistory_PublicKey, // pass the public key of StakeHistory sysvar to the list of accounts needed for the instruction
        recentBlockhashes: anchor.web3.SYSVAR_RECENT_BLOCKHASHES_PUBKEY, // pass the public key of RecentBlockhashes sysvar to the list of accounts needed for the instruction
				instructionSysvar: anchor.web3.SYSVAR_INSTRUCTIONS_PUBKEY, // Pass the public key of the Instruction sysvar to the list of accounts needed for the instruction
      })
      .rpc();
    console.log("Your transaction signature", tx);
  });
});

并运行测试:

solana sysvar 指令

如果仔细检查日志,我们可以看到程序 Id、sysvar 指令的公钥、序列化数据和其他元数据。

我们还可以在序列化指令数据和我们自己的程序日志中看到用黄色箭头突出显示的数字 3。红色突出显示的序列化数据是 Anchor 注入的一个鉴别器(我们可以忽略它)。

练习: 访问LastRestartSlot 系统变量

SysvarLastRestartS1ot1111111111111111111111 使用上述方法。请注意,Anchor 没有这个系统变量的地址,因此你需要创建一个PublicKey对象。

在当前版本的 Anchor 中无法访问的 Solana 系统变量。

在当前版本的 Anchor 中,无法访问某些系统变量。这些系统变量包括EpochRewardsSlotHistorySlotHashes。尝试访问这些系统变量时会导致错误。

了解更多

本教程是我们免费的 Solana 课程的一部分。

Solana 日志,“事件”和交易历史

更新日期:Apr 5

img

Solana 程序可以发出类似于 Ethereum 触发事件的事件,尽管我们将讨论一些不同之处。

具体来说,Solana 中的事件旨在将信息传递给前端,而不是记录过去的交易。要获取过去的历史记录,可以通过地址查询 Solana 交易。

Solana 日志和事件

以下程序有两个事件:MyEventMySecondEvent。与 Ethereum 事件具有“参数”类似,Solana 事件在结构体中具有字段:

use anchor_lang::prelude::*;

declare_id!("FmyZrMmPvRzmJCG3p5R1AnbkPqSmzdJrcYzgnQiGKuBq");

#[program]
pub mod emit {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        emit!(MyEvent { value: 42 });
        emit!(MySecondEvent { value: 3, message: "hello world".to_string() });
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize {}

#[event]
pub struct MyEvent {
    pub value: u64,
}

#[event]
pub struct MySecondEvent {
    pub value: u64,
    pub message: String,
}

事件成为 Solana 程序的 IDL 的一部分,类似于事件是 Solidity 智能合约 ABI 的一部分。以下是上述程序的 IDL 截图,突出显示相关部分:

Solana IDL 上的事件定义

在 Solana 中,没有“索引”或“非索引”信息的概念,就像在 Ethereum 中一样(尽管上面的截图中有一个“index”字段,但它没有用)。

与 Ethereum 不同,我们不能直接查询一系列区块号的过去事件。我们只能在事件发生时监听事件。(稍后我们将看到 Solana 审计过去交易的方法)。以下代码显示了如何在 Solana 中监听事件:

import * as anchor from "@coral-xyz/anchor";
import { BorshCoder, EventParser, Program } from "@coral-xyz/anchor";
import { Emit } from "../target/types/emit";

describe("emit", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.Emit as Program<Emit>;

  it("Is initialized!", async () => {
    const listenerMyEvent = program.addEventListener('MyEvent', (event, slot) => {
      console.log(`slot ${slot} event value ${event.value}`);
    });

    const listenerMySecondEvent = program.addEventListener('MySecondEvent', (event, slot) => {
      console.log(`slot ${slot} event value ${event.value} event message ${event.message}`);
    });

    await program.methods.initialize().rpc();

		// This line is only for test purposes to ensure the event
		// listener has time to listen to event.
    await new Promise((resolve) => setTimeout(resolve, 5000));

    program.removeEventListener(listenerMyEvent);
    program.removeEventListener(listenerMySecondEvent);
  });
});

在 Solana 中不可能像在 Ethereum 中那样扫描过去的日志,它们必须在交易发生时进行监视。

来自测试的 Solana 事件日志

日志在幕后的工作原理

在 EVM 中,通过运行log0log1log2等操作码来发出日志。在 Solana 中,通过调用系统调用sol_log_data来运行日志。作为参数,它只是一个字节序列:

https://docs.rs/solana-program/latest/src/solana_program/log.rs.html#116-124

以下是 Solana 客户端中系统调用的功能:

sol_log_data

我们用来创建事件的“struct”结构是字节序列的抽象。在幕后,Anchor 将结构体转换为字节序列传递给此函数。Solana 系统调用只接受字节序列,而不是结构体。

Solana 日志不适用于历史查询

在 Ethereum 中,日志用于审计目的,但在 Solana 中,日志不能以这种方式使用,因为它们只能在发生时查询。因此,它们更适合于将信息传递给前端应用程序。Solana 函数无法像 Solidity 视图函数那样将数据返回给前端,因此 Solana 日志是一种轻量级的实现方式。

但是,事件在区块浏览器中是保留的。请参见此交易底部的示例:

https://explorer.solana.com/tx/JgyHQPxL3cPLFtV4cx5i842ZgBx57R2fkNn2TZn1wsQZqVXKfijd43CEHo88C3ridK27Kw8KkMzfvDdqaS398SX

与 Ethereum 不同,Solana 交易可以通过地址查询

在 Ethereum 中,没有直接的方法来查询发送到智能合约的交易或来自特定钱包的交易。

我们可以使用 eth_getTransactionCount计算从地址发送的交易数量。我们可以使用交易哈希和 eth_getTransactionByHash 来获取特定交易。我们可以使用 eth_getBlockByNumbereth_getBlockByHash 来获取特定区块中的交易。

但是,无法按地址获取所有交易。这必须通过间接方式,解析自钱包活跃或智能合约部署以来的每个区块来完成。

为了审计智能合约中的交易,开发人员添加智能合约事件来查询感兴趣的交易。

获取 Solana 中的交易历史

另一方面,Solana 有一个 RPC 函数 getSignaturesForAddress,列出地址完成的所有交易。地址可以是程序或钱包。

以下是列出地址的交易的脚本:

let web3 = require('@solana/web3.js');

const solanaConnection = new web3.Connection(web3.clusterApiUrl("mainnet-beta"));

const getTransactions = async(address,limit) => {
  const pubKey = new web3.PublicKey(address);
  let transactionList = await solanaConnection.getSignaturesForAddress(pubKey, {limit: limit});
  let signatureList = transactionList.map(transaction => transaction.signature);

  console.log(signatureList);

  for await (const sig of signatureList) {
    console.log(await solanaConnection.getParsedTransaction(sig, {maxSupportedTransactionVersion: 0}));
  }
}

let myAddress = "enter and address here";

getTransactions(myAddress, 3);

请注意,实际交易内容是使用getParsedTransaction RPC 方法检索的。

Solana 中的 Tx.origin、msg.sender 和 onlyOwner:识别调用者

Solana 中的 tx.origin、msg.sender 和 onlyOwner

在 Solidity 中,msg.sender是一个全局变量,代表调用或启动智能合约上的函数调用的地址。全局变量tx.origin是签署交易的钱包。

在 Solana 中,没有等价于msg.sender

在 Solana 中有一个等价于tx.origin,但你应该知道 Solana 交易可以有多个签署者,因此我们可以将其视为具有“多个 tx.origin”。

要在 Solana 中获取“tx.origin”地址,你需要通过向函数上下文添加 Signer 账户并在调用函数时将调用者的账户传递给它来设置它。

让我们看一个示例,演示如何在 Solana 中访问交易签署者的地址:

use anchor_lang::prelude::*;

declare_id!("Hf96fZsgq9R6Y1AHfyGbhi9EAmaQw2oks8NqakS6XVt1");

#[program]
pub mod day14 {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        let the_signer1: &mut Signer = &mut ctx.accounts.signer1;

				// Function logic....

        msg!("The signer1: {:?}", *the_signer1.key);

        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(mut)]
    pub signer1: Signer<'info>,
}

从上面的代码片段中,Signer<'info>用于验证Initialize<'info>账户结构中的signer1账户是否已签署交易。

initialize函数中,从上下文中对 signer1 账户进行可变引用,并将其分配给the_signer1变量。

最后,我们使用msg!宏记录了 signer1 的公钥(地址),并传入*the_signer1.key,该操作对the_signer1指向的实际值进行了解引用并访问了key字段或方法。

接下来是为上述程序编写一个测试:

describe("Day14", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.Day14 as Program<Day14>;

  it("Is signed by a single signer", async () => {
    // Add your test here.
    const tx = await program.methods.initialize().accounts({
      signer1: program.provider.publicKey
    }).rpc();

    console.log("The signer1: ", program.provider.publicKey.toBase58());
  });
});

在测试中,我们将我们的钱包账户作为签署者传递给signer1账户,然后调用 initialize 函数。随后,我们在控制台上记录了钱包账户,以验证其与我们程序中的账户一致性。

练习: 运行测试后,你在shell_1(命令终端)和shell_3(日志终端)的输出中注意到了什么?

多个签署者

在 Solana 中,我们还可以让多个签署者签署一个交易,你可以将其视为将一堆签名打包并在一个交易中发送。一个用例是在一个交易中执行多签交易。

为此,我们只需在程序中的账户结构中添加更多的 Signer 结构,然后确保在调用函数时传递必要的账户:

use anchor_lang::prelude::*;

declare_id!("Hf96fZsgq9R6Y1AHfyGbhi9EAmaQw2oks8NqakS6XVt1");

#[program]
pub mod day14 {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        let the_signer1: &mut Signer = &mut ctx.accounts.signer1;
        let the_signer2: &mut Signer = &mut ctx.accounts.signer2;

        msg!("The signer1: {:?}", *the_signer1.key);
        msg!("The signer2: {:?}", *the_signer2.key);

        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    pub signer1: Signer<'info>,
    pub signer2: Signer<'info>,
}

上面的示例与单个签署者示例有些相似,但有一个显著的区别。在这种情况下,我们向Initialize结构添加了另一个 Signer 账户(signer2),并在 initialize 函数中记录了两个签署者的公钥。

使用多个签署者调用 initialize 函数与单个签署者不同。下面的测试显示了如何使用多个签署者调用函数:

describe("Day14", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.Day14 as Program<Day14>;

  // generate a signer to call our function
  let myKeypair = anchor.web3.Keypair.generate();

  it("Is signed by multiple signers", async () => {
    // Add your test here.
    const tx = await program.methods
      .initialize()
      .accounts({
        signer1: program.provider.publicKey,
        signer2: myKeypair.publicKey,
      })
      .signers([myKeypair])
      .rpc();

    console.log("The signer1: ", program.provider.publicKey.toBase58());
    console.log("The signer2: ", myKeypair.publicKey.toBase58());
  });
});

上面的测试有什么不同?首先是signers()方法,该方法接受一个签署者数组作为参数。但我们的数组中只有一个签署者,而不是两个。Anchor 会自动将提供程序中的钱包账户作为签署者传递,因此我们不需要再将其添加到签署者数组中。

生成随机地址以进行测试

第二个变化是myKeypair变量,它存储了由anchor.web3模块随机生成的 Keypair(用于访问账户的公钥和相应的私钥)。在测试中,我们将 Keypair(存储在myKeypair变量中的)的公钥分配给signer2账户,这就是为什么它作为参数传递给.signers([myKeypair])方法。

多次运行测试,你会注意到signer1的公钥不会改变,但signer2的公钥会改变。这是因为分配给signer1账户(在测试中)的钱包账户来自提供程序,这也是你本地机器上的 Solana 钱包账户,而分配给signer2的账户每次运行anchor test --skip-local-validator时都会随机生成。

练习: 创建另一个需要三个签署者(提供程序钱包账户和两个随机生成账户)的函数,并为其编写一个测试。

onlyOwner

这是 Solidity 中常用的一种模式,用于限制函数的访问权限仅限于合约的所有者。使用 Anchor 的#[access_control]属性,我们也可以实现 only owner 模式,即将我们 Solana 程序中函数的访问权限限制为 PubKey(所有者的地址)。

以下是如何在 Solana 中实现“onlyOwner”功能的示例:

use anchor_lang::prelude::*;

declare_id!("Hf96fZsgq9R6Y1AHfyGbhi9EAmaQw2oks8NqakS6XVt1");

// NOTE: Replace with your wallet's public key
const OWNER: &str = "8os8PKYmeVjU1mmwHZZNTEv5hpBXi5VvEKGzykduZAik";

#[program]
pub mod day14 {
    use super::*;

    #[access_control(check(&ctx))]
    pub fn initialize(ctx: Context<OnlyOwner>) -> Result<()> {
        // Function logic...

        msg!("Holla, I'm the owner.");
        Ok(())
    }
}

fn check(ctx: &Context<OnlyOwner>) -> Result<()> {
    // Check if signer === owner
    require_keys_eq!(
        ctx.accounts.signer_account.key(),
        OWNER.parse::<Pubkey>().unwrap(),
        OnlyOwnerError::NotOwner
    );

    Ok(())
}

#[derive(Accounts)]
pub struct OnlyOwner<'info> {
    signer_account: Signer<'info>,
}

// An enum for custom error codes
#[error_code]
pub enum OnlyOwnerError {
    #[msg("Only owner can call this function!")]
    NotOwner,
}

在上述代码中,OWNER变量存储与我的本地 Solana 钱包关联的公钥(地址)。在测试之前,请确保将OWNER变量替换为你钱包的公钥。你可以通过运行solana address命令轻松检索你的公钥。

#[access_control]属性在运行主要指令之前执行给定的访问控制方法。当调用 initialize 函数时,将在运行 initialize 函数之前执行访问控制方法(check)。check方法接受引用上下文作为参数,然后检查交易的签署者是否等于OWNER变量的值。require_keys_eq!宏确保两个公钥值相等,如果为真,则执行 initialize 函数,否则,使用NotOwner自定义错误回滚。

测试 onlyOwner 功能 - 正常情况

在下面的测试中,我们调用 initialize 函数,并使用所有者的密钥对签署交易:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Day14 } from "../target/types/day14";

describe("day14", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.Day14 as Program<Day14>;

  it("Is called by the owner", async () => {
    // Add your test here.
    const tx = await program.methods
      .initialize()
      .accounts({
        signerAccount: program.provider.publicKey,
      })
      .rpc();

    console.log("Transaction hash:", tx);
  });
});

我们调用 initialize 函数,并将提供程序中的钱包账户(本地 Solana 钱包账户)传递给具有Signer<'info>结构的signerAccount,以验证钱包账户实际上签署了交易。还记得 Anchor 会使用提供程序中的钱包账户秘密签署任何交易。

如果一切都正确,运行测试anchor test --skip-local-validator,测试应该通过:

Anchor 测试通过

测试签署者不是所有者的情况 - 攻击案例

使用不是所有者的不同密钥对调用 initialize 函数并签署交易将引发错误,因为函数调用仅限于所有者:

describe("day14", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.Day14 as Program<Day14>;

  let Keypair = anchor.web3.Keypair.generate();

  it("Is NOT called by the owner", async () => {
    // Add your test here.
    const tx = await program.methods
      .initialize()
      .accounts({
        signerAccount: Keypair.publicKey,
      })
      .signers([Keypair])
      .rpc();

    console.log("Transaction hash:", tx);
  });
});

在这里,我们生成了一个随机密钥对,并用它来签署交易。让我们再次运行测试:

由于签署者错误而失败的 anchor 测试

正如预期的那样,由于签署者的公钥与所有者的公钥不相等,我们收到了错误。

修改所有者

要更改程序中的所有者,需要将分配给所有者的公钥存储在链上。但是,关于 Solana 中的“存储”讨论将在未来的教程中介绍。

所有者只需重新部署字节码。

练习: 将类似上述程序的程序升级为具有新所有者。

通过 RareSkills 了解更多

本教程是我们 Solana 课程中的第 14 章。

Solana 计算单元和交易费用简介

更新日期:2 月 29 日

img

在以太坊中,交易的价格计算为 gasUsed × gasPrice。这告诉我们将花费多少以太币将交易包含在区块链中。在发送交易之前,需要指定并预付 gasLimit。如果交易用尽了 gas,它将回滚。

与以太坊虚拟机链不同,Solana 操作码/指令消耗“计算单元”(可以说是更好的名称)而不是 gas,每个交易软上限为 200,000 计算单元。如果交易成本超过 200,000 计算单元,它将回滚。

在以太坊中,计算的 gas 成本与存储相关的 gas 成本是一样的。在 Solana 中,存储处理方式不同,因此 Solana 中持久数据的定价是一个不同的讨论主题。

从定价运行操作码的角度来看,以太坊和 Solana 的行为类似。

两个链都执行编译的字节码并为执行的每条指令收费。以太坊使用 EVM 字节码,但 Solana 运行的是一个修改过的 伯克利数据包过滤器 称为 Solana 数据包过滤器。

以太坊根据执行时间长短为不同的操作码收取不同的价格,从一个 gas 到数千个 gas 不等。在 Solana 中,每个操作码的成本为一个计算单元。

当计算单元不足时该怎么办

在执行无法在限制以下完成的重型计算操作时,传统策略是“保存工作”并在多个交易中执行。

“保存工作”部分需要放入永久存储,这是我们尚未涵盖的内容。这类似于在以太坊中尝试迭代一个庞大循环;你会有一个存储变量用于记录你停止的索引,以及一个存储变量保存到目前为止已完成的计算。

计算单元优化

正如我们已经知道的,Solana 使用计算单元来防止停机问题并防止运行永远运行的代码。每个交易的计算单元上限为 200,000 CU(可以在额外成本的情况下增加到 1.4m CU),如果超出了(所选限制),程序将终止,所有更改的状态将恢复,并且费用不会退还给调用者。这可以防止攻击者试图在节点上运行永不结束或计算密集的程序以减慢或停止链。

然而,与 EVM 链不同,交易中使用的计算资源不会影响该交易支付的费用。无论你使用了整个限制还是很少使用,你都将被收取费用。例如,一个 400 计算单元的交易的成本与一个 200,000 计算单元的交易相同。

除了计算单元,Solana 交易的签名者数量也会影响计算单元成本。根据 Solana 文档

"因此,目前,交易费仅由交易中需要验证的签名数量确定。交易(最大 1232 字节)中签名数量的唯一限制是交易本身的最大大小。交易中的每个签名(64 字节)必须引用一个唯一的公钥(32 字节),因此单个交易最多可以包含多达 12 个签名(不确定为什么要这样做)"

我们可以通过这个小例子看到这一点。从一个空的 Solana 程序开始,如下所示:

use anchor_lang::prelude::*;

declare_id!("6CCLqLGeyExCFegJDjRDirWQRRSbM5XNq3yKvmaWS2ZC");

#[program]
pub mod compute_unit {
    use super::*;

    pub fn initialize(_ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize {}

更新测试文件:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { ComputeUnit } from "../target/types/compute_unit";

describe("compute_unit", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());
  const program = anchor.workspace.ComputeUnit as Program<ComputeUnit>;
  const defaultKeyPair = new anchor.web3.PublicKey(
		// replace this with your default provider keypair, you can get it by running `solana address` in your terminal
    "EXJupeVMqDbHk7xY4XP4TVXq22L3ZJxJ9Gm68hJccpLp"
  );

  it("Is initialized!", async () => {
    // log the keypair's initial balance
    let bal_before = await program.provider.connection.getBalance(
      defaultKeyPair
    );
    console.log("before:", bal_before);

    // call the initialize function of our program
    const tx = await program.methods.initialize().rpc();

    // log the keypair's balance after
    let bal_after = await program.provider.connection.getBalance(
      defaultKeyPair
    );
    console.log("after:", bal_after);

    // log the difference
    console.log(
      "diff:",
      BigInt(bal_before.toString()) - BigInt(bal_after.toString())
    );
  });
});

注意:在 JavaScript 中,数字末尾的“n”表示它是一个 BigInt

运行:solana logs,如果你尚未运行。

当我们运行 anchor test --skip-local-validator 时,我们会得到以下输出作为测试日志和 Solana 验证器日志:

# test logs
		compute_unit
before: 15538436120
after: 15538431120
diff: 5000n


# solana logs
Status: Ok
Log Messages:
  Program 6CCLqLGeyExCFegJDjRDirWQRRSbM5XNq3yKvmaWS2ZC invoke [1]
  Program log: Instruction: Initialize
  Program 6CCLqLGeyExCFegJDjRDirWQRRSbM5XNq3yKvmaWS2ZC consumed 320 of 200000 compute units
  Program 6CCLqLGeyExCFegJDjRDirWQRRSbM5XNq3yKvmaWS2ZC success

5000 lamports 的余额差异是因为在发送此交易时我们只需要/使用了 1 个签名(即我们的默认提供者地址的签名)。这与我们上面建立的一致,即 1 * 5000 = 5000。还请注意,这在计算单元方面的成本为 320,但此金额不影响我们的交易费用。

现在,让我们给我们的程序增加一些复杂性并看看会发生什么:

pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
    let mut a = Vec::new();
    a.push(1);
    a.push(2);
    a.push(3);
    a.push(4);
    a.push(5);

    Ok(())
}

毫无疑问,这应该会对我们的交易费用产生一些影响对吧?

当我们运行 anchor test --skip-local-validator 时,我们会得到以下输出作为测试日志和 Solana 验证器日志:

# test logs
compute_unit
before: 15538436120
after: 15538431120
diff: 5000n


# solana logs
Status: Ok
Log Messages:
  Program 6CCLqLGeyExCFegJDjRDirWQRRSbM5XNq3yKvmaWS2ZC invoke [1]
  Program log: Instruction: Initialize
  Program 6CCLqLGeyExCFegJDjRDirWQRRSbM5XNq3yKvmaWS2ZC consumed 593 of 200000 compute units
  Program 6CCLqLGeyExCFegJDjRDirWQRRSbM5XNq3yKvmaWS2ZC success

我们可以看到这会消耗更多的计算单元,几乎是我们第一个示例的两倍。但这不会影响我们的交易费用。这是预期的,并显示了无论计算单元消耗多少,用户支付的交易费用都不会受到影响。

无论消耗的计算单元如何,该交易都将收取 5000 lamports 或 0.000005 SOL。

回到计算单元。那么,既然计算单元不影响交易的费用,我们为什么要优化计算单元呢?

  • 首先,目前是这样,未来 Solana 可能会决定提高上限,必须激励节点不将这些复杂交易与简单交易区别对待。这意味着在计算交易费用时考虑消耗的计算单元。
  • 其次,如果有大量网络活动竞争区块空间,较小的交易更有可能被包含在一个块中。
  • 第三,这将使你的程序更易与其他程序组合。如果另一个程序调用你的程序,则交易不会获得额外的计算限制。其他程序可能不希望与你集成,如果你的交易使用了太多计算,留下很少的计算给原始程序。

更小的整数节省计算单元

使用的值类型越大,消耗的计算单元就越多。最好在适用的情况下使用较小的类型。让我们看一下代码示例和注释:

pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
    // this costs 600 CU (type defaults to Vec<i32>)
    let mut a = Vec::new();
    a.push(1);
    a.push(1);
    a.push(1);
    a.push(1);
    a.push(1);
    a.push(1);

    // this costs 618 CU
    let mut a: Vec<u64> = Vec::new();
    a.push(1);
    a.push(1);
    a.push(1);
    a.push(1);
    a.push(1);
    a.push(1);

    // this costs 600 CU (same as the first one but the type was explicitly denoted)
    let mut a: Vec<i32> = Vec::new();
    a.push(1);
    a.push(1);
    a.push(1);
    a.push(1);
    a.push(1);
    a.push(1);

    // this costs 618 CU (takes the same space as u64)
    let mut a: Vec<i64> = Vec::new();
    a.push(1);
    a.push(1);
    a.push(1);
    a.push(1);
    a.push(1);
    a.push(1);

    // this costs 459 CU
    let mut a: Vec<u8> = Vec::new();
    a.push(1);
    a.push(1);
    a.push(1);
    a.push(1);
    a.push(1);
    a.push(1);

    Ok(())
}

注意随着整数类型的减少,计算单元成本的降低。这是预期内的,因为较大的类型在内存中占用的空间比较小的类型多,而不管所表示的值如何。

在链上使用 find_program_address 生成程序派生账户(PDA)可能会使用更多的计算单元,因为此方法会迭代调用 create_program_address 直到找到不在 ed25519 曲线上的 PDA。为了减少计算成本,尽可能在链下使用 find_program_address() 并在可能时将得到的 bump seed 传递给程序。关于这一点的更多讨论将在后面的部分中进行,因为这超出了本节的范围。

这不是一个详尽的列表,而是一些要点,以便了解什么使一个程序比另一个更具计算密集性。

什么是 eBPF?

Solana 的字节码主要源自 BPF。 “eBPF” 简单地表示 “extended(扩展的)BPF”。本节在 Linux 上下文中解释了 BPF。

正如你所期望的那样,Solana 虚拟机不理解 Rust 或 C。用这些语言编写的程序被编译成 eBPF(扩展伯克利数据包过滤器)。

简而言之,eBPF 允许在内核中(在沙箱环境中)执行任意 eBPF 字节码,当内核发出 eBPF 字节码订阅的事件时,例如:

  • 网络:打开/关闭套接字
  • 磁盘:写入/读取
  • 进程的创建
  • 线程的创建
  • CPU 指令调用
  • 支持最多 64 位(这就是为什么 Solana 具有最大 uint 类型为 u64)

你可以将其视为内核的 JavaScript。JavaScript 在事件发生时在浏览器上执行操作,eBPF 在内核中发生事件时执行类似的操作,例如当执行系统调用时。

这使我们能够为各种用例构建程序,例如(基于上述事件):

  • 网络:分析路由等
  • 安全性:根据某些规则过滤流量并报告任何不良/被阻止的流量
  • 跟踪和分析:从用户空间程序到内核指令收集详细的执行流程
  • 可观察性:报告和分析内核活动

仅当我们需要时才执行程序(即在内核中发生事件时)。例如,假设你想要在文件被写入时获取文件名和写入的数据,我们监听/注册/订阅 vfs_write() 系统调用事件。现在,每当该文件被写入时,我们就可以使用这些数据。

Solana 字节码格式(SBF)

Solana 字节码格式是 eBPF 的一种变体,具有某些更改,其中最突出的是删除了字节码验证器。eBPF 中存在字节码验证器,以确保所有可能的执行路径是有限的且安全的。

Solana 使用计算单元限制来处理这个问题。具有限制计算资源消耗的计算计量器,将安全检查移至运行时,并允许任意内存访问、间接跳转、循环和其他有趣的行为。

在以后的教程中,我们将深入研究一个简单程序及其字节码,调整它,了解不同的计算单元成本,并学习 Solana 字节码的工作原理以及如何分析它。

通过 RareSkills 了解更多

本教程是我们 Solana 课程的一部分。

在 Solana 和 Anchor 中初始化账户

更新日期:2 月 25 日

img

直到目前为止,我们的教程中都没有使用“存储变量”或存储任何永久性内容。

在 Solidity 和以太坊中,一种更为奇特的设计模式用于存储数据,即 SSTORE2 或 SSTORE3,其中数据存储在另一个智能合约的字节码中。

在 Solana 中,这不是一种奇特的设计模式,而是一种常态!

请记住,我们可以随意更新 Solana 程序的字节码(如果我们是原始部署者),除非该程序被标记为不可变。

Solana 使用相同的机制进行数据存储。

以太坊中的存储槽实际上是一个庞大的键值存储:

{
    key: [smart_contract_address, storage slot]
    value: 32_byte_slot // (for example: 0x00)
}

Solana 的模型类似:它是一个庞大的键值存储,其中“键”是一个 base58 编码的地址,而“值”是一个数据块,最大可达 10MB(或者可选择不存储任何内容)。可以将其可视化如下:

{
		// key is a base58 encoded 32 byte sequence
    key: ETnqC8mvPRyUVXyXoph22EQ1GS5sTs1zndkn5eGMYWfs
    value: {
			data: 020000006ad1897139ac2bdb67a3c66a...
			// other fields are omitted
		}
}

在以太坊中,智能合约的字节码和存储变量是分开存储的,即它们被不同方式索引,并且必须使用不同的 API 进行加载。

下图显示了以太坊如何维护状态。每个账户都是 Merkle 树中的一个叶子。请注意,“存储变量”存储在智能合约的账户内部(账户 1)。

以太坊存储

在 Solana 中,一切都是账户,这些账户都有可能存储数据。有时我们将一个账户称为“程序账户”,将另一个账户称为“存储账户”,但唯一的区别是是否将可执行标志设置为 true 以及我们打算如何使用账户的数据字段。

下面,我们可以看到 Solana 存储是一个从 Solana 地址到账户的巨大键值存储:

Solana 账户

想象一下,如果以太坊没有存储变量,并且智能合约默认是可变的。要存储数据,你必须创建其他“智能合约”并将数据存储在它们的字节码中,然后在必要时进行修改。这是 Solana 的一种思维模型。

另一种思维模型是一切都是 Unix 中的文件,只是某些文件是可执行的。Solana 账户可以被视为文件。它们保存内容,但也具有指示谁拥有文件、是否可执行等元数据。

在以太坊中,存储变量直接与智能合约耦合。除非智能合约通过公共变量、delegatecall 或某些设置器方法授予写入或读取访问权限,默认情况下,存储变量只能由单个合约写入或读取(尽管任何人都可以离线读取存储变量)。在 Solana 中,所有“存储变量”都可以被任何程序读取,但只有其所有者程序可以写入。

存储与程序“绑定”的方式是通过所有者字段。

在下图中,我们看到账户 B 是由程序账户 A 拥有的。我们知道 A 是一个程序账户,因为“可执行”被设置为 true。这表明 B 的数据字段将存储 A 的数据:

Solana 的程序存储

Solana 程序需要在使用之前进行初始化

在以太坊中,我们可以直接写入一个之前未使用过的存储变量。然而,在 Solana 中,程序需要一个显式的初始化事务。也就是说,我们必须在写入数据之前创建账户。

可以在一个事务中初始化并写入 Solana 账户 —— 但这会引入安全问题,如果我们现在处理这些问题将会时讨论复杂化。目前,只需说 Solana 账户必须在使用之前进行初始化即可。

一个基本的存储示例

让我们将以下 Solidity 代码翻译成 Solana:

contract BasicStorage {
    Struct MyStorage {
        uint64 x;
    }

    MyStorage public myStorage;

    function set(uint64 _x) external {
        myStorage.x = _x;
    }
} 

可能会觉得奇怪,我们将一个单变量放入一个结构体中。

但在 Solana 程序中,特别是 Anchor,所有存储,或者说账户数据,都被视为结构体。原因在于账户数据的灵活性。由于账户是数据块,可能相当大(最多可达 10MB),我们需要一些“结构”来解释数据,否则它只是一系列没有意义的字节。

在幕后,当我们尝试读取或写入数据时,Anchor 会将账户数据反序列化和序列化为结构体。

如上所述,我们需要在使用 Solana 账户之前对其进行初始化,因此在实现 set() 函数之前,我们需要编写 initialize() 函数。

账户初始化样板代码

让我们创建一个名为 basic_storage 的新 Anchor 项目。

下面我们编写了初始化 MyStorage 结构体的最小代码,该结构体仅包含一个数字 x。(请查看代码底部的 MyStorage 结构体):

use anchor_lang::prelude::*;
use std::mem::size_of;

declare_id!("GLKUcCtHx6nkuDLTz5TNFrR4tt4wDNuk24Aid2GrDLC6");

#[program]
pub mod basic_storage {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {

    #[account(init,
              payer = signer,
              space=size_of::<MyStorage>() + 8,
              seeds = [],
              bump)]
    pub my_storage: Account<'info, MyStorage>,
    
    #[account(mut)]
    pub signer: Signer<'info>,

    pub system_program: Program<'info, System>,
}

#[account]
pub struct MyStorage {
    x: u64,
}

1) 初始化函数

请注意,initialize() 函数中没有代码 —— 实际上它只返回 Ok(())

Solana 初始化账户

初始化账户的函数不一定要为空,我们可以有自定义逻辑。但在我们的示例中,它是空的。初始化账户的函数也不一定要叫做 initialize,但这是一个有用的名称。

2) 初始化结构体

Initialize 结构体包含初始化账户所需资源的引用:

  • my_storage:我们正在初始化的类型为 MyStorage 的结构体。
  • signer:支付存储结构体“gas”费用的钱包(有关存储费用的讨论稍后进行)。
  • system_program:我们将在本教程后面讨论它。

带注释的初始化结构体

'info 关键字是一个 Rust 生命周期。这是一个庞大的主题,现在最好将其视为样板。

我们将重点放在上面 my_storage 之上的宏,因为这是初始化操作发生的地方。

3) 初始化结构体中的 my_storage 字段

my_storage 字段上面的属性宏(紫色箭头)是 Anchor 知道此事务旨在初始化此账户的方式(请记住,类似属性的宏# 开头,并使用 init 修改结构体以提供额外功能):

结构体字段的注释

这里重要的关键字是 init

当我们初始化一个账户时,我们必须提供额外的信息:

  • payer(蓝色框):谁支付 SOL 以分配存储空间。签名者被指定为 mut,因为他们的账户余额将发生变化,即他们的账户将被扣除一些 SOL。因此,我们将其账户标记为“mutable”。
  • space(橙色框):这表示账户将占用多少空间。我们可以使用 std::mem::size_of 实用程序,并使用我们要存储的结构体 MyStorage(绿色框)作为参数,而不是自己计算。我们将在下一点中讨论 + 8(粉色框)。
  • seedsbump(红色框):一个程序可以拥有多个账户,它使用“seed”在计算“鉴别器”时进行“区分”。 “鉴别器(discriminator)”占用 8 个字节,这就是为什么我们需要额外分配 8 个字节的空间,除了我们的结构体占用的空间。暂时将 bump 视为样板。

这可能看起来很复杂,不用担心。目前,可以将初始化账户视为样板。

4) 系统程序是什么?

系统程序 是内置于 Solana 运行时的程序(有点类似于 以太坊预编译),它将 SOL 从一个账户转移到另一个账户。我们将在稍后关于转移 SOL 的教程中重新讨论这一点。目前,我们需要将 SOL 从支付 MyStruct 存储费用的签名者转移出去,因此 系统程序 总是初始化事务的一部分。

5) MyStorage 结构体

回想一下 Solana 账户内部的数据字段:

突出显示的 Solana 账户中的数据

在幕后,这是一个字节序列。上面示例中的结构体:

mystorage 结构体

在写入时,结构体将被序列化为字节序列并存储在 data 字段中。在写入时,data 字段将根据该结构体进行反序列化。

在我们的示例中,我们只使用了结构体中的一个变量,尽管如果需要,我们可以添加更多变量或其他类型的变量。

Solana 运行时不强制我们使用结构体来存储数据。从 Solana 的角度来看,账户只是一个数据块。但是,Rust 有许多方便的库可以将结构体转换为数据块,反之亦然,因此结构体是约定俗成的。Anchor 在幕后利用这些库。

你不必使用结构体来使用 Solana 账户。可以直接写入字节序列,但这不是一种方便的存储数据的方式。

#[account] 宏会透明地实现所有魔法。

6) 单元测试初始化

以下 Typescript 代码将运行上面的 Rust 代码。

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { BasicStorage } from "../target/types/basic_storage";

describe("basic_storage", () => {
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.BasicStorage as Program<BasicStorage>;

  it("Is initialized!", async () => {
    const seeds = []
    const [myStorage, _bump] = anchor.web3.PublicKey.findProgramAddressSync(seeds, program.programId);

    console.log("the storage account address is", myStorage.toBase58());

    await program.methods.initialize().accounts({ myStorage: myStorage }).rpc();
  });
});

以下是单元测试的输出:

Solana 账户初始化测试通过

我们将在后续教程中了解更多,但 Solana 要求我们提前指定事务将与之交互的账户。由于我们正在与存储 MyStruct 的账户交互,因此我们需要提前计算其“地址”并将其传递给 initialize() 函数。以下是使用以下 Typescript 代码执行此操作:

seeds = []
const [myStorage, _bump] = 
    anchor.web3.PublicKey.findProgramAddressSync(seeds, program.programId);

请注意,seeds 是一个空数组,就像在 Anchor 程序中一样。

预测 Solana 中的账户地址就像在以太坊中使用 create2

在以太坊中,使用 create2 创建的合约的地址取决于:

  • 部署合约的地址
  • 一个 salt
  • 以及创建的合约的字节码

在 Solana 中,预测初始化账户的地址非常类似,只是忽略了“字节码”。具体来说,它取决于:

  • 拥有存储账户的程序,basic_storage(类似于部署合约的地址)
  • 以及 seeds(类似于 create2 的“salt”)

在本教程中的所有示例中,seeds 都是一个空数组,但我们将在以后的教程中探讨非空数组。

不要忘记将 my_storage 转换为 myStorage

Anchor 悄悄地将 Rust 的蛇形命名法转换为 Typescript 的驼峰命名法。当我们在 Typescript 中向 initialize 函数提供 .accounts({myStorage: myStorage}) 时,它会在 Rust 中的 Initialize 结构体中“填充” my_storage 键(下面绿色圈中)。system_programSigner 会被 Anchor 静默填充:

snake case to camel case conversion

账户不能被初始化两次

如果我们可以重新初始化一个账户,那将是非常有问题的,因为用户可能会擦除系统中的数据!幸运的是,Anchor 在后台防范了这种情况。

如果你第二次运行测试(而不重置本地验证器),你将会收到下面截图中显示的错误。

或者,如果你不使用本地验证器,你可以运行以下测试:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { BasicStorage} from "../target/types/basic_storage";

describe("basic_storage", () => {
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.BasicStorage as Program<BasicStorage>;

  it("Is initialized!", async () => {
    const seeds = []
    const [myStorage, _bump] = anchor.web3.PublicKey.findProgramAddressSync(seeds, program.programId);
 
		// ********************************************
		// **** NOTE THAT WE CALL INITIALIZE TWICE ****
    // ********************************************
    await program.methods.initialize().accounts({myStorage: myStorage}).rpc();
    await program.methods.initialize().accounts({myStorage: myStorage}).rpc();
  });
});

当我们运行测试时,测试会失败,因为第二次调用 initialize 会抛出错误。预期输出如下:

Solana account cannot be initialized twice

不要忘记在多次运行测试时重置验证器

因为 solana-test-validator 仍会记住第一个单元测试中的账户,所以你需要在测试之间使用 solana-test-validator --reset 来重置验证器。否则,你将会收到上面的错误。

初始化账户摘要

对于大多数以太坊开发者来说,初始化账户的需求可能会感到不自然。

不用担心,你会一遍又一遍地看到这段代码序列,过一段时间后,这将变得轻而易举。

在本教程中,我们只看了初始化存储,而在接下来的教程中,我们将学习读取、写入和删除存储。在今天看到的所有代码中,你将有很多机会直观地理解它们的作用。

练习: 修改 MyStorage,使其像笛卡尔坐标一样保存 xy。这意味着向 MyStorage 结构体添加 y 并将它们从 u64 更改为 i64。你不需要修改代码的其他部分,因为 size_of 将为你重新计算大小。请确保重置验证器,以便原始存储账户被擦除,你不会被阻止再次初始化账户。

通过 RareSkills 了解更多

查看我们的 Solana 课程 以获取更多信息。

Solana 计数器教程:读取和写入账户数据

更新日期:2 月 25 日

img

在我们之前的教程中,我们讨论了如何初始化一个账户,以便我们可以将数据持久化存储。本教程展示了如何向我们已经初始化的账户写入数据。

以下是来自之前 Solana 账户初始化教程的代码。我们添加了一个set()函数,用于将一个数字存储在MyStorage和相关的Set结构中。

其余代码保持不变:

use anchor_lang::prelude::*;
use std::mem::size_of;

declare_id!("GLKUcCtHx6nkuDLTz5TNFrR4tt4wDNuk24Aid2GrDLC6");

#[program]
pub mod basic_storage {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }

    // ****************************
    // *** THIS FUNCTION IS NEW ***
    // ****************************
    pub fn set(ctx: Context<Set>, new_x: u64) -> Result<()> {
        ctx.accounts.my_storage.x = new_x;
        Ok(())
    }
}

// **************************
// *** THIS STRUCT IS NEW ***
// **************************
#[derive(Accounts)]
pub struct Set<'info> {
    #[account(mut, seeds = [], bump)]
    pub my_storage: Account<'info, MyStorage>,
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(init,
              payer = signer,
              space=size_of::<MyStorage>() + 8,
              seeds = [],
              bump)]
    pub my_storage: Account<'info, MyStorage>,

    #[account(mut)]
    pub signer: Signer<'info>,

    pub system_program: Program<'info, System>,
}

#[account]
pub struct MyStorage {
    x: u64,
}

练习: 修改测试,使用参数170调用set()。这是我们试图持久化的MyStoragex的值。在initialize()之后调用set()。不要忘记将170转换为大数。

set()函数解释

下面,我们稍微重新排列了代码,展示了set()函数、Set结构和MyStorage结构紧密相连:

img

我们现在解释ctx.accounts.my_storage.x = new_x的工作原理:

  • ctx 中的 accounts 字段(顶部蓝色框)为我们提供了访问 Set 结构中所有键的权限。这不是在 Rust 中列出结构键的方式。accounts 能够引用 Set 结构中的键,是由于 #[derive(Accounts)] 宏(底部蓝色框)的神奇插入。
  • 账户 my_storage(橙色框)被设置为可变(绿色框),因为我们打算更改其中的值 x(红色框)。
  • my_storage(橙色框)通过将MyStorage作为泛型参数传递给Account,为我们提供了对MyStorage账户(黄色框)的引用。我们使用键my_storage和存储结构MyStorage的事实仅是为了可读性,它们不需要彼此是驼峰式变体。将它们“联系在一起”的方式用黄色框和黄色箭头进行了说明。

实质上,当调用 set()时,调用者(Typescript 客户端)将 myStorage 账户传递给 set()。在这个账户内部是存储的地址。在幕后,set 将加载存储,写入 x 的新值,序列化结构,然后将其存储回去。

Context结构 Set

set()Context结构比initialize要简单得多,因为它只需要一个资源:对MyStorage账户的可变引用。

img

回想一下,Solana 交易必须预先指定将访问哪些账户。set()函数的结构指定将可变地(mut)访问my_storage账户。

seeds = []bump用于推导我们将要修改的账户的地址。尽管用户为我们传入了账户,但 Anchor 会验证用户是否真的传入了这个程序真正拥有的账户,方法是重新推导地址并将其与用户提供的进行比较。

术语bump目前可以视为样板。但对于好奇的人来说,它用于确保该账户不是一个密码学上有效的公钥。这是运行时如何知道这将被用作程序的数据存储的方式。

尽管我们的 Solana 程序可以自行推导存储账户的地址,但用户仍然需要提供myStorage账户。这是 Solana 运行时要求的,我们将在接下来的教程中讨论原因。

写入set函数的另一种方法

如果我们要向账户写入多个变量,那么像这样一遍又一遍地写ctx.accounts.my_storage会显得相当笨拙:

ctx.accounts.my_storage.x = new_x;
ctx.accounts.my_storage.y = new_y;
ctx.accounts.my_storage.z = new_z;

相反,我们可以使用 Rust 中的“可变引用”(&mut),为我们提供一个对值的“句柄”,以便我们操作。考虑我们set()函数的以下重写:

pub fn set(ctx: Context<Set>, new_x: u64) -> Result<()> {
    let my_storage = &mut ctx.accounts.my_storage;
	my_storage.x = new_x;

    Ok(())
}

练习: 使用新的set函数重新运行测试。如果你正在使用本地测试网,请不要忘记重置验证器。

查看我们的存储账户

如果你正在运行用于测试的本地验证器,你可以使用以下 Solana 命令行指令查看账户数据:

# 用你测试中的地址替换这里的地址
solana account 9opwLZhoPdEh12DYpksnSmKQ4HTPSAmMVnRZKymMfGvn

将地址替换为从单元测试中记录在控制台的地址。

输出如下:

img

前 8 个字节(绿色框)是鉴别器。我们的测试将数字170存储在结构中,这个数字的十六进制表示为aa,显示在红色框中。

当然,命令行不是我们想要用来在前端查看账户数据的机制,也不是我们想要让我们的程序查看另一个程序的账户的机制。这将在接下来的教程中讨论。

从 Rust 程序内部查看我们的存储账户

然而,在 Rust 程序内部读取我们自己的存储值是很简单的。

我们向pub mod basic_storage添加以下函数:

pub fn print_x(ctx: Context<PrintX>) -> Result<()> {
    let x = ctx.accounts.my_storage.x;
    msg!("The value of x is {}", x);
    Ok(())
}

然后我们为PrintX添加以下结构:

#[derive(Accounts)]
pub struct PrintX<'info> {
    pub my_storage: Account<'info, MyStorage>,
}

请注意,my_storage没有#[account(mut)]宏,因为我们不需要它是可变的,我们只是在读取它。

然后我们将以下行添加到我们的测试中:

await program.methods.printX().accounts({myStorage: myStorage}).rpc();

如果你正在后台运行solana logs,你应该看到数字被打印出来。

练习: 编写一个增量函数,读取x并将x + 1存储回x

使用 Solana web3 js 和 Anchor 读取账户数据

img

本教程展示了如何直接从 Solana web3 Javascript 客户端读取账户数据,以便 Web 应用程序可以在前端读取它。

在之前的教程中,我们使用 solana account <账户地址> 来读取我们写入的数据,但如果我们正在构建一个网站上的 dApp,则这种方法不起作用。

相反,我们必须计算存储账户的地址,读取数据,并从 Solana web3 客户端反序列化数据。

想象一下,在以太坊中,我们想要避免使用公共变量或视图函数,但仍然想要在前端显示它们的值。要查看存储变量中的值,而不使它们公开或添加视图函数,我们将使用 getStorageAt(contract_address, slot) API。我们将在 Solana 中做类似的事情,只是不是传入 (contract_address, slot) 对,而是只传入程序的地址,并推导其存储账户的地址。

以下是来自上一篇教程的 Rust 代码。它初始化了 MyStorage 并使用 set 函数写入 x。我们将在本教程中不对其进行修改:

use anchor_lang::prelude::*;
use std::mem::size_of;

declare_id!("GLKUcCtHx6nkuDLTz5TNFrR4tt4wDNuk24Aid2GrDLC6");

#[program]
pub mod basic_storage {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }

    pub fn set(ctx: Context<Set>, new_x: u64) -> Result<()> {
        ctx.accounts.my_storage.x = new_x;
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Set<'info> {
    #[account(mut, seeds = [], bump)]
    pub my_storage: Account<'info, MyStorage>,
}

#[derive(Accounts)]
pub struct Initialize<'info> {

    #[account(init,
              payer = signer,
              space=size_of::<MyStorage>() + 8,
              seeds = [],
              bump)]
    pub my_storage: Account<'info, MyStorage>,
    
    #[account(mut)]
    pub signer: Signer<'info>,

    pub system_program: Program<'info, System>,
}

#[account]
pub struct MyStorage {
    x: u64,
}

以下是 Typescript 单元测试,用于:

  1. 初始化账户
  2. 170 写入存储
  3. 使用 fetch 函数读取值:
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { BasicStorage} from "../target/types/basic_storage";

describe("basic_storage", () => {
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.BasicStorage as Program<BasicStorage>;

  it("Is initialized!", async () => {
    const seeds = []
    const [myStorage, _bump] = anchor.web3.PublicKey.findProgramAddressSync(seeds, program.programId);

    console.log("the storage account address is", myStorage.toBase58());
 
    await program.methods.initialize().accounts({myStorage: myStorage}).rpc();
    await program.methods.set(new anchor.BN(170)).accounts({myStorage: myStorage}).rpc();
  
		// ***********************************
		// *** NEW CODE TO READ THE STRUCT ***
		// ***********************************
		let myStorageStruct = await program.account.myStorage.fetch(myStorage);
    console.log("The value of x is:",myStorageStruct.x.toString());
	});
});

在 Anchor 中查看账户可以通过以下方式完成:

let myStorageStruct = await program.account.myStorage.fetch(myStorage);
console.log("x 的值为:", myStorageStruct.x.toString());

Anchor 自动计算 MyStorage 账户的地址,读取它,并将其格式化为 Typescript 对象。

要了解 Anchor 是如何将 Rust 结构神奇地转换为 Typescript 结构的,请看 target/idl/basic_storage.json 中的 IDL。在 JSON 的底部,我们可以看到我们的程序正在创建的结构的定义:

img

此方法仅适用于你的程序或客户端初始化或创建并具有 IDL 的账户,对于任意账户,此方法将无法正常工作。

也就是说,如果你选择 Solana 上的一个随机账户并使用上述代码,反序列化几乎肯定会失败。在本文的后面,我们将以更“原始”的方式读取账户。

fetch 函数并不神奇。那么,我们如何为我们没有创建的账户执行此操作呢?

从 Anchor Solana 程序创建的账户中获取数据

如果我们知道另一个使用 Anchor 创建的程序的 IDL,我们可以方便地读取其账户数据。

让我们在另一个 shell 中 anchor init 另一个程序,然后让其初始化一个账户,并将该结构中的单个布尔变量设置为 true。我们将称其为 other accountother_program,存储其布尔值的结构为 TrueOrFalse

use anchor_lang::prelude::*;
use std::mem::size_of;

declare_id!("4z4dduMSFKFJDnUAKaHnbhHySK8x1PwgArUBXzksjwa8");

#[program]
pub mod other_program {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }

    pub fn setbool(ctx: Context<SetFlag>, flag: bool) -> Result<()> {
        ctx.accounts.true_or_false.flag = flag;
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(mut)]
    signer: Signer<'info>,

    system_program: Program<'info, System>,

    #[account(init, payer = signer, space = size_of::<TrueOrFalse>() + 8, seeds=[], bump)]
    true_or_false: Account<'info, TrueOrFalse>,
}

#[derive(Accounts)]
pub struct SetFlag<'info> {
    #[account(mut)]
    true_or_false: Account<'info, TrueOrFalse>, 
}

#[account]
pub struct TrueOrFalse {
    flag: bool,
}

Typescript 代码:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { OtherProgram } from "../target/types/other_program";

describe("other_program", () => {
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.OtherProgram as Program<OtherProgram>;

  it("Is initialized!", async () => {
    const seeds = []
    const [TrueOrFalse, _bump] = anchor.web3.PublicKey.findProgramAddressSync(seeds, program.programId);

    console.log("address: ", program.programId.toBase58());

    await program.methods.initialize().accounts({trueOrFalse: TrueOrFalse}).rpc();
    await program.methods.setbool(true).accounts({trueOrFalse: TrueOrFalse}).rpc();
  });
});

针对本地验证器在另一个 shell 中运行测试。请注意打印出的 programId。我们将需要它来推导 other_program 的账户地址。

读取程序

在另一个 shell 中,使用 anchor init 初始化另一个程序。我们将其称为 read。我们将仅使用 Typescript 代码来读取 other_programTrueOrFalse 结构,不使用 Rust。这模拟了从另一个程序的存储账户中读取数据。

我们的目录布局如下:

parent_dir/
∟ other_program/
∟ read/

以下代码将从 other_program 读取 TrueOrFalse 结构。确保:

  • otherProgramAddress 与上面打印的地址匹配
  • 确保你从正确的文件位置读取 other_program.json IDL
  • 确保使用 --skip-local-validator 运行测试,以确保此代码读取另一个程序创建的账户
import * as anchor from "@coral-xyz/anchor";

describe("read", () => {
  anchor.setProvider(anchor.AnchorProvider.env());

  it("Read other account", async () => {
    // the other program's programdId -- make sure the address is correct
    const otherProgramAddress = "4z4dduMSFKFJDnUAKaHnbhHySK8x1PwgArUBXzksjwa8";
    const otherProgramId = new anchor.web3.PublicKey(otherProgramAddress);

    // load the other program's idl -- make sure the path is correct
    const otherIdl = JSON.parse(
        require("fs").readFileSync("../other_program/target/idl/other_program.json", "utf8")
    );
    
    const otherProgram = new anchor.Program(otherIdl, otherProgramId);

    const seeds = []
    const [trueOrFalseAcc, _bump] = 
	    anchor.web3.PublicKey.findProgramAddressSync(seeds, otherProgramId);
    let otherStorageStruct = await otherProgram.account.trueOrFalse.fetch(trueOrFalseAcc);

    console.log("The value of flag is:", otherStorageStruct.flag.toString());
  });
});

预期输出如下:

img

再次强调,此方法仅适用于使用 Anchor 构建的其他 Solana 程序。这依赖于 Anchor 如何序列化结构。

获取任意账户的数据

在以下部分,我们将展示如何在没有 Anchor 魔力的情况下读取数据。

不幸的是,Solana 的 Typescript 客户端文档非常有限,该库已经更新了多次,使得关于该主题的教程已经过时。

尝试查找你需要的 Solana web3 Typescript 函数的最佳方法是查看 HTTP JSON RPC 方法 ,并查找看起来有希望的方法。在我们的情况下,getAccountInfo 看起来很有希望(蓝色箭头)。

img

接下来,我们想尝试在 Solana web3 js 中找到该方法。最好使用具有自动完成功能的 IDE,这样你可以尝试找到该函数,就像以下视频演示的那样:

下面是再次运行测试的预期输出:

img

围绕十六进制 aa 字节的绿色框显示,我们已成功检索到我们在 set() 函数中存储的十进制 170 值。

下一步是解析数据缓冲区,这不是我们在这里要涵盖的内容。

读者应该注意,反序列化这些数据可能是一个令人沮丧的过程。

在 Solana 账户中,没有“强制”数据序列化的方式。Anchor 以自己的方式序列化结构,但如果有人使用原始 Rust(没有使用 Anchor)编写了 Solana 程序,或者使用了他们自己的序列化算法,那么你将不得不根据他们序列化数据的方式自定义你的反序列化算法。

继续学习 Solana

你可以在这里查看我们的 Solana 课程的其余部分。

在 Solana 中创建“映射”和“嵌套映射”

更新日期:3 月 1 日

在 Solana 中的映射和嵌套映射

在之前的教程中,seeds=[] 参数总是空的。如果我们向其中放入数据,它会像 Solidity 映射中的键一样运作。

考虑以下示例:

contract ExampleMapping {

    struct SomeNum {
        uint64 num;
    }

    mapping(uint64 => SomeNum) public exampleMap;

    function setExampleMap(uint64 key, uint64 val) public {
        exampleMap[key] = SomeNum(val);
    }
}

我们现在创建一个 Solana Anchor 程序 example_map

初始化映射:Rust

首先,我们只展示初始化步骤,因为它会引入一些新的语法,我们需要解释。

use anchor_lang::prelude::*;
use std::mem::size_of;

declare_id!("DntexDPByFxpVeBSjd6nLqQQSqZmSaDkP8TUbcJ9jAgt");

#[program]
pub mod example_map {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>, key: u64) -> Result<()> {
        Ok(())
    }
}

#[derive(Accounts)]
#[instruction(key: u64)]
pub struct Initialize<'info> {

    #[account(init,
              payer = signer,
              space = size_of::<Val>() + 8,
              seeds=[&key.to_le_bytes().as_ref()],
              bump)]
    val: Account<'info, Val>,
    
    #[account(mut)]
    signer: Signer<'info>,
    
    system_program: Program<'info, System>,
}

#[account]
pub struct Val {
    value: u64,
}

这是你可以考虑这个映射的方式:

&key.to_le_bytes().as_ref() 中的 key 参数可以被视为映射中的“键”,类似于 Solidity 构造:

mapping(uint256 => uint256) myMap;
myMap[key] = val

代码中不熟悉的部分是 #[instruction(key: u64)]seeds=[&key.to_le_bytes().as_ref()]

seeds = [&key.to_le_bytes().as_ref()]

seeds 中的项应为字节。然而,我们传入的是一个 u64,而不是字节类型。为了将其转换为字节,我们使用 to_le_bytes()。这里的“le”表示“ little endian(小端) ”。seeds 不一定要编码为小端字节,我们只是为了这个示例选择了这种方式。大端也可以,只要保持一致。要转换为大端,我们将使用 to_be_bytes()

#[instruction(key: u64)]

为了在 initialize(ctx: Context<Initialize>, key: u64) 中“传递”函数参数 key,我们需要使用 instruction 宏,否则我们的 init 宏无法“看到” initialize 中的 key 参数。

初始化映射:Typescript

下面的代码展示了如何初始化账户:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { ExampleMap } from "../target/types/example_map";

describe("example_map", () => {
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.ExampleMap as Program<ExampleMap>;

  it("Initialize mapping storage", async () => {
    const key = new anchor.BN(42);

    const [seeds, _bump] = [key.toArrayLike(Buffer, "le", 8)];
    let valueAccount = anchor.web3.PublicKey.findProgramAddressSync(
      seeds,
      program.programId,
    );

    await program.methods.initialize(key).accounts({val: valueAccount}).rpc();
  });
});

代码 key.toArrayLike(Buffer, "le", 8) 指定我们正在尝试使用来自 key 的值创建一个大小为 8 字节的字节缓冲区。我们选择了 8 字节,因为我们的 key 是 64 位,64 位等于 8 字节。"le" 表示小端,以便与 Rust 代码匹配。

映射中的每个“值”都是一个单独的账户,必须分别初始化。

设置映射:Rust

我们需要额外的 Rust 代码来设置值。这里的所有语法都应该是熟悉的。

// inside the #[program] module
pub fn set(ctx: Context<Set>, key: u64, val: u64) -> Result<()> {
    ctx.accounts.val.value = val;
    Ok(())
}

//...

#[derive(Accounts)]
#[instruction(key: u64)]
pub struct Set<'info> {
    #[account(mut)]
    val: Account<'info, Val>,
}

设置和读取映射:Typescript

因为我们在客户端(Typescript)中派生出存储值的账户地址,我们可以像处理 seeds 数组为空的账户一样从中读取和写入。读取 Solana 账户数据的语法和写入的语法与之前的教程相同:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { ExampleMap } from "../target/types/example_map";

describe("example_map", () => {
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.ExampleMap as Program<ExampleMap>;

  it("Initialize and set value", async () => {
    const key = new anchor.BN(42);
    const value = new anchor.BN(1337);

    const seeds = [key.toArrayLike(Buffer, "le", 8)];
    let valueAccount = anchor.web3.PublicKey.findProgramAddressSync(
      seeds,
      program.programId,
    )[0];

    			await program.methods.initialize(key).accounts({val: valueAccount}).rpc();

	// set the account
    await program.methods.set(key, value).accounts({val: valueAccount}).rpc();

    // read the account back
    let result = await program.account.val.fetch(valueAccount);

    console.log(`the value ${result.value} was stored in ${valueAccount.toBase58()}`);

  });
});

澄清“嵌套映射”

在像 Python 或 Javascript 这样的语言中,真正的嵌套映射是指指向另一个哈希映射的哈希映射。

然而,在 Solidity 中,“嵌套映射”只是一个具有多个键的单个映射,行为就像它们是一个键一样。

在一个“真正”的嵌套映射中,你只需提供第一个键,就会返回另一个哈希映射。

Solidity 的“嵌套映射”不是“真正”的嵌套映射:你不能提供一个键并获得一个映射返回:你必须提供所有键并获得最终结果。

如果你使用 seeds 来模拟类似于 Solidity 的嵌套映射,你将面临相同的限制。你必须提供所有 seeds —— Solana 不会接受只有一个 seed。

初始化嵌套映射:Rust

seeds 数组可以容纳任意数量的项,类似于 Solidity 中的嵌套映射。当然,这取决于每个交易所施加的计算限制。下面显示了执行初始化和设置的代码。

我们不需要任何特殊的语法来做到这一点,只需多接受一些函数参数并将更多项放入 seeds 中,因此我们将展示完整的代码而不再解释。

Rust 嵌套映射

use anchor_lang::prelude::*;
use std::mem::size_of;

declare_id!("DntexDPByFxpVeBSjd6nLqQQSqZmSaDkP8TUbcJ9jAgt");

#[program]
pub mod example_map {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>, key1: u64, key2: u64) -> Result<()> {
        Ok(())
    }

    pub fn set(ctx: Context<Set>, key1: u64, key2: u64, val: u64) -> Result<()> {
        ctx.accounts.val.value = val;
        Ok(())
    }
}

#[derive(Accounts)]
#[instruction(key1: u64, key2: u64)] // new key args added
pub struct Initialize<'info> {

    #[account(init,
              payer = signer,
              space = size_of::<Val>() + 8,
              seeds=[&key1.to_le_bytes().as_ref(), &key2.to_le_bytes().as_ref()], // 2 seeds
              bump)]
    val: Account<'info, Val>,
    
    #[account(mut)]
    signer: Signer<'info>,
    
    system_program: Program<'info, System>,
}

#[derive(Accounts)]
#[instruction(key1: u64, key2: u64)] // new key args added
pub struct Set<'info> {
    #[account(mut)]
    val: Account<'info, Val>,
}

#[account]
pub struct Val {
    value: u64,
}

Typescript 嵌套映射

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { ExampleMap } from "../target/types/example_map";

describe("example_map", () => {
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.ExampleMap as Program<ExampleMap>;

  it("Initialize and set value", async () => {
    // we now have two keys
    const key1 = new anchor.BN(42);
    const key2 = new anchor.BN(43);
    const value = new anchor.BN(1337);

    // seeds has two values
    const seeds = [key1.toArrayLike(Buffer, "le", 8), key2.toArrayLike(Buffer, "le", 8)];
    let valueAccount = anchor.web3.PublicKey.findProgramAddressSync(
      seeds,
      program.programId,
    )[0];

    // functions now take two keys
    await program.methods.initialize(key1, key2).accounts({val: valueAccount}).rpc();
    await program.methods.set(key1, key2, value).accounts({val: valueAccount}).rpc();

    // read the account back
    let result = await program.account.val.fetch(valueAccount);
    console.log(`the value ${result.value} was stored in ${valueAccount.toBase58()}`);

  });
});

练习: 修改上述代码以形成一个嵌套映射,其中有三个键。

初始化多个映射

实现拥有多个映射的简单方法是将另一个变量添加到 seeds 数组中,并将其视为“索引”第一个映射、第二个映射等等的方式。

以下代码展示了初始化 which_map 的示例,它只包含一个键。

#[derive(Accounts)]
#[instruction(which_map: u64, key: u64)]
pub struct InitializeMap<'info> {

    #[account(init,
              payer = signer,
              space = size_of::<Val1>() + 8,
              seeds=[&which_map.to_le_bytes().as_ref(), &key.to_le_bytes().as_ref()],
              bump)]
    val: Account<'info, Val1>,

    #[account(mut)]
    signer: Signer<'info>,

    system_program: Program<'info, System>,
}

练习: 完成 Rust 和 Typescript 代码,创建一个具有两个映射的程序:第一个映射具有单个键,第二个映射具有两个键。考虑如何在指定第一个映射时将两级映射转换为单级映射。

通过 RareSkills 学习 Solana

查看我们的 Solana 课程 以查看我们的其他 Solana 教程。

Solana 中的存储成本、最大存储大小和账户调整

solana 账户租金

在分配存储空间时,付款人必须按每分配的字节支付一定数量的 SOL。

Solana 将此称为“租金”。这个名称有点误导,因为它暗示需要每月充值,但情况并非总是如此。一旦支付了租金,即使两年过去了,也不需要再付款。支付了两年的租金后,该账户被视为“租金豁免”。

这个名称源自 Solana 最初按年度的字节数收费。如果你只支付了半年的租金,你的账户将在六个月后被删除。如果你提前支付了两年的租金,该账户将被视为“租金豁免”。该账户将永远不必再支付租金。如今,所有账户都必须是租金豁免的;你不能支付少于 2 年的租金。

尽管租金是按“每字节”计算的,但零数据的账户并不是免费的;Solana 仍然必须对其进行索引并存储有关其的元数据。

当初始化账户时,需要在后台计算所需的租金数量;你无需明确计算租金。

但是,你确实希望能够预估存储成本,以便能够正确设计你的应用程序。

如果你想要快速估算,可以在命令行中运行 solana rent <字节数> 来快速获得答案:

solana 租金 32

如前所述,分配零字节并不是免费的:

solana 租金 0

让我们看看如何计算这个费用。

Anchor Rent Module 提供了一些与租金相关的常量:

  • ACCOUNT_STORAGE_OVERHEAD:此常量的值为 128(字节),正如其名称所示,空账户有 128 字节的开销。
  • DEFAULT_EXEMPTION_THRESHOLD:此常量的值为 2.0(float 64),表示提前支付两年的租金使账户免除进一步支付租金。
  • DEFAULT_LAMPORTS_PER_BYTE_YEAR:此常量的值为 3,480,意味着每个字节需要 3,480 lamports 每年。由于我们需要支付两年的租金,每个字节将花费我们 6,960 lamports。

以下的 rust 程序打印出一个空账户将花费我们多少。请注意,结果与上面的 solana rent 0 的截屏相匹配:

use anchor_lang::prelude::*;
use anchor_lang::solana_program::rent as rent_module;

declare_id!("BfMny1VwizQh89rZtikEVSXbNCVYRmi6ah8kzvze5j1S");

#[program]
pub mod rent {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        let cost_of_empty_acc = rent_module::ACCOUNT_STORAGE_OVERHEAD as f64 * 
                                rent_module::DEFAULT_LAMPORTS_PER_BYTE_YEAR as f64 *
                                rent_module::DEFAULT_EXEMPTION_THRESHOLD; 

        msg!("cost to create an empty account: {}", cost_of_empty_acc);
        // 890880

        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize {}

如果我们想要计算一个非空账户将花费多少,那么我们只需将字节数添加到空账户的成本中,如下所示:

use anchor_lang::prelude::*;
use anchor_lang::solana_program::rent as rent_module;

declare_id!("BfMny1VwizQh89rZtikEVSXbNCVYRmi6ah8kzvze5j1S");

#[program]
pub mod rent {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        let cost_of_empty_acc = rent_module::ACCOUNT_STORAGE_OVERHEAD as f64 * 
                                rent_module::DEFAULT_LAMPORTS_PER_BYTE_YEAR as f64 *
                                rent_module::DEFAULT_EXEMPTION_THRESHOLD;

        msg!("cost to create an empty account: {}", cost_of_empty_acc);
        // 890,880 lamports
        
        let cost_for_32_bytes = cost_of_empty_acc + 
                                32 as f64 * 
                                rent_module::DEFAULT_LAMPORTS_PER_BYTE_YEAR as f64 *
                                rent_module::DEFAULT_EXEMPTION_THRESHOLD;

        msg!("cost to create a 32 byte account: {}", cost_for_32_bytes);
        // 1,113,600 lamports
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize {}

同样,请注意,此程序的输出与命令行上的输出相匹配。

将存储成本与 ETH 进行比较

在撰写本文时,ETH 的价值约为 $2,425。初始化一个新账户的成本为 22,100 gas,因此我们可以计算 32 字节的 gas 成本为 $0.80,假设 gas 成本为 15 gwei。

目前,Solana 的价格为 $90/SOL,因此支付 1,113,600 lamports 来初始化 32 字节存储将花费 $0.10。

然而,ETH 的市值是 SOL 的 7.5 倍,因此如果 SOL 的市值与 ETH 相同,那么 SOL 的当前价格将为 $675,而 32 字节存储将花费 $0.75。

Solana 有一个永久的通货膨胀模型,最终会收敛到每年 1.5%,因此这应该反映出存储随着时间按照摩尔定律变得更便宜的事实,即相同成本的晶体管密度每 18 个月翻倍。

请记住,从字节到加密货币的转换是协议中设置的常数,一个硬分叉随时可以更改。

余额低于 2 年租金豁免阈值的账户将被减少,直到删除账户

一个用户的钱包账户余额逐渐“减少”的有趣 Reddit 帖子可以在这里阅读:https://www.reddit.com/r/solana/comments/qwin1h/my_sol_balance_in_the_wallet_is_decreasing/

原因是钱包低于租金豁免阈值,Solana 运行时正在逐渐减少账户余额以支付租金。

如果由于余额低于租金豁免阈值而导致钱包被删除,可以通过向其发送更多的 SOL 来“复活”它,但如果账户中存储了数据,那么这些数据将会丢失。

大小限制

当我们初始化一个账户时,我们不能初始化超过 10,240 字节的大小。

练习: 创建一个基本的存储初始化程序,并设置 space=10241。这比限制高 1 字节。你应该会看到以下错误:

solana 账户由于超出大小限制而无法初始化

更改账户的大小

如果需要增加账户的大小,我们可以使用 realloc 宏。如果账户存储了一个向量并且需要更多空间,这可能会很方便。下面的代码示例中的 increase_account_size 函数和 IncreaseAccountSize 上下文结构体会将大小增加 1,000 字节(请查看下面代码中的全部大写注释):

use anchor_lang::prelude::*;
use std::mem::size_of;

declare_id!("GLKUcCtHx6nkuDLTz5TNFrR4tt4wDNuk24Aid2GrDLC6");

#[program]
pub mod basic_storage {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }

    pub fn increase_account_size(ctx: Context<IncreaseAccountSize>) -> Result<()> {
        Ok(())
    }
}


#[derive(Accounts)]
pub struct IncreaseAccountSize<'info> {

    #[account(mut,
							// ***** 1,000 BYTE INCREMENT IS OVER HERE *****
              realloc = size_of::<MyStorage>() + 8 + 1000,
              realloc::payer = signer,
              realloc::zero = false,
              seeds = [],
              bump)]
    pub my_storage: Account<'info, MyStorage>,
    
    #[account(mut)]
    pub signer: Signer<'info>,

    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct Initialize<'info> {

    #[account(init,
              payer = signer,
              space=size_of::<MyStorage>() + 8,
              seeds = [],
              bump)]
    pub my_storage: Account<'info, MyStorage>,
    
    #[account(mut)]
    pub signer: Signer<'info>,

    pub system_program: Program<'info, System>,
}

#[account]
pub struct MyStorage {
    x: u64,
}

增加账户大小时,请确保设置 realloc::zero = false(在上面的代码中)如果你不希望擦除账户数据。如果你希望将账户数据设置为全零,请使用 realloc::zero = true。你无需更改测试。该宏将在幕后为你处理这一点。

练习: 在测试中初始化一个账户,然后调用 increase_account_size 函数。在命令行中查看账户大小 solana account <地址>。你需要在本地验证器上执行此操作,以便账户持久存在。

Solana 账户的最大大小

每次重新分配的最大账户大小增加量为 10240。在 Solana 中,账户的最大大小为 10 MB。

预估部署程序的成本

部署 Solana 程序的大部分成本来自为存储字节码支付租金。字节码存储在从 anchor deploy 返回的地址不同的账户中。

下面的截图显示了如何获取这些信息:

img

一个简单的 hello world 程序当前部署的成本略高于 2.47 SOL。通过编写原始的 Rust 代码而不是使用 Anchor 框架,可以显著降低成本,但在你完全了解 Anchor 默认消除的所有安全风险之前,我们不建议这样做。

通过 RareSkills 了解更多

查看我们的 Solana 开发者课程以获取更多信息。

在 Anchor 中读取账户余额:address(account).balance in Solana

更新日期:3 月 5 日

Solana get account balance

在 Anchor Rust 中读取账户余额

要在 Solana 程序内部读取地址的 Solana 余额,请使用以下代码:

use anchor_lang::prelude::*;

declare_id!("Gnf6u7S7fGJbqEGH9PuDE5Prq6f6ZrDxHY3jNJ4SYySQ");

#[program]
pub mod balance {
    use super::*;

    pub fn read_balance(ctx: Context<ReadBalance>) -> Result<()> {
        let balance = ctx.accounts.acct.to_account_info().lamports();

        msg!("balance in Lamports is {}", balance);
        Ok(())
    }
}

#[derive(Accounts)]
pub struct ReadBalance<'info> {
    /// CHECK: although we read this account's balance, we don't do anything with the information
    pub acct: UncheckedAccount<'info>,
}

以下是用于触发的 web3 js 代码:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Balance } from "../target/types/balance";

describe("balance", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.Balance as Program<Balance>;

	// the following is the Solana wallet we are using
  let pubkey = new anchor.web3.PublicKey("5jmigjgt77kAfKsHri3MHpMMFPo6UuiAMF19VdDfrrTj");


  it("Tests the balance", async () => {
    const tx = await program.methods.readBalance().accounts({ acct: pubkey }).rpc();
  });
});

在此示例中,有些项目与先前的教程不同,特别是使用 UncheckedAccount

什么是 Solana Anchor 中的 UncheckedAccount?

UncheckedAccount 类型告诉 Anchor 不要检查要读取的账户是否由程序拥有。

请注意,我们通过 Context 结构传递的账户不是此程序初始化的账户,因此程序不拥有它。

当 Anchor 读取 #[derive(Accounts)] 中的 Account 类型账户时,它将(在幕后)检查该账户是否由该程序拥有。如果不是,则执行将停止。

这是一个重要的安全检查。

如果恶意用户制作了程序未创建的账户,然后将其传递给 Solana 程序,并且 Solana 程序盲目地信任账户中的数据,可能会发生严重错误。

例如,如果程序是一个银行,账户存储用户的余额,那么黑客可以提供一个余额比实际余额高的不同账户。

然而,要实施这种黑客攻击,用户必须在单独的交易中创建虚假账户,然后将其传递给 Solana 程序。然而,Anchor 框架在幕后检查账户是否不属于该程序,并拒绝读取该账户。

UncheckedAccount 可以绕过此安全检查。

重要提示: AccountInfoUncheckedAccount 是彼此的别名,AccountInfo 具有相同的安全考虑。

在我们的示例中,我们传递的账户肯定不是程序拥有的账户 — 我们要检查任意账户的余额。因此,我们必须确保删除此安全检查后不会有任何关键逻辑被篡改。

在我们的示例中,我们只是将余额记录到控制台,但大多数真实用例将具有更复杂的逻辑。

什么是 /// CHECK:

由于使用 UncheckedAccount 的危险性,Anchor 强制你包含此注释以鼓励你不要忽视安全考虑。

练习: 删除 /// Check: 注释并运行 anchor build,你应该看到构建停止并要求你添加注释并解释为什么 Unchecked Account 是安全的。也就是说,读取不受信任的账户可能是危险的,Anchor 希望确保你不会对账户中的数据执行任何关键操作。

为什么程序中没有 #[account] 结构体?

#[account] 结构体告诉 Anchor 如何反序列化持有数据的账户。例如,类似以下内容的账户结构体将告诉 Anchor 应该将存储在账户中的数据反序列化为单个 u64

#[account]
pub struct Counter {
    counter: u64
}

然而,在我们的案例中,我们不是从账户中读取数据 — 我们只是读取余额。这类似于我们如何可以读取以太坊地址的余额,但不读取其代码。由于我们想反序列化数据,因此我们不提供 #[account] 结构体。

账户中的所有 SOL 都是可花费的

回想一下我们对 Solana 账户租金 的讨论,账户必须保持一定数量的 SOL 余额才能“免租”否则运行时将删除该账户。账户中有“1 SOL”并不一定意味着账户可以花费全部 1 SOL。

例如,如果你正在构建一个存款或银行应用程序,用户存入的 SOL 保留在单独的账户中,仅仅测量这些账户的 SOL 余额并不准确,因为租金将包含在余额中。

通过 RareSkills 了解更多

查看我们的 Solana 开发者课程 获取更多 Solana 资料。

Solana 中的函数修饰符(view、pure、payable)和回退函数:为什么它们不存在

Solana 中的 view、pure、payable、fallback 和 receive

Solana 没有回退或 receive 函数

Solana 交易必须预先指定作为交易一部分将修改或读取的账户。如果“回退”函数访问不确定的账户,整个交易将失败。这将使用户需要预料回退函数将访问的账户。因此,简单地禁止这类函数会更简单。

Solana 没有“view”或“pure”函数的概念

Solidity 中的“view”函数通过两种机制创建一个保证状态不会改变的保证:

  • 视图函数中的所有外部调用都是静态调用 (如果发生状态更改,则调用将回滚)
  • 如果编译器检测到更改状态的操作码,则会抛出错误

纯函数通过编译器检查是否有查看状态的操作码来进一步实现这一点。

这些函数限制主要发生在编译器级别,Anchor 不实现这些编译器检查。Anchor 并不是构建 Solana 程序的唯一框架。Seahorse 是另一个框架。也许会出现另一个框架,明确声明函数可以做什么和不能做什么,但目前我们可以依赖以下保证:如果一个账户未包含在 Context 结构定义中,该函数将不会访问该账户。

这并意味着账户根本无法访问。例如,我们可以编写一个单独的程序来读取一个账户,并以某种方式将数据转发给相关函数。

最后,在 Solana 虚拟机或运行时中并不存在staticcall这样的东西。

Solana 中并不需要视图函数

因为 Solana 程序可以读取传递给它的任何账户,它可以读取另一个程序拥有的账户。

拥有账户的程序不需要实现视图函数来授予另一个程序查看该账户的访问权限。web3 js 客户端 — 或另一个程序 — 可以直接查看 Solana 账户数据

这有一个非常重要的含义:

在 Solana 中,不可能使用递归锁直接防御只读递归。程序必须公开标志,以便读者知道数据是否可靠。

只读递归发生在受害合约访问显示被篡改数据的视图函数时。在 Solidity 中,可以通过向视图函数添加 nonReentrant 修饰符来防御这种情况。然而,在 Solana 中,没有办法阻止另一个程序查看账户中的数据。

但是,Solana 程序仍然可以实现用于检查递归锁使用的标志。消费另一个程序的账户的程序可以检查这些标志,以查看账户当前是否处于递归状态,不应信任该账户。

Rust 中没有自定义修饰符

onlyOwnernonReentrant这样的自定义修饰符是 Solidity 的创造物,而不是 Rust 中可用的功能。

Rust 或 Anchor 中没有自定义单位

因为 Solidity 与 Ethereum 紧密相关,它具有方便的关键字,如etherswei来衡量以太坊。不足为奇的是,在 Rust 中未定义LAMPORTS_PER_SOL,但有些令人惊讶的是,在 Anchor Rust 框架中也未定义。然而,在 Solana web3 js 库中是可用的。

类似地,Solidity 中有days作为 84,600 秒的便捷别名,但在 Rust/Anchor 中没有相对应的。

Solana 中不存在“可支付”函数。程序从用户那里转移 SOL,用户不会向程序转移 SOL

这是下一个教程的主题。

通过 RareSkills 了解更多 Solana

查看我们的 Solana 开发课程以获取下一章

转移 SOL 并构建支付分割器:Solana 中的 "msg.value"

img

本教程将介绍 Solana Anchor 程序如何将 SOL 作为交易的一部分进行转移的机制。

与以太坊不同,以太坊钱包在交易中指定 msg.value 并将 ETH “推送”到合约,而 Solana 程序则是从钱包“拉取” Solana。

因此,Solana 中没有“payable”函数或“msg.value”。

下面我们创建了一个名为 sol_splitter 的新 Anchor 项目,并放置了 Rust 代码以将 SOL 从发送方转移到接收方。

当然,如果发送方直接发送 SOL 而不通过程序进行操作会更有效,但我们想说明如何操作:

use anchor_lang::prelude::*;
use anchor_lang::system_program;

declare_id!("9qnGx9FgLensJQy1hSB4b8TaRae6oWuNDveUrxoYatr7");

#[program]
pub mod sol_splitter {
    use super::*;

    pub fn send_sol(ctx: Context<SendSol>, amount: u64) -> Result<()> {

        let cpi_context = CpiContext::new(
            ctx.accounts.system_program.to_account_info(), 

            system_program::Transfer {
                from: ctx.accounts.signer.to_account_info(),
                to: ctx.accounts.recipient.to_account_info(),
            }
        );

        let res = system_program::transfer(cpi_context, amount);

        if res.is_ok() {
            return Ok(());
        } else {
            return err!(Errors::TransferFailed);
        }
    }
}

#[error_code]
pub enum Errors {
    #[msg("transfer failed")]
    TransferFailed,
}

#[derive(Accounts)]
pub struct SendSol<'info> {
    /// CHECK: we do not read or write the data of this account
    #[account(mut)]
    recipient: UncheckedAccount<'info>,
    
    system_program: Program<'info, System>,

    #[account(mut)]
    signer: Signer<'info>,
}

这里有很多需要解释的地方。

介绍 CPI:跨程序调用

在以太坊中,通过在 msg.value 字段中指定一个值来转移 ETH。在 Solana 中,一个名为 system program 的内置程序将 SOL 从一个账户转移到另一个账户。这就是为什么在我们初始化账户时一直有它的身影,并且必须支付费用来初始化这些账户。

你可以粗略地将系统程序视为以太坊中的预编译。想象一下,它的行为有点像内置在协议中的 ERC-20 代币,用作原生货币。它有一个名为 transfer 的公共函数。

CPI 交易的上下文

每当调用 Solana 程序函数时,都必须提供一个 Context。该 Context 包含程序将交互的所有账户。

调用系统程序也不例外。系统程序需要一个包含 fromto 账户的 Context。要转移的 amount 作为“常规”参数传递 —— 它不是 Context 的一部分(因为“amount”不是一个账户,它只是一个值)。

现在我们可以解释下面的代码片段:

CpiContext

我们正在构建一个新的 CpiContext,它将第一个参数作为我们将要调用的程序(绿色框),以及作为该交易一部分的账户(黄色框)。这里没有提供参数 amount,因为 amount 不是一个账户。

现在我们已经构建了我们的 cpi_context,我们可以执行一个跨程序调用到系统程序(橙色框)同时指定金额。

这将返回一个 Result<()> 类型,就像我们的 Anchor 程序上的公共函数一样。

不要忽略跨程序调用的返回值。

要检查跨程序调用是否成功,我们只需要检查返回的值是否为 Ok。Rust 使用 is_ok() 方法使这一过程变得简单:

error return of Solana CPI

只有签名者可以作为“from”

如果你使用不是 Signer 的账户作为 from 调用系统程序,则系统程序将拒绝该调用。没有签名,系统程序无法知道你是否授权了该调用。

Typescript 代码:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { SolSplitter } from "../target/types/sol_splitter";

describe("sol_splitter", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.SolSplitter as Program<SolSplitter>;

  async function printAccountBalance(account) {
    const balance = await anchor.getProvider().connection.getBalance(account);
    console.log(`${account} has ${balance / anchor.web3.LAMPORTS_PER_SOL} SOL`);
  }

  it("Transmit SOL", async () => {
    // generate a new wallet
    const recipient = anchor.web3.Keypair.generate();

    await printAccountBalance(recipient.publicKey);

    // send the account 1 SOL via the program
    let amount = new anchor.BN(1 * anchor.web3.LAMPORTS_PER_SOL);
    await program.methods.sendSol(amount)
      .accounts({recipient: recipient.publicKey})
      .rpc();

    await printAccountBalance(recipient.publicKey);
  });
});

需要注意的一些事项:

  • 我们创建了一个辅助函数 printAccountBalance 来显示接收者地址的余额
  • 我们使用 anchor.web3.Keypair.generate() 生成了接收者钱包
  • 我们将一个 SOL 转移到了新账户

当我们运行代码时,预期结果如下。打印语句是接收者地址的余额变化前后:

SOL transfer test

练习: 构建一个 Solana 程序,将接收到的 SOL 平均分配给两个接收者。你无法通过函数参数完成此操作,账户需要在 Context 结构中。

构建支付分割器:使用 remaining_accounts 处理任意数量的账户

我们可以看到,如果我们想要在多个账户之间分配 SOL,需要指定一个像下面这样的 Context 结构会相当笨拙:

#[derive(Accounts)]
pub struct SendSol<'info> {
    /// CHECK: we do not read or write the data of this account
    #[account(mut)]
    recipient1: UncheckedAccount<'info>,

    /// CHECK: we do not read or write the data of this account
    #[account(mut)]
    recipient2: UncheckedAccount<'info>,

    /// CHECK: we do not read or write the data of this account
    #[account(mut)]
    recipient3: UncheckedAccount<'info>,

		// ...

    /// CHECK: we do not read or write the data of this account
    #[account(mut)]
    recipientn: UncheckedAccount<'info>,
    
    system_program: Program<'info, System>,

    #[account(mut)]
    signer: Signer<'info>,
}

为了解决这个问题,Anchor 在 Context 结构中添加了一个 remaining_accounts 字段。

下面的代码演示了如何使用该功能:

use anchor_lang::prelude::*;
use anchor_lang::system_program;

declare_id!("9qnGx9FgLensJQy1hSB4b8TaRae6oWuNDveUrxoYatr7");

#[program]
pub mod sol_splitter {
    use super::*;

		// 'a, 'b, 'c are Rust lifetimes, ignore them for now
    pub fn split_sol<'a, 'b, 'c, 'info>(
        ctx: Context<'a, 'b, 'c, 'info, SplitSol<'info>>,
        amount: u64,
    ) -> Result<()> {

        let amount_each_gets = amount / ctx.remaining_accounts.len() as u64;
        let system_program = &ctx.accounts.system_program;

				// note the keyword `remaining_accounts`
        for recipient in ctx.remaining_accounts {
            let cpi_accounts = system_program::Transfer {
                from: ctx.accounts.signer.to_account_info(),
                to: recipient.to_account_info(),
            };
            let cpi_program = system_program.to_account_info();
            let cpi_context = CpiContext::new(cpi_program, cpi_accounts);

            let res = system_program::transfer(cpi_context, amount_each_gets);
            if !res.is_ok() {
                return err!(Errors::TransferFailed);
            }
        }

        Ok(())
    }
}

#[error_code]
pub enum Errors {
    #[msg("transfer failed")]
    TransferFailed,
}

#[derive(Accounts)]
pub struct SplitSol<'info> {
    #[account(mut)]
    signer: Signer<'info>,
    system_program: Program<'info, System>,
}

以下是 Typescript 代码:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { SolSplitter } from "../target/types/sol_splitter";

describe("sol_splitter", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.SolSplitter as Program<SolSplitter>;

  async function printAccountBalance(account) {
    const balance = await anchor.getProvider().connection.getBalance(account);
    console.log(`${account} has ${balance / anchor.web3.LAMPORTS_PER_SOL} SOL`);
  }

  it("Split SOL", async () => {
    const recipient1 = anchor.web3.Keypair.generate();
    const recipient2 = anchor.web3.Keypair.generate();
    const recipient3 = anchor.web3.Keypair.generate();

    await printAccountBalance(recipient1.publicKey);
    await printAccountBalance(recipient2.publicKey);
    await printAccountBalance(recipient3.publicKey);

    const accountMeta1 = {pubkey: recipient1.publicKey, isWritable: true, isSigner: false};
    const accountMeta2 = {pubkey: recipient2.publicKey, isWritable: true, isSigner: false};
    const accountMeta3 = {pubkey: recipient3.publicKey, isWritable: true, isSigner: false};

    let amount = new anchor.BN(1 * anchor.web3.LAMPORTS_PER_SOL);
    await program.methods.splitSol(amount)
      .remainingAccounts([accountMeta1, accountMeta2, accountMeta3])
      .rpc();

    await printAccountBalance(recipient1.publicKey);
    await printAccountBalance(recipient2.publicKey);
    await printAccountBalance(recipient3.publicKey);
  });
});

运行测试显示了转移前后的余额:

Solana test before and after transfer

以下是有关 Rust 代码的一些评论:

Rust 生命周期

split_sol 函数声明引入了一些奇怪的语法:

pub fn split_sol<'a, 'b, 'c, 'info>(
    ctx: Context<'a, 'b, 'c, 'info, SplitSol<'info>>,
    amount: u64,
) -> Result<()>

'a'b'c 是 Rust 生命周期。Rust 生命周期是一个复杂的主题,我们现在最好避免讨论。但是,高层次的解释是,Rust 代码需要确保传递到循环 for recipient in ctx.remaining_accounts 中的资源将在整个循环中存在。

ctx.remaining_accounts

循环遍历 for recipient in ctx.remaining_accounts。关键字 remaining_accounts 是 Anchor 传递任意数量账户的机制,而无需在 Context 结构中创建一堆密钥。

在 Typescript 测试中,我们可以像这样将 remaining_accounts 添加到交易中:

await program.methods.splitSol(amount)
  .remainingAccounts([accountMeta1, accountMeta2, accountMeta3])
  .rpc();

通过 RareSkills 了解更多

查看我们的 Solana 课程 以获取 Solana 教程的其余部分。

使用不同签名者修改账户

更新日期:3 月 11 日

Anchor Signer

到目前为止,在我们的 Solana 教程中,我们只初始化并向账户写入了一个账户。

实际上,这是非常受限制的。例如,如果用户 Alice 正在向 Bob 转移积分,Alice 必须能够向由用户 Bob 初始化的账户写入。

在本教程中,我们将演示使用一个钱包初始化一个账户,然后使用另一个钱包更新它。

初始化步骤

我们一直在使用的用于初始化账户的 Rust 代码没有变化:

use anchor_lang::prelude::*;
use std::mem::size_of;

declare_id!("61As9Y8pREgvFZzps6rpFai8UkageeHT6kW1dnGRiefb");

#[program]
pub mod other_write {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(init,
              payer = signer,
              space=size_of::<MyStorage>() + 8,
              seeds = [],
              bump)]
    pub my_storage: Account<'info, MyStorage>,

    #[account(mut)]
    pub signer: Signer<'info>,

    pub system_program: Program<'info, System>,
}

#[account]
pub struct MyStorage {
    x: u64,
}

使用另一个钱包进行初始化交易

然而,在客户端代码中有一个重要的变化:

  • 为了测试目的,我们创建了一个名为newKeypair的新钱包。这与 Anchor 默认提供的钱包不同。
  • 我们向该新钱包空投 1 SOL,以便它可以支付交易费用。
  • 注意注释// THIS MUST BE EXPLICITLY SPECIFIED。我们将该钱包的公钥传递给Signer字段。当我们使用 Anchor 内置的默认签名者时,Anchor 会在后台为我们传递这个。但是,当我们使用不同的钱包时,我们需要明确提供这个。
  • 我们将签名者设置为newKeypair,使用.signers([newKeypair])配置。

我们将在这段代码片段之后解释为什么我们(表面上)要指定签名者两次:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { OtherWrite } from "../target/types/other_write";

// this airdrops sol to an address
async function airdropSol(publicKey, amount) {
  let airdropTx = await anchor.getProvider().connection.requestAirdrop(publicKey, amount);
  await confirmTransaction(airdropTx);
}

async function confirmTransaction(tx) {
  const latestBlockHash = await anchor.getProvider().connection.getLatestBlockhash();
  await anchor.getProvider().connection.confirmTransaction({
    blockhash: latestBlockHash.blockhash,
    lastValidBlockHeight: latestBlockHash.lastValidBlockHeight,
    signature: tx,
  });
}

describe("other_write", () => {
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.OtherWrite as Program<OtherWrite>;

  it("Is initialized!", async () => {
    const newKeypair = anchor.web3.Keypair.generate();
    await airdropSol(newKeypair.publicKey, 1e9); // 1 SOL

    let seeds = [];
    const [myStorage, _bump] = anchor.web3.PublicKey.findProgramAddressSync(seeds, program.programId);
    
    await program.methods.initialize().accounts({
      myStorage: myStorage,
      signer: newKeypair.publicKey // ** THIS MUST BE EXPLICITLY SPECIFIED **
    }).signers([newKeypair]).rpc();
  });
});

Anchor 不要求将键signer称为signer

练习: 在 Rust 代码中,将payer = signer更改为payer = fren,将pub signer: Signer<'info>更改为pub fren: Signer<'info>,并在测试中将signer: newKeypair.publicKey更改为fren: newKeypair.publicKey。初始化应该成功,测试应该通过。

为什么 Anchor 需要指定签名者和公钥?

起初,我们似乎是在重复指定签名者两次,但让我们仔细看一下:

Anchor 中的签名者类型

在红框中,我们看到fren字段被指定为一个签名者账户。Signer类型意味着 Anchor 将查看交易的签名,并确保签名与此处传递的地址匹配。

稍后我们将看到如何使用这一点来验证签名者是否被授权执行某个交易。

Anchor 一直在幕后做这件事,但由于我们传入了一个除了 Anchor 默认使用的签名者之外的Signer,我们必须明确指定Signer是哪个账户。

错误:Solana Anchor 中的未知签名者

当交易的签名者与传递给Signer的公钥不匹配时,会出现unknown signer错误。

假设我们修改测试以删除.signers([newKeypair])规范。Anchor 将使用默认签名者,而默认签名者将不匹配我们的newKeypair钱包的publicKey

使用默认签名者,将另一个密钥对作为签名者

我们将收到以下错误:

签名验证失败

同样,如果我们不显式传递公钥,Anchor 将悄悄使用默认签名者:

使用不同的密钥对作为签名者,但使用默认签名者地址作为公钥

然后我们将收到以下错误:未知签名者:

错误:未知签名者

有点误导,Anchor 并不是说签名者未知,因为它没有被明确指定。Anchor 能够确定如果没有指定签名者,那么它将使用默认签名者。如果我们同时删除.signers([newKeypair])代码和fren: newKeypair.publicKey代码,则 Anchor 将对公钥进行检查使用默认签名者,并验证签名者的签名是否与公钥匹配。

以下代码将导致初始化成功,因为Signer公钥和签署交易的账户都是 Anchor 默认签名者。

使用默认签名者进行初始化

使用默认签名者进行初始化测试通过

Bob 可以向 Alice 初始化的账户写入

下面展示了一个包含初始化账户和向其写入的功能的 Anchor 程序。

这将与我们的 Solana 计数器程序教程中熟悉,但请注意在底部附近的// THIS FIELD MUST BE INCLUDED注释标记的小添加:

use anchor_lang::prelude::*;
use std::mem::size_of;

declare_id!("61As9Y8pREgvFZzps6rpFai8UkageeHT6kW1dnGRiefb");

#[program]
pub mod other_write {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }

    pub fn update_value(ctx: Context<UpdateValue>, new_value: u64) -> Result<()> {
        ctx.accounts.my_storage.x = new_value;
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(init,
              payer = fren,
              space=size_of::<MyStorage>() + 8,
              seeds = [],
              bump)]
    pub my_storage: Account<'info, MyStorage>,

    #[account(mut)]
    pub fren: Signer<'info>, // A public key is passed here

    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct UpdateValue<'info> {
    #[account(mut, seeds = [], bump)]
    pub my_storage: Account<'info, MyStorage>,

	// THIS FIELD MUST BE INCLUDED
    #[account(mut)]
    pub fren: Signer<'info>,
}

#[account]
pub struct MyStorage {
    x: u64,
}

以下客户端代码将为 Alice 和 Bob 创建一个钱包,并向他们每人空投 1 SOL。Alice 将初始化账户MyStorage,而 Bob 将向其写入:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { OtherWrite } from "../target/types/other_write";

// this airdrops sol to an address
async function airdropSol(publicKey, amount) {
  let airdropTx = await anchor.getProvider().connection.requestAirdrop(publicKey, amount);
  await confirmTransaction(airdropTx);
}

async function confirmTransaction(tx) {
  const latestBlockHash = await anchor.getProvider().connection.getLatestBlockhash();
  await anchor.getProvider().connection.confirmTransaction({
    blockhash: latestBlockHash.blockhash,
    lastValidBlockHeight: latestBlockHash.lastValidBlockHeight,
    signature: tx,
  });
}

describe("other_write", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.OtherWrite as Program<OtherWrite>;

  it("Is initialized!", async () => {
    const alice = anchor.web3.Keypair.generate();
    const bob = anchor.web3.Keypair.generate();

    const airdrop_alice_tx = await anchor.getProvider().connection.requestAirdrop(alice.publicKey, 1 * anchor.web3.LAMPORTS_PER_SOL);
    await confirmTransaction(airdrop_alice_tx);

    const airdrop_alice_bob = await anchor.getProvider().connection.requestAirdrop(bob.publicKey, 1 * anchor.web3.LAMPORTS_PER_SOL);
    await confirmTransaction(airdrop_alice_bob);

    let seeds = [];
    const [myStorage, _bump] = anchor.web3.PublicKey.findProgramAddressSync(seeds, program.programId);
    
    // ALICE INITIALIZE ACCOUNT
    await program.methods.initialize().accounts({
      myStorage: myStorage,
      fren: alice.publicKey
    }).signers([alice]).rpc();

    // BOB WRITE TO ACCOUNT
    await program.methods.updateValue(new anchor.BN(3)).accounts({
      myStorage: myStorage,
      fren: bob.publicKey
    }).signers([bob]).rpc();

    let value = await program.account.myStorage.fetch(myStorage);
    console.log(`value stored is ${value.x}`);
  });
});

限制对 Solana 账户的写入

在实际应用中,我们不希望 Bob 向任意账户写入任意数据。让我们创建一个基本示例,用户可以使用 10 个积分初始化一个账户,并将这些积分转移到另一个账户。(显然,黑客可以使用不同的钱包创建任意多的账户,但这超出了我们示例的范围)。

构建原型 ERC20 程序

Alice 应该能够修改她自己的账户和 Bob 的账户。也就是说,她应该能够扣除自己的积分并向 Bob 增加积分。她不应该能够扣除 Bob 的积分 — 只有 Bob 才能做到这一点。

按照惯例,在 Solana 中,我们将可以对账户进行特权更改的地址称为“授权者”。在账户结构中存储“授权者”字段是一种常见模式,表示只有该账户才能对该账户执行敏感操作(例如在我们的示例中扣除积分)。

这在某种程度上类似于 Solidity 中的 onlyOwner 模式 ,不同之处在于它不适用于整个合约,而是仅适用于单个账户:

use anchor_lang::prelude::*;
use std::mem::size_of;

declare_id!("HFmGQX4wPgPYVMFe4WrBi925NKvGySrEG2LGyRXsXJ4Z");

const STARTING_POINTS: u32 = 10;

#[program]
pub mod points {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        ctx.accounts.player.points = STARTING_POINTS;
        ctx.accounts.player.authority = ctx.accounts.signer.key();
        Ok(())
    }

    pub fn transfer_points(ctx: Context<TransferPoints>,
                           amount: u32) -> Result<()> {
        require!(ctx.accounts.from.authority == ctx.accounts.signer.key(),
								 Errors::SignerIsNotAuthority);
        require!(ctx.accounts.from.points >= amount,
                 Errors::InsufficientPoints);
        
        ctx.accounts.from.points -= amount;
        ctx.accounts.to.points += amount;
        Ok(())
    }
}

#[error_code]
pub enum Errors {
    #[msg("SignerIsNotAuthority")]
    SignerIsNotAuthority,
    #[msg("InsufficientPoints")]
    InsufficientPoints
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(init,
              payer = signer,
              space = size_of::<Player>() + 8,
              seeds = [&(signer.as_ref().key().to_bytes())],
              bump)]
    player: Account<'info, Player>,
    #[account(mut)]
    signer: Signer<'info>,
    system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct TransferPoints<'info> {
    #[account(mut)]
    from: Account<'info, Player>,
    #[account(mut)]
    to: Account<'info, Player>,
    #[account(mut)]
    signer: Signer<'info>,
}

#[account]
pub struct Player {
    points: u32,
    authority: Pubkey
}

请注意,我们使用签名者的地址(&(signer.as_ref().key().to_bytes()))来派生存储其积分的账户地址。这类似于 Solana 中的 Solidity 映射,其中 Solana "msg.sender / tx.origin" 是键。

initialize函数中,程序将初始积分设置为10,并将授权者设置为signer。用户无法控制这些初始值。

transfer_points函数使用 Solana Anchor require 宏和错误代码宏来确保:1)交易的签名者是正在扣除余额的账户的授权者;2)账户有足够的积分余额进行转移。

测试代码库应该很容易理解。Alice 和 Bob 初始化他们的账户,然后 Alice 将 5 个积分转移到 Bob:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Points } from "../target/types/points";

// this airdrops sol to an address
async function airdropSol(publicKey, amount) {
  let airdropTx = await anchor.getProvider().connection.requestAirdrop(publicKey, amount);
  await confirmTransaction(airdropTx);
}

async function confirmTransaction(tx) {
  const latestBlockHash = await anchor.getProvider().connection.getLatestBlockhash();
  await anchor.getProvider().connection.confirmTransaction({
    blockhash: latestBlockHash.blockhash,
    lastValidBlockHeight: latestBlockHash.lastValidBlockHeight,
    signature: tx,
  });
}

describe("points", () => {
  anchor.setProvider(anchor.AnchorProvider.env());
  const program = anchor.workspace.Points as Program<Points>;


  it("Alice transfers points to Bob", async () => {
    const alice = anchor.web3.Keypair.generate();
    const bob = anchor.web3.Keypair.generate();

    const airdrop_alice_tx = await anchor.getProvider().connection.requestAirdrop(alice.publicKey, 1 * anchor.web3.LAMPORTS_PER_SOL);
    await confirmTransaction(airdrop_alice_tx);

    const airdrop_alice_bob = await anchor.getProvider().connection.requestAirdrop(bob.publicKey, 1 * anchor.web3.LAMPORTS_PER_SOL);
    await confirmTransaction(airdrop_alice_bob);

    let seeds_alice = [alice.publicKey.toBytes()];
    const [playerAlice, _bumpA] = anchor.web3.PublicKey.findProgramAddressSync(seeds_alice, program.programId);

    let seeds_bob = [bob.publicKey.toBytes()];
    const [playerBob, _bumpB] = anchor.web3.PublicKey.findProgramAddressSync(seeds_bob, program.programId);

    // Alice and Bob initialize their accounts
    await program.methods.initialize().accounts({
      player: playerAlice,
      signer: alice.publicKey,
    }).signers([alice]).rpc();

    await program.methods.initialize().accounts({
      player: playerBob,
      signer: bob.publicKey,
    }).signers([bob]).rpc();

    // Alice transfers 5 points to Bob. Note that this is a u32
    // so we don't need a BigNum
    await program.methods.transferPoints(5).accounts({
      from: playerAlice,
      to: playerBob,
      signer: alice.publicKey,
    }).signers([alice]).rpc();

    console.log(`Alice has ${(await program.account.player.fetch(playerAlice)).points} points`);
    console.log(`Bob has ${(await program.account.player.fetch(playerBob)).points} points`)
  });
});

练习: 创建一个密钥对mallory,并尝试使用mallory作为.signers([mallory])中的签名者来从 Alice 或 Bob 那里窃取积分。你的攻击应该失败,但你应该尝试。

使用 Anchor 约束替换 require!宏

一个替代方法是编写require!(ctx.accounts.from.authority == ctx.accounts.signer.key(), Errors::SignerIsNotAuthority);是使用 Anchor 约束。Anchor 账户文档为我们提供了可用的约束列表。

Anchor has_one约束

has_one约束假定#[derive(Accounts)]和#[account]之间存在“共享键”,并检查这两个键是否具有相同的值。最好的方法是通过图片来演示:

Anchor has_one 约束

在幕后,如果作为交易的一部分传递的authority账户(作为Signer)不等于存储在账户中的authority,Anchor 将阻止该交易。

在我们上面的实现中,我们在账户中使用了键authority,并在#[derive(Accounts)]中使用了signer。这种键名称不匹配将阻止此宏的工作,因此上面的代码将键signer更改为authorityAuthority不是一个特殊关键字,仅仅是一个约定。你可以尝试将所有authority实例更改为fren,代码将仍然正常工作。

Anchor constraint约束

我们还可以使用 Anchor 约束来替换宏require!(ctx.accounts.from.points >= amount, Errors::InsufficientPoints);

约束宏允许我们对传递给交易的账户和账户中的数据施加任意约束。在我们的情况下,我们希望确保发送方有足够的积分:

#[derive(Accounts)]
#[instruction(amount: u32)] // amount must be passed as an instruction
pub struct TransferPoints<'info> {
    #[account(mut,
              has_one = authority,
              constraint = from.points >= amount)]
    from: Account<'info, Player>,
    #[account(mut)]
    to: Account<'info, Player>,
    authority: Signer<'info>,
}

#[account]
pub struct Player {
    points: u32,
    authority: Pubkey
}

该宏足够智能,可以识别from基于传递给from键的账户,并且该账户具有points字段。transfer_points函数参数中的amount必须通过instruction宏传递,以便constraint宏可以将amount与账户中的积分余额进行比较。

向 Anchor 约束添加自定义错误消息

通过使用@符号添加自定义错误,我们可以改善违反约束时的错误消息的可读性,就像我们在require!宏中使用的自定义错误一样:

#[derive(Accounts)]
#[instruction(amount: u32)]
pub struct TransferPoints<'info> {
    #[account(mut,
              has_one = authority @ Errors::SignerIsNotAuthority,
              constraint = from.points >= amount @ Errors::InsufficientPoints)]
    from: Account<'info, Player>,
    #[account(mut)]
    to: Account<'info, Player>,
    authority: Signer<'info>,
}

#[account]
pub struct Player {
    points: u32,
    authority: Pubkey
}

Errors枚举在之前的 Rust 代码中定义了它们,并在require!宏中使用了它们。

练习: 修改测试以违反has_oneconstraint宏,并观察错误消息。

通过 RareSkills 了解更多 Solana 知识

我们的 Solana 教程介绍了如何作为以太坊或 EVM 开发人员学习 Solana。

PDA(程序派生地址)与 Solana 中的密钥对账户

更新日期:3 月 11 日

Solana PDA

程序派生地址(PDA)是一个账户,其地址是从创建它的程序的地址和传递给 init 事务的 seeds 派生而来的。直到目前为止,我们只使用了 PDAs。

也可以在程序外部创建一个账户,然后在程序内部进行 init

有趣的是,我们在程序外部创建的账户将拥有一个私钥,但我们将看到这并不会产生看似会有的安全影响。我们将称之为“密钥对账户”。

账户创建再探讨

在深入研究密钥对账户之前,让我们回顾一下迄今为止在我们的 Solana 教程 中如何创建账户。这是我们一直在使用的相同样板文件,它创建了程序派生地址(PDA):

use anchor_lang::prelude::*;
use std::mem::size_of; 

declare_id!("4wLnxvLwgXGT4eNg3D456K6Fxa1RieaUdERSPQ3WEpuV");

#[program]
pub mod keypair_vs_pda {
    use super::*;

    pub fn initialize_pda(ctx: Context<InitializePDA>) -> Result<()> {
        Ok(())
    }
}

#[derive(Accounts)]
pub struct InitializePDA<'info> {

    // This is the program derived address
    #[account(init,
              payer = signer,
              space=size_of::<MyPDA>() + 8,
              seeds = [],
              bump)]
    pub my_pda: Account<'info, MyPDA>,

    #[account(mut)]
    pub signer: Signer<'info>,

    pub system_program: Program<'info, System>,
}

#[account]
pub struct MyPDA {
    x: u64,
}

以下是调用 initialize 的相关 Typescript 代码:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { KeypairVsPda } from "../target/types/keypair_vs_pda";

describe("keypair_vs_pda", () => {
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.KeypairVsPda as Program<KeypairVsPda>;

  it("Is initialized -- PDA version", async () => {
    const seeds = []
    const [myPda, _bump] = anchor.web3.PublicKey.findProgramAddressSync(seeds, program.programId);

    console.log("the storage account address is", myPda.toBase58());

    const tx = await program.methods.initializePda().accounts({myPda: myPda}).rpc();
  });
});

到目前为止,所有这些都应该很熟悉,只是我们明确地将我们的账户称为“PDA”。

程序派生地址

如果账户的地址是从程序的地址派生而来的,即在 findProgramAddressSync(seeds, program.programId) 中的 programId,那么该账户就是程序派生地址(PDA)。它也是 seeds 的一个函数。

具体来说,我们知道它是一个 PDA,因为 seedsbumpinit 宏中存在。

密钥对账户

以下代码看起来与上面的代码非常相似,但请注意 init 宏缺少 seedsbump

use anchor_lang::prelude::*;
use std::mem::size_of;

declare_id!("4wLnxvLwgXGT4eNg3D456K6Fxa1RieaUdERSPQ3WEpuV");

#[program]
pub mod keypair_vs_pda {
    use super::*;

    pub fn initialize_keypair_account(ctx: Context<InitializeKeypairAccount>) -> Result<()> {
        Ok(())
    }
}

#[derive(Accounts)]
pub struct InitializeKeypairAccount<'info> {
		// This is the program derived address
    #[account(init,
              payer = signer,
              space = size_of::<MyKeypairAccount>() + 8,)]
    pub my_keypair_account: Account<'info, MyKeypairAccount>,

    #[account(mut)]
    pub signer: Signer<'info>,

    pub system_program: Program<'info, System>,
}

#[account]
pub struct MyKeypairAccount {
    x: u64,
}

当缺少 seedbump 时,Anchor 程序现在期望我们首先创建一个账户,然后将该账户传递给程序。由于我们自己创建了账户,其地址将不会“派生自”程序的地址。换句话说,它将不是程序派生账户(PDA)。

为程序创建一个账户就像生成一个新的密钥对一样简单(就像我们在 Anchor 中测试不同签名者 时使用的方式)。是的,这可能听起来有点可怕,因为我们持有程序用于存储数据的账户的私钥 — 我们稍后会再讨论这一点。现在,以下是创建一个新账户并将其传递给上面程序的 Typescript 代码。接下来我们将注意到重要部分:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { KeypairVsPda } from "../target/types/keypair_vs_pda";

// this airdrops sol to an address
async function airdropSol(publicKey, amount) {
  let airdropTx = await anchor.getProvider().connection.requestAirdrop(publicKey, amount * anchor.web3.LAMPORTS_PER_SOL);
  await confirmTransaction(airdropTx);
}

async function confirmTransaction(tx) {
  const latestBlockHash = await anchor.getProvider().connection.getLatestBlockhash();
  await anchor.getProvider().connection.confirmTransaction({
    blockhash: latestBlockHash.blockhash,
    lastValidBlockHeight: latestBlockHash.lastValidBlockHeight,
    signature: tx,
  });
}

describe("keypair_vs_pda", () => {
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.KeypairVsPda as Program<KeypairVsPda>;

  it("Is initialized -- keypair version", async () => {
		
    const newKeypair = anchor.web3.Keypair.generate();
    await airdropSol(newKeypair.publicKey, 1e9); // 1 SOL

    console.log("the keypair account address is", newKeypair.publicKey.toBase58());

    await program.methods.initializeKeypairAccount()
      .accounts({myKeypairAccount: newKeypair.publicKey})
      .signers([newKeypair]) // the signer must be the keypair
      .rpc();
  });
});

我们希望注意以下几点:

  • 我们添加了一个实用函数 airdropSol 来向我们创建的新密钥对 newKeypair 进行 airdrop SOL。没有 SOL,它将无法支付交易费用。此外,因为这也是将用于存储数据的账户,它需要一个 SOL 余额以免受租金豁免。当进行 SOL 空投时,需要额外的 confirmTransaction 程序,因为运行时似乎存在关于何时实际进行 SOL 空投和何时确认交易的竞争条件。
  • 我们将 signers 从默认值更改为 newKeypair。创建密钥对账户时,你无法创建你没有私钥的账户。

没有私钥的密钥对账户无法进行 initialize

如果你可以使用任意地址创建一个账户,那将是一个重大的安全风险,因为你可以向任意账户插入恶意数据。

练习: 修改测试以生成第二个密钥对 secondKeypair。使用第二个密钥对的公钥,并将 .accounts({myKeypairAccount: newKeypair.publicKey}) 替换为 .accounts({myKeypairAccount: secondKeypair.publicKey})。不要更改签名者。你应该看到测试失败。你无需向新密钥对进行 SOL 空投,因为它不是交易的签名者。

你应该看到如下错误:

密钥对账户初始化

如果我们尝试伪造 PDA 的地址会怎样?

练习: 在上面练习中,不要传入 secondKeypair,而是使用以下方式派生一个 PDA:

const seeds = []
const [pda, _bump] = anchor
                        .web3
                        .PublicKey
                        .findProgramAddressSync(
                            seeds,
                            program.programId);

然后将 myKeypairAccount 参数替换为 .accounts({myKeypairAccount: pda})

你应该再次看到一个 unknown signer 错误。

Solana 运行时不会允许你这样做。如果一个程序的 PDAs 突然出现而它们尚未被初始化,这将导致严重的安全问题。

拥有账户的私钥是否是一个问题?

似乎持有私钥的人将能够从账户中花费 SOL,并可能将其降至租金豁免阈值以下。但是,当账户由程序初始化时,Solana 运行时会阻止这种情况发生。

为了证实这一点,请考虑以下单元测试:

  • 在 Typescript 中创建一个密钥对账户
  • 向密钥对账户进行 SOL 空投
  • 从密钥对账户向另一个地址转移 SOL(成功)
  • 初始化密钥对账户
  • 尝试使用密钥对作为签名者从密钥对账户转移 SOL(失败)

以下是代码:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { KeypairVsPda } from "../target/types/keypair_vs_pda";

// Change this to your path
import privateKey from '/Users/RareSkills/.config/solana/id.json';

import { fs } from fs;

async function airdropSol(publicKey, amount) {
  let airdropTx = await anchor.getProvider().connection.requestAirdrop(publicKey, amount * anchor.web3.LAMPORTS_PER_SOL);
  await confirmTransaction(airdropTx);
}

async function confirmTransaction(tx) {
  const latestBlockHash = await anchor.getProvider().connection.getLatestBlockhash();
  await anchor.getProvider().connection.confirmTransaction({
    blockhash: latestBlockHash.blockhash,
    lastValidBlockHeight: latestBlockHash.lastValidBlockHeight,
    signature: tx,
  });
}


describe("keypair_vs_pda", () => {
  const deployer = anchor.web3.Keypair.fromSecretKey(Uint8Array.from(privateKey));

  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.KeypairVsPda as Program<KeypairVsPda>;

  it("Writing to keypair account fails", async () => {
    const newKeypair = anchor.web3.Keypair.generate();
    var recieverWallet = anchor.web3.Keypair.generate();

    await airdropSol(newKeypair.publicKey, 10);

    var transaction = new anchor.web3.Transaction().add(
      anchor.web3.SystemProgram.transfer({
        fromPubkey: newKeypair.publicKey,
        toPubkey: recieverWallet.publicKey,
        lamports: 1 * anchor.web3.LAMPORTS_PER_SOL,
      }),
    );
    await anchor.web3.sendAndConfirmTransaction(anchor.getProvider().connection, transaction, [newKeypair]);
    console.log('sent 1 lamport') 

    await program.methods.initializeKeypairAccount()
      .accounts({myKeypairAccount: newKeypair.publicKey})
      .signers([newKeypair]) // the signer must be the keypair
      .rpc();

  console.log("initialized");

  // try to transfer again, this fails
    var transaction = new anchor.web3.Transaction().add(
      anchor.web3.SystemProgram.transfer({
        fromPubkey: newKeypair.publicKey,
        toPubkey: recieverWallet.publicKey,
        lamports: 1 * anchor.web3.LAMPORTS_PER_SOL,
      }),
    );
    await anchor.web3.sendAndConfirmTransaction(anchor.getProvider().connection, transaction, [newKeypair]);
  });
});

以下是预期的错误消息:

无法向密钥对账户写入

即使我们持有该账户的私钥,但我们现在无法从该账户“花费 SOL”,因为它现在归程序所有。

拥有权和初始化简介

Solana 运行时如何知道在初始化后阻止 SOL 的转移?

练习: 修改测试为以下代码。注意已添加的控制台日志语句。它们记录了账户中的“owner”元数据字段和程序的地址:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { KeypairVsPda } from "../target/types/keypair_vs_pda";

import privateKey from '/Users/jeffreyscholz/.config/solana/id.json';


async function airdropSol(publicKey, amount) {
  let airdropTx = await anchor.getProvider().connection.requestAirdrop(publicKey, amount * anchor.web3.LAMPORTS_PER_SOL);
  await confirmTransaction(airdropTx);
}

async function confirmTransaction(tx) {
  const latestBlockHash = await anchor.getProvider().connection.getLatestBlockhash();
  await anchor.getProvider().connection.confirmTransaction({
    blockhash: latestBlockHash.blockhash,
    lastValidBlockHeight: latestBlockHash.lastValidBlockHeight,
    signature: tx,
  });
}


describe("keypair_vs_pda", () => {
  const deployer = anchor.web3.Keypair.fromSecretKey(Uint8Array.from(privateKey));

  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.KeypairVsPda as Program<KeypairVsPda>;
  it("Console log account owner", async () => {

    console.log(`The program address is ${program.programId}`) 
    const newKeypair = anchor.web3.Keypair.generate();
    var recieverWallet = anchor.web3.Keypair.generate();

		// get account owner before initialization
    await airdropSol(newKeypair.publicKey, 10);
		const accountInfoBefore = await anchor.getProvider().connection.getAccountInfo(newKeypair.publicKey);
		console.log(`initial keypair account owner is ${accountInfoBefore.owner}`);

    await program.methods.initializeKeypairAccount()
      .accounts({myKeypairAccount: newKeypair.publicKey})
      .signers([newKeypair]) // the signer must be the keypair
      .rpc();

		// get account owner after initialization
		const accountInfoAfter = await anchor.getProvider().connection.getAccountInfo(newKeypair.publicKey);
		console.log(`initial keypair account owner is ${accountInfoAfter.owner}`);
  });
});

以下截图显示了预期结果:

账户拥有权

初始化后,密钥对账户的所有者从 111...111 更改为部署的程序。我们尚未在我们的 Solana 教程 中深入讨论账户所有权或系统程序(全为 1 的地址)。但是,这应该让你了解“初始化”正在做什么以及为什么私钥的所有者不再能够将 SOL 转移出账户。

我应该使用 PDAs 还是密钥对账户?

一旦账户被初始化,它们的行为方式相同,因此实际上没有太大区别。

唯一显著的区别(这不会影响大多数应用程序)是 PDAs 只能以 10,240 字节的大小进行初始化,但密钥对账户可以初始化到完整的 10 MB 大小。但是,PDA 可以调整大小以达到 10 MB 的限制。

大多数应用程序使用 PDAs,因为它们可以通过 seeds 参数以编程方式寻址,但要访问密钥对账户,你必须事先知道地址。我们囊括密钥对账户的讨论,因为在线教程中有几个示例使用它们,所以我们希望你有一些背景知识。然而,在实践中,PDAs 是存储数据的首选方式。

通过 RareSkills 了解更多

继续学习我们的 Solana 课程

理解 Solana 中的账户所有权:将 SOL 转出 PDA

solana account owner

在 Solana 中,账户的所有者能够减少 SOL 余额、向账户写入数据以及更改所有者。

以下是 Solana 中账户所有权的摘要:

  1. 系统程序 拥有尚未分配所有权给程序(已初始化)的钱包和密钥对账户。
  2. BPFLoader 拥有程序。
  3. 程序拥有 Solana PDA。如果所有权已转移给程序,则它也可以拥有密钥对账户(这是在初始化期间发生的事情)。

现在我们来研究这些事实的影响。

系统程序拥有密钥对账户

为了说明这一点,让我们使用 Solana CLI 查看我们的 Solana 钱包地址并检查其元数据:

system program

请注意,所有者不是我们的地址,而是一个地址为 111…111 的账户。这是系统程序,与我们在早期教程中看到的那个移动 SOL 的系统程序相同。

只有账户的所有者才能修改其中的数据

这包括减少 lamport 数据(你无需是所有者即可增加另一个账户的 lamport 数据,我们稍后会看到)。

尽管你在某种形而上学意义上“拥有”你的钱包,但从 Solana 运行时的角度来看,你无法直接向其中写入数据或减少 lamport 余额,因为你不是所有者。

你之所以能够在你的钱包中花费 SOL,是因为你拥有生成该地址或公钥的私钥。当系统程序认识到你为公钥生成了有效签名时,它将认可你请求花费账户中的 lamports 是合法的,然后根据你的指示花费它们。

然而,系统程序并没有提供一个机制,让签名者直接向账户写入数据。

上面示例中显示的账户是一个密钥对账户,或者我们可能认为是一个“常规 Solana 钱包”。系统程序是密钥对账户的所有者。

程序初始化的 PDAs 和密钥对账户由程序拥有

程序可以写入由程序初始化但在程序外创建的 PDA 或密钥对账户的原因是因为程序拥有它们。

当我们讨论重新初始化攻击时,我们将更仔细地探讨初始化,但现在,重要的一点是初始化账户会将账户的所有者从系统程序更改为程序。

为了说明这一点,考虑以下初始化 PDA 和密钥对账户的程序。Typescript 测试将在初始化事务之前和之后记录所有者。

如果我们尝试确定一个不存在的地址的所有者,我们会得到一个 null

以下是 Rust 代码:

use anchor_lang::prelude::*;

declare_id!("C2ZKJPhNiCM6CqTneGUXJoE4o6YhMzNUes3q5WNcH3un");

#[program]
pub mod owner {
    use super::*;

    pub fn initialize_keypair(ctx: Context<InitializeKeypair>) -> Result<()> {
        Ok(())
    }

    pub fn initialize_pda(ctx: Context<InitializePda>) -> Result<()> {
        Ok(())
    }
}

#[derive(Accounts)]
pub struct InitializeKeypair<'info> {
    #[account(init, payer = signer, space = 8)]
    keypair: Account<'info, Keypair>,
    #[account(mut)]
    signer: Signer<'info>,
    system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct InitializePda<'info> {
    #[account(init, payer = signer, space = 8, seeds = [], bump)]
    pda: Account<'info, Pda>,
    #[account(mut)]
    signer: Signer<'info>,
    system_program: Program<'info, System>,
}

#[account]
pub struct Keypair();

#[account]
pub struct Pda();

以下是 Typescript 代码:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Owner } from "../target/types/owner";

async function airdropSol(publicKey, amount) {
  let airdropTx = await anchor.getProvider().connection.requestAirdrop(publicKey, amount * anchor.web3.LAMPORTS_PER_SOL);
  await confirmTransaction(airdropTx);
}

async function confirmTransaction(tx) {
  const latestBlockHash = await anchor.getProvider().connection.getLatestBlockhash();
  await anchor.getProvider().connection.confirmTransaction({
    blockhash: latestBlockHash.blockhash,
    lastValidBlockHeight: latestBlockHash.lastValidBlockHeight,
    signature: tx,
  });
}

describe("owner", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.Owner as Program<Owner>;

  it("Is initialized!", async () => {
    console.log("program address", program.programId.toBase58());    
    const seeds = []
    const [pda, bump_] = anchor.web3.PublicKey.findProgramAddressSync(seeds, program.programId);

    console.log("owner of pda before initialize:",
                await anchor.getProvider().connection.getAccountInfo(pda));

    await program.methods.initializePda()
    .accounts({pda: pda}).rpc();

    console.log("owner of pda after initialize:",
                (await anchor.getProvider().connection.getAccountInfo(pda)).owner.toBase58());

    let keypair = anchor.web3.Keypair.generate();

    console.log("owner of keypair before airdrop:",
                await anchor.getProvider().connection.getAccountInfo(keypair.publicKey));

    await airdropSol(keypair.publicKey, 1); // 1 SOL
   
    console.log("owner of keypair after airdrop:",
                (await anchor.getProvider().connection.getAccountInfo(keypair.publicKey)).owner.toBase58());
    
    await program.methods.initializeKeypair()
      .accounts({keypair: keypair.publicKey})
      .signers([keypair]) // the signer must be the keypair
      .rpc();

    console.log("owner of keypair after initialize:",
                (await anchor.getProvider().connection.getAccountInfo(keypair.publicKey)).owner.toBase58());
 
  });
});

测试的工作方式如下:

  1. 预测 PDA 的地址并查询所有者。得到 null
  2. 调用 initializePDA 然后查询所有者。得到程序的地址。
  3. 生成一个密钥对账户并查询所有者。得到 null
  4. 向密钥对账户空投 SOL。现在所有者是系统程序,就像一个普通的钱包一样。
  5. 调用 initializeKeypair 然后查询所有者。得到程序的地址。

测试结果截图如下:

print solana account owner

这就是程序能够向账户写入数据的方式:它们拥有这些账户。在初始化期间,程序接管了账户的所有权。

练习: 修改测试以打印密钥对和 PDA 的地址。然后使用 Solana CLI 检查这些账户的所有者是谁。它应该与测试打印的内容相匹配。确保 solana-test-validator 在后台运行,以便你可以使用 CLI。

BPFLoaderUpgradeable 拥有程序

让我们使用 Solana CLI 确定我们的程序的所有者:

BPFLoaderUpgradeable

部署程序的钱包并不是其所有者。Solana 程序之所以能够被部署的钱包升级,是因为 BpfLoaderUpgradeable 能够向程序写入新的字节码,并且它只会接受来自预先指定地址的新字节码:最初部署程序的地址。

当我们部署(或升级)一个程序时,实际上是在调用 BPFLoaderUpgradeable 程序,如日志所示:

BPFLoader call

程序可以转移拥有账户的所有权

这可能是你不太经常使用的功能,但以下是执行此操作的代码。

Rust:

use anchor_lang::prelude::*;
use std::mem::size_of;
use anchor_lang::system_program;

declare_id!("Hxj38tktrD7YcSvKRxVrYQfxptkZd7NVbmrRKvLxznyA");


#[program]
pub mod change_owner {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }

    pub fn change_owner(ctx: Context<ChangeOwner>) -> Result<()> {
        let account_info = &mut ctx.accounts.my_storage.to_account_info();
        
				// assign is the function to transfer ownership
				account_info.assign(&system_program::ID);

				// we must erase all the data in the account or the transfer will fail
        let res = account_info.realloc(0, false);

        if !res.is_ok() {
            return err!(Err::ReallocFailed);
        }

        Ok(())
    }
}

#[error_code]
pub enum Err {
    #[msg("realloc failed")]
    ReallocFailed,
}

#[derive(Accounts)]
pub struct Initialize<'info> {

    #[account(init,
              payer = signer,
              space=size_of::<MyStorage>() + 8,
              seeds = [],
              bump)]
    pub my_storage: Account<'info, MyStorage>,
    
    #[account(mut)]
    pub signer: Signer<'info>,

    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct ChangeOwner<'info> {
    #[account(mut)]
    pub my_storage: Account<'info, MyStorage>,
}

#[account]
pub struct MyStorage {
    x: u64,
}

Typescript:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { ChangeOwner } from "../target/types/change_owner";

import privateKey from '/Users/jeffreyscholz/.config/solana/id.json';

describe("change_owner", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.ChangeOwner as Program<ChangeOwner>;

  it("Is initialized!", async () => {
    const deployer = anchor.web3.Keypair.fromSecretKey(Uint8Array.from(privateKey));

    const seeds = []
    const [myStorage, _bump] = anchor.web3.PublicKey.findProgramAddressSync(seeds, program.programId);

    console.log("the storage account address is", myStorage.toBase58());

    await program.methods.initialize().accounts({myStorage: myStorage}).rpc();
    await program.methods.changeOwner().accounts({myStorage: myStorage}).rpc();
    
		// after the ownership has been transferred
		// the account can still be initialized again
		await program.methods.initialize().accounts({myStorage: myStorage}).rpc();
  });
});

以下是我们要注意的一些事项:

  • 在转移账户后,必须在同一事务中擦除数据。否则,我们可能会向其他程序拥有的账户插入数据。这是 account_info.realloc(0, false); 代码。false 表示不要清零数据,但这没有关系,因为数据已经不存在了。
  • 转移账户所有权并不会永久删除账户,它可以再次初始化,正如测试所示。

既然我们清楚地了解了程序拥有由它们初始化的 PDAs 和密钥对账户,我们可以做的有趣且有用的事情是将 SOL 从中转出。

从 PDA 转出 SOL:众筹示例

以下是一个简单的众筹应用程序的代码。感兴趣的函数是 withdraw 函数,其中程序将 lamports 从 PDA 转出并转给提款人。

use anchor_lang::prelude::*;
use anchor_lang::system_program;
use std::mem::size_of;
use std::str::FromStr;

declare_id!("BkthFL8LV2V2MxVgQtA9tT5goeeJhUdxRPahzavqHPFZ");

#[program]
pub mod crowdfund {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        let initialized_pda = &mut ctx.accounts.pda;
        Ok(())
    }

    pub fn donate(ctx: Context<Donate>, amount: u64) -> Result<()> {
        let cpi_context = CpiContext::new(
            ctx.accounts.system_program.to_account_info(),
            system_program::Transfer {
                from: ctx.accounts.signer.to_account_info().clone(),
                to: ctx.accounts.pda.to_account_info().clone(),
            },
        );

        system_program::transfer(cpi_context, amount)?;

        Ok(())
    }

    pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
        ctx.accounts.pda.sub_lamports(amount)?;
        ctx.accounts.signer.add_lamports(amount)?;

        // in anchor 0.28 or lower, use the following syntax:
        // **ctx.accounts.pda.to_account_info().try_borrow_mut_lamports()? -= amount;
        // **ctx.accounts.signer.to_account_info().try_borrow_mut_lamports()? += amount;
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(mut)]
    pub signer: Signer<'info>,

    #[account(init, payer = signer, space=size_of::<Pda>() + 8, seeds=[], bump)]
    pub pda: Account<'info, Pda>,
    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct Donate<'info> {
    #[account(mut)]
    pub signer: Signer<'info>,

    #[account(mut)]
    pub pda: Account<'info, Pda>,
    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct Withdraw<'info> {
    #[account(mut, address = Pubkey::from_str("5jmigjgt77kAfKsHri3MHpMMFPo6UuiAMF19VdDfrrTj").unwrap())]
    pub signer: Signer<'info>,

    #[account(mut)]
    pub pda: Account<'info, Pda>,
}

#[account]
pub struct Pda {}

因为程序拥有 PDA,所以它可以直接从账户中扣除 lamport 余额。

当我们作为正常钱包交易的一部分转移 SOL 时,我们不会直接扣除 lamport 余额,因为我们不是账户的所有者。系统程序拥有钱包,并且只有在看到请求其这样做的交易上有有效签名时,它才会扣除 lamport 余额。

在这种情况下,程序拥有 PDA,因此可以直接从中扣除 lamports。

代码中还值得注意的一些内容:

  • 我们硬编码了谁可以从 PDA 提取的约束,使用约束 #[account(mut, address = Pubkey::from_str("5jmigjgt77kAfKsHri3MHpMMFPo6UuiAMF19VdDfrrTj").unwrap())]。这检查该账户的地址是否与字符串中的地址匹配。为了使此代码工作,我们还需要导入 use std::str::FromStr;。要测试此代码,请将字符串中的地址更改为你的 solana address
  • 使用 Anchor 0.29,我们可以使用语法 ctx.accounts.pda.sub_lamports(amount)?;ctx.accounts.signer.add_lamports(amount)?;。对于 Anchor 的早期版本,请使用 ctx.accounts.pda.to_account_info().try_borrow_mut_lamports()? -= amount; ctx.accounts.signer.to_account_info().try_borrow_mut_lamports()? += amount;
  • 你不需要拥有你要转移 lamports 的账户。

以下是相应的 Typescript 代码:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Crowdfund } from "../target/types/crowdfund";

describe("crowdfund", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.Crowdfund as Program<Crowdfund>;

  it("Is initialized!", async () => {
    const programId = await program.account.pda.programId;

    let seeds = [];
    let pdaAccount = anchor.web3.PublicKey.findProgramAddressSync(seeds, programId)[0];

    const tx = await program.methods.initialize().accounts({
      pda: pdaAccount
    }).rpc();

    // transfer 2 SOL
    const tx2 = await program.methods.donate(new anchor.BN(2_000_000_000)).accounts({
      pda: pdaAccount
    }).rpc();

    console.log("lamport balance of pdaAccount",
								await anchor.getProvider().connection.getBalance(pdaAccount));

    // transfer back 1 SOL
		// the signer is the permitted address
    await program.methods.withdraw(new anchor.BN(1_000_000_000)).accounts({
      pda: pdaAccount
    }).rpc();

    console.log("lamport balance of pdaAccount",
							  await anchor.getProvider().connection.getBalance(pdaAccount));

  });
});

练习: 尝试向接收地址添加比你从 PDA 提取的 lamports 更多的 lamports。即将代码更改为以下内容:

ctx.accounts.pda.sub_lamports(amount)?;
// sneak in an extra lamport
ctx.accounts.signer.add_lamports(amount + 1)?;

运行时应该会阻止你。

请注意,将 lamport 余额提取到低于租金免除阈值的账户将导致该账户被关闭。如果账户中有数据,那将被擦除。因此,程序应该在提取 SOL 之前跟踪需要多少 SOL 才能获得租金豁免,除非他们不在乎账户被擦除。

通过 RareSkills 了解更多

请查看我们的 Solana 教程 以获取完整的主题列表。

在 Anchor 中的 init_if_needed 和重新初始化攻击

anchor init if needed

在之前的教程中,我们必须在可以向其写入数据之前在单独的事务中初始化帐户。我们可能希望能够在一个事务中初始化帐户并向其写入数据,以简化用户操作。

Anchor 提供了一个方便的宏称为 init_if_needed,正如其名称所示,如果帐户不存在,它将初始化该帐户。

下面的示例计数器不需要单独的初始化事务,它将立即开始向 counter 存储添加“1”。

Rust:

use anchor_lang::prelude::*;
use std::mem::size_of;

declare_id!("9DbiqCqtqgP3NYufxBakbeRd7JyNpNYbsm6Jqrn8Z2Hn");

#[program]
pub mod init_if_needed {
    use super::*;

    pub fn increment(ctx: Context<Initialize>) -> Result<()> {
        let current_counter = ctx.accounts.my_pda.counter;
        ctx.accounts.my_pda.counter = current_counter + 1;
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(
        init_if_needed,
        payer = signer,
        space = size_of::<MyPDA>() + 8,
        seeds = [],
        bump
    )]
    pub my_pda: Account<'info, MyPDA>,

    #[account(mut)]
    pub signer: Signer<'info>,
    pub system_program: Program<'info, System>,
}

#[account]
pub struct MyPDA {
    pub counter: u64,
}

Typescript:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { InitIfNeeded } from "../target/types/init_if_needed";

describe("init_if_needed", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.InitIfNeeded as Program<InitIfNeeded>;

  it("Is initialized!", async () => {
    const [myPda, _bump] = anchor.web3.PublicKey.findProgramAddressSync([], program.programId);
    await program.methods.increment().accounts({myPda: myPda}).rpc();
    await program.methods.increment().accounts({myPda: myPda}).rpc();
    await program.methods.increment().accounts({myPda: myPda}).rpc();

    let result = await program.account.myPda.fetch(myPda);
    console.log(`counter is ${result.counter}`);
  });
});

当我们尝试使用 anchor build 构建此程序时,将会出现以下错误:

anchor init_if_needed warning

为了消除错误 init_if_needed requires that anchor-lang be imported with the init-if-needed cargo feature enabled,我们可以打开 programs/<anchor_project_name> 中的 Cargo.toml 文件,并添加以下行:

[dependencies]
anchor-lang = { version = "0.29.0", features = ["init-if-needed"] }

但在消除错误之前,我们应该了解重新初始化攻击是什么以及它是如何发生的。

在 Anchor 程序中,帐户不能被初始化两次(默认情况下)

如果我们尝试初始化已经被初始化的帐户,事务将失败。

Anchor 如何知道帐户是否已经初始化?

从 Anchor 的角度来看,如果帐户具有非零的 lamport 余额或者帐户由系统程序拥有,则它未被初始化。

由系统程序拥有或者具有零 lamport 余额的帐户可以被重新初始化。

为了说明这一点,我们有一个具有典型 initialize 函数(使用 init 而不是 init_if_needed)的 Solana 程序。它还有一个 drain_lamports 函数和一个 give_to_system_program 函数,它们的功能与它们的名称一样:

use anchor_lang::prelude::*;
use std::mem::size_of;
use anchor_lang::system_program;

declare_id!("FC467mPCCKXG97ut1WdLLi55vuAcyCW8AD1vid27bZfn");

#[program]
pub mod reinit_attack {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }

    pub fn drain_lamports(ctx: Context<DrainLamports>) -> Result<()> {
        let lamports = ctx.accounts.my_pda.to_account_info().lamports();
        ctx.accounts.my_pda.sub_lamports(lamports)?;
				ctx.accounts.signer.add_lamports(lamports)?;
        Ok(())
    }

    pub fn give_to_system_program(ctx: Context<GiveToSystemProgram>) -> Result<()> {
        let account_info = &mut ctx.accounts.my_pda.to_account_info();
        // the assign method changes the owner
				account_info.assign(&system_program::ID);
        account_info.realloc(0, false)?;

        Ok(())
    }
}

#[derive(Accounts)]
pub struct DrainLamports<'info> {
    #[account(mut)]
    pub my_pda: Account<'info, MyPDA>,
    #[account(mut)]
    pub signer: Signer<'info>,
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(init, payer = signer, space = 8, seeds = [], bump)]
    pub my_pda: Account<'info, MyPDA>,

    #[account(mut)]
    pub signer: Signer<'info>,

    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct GiveToSystemProgram<'info> {
    #[account(mut)]
    pub my_pda: Account<'info, MyPDA>,
}

#[account]
pub struct MyPDA {}

现在考虑以下单元测试:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { ReinitAttack } from "../target/types/reinit_attack";

describe("Program", () => {
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.ReinitAttack as Program<ReinitAttack>;

  it("initialize after giving to system program or draining lamports", async () => {
    const [myPda, _bump] = anchor.web3.PublicKey.findProgramAddressSync([], program.programId);

    await program.methods.initialize().accounts({myPda: myPda}).rpc();

    await program.methods.giveToSystemProgram().accounts({myPda: myPda}).rpc();

    await program.methods.initialize().accounts({myPda: myPda}).rpc();
    console.log("account initialized after giving to system program!")

    await program.methods.drainLamports().accounts({myPda: myPda}).rpc();

    await program.methods.initialize().accounts({myPda: myPda}).rpc();
    console.log("account initialized after draining lamports!")
  });
});

序列如下:

  1. 我们初始化 PDA
  2. 我们将 PDA 的所有权转移给系统程序
  3. 我们再次调用初始化,它成功了
  4. 我们从 my_pda 帐户中取出 lamports
  5. 具有零 lamport 余额的帐户将被计划删除,因为它不再免租。Solana 运行时将认为该帐户不存在。
  6. 我们再次调用初始化,它成功了。在按照此顺序操作后,我们成功重新初始化了该帐户。

再次强调,Solana 没有“initialized(已初始化)”标志或其他内容。如果帐户的所有者是系统程序或 lamport 余额为零,则 Anchor 将允许初始化事务成功。

为什么在我们的示例中重新初始化可能会成为问题

将所有权转移给系统程序需要擦除帐户中的数据。清空所有 lamports “传达”了你不希望该帐户继续存在的意图。

通过执行这些操作,你是想重新开始计数器还是结束计数器的生命周期?如果你的应用程序永远不希望计数器被重置,这可能会导致错误。

Anchor 希望你仔细考虑你的意图,这就是为什么它让你在 Cargo.toml 中启用一个特性标志的额外步骤。

如果你可以接受计数器在某个时刻被重置并重新计数,那么重新初始化就不是一个问题。但是,如果计数器在任何情况下都不应重置为零,则最好是单独实现 initialization 函数,并添加一个保障,确保它在其生命周期内只能被调用一次(例如,在单独帐户中存储一个布尔标志)。

当然,你的程序可能并没有机制将帐户转移给系统程序或从帐户中提取 lamports。但 Anchor 无法知道这一点,因此它总是会发出关于 init_if_needed 的警告,因为它无法确定帐户是否可以回到可初始化状态。

拥有两个初始化路径可能会导致差一错误或其他令人惊讶的行为

在我们的带有 init_if_needed 的计数器示例中,计数器永远不会等于零,因为第一个初始化事务还会将值从零增加到一。

如果我们有一个常规初始化函数,该函数不会增加计数器的值,那么计数器将被初始化并具有零值。如果某些业务逻辑从不希望看到值为零的计数器,则可能会发生意外行为。

在以太坊中,从未“触及”过的变量的存储值默认为零。在 Solana 中,未初始化的帐户不持有零值变量 —— 它们不存在,也无法读取

“初始化”在 Anchor 中并不总是指“init”

有些情况下,“初始化”一词被用来更一般地表示“首次向帐户写入数据”,而不仅仅是 Anchor 的 init 宏。

如果我们查看来自 Soldev 的示例程序,我们会发现没有使用 init 宏:

soldev screenshopt reinitialization

代码直接在第 11 行读取帐户,然后设置字段。该程序无论是首次写入数据还是第二次(或第三次)写入数据,都会盲目地覆盖数据。

在这里,“初始化”术语的含义是“首次向帐户写入数据”。

这里的“重新初始化攻击”是一种不同类型,与 Anchor 框架警告的内容不同。具体来说,“初始化”可以被调用多次。Anchor 的 init 宏会检查 lamport 余额是否为非零,并且程序是否已经拥有该帐户,这将防止多次调用 initialize。init 宏可以看到帐户已经具有 lamports 或者由程序拥有。然而,上面的代码没有这样的检查。

值得一提的是,通过他们的教程了解这种重新初始化攻击的变体是值得的。

请注意,这使用的是 Anchor 的旧版本。AccountInfoUncheckedAccount 的另一个术语,因此你需要在其上方添加一个 /// Check: 注释。

擦除帐户鉴别器不会使帐户可重新初始化

帐户是否已初始化与其内部的数据(或缺乏数据)无关。

要擦除帐户中的数据而不转移它:

use anchor_lang::prelude::*;
use std::mem::size_of;
use anchor_lang::system_program;

declare_id!("FC467mPCCKXG97ut1WdLLi55vuAcyCW8AD1vid27bZfn");

#[program]
pub mod reinit_attack {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }

    pub fn erase(ctx: Context<Erase>) -> Result<()> {
        ctx.accounts.my_pda.realloc(0, false)?;
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Erase<'info> {
		/// CHECK: We are going to erase the account
    #[account(mut)]
    pub my_pda: UncheckedAccount<'info>,
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(init, payer = signer, space = 8, seeds = [], bump)]
    pub my_pda: Account<'info, MyPDA>,

    #[account(mut)]
    pub signer: Signer<'info>,

    pub system_program: Program<'info, System>,
}

#[account]
pub struct MyPDA {}

重要的是,我们使用 UncheckedAccount 来擦除数据,因为常规 Account 上没有 .realloc(0, false) 方法。

此操作将擦除帐户鉴别器,因此它将无法通过 Account 再次读取。

练习: 初始化帐户,调用 erase 然后尝试再次初始化帐户。它将失败,因为即使帐户没有数据,它仍然由程序拥有并且具有非零 lamport 余额。

总结

init_if_needed 宏可以方便地避免需要两个事务与新存储帐户交互。Anchor 框架默认阻止它,以迫使我们考虑以下可能不希望发生的情况:

  • 如果有一种方法可以将 lamport 余额减少到零或将所有权转移给系统程序,则可以重新初始化帐户。这可能是一个问题,也可能不是,这取决于业务需求。
  • 如果程序既有 init 宏又有 init_if_needed 宏,开发人员必须确保拥有两个代码路径不会导致意外状态。
  • 即使帐户中的数据完全被擦除,帐户仍然已初始化。
  • 如果程序有一个“盲目”写入帐户的函数,那么该帐户中的数据可能会被覆盖。这通常需要通过 AccountInfo 或其别名 UncheckedAccount 加载帐户。

通过 RareSkills 了解更多

查看我们的 Solana 开发课程 以获取我们的其他 Solana 教程。感谢阅读!

Solana 中的 Multicall:批处理交易和交易大小限制

solana transaction batch and transaction size limits

Solana 内置 Multicall

在以太坊中,如果我们想要原子地批处理多个交易,我们会使用 Multicall 模式。如果一个失败,其余的也会失败。

Solana 已经将此功能内置到运行时中,因此我们不需要实现 Multicall。在下面的示例中,我们在一个交易中初始化一个账户并向其写入 —— 而不使用init_if_needed

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Batch } from "../target/types/batch";

describe("batch", () => {
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.Batch as Program<Batch>;

  it("Is initialized!", async () => {
    const wallet = anchor.workspace.Batch.provider.wallet.payer;
    const [pda, _bump] = anchor.web3.PublicKey.findProgramAddressSync([], program.programId);

    const initTx = await program.methods.initialize()
			              .accounts({pda: pda})
										.transaction();

    // for u32, we don't need to use big numbers
    const setTx = await program.methods.set(5)
										.accounts({pda: pda})
										.transaction();

    let transaction = new anchor.web3.Transaction();
    transaction.add(initTx);
    transaction.add(setTx);

    await anchor.web3.sendAndConfirmTransaction(anchor.getProvider().connection, transaction, [wallet]);

    const pdaAcc = await program.account.pda.fetch(pda);
    console.log(pdaAcc.value); // prints 5
  });
});

以下是相应的 Rust 代码:

use anchor_lang::prelude::*;
use std::mem::size_of;

declare_id!("Ao9LdZtHdMAzrFUEfRNbKEb5H4nXvpRZC69kxeAGbTPE");

#[program]
pub mod batch {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }

    pub fn set(ctx: Context<Set>, new_val: u32) -> Result<()> {
        ctx.accounts.pda.value = new_val;
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(init, payer = signer, space = size_of::<PDA>() + 8, seeds = [], bump)]
    pub pda: Account<'info, PDA>,

    #[account(mut)]
    pub signer: Signer<'info>,

    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct Set<'info> {
    #[account(mut)]
    pub pda: Account<'info, PDA>,
}

#[account]
pub struct PDA {
    pub value: u32,
}

关于上面代码的一些注释:

  • 当向 Rust 传递u32值或更小值时,我们不需要使用 JavaScript 大数。
  • 我们不再需要使用await program.methods.initialize().accounts({pda: pda}).rpc(),而是使用await program.methods.initialize().accounts({pda: pda}).transaction()来创建一个交易。

Solana 交易大小限制

Solana 交易的总大小不能超过1232 字节

这意味着你将无法像在以太坊中那样批处理“无限”数量的交易并支付更多的 gas。

演示批处理交易的原子性

让我们修改我们 Rust 中的set函数,使其始终失败。这将帮助我们看到如果其中一个批处理的交易失败,initialize交易会被回滚。

以下 Rust 程序在调用set时始终返回错误:

use anchor_lang::prelude::*;
use std::mem::size_of;

declare_id!("Ao9LdZtHdMAzrFUEfRNbKEb5H4nXvpRZC69kxeAGbTPE");

#[program]
pub mod batch {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }

    pub fn set(ctx: Context<Set>, new_val: u32) -> Result<()> {
        ctx.accounts.pda.value = new_val;
        return err!(Error::AlwaysFails);
    }
}

#[error_code]
pub enum Error {
    #[msg(always fails)]
    AlwaysFails,
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(init, payer = signer, space = size_of::<PDA>() + 8, seeds = [], bump)]
    pub pda: Account<'info, PDA>,

    #[account(mut)]
    pub signer: Signer<'info>,

    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct Set<'info> {
    #[account(mut)]
    pub pda: Account<'info, PDA>,
}

#[account]
pub struct PDA {
    pub value: u32,
}

以下 Typescript 代码发送了一个初始化和设置的批处理交易:

import * as anchor from "@coral-xyz/anchor";
import { Program, SystemProgram } from "@coral-xyz/anchor";
import { Batch } from "../target/types/batch";

describe("batch", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.Batch as Program<Batch>;

  it("Set the number to 5, initializing if necessary", async () => {
    const wallet = anchor.workspace.Batch.provider.wallet.payer;
    const [pda, _bump] = anchor.web3.PublicKey.findProgramAddressSync([], program.programId);

    // console.log the address of the pda
    console.log(pda.toBase58());

    let transaction = new anchor.web3.Transaction();
    transaction.add(await program.methods.initialize().accounts({pda: pda}).transaction());
    transaction.add(await program.methods.set(5).accounts({pda: pda}).transaction());

await anchor.web3.sendAndConfirmTransaction(anchor.getProvider().connection, transaction, [wallet]);
  });
});

当我们运行测试,然后查询本地验证器以获取 pda 账户时,我们会发现它不存在。尽管初始化交易首先执行,但随后执行的设置交易失败,导致整个交易被取消,因此没有账户被初始化。

atomic multiple transaction fails

前端中的“如果需要则初始化”

你可以使用前端代码模拟init_if_needed的行为,同时拥有一个单独的initialize函数。然而,从用户的角度来看,当他们第一次使用账户时,所有这些都会被平滑处理,因为他们不必在第一次使用账户时发出多个交易。

要确定是否需要初始化一个账户,我们检查它是否具有零 lamports 或是否由系统程序拥有。以下是我们在 Typescript 中如何做到这一点:

import * as anchor from "@coral-xyz/anchor";
import { Program, SystemProgram } from "@coral-xyz/anchor";
import { Batch } from "../target/types/batch";

describe("batch", () => {
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.Batch as Program<Batch>;

  it("Set the number to 5, initializing if necessary", async () => {
    const wallet = anchor.workspace.Batch.provider.wallet.payer;
    const [pda, _bump] = anchor.web3.PublicKey.findProgramAddressSync([], program.programId);

    let accountInfo = await anchor.getProvider().connection.getAccountInfo(pda);

    let transaction = new anchor.web3.Transaction();
    if (accountInfo == null || accountInfo.lamports == 0 || accountInfo.owner == anchor.web3.SystemProgram.programId) {
      console.log("need to initialize");
      const initTx = await program.methods.initialize().accounts({pda: pda}).transaction();
      transaction.add(initTx);
    }
    else {
      console.log("no need to initialize");
    }

    // we're going to set the number anyway
    const setTx = await program.methods.set(5).accounts({pda: pda}).transaction();
    transaction.add(setTx);

    await anchor.web3.sendAndConfirmTransaction(anchor.getProvider().connection, transaction, [wallet]);

    const pdaAcc = await program.account.pda.fetch(pda);
    console.log(pdaAcc.value);
  });
});

我们还需要修改我们的 Rust 代码,set操作上强制失败。

use anchor_lang::prelude::*;
use std::mem::size_of;

declare_id!("Ao9LdZtHdMAzrFUEfRNbKEb5H4nXvpRZC69kxeAGbTPE");

#[program]
pub mod batch {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }

    pub fn set(ctx: Context<Set>, new_val: u32) -> Result<()> {
        ctx.accounts.pda.value = new_val;
				Ok(()) // ERROR HAS BEEN REMOVED
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(init, payer = signer, space = size_of::<PDA>() + 8, seeds = [], bump)]
    pub pda: Account<'info, PDA>,

    #[account(mut)]
    pub signer: Signer<'info>,

    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct Set<'info> {
    #[account(mut)]
    pub pda: Account<'info, PDA>,
}

#[account]
pub struct PDA {
    pub value: u32,
}

如果我们针对同一个本地验证器实例运行测试两次,我们将得到以下输出:

第一次测试运行:

batched transaction succeeds on first initialized

第二次测试运行:

batched transaction suceeds on second call with account already initialized

Solana 如何部署超过 1232 字节的程序?

如果你创建一个新的 Solana 程序并运行anchor deploy(或anchor test),你将在日志中看到有大量交易到BFPLoaderUpgradeable

anchor deploy logs with many transactions

在这里,Anchor 正在将部署的字节码拆分成多个交易,因为一次性部署整个字节码无法适应单个交易。我们可以通过将日志导向文件并计算发生的交易数量来查看它花费了多少交易:

solana logs > logs.txt
# run `anchor deploy` in another shell
grep "Transaction executed" logs.txt | wc -l

这将大致匹配在执行anchor testanchor deploy命令后暂时出现的内容:

transaction count to deploy program

关于如何将交易批处理的确切过程在 Solana 文档:Solana 程序部署工作原理中有描述。

这些交易列表是单独的交易,而不是批处理的交易。如果是批处理的话,将会超过 1232 字节的限制。

通过 RareSkills 了解更多

查看我们的 Solana 开发课程以获取更多 Solana 教程。

Solana 中的 Owner 和 Authority

solana owner vs authority

Solana 的新手经常对“owner(所有者)”和“authority(权限)”之间的区别感到困惑。本文试图尽可能简洁地澄清这种混淆。

Owner vs Authority

只有程序才能向账户写入数据 — 具体来说,只能写入它们拥有的账户。程序不能向任意账户写入数据。

当然,程序不能自发地向账户写入数据。它们需要从钱包接收指令才能这样做。然而,通常情况下,程序只会接受来自特定钱包的写入指令:authority

账户的 owner 是一个程序。authority 是一个钱包。authority 向程序发送交易,而该程序可以向账户写入数据。

账户在 Solana 中都具有以下字段,这些字段大多是不言自明的:

  • Public Key
  • lamport balance
  • owner
  • executable (a boolean flag)
  • rent_epoch (can be ignored for rent-exempt accounts)
  • data

我们可以通过在终端中运行 solana account <钱包地址> 来查看这些字段(在后台运行 Solana 验证器):

system program as owner

注意一个有趣的事实:我们不是我们钱包的 owner! 地址 111…111 是系统程序的 owner。

为什么系统程序拥有钱包,而不是钱包拥有自己?

只有账户的 owner 才能修改其中的数据。

这意味着我们无法直接修改我们的余额。只有系统程序才能这样做。要从我们的账户中转移 SOL,我们向系统程序发送一个已签名的交易。系统程序会验证我们拥有账户的私钥,然后代表我们修改余额。

这是你在 Solana 中经常看到的模式:只有账户的 owner 才能修改账户中的数据。如果程序看到来自预定地址的有效签名:authority,程序将修改账户中的数据。

authority 是程序看到有效签名时将接受指令的地址。authority 不能直接修改账户。它需要通过拥有要修改的账户的程序来操作。

system program as owner

然而,owner 始终是一个程序,如果交易的签名有效,该程序将代表其他人修改账户。

例如,在我们的使用不同签名者修改账户的教程中,我们看到了这一点。

练习: 创建一个初始化存储账户的程序。你可能需要程序和存储账户的地址。考虑将以下代码添加到测试中:

console.log(`program: ${program.programId.toBase58()}`);
console.log(`storage account: ${myStorage.toBase58()}`);

然后在初始化的账户上运行 solana account <storage account>。你应该看到 owner 是程序。

这是运行练习的屏幕截图:

relating program address to owner of account

当我们查看存储账户的元数据时,我们看到程序是 owner。

因为程序拥有存储账户,所以它能够向其写入数据。 用户无法直接向存储账户写入数据,他们需要签署交易并要求程序写入数据。

Solana 中的 owner 与 Solidity 中的 owner 非常不同

在 Solidity 中,我们通常将 owner 称为具有智能合约管理权限的特殊地址。“owner”不是以太坊运行时级别存在的概念,而是应用于 Solidity 合约的设计模式。Solana 中的 owner 更为基础。在以太坊中,智能合约只能写入自己的存储槽。想象一下,如果我们有一种机制允许以太坊智能合约能够写入其他一些存储槽。在 Solana 术语中,它将成为这些存储槽的 owner

authority 可以表示部署合约的账户和可以为特定账户发送写入交易的账户

authority 可以是程序级别的构造。在我们的 Anchor 签名者教程中,我们创建了一个程序,Alice 可以从她的账户中扣除积分并转移给其他人。为了确保只有 Alice 可以为该账户发送扣除交易,我们将她的地址存储在账户中:

#[account]
pub struct Player {
    points: u32,
    authority: Pubkey
}

Solana 使用类似的机制来记住谁部署了一个程序。在我们的 Anchor 部署教程中,我们注意到部署程序的钱包也能够升级它。

“升级”程序就是向其写入新数据 — 即新的字节码。只有程序的 owner 才能向其写入数据(我们很快将看到,这个程序是BPFLoaderUpgradeable)。

那么,Solana 如何知道如何授予部署某个程序的钱包升级 authority 呢?

从命令行查看程序的 authority

在部署程序之前,让我们通过在终端中运行 solana address 来查看 anchor 正在使用的钱包:

solana 地址 cli 获取地址

请注意我们的地址是 5jmi...rrTj。现在让我们创建一个程序。

确保solana-test-validatorsolana logs在后台运行,然后部署 Solana 程序:

anchor init owner_authority
cd owner_authority
anchor build
anchor test --skip-local-validator

当我们查看日志时,我们会看到我们刚刚部署的程序的地址:

部署的程序地址

请记住,Solana 中的所有内容都是账户,包括程序。现在让我们使用 solana account 6Ye7CgrwJxH3b4EeWKh54NM8e6ZekPcqREgkrn7Yy3Tg 来检查此账户。我们得到以下结果:

打印程序元数据

请注意,authority 字段不存在,因为“authority”不是 Solana 账户持有的字段。 如果你向本文顶部滚动,你会看到控制台中的密钥与我们在本文顶部列出的字段匹配。

在这里,“owner”是 BPFLoaderUpgradeable111…111,这是所有 Solana 程序的 owner 。

现在让我们运行 solana program show 6Ye7CgrwJxH3b4EeWKh54NM8e6ZekPcqREgkrn7Yy3Tg,其中 6Ye7...y3TG 是我们程序的地址:

solana 程序展示

在上面的绿色框中,我们看到我们的钱包地址 — 用于部署程序的地址,以及我们之前使用 solana address 打印出的地址:

再次在 CLI 中显示我们的地址

但这引出了一个重要问题…

Solana 将“authority”存储在哪里,目前是我们的钱包?

它不是账户中的字段,因此必须存储在某个 Solana 账户的data字段中。“authority” 存储在存储程序字节码的ProgramData地址中:

ProgramData 地址

我们钱包的十六进制编码(authority)

在继续之前,将有助于将ProgramData Address的 base58 编码转换为十六进制表示。完成此转换的代码提供在文章末尾,但现在请读者接受我们 Solana 钱包地址 5jmigjgt77kAfKsHri3MHpMMFPo6UuiAMF19VdDfrrTj 的十六进制表示为:

4663b48dfe92ac464658e512f74a8ee0ffa99fffe89fb90e8d0101a0c3c7767a

查看存储可执行文件的 ProgramData Address 中的数据

我们可以使用 solana account 查看ProgramData Address账户,但我们还将将其发送到临时文件以避免向终端转储过多数据。

solana account FkYygT7X7qjifdxfBVWXTHpj87THJGmtmKUyU4SamfQm > tempfile

head -n 10 tempfile

上述命令的输出显示我们的钱包(十六进制)嵌入到data中。请注意,黄色下划线的十六进制代码与我们的钱包的十六进制编码(authority)匹配:

我们地址的十六进制编码

程序的字节码存储在单独的账户中,而不是程序的地址

这应该可以从上述命令序列中推断出,但值得明确说明。即使程序是一个标记为可执行的账户,字节码也不存储在其自己的数据字段中,而是存储在另一个账户中(有点令人困惑的是,这个账户并非可执行,它只是存储字节码)。

练习: 你能找到程序存储持有字节码的账户地址的位置吗?本文附录中的代码可能会有所帮助。

总结

只有程序的 owner 才能更改其数据。Solana 程序的 owner 是BPFLoaderUpgradeable系统程序,因此默认情况下,部署程序的钱包无法更改存储在账户中的数据(字节码)。

为了使程序升级,Solana 运行时将部署者的钱包嵌入到程序的字节码中。它将此字段称为“authority”。

当部署的钱包尝试升级字节码时,Solana 运行时将检查事务签名者是否是 authority。如果事务签名者与 authority 匹配,则BPFLoaderUpgradeable将代表 authority 更新程序的字节码。

附录:将 base58 转换为十六进制

以下 Python 代码将完成转换。它是由一个聊天机器人生成的,因此仅供说明目的使用:

def decode_base58(bc, length):
    base58_digits = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
    n = 0
    for char in bc:
        n = n * 58 + base58_digits.index(char)
    return n.to_bytes(length, 'big')

def find_correct_length_for_decoding(base58_string):
    for length in range(25, 50):  # Trying lengths from 25 to 50
        try:
            decoded_bytes = decode_base58(base58_string, length)
            return decoded_bytes.hex()
        except OverflowError:
            continue
    return None

# Base58 string to convert
base58_string = "5jmigjgt77kAfKsHri3MHpMMFPo6UuiAMF19VdDfrrTj"

# Convert and get the hexadecimal string
hex_string = find_correct_length_for_decoding(base58_string)
print(hex_string)

通过 RareSkills 了解更多

查看我们的 Solana 开发课程以了解更多 Solana 主题!有关其他区块链主题,请查看我们的区块链训练营

删除和关闭 Solana 中的账户和程序

Solana close acocunt

在 Solana 的 Anchor 框架中,closeinit在 Anchor 中初始化账户)的相反操作 — 它将 lamport 余额减少到零,将 lamports 发送到目标地址,并将账户的所有者更改为系统程序。

以下是在 Rust 中使用 close 指令的示例:

use anchor_lang::prelude::*;
use std::mem::size_of;

declare_id!("8gaSDFr5cVy2BkLrWfSX9MCtPX9N4gmXDvTVm7RS6DYK");

#[program]
pub mod close_program {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }

    pub fn delete(ctx: Context<Delete>) -> Result<()> {
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(init, payer = signer, space = size_of::<ThePda>() + 8, seeds = [], bump)]
    pub the_pda: Account<'info, ThePda>,

    #[account(mut)]
    pub signer: Signer<'info>,

    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct Delete<'info> {
    #[account(mut, close = signer, )]
    pub the_pda: Account<'info, ThePda>,

    #[account(mut)]
    pub signer: Signer<'info>,
}

#[account]
pub struct ThePda {
    pub x: u32,
}

Solana 为关闭账户返回租金

close = signer 宏指定交易中的签名者将收到为存储支付的租金(当然也可以指定其他地址)。这类似于以太坊中的 selfdestruct(在 Decun 升级之前)为清理空间的用户退款。关闭账户可以获得的 SOL 数量与账户大小成比例。

以下是调用 initialize 后跟 delete 的 Typescript:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { CloseProgram } from "../target/types/close_program";
import { assert } from "chai";

describe("close_program", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.CloseProgram as Program<CloseProgram>;

  it("Is initialized!", async () => {
   let [thePda, _bump] = anchor.web3.PublicKey.findProgramAddressSync([], program.programId);
    await program.methods.initialize().accounts({thePda: thePda}).rpc();
    await program.methods.delete().accounts({thePda: thePda}).rpc();

    let account = await program.account.thePda.fetchNullable(thePda);
    console.log(account)
  });
});

close = signer 指令表示将租金 lamports 发送给签名者,但你可以指定任何你喜欢的地址。

上述结构允许任何人关闭账户,你可能希望在真实应用程序中添加某种访问控制!

关闭后可以初始化账户

如果在关闭账户后调用 initialize,则会再次初始化。当然,之前赎回的租金必须再次支付。

练习: 在单元测试中添加另一个调用以初始化,以查看其是否通过。请注意,在测试结束时,账户不再为空。

close 在底层执行了什么操作?

如果我们查看 Anchor 中 close 命令的源代码 ,我们可以看到它执行了我们上面描述的操作:

Anchor close source code

许多 Anchorlang 示例已过时

在 Anchor 的 0.25 版本中,关闭次序不同。

与当前实现类似,它首先将所有 lamports 发送到目标地址。

但是,与擦除数据并将其转移到系统程序不同,close 会写入一个称为 CLOSE_ACCOUNT_DISCRIMINATOR 的特殊 8 字节序列。( 原始代码 ):

Anchor account discriminator

最终,运行时会擦除账户,因为它的 lamports 为零。

Anchor 中的账户鉴别器是什么?

当 Anchor 初始化账户时,它计算鉴别器并将其存储在账户的前 8 个字节中。账户鉴别器是结构的 Rust 标识符的 SHA256 的前 8 个字节。

当用户要求程序通过 pub the_pda: Account<'info, ThePda> 加载账户时,程序将计算 ThePda 标识符的 SHA256 的前 8 个字节。然后,它将加载 ThePda 数据并将存储在那里的鉴别器与计算的鉴别器进行比较。如果它们不匹配,则 Anchor 将不会反序列化账户。

这里的目的是防止攻击者制作一个恶意账户,当通过“错误的结构”解析时,会反序列化为意外结果。

为什么 Anchor 以 [255, …, 255] 设置账户鉴别器

通过将账户鉴别器设置为全为 1,然后 Anchor 将始终拒绝反序列化账户,因为它不会与任何账户鉴别器匹配。

将账户鉴别器写为全为 1 的原因是为了防止攻击者在运行时擦除之前直接向账户发送 SOL。在这种情况下,程序“认为”关闭了程序,但攻击者“复活”了它。如果旧的账户鉴别器仍然存在,那么被认为已删除的数据将被重新读取。

为什么不再需要将账户鉴别器设置为 [255, …, 255]

通过改变所有权为系统程序,复活账户不会导致程序突然“拥有”该账户,系统程序拥有复活的账户,攻击者浪费了 SOL。

要将所有权重新更改为程序,需要明确再次初始化,不能通过发送 SOL 来复活,以防止运行时擦除它。

通过 CLI 关闭程序

要关闭程序,而不是由其拥有的账户,我们可以使用命令行:

solana program close <address> --bypass warning

警告是一旦关闭程序,具有相同地址的程序将无法重新创建。以下是演示关闭账户的一系列 shell 命令:

solana close program cli

以下是上述截图中的一系列命令:

  1. 首先部署程序
  2. 我们关闭程序时没有使用 --bypass warning 标志,工具会警告我们无法再次部署程序
  3. 我们使用标志关闭程序,程序关闭,我们收到 2.918 SOL 作为关闭账户的退款
  4. 我们尝试再次部署,但失败,因为关闭的程序无法重新部署

通过 RareSkills 了解更多

要继续学习 Solana 开发,请查看我们的 Solana 课程 。有关其他区块链主题,请查看我们的区块链训练营

在 Anchor 中的 #[derive(Accounts)]:不同类型的账户

#[Derive(Accounts)]在 Solana Anchor 中是一个类似属性的宏,用于结构体,该结构体保存函数在执行期间将访问的所有账户的引用。

在 Solana 中,交易将访问的每个账户必须事先指定

Solana 之所以如此快,一个原因是它可以并行执行交易。也就是说,如果 Alice 和 Bob 都想执行一个交易,Solana 将尝试同时处理他们的交易。但是,如果他们的交易通过访问相同的存储而发生冲突,则会出现问题。例如,假设 Alice 和 Bob 都试图写入同一个账户。显然,他们的交易无法并行运行。

为了让 Solana 知道 Alice 和 Bob 的交易不能并行化,Alice 和 Bob 都必须事先指定他们的交易将更新的所有账户。

由于 Alice 和 Bob 都指定了一个(存储)账户,Solana 运行时可以推断出两个交易存在冲突。必须选择一个(可能是支付了更高优先级费用的那个),另一个将失败。

这就是为什么每个函数都有自己单独的#[derive(Accounts)]结构体。结构体中的每个字段都是程序在执行期间打算(但不是必须)访问的账户。

一些以太坊开发人员可能会注意到这一要求与 EIP 2930 访问列表交易的相似之处。

账户类型中你将最常使用的有:账户(Account)、未经检查的账户(Unchecked Account)、系统程序(System Program)和签名者(Signer)。

在我们用于初始化存储的代码中,我们看到了三种不同的“类型”账户:

  • 账户(Account)
  • 签名者(Signer)
  • 程序(Program)

以下是代码:

用于初始化存储的代码图,突出显示了三种类型的账户:账户、签名者和程序。

当我们读取一个账户余额时,我们看到了第四种类型:

  • 未经检查的账户(UncheckedAccount)

以下是我们使用的代码:

ReadBalance 结构的代码图,突出显示了第四种账户框:未经检查的账户。

我们用绿色框突出显示的每个项目都是通过文件顶部的anchor_lang::prelude::*;**;**导入的。

账户(Account)未经检查的账户(UncheckedAccount)签名者(Signer)程序(Program)的目的是在继续之前对传入的账户执行某种检查,并公开用于与这些账户交互的函数。

我们将在以下部分进一步解释这四种类型中的每一种。

账户(Account)

账户(Account)类型将检查加载的账户的所有者是否实际上由程序拥有。如果所有者不匹配,则不会加载。这是一个重要的安全措施,以防止意外读取程序未创建的数据。

在以下示例中,我们创建了一个密钥对账户,并尝试将其传递给foo。因为该账户不是由程序拥有,所以交易失败了。

Rust:

use anchor_lang::prelude::*;

declare_id!("ETnqC8mvPRyUVXyXoph22EQ1GS5sTs1zndkn5eGMYWfs");

#[program]
pub mod account_types {    
	use super::*;   

	pub fn foo(ctx: Context<Foo>) -> Result<()> {        
		// we don't do anything with the account SomeAccount        
		Ok(())    
		}
}

#[derive(Accounts)]
pub struct Foo<'info> {    
	some_account: Account<'info, SomeAccount>,
}

#[account]
pub struct SomeAccount {}

Typescript:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { AccountTypes } from "../target/types/account_types";

describe("account_types", () => {
	async function airdropSol(publicKey, amount) {    
		let airdropTx = await anchor
			.getProvider()
			.connection.requestAirdrop(
				publicKey, 
				amount * anchor.web3.LAMPORTS_PER_SOL
			);  
  
		await confirmTransaction(airdropTx);  
	}  

	async function confirmTransaction(tx) {    
		const latestBlockHash = await anchor
			.getProvider()
			.connection.getLatestBlockhash();
   
		await anchor
			.getProvider()
			.connection.confirmTransaction({      
				blockhash: latestBlockHash.blockhash,      	
				lastValidBlockHeight: latestBlockHash.lastValidBlockHeight,      
				signature: tx,    
		});  
	}  

// Configure the client to use the local cluster.  
anchor.setProvider(anchor.AnchorProvider.env());  

const program = anchor.workspace.AccountTypes as Program<AccountTypes>;  

it("Wrong owner with Account", async () => {    
	const newKeypair = anchor.web3.Keypair.generate();    
	await airdropSol(newKeypair.publicKey, 10);    

	await program.methods
		.foo()
		.accounts({someAccount: newKeypair
		.publicKey}).rpc();  
	});
});

这是执行测试后的输出:

尝试传递一个不属于程序所有的密钥对账户后显示的错误消息图。

如果我们为账户(Account)添加一个init宏,那么它将尝试将所有权从系统程序转移给此程序。但是,上面的代码没有init宏。

有关账户(Account)类型的更多信息,请参阅文档:https://docs.rs/anchor-lang/latest/anchor_lang/accounts/account/struct.Account.html

UncheckedAccount(未经检查的账户) 或 AccountInfo(账户信息)

UncheckedAccountAccountInfo的别名。它不检查所有权,因此必须小心,因为它将接受任意账户。

以下是使用UncheckedAccount读取其不拥有的账户数据的示例。

use anchor_lang::prelude::*;

declare_id!("ETnqC8mvPRyUVXyXoph22EQ1GS5sTs1zndkn5eGMYWfs");

#[program]
pub mod account_types {    
	use super::*;    
	
	pub fn foo(ctx: Context<Foo>) -> Result<()> {        
		let data = &ctx.accounts.some_account.try_borrow_data()?;        
		msg!("{:?}", data);        
		Ok(())    
	}
}

#[derive(Accounts)]
pub struct Foo<'info> {    
	/// CHECK: we are just printing the data    
	some_account: AccountInfo<'info>,
}

以下是我们的 Typescript 代码。请注意,我们直接调用系统程序以创建密钥对账户,以便我们可以分配 16 字节的数据。

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { AccountTypes } from "../target/types/account_types";

describe("account_types", () => {  
	const wallet = anchor.workspace.AccountTypes.provider.wallet;  
	
	// Configure the client to use the local cluster.  
	anchor.setProvider(anchor.AnchorProvider.env());  

	const program = anchor.workspace.AccountTypes as Program<AccountTypes>;  
	it("Load account with accountInfo", async () => {    
	// CREATE AN ACCOUNT NOT OWNED BY THE PROGRAM    
	const newKeypair = anchor.web3.Keypair.generate();    
	const tx = new anchor.web3.Transaction().add(      
		anchor.web3.SystemProgram.createAccount({        
			fromPubkey: wallet.publicKey,        
	 		newAccountPubkey: newKeypair.publicKey,        
			space: 16,        
			lamports: await anchor          
				.getProvider()          				
				.connection
				.getMinimumBalanceForRentExemption(32),        		
			programId: program.programId,      
		})    
	);    

	await anchor.web3.sendAndConfirmTransaction(      
		anchor.getProvider().connection,      
		tx,      
		[wallet.payer, newKeypair]    
	);    

	// READ THE DATA IN THE ACCOUNT    
	await program.methods      
		.foo()      
		.accounts({ someAccount: newKeypair.publicKey })      
		.rpc();  
	});
});

程序运行后,我们可以看到它打印出了账户中的数据,其中包含 16 个零字节:

用于验证交易签名并读取账户余额的签名者输出图。

当我们传入一个任意地址时,我们需要使用这种账户类型,但是要非常小心数据的使用方式,因为黑客可能能够在一个账户中构建恶意数据,然后将其传递给 Solana 程序。

签名者(Signer)

此类型将检查Signer账户是否签署了交易;它检查签名是否与账户的公钥匹配。

因为签名者也是一个账户,你可以读取签名者的余额或存储在账户中的数据(如果有的话),尽管其主要目的是验证签名。

根据文档(https://docs.rs/anchor-lang/latest/anchor_lang/accounts/signer/struct.Signer.html),Signer是一种验证账户是否签署了交易的类型。不会执行其他所有权或类型检查。如果使用了这个类型,就不应尝试访问底层账户数据。

Rust 示例:

use anchor_lang::prelude::*;

declare_id!("ETnqC8mvPRyUVXyXoph22EQ1GS5sTs1zndkn5eGMYWfs");#

[program]
pub mod account_types {    
	use super::*;    
	pub fn hello(ctx: Context<Hello>) -> Result<()> {        
		let lamports = ctx.accounts.signer.lamports();        
		let address = &ctx.accounts
			.signer
			.signer_key().unwrap();        
		msg!(
			"hello {:?} you have {} lamports", 
			address, 
			lamports
		);        
		Ok(())    
}}

#[derive(Accounts)]
pub struct Hello<'info> {    
	pub signer: Signer<'info>,
}

Typescript:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { AccountTypes } from "../target/types/account_types";

describe("account_types", () => {  
	anchor.setProvider(anchor.AnchorProvider.env()); 
 
	const program = anchor.workspace.AccountTypes as Program<AccountTypes>;  

	it("Wrong owner with Account", async () => {    
		await program.methods.hello().rpc();  
	});
});

以下是程序的输出:

用于验证交易签名并读取账户余额的签名者的程序输出图。

程序(Program)

这应该是不言自明的。它向 Anchor 发出信号,表明该账户是一个可执行账户,即一个程序,你可以向其发出跨程序调用。我们一直在使用的是系统程序,尽管以后我们将使用我们自己的程序。

了解更多

在我们的以太坊到 Solana 课程中, 学习 Solana 开发。

在链上读取另一个 Anchor 程序的账户数据

在 Solidity 中,读取另一个合约的存储需要调用一个view函数或者存储变量是公共的。在 Solana 中,一个链下客户端可以直接读取一个存储账户。本教程展示了一个在链上的 Solana 程序如何读取它不拥有的账户中的数据。

我们将设置两个程序:data_holderdata_reader.data_holder将初始化并拥有一个包含data_reader将要读取的数据的 PDA。

设置存储数据的data_holder程序:Shell 1

以下代码是一个初始化带有u64字段x的账户Storage并在初始化时将值 9 存储在其中的基本 Solana 程序:

Typescript 代码:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { DataHolder } from "../target/types/data_holder";
describe("data-holder", () => {
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace
    .DataHolder as Program<DataHolder>;

  it("Is initialized!", async () => {
    const seeds = [];
    const [storage, _bump] = anchor.web3.PublicKey.findProgramAddressSync(
        seeds,
        program.programId
      );

    await program.methods
      .initialize()
      .accounts({ storage: storage })
      .rpc();

    let storageStruct = await program.account.storage.fetch(
      storage
    );

    console.log(
      "The value of x is: ",
      storageStruct.x.toString()
    );

    console.log("Storage account address: ", storage.toBase58());
  });
});

测试将打印出 PDA 的地址,我们将很快引用这个地址:

图 1:PDA 地址的终端输出

读取器

为了让data_reader读取另一个账户,该账户的公钥需要作为交易的一部分通过Context结构传递。这与传递任何其他类型的账户没有区别。

账户中的数据以序列化字节的形式存储。为了反序列化账户,data_reader程序需要一个 Rust 定义的结构体,该结构体与data_holder中的Storage结构体相同:

#[account]
pub struct Storage {
    x: u64,
}

这个结构体与data_reader中的结构体完全相同 — 即使名称也必须相同(稍后我们将详细介绍原因)。读取账户的代码在以下两行中:

let mut data_slice: &[u8] = &data_account.data.borrow();

let data_struct: Storage =
    AccountDeserialize::try_deserialize(
        &mut data_slice,
    )?;

data_slice是账户中数据的原始字节。如果你运行solana account <pda 地址>(使用我们部署data_holder时生成的 PDA 地址),你可以在那里看到数据,包括我们存储在red框中的数字 9:

图 2:包含数字 9 的solana account <pda 地址>的终端输出

黄框中的前 8 个字节是账户鉴别器,稍后我们将描述它们。

反序列化发生在这一步:

let data_struct: Storage =
    AccountDeserialize::try_deserialize(
        &mut data_slice,
    )?;

在这里传递类型Storage(我们上面定义的相同结构体)告诉 Solana 如何(尝试)反序列化数据。

现在让我们在一个新文件夹中创建一个单独的 Anchor 项目 anchor new data_reader

以下是完整的 Rust 代码:

use anchor_lang::prelude::*;

declare_id!("HjJ1Rqsth5uxA6HKNGy8VVRvwK4W7aFgmQsss7UxePBw");

#[program]pub mod data_reader {
    use super::*;

    pub fn read_other_data(
        ctx: Context<ReadOtherData>,
    ) -> Result<()> {

            let data_account = &ctx.accounts.other_data;

        if data_account.data_is_empty() {
            return err!(MyError::NoData);
        }

        let mut data_slice: &[u8] = &data_account.data.borrow();

        let data_struct: Storage =
            AccountDeserialize::try_deserialize(
                &mut data_slice,
            )?;

        msg!("The value of x is: {}", data_struct.x);

        Ok(())
    }
}
#[error_code]
pub enum MyError {
    #[msg("No data")]
    NoData,
}

#[derive(Accounts)]
pub struct ReadOtherData<'info> {
    /// CHECK: We do not own this account so
    // we must be very cautious with how we
    // use the data
    other_data: UncheckedAccount<'info>,
}

#[account]
pub struct Storage {
    x: u64,
}

这是运行它的测试代码。确保在下面的代码中更改 PDA 的地址:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { DataReader } from "../target/types/data_reader";

describe("data-reader", () => {
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace
    .DataReader as Program<DataReader>;

  it("Is initialized!", async () => {
    // CHANGE THIS TO THE ADDRESS OF THE PDA OF
    // DATA ACCOUNT HOLDER
    const otherStorageAddress =
      "HRGqGCLXxLryZav2SeKJKqBWYs8Ne7ppJxf3MLM3Y71E";

    const pub_key_other_storage = new anchor.web3.PublicKey(
      otherStorageAddress
    );

    const tx = await program.methods
      .readOtherData()
      .accounts({ otherData: pub_key_other_storage })
      .rpc();
  });
});

要测试读取另一个账户的数据:

  1. 运行solana-test-validator上的data_holder测试。

  2. 复制并粘贴Storage账户的公钥

  3. 将该公钥放入data_reader测试的otherStorageAddress

  4. 在另一个 Shell 中运行 Solana 日志

  5. 运行data_reader的测试以读取数据。

在 Solana 日志中应该看到以下内容:

图 3:data_reader测试的终端输出

如果我们不给结构体相同的名称会发生什么?

如果你将data_reader中的Storage结构体更改为除Storage之外的名称,比如Storage2,并尝试读取该账户,将会出现以下错误:

图 4:更改 solana 中 data_reader 名称时的错误输出

由 Anchor 计算的账户鉴别器是结构体名称 sha256 后的前八个字节的 。账户鉴别器不依赖于结构体中的变量。

当 Anchor 读取账户时,它会检查前八个字节(账户鉴别器),看看它们是否与本地用于反序列化数据的结构体定义的账户鉴别器匹配。如果它们不匹配,Anchor 将不会反序列化数据。

检查账户鉴别器是防止客户端意外传递错误账户或其数据格式不符合 Anchor 预期的一种保护措施。

反序列化不会因解析更大的结构体而回滚

Anchor 检查账户鉴别器是否匹配 — 它不验证正在读取的账户内部的字段。

情况 1:Anchor 不检查结构体字段名称是否匹配

让我们将data_readerStorage结构体的x字段更改为y,保持data_holder中的Storage结构体不变:

// data_reader

#[account]
pub struct Storage {
    y: u64,
}

我们还需要将日志行更改如下:

msg!("The value of y is: {}", data_struct.y);

当我们重新运行测试时,它成功读取数据:

图 5:ReadOtherData 的成功终端输出

情况 2:Anchor 不检查数据类型

现在让我们将data_readerStorage中的y的数据类型更改为u32,即使原始结构体是u64

// data_reader

#[account]
pub struct Storage {
    y: u32,
}

当我们运行测试时,Anchor 仍然成功解析账户数据。

图 6:更改 data_reader 中数据类型的终端输出

这个“成功”的原因是数据的布局方式:

图 7:显示 data_reader Storage 原始字节数据的终端

7 中的9在第一个字节中可用 — 一个u32将在前 4 个字节中查找数据,因此它将能够“看到”9

当然,如果我们存储一个u32无法容纳的值在x中,比如 2³²,那么我们的读取程序将打印错误的数字。

练习: 重置验证器并重新部署data_holder,值为 2³²。在 Rust 中求幂的方法是 let result = u64::pow(base, exponent)。例如,let result = u64::pow(2, 32); 看看data_reader记录了什么值。

情况 3:解析超出数据量

存储账户大小为 16 字节。它包含 8 字节的账户鉴别器和 8 字节的u64变量。如果我们尝试读取超出账户大小的数据,比如定义一个需要超过 16 字节的值的结构体,那么读取时的反序列化将失败:

#[account]
pub struct Storage {
    y: u64,
    z: u64,
}

上面的结构体需要 16 字节来存储 y 和 z,但还需要额外的 8 字节来存储账户鉴别器,使账户大小为 24 字节。

图 8:由于提供的数据超出所需数据而导致 data_reader 初始化失败的错误

解析 Anchor 账户数据总结

当从外部账户读取数据时,Anchor 将检查账户鉴别器是否匹配,并且账户中有足够的数据可以反序列化为用作 try_deserialize 类型的结构体:

let data_struct: Storage =
    AccountDeserialize::try_deserialize(
        &mut data_slice,
    )?;

Anchor 不检查变量的名称或其长度。

在幕后,Anchor 不存储任何元数据来解释账户中的数据。它只是端到端存储的变量字节。

不是所有的数据账户都遵循 Anchor 的约定

Solana 不要求使用账户鉴别器。使用原始 Rust 编写的 Solana 程序(没有使用 Anchor 框架)可能会以一种与 Anchor 的序列化方法不直接兼容的方式存储它们的数据,而 AccountDeserialize::try_deserialize 实现了这种方法。要反序列化非 Anchor 数据,开发人员必须事先了解所使用的序列化方法 — Solana 生态系统中没有强制执行的通用约定。

从任意账户读取数据时要小心

Solana 程序默认可升级。它们在账户中存储数据的方式可能随时发生变化,这可能会破坏正在从中读取数据的程序。

从任意账户接受数据是危险的 — 通常应在读取数据之前检查账户是否由受信任的程序拥有。

Anchor 中的跨程序调用

跨程序调用(CPI)是 Solana 中一个程序调用另一个程序的公共函数的术语。

我们之前已经做过 CPI,当我们发送一个转账 SOL 交易到系统程序时。以下是相关代码片段以作提醒:

pub fn send_sol(ctx: Context<SendSol>, amount: u64) -> Result<()> {  
    let cpi_context = CpiContext::new(
        ctx.accounts.system_program.to_account_info(),
        system_program::Transfer {
            from: ctx.accounts.signer.to_account_info(),
            to: ctx.accounts.recipient.to_account_info(),
        }
    );

    let res = system_program::transfer(cpi_context, amount);

    if res.is_ok() {
        return Ok(());
    } else {
        return err!(Errors::TransferFailed);
    }
}

CpiContext 中的 Cpi 字面意思是“跨程序调用”。

调用除系统程序外的其他程序的公共函数的工作流程并没有太大不同——我们将在本教程中教授这一点。

本教程仅关注如何调用使用 Anchor 构建的另一个 Solana 程序。如果另一个程序是用纯 Rust 开发的,那么以下指南将不起作用。

在我们的运行示例中,Alice 程序将调用 Bob 程序中的一个函数。

Bob 程序

我们首先使用 Anchor 的 CLI 创建一个新项目:

anchor init bob

然后将以下代码复制粘贴到 bob/lib.rs 中。该账户有两个函数,一个用于初始化存储 u64 的存储账户,另一个函数 add_and_store 接受两个 u64 变量,将它们相加并存储在由结构体 BobData 定义的账户中。

use anchor_lang::prelude::*;
use std::mem::size_of;

// REPLACE WITTH YOUR <PROGRAM_ID>declare_id!
("8GYu5JYsvAYoinbFTvW4AACYB5GxGstz21FmZe3MNFn4");

#[program]
pub mod bob {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        msg!("Data Account Initialized: {}", ctx.accounts.bob_data_account.key());

        Ok(())
    }

    pub fn add_and_store(ctx: Context<BobAddOp>, a: u64, b: u64) -> Result<()> {
        let result = a + b;
                        
        // MODIFY/UPDATE THE DATA ACCOUNT
        ctx.accounts.bob_data_account.result = result;
        Ok(())
    }
}

#[account]
pub struct BobData {
    pub result: u64,
}

#[derive(Accounts)]
pub struct BobAddOp<'info> {   
    #[account(mut)]
    pub bob_data_account: Account<'info, BobData>,
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(init, payer = signer, space = size_of::<BobData>() + 8)]
    pub bob_data_account: Account<'info, BobData>,

    #[account(mut)]
    pub signer: Signer<'info>,

    pub system_program: Program<'info, System>,
}

本教程的目标是创建另一个程序 alice 来调用 bob.add_and_store

仍然在项目(bob)中,使用 anchor new 命令创建一个新程序:

anchor new alice

命令行应打印出 Created new program

在开始编写 Alice 程序之前,以下代码片段必须添加到 Alice 的 [dependencies] 部分的 Cargo.toml 文件中,路径为 programs/alice/Cargo.toml

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

Anchor 在后台做了大量工作。Alice 现在可以访问 Bob 的公共函数和 Bob 的结构体定义。你可以将其类比为在 Solidity 中导入一个接口,以便我们知道如何与另一个合约交互。

下面我们展示 Alice 程序。在顶部,Alice 程序导入了携带 BobAddOp 账户的结构体(用于 add_and_store)。请注意代码中的注释:

use anchor_lang::prelude::*;
// account struct for 
add_and_storeuse bob::cpi::accounts::BobAddOp;

// The program definition for Bob
use bob::program::Bob;

// the account where Bob is storing the sum
use bob::BobData;

declare_id!("6wZDNWprmb9TAZYMAPpT23kHDPABvBLT8jbWQKLHEmBy");

#[program]
pub mod alice {
    use super::*;

    pub fn ask_bob_to_add(ctx: Context<AliceOp>, a: u64, b: u64) -> Result<()> {
        let cpi_ctx = CpiContext::new(
            ctx.accounts.bob_program.to_account_info(),
            BobAddOp {
                bob_data_account: ctx.accounts.bob_data_account.to_account_info(),
            }
        );

        let res = bob::cpi::add_and_store(cpi_ctx, a, b);

        // return an error if the CPI failed
        if res.is_ok() {
            return Ok(());
        } else {
            return err!(Errors::CPIToBobFailed);
        }
    }
}

#[error_code]
pub enum Errors {
    #[msg("cpi to bob failed")]
    CPIToBobFailed,
}

#[derive(Accounts)]
pub struct AliceOp<'info> {
    #[account(mut)]
    pub bob_data_account: Account<'info, BobData>,

    pub bob_program: Program<'info, Bob>,
}

如果我们将 ask_bob_to_add 与本文顶部显示的转账 SOL 的代码片段进行比较,我们会发现很多相似之处。

Image 1: Code snippet comparison of ask_bob_to_add to the code snippet at the top of this article

要进行 CPI,需要以下内容:

  • 目标程序的引用(作为 AccountInfo)(红框)

  • 目标程序运行所需的账户列表(包含所有账户的 ctx 结构体)(绿框)

  • 传递给函数的参数(橙框)

测试 CPI

以下 Typescript 代码可用于测试 CPI:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Bob } from "../target/types/bob";
import { Alice } from "../target/types/alice";
import { expect } from "chai";

describe("CPI from Alice to Bob", () => {
  const provider = anchor.AnchorProvider.env();

  // Configure the client to use the local cluster.
  anchor.setProvider(provider);

  const bobProgram = anchor.workspace.Bob as Program<Bob>;
  const aliceProgram = anchor.workspace.Alice as Program<Alice>;
  const dataAccountKeypair = anchor.web3.Keypair.generate();

  it("Is initialized!", async () => {
    // Add your test here.
    const tx = await bobProgram.methods
      .initialize()
      .accounts({
        bobDataAccount: dataAccountKeypair.publicKey,
        signer: provider.wallet.publicKey,
        systemProgram: anchor.web3.SystemProgram.programId,
      })
      .signers([dataAccountKeypair])
      .rpc();
  });

  it("Can add numbers then double!", async () => {
    // Add your test here.
    const tx = await aliceProgram.methods
      .askBobToAddThenDouble(new anchor.BN(4), new anchor.BN(2))
      .accounts({
        bobDataAccount: dataAccountKeypair.publicKey,
        bobProgram: bobProgram.programId,
      })
      .rpc();
  });

   it("Can assert value in Bob's data account equals 4 + 2", async () => {

    const BobAccountValue = (
      await bobProgram.account.bobData.fetch(dataAccountKeypair.publicKey)    ).result.toNumber();
    expect(BobAccountValue).to.equal(6);
  });
});

一行代码进行 CPI

由于传递给 Alice 的 ctx 账户包含进行交易所需的所有账户的引用,我们可以在该结构体的 impl 中创建一个函数来完成 CPI。记住,所有 impl 都是将函数“附加”到一个结构体上,可以使用结构体中的数据。由于 ctx 结构体 AliceOp 已经包含了 Bob 进行交易所需的所有账户,我们可以将所有 CPI 代码移到:

let cpi_ctx = CpiContext::new(
    ctx.accounts.bob_program.to_account_info(),

    BobAddOp {
        bob_data_account: ctx.accounts.bob_data_account.to_account_info(),
    }
);

像这样的 impl:

let cpi_ctx = CpiContext::new(
    ctx.accounts.bob_program.to_account_info(),
    BobAddOp {
        bob_data_account:
 ctx.accounts.bob_data_account.to_account_info(),
    }
);

use anchor_lang::prelude::*;
use bob::cpi::accounts::BobAddOp;
use bob::program::Bob;
use bob::BobData;

// REPLACE WITTH YOUR <PROGRAM_ID>declare_id!
("B2BNs2GecG8Ux5EchDDFZakRWX2NDfy1RDhPCTJuJtr5");

#[program]
pub mod alice {
    use super::*;

    pub fn ask_bob_to_add(ctx: Context<AliceOp>, a: u64, b: u64) -> Result<()> {
        // Calls the `bob_add_operation` function in bob program
        let res = bob::cpi::bob_add_operation(ctx.accounts.add_function_ctx(), a, b);
        
        if res.is_ok() {
            return Ok(());
        } else {
            return err!(Errors::CPIToBobFailed);
        }
    }
}

impl<'info> AliceOp<'info> {
    pub fn add_function_ctx(&self) -> CpiContext<'_, '_, '_, 'info, BobAddOp<'info>> {
        // The bob program we are interacting with
        let cpi_program = self.bob_program.to_account_info();

        // Passing the necessary account(s) to the `BobAddOp` account struct in Bob program
        let cpi_account = BobAddOp {
            bob_data_account: self.bob_data_account.to_account_info(),
        };

        // Creates a `CpiContext` object using the new method
        CpiContext::new(cpi_program, cpi_account)
    }
}

#[error_code]
pub enum Errors {
    #[msg("cpi to bob failed")]
    CPIToBobFailed,
}

#[derive(Accounts)]
pub struct AliceOp<'info> {
    #[account(mut)]

    pub bob_data_account: Account<'info, BobData>,
    pub bob_program: Program<'info, Bob>,
}

我们能够用“一行”代码对 Bob 进行 CPI 调用。如果 Alice 程序的其他部分也需要对 Bob 进行 CPI 调用,将代码移动到 impl 中可以防止我们复制粘贴创建 CpiContext 的代码。

了解更多 RareSkills

本教程是学习 Solana 开发系列的一部分。