本文详细介绍了如何在 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 ---
l... 如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!