白名单与权限控制
在热门 NFT 发售中,白名单 (Whitelist) 是至关重要的机制。它用于奖励早期社区成员、防止科学家(Bot)抢跑,以及避免 Gas War(网络拥堵)。
实现白名单有多种技术方案,核心挑战在于:如何在不消耗巨额链上存储成本的前提下,验证数千个地址的资格?
1. 方案对比
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 链上存储 (Map) | 将所有白名单地址写入一个巨大的 PDA 账户。 | 逻辑最简单,实时性高。 | 成本极高。存 1000 个地址需要大量 SOL 租金,且受账户大小限制。 |
| Merkle Tree | 将地址列表构建成哈希树,链上只存 根哈希 (Root Hash)。 | 成本极低 (仅存 32 字节),支持无限数量地址。 | 需要前端配合生成证明 (Proof),逻辑稍复杂。 |
| 签名验证 (Sig) | 后端服务器对用户地址签名,链上验证签名。 | 极度灵活(可动态调整),零存储成本。 | 中心化依赖。如果服务器私钥泄露或宕机,铸造将停止。 |
在 Solana 生态中,Merkle Tree 是最主流的去中心化方案。
2. Merkle Tree 白名单实现
Merkle Tree 的魔法在于:你不需要知道整棵树的所有叶子,只需要知道通往根节点的路径上的“邻居”哈希值,就能证明你属于这棵树。
2.1 链下:生成树与证明 (TypeScript)
在发售前,项目方在本地计算出 Merkle Root,并将其写入铸造程序的配置账户中。
typescriptimport { MerkleTree } from "merkletreejs"; import keccak256 from "keccak256"; import { PublicKey } from "@solana/web3.js"; // 1. 准备白名单 const whitelistAddresses = [ "User1...", "User2...", "User3..." ]; // 2. 将地址哈希化 (作为叶子节点) const leafNodes = whitelistAddresses.map(addr => keccak256(new PublicKey(addr).toBuffer()) ); // 3. 构建树 const tree = new MerkleTree(leafNodes, keccak256, { sortPairs: true }); // 4. 获取根哈希 (存入链上) const root = tree.getRoot(); console.log("Merkle Root:", root.toString('hex')); // 5. 为前端用户生成证明 // 当 User1 来铸造时,前端需要计算这个 Proof 传给合约 const leaf = keccak256(new PublicKey("User1...").toBuffer()); const proof = tree.getProof(leaf).map(p => p.data);
2.2 链上:验证证明 (Anchor Rust)
在铸造指令中,合约接收用户提供的 proof,尝试结合用户的地址(leaf)重新计算根哈希。如果计算结果与存储的 merkle_root 一致,说明用户在白名单内。
rustuse anchor_lang::solana_program::keccak; pub fn whitelist_mint( ctx: Context<WhitelistMint>, proof: Vec<[u8; 32]>, // 用户传入的证明路径 ) -> Result<()> { let config = &ctx.accounts.config; let minter = ctx.accounts.minter.key(); // 1. 计算当前用户的叶子节点哈希 let leaf = keccak::hashv(&[minter.as_ref()]).0; // 2. 验证 Merkle Proof require!( verify_merkle_proof(config.merkle_root, leaf, proof), MinterError::NotWhitelisted ); // ... 执行铸造逻辑 Ok(()) } // 验证逻辑:沿着路径向上哈希,直到根节点 fn verify_merkle_proof( root: [u8; 32], leaf: [u8; 32], proof: Vec<[u8; 32]>, ) -> bool { let mut computed_hash = leaf; for proof_element in proof.iter() { if computed_hash <= *proof_element { computed_hash = keccak::hashv(&[&computed_hash, proof_element]).0; } else { computed_hash = keccak::hashv(&[proof_element, &computed_hash]).0; } } computed_hash == root }
3. 签名验证方案 (Signature Verification)
这种方案适用于需要动态逻辑的场景(例如:必须关注 Twitter 才能铸造,或者白名单每分钟都在变)。
原理
- 后端:配置一个
Admin Keypair。 - 前端:用户请求后端,后端验证资格后,用
Admin Private Key对消息"Allow Mint: UserAddr, ExpiryTime"进行签名。 - 链上:用户发送交易时携带这个签名。合约使用存储的
Admin Public Key验证签名是否有效且未过期。
rust// Anchor 指令中验证 Ed25519 签名 use anchor_lang::solana_program::ed25519_program; // 注意:Solana 有专门的 Ed25519 预编译指令检查机制 // 通常的做法是将 Ed25519 验证指令作为交易的第一条指令 (Instruction Introspection)
或者在合约内部简单验证(消耗较多 Compute Units,不推荐用于复杂逻辑):
这种方式通常依赖 load_instruction_at_checked 检查交易中是否包含 Ed25519 程序的验证指令。