dApp Docs/Merkle空投Airdrop合约实现
Development reference. Not independently verified for production.

Merkle空投Airdrop合约实现 —— MSG Chain 完整指南

适用环境: MSG Chain (msg-chain-1) | CosmWasm 1.x | CW20 标准代币
主网状态: No-Go


目录

  1. Merkle空投原理
  2. 合约完整实现
  3. 后端生成脚本
  4. 空投部署与分发
  5. 前端Claim页面
  6. 扩展功能
  7. 安全考虑
  8. 附录

1. Merkle空投原理

1.1 什么是Merkle Tree

Merkle Tree(默克尔树)是一种二叉树数据结构,每个叶子节点存储数据块的哈希值,每个非叶子节点存储其两个子节点哈希的哈希值。树的根节点称为 Merkle Root(默克尔根),是整棵树的唯一指纹。

        Root = H(H1 + H2)
       /                \
     H1 = H(L1+L2)     H2 = H(L3+L4)
     /        \         /        \
   L1        L2        L3        L4
   (addr1,   (addr2,   (addr3,   (addr4,
    amount1)  amount2)  amount3)  amount4)

在空投场景中:

1.2 为什么使用Merkle Proof进行空投

传统空投方式的问题:

方式 问题
链上存储所有地址 Gas 费用极高,存储受限
逐一转账 Gas 费用极高,等待时间长
中心化分发 信任问题,运营复杂

Merkle Proof 空投的优势:

  1. Gas 效率极高: 只有根节点存储在链上(32字节),所有参与数据在链下
  2. 计算在链下: Merkle 树的生成和证明计算均在链下完成
  3. 无信任假设: 用户可自行验证证明的正确性,无需依赖第三方
  4. 可扩展性: 支持数百万参与地址,链上存储不变
  5. 按需领取: 用户主动触发 claim,Gas 由用户承担

1.3 承诺-揭示模式

Merkle 空投基于密码学中的承诺-揭示(Commit-Reveal)模式:

承诺阶段(部署时):

  1. 确定空投列表(地址 -> 数量)
  2. 构建 Merkle Tree
  3. 将 Merkle Root 部署到合约中
  4. 将完整空投列表和证明存储到链下(IPFS / 中心化服务器)

揭示阶段(Claim时):

  1. 用户提供自己的地址和应得数量
  2. 用户提供 Merkle Proof(从叶子到根的路径)
  3. 合约验证 proof 是否可以得到根节点
  4. 验证通过后,代币发放给用户

1.4 哈希函数

MSG Chain 使用 SHA-256 作为标准哈希函数。

use sha2::{Digest, Sha256};

fn hash_pair(a: &[u8], b: &[u8]) -> [u8; 32] {
    let mut hasher = Sha256::new();
    if a < b {
        hasher.update(a);
        hasher.update(b);
    } else {
        hasher.update(b);
        hasher.update(a);
    }
    let result = hasher.finalize();
    let mut hash = [0u8; 32];
    hash.copy_from_slice(&result);
    hash
}

为什么需要排序拼接? 如果不排序,攻击者可以构造一个不同的树,使得不同的叶子节点产生相同的根节点(第二原像攻击)。排序拼接消除了这种可能性。

1.5 叶子节点编码

每个叶子节点包含地址和数量,编码方式为:

leaf_hash = SHA256(address_bytes ++ amount_be_bytes)

1.6 空投流程总览

部署阶段:
  1. 准备空投列表 (CSV: address, amount)
  2. Python 脚本生成 Merkle Root 和 proofs
  3. 合约部署时传入 Merkle Root
  4. 空投列表 + proofs 上传 IPFS

Claim阶段:
  1. 用户访问 DApp
  2. DApp 根据用户地址查询空投数量
  3. DApp 构建 claim 交易 (含 Merkle Proof)
  4. 用户签名发送交易
  5. 合约验证并通过 CW20 发送代币

管理员阶段:
  1. 空投结束后回收未领取代币(如需要)
  2. 需要治理投票批准

1.7 MSG Chain 环境参数

参数 值
Chain ID msg-chain-1
Bech32 前缀 msg
Coin Type 118
MSG 小数位数 18
哈希函数 SHA-256
区块时间 5s
Gas 价格 1,000,000,000 attoMSG/gas
CosmWasm 版本 1.x
代币标准 CW20

2. 合约完整实现

2.1 项目结构

merkle-airdrop/
├── Cargo.toml
├── src/
│   ├── contract.rs       # 合约入口 (instantiate, execute, query)
│   ├── state.rs          # 状态存储
│   ├── msg.rs            # 消息定义
│   ├── error.rs          # 错误类型
│   ├── merkle.rs         # Merkle 验证
│   └── lib.rs            # 库入口
├── tests/
│   └── integration.rs    # 集成测试
└── schema/
    └── ...               # 自动生成的 JSON Schema

2.2 Cargo.toml

[package]
name = "merkle-airdrop"
version = "1.0.0"
edition = "2021"
description = "Merkle proof based airdrop contract for MSG Chain"

[lib]
crate-type = ["cdylib", "rlib"]

[features]
default = []
library = []

[dependencies]
cosmwasm-std = { version = "1.5", features = ["staking"] }
cosmwasm-storage = "1.5"
cw20 = "1.1"
cw-storage-plus = "1.2"
cw-utils = "1.0"
schemars = "0.8"
serde = { version = "1.0", default-features = false, features = ["derive"] }
thiserror = "1.0"
sha2 = "0.10"
hex = "0.4"

[dev-dependencies]
cosmwasm-schema = "1.5"
cw-multi-test = "0.18"
anyhow = "1"

2.3 lib.rs

pub mod contract;
pub mod error;
pub mod merkle;
pub mod msg;
pub mod state;

#[cfg(test)]
mod tests;

2.4 error.rs

use cosmwasm_std::StdError;
use thiserror::Error;

#[derive(Error, Debug, PartialEq)]
pub enum ContractError {
    #[error("{0}")]
    Std(#[from] StdError),

    #[error("Unauthorized")]
    Unauthorized {},

    #[error("Airdrop already started")]
    AlreadyStarted {},

    #[error("Airdrop not started yet")]
    NotStarted {},

    #[error("Airdrop already ended")]
    AlreadyEnded {},

    #[error("Invalid Merkle proof")]
    InvalidProof {},

    #[error("Already claimed")]
    AlreadyClaimed {},

    #[error("Invalid amount: {0}")]
    InvalidAmount(String),

    #[error("Invalid Merkle root")]
    InvalidMerkleRoot {},

    #[error("Token address is not a CW20 token")]
    InvalidToken {},

    #[error("Invalid start or end time")]
    InvalidTime {},

    #[error("Claim amount is zero or below minimum: {0}")]
    DustClaim(String),

    #[error("Transfer failed: {0}")]
    TransferFailed(String),

    #[error("Stage already exists")]
    StageAlreadyExists {},

    #[error("Stage not found")]
    StageNotFound {},

    #[error("Governance proposal required")]
    GovernanceRequired {},
}

2.5 state.rs

use cosmwasm_std::{Addr, Timestamp, Uint128};
use cw_storage_plus::{Item, Map};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

/// 空投配置
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct AirdropConfig {
    /// CW20 代币合约地址
    pub token: Addr,
    /// Merkle 根 (hex 编码)
    pub merkle_root: String,
    /// 空投开始时间
    pub start_time: Timestamp,
    /// 空投结束时间
    pub end_time: Timestamp,
    /// 已领取总量
    pub total_claimed: Uint128,
    /// 空投总供应量
    pub total_amount: Uint128,
    /// 最低领取数量 (防止粉尘)
    pub minimum_claim: Uint128,
    /// 是否可回收未领取代币
    pub enable_recovery: bool,
    /// 所属阶段编号 (多阶段空投)
    pub stage: u32,
}

/// 领取记录
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct Claim {
    pub address: Addr,
    pub amount: Uint128,
    pub claimed_at: Timestamp,
}

/// 合约配置存储
pub const CONFIG: Item<AirdropConfig> = Item::new("config");

/// 已领取记录: address => bool
pub const CLAIMED: Map<&Addr, bool> = Map::new("claimed");

/// 多阶段配置: stage_id => AirdropConfig
pub const STAGES: Map<u32, AirdropConfig> = Map::new("stages");

/// 阶段领取记录: (stage_id, address) => bool
pub const STAGE_CLAIMED: Map<(u32, &Addr), bool> = Map::new("stage_claimed");

2.6 msg.rs

use cosmwasm_std::{Addr, Timestamp, Uint128};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct InstantiateMsg {
    pub token: String,
    pub merkle_root: String,
    pub start_time: Timestamp,
    pub end_time: Timestamp,
    pub total_amount: Uint128,
    pub minimum_claim: Option<Uint128>,
    pub enable_recovery: Option<bool>,
    pub stage: Option<u32>,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub enum ExecuteMsg {
    Claim {
        proof: Vec<String>,
        index: u32,
        amount: Uint128,
        beneficiary: String,
    },
    BatchClaim {
        claims: Vec<BatchClaimItem>,
    },
    AddStage {
        merkle_root: String,
        start_time: Timestamp,
        end_time: Timestamp,
        total_amount: Uint128,
    },
    RecoverUnclaimed {
        recipient: String,
    },
    UpdateMinimumClaim {
        minimum_claim: Uint128,
    },
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct BatchClaimItem {
    pub proof: Vec<String>,
    pub index: u32,
    pub amount: Uint128,
    pub beneficiary: String,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub enum QueryMsg {
    Config {},
    IsClaimed {
        address: String,
    },
    IsStageClaimed {
        stage: u32,
        address: String,
    },
    ClaimableAmount {
        address: String,
        proof: Vec<String>,
        index: u32,
        amount: Uint128,
    },
    TotalClaimed {},
    StageConfig {
        stage: u32,
    },
    Stages {},
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct ConfigResponse {
    pub token: String,
    pub merkle_root: String,
    pub start_time: Timestamp,
    pub end_time: Timestamp,
    pub total_claimed: Uint128,
    pub total_amount: Uint128,
    pub minimum_claim: Uint128,
    pub enable_recovery: bool,
    pub stage: u32,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct IsClaimedResponse {
    pub is_claimed: bool,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct ClaimableAmountResponse {
    pub amount: Uint128,
    pub is_claimable: bool,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct TotalClaimedResponse {
    pub total_claimed: Uint128,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct StagesResponse {
    pub stages: Vec<u32>,
}

### 2.7 merkle.rs

```rust
use crate::error::ContractError;
use sha2::{Digest, Sha256};

pub fn hash_pair(a: &[u8], b: &[u8]) -> [u8; 32] {
    let mut hasher = Sha256::new();
    if a < b {
        hasher.update(a);
        hasher.update(b);
    } else {
        hasher.update(b);
        hasher.update(a);
    }
    let mut result = [0u8; 32];
    result.copy_from_slice(&hasher.finalize());
    result
}

pub fn leaf_hash(address: &str, amount: &[u8; 32]) -> [u8; 32] {
    let mut hasher = Sha256::new();
    hasher.update(address.as_bytes());
    hasher.update(amount);
    let mut result = [0u8; 32];
    result.copy_from_slice(&hasher.finalize());
    result
}

pub fn verify_proof(proof: &[String], root: &str, leaf: &[u8; 32]) -> bool {
    let root_bytes = match hex::decode(root) {
        Ok(r) => r,
        Err(_) => return false,
    };
    if root_bytes.len() != 32 {
        return false;
    }
    let mut current = *leaf;
    for p in proof {
        let proof_bytes = match hex::decode(p) {
            Ok(b) => b,
            Err(_) => return false,
        };
        if proof_bytes.len() != 32 {
            return false;
        }
        current = hash_pair(&current, &proof_bytes);
    }
    current == root_bytes.as_slice()
}

pub fn amount_to_bytes32(amount: u128) -> [u8; 32] {
    let mut bytes = [0u8; 32];
    bytes[16..].copy_from_slice(&amount.to_be_bytes());
    bytes
}

pub fn verify_proof_verified(
    proof: &[String],
    root: &str,
    address: &str,
    amount: u128,
) -> Result<(), ContractError> {
    let amount_bytes = amount_to_bytes32(amount);
    let computed_leaf = leaf_hash(address, &amount_bytes);
    if !verify_proof(proof, root, &computed_leaf) {
        return Err(ContractError::InvalidProof {});
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_hash_pair_deterministic() {
        let a = [0u8; 32];
        let b = [1u8; 32];
        let h1 = hash_pair(&a, &b);
        let h2 = hash_pair(&b, &a);
        assert_eq!(h1, h2);
    }

    #[test]
    fn test_leaf_hash_different_inputs() {
        let amount = amount_to_bytes32(1000);
        let h1 = leaf_hash("msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqp5n4hg", &amount);
        let h2 = leaf_hash("msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqp5n4h8", &amount);
        assert_ne!(h1, h2);
    }

    #[test]
    fn test_amount_to_bytes32() {
        let amount = 1u128;
        let bytes = amount_to_bytes32(amount);
        assert_eq!(bytes[31], 1);
        assert_eq!(bytes[15], 0);
        let amount2 = 256u128;
        let bytes2 = amount_to_bytes32(amount2);
        assert_eq!(bytes2[30], 1);
        assert_eq!(bytes2[31], 0);
    }

    #[test]
    fn test_verify_proof_valid() {
        let addr = "msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqp5n4hg";
        let amount = amount_to_bytes32(1000);
        let leaf = leaf_hash(addr, &amount);

        let other_addr = "msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqp5n4h8";
        let other_amount = amount_to_bytes32(2000);
        let other_leaf = leaf_hash(other_addr, &other_amount);

        let root = hash_pair(&leaf, &other_leaf);
        let root_hex = hex::encode(root);

        let proof = vec![hex::encode(other_leaf)];
        assert!(verify_proof(&proof, &root_hex, &leaf));

        let bad_proof = vec![hex::encode([0u8; 32])];
        assert!(!verify_proof(&bad_proof, &root_hex, &leaf));
    }
}

2.8 contract.rs

use cosmwasm_std::{
    entry_point, to_json_binary, Binary, CosmosMsg, Deps, DepsMut, Env,
    MessageInfo, Response, StdResult, Uint128, WasmMsg, Addr, StdError,
    Timestamp,
};
use cw20::Cw20ExecuteMsg;

use crate::error::ContractError;
use crate::merkle::{amount_to_bytes32, leaf_hash, verify_proof};
use crate::msg::{
    BatchClaimItem, ConfigResponse, ExecuteMsg, InstantiateMsg, IsClaimedResponse,
    QueryMsg, ClaimableAmountResponse, TotalClaimedResponse, StagesResponse,
};
use crate::state::{AirdropConfig, CLAIMED, CONFIG, STAGES, STAGE_CLAIMED};

const DEFAULT_MINIMUM_CLAIM: Uint128 = Uint128::new(1);
const CONTRACT_NAME: &str = "merkle-airdrop";
const CONTRACT_VERSION: &str = "1.0.0";

#[entry_point]
pub fn instantiate(
    deps: DepsMut,
    env: Env,
    _info: MessageInfo,
    msg: InstantiateMsg,
) -> Result<Response, ContractError> {
    let root_bytes = hex::decode(&msg.merkle_root)
        .map_err(|_| ContractError::InvalidMerkleRoot {})?;
    if root_bytes.len() != 32 {
        return Err(ContractError::InvalidMerkleRoot {});
    }
    let token = deps.api.addr_validate(&msg.token)?;
    if msg.start_time >= msg.end_time {
        return Err(ContractError::InvalidTime {});
    }
    if msg.total_amount.is_zero() {
        return Err(ContractError::InvalidAmount(
            "Total amount must be greater than zero".to_string(),
        ));
    }

    let config = AirdropConfig {
        token,
        merkle_root: msg.merkle_root,
        start_time: msg.start_time,
        end_time: msg.end_time,
        total_claimed: Uint128::zero(),
        total_amount: msg.total_amount,
        minimum_claim: msg.minimum_claim.unwrap_or(DEFAULT_MINIMUM_CLAIM),
        enable_recovery: msg.enable_recovery.unwrap_or(false),
        stage: msg.stage.unwrap_or(0),
    };

    CONFIG.save(deps.storage, &config)?;

    Ok(Response::new()
        .add_attribute("action", "instantiate")
        .add_attribute("contract_name", CONTRACT_NAME)
        .add_attribute("contract_version", CONTRACT_VERSION)
        .add_attribute("token", config.token.to_string())
        .add_attribute("merkle_root", &config.merkle_root)
        .add_attribute("start_time", config.start_time.to_string())
        .add_attribute("end_time", config.end_time.to_string())
        .add_attribute("total_amount", config.total_amount.to_string())
        .add_attribute("stage", config.stage.to_string()))
}

#[entry_point]
pub fn execute(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    msg: ExecuteMsg,
) -> Result<Response, ContractError> {
    match msg {
        ExecuteMsg::Claim { proof, index: _, amount, beneficiary } => {
            execute_claim(deps, env, info, proof, amount, beneficiary)
        }
        ExecuteMsg::BatchClaim { claims } => {
            execute_batch_claim(deps, env, info, claims)
        }
        ExecuteMsg::AddStage { merkle_root, start_time, end_time, total_amount } => {
            execute_add_stage(deps, env, info, merkle_root, start_time, end_time, total_amount)
        }
        ExecuteMsg::RecoverUnclaimed { recipient } => {
            execute_recover_unclaimed(deps, env, info, recipient)
        }
        ExecuteMsg::UpdateMinimumClaim { minimum_claim } => {
            execute_update_minimum_claim(deps, env, info, minimum_claim)
        }
    }
}

fn execute_claim(
    deps: DepsMut,
    env: Env,
    _info: MessageInfo,
    proof: Vec<String>,
    amount: Uint128,
    beneficiary: String,
) -> Result<Response, ContractError> {
    let config = CONFIG.load(deps.storage)?;
    let beneficiary_addr = deps.api.addr_validate(&beneficiary)?;

    if env.block.time < config.start_time {
        return Err(ContractError::NotStarted {});
    }
    if env.block.time >= config.end_time {
        return Err(ContractError::AlreadyEnded {});
    }
    if CLAIMED.has(deps.storage, &beneficiary_addr) {
        return Err(ContractError::AlreadyClaimed {});
    }
    if amount < config.minimum_claim {
        return Err(ContractError::DustClaim(format!(
            "Claim amount {} is below minimum {}", amount, config.minimum_claim
        )));
    }

    let amount_bytes = amount_to_bytes32(amount.u128());
    let computed_leaf = leaf_hash(&beneficiary, &amount_bytes);
    if !verify_proof(&proof, &config.merkle_root, &computed_leaf) {
        return Err(ContractError::InvalidProof {});
    }

    CLAIMED.save(deps.storage, &beneficiary_addr, &true)?;
    CONFIG.update(deps.storage, |mut c| -> StdResult<_> {
        c.total_claimed = c.total_claimed.checked_add(amount)
            .map_err(|_| StdError::generic_err("Total claimed overflow"))?;
        Ok(c)
    })?;

    let transfer_msg = CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: config.token.to_string(),
        msg: to_json_binary(&Cw20ExecuteMsg::Transfer {
            recipient: beneficiary.clone(),
            amount,
        })?,
        funds: vec![],
    });

    Ok(Response::new()
        .add_message(transfer_msg)
        .add_attribute("action", "claim")
        .add_attribute("beneficiary", &beneficiary)
        .add_attribute("amount", amount.to_string()))
}

fn execute_batch_claim(
    deps: DepsMut,
    env: Env,
    _info: MessageInfo,
    claims: Vec<BatchClaimItem>,
) -> Result<Response, ContractError> {
    let config = CONFIG.load(deps.storage)?;
    if env.block.time < config.start_time {
        return Err(ContractError::NotStarted {});
    }
    if env.block.time >= config.end_time {
        return Err(ContractError::AlreadyEnded {});
    }

    let mut total_batch = Uint128::zero();
    let mut messages: Vec<CosmosMsg> = Vec::new();
    let mut claimed_count = 0u32;

    for claim in &claims {
        let beneficiary_addr = deps.api.addr_validate(&claim.beneficiary)?;
        if CLAIMED.has(deps.storage, &beneficiary_addr) {
            continue;
        }
        if claim.amount < config.minimum_claim {
            continue;
        }
        let amount_bytes = amount_to_bytes32(claim.amount.u128());
        let computed_leaf = leaf_hash(&claim.beneficiary, &amount_bytes);
        if !verify_proof(&claim.proof, &config.merkle_root, &computed_leaf) {
            continue;
        }
        CLAIMED.save(deps.storage, &beneficiary_addr, &true)?;
        total_batch = total_batch.checked_add(claim.amount)
            .map_err(|_| ContractError::InvalidAmount("Overflow".into()))?;

        messages.push(CosmosMsg::Wasm(WasmMsg::Execute {
            contract_addr: config.token.to_string(),
            msg: to_json_binary(&Cw20ExecuteMsg::Transfer {
                recipient: claim.beneficiary.clone(),
                amount: claim.amount,
            })?,
            funds: vec![],
        }));
        claimed_count += 1;
    }

    CONFIG.update(deps.storage, |mut c| -> StdResult<_> {
        c.total_claimed = c.total_claimed.checked_add(total_batch)
            .map_err(|_| StdError::generic_err("Total claimed overflow"))?;
        Ok(c)
    })?;

    Ok(Response::new()
        .add_messages(messages)
        .add_attribute("action", "batch_claim")
        .add_attribute("claimed_count", claimed_count.to_string())
        .add_attribute("total_amount", total_batch.to_string()))
}

fn execute_add_stage(
    deps: DepsMut,
    _env: Env,
    _info: MessageInfo,
    merkle_root: String,
    start_time: Timestamp,
    end_time: Timestamp,
    total_amount: Uint128,
) -> Result<Response, ContractError> {
    let root_bytes = hex::decode(&merkle_root)
        .map_err(|_| ContractError::InvalidMerkleRoot {})?;
    if root_bytes.len() != 32 {
        return Err(ContractError::InvalidMerkleRoot {});
    }
    if start_time >= end_time {
        return Err(ContractError::InvalidTime {});
    }

    let config = CONFIG.load(deps.storage)?;
    let stage_id = config.stage + 1;

    let stage_config = AirdropConfig {
        token: config.token.clone(),
        merkle_root,
        start_time,
        end_time,
        total_claimed: Uint128::zero(),
        total_amount,
        minimum_claim: config.minimum_claim,
        enable_recovery: config.enable_recovery,
        stage: stage_id,
    };

    STAGES.save(deps.storage, stage_id, &stage_config)?;

    Ok(Response::new()
        .add_attribute("action", "add_stage")
        .add_attribute("stage_id", stage_id.to_string())
        .add_attribute("merkle_root", &stage_config.merkle_root))
}

fn execute_recover_unclaimed(
    deps: DepsMut,
    env: Env,
    _info: MessageInfo,
    recipient: String,
) -> Result<Response, ContractError> {
    let config = CONFIG.load(deps.storage)?;
    if !config.enable_recovery {
        return Err(ContractError::GovernanceRequired {});
    }
    if env.block.time < config.end_time {
        return Err(ContractError::NotStarted {});
    }

    let recipient_addr = deps.api.addr_validate(&recipient)?;
    let unclaimed = config.total_amount.checked_sub(config.total_claimed)
        .map_err(|_| ContractError::InvalidAmount("Overflow in calculation".into()))?;

    if unclaimed.is_zero() {
        return Err(ContractError::InvalidAmount("No unclaimed tokens".to_string()));
    }

    let transfer_msg = CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: config.token.to_string(),
        msg: to_json_binary(&Cw20ExecuteMsg::Transfer {
            recipient: recipient.clone(),
            amount: unclaimed,
        })?,
        funds: vec![],
    });

    Ok(Response::new()
        .add_message(transfer_msg)
        .add_attribute("action", "recover_unclaimed")
        .add_attribute("recipient", &recipient)
        .add_attribute("amount", unclaimed.to_string()))
}

fn execute_update_minimum_claim(
    deps: DepsMut,
    _env: Env,
    _info: MessageInfo,
    minimum_claim: Uint128,
) -> Result<Response, ContractError> {
    CONFIG.update(deps.storage, |mut config| -> Result<_, ContractError> {
        config.minimum_claim = minimum_claim;
        Ok(config)
    })?;

    Ok(Response::new()
        .add_attribute("action", "update_minimum_claim")
        .add_attribute("minimum_claim", minimum_claim.to_string()))
}

#[entry_point]
pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult<Binary> {
    match msg {
        QueryMsg::Config {} => to_json_binary(&query_config(deps)?),
        QueryMsg::IsClaimed { address } => to_json_binary(&query_is_claimed(deps, address)?),
        QueryMsg::IsStageClaimed { stage, address } => {
            to_json_binary(&query_is_stage_claimed(deps, stage, address)?)
        }
        QueryMsg::ClaimableAmount { address, proof, index: _, amount } => {
            to_json_binary(&query_claimable_amount(deps, address, proof, amount)?)
        }
        QueryMsg::TotalClaimed {} => to_json_binary(&query_total_claimed(deps)?),
        QueryMsg::StageConfig { stage } => to_json_binary(&query_stage_config(deps, stage)?),
        QueryMsg::Stages {} => to_json_binary(&query_stages(deps)?),
    }
}

fn query_config(deps: Deps) -> StdResult<ConfigResponse> {
    let config = CONFIG.load(deps.storage)?;
    Ok(ConfigResponse {
        token: config.token.to_string(),
        merkle_root: config.merkle_root,
        start_time: config.start_time,
        end_time: config.end_time,
        total_claimed: config.total_claimed,
        total_amount: config.total_amount,
        minimum_claim: config.minimum_claim,
        enable_recovery: config.enable_recovery,
        stage: config.stage,
    })
}

fn query_is_claimed(deps: Deps, address: String) -> StdResult<IsClaimedResponse> {
    let addr = deps.api.addr_validate(&address)?;
    let is_claimed = CLAIMED.may_load(deps.storage, &addr)?.unwrap_or(false);
    Ok(IsClaimedResponse { is_claimed })
}

fn query_is_stage_claimed(deps: Deps, stage: u32, address: String) -> StdResult<IsClaimedResponse> {
    let addr = deps.api.addr_validate(&address)?;
    let is_claimed = STAGE_CLAIMED.may_load(deps.storage, (stage, &addr))?.unwrap_or(false);
    Ok(IsClaimedResponse { is_claimed })
}

fn query_claimable_amount(
    deps: Deps,
    address: String,
    proof: Vec<String>,
    amount: Uint128,
) -> StdResult<ClaimableAmountResponse> {
    let config = CONFIG.load(deps.storage)?;
    let amount_bytes = amount_to_bytes32(amount.u128());
    let computed_leaf = leaf_hash(&address, &amount_bytes);
    let valid = verify_proof(&proof, &config.merkle_root, &computed_leaf);

    if !valid {
        return Ok(ClaimableAmountResponse { amount: Uint128::zero(), is_claimable: false });
    }

    let addr = deps.api.addr_validate(&address)?;
    let already_claimed = CLAIMED.may_load(deps.storage, &addr)?.unwrap_or(false);

    Ok(ClaimableAmountResponse { amount, is_claimable: !already_claimed })
}

fn query_total_claimed(deps: Deps) -> StdResult<TotalClaimedResponse> {
    let config = CONFIG.load(deps.storage)?;
    Ok(TotalClaimedResponse { total_claimed: config.total_claimed })
}

fn query_stage_config(deps: Deps, stage: u32) -> StdResult<ConfigResponse> {
    let config = STAGES.load(deps.storage, stage)?;
    Ok(ConfigResponse {
        token: config.token.to_string(),
        merkle_root: config.merkle_root,
        start_time: config.start_time,
        end_time: config.end_time,
        total_claimed: config.total_claimed,
        total_amount: config.total_amount,
        minimum_claim: config.minimum_claim,
        enable_recovery: config.enable_recovery,
        stage: config.stage,
    })
}

fn query_stages(deps: Deps) -> StdResult<StagesResponse> {
    let current = CONFIG.load(deps.storage)?;
    let mut stages = vec![current.stage];
    let stage_keys: Vec<u32> = STAGES
        .keys(deps.storage, None, None, cosmwasm_std::Order::Ascending)
        .collect::<StdResult<Vec<u32>>>()?;
    stages.extend(stage_keys);
    stages.sort();
    stages.dedup();
    Ok(StagesResponse { stages })
}

2.9 集成测试

#[cfg(test)]
mod tests {
    use cosmwasm_std::{
        testing::{mock_dependencies, mock_env, mock_info},
        Addr, CosmosMsg, Timestamp, Uint128, WasmMsg, to_json_binary,
    };
    use cw20::Cw20ExecuteMsg;
    use crate::contract::{execute, instantiate, query};
    use crate::error::ContractError;
    use crate::merkle::{amount_to_bytes32, leaf_hash, verify_proof};
    use crate::msg::{
        ExecuteMsg, InstantiateMsg, QueryMsg, ConfigResponse, IsClaimedResponse,
        ClaimableAmountResponse, TotalClaimedResponse, BatchClaimItem,
    };
    use crate::state::{CLAIMED, CONFIG};
    use sha2::{Digest, Sha256};

    fn mock_token() -> Addr { Addr::unchecked("msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqp5n4hg") }
    fn mock_admin() -> Addr { Addr::unchecked("admin") }
    fn mock_user1() -> Addr { Addr::unchecked("msg1user1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqquk9y5") }
    fn mock_user2() -> Addr { Addr::unchecked("msg1user2qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj6kv") }
    fn mock_user3() -> Addr { Addr::unchecked("msg1user3qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqd8w5") }

    fn build_simple_tree(claims: &[(String, u128)]) -> (String, Vec<Vec<String>>) {
        let leaves: Vec<[u8; 32]> = claims.iter()
            .map(|(addr, amount)| {
                let amt_bytes = amount_to_bytes32(*amount);
                leaf_hash(addr, &amt_bytes)
            })
            .collect();
        assert_eq!(leaves.len(), 2);
        let inner = {
            let mut hasher = Sha256::new();
            if leaves[0] < leaves[1] {
                hasher.update(&leaves[0]);
                hasher.update(&leaves[1]);
            } else {
                hasher.update(&leaves[1]);
                hasher.update(&leaves[0]);
            }
            let mut h = [0u8; 32];
            h.copy_from_slice(&hasher.finalize());
            h
        };
        let root = hex::encode(inner);
        let proof0 = vec![hex::encode(leaves[1])];
        let proof1 = vec![hex::encode(leaves[0])];
        (root, vec![proof0, proof1])
    }

    fn setup_contract() -> (
        cosmwasm_std::OwnedDeps<cosmwasm_std::MemoryStorage, cosmwasm_std::testing::MockApi, cosmwasm_std::testing::MockQuerier>,
        cosmwasm_std::Env, String,
    ) {
        let mut deps = mock_dependencies();
        let env = mock_env();
        let claims = vec![
            (mock_user1().to_string(), 1000u128),
            (mock_user2().to_string(), 2000u128),
        ];
        let (root, _proofs) = build_simple_tree(&claims);
        let msg = InstantiateMsg {
            token: mock_token().to_string(),
            merkle_root: root.clone(),
            start_time: Timestamp::from_seconds(1_000_000),
            end_time: Timestamp::from_seconds(2_000_000),
            total_amount: Uint128::new(3000),
            minimum_claim: Some(Uint128::new(1)),
            enable_recovery: Some(true),
            stage: Some(0),
        };
        let info = mock_info(mock_admin().as_str(), &[]);
        instantiate(deps.as_mut(), env.clone(), info, msg).unwrap();
        (deps, env, root)
    }

    #[test]
    fn test_instantiate() {
        let (deps, _env, root) = setup_contract();
        let config = CONFIG.load(deps.as_ref().storage).unwrap();
        assert_eq!(config.token, mock_token());
        assert_eq!(config.merkle_root, root);
        assert_eq!(config.total_amount, Uint128::new(3000));
    }

    #[test]
    fn test_instantiate_invalid_merkle_root() {
        let mut deps = mock_dependencies();
        let env = mock_env();
        let msg = InstantiateMsg {
            token: mock_token().to_string(),
            merkle_root: "invalid".to_string(),
            start_time: Timestamp::from_seconds(1_000_000),
            end_time: Timestamp::from_seconds(2_000_000),
            total_amount: Uint128::new(3000),
            minimum_claim: None, enable_recovery: None, stage: None,
        };
        let info = mock_info(mock_admin().as_str(), &[]);
        let err = instantiate(deps.as_mut(), env, info, msg).unwrap_err();
        assert_eq!(err, ContractError::InvalidMerkleRoot {});
    }

    #[test]
    fn test_instantiate_wrong_length_root() {
        let mut deps = mock_dependencies();
        let env = mock_env();
        let msg = InstantiateMsg {
            token: mock_token().to_string(),
            merkle_root: hex::encode([0u8; 16]),
            start_time: Timestamp::from_seconds(1_000_000),
            end_time: Timestamp::from_seconds(2_000_000),
            total_amount: Uint128::new(3000),
            minimum_claim: None, enable_recovery: None, stage: None,
        };
        let info = mock_info(mock_admin().as_str(), &[]);
        let err = instantiate(deps.as_mut(), env, info, msg).unwrap_err();
        assert_eq!(err, ContractError::InvalidMerkleRoot {});
    }

    #[test]
    fn test_instantiate_invalid_time() {
        let mut deps = mock_dependencies();
        let env = mock_env();
        let msg = InstantiateMsg {
            token: mock_token().to_string(),
            merkle_root: hex::encode([1u8; 32]),
            start_time: Timestamp::from_seconds(2_000_000),
            end_time: Timestamp::from_seconds(1_000_000),
            total_amount: Uint128::new(3000),
            minimum_claim: None, enable_recovery: None, stage: None,
        };
        let info = mock_info(mock_admin().as_str(), &[]);
        let err = instantiate(deps.as_mut(), env, info, msg).unwrap_err();
        assert_eq!(err, ContractError::InvalidTime {});
    }

    #[test]
    fn test_claim_success() {
        let (mut deps, env, _root) = setup_contract();
        let claims = vec![
            (mock_user1().to_string(), 1000u128),
            (mock_user2().to_string(), 2000u128),
        ];
        let (_root, proofs) = build_simple_tree(&claims);
        let claim_msg = ExecuteMsg::Claim {
            proof: proofs[0].clone(), index: 0,
            amount: Uint128::new(1000), beneficiary: mock_user1().to_string(),
        };
        let info = mock_info(mock_user1().as_str(), &[]);
        let res = execute(deps.as_mut(), env.clone(), info, claim_msg).unwrap();
        assert_eq!(res.messages.len(), 1);
        match &res.messages[0].msg {
            CosmosMsg::Wasm(WasmMsg::Execute { contract_addr, msg, funds: _ }) => {
                assert_eq!(contract_addr, mock_token().to_string());
                let transfer: Cw20ExecuteMsg = cosmwasm_std::from_json(msg).unwrap();
                match transfer {
                    Cw20ExecuteMsg::Transfer { recipient, amount } => {
                        assert_eq!(recipient, mock_user1().to_string());
                        assert_eq!(amount, Uint128::new(1000));
                    }
                    _ => panic!("Expected Transfer"),
                }
            }
            _ => panic!("Expected Wasm"),
        }
        let is_claimed = CLAIMED.load(deps.as_ref().storage, &mock_user1()).unwrap();
        assert!(is_claimed);
        let config = CONFIG.load(deps.as_ref().storage).unwrap();
        assert_eq!(config.total_claimed, Uint128::new(1000));
    }

    #[test]
    fn test_claim_double_claim_fails() {
        let (mut deps, env, _root) = setup_contract();
        let claims = vec![
            (mock_user1().to_string(), 1000u128),
            (mock_user2().to_string(), 2000u128),
        ];
        let (_root, proofs) = build_simple_tree(&claims);
        let claim_msg = ExecuteMsg::Claim {
            proof: proofs[0].clone(), index: 0,
            amount: Uint128::new(1000), beneficiary: mock_user1().to_string(),
        };
        let info = mock_info(mock_user1().as_str(), &[]);
        execute(deps.as_mut(), env.clone(), info.clone(), claim_msg).unwrap();
        let claim_msg2 = ExecuteMsg::Claim {
            proof: proofs[0].clone(), index: 0,
            amount: Uint128::new(1000), beneficiary: mock_user1().to_string(),
        };
        let err = execute(deps.as_mut(), env, info, claim_msg2).unwrap_err();
        assert_eq!(err, ContractError::AlreadyClaimed {});
    }

    #[test]
    fn test_claim_before_start_fails() {
        let (mut deps, _env, _root) = setup_contract();
        let mut early_env = mock_env();
        early_env.block.time = Timestamp::from_seconds(500_000);
        let claims = vec![
            (mock_user1().to_string(), 1000u128),
            (mock_user2().to_string(), 2000u128),
        ];
        let (_root, proofs) = build_simple_tree(&claims);
        let claim_msg = ExecuteMsg::Claim {
            proof: proofs[0].clone(), index: 0,
            amount: Uint128::new(1000), beneficiary: mock_user1().to_string(),
        };
        let info = mock_info(mock_user1().as_str(), &[]);
        let err = execute(deps.as_mut(), early_env, info, claim_msg).unwrap_err();
        assert_eq!(err, ContractError::NotStarted {});
    }

    #[test]
    fn test_claim_after_end_fails() {
        let (mut deps, _env, _root) = setup_contract();
        let mut late_env = mock_env();
        late_env.block.time = Timestamp::from_seconds(3_000_000);
        let claims = vec![
            (mock_user1().to_string(), 1000u128),
            (mock_user2().to_string(), 2000u128),
        ];
        let (_root, proofs) = build_simple_tree(&claims);
        let claim_msg = ExecuteMsg::Claim {
            proof: proofs[0].clone(), index: 0,
            amount: Uint128::new(1000), beneficiary: mock_user1().to_string(),
        };
        let info = mock_info(mock_user1().as_str(), &[]);
        let err = execute(deps.as_mut(), late_env, info, claim_msg).unwrap_err();
        assert_eq!(err, ContractError::AlreadyEnded {});
    }

    #[test]
    fn test_claim_invalid_proof_fails() {
        let (mut deps, env, _root) = setup_contract();
        let claim_msg = ExecuteMsg::Claim {
            proof: vec![hex::encode([0u8; 32])], index: 0,
            amount: Uint128::new(1000), beneficiary: mock_user1().to_string(),
        };
        let info = mock_info(mock_user1().as_str(), &[]);
        let err = execute(deps.as_mut(), env, info, claim_msg).unwrap_err();
        assert_eq!(err, ContractError::InvalidProof {});
    }

    #[test]
    fn test_batch_claim() {
        let (mut deps, env, _root) = setup_contract();
        let claims = vec![
            (mock_user1().to_string(), 1000u128),
            (mock_user2().to_string(), 2000u128),
        ];
        let (_root, proofs) = build_simple_tree(&claims);
        let batch_msg = ExecuteMsg::BatchClaim {
            claims: vec![
                BatchClaimItem {
                    proof: proofs[0].clone(), index: 0,
                    amount: Uint128::new(1000), beneficiary: mock_user1().to_string(),
                },
                BatchClaimItem {
                    proof: proofs[1].clone(), index: 1,
                    amount: Uint128::new(2000), beneficiary: mock_user2().to_string(),
                },
            ],
        };
        let info = mock_info("batch_caller", &[]);
        let res = execute(deps.as_mut(), env, info, batch_msg).unwrap();
        assert_eq!(res.messages.len(), 2);
        let config = CONFIG.load(deps.as_ref().storage).unwrap();
        assert_eq!(config.total_claimed, Uint128::new(3000));
    }

    #[test]
    fn test_query_config() {
        let (deps, _env, root) = setup_contract();
        let resp: ConfigResponse = query(
            deps.as_ref(), mock_env(), QueryMsg::Config {},
        ).map(|b| cosmwasm_std::from_json(&b).unwrap()).unwrap();
        assert_eq!(resp.merkle_root, root);
        assert_eq!(resp.total_amount, Uint128::new(3000));
    }

    #[test]
    fn test_query_is_claimed() {
        let (mut deps, env, _root) = setup_contract();
        let resp: IsClaimedResponse = query(
            deps.as_ref(), env.clone(),
            QueryMsg::IsClaimed { address: mock_user1().to_string() },
        ).map(|b| cosmwasm_std::from_json(&b).unwrap()).unwrap();
        assert!(!resp.is_claimed);
        let claims = vec![
            (mock_user1().to_string(), 1000u128),
            (mock_user2().to_string(), 2000u128),
        ];
        let (_root, proofs) = build_simple_tree(&claims);
        execute(
            deps.as_mut(), env.clone(), mock_info(mock_user1().as_str(), &[]),
            ExecuteMsg::Claim {
                proof: proofs[0].clone(), index: 0,
                amount: Uint128::new(1000), beneficiary: mock_user1().to_string(),
            },
        ).unwrap();
        let resp: IsClaimedResponse = query(
            deps.as_ref(), env,
            QueryMsg::IsClaimed { address: mock_user1().to_string() },
        ).map(|b| cosmwasm_std::from_json(&b).unwrap()).unwrap();
        assert!(resp.is_claimed);
    }

    #[test]
    fn test_query_claimable_amount() {
        let (mut deps, env, _root) = setup_contract();
        let claims = vec![
            (mock_user1().to_string(), 1000u128),
            (mock_user2().to_string(), 2000u128),
        ];
        let (_root, proofs) = build_simple_tree(&claims);
        let resp: ClaimableAmountResponse = query(
            deps.as_ref(), env.clone(),
            QueryMsg::ClaimableAmount {
                address: mock_user1().to_string(),
                proof: proofs[0].clone(), index: 0,
                amount: Uint128::new(1000),
            },
        ).map(|b| cosmwasm_std::from_json(&b).unwrap()).unwrap();
        assert!(resp.is_claimable);
        assert_eq!(resp.amount, Uint128::new(1000));
        execute(
            deps.as_mut(), env.clone(), mock_info(mock_user1().as_str(), &[]),
            ExecuteMsg::Claim {
                proof: proofs[0].clone(), index: 0,
                amount: Uint128::new(1000), beneficiary: mock_user1().to_string(),
            },
        ).unwrap();
        let resp: ClaimableAmountResponse = query(
            deps.as_ref(), env,
            QueryMsg::ClaimableAmount {
                address: mock_user1().to_string(),
                proof: proofs[0].clone(), index: 0,
                amount: Uint128::new(1000),
            },
        ).map(|b| cosmwasm_std::from_json(&b).unwrap()).unwrap();
        assert!(!resp.is_claimable);
    }

    #[test]
    fn test_dust_claim_fails() {
        let mut deps = mock_dependencies();
        let env = mock_env();
        let claims = vec![(mock_user1().to_string(), 1000u128)];
        let (root, proofs) = build_simple_tree(&claims);
        let msg = InstantiateMsg {
            token: mock_token().to_string(), merkle_root: root,
            start_time: Timestamp::from_seconds(1_000_000),
            end_time: Timestamp::from_seconds(2_000_000),
            total_amount: Uint128::new(1000),
            minimum_claim: Some(Uint128::new(2000)),
            enable_recovery: None, stage: None,
        };
        let info = mock_info(mock_admin().as_str(), &[]);
        instantiate(deps.as_mut(), env.clone(), info, msg).unwrap();
        let claim_msg = ExecuteMsg::Claim {
            proof: proofs[0].clone(), index: 0,
            amount: Uint128::new(1000), beneficiary: mock_user1().to_string(),
        };
        let info = mock_info(mock_user1().as_str(), &[]);
        let err = execute(deps.as_mut(), env, info, claim_msg).unwrap_err();
        assert!(matches!(err, ContractError::DustClaim(_)));
    }
}

3. 后端生成脚本

3.1 Merkle Tree 生成器 (Python)

#!/usr/bin/env python3
"""
Merkle Tree Generator for MSG Chain Airdrop
=============================================
生成 Merkle Root 和 Proofs,用于链上空投合约验证。

使用方法:
    python3 merkle_generator.py --input claims.csv --output output_dir

输入 CSV 格式:
    address,amount
    msg1...,1000
    msg1...,2500
    ...

输出:
    - merkle_root.txt          : Merkle Root (hex)
    - proofs.json              : 所有用户的证明
    - claims.json              : 空投列表汇总
    - tree_debug.json          : 完整 Merkle 树 (调试用)
"""

import argparse
import csv
import hashlib
import json
import os
import sys
from typing import List, Tuple, Optional


def sha256(data: bytes) -> bytes:
    return hashlib.sha256(data).digest()


def hash_pair(a: bytes, b: bytes) -> bytes:
    if a < b:
        return sha256(a + b)
    else:
        return sha256(b + a)


def amount_to_bytes32(amount: int) -> bytes:
    return amount.to_bytes(32, byteorder='big')


def leaf_hash(address: str, amount: int) -> bytes:
    addr_bytes = address.encode('utf-8')
    amt_bytes = amount_to_bytes32(amount)
    return sha256(addr_bytes + amt_bytes)


def build_merkle_tree(leaves: List[bytes]) -> Tuple[bytes, List[List[bytes]]]:
    if not leaves:
        raise ValueError("Leaves list cannot be empty")

    working = list(leaves)
    if len(working) % 2 != 0:
        working.append(working[-1])

    levels = [working]

    while len(working) > 1:
        next_level = []
        for i in range(0, len(working), 2):
            if i + 1 < len(working):
                next_level.append(hash_pair(working[i], working[i + 1]))
            else:
                next_level.append(working[i])
        working = next_level
        if len(working) % 2 != 0 and len(working) > 1:
            working.append(working[-1])
        levels.append(working)

    root = levels[-1][0]

    proofs: List[List[bytes]] = []
    num_leaves = len(leaves)

    for leaf_idx in range(num_leaves):
        proof: List[bytes] = []
        idx = leaf_idx

        for level_idx in range(len(levels) - 1):
            level = levels[level_idx]
            if idx % 2 == 0:
                sibling_idx = idx + 1
                if sibling_idx < len(level):
                    proof.append(level[sibling_idx])
                else:
                    proof.append(level[idx])
            else:
                sibling_idx = idx - 1
                proof.append(level[sibling_idx])
            idx //= 2

        proofs.append(proof)

    return root, proofs


def build_merkle_tree_sorted(
    claims: List[Tuple[str, int]]
) -> Tuple[bytes, List[List[bytes]], List[bytes], List[Tuple[str, int]]]:
    leaf_data = []
    for addr, amount in claims:
        leaf = leaf_hash(addr, amount)
        leaf_data.append((leaf, addr, amount))

    leaf_data.sort(key=lambda x: x[0])

    sorted_leaves = [item[0] for item in leaf_data]
    sorted_claims = [(item[1], item[2]) for item in leaf_data]

    root, proofs = build_merkle_tree(sorted_leaves)

    return root, proofs, sorted_leaves, sorted_claims


def find_proof_by_address(
    sorted_claims: List[Tuple[str, int]],
    proofs: List[List[bytes]],
    target_address: str,
    target_amount: int
) -> Optional[List[str]]:
    for idx, (addr, amount) in enumerate(sorted_claims):
        if addr == target_address and amount == target_amount:
            return [p.hex() for p in proofs[idx]]
    return None


def export_proofs_json(
    sorted_claims: List[Tuple[str, int]],
    proofs: List[List[bytes]],
    output_path: str
):
    result = []
    for idx, (addr, amount) in enumerate(sorted_claims):
        proof_hex = [p.hex() for p in proofs[idx]]
        result.append({
            "address": addr,
            "amount": str(amount),
            "index": idx,
            "proof": proof_hex,
        })
    with open(output_path, 'w', encoding='utf-8') as f:
        json.dump(result, f, indent=2, ensure_ascii=False)
    print(f"  [+] 已导出 {len(result)} 条证明记录 -> {output_path}")


def export_claims_json(sorted_claims: List[Tuple[str, int]], output_path: str):
    result = [
        {"address": addr, "amount": str(amount)}
        for addr, amount in sorted_claims
    ]
    total = sum(amount for _, amount in sorted_claims)
    output = {"total_claims": len(result), "total_amount": str(total), "claims": result}
    with open(output_path, 'w', encoding='utf-8') as f:
        json.dump(output, f, indent=2, ensure_ascii=False)
    print(f"  [+] 已导出空投列表 -> {output_path} (共 {len(result)} 条, 总量 {total})")


def export_tree_debug(
    sorted_leaves: List[bytes],
    sorted_claims: List[Tuple[str, int]],
    proofs: List[List[bytes]],
    root: bytes,
    output_path: str
):
    proof_data = []
    for idx, (addr, amount) in enumerate(sorted_claims):
        proof_data.append({
            "address": addr,
            "amount": str(amount),
            "leaf_hash": sorted_leaves[idx].hex(),
            "proof": [p.hex() for p in proofs[idx]],
        })
    output = {
        "algorithm": "SHA-256",
        "leaf_encoding": "SHA256(address_bytes ++ amount_be_bytes32)",
        "tree_height": (len(sorted_leaves) - 1).bit_length(),
        "leaves_count": len(sorted_leaves),
        "merkle_root": root.hex(),
        "sorted_leaves": [l.hex() for l in sorted_leaves],
        "proofs": proof_data,
    }
    with open(output_path, 'w', encoding='utf-8') as f:
        json.dump(output, f, indent=2, ensure_ascii=False)
    print(f"  [+] 已导出调试信息 -> {output_path}")


def parse_claims_csv(filepath: str) -> List[Tuple[str, int]]:
    claims = []
    with open(filepath, 'r', encoding='utf-8') as f:
        reader = csv.reader(f)
        header = next(reader, None)
        addr_col, amt_col = 0, 1
        if header:
            header_lower = [h.strip().lower() for h in header]
            for i, h in enumerate(header_lower):
                if h in ('address', 'addr', 'wallet', 'account'):
                    addr_col = i
                elif h in ('amount', 'quantity', 'value', 'tokens'):
                    amt_col = i
        for row_num, row in enumerate(reader, start=2):
            if not row or all(cell.strip() == '' for cell in row):
                continue
            addr = row[addr_col].strip()
            try:
                amount = int(row[amt_col].strip())
            except ValueError:
                print(f"  [!] 第 {row_num} 行金额无效: {row[amt_col]}, 跳过")
                continue
            if not addr or amount <= 0:
                continue
            claims.append((addr, amount))
    print(f"  [+] 从 {filepath} 解析到 {len(claims)} 条空投记录")
    return claims


def validate_addresses(claims: List[Tuple[str, int]]):
    for addr, _ in claims:
        if not addr.startswith("msg1"):
            print(f"  [!] 警告: 地址格式异常: {addr}")


def main():
    parser = argparse.ArgumentParser(description="MSG Chain Merkle 空投生成器")
    parser.add_argument('-i', '--input', required=True, help='输入 CSV 文件')
    parser.add_argument('-o', '--output', default='./merkle_output', help='输出目录')
    parser.add_argument('--query', help='查询指定地址的证明')
    parser.add_argument('--amount', type=int, help='查询时指定的金额')
    parser.add_argument('--no-export', action='store_true', help='不导出文件')

    args = parser.parse_args()

    if not args.no_export:
        os.makedirs(args.output, exist_ok=True)

    print("[*] 正在解析空投列表...")
    claims = parse_claims_csv(args.input)
    if not claims:
        print("[!] 错误: 空投列表为空")
        sys.exit(1)

    print("[*] 正在构建 Merkle 树...")
    root, proofs, sorted_leaves, sorted_claims = build_merkle_tree_sorted(claims)

    print(f"\n{'='*60}")
    print(f"  Merkle Root: {root.hex()}")
    print(f"  参与者数量: {len(claims)}")
    print(f"  空投总数量: {sum(a for _, a in claims)}")
    print(f"  树的高度: {len(proofs[0]) if proofs else 0}")
    print(f"{'='*60}\n")

    if args.query:
        amt = args.amount
        if amt is None:
            for addr, a in sorted_claims:
                if addr == args.query:
                    amt = a
                    break
        if amt is None:
            print(f"[!] 未找到地址 {args.query}")
            sys.exit(1)
        result = find_proof_by_address(sorted_claims, proofs, args.query, amt)
        if result:
            print(f"[+] 地址: {args.query}")
            print(f"[+] 金额: {amt}")
            print(f"[+] Proof ({len(result)} 个节点):")
            for i, p in enumerate(result):
                print(f"    [{i}] {p}")
        else:
            print(f"[!] 未找到证明")
        return

    if not args.no_export:
        with open(os.path.join(args.output, 'merkle_root.txt'), 'w') as f:
            f.write(root.hex())
        print(f"  [+] Merkle Root -> {os.path.join(args.output, 'merkle_root.txt')}")
        export_proofs_json(sorted_claims, proofs, os.path.join(args.output, 'proofs.json'))
        export_claims_json(sorted_claims, os.path.join(args.output, 'claims.json'))
        export_tree_debug(sorted_leaves, sorted_claims, proofs, root,
                          os.path.join(args.output, 'tree_debug.json'))
        print(f"\n[*] 所有文件已导出到: {args.output}/")


def verify_claim_onchain(proof_hex: List[str], root_hex: str, address: str, amount: int) -> bool:
    leaf = leaf_hash(address, amount)
    current = leaf
    for p_hex in proof_hex:
        sibling = bytes.fromhex(p_hex)
        current = hash_pair(current, sibling)
    root = bytes.fromhex(root_hex)
    return current == root


def test_small_tree():
    claims = [
        ("msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqp5n4hg", 1000),
        ("msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqp5n4h8", 2000),
    ]
    root, proofs, _, sorted_claims = build_merkle_tree_sorted(claims)
    assert len(root) == 32
    for idx, (addr, amt) in enumerate(sorted_claims):
        proof_hex = [p.hex() for p in proofs[idx]]
        assert verify_claim_onchain(proof_hex, root.hex(), addr, amt)
    print("[✓] 小型树测试通过")


def test_large_tree(num_claims: int = 100):
    import random, string
    claims = []
    for i in range(num_claims):
        addr = "msg1" + ''.join(random.choices(string.ascii_lowercase, k=38))
        claims.append((addr, random.randint(1, 10000)))
    root, proofs, _, sorted_claims = build_merkle_tree_sorted(claims)
    for idx in list(range(10)) + list(range(num_claims - 10, num_claims)):
        addr, amt = sorted_claims[idx]
        proof_hex = [p.hex() for p in proofs[idx]]
        assert verify_claim_onchain(proof_hex, root.hex(), addr, amt)
    print(f"[✓] 大规模树测试通过 ({num_claims} 条)")


if __name__ == '__main__':
    if len(sys.argv) > 1:
        main()
    else:
        test_small_tree()
        test_large_tree(100)
        print("\\n所有测试通过!")

3.2 使用示例

# 1. 准备空投列表
cat > claims.csv << EOF
address,amount
msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqp5n4hg,1000
msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqp5n4h8,2500
msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqp5n4h6,1500
EOF

# 2. 生成 Merkle 树
python3 merkle_generator.py -i claims.csv -o ./airdrop_output

# 3. 查看 Merkle Root
cat ./airdrop_output/merkle_root.txt

# 4. 查询特定用户证明
python3 merkle_generator.py -i claims.csv --query msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqp5n4hg

3.3 Proofs JSON 格式

[
  {
    "address": "msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqp5n4hg",
    "amount": "1000",
    "index": 0,
    "proof": ["a1b2c3d4e5f6..."]
  }
]

4. 空投部署与分发

4.1 编译合约

# 进入合约目录
cd merkle-airdrop

# 运行单元测试
cargo unit-test

# 编译为 Wasm
cargo wasm

# 优化编译 (推荐)
docker run --rm -v "$(pwd)":/code \
  --mount type=volume,source="$(basename "$(pwd)")_cache",target=/code/target \
  --mount type=volume,source=registry_cache,target=/usr/local/cargo/registry \
  cosmwasm/workspace-optimizer:0.14.0

# 产物在 artifacts/ 目录
ls -la artifacts/

4.2 部署合约

# 设置环境变量
export CHAIN_ID="msg-chain-1"
export RPC_URL="https://rpc.msgchain.zone:26657"
export DEPLOYER_KEY="deployer"
export GAS_PRICES="1000000000attoMSG"

# 1. 上传 Wasm 代码
TX_RESULT=$(msg-chain-devkit tx wasm store ./artifacts/merkle_airdrop.wasm \
  --from "$DEPLOYER_KEY" \
  --gas auto \
  --gas-prices "$GAS_PRICES" \
  --chain-id "$CHAIN_ID" \
  --node "$RPC_URL" \
  --broadcast-mode block \
  --output json)

CODE_ID=$(echo "$TX_RESULT" | jq -r '.logs[0].events[] | select(.type=="store_code") | .attributes[] | select(.key=="code_id") | .value')
echo "Code ID: $CODE_ID"

# 2. 设置空投参数
MERKLE_ROOT=$(cat ./airdrop_output/merkle_root.txt)
TOKEN_CONTRACT="msg1token_contract_address_here"
TOTAL_AMOUNT="50000000000000000000000"   # 50000 * 10^18
MIN_CLAIM="1000000000000000000"           # 1 token
START_TIME=$(date -d "+1 day" +%s)000000000
END_TIME=$(date -d "+30 days" +%s)000000000

# 3. 实例化合约
ADMIN=$(msg-chain-devkit keys show "$DEPLOYER_KEY" -a)

msg-chain-devkit tx wasm instantiate "$CODE_ID" \
  '{
    "token": "'"$TOKEN_CONTRACT"'",
    "merkle_root": "'"$MERKLE_ROOT"'",
    "start_time": "'"$START_TIME"'",
    "end_time": "'"$END_TIME"'",
    "total_amount": "'"$TOTAL_AMOUNT"'",
    "minimum_claim": "'"$MIN_CLAIM"'",
    "enable_recovery": true
  }' \
  --label "merkle-airdrop-$(date +%Y%m%d)" \
  --from "$DEPLOYER_KEY" \
  --gas auto \
  --gas-prices "$GAS_PRICES" \
  --chain-id "$CHAIN_ID" \
  --node "$RPC_URL" \
  --broadcast-mode block \
  --admin "$ADMIN" \
  --output json

4.3 获取合约地址

# 通过 code ID 查询合约列表
CONTRACT_ADDR=$(msg-chain-devkit query wasm list-contract-by-code "$CODE_ID" \
  --node "$RPC_URL" \
  --output json | jq -r '.contracts[-1]')

echo "合约地址: $CONTRACT_ADDR"

# 验证合约配置
msg-chain-devkit query wasm contract-state smart "$CONTRACT_ADDR" \
  '{"config":{}}' \
  --node "$RPC_URL" \
  --output json

4.4 转入代币

# 将空投代币转入空投合约
msg-chain-devkit tx wasm execute "$TOKEN_CONTRACT" \
  '{"transfer":{"recipient":"'"$CONTRACT_ADDR"'","amount":"'"$TOTAL_AMOUNT"'"}}' \
  --from "$DEPLOYER_KEY" \
  --gas auto \
  --gas-prices "$GAS_PRICES" \
  --chain-id "$CHAIN_ID" \
  --node "$RPC_URL"

# 验证余额
msg-chain-devkit query wasm contract-state smart "$TOKEN_CONTRACT" \
  '{"balance":{"address":"'"$CONTRACT_ADDR"'"}}' \
  --node "$RPC_URL"

4.5 上传到 IPFS

#!/usr/bin/env bash
# upload_ipfs.sh

DATA_DIR="./airdrop_output"
IPFS_GATEWAY="https://ipfs.io"

# 检查 IPFS
if ! command -v ipfs &> /dev/null; then
    echo "[!] 请安装 IPFS: https://docs.ipfs.tech/install/"
    echo "    或者使用第三方服务: Pinata / web3.storage / Filebase"
    exit 1
fi

# 启动 IPFS 守护进程 (如果未运行)
if ! curl -s http://localhost:5001/api/v0/version > /dev/null 2>&1; then
    echo "[*] 启动 IPFS 守护进程..."
    ipfs daemon &
    sleep 5
fi

# 上传整个目录
CID=$(ipfs add -r -Q "$DATA_DIR")
echo "IPFS CID: $CID"
echo "访问链接: $IPFS_GATEWAY/ipfs/$CID"
echo ""

# 输出文件引用
echo "proofs.json:   $IPFS_GATEWAY/ipfs/$CID/proofs.json"
echo "claims.json:   $IPFS_GATEWAY/ipfs/$CID/claims.json"
echo "merkle_root.txt: $IPFS_GATEWAY/ipfs/$CID/merkle_root.txt"

4.6 验证空投

#!/usr/bin/env python3
"""verify_airdrop.py - 验证空投配置和证明"""

import json
import sys
import requests

def verify_deployment(contract_addr: str, rpc_url: str, merkle_root: str, total_amount: str):
    """验证合约部署是否正确"""
    payload = {
        "jsonrpc": "2.0",
        "id": 1,
        "method": "abci_query",
        "params": {
            "path": f"/wasm/contract-state/smart/{contract_addr}",
            "data": base64.b64encode(json.dumps({"config": {}}).encode()).decode(),
        }
    }
    resp = requests.post(f"{rpc_url}/", json=payload)
    data = resp.json()
    return data


def main():
    """快速验证脚本"""
    import argparse
    parser = argparse.ArgumentParser(description="验证空投")
    parser.add_argument('--contract', required=True, help='合约地址')
    parser.add_argument('--root', required=True, help='Merkle Root')
    parser.add_argument('--file', help='proofs.json 文件')
    parser.add_argument('--address', help='验证特定地址')

    args = parser.parse_args()

    print(f"[*] 合约地址: {args.contract}")
    print(f"[*] Merkle Root: {args.root}")

    if args.file:
        with open(args.file) as f:
            proofs = json.load(f)
        print(f"[*] 共 {len(proofs)} 个证明")

        if args.address:
            for p in proofs:
                if p['address'] == args.address:
                    print(f"\n[+] 找到地址 {args.address}")
                    print(f"    金额: {p['amount']}")
                    print(f"    索引: {p['index']}")
                    print(f"    证明节点数: {len(p['proof'])}")

                    from merkle_generator import verify_claim_onchain
                    valid = verify_claim_onchain(
                        p['proof'], args.root,
                        p['address'], int(p['amount'])
                    )
                    print(f"    链下验证: {'[✓] 有效' if valid else '[✗] 无效'}")
                    return
            print(f"[!] 未找到地址 {args.address}")


if __name__ == '__main__':
    main()

4.7 完整部署脚本

#!/usr/bin/env bash
# deploy_airdrop.sh - 一键部署空投
set -euo pipefail

# ─── 配置 ─────────────────────────────────────────────────────
TOKEN_CONTRACT="msg1token_here"
DEPLOYER_KEY="deployer"
CHAIN_ID="msg-chain-1"
NODE="https://rpc.msgchain.zone:26657"
GAS_PRICES="1000000000attoMSG"
TOTAL_AMOUNT="50000000000000000000000"
MIN_CLAIM="1000000000000000000"
AIRDROP_DAYS=30
CSV_FILE="./claims.csv"

# ─── 流程 ─────────────────────────────────────────────────────
echo "=== Step 1: 生成 Merkle 树 ==="
python3 merkle_generator.py -i "$CSV_FILE" -o ./airdrop_output
MERKLE_ROOT=$(cat ./airdrop_output/merkle_root.txt)
echo "Merkle Root: $MERKLE_ROOT"

echo ""
echo "=== Step 2: 编译合约 ==="
cd merkle-airdrop
cargo wasm
docker run --rm -v "$(pwd)":/code \
  --mount type=volume,source="$(basename "$(pwd)")_cache",target=/code/target \
  --mount type=volume,source=registry_cache,target=/usr/local/cargo/registry \
  cosmwasm/workspace-optimizer:0.14.0
cd ..

echo ""
echo "=== Step 3: 部署合约代码 ==="
RESULT=$(msg-chain-devkit tx wasm store ./merkle-airdrop/artifacts/merkle_airdrop.wasm \
  --from "$DEPLOYER_KEY" --gas auto --gas-prices "$GAS_PRICES" \
  --chain-id "$CHAIN_ID" --node "$NODE" --broadcast-mode block --output json)
CODE_ID=$(echo "$RESULT" | jq -r '.logs[0].events[] | select(.type=="store_code") | .attributes[] | select(.key=="code_id") | .value')
echo "Code ID: $CODE_ID"

echo ""
echo "=== Step 4: 实例化合约 ==="
START_TIME=$(date -d "+1 day" +%s)000000000
END_TIME=$(date -d "+${AIRDROP_DAYS} days" +%s)000000000
ADMIN=$(msg-chain-devkit keys show "$DEPLOYER_KEY" -a)

INST_RESULT=$(msg-chain-devkit tx wasm instantiate "$CODE_ID" \
  '{"token":"'"$TOKEN_CONTRACT"'","merkle_root":"'"$MERKLE_ROOT"'","start_time":"'"$START_TIME"'","end_time":"'"$END_TIME"'","total_amount":"'"$TOTAL_AMOUNT"'","minimum_claim":"'"$MIN_CLAIM"'","enable_recovery":true}' \
  --label "merkle-airdrop-$(date +%Y%m%d)" \
  --from "$DEPLOYER_KEY" --gas auto --gas-prices "$GAS_PRICES" \
  --chain-id "$CHAIN_ID" --node "$NODE" --broadcast-mode block \
  --admin "$ADMIN" --output json)

CONTRACT_ADDR=$(echo "$INST_RESULT" | jq -r '.logs[0].events[] | select(.type=="instantiate") | .attributes[] | select(.key=="_contract_address") | .value')
echo "合约地址: $CONTRACT_ADDR"

echo ""
echo "=== Step 5: 转入代币 ==="
msg-chain-devkit tx wasm execute "$TOKEN_CONTRACT" \
  '{"transfer":{"recipient":"'"$CONTRACT_ADDR"'","amount":"'"$TOTAL_AMOUNT"'"}}' \
  --from "$DEPLOYER_KEY" --gas auto --gas-prices "$GAS_PRICES" \
  --chain-id "$CHAIN_ID" --node "$NODE"

echo ""
echo "=== Step 6: 上传到 IPFS ==="
CID=$(ipfs add -r -Q ./airdrop_output 2>/dev/null || echo "manual")
echo "IPFS CID: $CID"

echo ""
echo "=== 部署完成 ==="
echo "合约地址: $CONTRACT_ADDR"
echo "Code ID:  $CODE_ID"
echo "Merkle Root: $MERKLE_ROOT"
echo "空投总额: $TOTAL_AMOUNT"
echo "开始时间: $(date -d @${START_TIME%000000000})"
echo "结束时间: $(date -d @${END_TIME%000000000})"
echo ""
echo "空投数据: ipfs://$CID"
echo "Claim 页面: https://claim.example.com"

4.8 空投监控

#!/usr/bin/env python3
"""monitor_airdrop.py - 监控空投领取状态"""

import json
import time
import argparse
import subprocess
from datetime import datetime

def query_contract(contract_addr: str, query_msg: dict, node: str) -> dict:
    cmd = [
        'msg-chain-devkit', 'query', 'wasm', 'contract-state', 'smart',
        contract_addr, json.dumps(query_msg),
        '--node', node, '--output', 'json',
    ]
    result = subprocess.run(cmd, capture_output=True, text=True)
    return json.loads(result.stdout) if result.returncode == 0 else {}

def main():
    parser = argparse.ArgumentParser(description="空投领取监控")
    parser.add_argument('--contract', required=True)
    parser.add_argument('--node', default='https://rpc.msgchain.zone:26657')
    parser.add_argument('--interval', type=int, default=60)

    args = parser.parse_args()

    print(f"[*] 监控合约: {args.contract}")
    print(f"[*] 轮询间隔: {args.interval}s\n")

    prev = 0
    while True:
        try:
            config = query_contract(args.contract, {"config": {}}, args.node)
            tc = int(config.get('total_claimed', '0'))
            ta = int(config.get('total_amount', '1'))
            pct = tc / ta * 100 if ta > 0 else 0
            now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
            diff = tc - prev
            extra = f" [+{diff}]" if diff > 0 and prev > 0 else ""
            print(f"[{now}] 已领取: {tc} / {ta} ({pct:.2f}%){extra}")
            prev = tc
            time.sleep(args.interval)
        except KeyboardInterrupt:
            print("\n[*] 停止监控")
            break
        except Exception as e:
            print(f"[!] 错误: {e}")
            time.sleep(args.interval)

if __name__ == '__main__':
    main()

5. 前端Claim页面

5.1 项目结构

airdrop-frontend/
├── public/
│   └── index.html
├── src/
│   ├── index.tsx
│   ├── App.tsx
│   ├── config.ts
│   ├── types.ts
│   ├── hooks/
│   │   ├── useWallet.ts
│   │   ├── useAirdrop.ts
│   │   └── useClaimData.ts
│   ├── components/
│   │   ├── WalletConnect.tsx
│   │   ├── AirdropStatus.tsx
│   │   ├── ClaimForm.tsx
│   │   └── AirdropPage.tsx
│   └── styles/
│       └── airdrop.css
├── package.json
└── tsconfig.json

5.2 package.json

{
  "name": "merkle-airdrop-frontend",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@cosmjs/cosmwasm-stargate": "^0.32.0",
    "@cosmjs/stargate": "^0.32.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@types/react": "^18.2.0",
    "@types/react-dom": "^18.2.0",
    "typescript": "^5.3.0",
    "vite": "^5.0.0",
    "@vitejs/plugin-react": "^4.2.0"
  }
}

5.3 类型定义

// src/types.ts

export interface AirdropConfig {
  token: string;
  merkle_root: string;
  start_time: string;
  end_time: string;
  total_claimed: string;
  total_amount: string;
  minimum_claim: string;
  enable_recovery: boolean;
  stage: number;
}

export interface ClaimProof {
  address: string;
  amount: string;
  index: number;
  proof: string[];
}

export interface ClaimableAmount {
  amount: string;
  is_claimable: boolean;
}

export interface ChainConfig {
  chainId: string;
  chainName: string;
  rpcUrl: string;
  restUrl: string;
  bech32Prefix: string;
  gasPrice: string;
}

5.4 链配置

// src/config.ts

import type { ChainConfig } from './types';

export const MSG_CHAIN_CONFIG: ChainConfig = {
  chainId: 'msg-chain-1',
  chainName: 'MSG Chain',
  rpcUrl: 'https://rpc.msgchain.zone:26657',
  restUrl: 'https://api.msgchain.zone:1317',
  bech32Prefix: 'msg',
  gasPrice: '1000000000',
};

export const AIRDROP_CONTRACT = 'msg1contract_address_here';
export const PROOFS_URL = 'https://ipfs.io/ipfs/QmYourCID/proofs.json';

5.5 钱包连接 Hook

// src/hooks/useWallet.ts

import { useState, useCallback } from 'react';
import { MSG_CHAIN_CONFIG } from '../config';

declare global {
  interface Window { keplr?: any; }
}

interface WalletState {
  address: string;
  isConnected: boolean;
}

export function useWallet() {
  const [wallet, setWallet] = useState<WalletState>({
    address: '', isConnected: false,
  });
  const [connecting, setConnecting] = useState(false);

  const connect = useCallback(async () => {
    if (!window.keplr) {
      window.open('https://www.keplr.app/download', '_blank');
      return;
    }
    setConnecting(true);
    try {
      await window.keplr.enable(MSG_CHAIN_CONFIG.chainId);
      const signer = window.keplr.getOfflineSigner(MSG_CHAIN_CONFIG.chainId);
      const accounts = await signer.getAccounts();
      setWallet({ address: accounts[0].address, isConnected: true });
    } catch (err) {
      console.error(err);
      alert('钱包连接失败');
    } finally {
      setConnecting(false);
    }
  }, []);

  const disconnect = useCallback(() => {
    setWallet({ address: '', isConnected: false });
  }, []);

  const formatAddress = useCallback((addr: string) => {
    return addr ? `${addr.slice(0, 8)}...${addr.slice(-6)}` : '';
  }, []);

  return { wallet, connecting, connect, disconnect, formatAddress };
}

5.6 空投合约交互 Hook

// src/hooks/useAirdrop.ts

import { useState, useCallback } from 'react';
import { CosmWasmClient, SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate';
import { GasPrice } from '@cosmjs/stargate';
import { MSG_CHAIN_CONFIG } from '../config';
import type { AirdropConfig, ClaimProof, ClaimableAmount } from '../types';

export function useAirdrop(contractAddress: string) {
  const [config, setConfig] = useState<AirdropConfig | null>(null);
  const [loading, setLoading] = useState(false);

  const getClient = useCallback(async () => {
    return CosmWasmClient.connect(MSG_CHAIN_CONFIG.rpcUrl);
  }, []);

  const getSigningClient = useCallback(async () => {
    if (!window.keplr) throw new Error('Keplr not installed');
    await window.keplr.enable(MSG_CHAIN_CONFIG.chainId);
    const signer = window.keplr.getOfflineSigner(MSG_CHAIN_CONFIG.chainId);
    return SigningCosmWasmClient.connectWithSigner(
      MSG_CHAIN_CONFIG.rpcUrl, signer,
      { gasPrice: GasPrice.fromString(MSG_CHAIN_CONFIG.gasPrice) }
    );
  }, []);

  const fetchConfig = useCallback(async () => {
    setLoading(true);
    try {
      const client = await getClient();
      const c = await client.queryContractSmart(contractAddress, { config: {} });
      setConfig(c);
      return c;
    } finally {
      setLoading(false);
    }
  }, [contractAddress, getClient]);

  const checkClaimed = useCallback(async (address: string): Promise<boolean> => {
    try {
      const client = await getClient();
      const r: { is_claimed: boolean } = await client.queryContractSmart(
        contractAddress, { is_claimed: { address } }
      );
      return r.is_claimed;
    } catch { return false; }
  }, [contractAddress, getClient]);

  const queryClaimable = useCallback(async (
    address: string, proof: ClaimProof
  ): Promise<ClaimableAmount> => {
    try {
      const client = await getClient();
      return await client.queryContractSmart(contractAddress, {
        claimable_amount: { address, proof: proof.proof, index: proof.index, amount: proof.amount },
      });
    } catch {
      return { amount: '0', is_claimable: false };
    }
  }, [contractAddress, getClient]);

  const claim = useCallback(async (proof: ClaimProof, beneficiary: string): Promise<string> => {
    setLoading(true);
    try {
      const client = await getSigningClient();
      const key = await window.keplr.getKey(MSG_CHAIN_CONFIG.chainId);
      const result = await client.execute(
        key.bech32Address, contractAddress,
        { claim: { proof: proof.proof, index: proof.index, amount: proof.amount, beneficiary } },
        'auto'
      );
      return result.transactionHash;
    } catch (err: any) {
      if (err?.message?.includes('AlreadyClaimed')) throw new Error('已领取过');
      if (err?.message?.includes('InvalidProof')) throw new Error('证明无效');
      if (err?.message?.includes('AlreadyEnded')) throw new Error('空投已结束');
      if (err?.message?.includes('NotStarted')) throw new Error('空投未开始');
      throw new Error(`领取失败: ${err?.message || '未知错误'}`);
    } finally {
      setLoading(false);
    }
  }, [contractAddress, getSigningClient]);

  return { config, loading, fetchConfig, checkClaimed, queryClaimable, claim };
}

5.7 空投数据加载 Hook

// src/hooks/useClaimData.ts

import { useState, useEffect, useCallback } from 'react';
import type { ClaimProof } from '../types';

export function useClaimData(dataUrl: string) {
  const [proofs, setProofs] = useState<ClaimProof[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  const fetchData = useCallback(async () => {
    setLoading(true);
    setError(null);
    try {
      const resp = await fetch(dataUrl);
      if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
      setProofs(await resp.json());
    } catch (err) {
      setError(err instanceof Error ? err.message : '加载失败');
    } finally {
      setLoading(false);
    }
  }, [dataUrl]);

  useEffect(() => { fetchData(); }, [fetchData]);

  const findProof = useCallback((address: string): ClaimProof | undefined => {
    return proofs.find(p => p.address === address);
  }, [proofs]);

  return { proofs, loading, error, findProof, refresh: fetchData };
}

5.8 钱包连接组件

// src/components/WalletConnect.tsx

import React from 'react';

interface Props {
  address: string;
  isConnected: boolean;
  connecting: boolean;
  onConnect: () => void;
  onDisconnect: () => void;
  formatAddress: (a: string) => string;
}

export const WalletConnect: React.FC<Props> = ({
  address, isConnected, connecting, onConnect, onDisconnect, formatAddress,
}) => (
  <div className="wallet-connect">
    {!isConnected ? (
      <button className="btn btn-primary" onClick={onConnect} disabled={connecting}>
        {connecting ? '连接中...' : '连接钱包'}
      </button>
    ) : (
      <div className="wallet-info">
        <span className="wallet-address" title={address}>
          {formatAddress(address)}
        </span>
        <button className="btn btn-secondary" onClick={onDisconnect}>断开</button>
      </div>
    )}
  </div>
);

5.9 空投状态组件

// src/components/AirdropStatus.tsx

import React from 'react';
import type { AirdropConfig } from '../types';

interface Props { config: AirdropConfig | null; loading: boolean; }

function fmtTime(ts: string): string {
  const d = new Date(parseInt(ts) / 1_000_000);
  return d.toLocaleDateString('zh-CN', {
    year: 'numeric', month: 'long', day: 'numeric',
    hour: '2-digit', minute: '2-digit',
  });
}

function fmtAmt(amt: string, decimals = 18): string {
  const v = BigInt(amt);
  const div = BigInt(10 ** decimals);
  return `${v / div}.${(v % div).toString().padStart(decimals, '0').slice(0, 4)}`;
}

export const AirdropStatus: React.FC<Props> = ({ config, loading }) => {
  if (loading) return <div className="status-loading">加载中...</div>;
  if (!config) return <div className="status-error">无法加载空投配置</div>;

  const totalClaimed = BigInt(config.total_claimed);
  const totalAmount = BigInt(config.total_amount);
  const progress = totalAmount > 0n
    ? Number((totalClaimed * 10000n) / totalAmount) / 100 : 0;

  const now = BigInt(Date.now()) * 1_000_000n;
  const start = BigInt(config.start_time);
  const end = BigInt(config.end_time);
  const isStarted = now >= start;
  const isEnded = now >= end;
  const statusLabel = isEnded ? '已结束' : isStarted ? '进行中' : '尚未开始';

  return (
    <div className="airdrop-status">
      <h3>空投状态</h3>
      <div className="status-grid">
        <div className="status-item">
          <span className="label">状态</span>
          <span className={`value status-${statusLabel}`}>{statusLabel}</span>
        </div>
        <div className="status-item">
          <span className="label">开始</span>
          <span className="value">{fmtTime(config.start_time)}</span>
        </div>
        <div className="status-item">
          <span className="label">结束</span>
          <span className="value">{fmtTime(config.end_time)}</span>
        </div>
        <div className="status-item">
          <span className="label">进度</span>
          <span className="value">{fmtAmt(config.total_claimed)} / {fmtAmt(config.total_amount)}</span>
        </div>
      </div>
      <div className="progress-bar-container">
        <div className="progress-bar-fill" style={{ width: `${Math.min(progress, 100)}%` }} />
        <span className="progress-text">{progress.toFixed(2)}%</span>
      </div>
    </div>
  );
};

5.10 Claim表单组件

// src/components/ClaimForm.tsx

import React, { useState, useEffect } from 'react';
import type { ClaimProof, ClaimableAmount } from '../types';

interface Props {
  walletAddress: string;
  userProof?: ClaimProof;
  checkClaimed: (addr: string) => Promise<boolean>;
  queryClaimable: (addr: string, proof: ClaimProof) => Promise<ClaimableAmount>;
  onClaim: (proof: ClaimProof) => Promise<string>;
}

export const ClaimForm: React.FC<Props> = ({
  walletAddress, userProof, checkClaimed, queryClaimable, onClaim,
}) => {
  const [isClaimed, setIsClaimed] = useState<boolean | null>(null);
  const [claimable, setClaimable] = useState<ClaimableAmount | null>(null);
  const [claiming, setClaiming] = useState(false);
  const [txHash, setTxHash] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!walletAddress) return;
    (async () => {
      const claimed = await checkClaimed(walletAddress);
      setIsClaimed(claimed);
      if (!claimed && userProof) {
        setClaimable(await queryClaimable(walletAddress, userProof));
      }
    })();
  }, [walletAddress, userProof, checkClaimed, queryClaimable]);

  const handleClaim = async () => {
    if (!userProof) return;
    setClaiming(true);
    setError(null);
    try {
      const hash = await onClaim(userProof);
      setTxHash(hash);
      setIsClaimed(true);
    } catch (err) {
      setError(err instanceof Error ? err.message : '领取失败');
    } finally {
      setClaiming(false);
    }
  };

  if (!walletAddress) return <div className="claim-form"><p className="hint">请先连接钱包</p></div>;
  if (isClaimed === null) return <div className="claim-form"><p className="hint">查询中...</p></div>;
  if (isClaimed) return (
    <div className="claim-form claimed">
      <div className="check-icon">&#10003;</div>
      <h3>已领取空投</h3>
      {txHash && <p className="tx-hash">Tx: {txHash.slice(0, 16)}...</p>}
    </div>
  );
  if (!userProof) return (
    <div className="claim-form no-eligibility">
      <h3>无空投资格</h3>
      <p>当前地址不在空投列表中</p>
    </div>
  );
  if (claimable && !claimable.is_claimable) return (
    <div className="claim-form no-eligibility">
      <h3>无法领取</h3>
    </div>
  );

  const amount = claimable ? claimable.amount : userProof.amount;
  const v = BigInt(amount);
  const div = BigInt(10 ** 18);

  return (
    <div className="claim-form eligible">
      <h3>空投可领取</h3>
      <div className="amount-display">
        <span className="amount-value">{v / div}.{(v % div).toString().padStart(18, '0').slice(0, 4)}</span>
        <span className="amount-symbol">MSG</span>
      </div>
      {error && <div className="error-message">{error}</div>}
      <button className="btn btn-primary btn-large" onClick={handleClaim} disabled={claiming}>
        {claiming ? '领取中...' : '领取空投'}
      </button>
      {txHash && (
        <p className="tx-success">
          已提交!
          <a href={`https://explorer.msgchain.zone/tx/${txHash}`} target="_blank" rel="noreferrer">查看交易</a>
        </p>
      )}
    </div>
  );
};

5.11 主页面

// src/components/AirdropPage.tsx

import React, { useEffect, useState } from 'react';
import { useWallet } from '../hooks/useWallet';
import { useAirdrop } from '../hooks/useAirdrop';
import { useClaimData } from '../hooks/useClaimData';
import { WalletConnect } from './WalletConnect';
import { AirdropStatus } from './AirdropStatus';
import { ClaimForm } from './ClaimForm';
import { AIRDROP_CONTRACT, PROOFS_URL } from '../config';
import type { ClaimProof } from '../types';

export const AirdropPage: React.FC = () => {
  const { wallet, connecting, connect, disconnect, formatAddress } = useWallet();
  const { config, loading: configLoading, fetchConfig, checkClaimed, queryClaimable, claim } = useAirdrop(AIRDROP_CONTRACT);
  const { proofs, loading: dataLoading, findProof } = useClaimData(PROOFS_URL);
  const [userProof, setUserProof] = useState<ClaimProof | undefined>();

  useEffect(() => { fetchConfig(); }, [fetchConfig]);

  useEffect(() => {
    if (wallet.isConnected) {
      setUserProof(findProof(wallet.address));
    } else {
      setUserProof(undefined);
    }
  }, [wallet.address, wallet.isConnected, findProof]);

  return (
    <div className="airdrop-container">
      <header className="airdrop-header">
        <h1>MSG Chain 空投</h1>
        <WalletConnect
          address={wallet.address} isConnected={wallet.isConnected}
          connecting={connecting} onConnect={connect}
          onDisconnect={disconnect} formatAddress={formatAddress}
        />
      </header>
      <main>
        <AirdropStatus config={config} loading={configLoading} />
        {dataLoading && <div className="hint">加载空投数据...</div>}
        <ClaimForm
          walletAddress={wallet.address} userProof={userProof}
          checkClaimed={checkClaimed} queryClaimable={queryClaimable}
          onClaim={(p) => claim(p, wallet.address)}
        />
      </main>
      <footer className="airdrop-footer">
        <p>遇到问题? 查看 <a href="https://docs.msgchain.zone/airdrop" target="_blank">空投指南</a></p>
      </footer>
    </div>
  );
};

5.12 样式

/* src/styles/airdrop.css */

.airdrop-container { max-width: 640px; margin: 0 auto; padding: 24px 16px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
.airdrop-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 32px; }
.airdrop-header h1 { font-size: 24px; font-weight: 700; margin: 0; }
.wallet-connect { display: flex; align-items: center; gap: 12px; }
.wallet-address { font-family: 'SF Mono', 'Monaco', monospace; font-size: 14px; color: #666; }
.btn { padding: 8px 20px; border-radius: 8px; border: none; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s; }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-primary { background: #4f46e5; color: white; }
.btn-primary:hover:not(:disabled) { background: #4338ca; }
.btn-secondary { background: #f3f4f6; color: #374151; }
.btn-large { width: 100%; padding: 14px 24px; font-size: 16px; }
.airdrop-status { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 12px; padding: 20px; margin-bottom: 24px; }
.status-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 16px 0; }
.status-item { display: flex; flex-direction: column; gap: 4px; }
.status-item .label { font-size: 12px; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em; }
.status-item .value { font-size: 14px; font-weight: 500; }
.status-进行中 { color: #059669; font-weight: 700; }
.status-已结束 { color: #dc2626; }
.status-尚未开始 { color: #d97706; }
.progress-bar-container { position: relative; height: 24px; background: #e5e7eb; border-radius: 12px; overflow: hidden; margin-top: 12px; }
.progress-bar-fill { height: 100%; background: linear-gradient(90deg, #4f46e5, #7c3aed); border-radius: 12px; transition: width 0.5s ease; }
.progress-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 12px; font-weight: 600; color: #374151; }
.claim-form { background: white; border: 1px solid #e5e7eb; border-radius: 12px; padding: 24px; text-align: center; }
.claim-form h3 { margin: 0 0 16px 0; font-size: 18px; }
.claim-form .hint { color: #6b7280; font-size: 14px; }
.amount-display { margin: 20px 0; }
.amount-value { font-size: 36px; font-weight: 800; color: #4f46e5; }
.amount-symbol { font-size: 18px; font-weight: 600; color: #6b7280; margin-left: 8px; }
.error-message { background: #fef2f2; color: #dc2626; padding: 10px 16px; border-radius: 8px; margin: 12px 0; font-size: 14px; }
.tx-success { margin-top: 12px; font-size: 13px; color: #059669; }
.tx-success a { color: #4f46e5; margin-left: 8px; }
.tx-hash { font-size: 12px; color: #6b7280; margin-top: 8px; }
.check-icon { width: 48px; height: 48px; border-radius: 50%; background: #d1fae5; color: #059669; display: flex; align-items: center; justify-content: center; font-size: 24px; margin: 0 auto 16px; }
.claimed { background: #f0fdf4; border-color: #bbf7d0; }
.no-eligibility { background: #fefce8; border-color: #fde68a; }
.eligible { border-color: #c7d2fe; }
.airdrop-footer { text-align: center; margin-top: 32px; color: #9ca3af; font-size: 13px; }
.airdrop-footer a { color: #4f46e5; }



6. 扩展功能

6.1 多阶段空投

支持多轮空投,每轮拥有独立的 Merkle Root、时间窗口和总额度。

// 在合约中添加阶段管理
pub fn execute_add_stage(
    deps: DepsMut,
    _env: Env,
    _info: MessageInfo,
    merkle_root: String,
    start_time: Timestamp,
    end_time: Timestamp,
    total_amount: Uint128,
) -> Result<Response, ContractError> {
    let config = CONFIG.load(deps.storage)?;

    // 查找可用 stage ID
    let stage_id = STAGES
        .keys(deps.storage, None, None, cosmwasm_std::Order::Descending)
        .next()
        .transpose()?
        .unwrap_or(0) + 1;

    let stage = AirdropConfig {
        token: config.token.clone(),
        merkle_root,
        start_time,
        end_time,
        total_claimed: Uint128::zero(),
        total_amount,
        minimum_claim: config.minimum_claim,
        enable_recovery: config.enable_recovery,
        stage: stage_id,
    };

    STAGES.save(deps.storage, stage_id, &stage)?;

    Ok(Response::new()
        .add_attribute("action", "add_stage")
        .add_attribute("stage_id", stage_id.to_string()))
}

后端生成多阶段空投列表:

def generate_multiphase_claims(
    phases: List[dict],
) -> List[Tuple[str, List[Tuple[str, int]]]]:
    """
    生成多阶段空投。

    参数:
        phases: [
            {
                "name": "phase1",
                "claims": [("addr1", 100), ("addr2", 200)],
            },
            ...
        ]

    返回:
        [(phase_name, [(addr, amount), ...]), ...]
    """
    results = []
    for phase in phases:
        root, proofs, _, sorted_claims = build_merkle_tree_sorted(phase["claims"])
        results.append({
            "name": phase["name"],
            "merkle_root": root.hex(),
            "claims": sorted_claims,
            "proofs": [ [p.hex() for p in proof] for proof in proofs ],
        })
    return results

6.2 投票权委托

允许领取空投时将投票权委托给其他地址。

pub enum ExecuteMsg {
    Claim {
        proof: Vec<String>,
        index: u32,
        amount: Uint128,
        beneficiary: String,
        delegate: Option<String>,  // 投票委托地址
    },
    // ...
}

fn execute_claim_with_delegate(
    deps: DepsMut,
    env: Env,
    _info: MessageInfo,
    proof: Vec<String>,
    amount: Uint128,
    beneficiary: String,
    delegate: Option<String>,
) -> Result<Response, ContractError> {
    // 标准验证流程 ...
    let config = CONFIG.load(deps.storage)?;

    // 发送代币
    let mut messages = vec![
        CosmosMsg::Wasm(WasmMsg::Execute {
            contract_addr: config.token.to_string(),
            msg: to_json_binary(&Cw20ExecuteMsg::Transfer {
                recipient: beneficiary.clone(),
                amount,
            })?,
            funds: vec![],
        }),
    ];

    // 如果有委托,创建质押委托消息 (使用链的原生 staking 模块)
    if let Some(delegate_addr) = delegate {
        let validated = deps.api.addr_validate(&delegate_addr)?;
        messages.push(CosmosMsg::Staking(cosmwasm_std::StakingMsg::Delegate {
            validator: validated.to_string(),
            amount: cosmwasm_std::Coin {
                denom: "umsg".to_string(),
                amount,
            },
        }));
    }

    Ok(Response::new().add_messages(messages))
}

6.3 Staking 要求

要求在领取空投时同时质押一定数量。

pub enum ExecuteMsg {
    ClaimWithStake {
        proof: Vec<String>,
        index: u32,
        amount: Uint128,
        beneficiary: String,
        stake_percentage: u8,  // 0-100, 需要同时质押的百分比
    },
}

fn execute_claim_with_stake(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    proof: Vec<String>,
    amount: Uint128,
    beneficiary: String,
    stake_percentage: u8,
) -> Result<Response, ContractError> {
    // 验证
    let config = CONFIG.load(deps.storage)?;
    let beneficiary_addr = deps.api.addr_validate(&beneficiary)?;

    // 检查 minimum_stake 配置
    let stake_required = amount.multiply_ratio(stake_percentage, 100u128);

    // 先检查用户是否已经质押足够
    let stake_info = deps.querier.query_staking(
        beneficiary_addr.clone(),
        // ...
    )?;

    // 如果不够,从领取金额中扣除质押部分
    let transfer_amount = amount.checked_sub(stake_required)
        .map_err(|_| ContractError::InvalidAmount("Insufficient for stake".into()))?;

    let mut messages = vec![];

    // 发送可提取部分
    messages.push(CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: config.token.to_string(),
        msg: to_json_binary(&Cw20ExecuteMsg::Transfer {
            recipient: beneficiary.clone(),
            amount: transfer_amount,
        })?,
        funds: vec![],
    }));

    // 质押剩余部分 (模拟,实际需要转换为 Native Token)
    // 注意: CW20 代币不能直接用于 staking,需要 wrap
    // 此处仅为概念示意

    Ok(Response::new().add_messages(messages))
}

6.4 推荐奖励

def generate_referral_airdrop(
    base_claims: List[Tuple[str, int]],
    referrals: List[Tuple[str, str]],  # (referrer, referee)
    bonus_per_referral: int = 100,
    max_bonus: int = 1000,
) -> List[Tuple[str, int]]:
    """
    生成带推荐奖励的空投列表。

    每个被推荐人获得 base 金额 + bonus
    推荐人获得每个成功推荐的 bonus

    参数:
        base_claims: 基础空投列表
        referrals: 推荐关系 [(referrer, referee), ...]
        bonus_per_referral: 每个推荐奖励金额
        max_bonus: 推荐奖励上限
    """
    from collections import defaultdict

    base_map = {addr: amt for addr, amt in base_claims}
    referee_bonus_map = defaultdict(int)
    referrer_count = defaultdict(int)

    for referrer, referee in referrals:
        if referee in base_map:
            referee_bonus_map[referee] += bonus_per_referral
            referrer_count[referrer] += 1

    final_claims = {}

    # 基础 + 推荐奖励 (被推荐人)
    for addr, base in base_map.items():
        bonus = referee_bonus_map.get(addr, 0)
        final_claims[addr] = base + bonus

    # 推荐人奖励
    for referrer, count in referrer_count.items():
        bonus = min(count * bonus_per_referral, max_bonus)
        final_claims[referrer] = final_claims.get(referrer, 0) + bonus

    return list(final_claims.items())

6.5 解锁计划 (Vesting)

// 带解锁计划的空投领取

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct VestingSchedule {
    pub beneficiary: Addr,
    pub total_amount: Uint128,
    pub start_time: Timestamp,
    pub cliff_duration: u64,      // 锁定期 (秒)
    pub vesting_duration: u64,    // 解锁期 (秒)
    pub released_amount: Uint128,
}

pub const VESTING_SCHEDULES: Map<&Addr, VestingSchedule> = Map::new("vesting");

pub fn execute_claim_vesting(
    deps: DepsMut,
    env: Env,
    _info: MessageInfo,
    proof: Vec<String>,
    amount: Uint128,
    beneficiary: String,
) -> Result<Response, ContractError> {
    let config = CONFIG.load(deps.storage)?;
    let beneficiary_addr = deps.api.addr_validate(&beneficiary)?;

    // 标准验证
    // ...

    // 创建解锁计划: 30天 cliff, 90天线性解锁
    let schedule = VestingSchedule {
        beneficiary: beneficiary_addr.clone(),
        total_amount: amount,
        start_time: env.block.time,
        cliff_duration: 30 * 24 * 3600,     // 30 天
        vesting_duration: 90 * 24 * 3600,    // 90 天
        released_amount: Uint128::zero(),
    };

    VESTING_SCHEDULES.save(deps.storage, &beneficiary_addr, &schedule)?;

    // 代币仍然保留在合约中,不立即发送
    // 用户通过 release_vesting 函数逐步提取

    Ok(Response::new()
        .add_attribute("action", "claim_vesting")
        .add_attribute("beneficiary", &beneficiary)
        .add_attribute("total_amount", amount.to_string()))
}

pub fn execute_release_vesting(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
) -> Result<Response, ContractError> {
    let config = CONFIG.load(deps.storage)?;
    let mut schedule = VESTING_SCHEDULES
        .load(deps.storage, &info.sender)?;

    let elapsed = env.block.time.seconds() - schedule.start_time.seconds();

    let releasable = if elapsed < schedule.cliff_duration {
        Uint128::zero()
    } else if elapsed >= schedule.cliff_duration + schedule.vesting_duration {
        schedule.total_amount - schedule.released_amount
    } else {
        let vested = schedule.total_amount.multiply_ratio(
            elapsed - schedule.cliff_duration,
            schedule.vesting_duration,
        );
        vested - schedule.released_amount
    };

    if releasable.is_zero() {
        return Err(ContractError::InvalidAmount("Nothing to release".into()));
    }

    schedule.released_amount = schedule.released_amount.checked_add(releasable)?;
    VESTING_SCHEDULES.save(deps.storage, &info.sender, &schedule)?;

    let transfer_msg = CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: config.token.to_string(),
        msg: to_json_binary(&Cw20ExecuteMsg::Transfer {
            recipient: info.sender.to_string(),
            amount: releasable,
        })?,
        funds: vec![],
    });

    Ok(Response::new()
        .add_message(transfer_msg)
        .add_attribute("action", "release_vesting")
        .add_attribute("amount", releasable.to_string()))
}

6.6 未领取代币回收

// 通过治理投票回收未领取代币
// 需要在合约层面与治理模块集成

pub fn execute_recover_governance(
    deps: DepsMut,
    env: Env,
    _info: MessageInfo,
    recipient: String,
    amount: Uint128,
) -> Result<Response, ContractError> {
    let config = CONFIG.load(deps.storage)?;

    // 确保空投已结束
    if env.block.time < config.end_time {
        return Err(ContractError::NotStarted {});
    }

    // 检查治理提案是否通过 (伪代码,实际需要与 DAO 合约交互)
    // let proposal_passed = dao_contract.query_proposal(proposal_id)?;
    // if !proposal_passed {
    //     return Err(ContractError::GovernanceRequired {});
    // }

    let unclaimed = config.total_amount.checked_sub(config.total_claimed)?;
    let recover_amount = amount.min(unclaimed);

    let transfer_msg = CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: config.token.to_string(),
        msg: to_json_binary(&Cw20ExecuteMsg::Transfer {
            recipient: recipient.clone(),
            amount: recover_amount,
        })?,
        funds: vec![],
    });

    Ok(Response::new()
        .add_message(transfer_msg)
        .add_attribute("action", "recover_governance")
        .add_attribute("recipient", &recipient)
        .add_attribute("amount", recover_amount.to_string()))
}

6.7 白名单 + Merkle 混合模式

// 白名单 + Merkle 混合: 先检查白名单,再使用 Merkle 验证
pub const WHITELIST: Map<&Addr, bool> = Map::new("whitelist");

pub fn execute_add_whitelist(
    deps: DepsMut,
    _env: Env,
    info: MessageInfo,
    addresses: Vec<String>,
) -> Result<Response, ContractError> {
    // 仅管理员
    // ...

    for addr_str in &addresses {
        let addr = deps.api.addr_validate(addr_str)?;
        WHITELIST.save(deps.storage, &addr, &true)?;
    }

    Ok(Response::new()
        .add_attribute("action", "add_whitelist")
        .add_attribute("count", addresses.len().to_string()))
}

pub fn execute_claim_whitelist(
    deps: DepsMut,
    env: Env,
    _info: MessageInfo,
    proof: Vec<String>,
    amount: Uint128,
    beneficiary: String,
) -> Result<Response, ContractError> {
    let beneficiary_addr = deps.api.addr_validate(&beneficiary)?;

    // 额外检查白名单
    let is_whitelisted = WHITELIST
        .may_load(deps.storage, &beneficiary_addr)?
        .unwrap_or(false);

    if !is_whitelisted {
        return Err(ContractError::Unauthorized {});
    }

    // 然后执行标准 Merkle 验证流程
    // ...
}

6.8 跨链空投 (IBC 集成)

// 概念: 通过 IBC 将空投数据发送到其他链
// MSG Chain 可以发送 IBC 数据包到其他 IBC 兼容链

pub fn execute_ibc_airdrop(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    channel_id: String,
    remote_merkle_root: String,
) -> Result<Response, ContractError> {
    // 构建 IBC 数据包
    let packet = IbcAirdropPacket {
        merkle_root: remote_merkle_root,
        sender: info.sender.to_string(),
    };

    let msg = CosmosMsg::Ibc(IbcMsg::SendPacket {
        channel_id,
        data: to_json_binary(&packet)?,
        timeout: IbcTimeout::with_timestamp(env.block.time.plus_seconds(3600)),
    });

    Ok(Response::new()
        .add_message(msg)
        .add_attribute("action", "ibc_airdrop"))
}

7. 安全考虑

7.1 Merkle Proof 重放保护

问题: 攻击者观察到某用户的 claim 交易后,可以复制证明重新提交。

解决方案:

  1. 合约内置重放保护: 使用 CLAIMED map 记录每个地址的状态。
  2. Nonce 机制: 每个地址只能 claim 一次,不可重复。
  3. 过期时间: 空投结束时间后禁止 claim。
// 重放保护的核心逻辑:
// - CLAIMED map 确保每个地址只能 claim 一次
// - 即使相同的 proof 被重复提交,合约会拒绝
if CLAIMED.has(deps.storage, &beneficiary_addr) {
    return Err(ContractError::AlreadyClaimed {});
}

7.2 第二原像攻击

问题: 攻击者构造一个不同的叶子节点,但哈希值与另一个叶子相同。

解决方案:

// 使用排序拼接 (sorted pair hashing)
// 而不是直接拼接
fn hash_pair(a: &[u8], b: &[u8]) -> [u8; 32] {
    let mut hasher = Sha256::new();
    if a < b {
        hasher.update(a);
        hasher.update(b);
    } else {
        hasher.update(b);
        hasher.update(a);
    }
    // ...
}

7.3 粉尘攻击

问题: 攻击者大量创建小额 claim 来消耗 Gas。

解决方案:

// 设置最低领取数量
if amount < config.minimum_claim {
    return Err(ContractError::DustClaim(format!(
        "Claim amount {} is below minimum {}", amount, config.minimum_claim
    )));
}

推荐的最低值取决于代币小数位:

7.4 前端抢跑

问题: 矿工/验证者看到用户的 claim 交易后,抢先提交自己的 claim。

解决方案:

  1. 时间窗口检查: 合约检查 env.block.time 在 start_time 和 end_time 之间。
  2. Gas 费用控制: 合理设置 gas 限制,减少 MEV 风险。
  3. 批量处理: 通过批量合约统一处理 claim 请求。

7.5 Merkle Root 验证

问题: 错误的 Merkle Root 导致所有 claim 失败或资金被窃取。

解决方案:

// 实例化时严格验证 Root 格式
let root_bytes = hex::decode(&msg.merkle_root)
    .map_err(|_| ContractError::InvalidMerkleRoot {})?;
if root_bytes.len() != 32 {
    return Err(ContractError::InvalidMerkleRoot {});
}

7.6 时间操纵

问题: 验证者操纵区块时间戳。

解决方案:

  1. MSG Chain 区块时间由验证者共识决定,单验证者操纵难度大。
  2. 设置合理的时间缓冲 (如空投开始后 1 天才能领取)。
  3. 不依赖绝对精确的时间,使用 +/- 容差。
// 时间检查有 1 区块的容忍度
// 但对于精确时间要求高的场景,可以增加缓冲
const TIME_BUFFER: u64 = 5;  // 5秒 (1个区块)
if env.block.time.seconds() + TIME_BUFFER < config.start_time.seconds() {
    return Err(ContractError::NotStarted {});
}

7.7 整数溢出

问题: 计算总领取量时发生整数溢出。

解决方案:

// 使用 checked_add / checked_sub
config.total_claimed.checked_add(amount)
    .map_err(|_| StdError::generic_err("Total claimed overflow"))?;

// 并且检查不超过总量
if new_total > config.total_amount {
    return Err(StdError::generic_err("Exceeds total amount"));
}

7.8 前端安全

// 1. 验证用户输入的地址
function validateAddress(addr: string): boolean {
    return addr.startsWith('msg1') && addr.length === 44;
}

// 2. 检查 chain ID 防止跨链重放
const chainId = await window.keplr.getKey(MSG_CHAIN_CONFIG.chainId);
if (chainId.bech32Address !== expectedAddress) {
    throw new Error('Chain ID mismatch');
}

// 3. 交易确认等待多个区块
const result = await client.execute(sender, contract, msg, 'auto');
await client.waitForTx(result.transactionHash, 3);  // 等待 3 个区块确认

7.9 合约权限

// 使用 ownership 模式管理权限
pub const OWNER: Item<Addr> = Item::new("owner");

pub fn execute_transfer_ownership(
    deps: DepsMut,
    info: MessageInfo,
    new_owner: String,
) -> Result<Response, ContractError> {
    let owner = OWNER.load(deps.storage)?;
    if info.sender != owner {
        return Err(ContractError::Unauthorized {});
    }
    let new = deps.api.addr_validate(&new_owner)?;
    OWNER.save(deps.storage, &new)?;
    Ok(Response::new().add_attribute("action", "transfer_ownership"))
}

7.10 安全检查清单

检查项 说明 优先级
Merkle Root 长度验证 必须为 32 字节 高
叶子节点编码一致性 链上链下编码必须完全一致 高
重放保护 每个地址只能 Claim 一次 高
时间窗口验证 start <= block.time < end 高
粉尘控制 minimum_claim 验证 中
整数溢出检查 checked_add / checked_sub 高
权限控制 owner 权限管理 高
前端地址验证 Bech32 格式校验 中
跨链重放保护 chain ID 校验 中
资金回收治理 通过 DAO 投票 中
合约升级 使用 migrate 或 proxy 模式 低
Gas 限制 合理设置 gas 参数 中

附录

A. Gas 消耗估算

操作 Gas 消耗 (估计) 费用 (umsg)
合约实例化 ~200,000 5,000
Claim (1 个证明节点) ~150,000 3,750
Claim (10 个证明节点) ~180,000 4,500
Claim (20 个证明节点) ~220,000 5,500
查询配置 ~10,000 250
查询是否已领取 ~8,000 200

Gas 价格: 1,000,000,000 attoMSG/gas

B. 常见问题

Q: 为什么 Claim 失败? 可能原因:

  1. 空投未开始或已结束
  2. Merkle Proof 不匹配(地址或金额错误)
  3. 该地址已经 Claim 过
  4. 金额低于最低领取标准

Q: 如何验证自己的空投资格? 在前端输入地址查询,或使用 Python 脚本验证:

from merkle_generator import verify_claim_onchain
root = "your_merkle_root"
proof = ["proof_hash_1", "proof_hash_2"]
address = "msg1your_address"
amount = 1000
valid = verify_claim_onchain(proof, root, address, amount)
print("Valid:", valid)

Q: 发现错误怎么办? 空投列表生成后不可修改合约内 Merkle Root。如果发现错误:

Q: 代币为什么没到账? 可能在区块浏览器中查询交易状态。等待 2-3 个区块确认。

C. 相关资源

D. 完整合约文件清单

merkle-airdrop/
├── Cargo.toml
├── src/
│   ├── lib.rs               # 模块声明
│   ├── contract.rs          # 合约入口 (instantiate, execute, query)
│   ├── msg.rs               # 消息定义 (InstantiateMsg, ExecuteMsg, QueryMsg)
│   ├── state.rs             # 状态存储 (AirdropConfig, CLAIMED, STAGES)
│   ├── error.rs             # 错误类型 (ContractError)
│   └── merkle.rs            # Merkle 验证 (hash_pair, leaf_hash, verify_proof)
├── tests/
│   └── integration.rs       # 集成测试
├── schema/                  # JSON Schema (自动生成)
│   ├── instantiate_msg.json
│   ├── execute_msg.json
│   └── query_msg.json
└── artifacts/               # 编译产物
    └── merkle_airdrop.wasm

E. 测试命令

# 运行全部测试
cargo test

# 运行特定测试
cargo test test_claim_success

# 测试覆盖率 (需要 nightly)
cargo +nightly tarpaulin --ignore-tests

# 生成 Schema
cargo schema

# 检查编译
cargo check
cargo wasm-debug

F. 版本兼容性

组件 最低版本 推荐版本
cosmwasm-std 1.5 1.5.x
cw20 1.1 1.1.x
cw-storage-plus 1.2 1.2.x
sha2 0.10 0.10.x
Rust 1.70 1.75+
MSG Chain v0.1 latest
Keplr 0.12 latest

本文档基于 MSG Chain 代码库核实的技术事实。
白皮书系统: https://msgchain.org/whitepaper/