epoch.rs
1 // Copyright (c) 2025 ADnet Contributors 2 // This file is part of the deltavm library. 3 // Licensed under the Apache License, Version 2.0 4 5 //! Epoch Transition System (F-G16-G19) 6 //! 7 //! Handles epoch-based state transitions for staking, dividends, and slashing. 8 //! 9 //! # Epoch Transition Priority Order 10 //! 11 //! At each epoch transition (daily), all wallet actions are processed in strict order: 12 //! 13 //! 1. **SLASHING** (always first) - Governance penalties applied before any escape 14 //! 2. **DIVIDENDS** - Calculated on current staked balances (before changes) 15 //! 3. **STAKING** - Pending stake requests take effect 16 //! 4. **UNSTAKING** (last) - Pending unstake requests take effect 17 //! 18 //! # Rationale 19 //! 20 //! - Slashing first prevents escape from governance penalties 21 //! - Dividends second ensures unstakers receive their last epoch's rewards 22 //! - Dividends second ensures new stakers don't receive unearned rewards 23 //! - Unstaking last ensures the full epoch was staked before release 24 25 use anyhow::Result; 26 use deltavm_token::StakingRegistry; 27 28 // ============================================================================ 29 // Constants 30 // ============================================================================ 31 32 /// Blocks per epoch (~24 hours at 5 second blocks) 33 pub const BLOCKS_PER_EPOCH: u64 = 17_280; 34 35 /// Minimum blocks per epoch (for testing) 36 pub const MIN_BLOCKS_PER_EPOCH: u64 = 10; 37 38 // ============================================================================ 39 // Epoch State 40 // ============================================================================ 41 42 /// Epoch transition event for logging and metrics 43 #[derive(Clone, Debug)] 44 pub struct EpochTransitionEvent { 45 /// Previous epoch number 46 pub from_epoch: u64, 47 /// New epoch number 48 pub to_epoch: u64, 49 /// Block height at transition 50 pub block_height: u64, 51 /// Slashing results 52 pub slashing: SlashingResult, 53 /// Dividend distribution results 54 pub dividends: DividendResult, 55 /// Staking transition results 56 pub staking: StakingTransitionResult, 57 } 58 59 /// Result of slashing phase 60 #[derive(Clone, Debug, Default)] 61 pub struct SlashingResult { 62 /// Number of wallets slashed 63 pub wallets_slashed: usize, 64 /// Total DX slashed 65 pub total_slashed: u64, 66 /// Slashed amount added to reward pool 67 pub added_to_pool: u64, 68 } 69 70 /// Result of dividend phase 71 #[derive(Clone, Debug, Default)] 72 pub struct DividendResult { 73 /// Total dividends distributed this epoch 74 pub total_distributed: u64, 75 /// Number of eligible holders 76 pub eligible_holders: usize, 77 /// Number of inactive holders deregistered 78 pub inactive_deregistered: usize, 79 } 80 81 /// Result of staking phase 82 #[derive(Clone, Debug, Default)] 83 pub struct StakingTransitionResult { 84 /// Number of wallets that completed staking 85 pub newly_staked: usize, 86 /// Number of wallets that completed unstaking 87 pub newly_unstaked: usize, 88 /// Total DX now staked 89 pub total_staked: u64, 90 /// Total staked wallets 91 pub staked_wallet_count: u64, 92 } 93 94 // ============================================================================ 95 // Epoch Processor 96 // ============================================================================ 97 98 /// Epoch processor for handling epoch transitions 99 /// 100 /// Coordinates all epoch-based operations including slashing, dividends, 101 /// staking, and unstaking with proper priority ordering. 102 pub struct EpochProcessor { 103 /// Current epoch number 104 current_epoch: u64, 105 /// Blocks per epoch (configurable for testing) 106 blocks_per_epoch: u64, 107 /// Staking registry 108 staking: StakingRegistry, 109 /// Pending slashing events (from governance module) 110 pending_slashing: Vec<PendingSlash>, 111 /// Last epoch transition block 112 last_transition_block: u64, 113 } 114 115 /// A pending slash event from governance 116 #[derive(Clone, Debug)] 117 pub struct PendingSlash { 118 /// Wallet to slash 119 pub dx_pubkey: [u8; 32], 120 /// Slash rate in basis points (100 = 1%) 121 pub slash_rate_bps: u16, 122 /// Reason for slashing 123 pub reason: SlashReason, 124 } 125 126 /// Reason for slashing 127 #[derive(Clone, Debug)] 128 pub enum SlashReason { 129 /// Missed standard governance vote (3 consecutive misses = 1% slash) 130 StandardVoteMiss { consecutive_misses: u8 }, 131 /// Missed emergency governance vote (2 consecutive misses = 10% slash) 132 EmergencyVoteMiss { consecutive_misses: u8 }, 133 /// Custom reason from governance 134 Governance { reason: String }, 135 } 136 137 impl EpochProcessor { 138 /// Create a new epoch processor 139 pub fn new() -> Self { 140 Self { 141 current_epoch: 0, 142 blocks_per_epoch: BLOCKS_PER_EPOCH, 143 staking: StakingRegistry::new(), 144 pending_slashing: Vec::new(), 145 last_transition_block: 0, 146 } 147 } 148 149 /// Create epoch processor with custom blocks per epoch (for testing) 150 pub fn with_epoch_blocks(blocks_per_epoch: u64) -> Self { 151 Self { 152 current_epoch: 0, 153 blocks_per_epoch: blocks_per_epoch.max(MIN_BLOCKS_PER_EPOCH), 154 staking: StakingRegistry::new(), 155 pending_slashing: Vec::new(), 156 last_transition_block: 0, 157 } 158 } 159 160 /// Create epoch processor at a specific epoch (for recovery) 161 pub fn at_epoch(epoch: u64, blocks_per_epoch: u64) -> Self { 162 Self { 163 current_epoch: epoch, 164 blocks_per_epoch: blocks_per_epoch.max(MIN_BLOCKS_PER_EPOCH), 165 staking: StakingRegistry::at_epoch(epoch), 166 pending_slashing: Vec::new(), 167 last_transition_block: epoch * blocks_per_epoch, 168 } 169 } 170 171 /// Get current epoch 172 pub fn current_epoch(&self) -> u64 { 173 self.current_epoch 174 } 175 176 /// Get blocks per epoch 177 pub fn blocks_per_epoch(&self) -> u64 { 178 self.blocks_per_epoch 179 } 180 181 /// Get staking registry (immutable) 182 pub fn staking(&self) -> &StakingRegistry { 183 &self.staking 184 } 185 186 /// Get staking registry (mutable) 187 pub fn staking_mut(&mut self) -> &mut StakingRegistry { 188 &mut self.staking 189 } 190 191 /// Check if epoch transition is needed at given block height 192 pub fn should_transition(&self, block_height: u64) -> bool { 193 if block_height == 0 { 194 return false; 195 } 196 let next_transition = (self.current_epoch + 1) * self.blocks_per_epoch; 197 block_height >= next_transition 198 } 199 200 /// Calculate epoch number for a given block height 201 pub fn epoch_for_block(&self, block_height: u64) -> u64 { 202 block_height / self.blocks_per_epoch 203 } 204 205 /// Queue a slashing event to be processed at next epoch transition 206 pub fn queue_slashing(&mut self, slash: PendingSlash) { 207 self.pending_slashing.push(slash); 208 } 209 210 /// Process epoch transition 211 /// 212 /// This is the main entry point for epoch transitions. It processes 213 /// all pending operations in the correct priority order: 214 /// 215 /// 1. SLASHING - Apply governance penalties first 216 /// 2. DIVIDENDS - Calculate on current staked balances 217 /// 3. STAKING - Process pending stake requests 218 /// 4. UNSTAKING - Process pending unstake requests (last) 219 pub fn process_epoch_transition(&mut self, block_height: u64) -> Result<EpochTransitionEvent> { 220 let from_epoch = self.current_epoch; 221 let to_epoch = self.epoch_for_block(block_height); 222 223 tracing::info!( 224 "Processing epoch transition: {} -> {} at block {}", 225 from_epoch, 226 to_epoch, 227 block_height 228 ); 229 230 // ===================================================================== 231 // PRIORITY 1: SLASHING (always first - no escape from penalties) 232 // ===================================================================== 233 let slashing = self.process_slashing()?; 234 235 // ===================================================================== 236 // PRIORITY 2: DIVIDENDS (on current staked balances BEFORE changes) 237 // ===================================================================== 238 let dividends = self.process_dividends()?; 239 240 // ===================================================================== 241 // PRIORITY 3: STAKING (pending stake requests take effect) 242 // ===================================================================== 243 let staked_wallets = self.staking.process_pending_stakes(to_epoch)?; 244 245 // ===================================================================== 246 // PRIORITY 4: UNSTAKING (pending unstake requests take effect LAST) 247 // ===================================================================== 248 let unstaked_wallets = self.staking.process_pending_unstakes(to_epoch)?; 249 250 // Update epoch state 251 self.current_epoch = to_epoch; 252 self.last_transition_block = block_height; 253 254 let staking = StakingTransitionResult { 255 newly_staked: staked_wallets.len(), 256 newly_unstaked: unstaked_wallets.len(), 257 total_staked: self.staking.total_staked(), 258 staked_wallet_count: self.staking.staked_wallet_count(), 259 }; 260 261 tracing::info!( 262 "Epoch {} complete: slashed={} wallets ({}), dividends to {} holders, \ 263 staked={}, unstaked={}, total_staked={}", 264 to_epoch, 265 slashing.wallets_slashed, 266 slashing.total_slashed, 267 dividends.eligible_holders, 268 staking.newly_staked, 269 staking.newly_unstaked, 270 staking.total_staked 271 ); 272 273 Ok(EpochTransitionEvent { 274 from_epoch, 275 to_epoch, 276 block_height, 277 slashing, 278 dividends, 279 staking, 280 }) 281 } 282 283 /// Process pending slashing events 284 /// 285 /// Called first during epoch transition to prevent escape from penalties. 286 fn process_slashing(&mut self) -> Result<SlashingResult> { 287 let mut result = SlashingResult::default(); 288 289 // Drain pending slashing events 290 let pending = std::mem::take(&mut self.pending_slashing); 291 292 for slash in pending { 293 // Get current staked balance to calculate slash amount 294 let staked_balance = self.staking.get_staked_balance(&slash.dx_pubkey); 295 if staked_balance == 0 { 296 tracing::debug!( 297 "Skipping slash for {:?}: no staked balance", 298 &slash.dx_pubkey[..4] 299 ); 300 continue; 301 } 302 303 // Calculate slash amount from rate (bps = basis points, 10000 = 100%) 304 let slash_amount = 305 (staked_balance as u128 * slash.slash_rate_bps as u128 / 10_000) as u64; 306 if slash_amount == 0 { 307 continue; 308 } 309 310 // Apply the slashing 311 let slashed_amount = self.staking.apply_slashing(&slash.dx_pubkey, slash_amount); 312 313 if slashed_amount > 0 { 314 result.wallets_slashed += 1; 315 result.total_slashed += slashed_amount; 316 result.added_to_pool += slashed_amount; 317 318 tracing::debug!( 319 "Slashed {:?}: {} DX ({} bps) for {:?}", 320 &slash.dx_pubkey[..4], 321 slashed_amount, 322 slash.slash_rate_bps, 323 slash.reason 324 ); 325 } 326 } 327 328 Ok(result) 329 } 330 331 /// Process dividend distribution 332 /// 333 /// Called second during epoch transition on current staked balances. 334 /// This ensures: 335 /// - Unstakers receive their last epoch's rewards 336 /// - New stakers don't receive unearned rewards 337 fn process_dividends(&self) -> Result<DividendResult> { 338 // For now, return empty result - dividend distribution is handled 339 // by DividendState in the runtime. This placeholder allows for 340 // future integration with the epoch processor. 341 // 342 // TODO: Integrate with deltavm_dividends::DividendState 343 // - Get list of staked wallets from staking registry 344 // - Calculate per-holder dividend amount 345 // - Update dividend claims 346 347 Ok(DividendResult::default()) 348 } 349 350 /// Check and process epoch if needed 351 /// 352 /// Convenience method to call from advance_block(). 353 /// Returns Some(event) if epoch transition occurred. 354 pub fn check_and_process(&mut self, block_height: u64) -> Option<EpochTransitionEvent> { 355 if self.should_transition(block_height) { 356 match self.process_epoch_transition(block_height) { 357 Ok(event) => Some(event), 358 Err(e) => { 359 tracing::error!("Epoch transition failed: {}", e); 360 None 361 } 362 } 363 } else { 364 None 365 } 366 } 367 } 368 369 impl Default for EpochProcessor { 370 fn default() -> Self { 371 Self::new() 372 } 373 } 374 375 // ============================================================================ 376 // Tests 377 // ============================================================================ 378 379 #[cfg(test)] 380 mod tests { 381 use super::*; 382 383 #[test] 384 fn test_epoch_calculation() { 385 let processor = EpochProcessor::with_epoch_blocks(100); 386 387 assert_eq!(processor.epoch_for_block(0), 0); 388 assert_eq!(processor.epoch_for_block(50), 0); 389 assert_eq!(processor.epoch_for_block(99), 0); 390 assert_eq!(processor.epoch_for_block(100), 1); 391 assert_eq!(processor.epoch_for_block(199), 1); 392 assert_eq!(processor.epoch_for_block(200), 2); 393 } 394 395 #[test] 396 fn test_should_transition() { 397 let mut processor = EpochProcessor::with_epoch_blocks(100); 398 399 // At epoch 0 400 assert!(!processor.should_transition(0)); 401 assert!(!processor.should_transition(50)); 402 assert!(!processor.should_transition(99)); 403 assert!(processor.should_transition(100)); 404 405 // Process transition to epoch 1 406 processor.process_epoch_transition(100).unwrap(); 407 408 assert!(!processor.should_transition(100)); 409 assert!(!processor.should_transition(150)); 410 assert!(processor.should_transition(200)); 411 } 412 413 #[test] 414 fn test_epoch_transition_priority() { 415 let mut processor = EpochProcessor::with_epoch_blocks(100); 416 417 // Register a wallet 418 processor.staking_mut().register_wallet(&[1u8; 32], 100_000); 419 420 // Request unstake 421 processor.staking_mut().request_unstake(&[1u8; 32]).unwrap(); 422 423 // At this point, wallet should still be staked 424 assert!(processor.staking().is_staked(&[1u8; 32])); 425 426 // Process epoch transition 427 let event = processor.process_epoch_transition(100).unwrap(); 428 429 // After transition, wallet should be unstaked 430 assert!(!processor.staking().is_staked(&[1u8; 32])); 431 assert_eq!(event.staking.newly_unstaked, 1); 432 } 433 434 #[test] 435 fn test_slashing_before_unstaking() { 436 let mut processor = EpochProcessor::with_epoch_blocks(100); 437 let pubkey = [2u8; 32]; 438 439 // Register a wallet with 100,000 DX 440 processor.staking_mut().register_wallet(&pubkey, 100_000); 441 442 // Queue a 10% slash 443 processor.queue_slashing(PendingSlash { 444 dx_pubkey: pubkey, 445 slash_rate_bps: 1000, // 10% 446 reason: SlashReason::EmergencyVoteMiss { 447 consecutive_misses: 2, 448 }, 449 }); 450 451 // Request unstake 452 processor.staking_mut().request_unstake(&pubkey).unwrap(); 453 454 // Process epoch transition 455 let event = processor.process_epoch_transition(100).unwrap(); 456 457 // Verify slashing happened first 458 assert_eq!(event.slashing.wallets_slashed, 1); 459 assert_eq!(event.slashing.total_slashed, 10_000); // 10% of 100,000 460 461 // Verify unstaking happened after slashing 462 assert_eq!(event.staking.newly_unstaked, 1); 463 464 // Wallet should now have 90,000 DX (100,000 - 10,000) 465 // Note: Use balance() not staked_balance() since wallet is now unstaked 466 let state = processor.staking().get_wallet(&pubkey).unwrap(); 467 assert_eq!(state.balance(), 90_000); 468 } 469 470 #[test] 471 fn test_staking_transition() { 472 let mut processor = EpochProcessor::with_epoch_blocks(100); 473 let pubkey = [3u8; 32]; 474 475 // Register wallet (staked by default) 476 processor.staking_mut().register_wallet(&pubkey, 50_000); 477 assert!(processor.staking().is_staked(&pubkey)); 478 479 // Unstake 480 processor.staking_mut().request_unstake(&pubkey).unwrap(); 481 processor.process_epoch_transition(100).unwrap(); 482 assert!(!processor.staking().is_staked(&pubkey)); 483 484 // Re-stake 485 processor.staking_mut().request_stake(&pubkey).unwrap(); 486 487 // Still unstaked until next epoch 488 assert!(!processor.staking().is_staked(&pubkey)); 489 490 // Process next epoch 491 processor.process_epoch_transition(200).unwrap(); 492 493 // Now staked again 494 assert!(processor.staking().is_staked(&pubkey)); 495 } 496 497 #[test] 498 fn test_check_and_process() { 499 let mut processor = EpochProcessor::with_epoch_blocks(100); 500 501 // No transition needed 502 assert!(processor.check_and_process(50).is_none()); 503 assert_eq!(processor.current_epoch(), 0); 504 505 // Transition needed 506 let event = processor.check_and_process(100); 507 assert!(event.is_some()); 508 assert_eq!(processor.current_epoch(), 1); 509 510 // No transition needed again 511 assert!(processor.check_and_process(150).is_none()); 512 assert_eq!(processor.current_epoch(), 1); 513 } 514 }