您正在查看: 2025年3月

Solana CLI 中的持久事务 Nonces

背景

离线交易

常规Solana交易都是典型短生命周期(大约1分钟),依赖recent_blockhash。如果想提前离线生成交易,等待较长时间再发送,则会过期,交易失败。
持久交易 nonce 是一种绕过交易的典型短生命周期的机制 recent_blockhash

顺序执行

持久交易 nonce可以让交易按照顺序执行

创建Nonce账户

账户地址可以通过create-nonce-account创建nonce账户,nonce账户用于存放下一个nonce值,nonce账户必须是免租的,所以需要持有最低的余额。

solana-keygen new -o nonce-keypair.json
solana create-nonce-account nonce-keypair.json 1

查询nonce

solana nonce nonce-keypair.json

输出

8GRipryfxcsxN8mAGjy8zbFo9ezaUsh47TsPzmZbuytU

提高存储的 Nonce

solana new-nonce nonce-keypair.json

显示nonce账户

solana nonce-account nonce-keypair.json

输出

balance: 0.5 SOL
minimum balance required: 0.00136416 SOL
nonce: DZar6t2EaCFQTbUP4DHKwZ1wT8gCPW2aRfkVWhydkBvS

从nonce账户提取资金

solana withdraw-from-nonce-account nonce-keypair.json ~/.config/solana/id.json 0.5

通过提取全部余额来关闭 nonce 账户

创建 nonce 账户后重新分配权限

solana authorize-nonce-account nonce-keypair.json nonce-authority.json

CLI 实测

这里我们演示了 Alice 使用持久随机数向 Bob 支付 1 个 SOL。对于所有支持持久随机数的子命令,该过程都是相同的

创建

首先,我们需要一些 Alice、Alice 的随机数和 Bob 的账户

solana-keygen new -o alice.json
solana-keygen new -o nonce.json
solana-keygen new -o bob.json

为Alice领取测试代币

Alice 需要一些资金来创建一个 nonce 账户并发送给 Bob。空投一些 SOL

solana airdrop -k alice.json 1
1 SOL

创建 Alice 的 nonce

现在 Alice 需要一个 nonce 账户。创建一个
这里没有使用 单独的nonce 权限alice.json,因此对 nonce 账户拥有完全的权限

solana create-nonce-account -k alice.json nonce.json 0.1
3KPZr96BTsL3hqera9up82KAU462Gz31xjqJ6eHUAjF935Yf8i1kmfEbo6SVbNaACKE5z6gySrNjVRvmS8DcPuwV

第一次向Bob转账

Alice 尝试向 Bob 付款,但签名时间过长。指定的区块哈希过期,交易失败

$ solana transfer -k alice.json --blockhash expiredDTaxfagttWjQweib42b6ZHADSx94Tw8gHx11 bob.json 0.01
[2025-03-06T18:48:28.462911000Z ERROR solana_cli::cli] Io(Custom { kind: Other, error: "Transaction \"33gQQaoPc9jWePMvDAeyJpcnSPiGUAdtVg8zREWv4GiKjkcGNufgpcbFyRKRrA25NkgjZySEeKue5rawyeH5TzsV\" failed: None" })
Error: Io(Custom { kind: Other, error: "Transaction \"33gQQaoPc9jWePMvDAeyJpcnSPiGUAdtVg8zREWv4GiKjkcGNufgpcbFyRKRrA25NkgjZySEeKue5rawyeH5TzsV\" failed: None" })

Nonce 来救援

Alice 重试交易,这次指定她的 nonce 账户和存储在那里的区块哈希
记住,在这个例子中,alice.json是nonce 权限

solana nonce-account nonce.json
balance: 0.1 SOL
minimum balance required: 0.00136416 SOL
nonce: F7vmkY3DTaxfagttWjQweib42b6ZHADSx94Tw8gHx3W7
$ solana transfer -k alice.json --blockhash F7vmkY3DTaxfagttWjQweib42b6ZHADSx94Tw8gHx3W7 --nonce nonce.json bob.json 0.01
HR1368UKHVZyenmH7yVz5sBAijV6XAPeWbEiXEGVYQorRMcoijeNAbzZqEZiH8cDB8tk65ckqeegFjK8dHwNFgQ

成功

交易成功!Bob 从 Alice 处收到 0.01 SOL,Alice 存储的 nonce 值增加到新值

solana balance -k bob.json
0.01 SOL
solana nonce-account nonce.json
balance: 0.1 SOL
minimum balance required: 0.00136416 SOL
nonce: 6bjroqDcZgTv6Vavhqf81oBHTv3aMnX19UTB51YhAZnN

测试代码

const {
  Connection,
  Keypair,
  PublicKey,
  LAMPORTS_PER_SOL,
  SystemProgram,
  Transaction,
  sendAndConfirmTransaction,
  NonceAccount
} = require('@solana/web3.js');

async function main() {
  // 连接到 Solana 网络
  const connection = new Connection('https://api.devnet.solana.com', 'confirmed');

  // 创建一个新的 Keypair 作为 Nonce 账户的所有者
  const owner = Keypair.generate();

  // 创建一个新的 Keypair 作为 Nonce 账户
  const nonceAccount = Keypair.generate();

  // 计算 Nonce 账户所需的租金
  const nonceRent = await connection.getMinimumBalanceForRentExemption(NonceAccount.span);

  // 创建一个创建 Nonce 账户的交易
  const createNonceAccountTransaction = new Transaction().add(
    SystemProgram.createNonceAccount({
      fromPubkey: owner.publicKey,
      noncePubkey: nonceAccount.publicKey,
      lamports: nonceRent
    })
  );

  // 发送并确认创建 Nonce 账户的交易
  await sendAndConfirmTransaction(connection, createNonceAccountTransaction, [owner, nonceAccount]);

  // 获取 Nonce 账户的状态
  const nonceAccountState = await connection.getNonce(nonceAccount.publicKey);
  let nonce = nonceAccountState.nonce;

  // 离线签署 10 笔交易
  for (let i = 0; i < 10; i++) {
    // 创建一个新的交易
    const transaction = new Transaction().add(
      SystemProgram.transfer({
        fromPubkey: owner.publicKey,
        toPubkey: new PublicKey('your_recipient_address'),
        lamports: LAMPORTS_PER_SOL * 0.01 // 示例金额
      })
    );

    // 设置 Nonce 信息
    transaction.recentBlockhash = nonce;
    transaction.feePayer = owner.publicKey;

    // 签署交易
    transaction.sign(owner);

    // 在这里你可以将交易序列化并保存,以便离线发送
    const serializedTransaction = transaction.serialize();

    console.log(`第 ${i + 1} 笔交易的 Nonce:`, nonce);

    // 推进 Nonce
    const advanceNonceTransaction = new Transaction().add(
      SystemProgram.advanceNonceAccount({
        noncePubkey: nonceAccount.publicKey,
        authorizedPubkey: owner.publicKey
      })
    );
    await sendAndConfirmTransaction(connection, advanceNonceTransaction, [owner]);

    // 获取新的 Nonce
    const newNonceAccountState = await connection.getNonce(nonceAccount.publicKey);
    nonce = newNonceAccountState.nonce;
  }
}

main().catch(console.error);

总结

  1. 如果想离线长期保存交易或者期望交易按顺序执行,可以使用nonce账户
  2. 实现过程,查询对应的nonce账户的nonce值,代替recent_blockhash
  3. CLI中每次交易后nonce账户的nonce值会自动推进更新,对于代码调用,需要发起SystemProgram.advanceNonceAccount交易主动推进
  4. nonce值无法本地离线计算推进,每次都需要链上发起更新

https://docs.anza.xyz/cli/examples/durable-nonce

使用Anchor编写测试程序

使用Anchor编写测试程序

安装依赖

sudo apt-get update
sudo apt-get install -y \
    build-essential \
    pkg-config \
    libudev-dev llvm libclang-dev \
    protobuf-compiler libssl-dev

安装Rust

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
. "$HOME/.cargo/env"
rustc --version

安装Solana CLI

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

添加环境变量

export PATH="$HOME/.local/share/solana/install/active_release/bin:$PATH"
sudo vi /etc/profile
export PATH=$PATH:/usr/local/go/bin
source /etc/profile

版本更新

agave-install update

安装 Anchor CLI

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

版本更新

avm install latest
avm use latest

指定版本

avm install 0.30.1
avm use 0.30.1

确认版本

anchor --version

Solana CLI Basics

solana config set --url devnet

Create Wallet

solana-keygen new
Wrote new keypair to /home/surou/.config/solana/id.json
pubkey: BAGNJGtBngKZKdcZRNjebnS6mwPg5prDgj6JyR74D4ad

查看当前地址

solana address

获取测试币

solana config set -ud
solana airdrop 5

注:当前devnet最大每次5 SOL

查看余额

solana balance

Anchor CLI Basics

初始化项目名

anchor init anchor init my-project
cd my-project
anchor build
anchor deploy
anchor test

测试交易

anchor init 自动创建的测试程序

use anchor_lang::prelude::*;

declare_id!("9X2BqKSgKDrEhRsemfmkrAjpYVB78HpoWRBKTMPBPup9");

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

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        msg!("Greetings from: {:?}", ctx.program_id);
        Ok(())
    }
}

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

交易发起地址:https://explorer.solana.com/address/BAGNJGtBngKZKdcZRNjebnS6mwPg5prDgj6JyR74D4ad?cluster=devnet

部署交易

https://explorer.solana.com/tx/47xSqiSDduatTW2xXgQeDNvkN12aboAaThzFqXVHQTqsZGXhrbQRTkRiREkLk3skLfCRxJVmbHi2da5t6BkGxL9d?cluster=devnet

  • 程序账户(新建):9X2BqKSgKDrEhRsemfmkrAjpYVB78HpoWRBKTMPBPup9
  • 缓冲区账户:HHYvtbHvaUQgX6zG2BvPui8PwQ23zS5Bneqm1eELj8V6 {缓冲区账户是 Solana 生态系统中一种用于存储和管理程序数据的特殊账户类型,它为开发人员提供了灵活的数据管理和更新机制}
  • 程序存储账户:https://explorer.solana.com/address/9ZHpouNY6xrEVWjzgtFp4zsJGrViVGkS7JS4xEn9qo5f?cluster=devnet
    • Upgradeable:Yes
    • Upgrade Authority: BAGNJGtBngKZKdcZRNjebnS6mwPg5prDgj6JyR74D4ad

查看交易地址,发现很多笔与BPFLoaderUpgradeab1e11111111111111111111111的交易被打包
例如:https://explorer.solana.com/tx/2FB1tRfGn2NSXF7hHFwTeE9WAAa4eHTwmh82Ly1M4EUBcRZfTiCo2RRyjWPK6w4vD9vNNSH6RjWRudhHmtmBpWWn?cluster=devnet

Solana 上每笔交易能够携带的数据量存在限制。要是程序的字节码过大,就需要把字节码分割成多个部分,然后通过多笔交易依次上传。这就会产生很多笔与 BPFLoaderUpgradeable 相关的交易。

发起交易

执行 anchor test,自动执行程序中的initialize

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

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

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

对应交易为:https://explorer.solana.com/tx/3vzfjypR6AJVYvGgr8tiqpqTXrbYGsyMXwaidhv35haxCGFJJfDUT5oLra5dAoZpLx7tQsmTT7goZxTCuLNUz5qS?cluster=devnet

程序升级

anchor upgrade --program-id 9X2BqKSgKDrEhRsemfmkrAjpYVB78HpoWRBKTMPBPup9 ./target/deploy/my_project.so 

对应交易:https://explorer.solana.com/tx/5B8ZdZrZ3oKxMr5iF1PXdbPRPD1LZ75vEFVTmnQUUNrAhqm18kt8ydM4S6o4Z9LN3jMN69nvDXW5b4vc8PzzLWDM?cluster=devnet

对比首次部署时的交易:https://explorer.solana.com/tx/47xSqiSDduatTW2xXgQeDNvkN12aboAaThzFqXVHQTqsZGXhrbQRTkRiREkLk3skLfCRxJVmbHi2da5t6BkGxL9d?cluster=devnet

缓冲区账户由 HHYvtbHvaUQgX6zG2BvPui8PwQ23zS5Bneqm1eELj8V6 更新为 AxQ65Tm1VSptQWT22AsZFsdFovbYgo4i7dJHJLNwJjxa
类似于以太坊中的升级代理合约变更了逻辑地址

对比以太坊,Solana的程序升级简化很多

https://www.anchor-lang.com/docs/installation

ubuntu 安装just

wget https://github.com/casey/just/releases/download/1.39.0/just-1.39.0-x86_64-unknown-linux-musl.tar.gz
tar -xzf just-1.39.0-x86_64-unknown-linux-musl.tar.gz
sudo mv just /usr/local/bin/
just --version

EVM与SVM:帐户 看看在以太坊和索拉纳上构建时帐户有何不同

作为区块链网络,以太坊和索拉纳拥有独特的数据结构,作为全球公共世界计算机,在其网络上存储和共享数据。在本章中,我们旨在探索这些链如何构建其数据集。

以太坊中的帐户

在以太坊中,“帐户”是指拥有以太并可以发送交易的实体。它包括存款和取款所需的地址,分类如下:

  • EOA(外部拥有的帐户):外部拥有的帐户,拥有私钥。把它想象成个人钱包的帐户。
  • CA(合同账户):合同账户,持有智能合同代码。

以太坊中EOA和CA的一个关键区别是,EOA不是智能合约,通常没有自己的存储空间。因此,EOA的代码散列设置为“空”散列,表示帐户没有存储空间。

外部拥有帐户(EOA)是具有私钥的帐户,拥有私钥意味着控制对资金或合同的访问。私钥意味着对资金或合同的访问进行控制。EOA中包含以下数据:

合同帐户包含EOA根本无法持有的智能合同代码。此外,合同帐户没有私钥。相反,它由智能合约代码的逻辑控制。这个智能合同代码在创建合同帐户时记录在以太坊区块链上,是由EVM执行的软件程序。

与EOA一样,合同帐户有一个地址,可以发送和接收以太币。然而,当交易的目的地是合同帐户地址时,交易和交易数据将用作在EVM中执行合同的输入。除了以太外,该事务还可以包括指示要执行的合同特定功能的数据以及要传递给该函数的参数。因此,事务可以调用合同中的函数。如果EOA要求,合同也可以调用其他合同。然而,由于合同帐户没有私钥,它不能签署交易,也不能自行启动交易。这些关系总结如下:

  • EOA → EOA (OK)
  • EOA → CA (OK)
  • EOA → CA → CA (OK)
  • CA → CA (Impossible)

Solana的帐户

Solana帐户的概念比以太坊更广泛。在Solana中,所有数据都根据帐户进行存储和执行。这意味着,在每次需要在事务之间存储状态的情况下,都会使用帐户进行保存。与Linux等操作系统中的文件类似,帐户可以存储超出程序生命周期的任意数据。此外,与文件一样,帐户包含元数据,这些元数据告知运行时谁可以访问数据以及如何访问数据。

在Solana的Sealevel VM中,所有帐户都能够存储数据。那么,智能合约开发人员可以在哪里存储他们的数据呢?他们可以将数据存储在可执行帐户拥有的不可执行帐户(PDA)中。开发人员可以通过分配与其可执行帐户地址相同的所有者来创建新帐户来存储数据。

然而,存储数据的Solana网络上的“帐户”需要支付费用。这些帐户包括关于它们所含数据寿命的元数据,以名为“Lamports”的本机令牌表示。帐户存储在验证器的内存中,并支付“租金”以保留在那里。验证员定期扫描所有帐户并收取租金。Lamports降至零的帐户将自动删除,因为他们无法支付租金。如果一个帐户包含足够数量的Lamports,它将免收租金,并且不单独扣除租金。

Solana的帐户分为以下两种类型,类似于以太坊:

可执行帐户(程序帐户):这些是存储代码的智能合约,通常更简单地称为“程序”。

不可执行帐户(数据帐户):这些可以接收令牌或数据,但不能执行代码,因为可执行变量设置为“false”。

(*与以太坊不同,Solana使用“程序”而不是“合同”一词。)
比较每个链中的帐户结构揭示了以下差异。

那么,EOA和CA如何与Solana的帐户结构相对应?它们可以映射如下。

帐户抽象

以太坊长期以来一直在探索帐户抽象的概念。以太坊有两种类型的帐户:EOA和CA,每种帐户都具有明显不同的功能。值得注意的是,合同账户(CA)无法生成或签署交易,导致重大限制。交易必须通过EOA发起和签署,这意味着使用21,000天然气的基本费用,并增加了帐户管理的复杂性。帐户抽象旨在消除这些限制,允许单个帐户同时执行EOA和合同帐户的功能。
因此,可以对图表进行以下调整:

  • EOA → EOA(OK)
  • EOA → CA(OK)
  • EOA → CA → CA(OK)
  • EOA + CA(AA)→CA(现在,好的!)

例如,multisig钱包或智能合约钱包需要将少量以太坊存储在单独的EOA中以支付交易费用,导致随着时间的推移不得不补充它的不便。帐户抽象允许单个帐户执行合同和签发交易,从而改善了这种不便。通过ERC-4337,Vitalik向社区提出了这个概念,并于2021年被采纳,现在在以太坊网络中实施。

总之,帐户抽象提供了以下好处:

  • 其他人支付我的交易费用,或者我为其他人支付。
  • 使用ERC-20代币支付费用
  • 设置自定义安全规则。
  • 在密钥丢失的情况下恢复帐户。
  • 在受信任的设备或个人之间共享帐户安全。
  • 批量交易(例如,一次性授权和执行掉期)。
  • Dapp和钱包开发人员有更多机会创新用户体验。

帐户抽象是否在索拉纳中实施?

Solana自推出以来一直实施帐户抽象(AA)。如前所述,Solana将所有数据存储在称为“帐户”的单元中,分为可执行文件(程序帐户)和不可执行文件(数据帐户)。从一开始,Solana就支持程序创建和管理特定帐户(即直接发起交易)的能力。此功能扩展了Solana中的帐户抽象功能,被称为程序派生地址(PDA)。Solana程序与数据帐户不同,是包含可执行代码的可执行帐户。使用PDA,开发人员可以为交易签名设置规则和机制,允许代表Solana网络认可和批准的受控帐户(PDA)自主授权各种链上操作。因此,与以太坊不同,Solana允许直接控制另一个基于Solana程序的程序,而无需繁琐的分层。

总结

Solana的帐户概念构建了链上的所有数据,所有数据都基于帐户。
Solana原生支持AA,允许在程序之间进行自调用。

原文:https://learnblockchain.cn/article/8416

Solana eBPF 虚拟机

也许您已经看到了最近关于 Solana 虚拟机 (SVM) 的所有炒作以及在其上构建的所有激动人心的项目。也许您甚至读过我们之前关于 SVM API 的博客文章。

无论哪种情况,您可能都在寻找有关 Solana 虚拟机的更多信息。本指南将带您了解 Agave 验证器对rBPF 虚拟机的使用,解释它是什么、它如何工作以及验证器如何使用它来执行 Solana 程序。

rBPF:用于 eBPF 程序的 Rust 虚拟机和 JIT 编译器

rBPF VM

rBPF虚拟机是Quentin Monnet创建的扩展伯克利包过滤器 (eBPF) 虚拟机的 Rust 实现。在 Solana 早期,rBPF 项目在 Solana Labs 下分叉并进行了轻微修改以支持自定义 Solana 特定功能,这将在后面的部分中介绍。如今,rBPF 分叉由 Anza 工程师维护。

如存储库的 README 文件中所述,rBPF VM 旨在在用户空间而不是内核中运行。这使得 rBPF 成为 Solana 验证器用于执行程序的虚拟机的理想候选者,因为验证器的运行时在节点的用户空间中运行。

流行的术语“SVM”实际上有点用词不当。在整个生态系统中,当 Solana 开发人员提到 Solana 虚拟机 (SVM) 时,他们通常指的是 Solana 运行时内的整个事务处理管道或执行层。然而,负责执行 Solana 程序的实际虚拟机是一个 eBPF VM,受到 Solana 虚拟机指令集架构 (SVM ISA) 的限制。

Solana rBPF 是实现 SVM ISA 的 Rust 虚拟机,由 Agave 验证器使用。例如,Firedancer 有一个完全重新实现的虚拟机版本,它遵循 SVM ISA。

虚拟机本身也可以访问 Solana 协议定义的一组系统调用(“sycalls”),后面的部分会介绍这些系统调用。这些系统调用也是对较低级别的 Solana 虚拟机环境施加的约束的一部分。

伯克利包过滤器

Solana 程序被编译为伯克利数据包过滤器(BPF) 格式。BPF 最初是为伯克利软件发行版(BSD) Unix 系统设计的,用于过滤操作系统内核中的网络数据包。该格式利用类似于鉴别器的限定符,允许高效过滤数据包而无需复制数据。

BPF 程序使用这些限定符来定义数据包被捕获或丢弃的条件。它们定义了一组对寄存器、内存或数据包数据进行操作的指令(操作码)。

BPF 最终演变为扩展伯克利数据包过滤器(eBPF) 格式,Solana 的 LLVM 现已将其编译为该格式。eBPF 允许程序配置专为安全内核执行而设计的受限指令集和约束。这对 Solana 程序非常有用,因为它可以防止验证器崩溃并为所有程序创建一致的环境。

Solana 程序通常用 Rust 编写,然后由Solana 平台工具编译为 eBPF 。但是,程序也可以用Zig 、C 或汇编编写。平台工具确保程序被编译为正确的 eBPF 格式,以遵守 Solana VM 施加的 eBPF 限制,下一节将对此进行描述。

Solana rBPF ISA

如上一节所述,eBPF 允许平台(例如虚拟机)为 eBPF 程序施加严格的指令集和约束。Solana rBPF 存储库的指令集架构(ISA) 正是对此进行了定义。所有 Solana 虚拟机都必须遵守此 ISA,才能符合 Solana 协议。

第一部分介绍rBPF VM 支持的注册表。它们有 64 位宽,这意味着它们可以容纳 64 位整数或地址

name feature set kind Solana ABI
r0 all GPR Return value
r1 all GPR Argument 0
r2 all GPR Argument 1
r3 all GPR Argument 2
r4 all GPR Argument 3
r5 all GPR Argument 4
or stack spill ptr
r6 all GPR Call-preserved
r7 all GPR Call-preserved
r8 all GPR Call-preserved
r9 all GPR Call-preserved
r10 all Frame pointer System register
r11 from v2 Stack pointer System register
pc all Program counter Hidden register

寄存器是存储当前正在操作的数据的小型内存位置。ISA 定义了十个通用寄存器 (GPR)。

  • r0保存函数的返回数据。
  • r1通过r5存储函数参数,并且r5实际上可以存储“溢出”数据,这些数据由指向某些堆栈数据的指针表示。
  • r6到 都是r9调用保留寄存器,这意味着它们的值在函数调用之间得以保留。

除了 GPR 之外,还有一个帧指针 ( r10),它引用内存中的当前堆栈帧,一个堆栈指针 ( r11),它跟踪堆栈顶部的位置,以及一个程序计数器 ( pc),它保存正在执行的当前指令的地址。

下一节将介绍指令布局。如文档中所述,字节码以 64 位槽位编码,指令可以占用一个或两个槽位,由第一个槽位的操作码指示。

+-------+--------+---------+---------+--------+-----------+
| class | opcode | dst reg | src reg | offset | immediate |
| 0..3 | 3..8 | 8..12 | 12..16 | 16..32 | 32..64 | Bits
+-------+--------+---------+---------+--------+-----------+
low byte high byte

bit index meaning
0..=2 instruction class
3..=7 operation code
8..=11 destination register
12..=15 source register
16..=31 offset
32..=63 immediate

指令布局精确涵盖了指令在虚拟机中的编码方式以及每个位的含义。

  • 指令类:标识指令的类型(算术、内存访问等)。
  • 操作码:具体操作本身。
  • 目标寄存器:存储运算结果的寄存器。
  • 源寄存器:运算输入数据的来源寄存器。
  • 偏移量:用于内存访问或者跳转偏移量。
  • 立即数:常量值。

下一节将介绍 rBPF VM 支持的所有操作码。文档中提供的表格详细列出了 VM 支持的每个操作码,其中行标签是操作码的高四位,列标签是操作码的低四位。

在操作码之后,ISA 定义了一个“按类别划分的指令”部分,其中定义了有关特定操作及其约束的细节。例如,它涵盖了 32 位和 64 位算术、乘法、除法、余数、内存访问和控制流。对于每个部分,都提供了有关预期恐慌的具体信息。这些是前面提到的 eBPF 约束,它们在 SVM ISA 中明确定义。

请注意,ISA 中存在 32 位和 64 位算术定义并不意味着虚拟机可以在 32 位和 64 位架构上运行。这些部分专门定义了算术运算,这些运算可能使用 32 位进行内存优化,或在必要时使用 64 位。

ISA 中定义的恐慌相当简单。对于除法,它将除以零和负溢出定义为恐慌情况。对于内存访问,它引用越界或访问违规(即写入只读部分)。最后,对于控制流,提到了越界、对未注册函数的引用和堆栈溢出。

最后,验证部分定义了验证 eBPF 程序的规则,该规则涉及程序二进制文件的静态分析。总之,这构成了 Solana 虚拟机的整个 eBPF VM ISA 定义。

Solana VM 内置程序(加载器)

当 eBPF VM 加载二进制文件时,已编译 eBPF 程序中的函数会被读入所谓的函数注册表。不过,rBPF VM 支持所谓的“内置程序”,这些程序也有自己的函数注册表。

您可能熟悉 Solana 运行时中的这个术语,它使用内置(有时称为“本机”)程序。这两个术语的设计相同,因为两者具有一些相同的行为。Solana 本机程序为运行时提供的功能与 rBPF 虚拟机内置程序为执行 BPF 程序提供的功能相同:访问执行环境中内置的函数。

当 Solana 运行时遇到内置程序的指令(例如系统传输)时,它不会加载和执行某个已编译的 BPF 程序,而是简单地调用运行时内置的函数来执行传输。这个内置函数是系统程序,其代码实际上随 Solana 运行时一起提供,是其环境不可或缺的一部分。这些程序不存在于链上,而是在其相应地址处具有链上占位符。

类似地,在 rBPF 虚拟机环境中,执行程序实际上可以定义调用内置函数的指令。这些函数(就像运行时的内置程序一样)内置在 VM 中。

// <https://github.com/solana-labs/rbpf/blob/9d1a9a0c394e65a322be2826144b64f00fbce1a4/src/vm.rs#L365>
impl<'a, C: ContextObject> EbpfVm<'a, C> {
     /* ... */
     pub fn execute_program(
   &mut self,
   executable: &Executable<C>,
   interpreted: bool,
 ) -> (u64, ProgramResult)
}

// <https://github.com/solana-labs/rbpf/blob/7364447cba1319e8b63d54b521776439181853a7/src/elf.rs#L249>
pub struct Executable<C: ContextObject> {
 /* ... */
 function_registry: FunctionRegistry<usize>,
 loader: Arc<BuiltinProgram<C>>,
}

// <https://github.com/solana-labs/rbpf/blob/7364447cba1319e8b63d54b521776439181853a7/src/program.rs#L214>
pub struct BuiltinProgram<C: ContextObject> {
    /* ... */
    functions: FunctionRegistry<BuiltinFunction<C>>,
}

VM 级内置程序(允许可执行程序访问内置函数集)称为加载器。VM 内置函数有很多种,但 VM 加载器提供的主要函数是系统调用(或“syscall”)。

Solana 系统调用允许执行 eBPF 程序来调用其编译字节码之外的函数(内置于虚拟机中),以执行许多操作,例如:

  • 打印日志消息
  • 调用其他 Solana 程序(CPI)
  • 执行加密算术运算

与虚拟机的 ISA 类似,所有 Solana 系统调用都是 Solana 协议的一部分,并且具有明确定义的接口。这些接口的更改以及新系统调用的引入均受 Solana 改进文档 (SIMD) 流程的管理。

Agave 验证器在 BPF Loader 上实现了所有 Solana 系统调用,BPF Loader 是提供给 VM 的加载器机制。BPF Loader 已有多个版本,包括目前正在开发的Loader v4

Solana BPF Loaders 也是运行时内置程序(类似于 System 程序),由运行时调用。实际上,当链上 eBPF 程序被指令调用时,运行时实际上会调用拥有它的 BPF Loader 程序来执行它。稍后将详细介绍这一点。

程序执行

rBPF VM 库可以通过两种方式执行 eBPF 程序:通过解释器或使用 JIT 编译为 x86_64 机器代码。

解释器只是逐条执行每条指令,在运行时解释并执行每条指令。这可能会增加一点运行时开销,因为解释器必须在运行时确定每条指令的作用,然后才能执行它,但好处是加载时间大大减少。

相反,将程序实时(JIT)编译为 x86_64 机器代码可以使程序的执行速度更快,但由于初始编译,加载时间会更长。

Agave 目前使用 JIT 编译有几个原因。首先,系统调用目前是动态注册的,这意味着它们无法在[静态分析](https://seahorn.github.io/seahorn/crab/static analysis/linux extensions/ebpf/2019/07/04/seahorn-ebpf.html)期间由验证步骤处理,而是被标记为“未知”的外部函数调用。在 JIT 编译步骤中,程序二进制文件中的系统调用函数引用会链接到其注册的内置函数。

Agave之旅

了解了 rBPF 虚拟机如何工作的一般背景后,现在是时候了解一下 Agave 验证器,并确切了解当用户发送包含链上程序指令的交易时,rBPF VM 是如何用于执行 Solana 程序的。

项目部署

在开始了解 Agave 的指令处理管道之前,重要的是了解开发人员部署 Solana 程序时发生的情况。

程序部署是通过调用 BPF Loader 程序来完成的,如前所述,这是一个内置程序。作为内置程序,它允许程序访问额外的计算资源,从而实现验证请求部署的程序所必需的几个关键步骤。

solana program deploy例如,当您运行 CLI 命令时,CLI 将发送一组事务,这些事务将首先分配一个缓冲区帐户并将程序的 ELF 文件写入其中。ELF 非常大,因此这需要经过几个事务才能完成,其中 ELF 会被分块。缓冲区包含整个 ELF 后,即可“部署”程序(最后的 CLI 指令)。

当 BPF Loader 程序的“部署”指令被调用时,它将尝试验证存储在提供的缓冲账户中的 ELF,如果成功,则将 ELF 移入程序账户并将其标记为可执行。只有在成功验证之后,程序才能被 Solana 交易指令调用。

程序 ELF 的验证在 BPF Loader 的中已经做了很好的描述。步骤如下:deploy_program!

  1. 使用“严格”运行时环境将程序加载为 eBPF 可执行文件。此步骤中“严格”环境的目的是防止部署带有弃用 ELF 标头或系统调用的程序。这使用rBPF 中的方法,该方法验证 ELF 文件结构并执行指令重定位。load
  2. 根据 ISA 验证已加载的执行程序字节。这使用rBPF 中的方法。verify
  3. 使用当前运行环境重新加载程序。

ELF 验证是程序部署中非常重要的一步,因为它直接关系到虚拟机 ISA 设定的期望。BPF Loader 程序实际上将使用 rBPF 库提供的 eBPF 验证工具来验证程序二进制文件,确保它不违反任何约束。

这意味着只有有效的 Solana eBPF 程序二进制文件才能成为活动的 Solana 程序。从性能角度来看,这允许运行时通过简单检查它是否是可执行程序来快速丢弃无效的 Solana 程序二进制文件,因为它只有在通过部署验证后才能变为可执行程序。

交易管道

如前所述,运行时只有在成功部署并验证后才会遇到可执行的 BPF 程序。考虑到这一假设,可以通过 Agave 中的交易管道跟踪有效链上 BPF 程序的交易指令的生命周期。

交易由调度程序调度处理,最终通过Bank实例进行处理。Bank 使用 SVM API 的交易批处理器(具体方法是 )来处理交易。load_and_execute_sanitized_transactions

给定一批交易,处理器将首先评估是否有支付交易费用所需的账户。然后,它将过滤任何可执行程序账户,以供程序 JIT 缓存加载。

程序JIT 缓存仅仅是已经过 JIT 编译为 x86_64 机器代码(如前所述)并准备执行的程序的缓存。程序缓存的最大职责实际上是跨分支加载程序的正确版本,同时考虑到部署或关闭后可能出现的冲突版本。

不久之后,所有必要的账户都会被加载以处理交易。然后,处理交易,如果它是有效交易,则执行。执行交易涉及许多相邻的小步骤,但在本练习中,我们可以主要关注针对已加载的 BPF 程序执行指令的路径。

首先需要的是实例。这是特定于 Agave 的上下文对象,其中包含 rBPF VM 所需的许多特定于 Solana 协议的上下文配置。事实上,rBPF VM 本身是某些上下文对象的通用对象InvokeContext

/// Main pipeline from runtime to program execution.
pub struct InvokeContext<'a> {
    /// Information about the currently executing transaction.
    pub transaction_context: &'a mut TransactionContext,
    /// The local program cache for the transaction batch.
    pub program_cache_for_tx_batch: &'a mut ProgramCacheForTxBatch,
    /// Runtime configurations used to provision the invocation environment.
    pub environment_config: EnvironmentConfig<'a>,
    /// The compute budget for the current invocation.
    compute_budget: ComputeBudget,
    /// Instruction compute meter, for tracking compute units consumed against
    /// the designated compute budget during program execution.
    compute_meter: RefCell<u64>,
    log_collector: Option<Rc<RefCell<LogCollector>>>,
    /// Latest measurement not yet accumulated in [ExecuteDetailsTimings::execute_us]
    pub execute_time: Option<Measure>,
    pub timings: ExecuteDetailsTimings,
    pub syscall_context: Vec<Option<SyscallContext>>,
    traces: Vec<Vec<[u64; 12]>>,
}

在此调用上下文中,将处理事务的消息,在此期间将逐条执行每条指令。对于每条指令,将使用 eBPF VM 直接或间接调用目标程序。下一节将介绍调用样式之间的关系。

调用 BPF 程序

在运行时调用 BPF 程序的过程相当复杂。然而,本节将分解该过程,以阐明在 Agave 源代码中可以发现的各种细微差别。

首先,再次回顾一下 Solana 内置程序非常重要。正如我们提到的,这些程序内置于运行时中,因此它们不需要 eBPF 虚拟机即可执行。但是,无论如何都会使用一个。

使用 eBPF VM 执行运行时内置程序主要是为了在内置程序和 BPF 程序之间强制使用一致的接口。这个通用接口称为程序入口点。

// Psuedo-code Rust interface
fn rust(
    vm: &mut ContextObject,
    arg_a: u64,
    arg_b: u64,
    arg_c: u64,
    arg_d: u64,
    arg_e: u64,
    memory_mapping: &mut MemoryMapping,
) -> Result

回到 Agave 事务管道之旅,我们在 处停了下来InvokeContext。所有指令都由方法InvokeContext内的处理。在此方法中,仅直接调用内置程序。process_executable_chain

首先,运行时确定哪个加载器拥有目标程序。如果是本机加载器,则目标程序是内置程序。如果它是 BPF 加载器之一(所有 BPF 程序都归其所有),则调用该特定的 BPF 加载器内置程序来实际调用目标 BPF 程序。此步骤只是获取要使用的正确加载器 ID。

let builtin_id = {
    let borrowed_root_account = instruction_context
        .try_borrow_program_account(self.transaction_context, 0)
        .map_err(|_| InstructionError::UnsupportedProgramId)?;
    let owner_id = borrowed_root_account.get_owner();
    if native_loader::check_id(owner_id) {
        *borrowed_root_account.get_key()
    } else {
        *owner_id
    }
};

接下来,从加载器的函数注册表中获取对加载器的入口点函数(前面介绍的接口)的引用。这将用于调用内置加载器。

// The Murmur3 hash value (used by RBPF) of the string "entrypoint"
const ENTRYPOINT_KEY: u32 = 0x71E3CF81;
let entry = self
    .program_cache_for_tx_batch
    .find(&builtin_id)
    .ok_or(InstructionError::UnsupportedProgramId)?;
let function = match &entry.program {
    ProgramCacheEntryType::Builtin(program) => program
        .get_function_registry()
        .lookup_by_key(ENTRYPOINT_KEY)
        .map(|(_name, function)| function),
    _ => None,
}
.ok_or(InstructionError::UnsupportedProgramId)?;

再往下几行就是最终创建 eBPF VM 的地方。但是,这个 VM 只是一个模型。使用模拟 VM 可以强制遵守接口,还允许运行时将内置程序作为 rBPF 内置函数(或系统调用)调用。

let mock_config = Config::default();
let empty_memory_mapping =
    MemoryMapping::new(Vec::new(), &mock_config, &SBPFVersion::V1).unwrap();
let mut vm = EbpfVm::new(
    self.program_cache_for_tx_batch
        .environments
        .program_runtime_v2
        .clone(),
    &SBPFVersion::V1,
    // Removes lifetime tracking
    unsafe { std::mem::transmute::<&mut InvokeContext, &mut InvokeContext>(self) },
    empty_memory_mapping,
    0,
);
vm.invoke_function(function);
在 rBPF 内部,该方法仅调用 Rust 接口,而对其调用的实体一无所知。在本例中,它是一个 Solana 内置程序。invoke_functionEbpfVm
/// Invokes a built-in function
pub fn invoke_function(&mut self, function: BuiltinFunction<C>) {
    function(
        unsafe {
            std::ptr::addr_of_mut!(*self)
                .cast::<u64>()
                .offset(get_runtime_environment_key() as isize)
                .cast::<Self>()
        },
        self.registers[1],
        self.registers[2],
        self.registers[3],
        self.registers[4],
        self.registers[5],
    );
}

您可能想知道:如果运行时仅使用模拟的 eBPF VM 调用内置函数,那么我的指令的实际目标 BPF 程序在哪里被调用?答案从运行时代码中无法立即看出,因为该机制实际上是 BPF Loader 程序处理器的一部分。

如上所述,当 BPF 程序被指令瞄准时,运行时将调用其所有者,即 BPF 加载程序之一。BPF 加载程序的处理器将确定它收到了哪种类型的指令。这可以是程序帐户管理指令(即升级、关闭)或 BPF 程序的调用。

如果 BPF Loader 程序的账户在指令上下文中存在,处理器就会推断该指令是针对 BPF Loader 程序的。相反,如果推断目标程序是 BPF 程序,则会调用 BPF Loader 的函数。execute

pub fn process_instruction_inner(
    invoke_context: &mut InvokeContext,
) -> Result<u64, Box<dyn std::error::Error>> {
    /* ... */
    let program_account =
        instruction_context.try_borrow_last_program_account(transaction_context)?;

    // Program Management Instruction
    if native_loader::check_id(program_account.get_owner()) {
        /* ... */
        return {
         /* more logic ... */
         process_loader_upgradeable_instruction(invoke_context)
        }
    }

    // If the program account is not the BPF Loader program,
    // execute the BPF program.
}

BPF Loader 的功能包含真实executeeBPF VM的所有设置步骤,它将执行目标 BPF 程序。

执行 BPF 程序

如上一节所示,当使用 eBPF VM 调用 Solana 内置程序时,invoke_function将调用该方法,该方法会盲目调用内置函数。但是,当执行 BPF 程序时,运行时实际上会调用 VM 的方法。此时,正确设置 VM 变得至关重要。execute_program

Agave 运行时的介绍在上一节的 BPF Loader 函数中结束execute。在此功能中,将配置适当的 eBPF VM 并用于执行 BPF 程序。设置 VM 涉及四个重要步骤。

  1. 参数序列化
  2. 堆栈和堆的配置
  3. 内存映射配置
  4. 系统调用上下文配置

参数序列化是将规范程序参数序列化到虚拟机的内存区域(程序 ID、帐户信息、指令数据)的过程。在此步骤中,所有帐户、指令数据、程序 ID 都将被序列化,最终它们将由大多数 Solana 开发人员所熟知的 SDK 宏进行反序列化。entrypoint!

let (parameter_bytes, regions, accounts_metadata) = serialization::serialize_parameters(
    invoke_context.transaction_context,
    instruction_context,
    !direct_mapping,
)?;

接下来,配置程序内存的堆栈和堆。开发人员可以使用 Compute Budget 程序请求更多堆空间。

macro_rules! create_vm {
    /* ... */
    let stack_size = $program.get_config().stack_size();
    let heap_size = invoke_context.get_compute_budget().heap_size;
    let heap_cost_result = invoke_context.consume_checked($crate::calculate_heap_cost(
        heap_size,
        invoke_context.get_compute_budget().heap_cost,
    ));
    /* ... */
}

现在参数已序列化为内存区域,并且已配置堆栈和堆,所有这些区域都可用于构建主机内存到 VM 内存的内存映射。这会将这些新配置的区域与程序 ELF 中的区域结合起来,形成一个完整的映射,供 VM 使用。

最后,设置调用上下文中的系统调用上下文,用于存储账户字段的内存地址,以便提供更好的错误堆栈跟踪。

pub struct SyscallContext {
    pub allocator: BpfAllocator,
    pub accounts_metadata: Vec<SerializedAccountMetadata>,
    pub trace_log: Vec<[u64; 12]>,
}
pub struct SerializedAccountMetadata {
    pub original_data_len: usize,
    pub vm_data_addr: u64,
    pub vm_key_addr: u64,
    pub vm_lamports_addr: u64,
    pub vm_owner_addr: u64,
}
impl<'a> ContextObject for InvokeContext<'a> {
    fn trace(&mut self, state: [u64; 12]) {
        self.syscall_context
            .last_mut()
            .unwrap()
            .as_mut()
            .unwrap()
            .trace_log
            .push(state);
    }

    fn consume(&mut self, amount: u64) {
        // 1 to 1 instruction to compute unit mapping
        // ignore overflow, Ebpf will bail if exceeded
        let mut compute_meter = self.compute_meter.borrow_mut();
        *compute_meter = compute_meter.saturating_sub(amount);
    }

    fn get_remaining(&self) -> u64 {
        *self.compute_meter.borrow()
    }
}

ContextObjectrBPF特征在结构上的实现InvokeContext(前面提到过)显示了系统调用上下文用于提供跟踪的位置。InvokeContext还负责计量整个事务的计算单元 (CU)。该ContextObject方法consume在每个程序执行结束时调用,从而减少事务其余部分的 CU 计量。

VM 在每条指令上都会检查 CU 计量溢出,因此一旦达到最大 CU 预算,下一条指令将以 中止。这可以防止任何长时间运行的进程或无限循环通过恶意程序进程对 Solana 验证器执行拒绝服务攻击。它还会返回错误,导致在超出 CU 时交易失败。Error::ExceededMaxInstructions

完成这四个基本步骤后,就可以正确配置 eBPF VM执行程序了

Ok(EbpfVm::new(
    program.get_loader().clone(),
    program.get_sbpf_version(),
    invoke_context,
    memory_mapping,
    stack_size,
))
let (compute_units_consumed, result) = vm.execute_program(executable, !use_jit);

rBPF 中程序的执行只是执行程序二进制文件中每个操作码的过程,直到程序执行完毕。如前文所述,这可以通过解释或执行 JIT 编译的二进制文件来完成。完成后,VM 将返回一个代码,其中零表示程序成功u64

VM 中的恐慌由rBPF 库中的 处理。在 Agave 运行时端,这些错误由转换为运行时类型。例如,如果系统调用抛出,则该错误通过 传递到 VM ,然后在运行时重新转换为其正确类型。对于大多数其他变体,将抛出臭名昭著的错误。EbpfError``InstructionError``InvokeContext``InstructionError``EbpfError::SyscallError(..)``InstructionError``EbfError``InstructionError::ProgramFailedToComplete

最后,虚拟机的返回代码通过运行时传播,最终产生交易指令的程序结果。这产生了许多 Solana 开发人员共同的交易结果,至此结束了对 Agave 的运行时和虚拟机的这次游览!

随着我们继续发展和优化运行时,Anza 仍然高度关注提高性能、优化计算单元和扩展功能。在Solana 改进文档流程中发表您的意见,参与讨论!

原文:https://www.anza.xyz/blog/the-solana-ebpf-virtual-machine