Solana Token 分发全流程:从 Merkle 树到链上转账实战

·

这篇文章以官方 Merkle-Distributor 为核心,拆解 Solana 智能合约发币、白名单分发、安全验证 的完整思路,帮助你 15 分钟写出可生产环境部署的 Solana Token 分发程序。

1. Solana 发币为什么推荐 Merkle 空投

传统逐笔转账在 Solana 存在 存储租金昂贵 的痛点:

Merkle 空投把 Gas 转移给 领取人,官方实测单账户领取仅 0.00001 SOL,成本低 2000 倍。同时白名单上链后不可篡改,运营仅需一次交易即可发布全局根哈希。

👉 零 Gas 空投新范式:从 20 SOL 到 0.001 SOL 的实测对比

2. 核心数据结构:MerkleDistributor 与 ClaimStatus

#[account]
pub struct MerkleDistributor {
    pub base: Pubkey,              // 创建者签名地址,用于生成 PDA
    pub bump: u8,                  // PDA bump
    pub root: [u8; 32],            // 256 bit Merkle root
    pub mint: Pubkey,              // 待分发 token 地址
    pub max_total_claim: u64,      // 可领取总额上限
    pub max_num_nodes: u64,        // 最大领取地址数
    pub total_amount_claimed: u64, // 实时已领取总额
    pub num_nodes_claimed: u64,    // 实时已领取地址数
}
#[account]
pub struct ClaimStatus {
    pub is_claimed: bool,    // 是否已领取
    pub claimant: Pubkey,    // 领取人
    pub claimed_at: i64,     // 领取时间戳
    pub amount: u64,         // 本次领取数量
}

两个数据结构只需一次链上存储即可覆盖无限领取场景,逻辑极度精简。

3. 合约入口:new_distributor 完成功能初始化

pub fn new_distributor(
    ctx: Context<NewDistributor>,
    _bump: u8,
    root: [u8; 32],
    max_total_claim: u64,
    max_num_nodes: u64,
) -> Result<()> {
    let d = &mut ctx.accounts.distributor;
    d.base = ctx.accounts.base.key();
    d.bump = unwrap_bump!(ctx, "distributor");
    d.root = root;
    d.mint = ctx.accounts.mint.key();
    d.max_total_claim = max_total_claim;
    d.max_num_nodes = max_num_nodes;
    d.total_amount_claimed = 0;
    d.num_nodes_claimed = 0;
    Ok(())
}

要点:

4. claim 逻辑:零知识校验与 SPL 转账

领取函数共分三步:

  1. Proof 验证:将链下生成的 Merkle Proof 与链上 root 比对。
  2. 防重放:若当前 (index, distributor) 对应的 ClaimStatus 已存在则直接拒绝。
  3. Token 分发:通过 PDA 签名完成 SPL 转账。
let node = keccak::hashv(&[
    &index.to_le_bytes(),
    &claimant_account.key().to_bytes(),
    &amount.to_le_bytes(),
]);

invariant!(
    merkle_proof::verify(proof, distributor.root, node.0),
    InvalidProof
);

转账时使用 Anchor 的 CPI 封装,避免手写 instruction,安全且易维护。

token::transfer(
    CpiContext::new(
        ctx.accounts.token_program.to_account_info(),
        token::Transfer {
            from: from_ata.to_account_info(),
            to: to_ata.to_account_info(),
            authority: distributor.to_account_info(),
        },
    )
    .with_signer(&[&seeds[..]]),
    amount,
)?;

领取结束后自动发出 ClaimedEvent,前端实时监听即可更新进度条。

👉 如何监听链上事件并给用户秒级反馈

5. 动态扩容技巧:更新 root 的取舍

上述程序 不允许更改 root。如需追加空投,可升级逻辑:

安全提示:随意升级 root 可能破坏原有领取承诺,必须 同步公告 最新白名单。

6. Merkle 树 & Proof 构建脚本(Python 精简版)

import hashlib, json

def encode_leaf(i: int, addr: str, amt: int) -> bytes:
    return hashlib.keccak256(
        i.to_bytes(8, 'little') +
        bytes.fromhex(addr) +
        amt.to_bytes(8, 'little')
    ).digest()

def build_tree(leaves: list[bytes]) -> tuple:
    # 标准 Merkle 树实现,略
    return root, proofs

if __name__ == '__main__':
    data = [
        {"index": 0, "address": "A9...", "amount": 1000},
        {"index": 1, "address": "B8...", "amount": 500},
    ]
    leaves = [encode_leaf(d["index"], d["address"][2:], d["amount"]) for d in data]
    root, proofs = build_tree(leaves)
    # 输出 proofs.json 供前端调用

脚本跑出的 root 可直接填入 new_distributor 参数,proofs.json 发布在前端 CDN,减少服务器压力。

FAQ

Q1:领取失败提示 InvalidProof 怎么办?
A:先校验链下脚本与合约使用的叶子序列化格式是否一致;确认 index、地址、金额顺序及小端序无误。

Q2:可以一次发给几十万地址吗?
A:逻辑支持,但建议单 Merkle 树控制在 < 10 万节点,否则 Proof 太大导致交易超出 1.2 MB 上限。

Q3:领取后还能重复领吗?
A:不能,ClaimStatus 作为唯一 PDA 阻止双花,同时合约校验 is_claimed flag。

Q4:SOL 转账与 SPL 转账傻傻分不清?
A:

Q5:官方 code 是 GPL-3.0,我能否闭源商用?
A:需遵循 GPL-3.0 开源协议;如必须闭源,可参考思路自行从零开发,确保无 GPL 代码痕迹。

Q6:前端如何拿到领取进度?
A:订阅 ClaimedEvent,按 index 与本地 merkle_proofs 列表比对,即可实时渲染已领取/未领取状态。

7. 小结:三步完成 Solana Token 分发

  1. 链下生成白名单 + Merkle root
  2. 调用 new_distributor 初始化
  3. 用户前端调用 claim,带 Proof 实时领取

整个过程零后端、零延时、零单点故障,真正做到了 去中心化空投。现在就在测试网部署试试吧!