use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use uuid::Uuid;
use crate::error::{CoreError, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MigrationConfig {
pub old_token_id: Uuid,
pub new_token_id: Uuid,
pub migration_ratio: Decimal,
pub start_time: DateTime<Utc>,
pub deadline: DateTime<Utc>,
pub allow_partial: bool,
pub min_migration_amount: Decimal,
}
impl MigrationConfig {
pub fn new(
old_token_id: Uuid,
new_token_id: Uuid,
migration_ratio: Decimal,
start_time: DateTime<Utc>,
deadline: DateTime<Utc>,
) -> Result<Self> {
if migration_ratio <= dec!(0) {
return Err(CoreError::Validation(
"Migration ratio must be positive".to_string(),
));
}
if deadline <= start_time {
return Err(CoreError::Validation(
"Deadline must be after start time".to_string(),
));
}
Ok(Self {
old_token_id,
new_token_id,
migration_ratio,
start_time,
deadline,
allow_partial: true,
min_migration_amount: dec!(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
}
pub fn calculate_new_amount(&self, old_amount: Decimal) -> Decimal {
old_amount * self.migration_ratio
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MigrationStatus {
Pending,
Active,
Ended,
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenSnapshot {
pub id: Uuid,
pub token_id: Uuid,
pub snapshot_time: DateTime<Utc>,
pub balances: HashMap<Uuid, Decimal>,
pub total_supply: Decimal,
}
impl TokenSnapshot {
pub fn new(token_id: Uuid, balances: HashMap<Uuid, Decimal>) -> Self {
let total_supply: Decimal = balances.values().sum();
Self {
id: Uuid::new_v4(),
token_id,
snapshot_time: Utc::now(),
balances,
total_supply,
}
}
pub fn get_balance(&self, user_id: &Uuid) -> Decimal {
self.balances.get(user_id).copied().unwrap_or(dec!(0))
}
pub fn holder_count(&self) -> usize {
self.balances.len()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MigrationRecord {
pub id: Uuid,
pub user_id: Uuid,
pub old_token_id: Uuid,
pub new_token_id: Uuid,
pub old_amount: Decimal,
pub new_amount: Decimal,
pub migrated_at: DateTime<Utc>,
pub tx_hash: Option<String>,
}
impl MigrationRecord {
pub fn new(
user_id: Uuid,
old_token_id: Uuid,
new_token_id: Uuid,
old_amount: Decimal,
new_amount: Decimal,
) -> Self {
Self {
id: Uuid::new_v4(),
user_id,
old_token_id,
new_token_id,
old_amount,
new_amount,
migrated_at: Utc::now(),
tx_hash: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MigrationStats {
pub total_old_migrated: Decimal,
pub total_new_issued: Decimal,
pub migrated_users: usize,
pub total_eligible: usize,
pub migration_rate_pct: Decimal,
pub status: MigrationStatus,
}
pub struct TokenMigration {
pub config: MigrationConfig,
pub snapshot: TokenSnapshot,
pub migrations: Vec<MigrationRecord>,
migrated_users: HashMap<Uuid, Decimal>,
status: MigrationStatus,
}
impl TokenMigration {
pub fn new(config: MigrationConfig, snapshot: TokenSnapshot) -> Result<Self> {
if snapshot.token_id != config.old_token_id {
return Err(CoreError::Validation(
"Snapshot token ID does not match old token ID".to_string(),
));
}
let status = if config.is_active() {
MigrationStatus::Active
} else if config.is_ended() {
MigrationStatus::Ended
} else {
MigrationStatus::Pending
};
Ok(Self {
config,
snapshot,
migrations: Vec::new(),
migrated_users: HashMap::new(),
status,
})
}
pub fn update_status(&mut self) {
if self.status == MigrationStatus::Cancelled {
return;
}
self.status = if self.config.is_active() {
MigrationStatus::Active
} else if self.config.is_ended() {
MigrationStatus::Ended
} else {
MigrationStatus::Pending
};
}
pub fn is_eligible(&self, user_id: &Uuid) -> bool {
self.snapshot.get_balance(user_id) > dec!(0)
}
pub fn get_eligible_balance(&self, user_id: &Uuid) -> Decimal {
let snapshot_balance = self.snapshot.get_balance(user_id);
let already_migrated = self.migrated_users.get(user_id).copied().unwrap_or(dec!(0));
snapshot_balance - already_migrated
}
pub fn migrate(&mut self, user_id: Uuid, amount: Decimal) -> Result<MigrationRecord> {
self.update_status();
if self.status != MigrationStatus::Active {
return Err(CoreError::InvalidState(format!(
"Migration is not active (status: {:?})",
self.status
)));
}
if amount < self.config.min_migration_amount {
return Err(CoreError::InvalidOrderQuantity(format!(
"Amount {} is below minimum {}",
amount, self.config.min_migration_amount
)));
}
let eligible_amount = self.get_eligible_balance(&user_id);
if eligible_amount.is_zero() {
return Err(CoreError::Validation(
"User has no eligible balance to migrate".to_string(),
));
}
if amount > eligible_amount {
return Err(CoreError::InsufficientBalance {
required: amount,
available: eligible_amount,
});
}
let new_amount = self.config.calculate_new_amount(amount);
let record = MigrationRecord::new(
user_id,
self.config.old_token_id,
self.config.new_token_id,
amount,
new_amount,
);
*self.migrated_users.entry(user_id).or_insert(dec!(0)) += amount;
self.migrations.push(record.clone());
Ok(record)
}
pub fn migrate_all(&mut self, user_id: Uuid) -> Result<MigrationRecord> {
let eligible_amount = self.get_eligible_balance(&user_id);
self.migrate(user_id, eligible_amount)
}
pub fn stats(&self) -> MigrationStats {
let total_old_migrated: Decimal = self.migrations.iter().map(|m| m.old_amount).sum();
let total_new_issued: Decimal = self.migrations.iter().map(|m| m.new_amount).sum();
let migrated_users = self.migrated_users.len();
let total_eligible = self.snapshot.holder_count();
let migration_rate_pct = if total_eligible > 0 {
(Decimal::from(migrated_users) / Decimal::from(total_eligible)) * dec!(100)
} else {
dec!(0)
};
MigrationStats {
total_old_migrated,
total_new_issued,
migrated_users,
total_eligible,
migration_rate_pct,
status: self.status,
}
}
pub fn get_user_migrations(&self, user_id: &Uuid) -> Vec<&MigrationRecord> {
self.migrations
.iter()
.filter(|m| &m.user_id == user_id)
.collect()
}
pub fn cancel(&mut self) -> Result<()> {
if self.status != MigrationStatus::Pending {
return Err(CoreError::InvalidState(
"Can only cancel pending migrations".to_string(),
));
}
self.status = MigrationStatus::Cancelled;
Ok(())
}
pub fn get_unmigrated_balances(&self) -> HashMap<Uuid, Decimal> {
self.snapshot
.balances
.iter()
.map(|(user_id, balance)| {
let migrated = self.migrated_users.get(user_id).copied().unwrap_or(dec!(0));
(*user_id, *balance - migrated)
})
.filter(|(_, remaining)| *remaining > dec!(0))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
#[test]
fn test_migration_config_creation() {
let old_token = Uuid::new_v4();
let new_token = Uuid::new_v4();
let start = Utc::now();
let deadline = start + Duration::days(30);
let config = MigrationConfig::new(old_token, new_token, dec!(1), start, deadline).unwrap();
assert_eq!(config.old_token_id, old_token);
assert_eq!(config.new_token_id, new_token);
assert_eq!(config.migration_ratio, dec!(1));
}
#[test]
fn test_migration_config_invalid_ratio() {
let old_token = Uuid::new_v4();
let new_token = Uuid::new_v4();
let start = Utc::now();
let deadline = start + Duration::days(30);
let result = MigrationConfig::new(old_token, new_token, dec!(0), start, deadline);
assert!(result.is_err());
}
#[test]
fn test_migration_config_invalid_dates() {
let old_token = Uuid::new_v4();
let new_token = Uuid::new_v4();
let start = Utc::now();
let deadline = start - Duration::days(1);
let result = MigrationConfig::new(old_token, new_token, dec!(1), start, deadline);
assert!(result.is_err());
}
#[test]
fn test_calculate_new_amount() {
let config = MigrationConfig {
old_token_id: Uuid::new_v4(),
new_token_id: Uuid::new_v4(),
migration_ratio: dec!(2),
start_time: Utc::now(),
deadline: Utc::now() + Duration::days(30),
allow_partial: true,
min_migration_amount: dec!(1),
};
assert_eq!(config.calculate_new_amount(dec!(100)), dec!(200));
}
#[test]
fn test_snapshot_creation() {
let token_id = Uuid::new_v4();
let user1 = Uuid::new_v4();
let user2 = Uuid::new_v4();
let mut balances = HashMap::new();
balances.insert(user1, dec!(100));
balances.insert(user2, dec!(200));
let snapshot = TokenSnapshot::new(token_id, balances);
assert_eq!(snapshot.total_supply, dec!(300));
assert_eq!(snapshot.holder_count(), 2);
assert_eq!(snapshot.get_balance(&user1), dec!(100));
assert_eq!(snapshot.get_balance(&user2), dec!(200));
}
#[test]
fn test_token_migration() {
let old_token = Uuid::new_v4();
let new_token = Uuid::new_v4();
let user1 = Uuid::new_v4();
let user2 = Uuid::new_v4();
let mut balances = HashMap::new();
balances.insert(user1, dec!(100));
balances.insert(user2, dec!(200));
let snapshot = TokenSnapshot::new(old_token, balances);
let start = Utc::now() - Duration::days(1); let deadline = Utc::now() + Duration::days(29); let config = MigrationConfig::new(old_token, new_token, dec!(1), start, deadline).unwrap();
let mut migration = TokenMigration::new(config, snapshot).unwrap();
let record = migration.migrate(user1, dec!(100)).unwrap();
assert_eq!(record.old_amount, dec!(100));
assert_eq!(record.new_amount, dec!(100));
let stats = migration.stats();
assert_eq!(stats.total_old_migrated, dec!(100));
assert_eq!(stats.migrated_users, 1);
}
#[test]
fn test_migration_ratio() {
let old_token = Uuid::new_v4();
let new_token = Uuid::new_v4();
let user1 = Uuid::new_v4();
let mut balances = HashMap::new();
balances.insert(user1, dec!(100));
let snapshot = TokenSnapshot::new(old_token, balances);
let start = Utc::now() - Duration::days(1);
let deadline = Utc::now() + Duration::days(29);
let config = MigrationConfig::new(old_token, new_token, dec!(2), start, deadline).unwrap();
let mut migration = TokenMigration::new(config, snapshot).unwrap();
let record = migration.migrate(user1, dec!(100)).unwrap();
assert_eq!(record.old_amount, dec!(100));
assert_eq!(record.new_amount, dec!(200)); }
#[test]
fn test_partial_migration() {
let old_token = Uuid::new_v4();
let new_token = Uuid::new_v4();
let user1 = Uuid::new_v4();
let mut balances = HashMap::new();
balances.insert(user1, dec!(100));
let snapshot = TokenSnapshot::new(old_token, balances);
let start = Utc::now() - Duration::days(1);
let deadline = Utc::now() + Duration::days(29);
let config = MigrationConfig::new(old_token, new_token, dec!(1), start, deadline).unwrap();
let mut migration = TokenMigration::new(config, snapshot).unwrap();
migration.migrate(user1, dec!(60)).unwrap();
assert_eq!(migration.get_eligible_balance(&user1), dec!(40));
migration.migrate(user1, dec!(40)).unwrap();
assert_eq!(migration.get_eligible_balance(&user1), dec!(0));
}
#[test]
fn test_migration_all() {
let old_token = Uuid::new_v4();
let new_token = Uuid::new_v4();
let user1 = Uuid::new_v4();
let mut balances = HashMap::new();
balances.insert(user1, dec!(100));
let snapshot = TokenSnapshot::new(old_token, balances);
let start = Utc::now() - Duration::days(1);
let deadline = Utc::now() + Duration::days(29);
let config = MigrationConfig::new(old_token, new_token, dec!(1), start, deadline).unwrap();
let mut migration = TokenMigration::new(config, snapshot).unwrap();
let record = migration.migrate_all(user1).unwrap();
assert_eq!(record.old_amount, dec!(100));
assert_eq!(migration.get_eligible_balance(&user1), dec!(0));
}
#[test]
fn test_unmigrated_balances() {
let old_token = Uuid::new_v4();
let new_token = Uuid::new_v4();
let user1 = Uuid::new_v4();
let user2 = Uuid::new_v4();
let mut balances = HashMap::new();
balances.insert(user1, dec!(100));
balances.insert(user2, dec!(200));
let snapshot = TokenSnapshot::new(old_token, balances);
let start = Utc::now() - Duration::days(1);
let deadline = Utc::now() + Duration::days(29);
let config = MigrationConfig::new(old_token, new_token, dec!(1), start, deadline).unwrap();
let mut migration = TokenMigration::new(config, snapshot).unwrap();
migration.migrate(user1, dec!(100)).unwrap();
let unmigrated = migration.get_unmigrated_balances();
assert_eq!(unmigrated.len(), 1);
assert_eq!(unmigrated.get(&user2), Some(&dec!(200)));
}
}