本文详细介绍了如何在 Solana Anchor 程序中验证链下 Ed25519 签名。通过使用 Solana 提供的 Ed25519Program
原生程序和指令内省技术,实现了一个空投场景的签名验证流程,包括构建带有 Ed25519 验证指令和空投申领指令的交易,并在链上程序中验证签名的有效性,以授权代币申领。
本教程展示了如何在 Solana 程序中验证链下 Ed25519 签名。
在 Solana 中,自定义程序通常不自己实现诸如 Ed25519
或 Secp256k1
签名验证之类的密码学原语,因为此类操作计算密集型,并且会在 SVM 中消耗过多的 计算单元。
相反,Solana 提供了 Ed25519Program
和 Secp256k1Program
作为针对签名验证进行优化的原生程序。这类似于 Ethereum 如何使用 预编译 来验证 ECDSA 签名,因为直接在 EVM 字节码中实现该逻辑会消耗过多的 gas。
尽管钱包交易也使用 Ed25519
进行签名,但这些签名由 Solana 运行时本身验证,而不是由 Ed25519Program
验证。 当你需要验证包含在交易指令数据中的签名时,例如空投索赔的分发者签名时,会使用 Ed25519Program
。
在本文中,我们将展示如何在 Solana 中使用 Ed25519Program
和 指令内省 来实现签名验证。 我们的运行示例将是一个空投流程,其中分发者在链下签署包含每个接收者的钱包地址和代币数量 (recipient, amount)
的消息。链上程序负责分发空投,它验证这些签名以授权代币索赔并将 amount
转移给 recipient
。
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>,
}
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_index
、public_key_instruction_index
和 message_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 指令
)不会执行。
我们将在本文后面演示此验证的实际工作方式。
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 指令
。
Ed25519 验证指令
转到 Ed25519Program
以验证分发者的签名。AirdropClaim 指令
发送到 空投程序。Ed25519 验证指令
进行内省,检查其程序 ID、帐户和数据,以确认它是有效的 Ed25519
验证。Ed25519 验证指令
,则用户可以申领他们的空投代币。让我们编写实际代码,演示如何按照我们的空投分发流程使用指令内省来验证 Ed25519 签名。 此应用程序有两个阶段:
Ed25519 验证指令:指令 1
和 AirdropClaim 指令:指令 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
函数,其中包含所有逻辑。 以下是函数中发生的事情的细分:
sysvar
以读取完整的交易指令。Ed25519
程序并且没有帐户。Ed25519 验证指令:指令 1
数据,然后检查标头,验证签名数量并提取偏移量。[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<Claim>) -> Result<()> {
// --- 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<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<'info> {
/// The recipient of the airdrop (must match the recipient in the signed message)
#[account(mut)]
pub recipient: Signer<'info>,
/// Expected distributor pubkey (checked against signed message, not Anchor)
/// CHECK: Validated manually against the parsed message
pub expected_distributor: UncheckedAccount<'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<'info>,
/// System program used for the transfer
pub system_program: Program<'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,
}
让我们解释一下上面代码的关键部分。 我们将介绍:
Ed25519 验证指令:指令 1
Ed25519 验证指令:指令 1
数据我们将分享上面程序代码的每个关键部分的屏幕截图,并在以下部分中进行讨论。
Ed25519 验证指令:指令 1
我们程序代码中的以下屏幕截图显示了我们如何通过指令 sysvar 使用指令内省来验证 Ed25519 验证指令:指令 1
。
load_current_index_checked()
来获取当前指令的索引,并调用 load_instruction_at_checked()
来加载紧邻的前一个指令。Ed25519 验证指令:指令 1
),我们:
Ed25519Program
匹配。 这确保了该指令确实是 Ed25519 签名验证。data
。现在,我们已经成功验证了顶层 ed2559Program
指令信息:ID 和帐户。 我们还获取了 Ed25519 验证指令:指令 1
数据,因此,下一步是验证数据的内容。 该数据是 u8
数据类型的向量。
Ed25519 验证指令:指令 1
数据我们期望指令数据按顺序编码:指定签名计数和以下字段偏移量的标头; 分发者的公钥; 消息; 以及分发者的 Ed25519 签名。
现在,我们将逐步执行代码的下一部分,以了解空投程序如何访问和验证 Ed25519 验证指令:指令 1
数据。
下图中的代码从 Ed25519 验证指令:指令 1
数据向量中提取签名计数、偏移量和指向每个元素所在位置的索引。
在标头中,签名计数应位于第一个索引中,我们使用 data[0]
获取该索引。 期望的计数为 1,因为应该只有一个分发者签名。 我们使用 require
语句强制执行该要求。
之后,标头包含偏移量和索引值,这些值告诉我们在指令数据中找到分发者的公钥、签名和消息的位置。
为了解析它们,我们定义了一个闭包 read_u16
,它一次 2 个字节地遍历数据缓冲区,并将每个偏移量作为 u16
返回。 这使得重建一致的指令数据布局变得更加容易。
此时,我们有签名计数和偏移量,但我们需要确保:
signature_ix_idx
)、公钥 (public_key_ix_idx
) 和消息 (message_ix_idx
) 的索引在 Ed25519 源代码中设置为 u16::MAX
。 任何其他值都将指向交易中的另一个指令。下图显示了我们如何使用从 Ed25519 验证指令:指令 1
数据标头解析的偏移量来定位指令数据中的分发者公钥和消息内容(接收者和金额),并根据用户在 AirdropClaim 指令:指令 2
中提供的版本进行验证。
Ed25519 指令
数据中切出分发者的公钥,将其重建为 32 字节的 Pubkey
,并将其与 AirdropClaim 指令:指令 2
中分发者帐户中的 expected_distributor
公钥进行比较。AirdropClaim 指令:指令 2
中的 recipient
帐户匹配。如果两次检查都成功,则签名验证完成。 此时,你可以实现代币转账到接收者。 由于本文重点关注验证,因此我们没有实现转移。
我们已经了解了签名验证的工作方式。 现在,让我们通过创建一个包含两个指令(Ed25519 验证指令:指令 1
和 AirdropClaim 指令:指令 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";
我们将有四个测试用例:
Ed25519Program
指令在 claim
指令之抢跑,然后交易成功。claim
指令在 Ed25519Program
之前,交易失败,出现 InvalidInstructionSysvar
错误。expectedDistributor
签名不匹配,交易失败,出现 DistributorMismatch
错误。RecipientMismatch
错误。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<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<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 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!