Solana 60 天课程

2025年02月27日更新 78 人订阅
原价: ¥ 36 限时优惠
专栏简介 开始 Solana - 安装与故障排除 Solana 和 Rust 中的算术与基本类型 Solana Anchor 程序 IDL Solana中的Require、Revert和自定义错误 Solana程序是可升级的,并且没有构造函数 Solidity开发者的Rust基础 Rust不寻常的语法 Rust 函数式过程宏 Rust 结构体与属性式和自定义派生宏 Rust 和 Solana 中的可见性与“继承” Solana时钟及其他“区块”变量 Solana 系统变量详解 Solana 日志、“事件”与交易历史 Tx.origin、msg.sender 和 onlyOwner 在 Solana 中:识别调用者 Solana 计算单元与交易费用介绍 在 Solana 和 Anchor 中初始化账户 Solana 计数器教程:在账户中读写数据 使用 Solana web3 js 和 Anchor 读取账户数据 在Solana中创建“映射”和“嵌套映射” Solana中的存储成本、最大存储容量和账户调整 在 Solana 中读取账户余额的 Anchor 方法:address(account).balance 功能修饰符(view、pure、payable)和回退函数在 Solana 中不存在的原因 在 Solana 上实现 SOL 转账及构建支付分配器 使用不同签名者修改账户 PDA(程序派生地址)与 Solana 中的密钥对账户 理解 Solana 中的账户所有权:从PDA中转移SOL Anchor 中的 Init if needed 与重初始化攻击 Solana 中的多重调用:批量交易与交易大小限制 Solana 中的所有者与权限 在Solana中删除和关闭账户与程序 在 Anchor 中:不同类型的账户 在链上读取另一个锚点程序账户数据 在 Anchor 中的跨程序调用(CPI) SPL Token 的运作方式 使用 Anchor 和 Web3.js 转移 SPL Token Solana 教程 - 如何实现 Token 出售 基础银行教程 Metaplex Token 元数据工作原理 使用Metaplex实施代币元数据 使用 LiteSVM 进行时间旅行测试 Solana Token-2022 标准规范 生息代币第一部分 计息代币第二部分 Solana 中的 Ed25519 签名验证

Solana 中的 Ed25519 签名验证

本文详细介绍了如何在 Solana Anchor 程序中验证链下 Ed25519 签名。通过使用 Solana 提供的 Ed25519Program 原生程序和指令内省技术,实现了一个空投场景的签名验证流程,包括构建带有 Ed25519 验证指令和空投申领指令的交易,并在链上程序中验证签名的有效性,以授权代币申领。

在 Solana Anchor 程序中验证 Ed25519 签名

本教程展示了如何在 Solana 程序中验证链下 Ed25519 签名。

在 Solana 中,自定义程序通常不自己实现诸如 Ed25519Secp256k1 签名验证之类的密码学原语,因为此类操作计算密集型,并且会在 SVM 中消耗过多的 计算单元

相反,Solana 提供了 Ed25519ProgramSecp256k1Program 作为针对签名验证进行优化的原生程序。这类似于 Ethereum 如何使用 预编译 来验证 ECDSA 签名,因为直接在 EVM 字节码中实现该逻辑会消耗过多的 gas。

尽管钱包交易也使用 Ed25519 进行签名,但这些签名由 Solana 运行时本身验证,而不是由 Ed25519Program 验证。 当你需要验证包含在交易指令数据中的签名时,例如空投索赔的分发者签名时,会使用 Ed25519Program

在本文中,我们将展示如何在 Solana 中使用 Ed25519Program指令内省 来实现签名验证。 我们的运行示例将是一个空投流程,其中分发者在链下签署包含每个接收者的钱包地址和代币数量 (recipient, amount) 的消息。链上程序负责分发空投,它验证这些签名以授权代币索赔并将 amount 转移给 recipient

Ed25519Program 是无状态的

Solana Ed25519Program 仅根据提供的输入参数执行密码签名验证。 它不维护调用之间的任何持久数据,因此,它不拥有任何帐户。 因此,它不存储验证的结果。 如果签名验证失败,则整个交易将被拒绝; 如果成功,则执行继续,并且下一个指令可以安全地假定签名有效。

我们的运行示例:空投

在空投中,我们需要一种方法来知道谁有资格申领代币。 一种方法是将所有符合条件的地址存储在链上,但这成本很高。

基于签名的空投不将所有接收者地址存储在链上,而是使用受信任的分发者(例如,项目团队)来签署包含每个接收者的钱包地址和代币数量 (recipient, amount) 的链下消息。 负责分发空投的链上程序会验证这些签名,以授权代币申领并将 amount 转移给 recipient

验证过程如何工作

签名验证过程使用指令内省,程序可以在同一交易中读取其他指令。 我们之前讨论过指令内省,现在我们将重点关注它如何应用于签名验证。

首先,我们的空投接收者提交一个包含两个指令的单一交易,在本文中,我们将它们称为Ed25519 指令(指令 1)和AirdropClaim 指令(指令 2):

回想一下,一个指令包含一个程序 ID、一个帐户列表以及程序解释的任意数据。 我们将在本文中引用这个指令结构:

pub struct Instruction {
    /// 执行此指令的程序的公钥。
    pub program_id: Pubkey,
    /// 描述应传递给程序的帐户的元数据。
    pub accounts: Vec<AccountMeta>,
    /// 传递给程序供其自行解释的不透明数据。
    pub data: Vec<u8>,
}

指令 1:用于签名验证的 Ed25519 指令

Ed25519 指令 是一个 Solana 指令,其 program_id 是原生 Ed25519Program 验证器 (Ed25519SigVerify111111111111111111111111111)。 它是我们空投交易中的第一个指令。

由于 Ed25519Program 是无状态的,因此此指令不需要任何帐户,因此所有输入都编码在指令 data 中。

Ed25519Program 的指令数据如何格式化

Ed25519program 指令中的 data 以一个 16 字节的标头开始,该标头包含指令中的签名数量和偏移量。在我们的例子中,我们只有分发者的签名计数和偏移量。这些偏移量指向 data 的其余部分,以定位已验证的公钥、消息和签名。数据的其余部分将从第 16 个字节持续到第 151 个字节。

Ed25519 指令
[字节 0..15]<br>标头(16 字节) [字节 16..47]<br>分发者的公钥(32 字节) [字节 48..111]<br>分发者的签名(64 字节) [字节 112..151]<br>消息<br>– 接收者的公钥 (0..31)<br>– 空投代币数量(32..39,小端)

这是标头的 Rust 结构:

struct Ed25519InstructionHeader {
    num_signatures: u8,   // 1 字节
    padding: u8,          // 1 字节
    offsets: Ed25519SignatureOffsets, // 14 字节
}

struct Ed25519SignatureOffsets {
    signature_offset: u16,             // 2 字节
    signature_instruction_index: u16,  // 2 字节
    public_key_offset: u16,            // 2 字节
    public_key_instruction_index: u16, // 2 字节
    message_data_offset: u16,          // 2 字节
    message_data_size: u16,            // 2 字节
    message_instruction_index: u16,    // 2 字节
}

请注意,Ed25519SignatureOffsets 结构具有以下索引:signature_instruction_indexpublic_key_instruction_indexmessage_instruction_index。 这些索引用于确定指令数据是否在当前正在执行的指令中。 当前指令数据中的索引在 Solana Ed25519 源代码 中设置为 u16::MAX

    let offsets = Ed25519SignatureOffsets {
        signature_offset: signature_offset as u16,
        signature_instruction_index: u16::MAX,
        public_key_offset: public_key_offset as u16,
        public_key_instruction_index: u16::MAX,
        message_data_offset: message_data_offset as u16,
        message_data_size: message.len() as u16,
        message_instruction_index: u16::MAX,
    };

任何其他值都将指向交易中的另一个指令。

在我们的运行空投示例中,Ed25519 指令 数据的布局如下所示。

Ed25519 指令
0..15<br>标头(16 字节) 16..47<br>分发者的公钥 48..111<br>分发者的签名 112..151<br>消息<br>– 接收者的公钥 (0..31)<br>– 空投代币数量(32..39,小端)

在实践中,你将使用 Web3.js 或 solana-ed25519-program crate 之类的链下助手来构建有效的指令。 下面是 ed25519 crate 源代码的一个片段,显示了构建指令的输入参数,然后返回一个有效的链下指令。 (Typescript 版本将在稍后显示)

use solana_ed25519_program::new_ed25519_instruction_with_signature;

pub fn new_ed25519_instruction_with_signature(
    message: &[u8],
    signature: &[u8; 64],
    pubkey: &[u8; 32],
) -> Instruction

从概念上讲,反序列化的 Ed25519 指令 如下所示:

Ed25519 指令
程序 ID Ed25519SigVerify111111111111111111111111111
帐户 []
指令数据 – 标头(签名计数 + 偏移量)<br>– 分发者的公钥<br>– 消息(接收者、数量)<br>– 分发者的签名

当交易执行时,Ed25519 指令Ed25519Program 处理。 如果签名有效,则指令执行成功。 但是,如果签名无效,它会中止交易并记录错误代码,这意味着后续指令(如 AirdropClaim 指令)不会执行。

我们将在本文后面演示此验证的实际工作方式。

指令 2:AirdropClaim 指令

AirdropClaim 指令 是发送给空投程序的用于申领空投代币的标准 Solana 交易指令。 该指令包含空投程序 ID、接收者帐户和用于内省的指令 sysvar 帐户。

AirdropClaim 指令
程序 ID 空投程序 ID
帐户 [接收者,指令 sysvar 帐户]
指令数据 无自定义数据

空投程序将首先使用指令 sysvar 内省 **** Ed25519 验证指令:指令 1 以验证:

  • Ed25519 验证指令:指令 1 程序 ID 与 Ed25519Program (Ed25519SigVerify111111111111111111111111111) 匹配。
  • Ed25519 验证指令:指令 1 没有帐户,正如无状态 Ed25519Program 所期望的那样。
  • 该指令的数据包含正确的分发者公钥、签名和消息,与预期值匹配。

如果内省显示 Ed25519 验证指令:指令 1 有效,则用户可以申领他们的空投代币。

Ed25519 验证指令AirdropClaim 指令 的执行流程

下图显示了在可以领取空投之前,我们的程序中 Ed25519 验证指令AirdropClaim 指令 的高级执行流程。

用户发送一个包含两个指令的交易:Ed25519 验证指令AirdropClaim 指令

  1. Ed25519 验证指令 转到 Ed25519Program 以验证分发者的签名。
  2. 如果签名验证失败,则整个交易失败。 如果成功,则执行流程继续。
  3. 然后将 AirdropClaim 指令 发送到 空投程序
  4. 空投程序Ed25519 验证指令 进行内省,检查其程序 ID、帐户和数据,以确认它是有效的 Ed25519 验证。
  5. 如果内省确认了 Ed25519 验证指令,则用户可以申领他们的空投代币。

一张图表,说明了 Ed25519 验证指令和 AirdropClaim 指令的执行流程。

用于空投分发的签名验证程序

让我们编写实际代码,演示如何按照我们的空投分发流程使用指令内省来验证 Ed25519 签名。 此应用程序有两个阶段:

  1. 客户端 构建交易,添加 Ed25519 验证指令:指令 1AirdropClaim 指令:指令 2,然后将交易发送到网络。
  2. 程序逻辑 通过内省验证 Ed25519 验证指令:指令 1,并允许用户申领他们的空投代币。

我们将在测试套件中实现客户端逻辑,因此让我们首先创建程序逻辑。

程序逻辑:申领验证

要跟上本节的进度,请确保你的机器上已设置 Solana 开发环境。 否则,请阅读 本系列的第一篇文章 进行设置。

通过运行 anchor 命令初始化一个 Anchor 应用程序:

 anchor init airdrop-distribution

使用这些 Anchor 导入更新 programs/airdrop-distribution/lib.rs 文件中的导入。 我们需要:

  • 用于验证的 ed25519_program 导入,
  • 以及我们在不同实例中需要它的公钥,
  • 然后我们将使用 sysvar 导入进行内省。
use anchor_lang::prelude::*;
use anchor_lang::solana_program::{
    ed25519_program,
    pubkey::Pubkey,
    sysvar::instructions as ix_sysvar,
    sysvar::SysvarId
};

保留你生成的 declare_id

declare_id!("Gh2JoycvxfreSgjzhCHuRDK7sZDAbxeo7Pd8GKCoSLmS");

接下来,我们将包含程序的其余逻辑,并逐步讲解。

该程序包含一个 claim 函数,其中包含所有逻辑。 以下是函数中发生的事情的细分:

  1. 它加载指令 sysvar 以读取完整的交易指令。
  2. 查找当前指令的索引并加载其紧邻的前一个指令。
  3. 要求前面的指令已发送到原生的 Ed25519 程序并且没有帐户。
  4. 解析 Ed25519 验证指令:指令 1 数据,然后检查标头,验证签名数量并提取偏移量。
  5. 验证标头中的所有偏移量是否指向同一指令内的数据,并专门指向签名、公钥和消息。
  6. 从数据中重建分发者的公钥,并检查它是否与预期的分发者帐户匹配。
  7. 重建签名的消息 [recipient pubkey (32)][amount (u64 little-endian)] 并检查签名消息中的接收者是否与 AirdropClaim 指令:指令 2 中的接收者帐户匹配。
use anchor_lang::prelude::*;
use anchor_lang::solana_program::{
    ed25519_program,
    pubkey::Pubkey,
    sysvar::instructions as ix_sysvar,
    sysvar::SysvarId
};

declare_id!("Gh2JoycvxfreSgjzhCHuRDK7sZDAbxeo7Pd8GKCoSLmS");

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

    pub fn claim(ctx: Context&lt;Claim>) -> Result&lt;()> {

        // --- constants for parsing Ed25519 instruction data ---
        const HEADER_LEN: usize = 16;  // fixed-size instruction header
        const PUBKEY_LEN: usize = 32;  // size of an Ed25519 public key
        const SIG_LEN: usize = 64;     // size of an Ed25519 signature
        const MSG_LEN: usize = 40;     // expected message length: [recipient(32) + amount(8)]

        // Load the instruction sysvar account (holds all tx instructions)
        let ix_sysvar_account = ctx.accounts.instruction_sysvar.to_account_info();

        // Index of the current instruction in the transaction
        let current_ix_index = ix_sysvar::load_current_index_checked(&ix_sysvar_account)
            .map_err(|_| error!(AirdropError::InvalidInstructionSysvar))?;

        // The Ed25519 verification must have run just before this instruction
        require!(current_ix_index > 0, AirdropError::InvalidInstructionSysvar);

        // Load the immediately preceding instruction (the Ed25519 ix)
        let ed_ix = ix_sysvar::load_instruction_at_checked(
            (current_ix_index - 1) as usize,
            &ix_sysvar_account,
        )
        .map_err(|_| error!(AirdropError::InvalidInstructionSysvar))?;

        // Ensure it is the Ed25519 program and uses no accounts (stateless check)
        require!(ed_ix.program_id == ed25519_program::id(), AirdropError::BadEd25519Program);
        require!(ed_ix.accounts.is_empty(), AirdropError::BadEd25519Accounts);

        // Ed25519 Verification Instruction data
        let data = &ed_ix.data;

        // --- parse Ed25519 instruction format ---
        // First byte: number of signatures (must be 1)
        // Rest of header: offsets describing where signature, pubkey, and message are
        require!(data.len() >= HEADER_LEN, AirdropError::InvalidInstructionSysvar);
        let sig_count = data[0] as usize;
        require!(sig_count == 1, AirdropError::InvalidInstructionSysvar);

        // helper to read u16 offsets from the header (little-endian)
        let read_u16 = |i: usize| -> Result&lt;u16> {
            let start = 2 + 2 * i;
            let end = start + 2;
            let src = data
                .get(start..end)
                .ok_or(error!(AirdropError::InvalidInstructionSysvar))?;
            let mut arr = [0u8; 2];
            arr.copy_from_slice(src);
            Ok(u16::from_le_bytes(arr))
        };

        // Extract the offsets for signature, pubkey, and message
        let signature_offset = read_u16(0)? as usize;
        let signature_ix_idx = read_u16(1)? as usize;
        let public_key_offset = read_u16(2)? as usize;
        let public_key_ix_idx = read_u16(3)? as usize;
        let message_offset = read_u16(4)? as usize;
        let message_size = read_u16(5)? as usize;
        let message_ix_idx = read_u16(6)? as usize;

        // Enforce that all offsets point to the current instruction's data.
        // The Ed25519 program uses u16::MAX as a sentinel value for "current instruction".
        // This prevents the program from accidentally reading signature, public key,
        // or message bytes from some other instruction in the transaction.
        let this_ix = u16::MAX as usize;
        require!(
            signature_ix_idx == this_ix
                && public_key_ix_idx == this_ix
                && message_ix_idx == this_ix,
            AirdropError::InvalidInstructionSysvar
        );

        // Ensure all offsets point beyond the 16-byte header,
        // i.e. into the region containing the signature, public key, and message
        require!(
            signature_offset >= HEADER_LEN
                 && public_key_offset >= HEADER_LEN
                 && message_offset >= HEADER_LEN,
            AirdropError::InvalidInstructionSysvar
        );

        // Bounds checks for signature, pubkey, and message slices
        require!(data.len() >= signature_offset + SIG_LEN, AirdropError::InvalidInstructionSysvar);
        require!(data.len() >= public_key_offset + PUBKEY_LEN, AirdropError::InvalidInstructionSysvar);
        require!(data.len() >= message_offset + message_size, AirdropError::InvalidInstructionSysvar);
        require!(message_size == MSG_LEN, AirdropError::InvalidInstructionSysvar);

        // --- reconstruct and validate the distributor's pubkey ---
        let pk_slice = &data[public_key_offset..public_key_offset + PUBKEY_LEN];
        let mut pk_arr = [0u8; 32];
        pk_arr.copy_from_slice(pk_slice);
        let distributor_pubkey = Pubkey::new_from_array(pk_arr);

        if distributor_pubkey != ctx.accounts.expected_distributor.key() {
            return err!(AirdropError::DistributorMismatch);
        }

        // --- reconstruct and validate the signed message ---
        // Format: [recipient pubkey (32 bytes)][amount (u64 little-endian)]
        let msg = &data[message_offset..message_offset + message_size];

        let mut rec_arr = [0u8; 32];
        rec_arr.copy_from_slice(&msg[0..32]);
        let recipient_from_msg = Pubkey::new_from_array(rec_arr);
        if recipient_from_msg != ctx.accounts.recipient.key() {
            return err!(AirdropError::RecipientMismatch);
        }

        let mut amount_bytes = [0u8; 8];
        amount_bytes.copy_from_slice(&msg[32..40]);
        let amount = u64::from_le_bytes(amount_bytes);

        // User can now claim the airdrop token.
        // The airdrop transfer can now be implemented here.

        Ok(())
    }
}

#[derive(Accounts)]
pub struct Claim&lt;'info> {
    /// The recipient of the airdrop (must match the recipient in the signed message)
    #[account(mut)]
    pub recipient: Signer&lt;'info>,

    /// Expected distributor pubkey (checked against signed message, not Anchor)
    /// CHECK: Validated manually against the parsed message
    pub expected_distributor: UncheckedAccount&lt;'info>,

    /// The sysvar containing the full transaction's instructions
    /// CHECK: Validated by requiring its well-known address
    #[account(address = ix_sysvar::Instructions::id())]
    pub instruction_sysvar: AccountInfo&lt;'info>,

    /// System program used for the transfer
    pub system_program: Program&lt;'info, System>,
}

#[error_code]
pub enum AirdropError {
    #[msg("Invalid instruction sysvar")]
    InvalidInstructionSysvar,
    #[msg("Expected Ed25519 program id")]
    BadEd25519Program,
    #[msg("Bad Ed25519 accounts")]
    BadEd25519Accounts,
    #[msg("Distributor public key mismatch")]
    DistributorMismatch,
    #[msg("Recipient mismatch in message")]
    RecipientMismatch,
}

让我们解释一下上面代码的关键部分。 我们将介绍:

  1. 上面的代码如何使用 Solana Rust SDK 提供的相对指令索引辅助函数从 sysvar 帐户加载 Ed25519 验证指令:指令 1
  2. 访问和验证 Ed25519 验证指令:指令 1 数据
  3. 检索标头区域中的签名计数和偏移量
  4. 验证以确保我们正在访问当前交易中的准确签名、公钥和消息
  5. 访问指令数据中的分发者签名、公钥和消息

我们将分享上面程序代码的每个关键部分的屏幕截图,并在以下部分中进行讨论。

1. 内省:加载和验证 Ed25519 验证指令:指令 1

我们程序代码中的以下屏幕截图显示了我们如何通过指令 sysvar 使用指令内省来验证 Ed25519 验证指令:指令 1

  1. 我们调用 load_current_index_checked() 来获取当前指令的索引,并调用 load_instruction_at_checked() 来加载紧邻的前一个指令。
  2. 一旦我们有了前面的指令(Ed25519 验证指令:指令 1),我们:
    • 验证其程序 ID 是否与 Ed25519Program 匹配。 这确保了该指令确实是 Ed25519 签名验证。
    • 并确认指令帐户列表为空。
  3. 一旦这些检查成功,我们提取指令的数据,它是一个向量,并将其绑定到变量 data

一张屏幕截图,显示了有关如何加载和验证 Ed25519 验证指令的代码片段

现在,我们已经成功验证了顶层 ed2559Program 指令信息:ID 和帐户。 我们还获取了 Ed25519 验证指令:指令 1 数据,因此,下一步是验证数据的内容。 该数据是 u8 数据类型的向量。

2. 访问和验证 Ed25519 验证指令:指令 1 数据

我们期望指令数据按顺序编码:指定签名计数和以下字段偏移量的标头; 分发者的公钥; 消息; 以及分发者的 Ed25519 签名。

一张屏幕截图,显示了显示 Ed25519 指令内容的表

现在,我们将逐步执行代码的下一部分,以了解空投程序如何访问和验证 Ed25519 验证指令:指令 1 数据。

3. 检索标头区域中的签名计数和偏移量

下图中的代码从 Ed25519 验证指令:指令 1 数据向量中提取签名计数、偏移量和指向每个元素所在位置的索引。

在标头中,签名计数应位于第一个索引中,我们使用 data[0] 获取该索引。 期望的计数为 1,因为应该只有一个分发者签名。 我们使用 require 语句强制执行该要求。

之后,标头包含偏移量和索引值,这些值告诉我们在指令数据中找到分发者的公钥、签名和消息的位置。

为了解析它们,我们定义了一个闭包 read_u16,它一次 2 个字节地遍历数据缓冲区,并将每个偏移量作为 u16 返回。 这使得重建一致的指令数据布局变得更加容易。

一张屏幕截图,显示了用于检索指令数据标头区域中的签名计数和偏移量的代码片段。

4. 验证以确保我们正在访问当前指令中的准确签名、公钥和消息

此时,我们有签名计数和偏移量,但我们需要确保:

  1. 我们正在与从 sysvar 加载的指令作为当前指令进行交互。 回想一下,当前指令数据中签名 (signature_ix_idx)、公钥 (public_key_ix_idx) 和消息 (message_ix_idx) 的索引在 Ed25519 源代码中设置为 u16::MAX。 任何其他值都将指向交易中的另一个指令。
  2. 这些偏移量指向 16 字节标头之外,指向向量中包含签名、公钥和消息的部分。

一张屏幕截图,解释了:验证以确保我们正在按照我们的程序代码访问当前指令中的准确签名、公钥和消息。

5. 访问指令数据向量中的分发者签名、公钥和消息

下图显示了我们如何使用从 Ed25519 验证指令:指令 1 数据标头解析的偏移量来定位指令数据中的分发者公钥和消息内容(接收者和金额),并根据用户在 AirdropClaim 指令:指令 2 中提供的版本进行验证。

  • 第一个标记区域显示了我们如何从 Ed25519 指令 数据中切出分发者的公钥,将其重建为 32 字节的 Pubkey,并将其与 AirdropClaim 指令:指令 2 中分发者帐户中的 expected_distributor 公钥进行比较。
  • 第二个标记区域显示了我们如何切出签名的消息(接收者 + 金额),重建接收者公钥,并验证它是否与 AirdropClaim 指令:指令 2 中的 recipient 帐户匹配。

如果两次检查都成功,则签名验证完成。 此时,你可以实现代币转账到接收者。 由于本文重点关注验证,因此我们没有实现转移。

一张屏幕截图,显示了如何访问指令数据向量中的分发者签名、公钥和消息

客户端:链下构建交易

我们已经了解了签名验证的工作方式。 现在,让我们通过创建一个包含两个指令(Ed25519 验证指令:指令 1AirdropClaim 指令:指令 2)的交易来对其进行测试。

依赖项

我们将使用 tweetnacl 密码库来创建分发者签名,因此通过运行以下命令来安装它:

yarn add tweetnacl

完成后,将 tweetnacl 添加到 tests/airdrop-distribution.ts 中的导入中,如下所示。 我们将使用 Ed25519Program 依赖项来创建第一个用于验证的指令,而 TransactionInstruction 是预期的标准交易指令类型。

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

// Add the following
import { Airdrop } from "../target/types/airdrop"; // The IDL
import {
    PublicKey,
    Keypair,
    SystemProgram,
    Transaction,
    **TransactionInstruction,
    Ed25519Program**
} from "@solana/web3.js";
import * as nacl from "tweetnacl";

我们将有四个测试用例:

  1. Valid claim: 分发者签署正确的接收者和金额,Ed25519Program 指令在 claim 指令之抢跑,然后交易成功。
  2. Wrong order: claim 指令在 Ed25519Program 之前,交易失败,出现 InvalidInstructionSysvar 错误。
  3. Wrong distributor: 签名与 expectedDistributor 签名不匹配,交易失败,出现 DistributorMismatch 错误。
  4. Wrong recipient: 签名的接收者与尝试申领空投签名的用户不同,交易失败,出现 RecipientMismatch 错误。
  5. Multiple claims: 一个测试用例,用于表明通过构建多个 AirdropClaim 指令 来欺骗系统的尝试将会失败。 这是因为程序的内省逻辑仅查看紧邻的前一个 Ed25519 验证指令:指令 1,因此第二个 AirdropClaim 指令 将失败。

首先设置测试以使用本地集群,并为分发者、接收者和无效分发者帐户设置测试帐户以用于负面测试用例。

// ...
describe("airdrop", () => {
  // Configure the client to use the local cluster
  anchor.setProvider(anchor.AnchorProvider.env());
  const program = anchor.workspace.Airdrop as Program&lt;Airdrop>;
  const provider = anchor.getProvider();

  // Test accounts
  let distributorKeypair: Keypair;
  let recipientKeypair: Keypair;
  let invalidDistributorKeypair: Keypair;

  before(async () => {
    // Generate test keypairs
    distributorKeypair = Keypair.generate();
    recipientKeypair = Keypair.generate();
    invalidDistributorKeypair = Keypair.generate();
  });

接下来,我们将添加一个辅助函数,该函数构建 Ed25519 验证指令:指令 1。 它从接收者和金额构建消息,使用分发者的密钥对其进行签名,然后使用 Ed25519Program.createInstructionWithPublicKey 返回运行时可以验证的 TransactionInstruction

function createEd25519Instruction(
  distributorKeypair: Keypair,
  recipientPubkey: PublicKey,
  amount: number
): TransactionInstruction {
  // Build the message: 32 bytes recipient pubkey + 8 bytes amount
  const message = Buffer.alloc(40);
  recipientPubkey.toBuffer().copy(message, 0);
  message.writeBigUInt64LE(BigInt(amount), 32);

  // Sign the message with distributor
  const signature = nacl.sign.detached(message, distributorKeypair.secretKey);

  // Use the helper to build the instruction
  return Ed25519Program.createInstructionWithPublicKey({
    publicKey: distributorKeypair.publicKey.toBytes(),
    message,
    signature,
  });
}
```我们将在我们的测试用例中重用上述函数来创建 `Ed25519 Verification Instruction: Instruction 1`。让我们从我们的第一个测试用例开始,这是一个有效的空投声明,应该成功。

我们创建两个指令:`Ed25519 Verification Instruction: Instruction 1` 和 `AirdropClaim Instruction: Instruction 2`。我们将 distributor、recipient 和 instruction sysvar 账户传递给程序的 `claim` 函数,如前所述。然后我们发送交易并确认它成功。成功时,它返回一个交易 ID;否则,我们会收到一个错误。

```tsx hljs language-typescript
  it("Successfully claims airdrop with valid signature", async () => {
  const claimAmount = 1000000;

  // Create Ed25519 Signature Verification Instruction: Instruction 1
  const ed25519Ix = createEd25519Instruction(
    distributorKeypair,
    recipientKeypair.publicKey,
    claimAmount
  );

  // Create the AirdropClaim Instruction: Instruction 2
  const claimIx = await program.methods
    .claim()
    .accountsPartial({
      recipient: recipientKeypair.publicKey,
      expectedDistributor: distributorKeypair.publicKey,
      instructionSysvar: anchor.web3.SYSVAR_INSTRUCTIONS_PUBKEY,
    })
    .instruction();

  const tx = new Transaction();
  tx.add(ed25519Ix); // Add Instruction 1 to the transaction
  tx.add(claimIx); // Add Instruction 2 to the transaction

  // Just expect the transaction to succeed
  expect(await provider.sendAndConfirm(tx, [recipientKeypair])).to.not.be.empty;
});

失败的情况将涉及相同的过程,我们只需要添加会导致它们失败的无效数据。因此,这是包含解释性注释的完整测试代码。

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Airdrop } from "../target/types/airdrop";
import { PublicKey, Keypair, SystemProgram, Transaction, TransactionInstruction, Ed25519Program } from "@solana/web3.js";
import { expect } from "chai";
import * as nacl from "tweetnacl";

describe("airdrop", () => {
  // Configure the client to use the local cluster
  anchor.setProvider(anchor.AnchorProvider.env());
  const program = anchor.workspace.Airdrop as Program&lt;Airdrop>;
  const provider = anchor.getProvider();

  // Test accounts
  let distributorKeypair: Keypair;
  let recipientKeypair: Keypair;
  let invalidDistributorKeypair: Keypair;

  before(async () => {
    // Generate test keypairs
    distributorKeypair = Keypair.generate();
    recipientKeypair = Keypair.generate();
    invalidDistributorKeypair = Keypair.generate();
  });

function createEd25519Instruction(
  distributorKeypair: Keypair,
  recipientPubkey: PublicKey,
  amount: number
): TransactionInstruction {
  // Build the message: 32 bytes recipient pubkey + 8 bytes amount
  const message = Buffer.alloc(40);
  recipientPubkey.toBuffer().copy(message, 0);
  message.writeBigUInt64LE(BigInt(amount), 32);

  // Sign the message with distributor
  const signature = nacl.sign.detached(message, distributorKeypair.secretKey);

  // Use the helper to build the instruction
  return Ed25519Program.createInstructionWithPublicKey({
    publicKey: distributorKeypair.publicKey.toBytes(),
    message,
    signature,
  });
}

  it("Successfully claims airdrop with valid signature", async () => {
      const claimAmount = 1000000;

      const ed25519Ix = createEd25519Instruction(
        distributorKeypair,
        recipientKeypair.publicKey,
        claimAmount
      );

      const claimIx = await program.methods
        .claim()
        .accountsPartial({
          recipient: recipientKeypair.publicKey,
          expectedDistributor: distributorKeypair.publicKey,
          instructionSysvar: anchor.web3.SYSVAR_INSTRUCTIONS_PUBKEY,
        })
        .instruction();

      const tx = new Transaction();
      tx.add(ed25519Ix);
      tx.add(claimIx); // AirdropClaim Instruction: Instruction 2

      // Just expect the transaction to succeed
      expect(await provider.sendAndConfirm(tx, [recipientKeypair])).to.not.be.empty;
    });

  it("Fails when Ed25519 instruction is not first", async () => {
    const claimAmount = 1000000;

    const claimIx = await program.methods
      .claim()
      .accountsPartial({
        recipient: recipientKeypair.publicKey,
        expectedDistributor: distributorKeypair.publicKey,
        instructionSysvar: anchor.web3.SYSVAR_INSTRUCTIONS_PUBKEY,
      })
      .instruction();

    const ed25519Ix = createEd25519Instruction(
      distributorKeypair,
      recipientKeypair.publicKey,
      claimAmount
    );

    // Create transaction with claim first, then Ed25519 (wrong order)
    const tx = new Transaction();
    tx.add(claimIx);
    tx.add(ed25519Ix);

    try {
      await provider.sendAndConfirm(tx, [recipientKeypair]);
      expect.fail("Should have failed with wrong instruction order");
    } catch (error) {
      expect(error.message).to.include("InvalidInstructionSysvar");
    }
  });

  it("Fails with distributor mismatch", async () => {
    const claimAmount = 1000000;

    // Create Ed25519 instruction with wrong distributor
    const ed25519Ix = createEd25519Instruction(
      invalidDistributorKeypair, // Wrong distributor signs
      recipientKeypair.publicKey,
      claimAmount
    );

    const claimIx = await program.methods
      .claim()
      .accountsPartial({
        recipient: recipientKeypair.publicKey,
        expectedDistributor: distributorKeypair.publicKey, // But we expect the correct one
        instructionSysvar: anchor.web3.SYSVAR_INSTRUCTIONS_PUBKEY,
      })
      .instruction();

    const tx = new Transaction();
    tx.add(ed25519Ix);
    tx.add(claimIx);

    try {
      await provider.sendAndConfirm(tx, [recipientKeypair]);
      expect.fail("Should have failed with distributor mismatch");
    } catch (error) {
      expect(error.message).to.include("DistributorMismatch");
    }
  });

  it("Fails with recipient mismatch", async () => {
    const claimAmount = 1000000;
    const wrongRecipient = Keypair.generate();

    // Create Ed25519 instruction with wrong recipient in message
    const ed25519Ix = createEd25519Instruction(
      distributorKeypair,
      wrongRecipient.publicKey, // Wrong recipient in signed message
      claimAmount
    );

    const claimIx = await program.methods
      .claim()
      .accountsPartial({
        recipient: recipientKeypair.publicKey,
        expectedDistributor: distributorKeypair.publicKey,
        instructionSysvar: anchor.web3.SYSVAR_INSTRUCTIONS_PUBKEY,
      })
      .instruction();

    const tx = new Transaction();
    tx.add(ed25519Ix);
    tx.add(claimIx);

    try {
      await provider.sendAndConfirm(tx, [recipientKeypair]);
      expect.fail("Should have failed with recipient mismatch");
    } catch (error) {
      expect(error.message).to.include("RecipientMismatch");
    }
  });

   it("Fails when multiple claim instructions try to reuse the same Ed25519 signature", async () => {
        const claimAmount = 1000000;

        // Create a single Ed25519 instruction
        const ed25519Ix = createEd25519Instruction(
          distributorKeypair,
          recipientKeypair.publicKey,
          claimAmount
        );

        // First claim instruction (valid)
        const claimIx1 = await program.methods
          .claim()
          .accountsPartial({
            recipient: recipientKeypair.publicKey,
            expectedDistributor: distributorKeypair.publicKey,
            instructionSysvar: anchor.web3.SYSVAR_INSTRUCTIONS_PUBKEY,
          })
          .instruction();

        // Second claim instruction (tries to reuse the same Ed25519)
        const claimIx2 = await program.methods
          .claim()
          .accountsPartial({
            recipient: recipientKeypair.publicKey,
            expectedDistributor: distributorKeypair.publicKey,
            instructionSysvar: anchor.web3.SYSVAR_INSTRUCTIONS_PUBKEY,
          })
          .instruction();

        const tx = new Transaction();
        tx.add(ed25519Ix);
        tx.add(claimIx1);
        tx.add(claimIx2);

        try {
          await provider.sendAndConfirm(tx, [recipientKeypair]);
          expect.fail("Should have failed because multiple claims tried to reuse the same signature");
        } catch (error) {
          // The second claim fails because its immediately preceding instruction
          // is not the Ed25519 verification, so the program throws
          expect(error.message).to.include("BadEd25519Program");
        }
  });

});

让我们使用以下命令运行测试:

anchor test

结果应该如下所示:

显示成功测试结果的屏幕截图

到目前为止,我们的实现一直专注于签名验证。请理解,此示例仅用于学习目的,在创建和发送真实交易时,应考虑标准的程序安全最佳实践。

在某些情况下,错误的偏移实现引入了漏洞。文章 Wrong Offset: Bypassing Signature Verification. 中介绍了一个这样的例子。虽然我们在本文中学到的内容不受该漏洞的影响,但值得注意潜在的风险。

本文Solana 教程系列 的一部分。

  • 原文链接: rareskills.io/post/solan...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论