/ execution / src / epoch.rs
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  }