use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use uuid::Uuid;
use crate::error::{CoreError, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AirdropConfig {
pub token_id: Uuid,
pub total_amount: Decimal,
pub start_time: DateTime<Utc>,
pub deadline: DateTime<Utc>,
pub min_claim_amount: Decimal,
pub allow_multiple_claims: bool,
pub max_claims_per_user: usize,
}
impl AirdropConfig {
pub fn new(
token_id: Uuid,
total_amount: Decimal,
start_time: DateTime<Utc>,
deadline: DateTime<Utc>,
) -> Result<Self> {
if total_amount <= dec!(0) {
return Err(CoreError::Validation(
"Total amount must be positive".to_string(),
));
}
if deadline <= start_time {
return Err(CoreError::Validation(
"Deadline must be after start time".to_string(),
));
}
Ok(Self {
token_id,
total_amount,
start_time,
deadline,
min_claim_amount: dec!(0.001),
allow_multiple_claims: false,
max_claims_per_user: 1,
})
}
pub fn is_active(&self) -> bool {
let now = Utc::now();
now >= self.start_time && now <= self.deadline
}
pub fn is_ended(&self) -> bool {
Utc::now() > self.deadline
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AirdropStatus {
Pending,
Active,
Ended,
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AirdropEntry {
pub user_id: Uuid,
pub amount: Decimal,
pub metadata: Option<String>,
}
impl AirdropEntry {
pub fn new(user_id: Uuid, amount: Decimal) -> Self {
Self {
user_id,
amount,
metadata: None,
}
}
pub fn compute_hash(&self) -> String {
let data = format!("{}|{}", self.user_id, self.amount);
let hash = Sha256::digest(data.as_bytes());
hex::encode(hash)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MerkleProof {
pub leaf: String,
pub siblings: Vec<String>,
pub positions: Vec<bool>,
}
impl MerkleProof {
pub fn verify(&self, root: &str) -> bool {
let mut current_hash = self.leaf.clone();
for (sibling, &is_right) in self.siblings.iter().zip(&self.positions) {
current_hash = if is_right {
Self::hash_pair(sibling, ¤t_hash)
} else {
Self::hash_pair(¤t_hash, sibling)
};
}
current_hash == root
}
fn hash_pair(left: &str, right: &str) -> String {
let combined = format!("{}|{}", left, right);
let hash = Sha256::digest(combined.as_bytes());
hex::encode(hash)
}
}
#[derive(Debug, Clone)]
pub struct MerkleTree {
leaves: Vec<String>,
levels: Vec<Vec<String>>,
}
impl MerkleTree {
pub fn build(entries: &[AirdropEntry]) -> Self {
let mut leaves: Vec<String> = entries.iter().map(|e| e.compute_hash()).collect();
if leaves.is_empty() {
leaves.push(String::new());
}
let next_power_of_2 = leaves.len().next_power_of_two();
while leaves.len() < next_power_of_2 {
leaves.push(leaves.last().unwrap().clone());
}
let mut levels = vec![leaves.clone()];
let mut current_level = leaves.clone();
while current_level.len() > 1 {
let mut next_level = Vec::new();
for i in (0..current_level.len()).step_by(2) {
let left = ¤t_level[i];
let right = ¤t_level[i + 1];
let parent = MerkleProof::hash_pair(left, right);
next_level.push(parent);
}
levels.push(next_level.clone());
current_level = next_level;
}
Self { leaves, levels }
}
pub fn root(&self) -> String {
self.levels.last().unwrap()[0].clone()
}
pub fn get_proof(&self, index: usize) -> Option<MerkleProof> {
if index >= self.leaves.len() {
return None;
}
let leaf = self.leaves[index].clone();
let mut siblings = Vec::new();
let mut positions = Vec::new();
let mut current_index = index;
for level in &self.levels[..self.levels.len() - 1] {
let sibling_index = if current_index % 2 == 0 {
current_index + 1
} else {
current_index - 1
};
siblings.push(level[sibling_index].clone());
positions.push(current_index % 2 == 1);
current_index /= 2;
}
Some(MerkleProof {
leaf,
siblings,
positions,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaimRecord {
pub id: Uuid,
pub user_id: Uuid,
pub amount: Decimal,
pub claimed_at: DateTime<Utc>,
pub tx_hash: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AirdropStats {
pub total_allocated: Decimal,
pub total_claimed: Decimal,
pub remaining: Decimal,
pub eligible_users: usize,
pub claimed_users: usize,
pub claim_rate_pct: Decimal,
pub status: AirdropStatus,
}
pub struct Airdrop {
pub id: Uuid,
pub config: AirdropConfig,
pub entries: Vec<AirdropEntry>,
pub merkle_tree: MerkleTree,
pub merkle_root: String,
pub claims: Vec<ClaimRecord>,
claimed_users: HashSet<Uuid>,
total_claimed: Decimal,
status: AirdropStatus,
}
impl Airdrop {
pub fn new(config: AirdropConfig, entries: Vec<AirdropEntry>) -> Result<Self> {
if entries.is_empty() {
return Err(CoreError::Validation(
"No airdrop entries provided".to_string(),
));
}
let sum: Decimal = entries.iter().map(|e| e.amount).sum();
if (sum - config.total_amount).abs() > dec!(0.000001) {
return Err(CoreError::Validation(format!(
"Entry sum {} does not match total amount {}",
sum, config.total_amount
)));
}
let merkle_tree = MerkleTree::build(&entries);
let merkle_root = merkle_tree.root();
let status = if config.is_active() {
AirdropStatus::Active
} else if config.is_ended() {
AirdropStatus::Ended
} else {
AirdropStatus::Pending
};
Ok(Self {
id: Uuid::new_v4(),
config,
entries,
merkle_tree,
merkle_root,
claims: Vec::new(),
claimed_users: HashSet::new(),
total_claimed: dec!(0),
status,
})
}
pub fn update_status(&mut self) {
if self.status == AirdropStatus::Cancelled {
return;
}
self.status = if self.config.is_active() {
AirdropStatus::Active
} else if self.config.is_ended() {
AirdropStatus::Ended
} else {
AirdropStatus::Pending
};
}
pub fn claim(
&mut self,
user_id: Uuid,
amount: Decimal,
proof: MerkleProof,
) -> Result<ClaimRecord> {
self.update_status();
if self.status != AirdropStatus::Active {
return Err(CoreError::InvalidState(format!(
"Airdrop is not active (status: {:?})",
self.status
)));
}
if !self.config.allow_multiple_claims && self.claimed_users.contains(&user_id) {
return Err(CoreError::AlreadyExists(
"User has already claimed".to_string(),
));
}
if !proof.verify(&self.merkle_root) {
return Err(CoreError::Validation("Invalid Merkle proof".to_string()));
}
let entry = self
.entries
.iter()
.find(|e| e.user_id == user_id)
.ok_or_else(|| CoreError::NotFound("User not in airdrop list".to_string()))?;
if entry.amount != amount {
return Err(CoreError::Validation(format!(
"Amount mismatch: expected {}, got {}",
entry.amount, amount
)));
}
let claim = ClaimRecord {
id: Uuid::new_v4(),
user_id,
amount,
claimed_at: Utc::now(),
tx_hash: None,
};
self.claims.push(claim.clone());
self.claimed_users.insert(user_id);
self.total_claimed += amount;
Ok(claim)
}
pub fn get_proof(&self, user_id: &Uuid) -> Option<MerkleProof> {
let index = self.entries.iter().position(|e| &e.user_id == user_id)?;
self.merkle_tree.get_proof(index)
}
pub fn is_eligible(&self, user_id: &Uuid) -> bool {
self.entries.iter().any(|e| &e.user_id == user_id)
}
pub fn get_allocation(&self, user_id: &Uuid) -> Option<Decimal> {
self.entries
.iter()
.find(|e| &e.user_id == user_id)
.map(|e| e.amount)
}
pub fn stats(&self) -> AirdropStats {
let total_allocated = self.config.total_amount;
let total_claimed = self.total_claimed;
let remaining = total_allocated - total_claimed;
let eligible_users = self.entries.len();
let claimed_users = self.claimed_users.len();
let claim_rate_pct = if eligible_users > 0 {
(Decimal::from(claimed_users) / Decimal::from(eligible_users)) * dec!(100)
} else {
dec!(0)
};
AirdropStats {
total_allocated,
total_claimed,
remaining,
eligible_users,
claimed_users,
claim_rate_pct,
status: self.status,
}
}
pub fn cancel(&mut self) -> Result<()> {
if self.status == AirdropStatus::Ended {
return Err(CoreError::InvalidState(
"Cannot cancel ended airdrop".to_string(),
));
}
if !self.claims.is_empty() {
return Err(CoreError::InvalidState(
"Cannot cancel airdrop with existing claims".to_string(),
));
}
self.status = AirdropStatus::Cancelled;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
#[test]
fn test_airdrop_entry_hash() {
let user_id = Uuid::new_v4();
let entry = AirdropEntry::new(user_id, dec!(100));
let hash1 = entry.compute_hash();
let hash2 = entry.compute_hash();
assert_eq!(hash1, hash2);
}
#[test]
fn test_merkle_tree_build() {
let entries = vec![
AirdropEntry::new(Uuid::new_v4(), dec!(100)),
AirdropEntry::new(Uuid::new_v4(), dec!(200)),
AirdropEntry::new(Uuid::new_v4(), dec!(300)),
AirdropEntry::new(Uuid::new_v4(), dec!(400)),
];
let tree = MerkleTree::build(&entries);
let root = tree.root();
assert!(!root.is_empty());
}
#[test]
fn test_merkle_proof_verification() {
let entries = vec![
AirdropEntry::new(Uuid::new_v4(), dec!(100)),
AirdropEntry::new(Uuid::new_v4(), dec!(200)),
];
let tree = MerkleTree::build(&entries);
let root = tree.root();
let proof = tree.get_proof(0).unwrap();
assert!(proof.verify(&root));
}
#[test]
fn test_airdrop_creation() {
let token_id = Uuid::new_v4();
let start = Utc::now() - Duration::days(1);
let deadline = Utc::now() + Duration::days(29);
let config = AirdropConfig::new(token_id, dec!(1000), start, deadline).unwrap();
let entries = vec![
AirdropEntry::new(Uuid::new_v4(), dec!(400)),
AirdropEntry::new(Uuid::new_v4(), dec!(300)),
AirdropEntry::new(Uuid::new_v4(), dec!(300)),
];
let airdrop = Airdrop::new(config, entries).unwrap();
assert_eq!(airdrop.status, AirdropStatus::Active);
}
#[test]
fn test_airdrop_claim() {
let token_id = Uuid::new_v4();
let user1 = Uuid::new_v4();
let user2 = Uuid::new_v4();
let start = Utc::now() - Duration::days(1);
let deadline = Utc::now() + Duration::days(29);
let config = AirdropConfig::new(token_id, dec!(600), start, deadline).unwrap();
let entries = vec![
AirdropEntry::new(user1, dec!(400)),
AirdropEntry::new(user2, dec!(200)),
];
let mut airdrop = Airdrop::new(config, entries).unwrap();
let proof = airdrop.get_proof(&user1).unwrap();
let claim = airdrop.claim(user1, dec!(400), proof).unwrap();
assert_eq!(claim.amount, dec!(400));
let stats = airdrop.stats();
assert_eq!(stats.total_claimed, dec!(400));
assert_eq!(stats.claimed_users, 1);
}
#[test]
fn test_airdrop_duplicate_claim() {
let token_id = Uuid::new_v4();
let user1 = Uuid::new_v4();
let start = Utc::now() - Duration::days(1);
let deadline = Utc::now() + Duration::days(29);
let config = AirdropConfig::new(token_id, dec!(100), start, deadline).unwrap();
let entries = vec![AirdropEntry::new(user1, dec!(100))];
let mut airdrop = Airdrop::new(config, entries).unwrap();
let proof = airdrop.get_proof(&user1).unwrap();
airdrop.claim(user1, dec!(100), proof.clone()).unwrap();
let result = airdrop.claim(user1, dec!(100), proof);
assert!(result.is_err());
}
#[test]
fn test_airdrop_invalid_proof() {
let token_id = Uuid::new_v4();
let user1 = Uuid::new_v4();
let user2 = Uuid::new_v4();
let start = Utc::now() - Duration::days(1);
let deadline = Utc::now() + Duration::days(29);
let config = AirdropConfig::new(token_id, dec!(300), start, deadline).unwrap();
let entries = vec![
AirdropEntry::new(user1, dec!(200)),
AirdropEntry::new(user2, dec!(100)),
];
let mut airdrop = Airdrop::new(config, entries).unwrap();
let fake_proof = MerkleProof {
leaf: "fake".to_string(),
siblings: vec![],
positions: vec![],
};
let result = airdrop.claim(user1, dec!(200), fake_proof);
assert!(result.is_err());
}
}