Merkle空投Airdrop合约实现 —— MSG Chain 完整指南
适用环境: MSG Chain (msg-chain-1) | CosmWasm 1.x | CW20 标准代币
主网状态: No-Go
目录
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 空投的优势:
- Gas 效率极高: 只有根节点存储在链上(32字节),所有参与数据在链下
- 计算在链下: Merkle 树的生成和证明计算均在链下完成
- 无信任假设: 用户可自行验证证明的正确性,无需依赖第三方
- 可扩展性: 支持数百万参与地址,链上存储不变
- 按需领取: 用户主动触发 claim,Gas 由用户承担
1.3 承诺-揭示模式
Merkle 空投基于密码学中的承诺-揭示(Commit-Reveal)模式:
承诺阶段(部署时):
- 确定空投列表(地址 -> 数量)
- 构建 Merkle Tree
- 将 Merkle Root 部署到合约中
- 将完整空投列表和证明存储到链下(IPFS / 中心化服务器)
揭示阶段(Claim时):
- 用户提供自己的地址和应得数量
- 用户提供 Merkle Proof(从叶子到根的路径)
- 合约验证 proof 是否可以得到根节点
- 验证通过后,代币发放给用户
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)
- address_bytes: Bech32 地址解码后的字节(字符串 UTF-8 编码)
- amount_be_bytes: uint256 的大端序 32 字节编码
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(¤t, &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">✓</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 交易后,可以复制证明重新提交。
解决方案:
- 合约内置重放保护: 使用
CLAIMEDmap 记录每个地址的状态。 - Nonce 机制: 每个地址只能 claim 一次,不可重复。
- 过期时间: 空投结束时间后禁止 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
)));
}
推荐的最低值取决于代币小数位:
- 18 位小数:
minimum_claim = 1_000_000_000_000_000_000(1 token) - 18 位小数:
minimum_claim = 1_000_000(1 token)
7.4 前端抢跑
问题: 矿工/验证者看到用户的 claim 交易后,抢先提交自己的 claim。
解决方案:
- 时间窗口检查: 合约检查
env.block.time在start_time和end_time之间。 - Gas 费用控制: 合理设置 gas 限制,减少 MEV 风险。
- 批量处理: 通过批量合约统一处理 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 时间操纵
问题: 验证者操纵区块时间戳。
解决方案:
- MSG Chain 区块时间由验证者共识决定,单验证者操纵难度大。
- 设置合理的时间缓冲 (如空投开始后 1 天才能领取)。
- 不依赖绝对精确的时间,使用 +/- 容差。
// 时间检查有 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 失败? 可能原因:
- 空投未开始或已结束
- Merkle Proof 不匹配(地址或金额错误)
- 该地址已经 Claim 过
- 金额低于最低领取标准
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. 相关资源
- MSG Chain 官方文档: https://docs.msgchain.zone
- CosmWasm 文档: https://docs.cosmwasm.com
- Keplr 钱包: https://www.keplr.app
- IPFS 文档: https://docs.ipfs.tech
- cw-plus 仓库: https://github.com/CosmWasm/cw-plus
- CW20 规范: https://github.com/CosmWasm/cw-plus/blob/main/packages/cw20/README.md
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/
