1use crate::error::{Result, TokenError};
7use dashmap::DashMap;
8use serde::{Deserialize, Serialize};
9use std::sync::Arc;
10use tenzro_types::primitives::{Address, Timestamp};
11use tenzro_types::token::ProviderType;
12use tracing::{info, warn};
13
14pub const DEFAULT_UNBONDING_PERIOD_MS: i64 = 7 * 24 * 60 * 60 * 1000;
16
17pub const DEFAULT_MIN_STAKE: u128 = 1000 * 1_000_000_000_000_000_000; #[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct StakeInfo {
23 pub staker: Address,
25 pub amount: u128,
27 pub provider_type: ProviderType,
29 pub staked_at: Timestamp,
31 pub unbonding_at: Option<Timestamp>,
33 pub slashing_history: Vec<SlashEvent>,
35 #[serde(default)]
37 pub restoration_history: Vec<RestoreEvent>,
38 pub total_rewards: u128,
40 pub status: StakeStatus,
42 #[serde(default)]
51 pub lifecycle_freeze: bool,
52}
53
54impl StakeInfo {
55 pub fn new(staker: Address, amount: u128, provider_type: ProviderType) -> Self {
57 Self {
58 staker,
59 amount,
60 provider_type,
61 staked_at: Timestamp::now(),
62 unbonding_at: None,
63 slashing_history: Vec::new(),
64 restoration_history: Vec::new(),
65 total_rewards: 0,
66 status: StakeStatus::Active,
67 lifecycle_freeze: false,
68 }
69 }
70
71 pub fn is_locked(&self) -> bool {
73 matches!(self.status, StakeStatus::Active | StakeStatus::Unbonding)
74 }
75
76 pub fn is_unbonding_complete(&self) -> bool {
78 if let Some(unbonding_at) = self.unbonding_at {
79 Timestamp::now() >= unbonding_at
80 } else {
81 false
82 }
83 }
84
85 pub fn add_slash(&mut self, event: SlashEvent) {
89 self.amount = self.amount.checked_sub(event.amount)
90 .expect("BUG: add_slash called without prior balance check");
91 self.slashing_history.push(event);
92 }
93
94 pub fn add_rewards(&mut self, amount: u128) -> Result<()> {
100 if self.lifecycle_freeze {
101 return Err(TokenError::InvalidAmount(format!(
102 "stake for {} is frozen by kill-switch; rewards rejected",
103 self.staker
104 )));
105 }
106 self.total_rewards = self.total_rewards.checked_add(amount)
107 .ok_or_else(|| TokenError::ArithmeticOverflow {
108 operation: "stake add_rewards".to_string(),
109 })?;
110 Ok(())
111 }
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct SlashEvent {
117 pub timestamp: Timestamp,
119 pub amount: u128,
121 pub reason: String,
123 pub slashed_by: Address,
125}
126
127impl SlashEvent {
128 pub fn new(amount: u128, reason: String, slashed_by: Address) -> Self {
130 Self {
131 timestamp: Timestamp::now(),
132 amount,
133 reason,
134 slashed_by,
135 }
136 }
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct RestoreEvent {
152 pub timestamp: Timestamp,
154 pub amount: u128,
156 pub proposal_id: String,
158 pub reason: String,
160 pub authorized_by: Address,
163}
164
165impl RestoreEvent {
166 pub fn new(amount: u128, proposal_id: String, reason: String, authorized_by: Address) -> Self {
168 Self {
169 timestamp: Timestamp::now(),
170 amount,
171 proposal_id,
172 reason,
173 authorized_by,
174 }
175 }
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180pub enum StakeStatus {
181 Active,
183 Unbonding,
185 Withdrawn,
187}
188
189pub struct StakingManager {
193 stakes: DashMap<Address, StakeInfo>,
195 total_staked_by_type: DashMap<ProviderType, u128>,
197 min_stake: parking_lot::RwLock<u128>,
199 unbonding_period_ms: parking_lot::RwLock<i64>,
201 storage: Option<Arc<dyn tenzro_storage::KvStore>>,
203}
204
205const STAKE_PREFIX: &str = "stake:";
207const STAKING_CONFIG_KEY: &str = "staking:config";
209const STAKING_INDEX_KEY: &str = "staking:index";
211const STAKING_CF: &str = tenzro_storage::CF_STATE;
213
214fn parse_hex_to_address(hex_str: &str) -> Address {
216 let bytes = hex::decode(hex_str).unwrap_or_default();
217 let mut addr_bytes = [0u8; 32];
218 let len = bytes.len().min(32);
219 addr_bytes[..len].copy_from_slice(&bytes[..len]);
220 Address::new(addr_bytes)
221}
222
223impl StakingManager {
224 pub fn new() -> Self {
226 Self {
227 stakes: DashMap::new(),
228 total_staked_by_type: DashMap::new(),
229 min_stake: parking_lot::RwLock::new(DEFAULT_MIN_STAKE),
230 unbonding_period_ms: parking_lot::RwLock::new(DEFAULT_UNBONDING_PERIOD_MS),
231 storage: None,
232 }
233 }
234
235 pub fn with_storage(storage: Arc<dyn tenzro_storage::KvStore>) -> Self {
237 let manager = Self {
238 stakes: DashMap::new(),
239 total_staked_by_type: DashMap::new(),
240 min_stake: parking_lot::RwLock::new(DEFAULT_MIN_STAKE),
241 unbonding_period_ms: parking_lot::RwLock::new(DEFAULT_UNBONDING_PERIOD_MS),
242 storage: Some(storage),
243 };
244
245 if let Err(e) = manager.load_from_storage() {
247 warn!("Failed to load staking state from storage: {}", e);
248 }
249
250 manager
251 }
252
253 fn persist_stake(&self, address: &Address, stake_info: &StakeInfo) {
255 if let Some(storage) = &self.storage {
256 let key = format!("{}{}", STAKE_PREFIX, hex::encode(address.as_bytes()));
257 match bincode::serialize(stake_info) {
258 Ok(data) => {
259 if let Err(e) = storage.put(STAKING_CF, key.as_bytes(), &data) {
260 warn!("Failed to persist stake for {}: {}", address, e);
261 }
262 }
263 Err(e) => warn!("Failed to serialize stake for {}: {}", address, e),
264 }
265 }
266 }
267
268 fn persist_total_staked(&self, provider_type: ProviderType, amount: u128) {
270 if let Some(storage) = &self.storage {
271 let key = format!("total_staked:{:?}", provider_type);
272 if let Err(e) = storage.put(STAKING_CF, key.as_bytes(), &amount.to_le_bytes()) {
273 warn!("Failed to persist total staked for {:?}: {}", provider_type, e);
274 }
275 }
276 }
277
278 fn load_from_storage(&self) -> Result<()> {
280 let storage = match &self.storage {
281 Some(s) => s,
282 None => return Ok(()),
283 };
284
285 if let Ok(Some(index_data)) = storage.get(STAKING_CF, STAKING_INDEX_KEY.as_bytes()) {
287 let addresses: Vec<String> = match bincode::deserialize(&index_data) {
288 Ok(a) => a,
289 Err(e) => {
290 warn!("Failed to deserialize staking index: {}", e);
291 return Ok(());
292 }
293 };
294
295 for addr_hex in &addresses {
296 let key = format!("{}{}", STAKE_PREFIX, addr_hex);
297 if let Ok(Some(data)) = storage.get(STAKING_CF, key.as_bytes()) {
298 match bincode::deserialize::<StakeInfo>(&data) {
299 Ok(stake_info) => {
300 let addr = parse_hex_to_address(addr_hex);
301
302 if stake_info.status == StakeStatus::Active || stake_info.status == StakeStatus::Unbonding {
304 let mut total = self.total_staked_by_type.entry(stake_info.provider_type).or_insert(0);
305 *total = total.checked_add(stake_info.amount).unwrap_or(u128::MAX);
306 }
307
308 self.stakes.insert(addr, stake_info);
309 }
310 Err(e) => warn!("Failed to deserialize stake {}: {}", addr_hex, e),
311 }
312 }
313 }
314
315 info!("Loaded {} stakes from storage", addresses.len());
316 }
317
318 if let Ok(Some(config_data)) = storage.get(STAKING_CF, STAKING_CONFIG_KEY.as_bytes())
320 && config_data.len() >= 24
321 {
322 let min_stake = u128::from_le_bytes(config_data[0..16].try_into().unwrap());
323 let unbonding_ms = i64::from_le_bytes(config_data[16..24].try_into().unwrap());
324 *self.min_stake.write() = min_stake;
325 *self.unbonding_period_ms.write() = unbonding_ms;
326 info!("Loaded staking config: min_stake={}, unbonding_period={}ms", min_stake, unbonding_ms);
327 }
328
329 Ok(())
330 }
331
332 fn persist_index(&self) {
334 if let Some(storage) = &self.storage {
335 let addresses: Vec<String> = self.stakes.iter()
336 .map(|entry| hex::encode(entry.key().as_bytes()))
337 .collect();
338
339 match bincode::serialize(&addresses) {
340 Ok(data) => {
341 if let Err(e) = storage.put(STAKING_CF, STAKING_INDEX_KEY.as_bytes(), &data) {
342 warn!("Failed to persist staking index: {}", e);
343 }
344 }
345 Err(e) => warn!("Failed to serialize staking index: {}", e),
346 }
347 }
348 }
349
350 fn persist_config(&self) {
352 if let Some(storage) = &self.storage {
353 let min_stake = *self.min_stake.read();
354 let unbonding_ms = *self.unbonding_period_ms.read();
355
356 let mut config_data = Vec::with_capacity(24);
357 config_data.extend_from_slice(&min_stake.to_le_bytes());
358 config_data.extend_from_slice(&unbonding_ms.to_le_bytes());
359
360 if let Err(e) = storage.put(STAKING_CF, STAKING_CONFIG_KEY.as_bytes(), &config_data) {
361 warn!("Failed to persist staking config: {}", e);
362 }
363 }
364 }
365
366 pub fn stake(&self, staker: Address, amount: u128, provider_type: ProviderType) -> Result<()> {
374 let min_stake = *self.min_stake.read();
376 if amount < min_stake {
377 return Err(TokenError::MinimumStakeNotMet {
378 required: min_stake,
379 provided: amount,
380 });
381 }
382
383 if let Some(existing) = self.stakes.get(&staker)
385 && existing.is_locked()
386 {
387 return Err(TokenError::InvalidAmount(
388 "Already have an active stake. Unstake first.".to_string(),
389 ));
390 }
391
392 let stake_info = StakeInfo::new(staker, amount, provider_type);
394 self.stakes.insert(staker, stake_info.clone());
395
396 let mut total = self.total_staked_by_type.entry(provider_type).or_insert(0);
398 *total = total.checked_add(amount)
399 .ok_or_else(|| TokenError::ArithmeticOverflow {
400 operation: "staking total staked".to_string(),
401 })?;
402
403 self.persist_stake(&staker, &stake_info);
405 self.persist_total_staked(provider_type, *total);
406 self.persist_index();
407
408 info!("Staked {} for {:?} provider at {}", amount, provider_type, staker);
409 Ok(())
410 }
411
412 pub fn unstake(&self, staker: &Address) -> Result<()> {
418 let mut stake = self.stakes.get_mut(staker)
419 .ok_or_else(|| TokenError::StakeNotFound {
420 address: staker.to_string(),
421 })?;
422
423 if stake.lifecycle_freeze {
427 return Err(TokenError::InvalidAmount(format!(
428 "stake for {} is frozen by kill-switch; unstake rejected",
429 staker
430 )));
431 }
432
433 if stake.status == StakeStatus::Unbonding {
435 return Err(TokenError::InvalidAmount(
436 "Already unbonding".to_string()
437 ));
438 }
439
440 if stake.status != StakeStatus::Active {
441 return Err(TokenError::InvalidAmount(
442 "Stake is not active".to_string()
443 ));
444 }
445
446 let unbonding_period = *self.unbonding_period_ms.read();
448 let unbonding_at = Timestamp::new(Timestamp::now().as_millis() + unbonding_period);
449 stake.unbonding_at = Some(unbonding_at);
450 stake.status = StakeStatus::Unbonding;
451
452 let stake_clone = stake.clone();
454 drop(stake); self.persist_stake(staker, &stake_clone);
458
459 info!("Initiated unstaking for {}. Unbonding completes at {}", staker, unbonding_at);
460 Ok(())
461 }
462
463 pub fn withdraw(&self, staker: &Address) -> Result<u128> {
471 let mut stake = self.stakes.get_mut(staker)
472 .ok_or_else(|| TokenError::StakeNotFound {
473 address: staker.to_string(),
474 })?;
475
476 if stake.lifecycle_freeze {
480 return Err(TokenError::InvalidAmount(format!(
481 "stake for {} is frozen by kill-switch; withdraw rejected",
482 staker
483 )));
484 }
485
486 if stake.status != StakeStatus::Unbonding {
488 return Err(TokenError::InvalidAmount(
489 "Stake must be unbonding to withdraw".to_string()
490 ));
491 }
492
493 if !stake.is_unbonding_complete() {
495 let unbonding_at = stake.unbonding_at.unwrap();
496 return Err(TokenError::StakeLocked {
497 unlock_time: unbonding_at.as_millis(),
498 });
499 }
500
501 let amount = stake.amount;
502 let provider_type = stake.provider_type;
503
504 stake.status = StakeStatus::Withdrawn;
506 stake.amount = 0;
507
508 let stake_clone = stake.clone();
510 drop(stake); let new_total = if let Some(mut total) = self.total_staked_by_type.get_mut(&provider_type) {
514 *total = total.checked_sub(amount)
515 .ok_or_else(|| TokenError::ArithmeticOverflow {
516 operation: "staking withdraw total".to_string(),
517 })?;
518 *total
519 } else {
520 0
521 };
522
523 self.persist_stake(staker, &stake_clone);
525 self.persist_total_staked(provider_type, new_total);
526
527 info!("Withdrew {} from stake for {}", amount, staker);
528 Ok(amount)
529 }
530
531 pub fn slash(&self, staker: &Address, amount: u128, reason: String, slashed_by: Address) -> Result<()> {
544 let mut stake = self.stakes.get_mut(staker)
545 .ok_or_else(|| TokenError::StakeNotFound {
546 address: staker.to_string(),
547 })?;
548
549 if stake.amount < amount {
550 return Err(TokenError::InsufficientStake {
551 required: amount,
552 available: stake.amount,
553 });
554 }
555
556 let min_stake = *self.min_stake.read();
558 let new_amount = stake.amount.checked_sub(amount)
559 .ok_or_else(|| TokenError::ArithmeticOverflow {
560 operation: "staking slash amount".to_string(),
561 })?;
562
563 if new_amount > 0 && new_amount < min_stake && stake.status == StakeStatus::Active {
566 let unbonding_period = *self.unbonding_period_ms.read();
567 let unbonding_at = Timestamp::new(Timestamp::now().as_millis() + unbonding_period);
568 stake.unbonding_at = Some(unbonding_at);
569 stake.status = StakeStatus::Unbonding;
570 warn!(
571 "Slashing {} brought stake below minimum {}. Forcing unbonding until {}",
572 staker, min_stake, unbonding_at
573 );
574 }
575
576 if stake.status == StakeStatus::Unbonding {
578 let unbonding_period = *self.unbonding_period_ms.read();
579 let new_unbonding_at = Timestamp::new(Timestamp::now().as_millis() + unbonding_period);
580 stake.unbonding_at = Some(new_unbonding_at);
581 warn!(
582 "Slashing {} reset unbonding period to {}",
583 staker, new_unbonding_at
584 );
585 }
586
587 let slash_event = SlashEvent::new(amount, reason.clone(), slashed_by);
589
590 let provider_type = stake.provider_type;
592 stake.add_slash(slash_event);
593
594 let stake_clone = stake.clone();
596 drop(stake); let new_total = if let Some(mut total) = self.total_staked_by_type.get_mut(&provider_type) {
600 *total = total.checked_sub(amount)
601 .ok_or_else(|| TokenError::ArithmeticOverflow {
602 operation: "staking slash total".to_string(),
603 })?;
604 *total
605 } else {
606 0
607 };
608
609 self.persist_stake(staker, &stake_clone);
611 self.persist_total_staked(provider_type, new_total);
612
613 warn!("Slashed {} from {} for reason: {}", amount, staker, reason);
614 Ok(())
615 }
616
617 pub fn restore_slashed_stake(
648 &self,
649 staker: &Address,
650 amount: u128,
651 proposal_id: String,
652 reason: String,
653 authorized_by: Address,
654 ) -> Result<()> {
655 if proposal_id.trim().is_empty() {
658 return Err(TokenError::Unauthorized {
659 reason: "restore_slashed_stake requires a governance proposal_id".to_string(),
660 });
661 }
662 if amount == 0 {
663 return Err(TokenError::InvalidAmount(
664 "restore amount must be positive".to_string(),
665 ));
666 }
667
668 let mut stake = self.stakes.get_mut(staker)
669 .ok_or_else(|| TokenError::StakeNotFound {
670 address: staker.to_string(),
671 })?;
672
673 let total_slashed: u128 = stake.slashing_history.iter()
676 .map(|s| s.amount)
677 .fold(0u128, |acc, a| acc.saturating_add(a));
678 let total_restored: u128 = stake.restoration_history.iter()
679 .map(|r| r.amount)
680 .fold(0u128, |acc, a| acc.saturating_add(a));
681 let restorable = total_slashed.saturating_sub(total_restored);
682 if amount > restorable {
683 return Err(TokenError::InvalidAmount(format!(
684 "restore amount {} exceeds restorable {} (total_slashed={}, already_restored={})",
685 amount, restorable, total_slashed, total_restored
686 )));
687 }
688
689 let new_amount = stake.amount.checked_add(amount)
691 .ok_or_else(|| TokenError::ArithmeticOverflow {
692 operation: "staking restore amount".to_string(),
693 })?;
694 stake.amount = new_amount;
695
696 let restore_event = RestoreEvent::new(amount, proposal_id.clone(), reason.clone(), authorized_by);
698 stake.restoration_history.push(restore_event);
699
700 let provider_type = stake.provider_type;
706 let min_stake = *self.min_stake.read();
707 let return_to_active = stake.status == StakeStatus::Unbonding
708 && stake.amount >= min_stake;
709 if return_to_active {
710 stake.status = StakeStatus::Active;
711 stake.unbonding_at = None;
712 info!(
713 "Restoration brought {} back above min_stake {}; returning to Active",
714 staker, min_stake
715 );
716 }
717
718 let counts_toward_total = matches!(
719 stake.status,
720 StakeStatus::Active | StakeStatus::Unbonding
721 );
722
723 let stake_clone = stake.clone();
725 drop(stake);
726
727 let new_total = if counts_toward_total {
730 let mut total = self.total_staked_by_type.entry(provider_type).or_insert(0);
731 *total = total.checked_add(amount)
732 .ok_or_else(|| TokenError::ArithmeticOverflow {
733 operation: "staking restore total".to_string(),
734 })?;
735 *total
736 } else {
737 self.total_staked_by_type
738 .get(&provider_type)
739 .map(|v| *v)
740 .unwrap_or(0)
741 };
742
743 self.persist_stake(staker, &stake_clone);
745 if counts_toward_total {
746 self.persist_total_staked(provider_type, new_total);
747 }
748
749 info!(
750 "Restored {} TNZO to {} under governance proposal {} (reason: {})",
751 amount, staker, proposal_id, reason
752 );
753 Ok(())
754 }
755
756 pub fn freeze_stake(&self, staker: &Address) -> Result<()> {
767 let mut stake = self.stakes.get_mut(staker)
768 .ok_or_else(|| TokenError::StakeNotFound {
769 address: staker.to_string(),
770 })?;
771
772 if stake.lifecycle_freeze {
773 return Ok(());
774 }
775 stake.lifecycle_freeze = true;
776
777 let stake_clone = stake.clone();
778 drop(stake);
779
780 self.persist_stake(staker, &stake_clone);
781 warn!("Froze stake for {} under kill-switch authority", staker);
782 Ok(())
783 }
784
785 pub fn thaw_stake(&self, staker: &Address) -> Result<()> {
791 let mut stake = self.stakes.get_mut(staker)
792 .ok_or_else(|| TokenError::StakeNotFound {
793 address: staker.to_string(),
794 })?;
795
796 if !stake.lifecycle_freeze {
797 return Ok(());
798 }
799 stake.lifecycle_freeze = false;
800
801 let stake_clone = stake.clone();
802 drop(stake);
803
804 self.persist_stake(staker, &stake_clone);
805 info!("Thawed stake for {} (kill-switch released)", staker);
806 Ok(())
807 }
808
809 pub fn get_stake(&self, staker: &Address) -> Option<StakeInfo> {
811 self.stakes.get(staker).map(|s| s.clone())
812 }
813
814 pub fn get_total_staked(&self, provider_type: ProviderType) -> u128 {
816 self.total_staked_by_type.get(&provider_type).map(|v| *v).unwrap_or(0)
817 }
818
819 pub fn get_total_staked_all(&self) -> u128 {
821 self.total_staked_by_type.iter().map(|entry| *entry.value()).sum()
822 }
823
824 pub fn total_slashed(&self) -> u128 {
834 self.stakes
835 .iter()
836 .map(|entry| {
837 let stake = entry.value();
838 let slashed: u128 = stake
839 .slashing_history
840 .iter()
841 .map(|s| s.amount)
842 .fold(0u128, |a, b| a.saturating_add(b));
843 let restored: u128 = stake
844 .restoration_history
845 .iter()
846 .map(|r| r.amount)
847 .fold(0u128, |a, b| a.saturating_add(b));
848 slashed.saturating_sub(restored)
849 })
850 .fold(0u128, |a, b| a.saturating_add(b))
851 }
852
853 pub fn set_min_stake(&self, min_stake: u128) {
855 *self.min_stake.write() = min_stake;
856 self.persist_config();
857 info!("Updated minimum stake to {}", min_stake);
858 }
859
860 pub fn set_unbonding_period(&self, period_ms: i64) {
862 *self.unbonding_period_ms.write() = period_ms;
863 self.persist_config();
864 info!("Updated unbonding period to {} ms", period_ms);
865 }
866
867 pub fn get_all_stakes(&self) -> Vec<(Address, StakeInfo)> {
869 self.stakes
870 .iter()
871 .map(|entry| (*entry.key(), entry.value().clone()))
872 .collect()
873 }
874}
875
876impl Default for StakingManager {
877 fn default() -> Self {
878 Self::new()
879 }
880}
881
882impl std::fmt::Debug for StakingManager {
883 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
884 f.debug_struct("StakingManager")
885 .field("stakes_count", &self.stakes.len())
886 .field("min_stake", &self.min_stake)
887 .field("unbonding_period_ms", &self.unbonding_period_ms)
888 .field("has_storage", &self.storage.is_some())
889 .finish()
890 }
891}
892
893#[cfg(test)]
894mod tests {
895 use super::*;
896 use tenzro_storage::RocksDbStore;
897
898 #[test]
899 fn test_stake() {
900 let manager = StakingManager::new();
901 let staker = Address::new([1u8; 32]);
902 let amount = DEFAULT_MIN_STAKE;
903
904 manager.stake(staker, amount, ProviderType::Validator).unwrap();
905
906 let stake_info = manager.get_stake(&staker).unwrap();
907 assert_eq!(stake_info.amount, amount);
908 assert_eq!(stake_info.status, StakeStatus::Active);
909 }
910
911 #[test]
912 fn test_unstake() {
913 let manager = StakingManager::new();
914 let staker = Address::new([1u8; 32]);
915 let amount = DEFAULT_MIN_STAKE;
916
917 manager.stake(staker, amount, ProviderType::Validator).unwrap();
918 manager.unstake(&staker).unwrap();
919
920 let stake_info = manager.get_stake(&staker).unwrap();
921 assert_eq!(stake_info.status, StakeStatus::Unbonding);
922 assert!(stake_info.unbonding_at.is_some());
923 }
924
925 #[test]
926 fn test_slash() {
927 let manager = StakingManager::new();
928 let staker = Address::new([1u8; 32]);
929 let slasher = Address::new([2u8; 32]);
930 let amount = DEFAULT_MIN_STAKE;
931 let slash_amount = amount / 10; manager.stake(staker, amount, ProviderType::Validator).unwrap();
934 manager.slash(&staker, slash_amount, "Misbehavior".to_string(), slasher).unwrap();
935
936 let stake_info = manager.get_stake(&staker).unwrap();
937 assert_eq!(stake_info.amount, amount - slash_amount);
938 assert_eq!(stake_info.slashing_history.len(), 1);
939 }
940
941 #[test]
942 fn test_restore_slashed_stake_round_trip() {
943 let manager = StakingManager::new();
944 let staker = Address::new([1u8; 32]);
945 let slasher = Address::new([2u8; 32]);
946 let governance = Address::new([3u8; 32]);
947 let amount = DEFAULT_MIN_STAKE * 2;
948 let slash_amount = amount / 10;
949
950 manager.stake(staker, amount, ProviderType::Validator).unwrap();
951 let total_before_slash = manager.get_total_staked(ProviderType::Validator);
952 manager
953 .slash(&staker, slash_amount, "buggy equivocation detector".to_string(), slasher)
954 .unwrap();
955
956 manager
958 .restore_slashed_stake(
959 &staker,
960 slash_amount,
961 "TGP-1".to_string(),
962 "view=62 cascade was a bug, not Byzantine".to_string(),
963 governance,
964 )
965 .unwrap();
966
967 let stake_info = manager.get_stake(&staker).unwrap();
968 assert_eq!(stake_info.amount, amount, "balance fully restored");
969 assert_eq!(stake_info.slashing_history.len(), 1);
970 assert_eq!(stake_info.restoration_history.len(), 1);
971 assert_eq!(stake_info.restoration_history[0].proposal_id, "TGP-1");
972 assert_eq!(stake_info.restoration_history[0].amount, slash_amount);
973 assert_eq!(
974 manager.get_total_staked(ProviderType::Validator),
975 total_before_slash,
976 "total_staked restored to pre-slash value"
977 );
978 }
979
980 #[test]
981 fn test_restore_slashed_stake_requires_proposal_id() {
982 let manager = StakingManager::new();
983 let staker = Address::new([1u8; 32]);
984 let slasher = Address::new([2u8; 32]);
985 let governance = Address::new([3u8; 32]);
986 let amount = DEFAULT_MIN_STAKE * 2;
987 let slash_amount = amount / 10;
988
989 manager.stake(staker, amount, ProviderType::Validator).unwrap();
990 manager
991 .slash(&staker, slash_amount, "fault".to_string(), slasher)
992 .unwrap();
993
994 let err = manager
996 .restore_slashed_stake(
997 &staker,
998 slash_amount,
999 " ".to_string(),
1000 "no proposal".to_string(),
1001 governance,
1002 )
1003 .unwrap_err();
1004 match err {
1005 TokenError::Unauthorized { .. } => {}
1006 other => panic!("expected Unauthorized, got {:?}", other),
1007 }
1008 }
1009
1010 #[test]
1011 fn test_restore_cannot_exceed_slashed_amount() {
1012 let manager = StakingManager::new();
1013 let staker = Address::new([1u8; 32]);
1014 let slasher = Address::new([2u8; 32]);
1015 let governance = Address::new([3u8; 32]);
1016 let amount = DEFAULT_MIN_STAKE * 2;
1017 let slash_amount = amount / 10;
1018
1019 manager.stake(staker, amount, ProviderType::Validator).unwrap();
1020 manager
1021 .slash(&staker, slash_amount, "fault".to_string(), slasher)
1022 .unwrap();
1023
1024 let err = manager
1026 .restore_slashed_stake(
1027 &staker,
1028 slash_amount + 1,
1029 "TGP-1".to_string(),
1030 "over-restore attempt".to_string(),
1031 governance,
1032 )
1033 .unwrap_err();
1034 match err {
1035 TokenError::InvalidAmount(_) => {}
1036 other => panic!("expected InvalidAmount, got {:?}", other),
1037 }
1038
1039 manager
1041 .restore_slashed_stake(
1042 &staker,
1043 slash_amount,
1044 "TGP-1".to_string(),
1045 "ok".to_string(),
1046 governance,
1047 )
1048 .unwrap();
1049
1050 let err = manager
1052 .restore_slashed_stake(
1053 &staker,
1054 1,
1055 "TGP-2".to_string(),
1056 "double-restore attempt".to_string(),
1057 governance,
1058 )
1059 .unwrap_err();
1060 match err {
1061 TokenError::InvalidAmount(_) => {}
1062 other => panic!("expected InvalidAmount on double-restore, got {:?}", other),
1063 }
1064 }
1065
1066 #[test]
1067 fn test_restore_returns_unbonding_to_active_when_above_min() {
1068 let manager = StakingManager::new();
1069 let staker = Address::new([1u8; 32]);
1070 let slasher = Address::new([2u8; 32]);
1071 let governance = Address::new([3u8; 32]);
1072 let amount = DEFAULT_MIN_STAKE;
1074 let slash_amount = 1u128;
1075
1076 manager.stake(staker, amount, ProviderType::Validator).unwrap();
1077 manager
1078 .slash(&staker, slash_amount, "below-min nudge".to_string(), slasher)
1079 .unwrap();
1080
1081 let stake_after_slash = manager.get_stake(&staker).unwrap();
1083 assert_eq!(stake_after_slash.status, StakeStatus::Unbonding);
1084
1085 manager
1087 .restore_slashed_stake(
1088 &staker,
1089 slash_amount,
1090 "TGP-1".to_string(),
1091 "restore".to_string(),
1092 governance,
1093 )
1094 .unwrap();
1095
1096 let stake_after_restore = manager.get_stake(&staker).unwrap();
1097 assert_eq!(stake_after_restore.amount, amount);
1098 assert_eq!(stake_after_restore.status, StakeStatus::Active);
1099 assert!(stake_after_restore.unbonding_at.is_none());
1100 }
1101
1102 #[test]
1103 fn test_persistence_stake_and_reload() {
1104 let temp_dir = std::env::temp_dir().join(format!("tenzro_test_staking_{}", uuid::Uuid::new_v4()));
1106 std::fs::create_dir_all(&temp_dir).unwrap();
1107
1108 let storage = Arc::new(RocksDbStore::open_default(&temp_dir).unwrap());
1110 let manager = StakingManager::with_storage(storage.clone());
1111
1112 let staker1 = Address::new([1u8; 32]);
1114 let staker2 = Address::new([2u8; 32]);
1115 let amount1 = DEFAULT_MIN_STAKE;
1116 let amount2 = DEFAULT_MIN_STAKE * 2;
1117
1118 manager.stake(staker1, amount1, ProviderType::Validator).unwrap();
1119 manager.stake(staker2, amount2, ProviderType::ModelProvider).unwrap();
1120
1121 assert!(manager.get_stake(&staker1).is_some());
1123 assert!(manager.get_stake(&staker2).is_some());
1124 assert_eq!(manager.get_total_staked(ProviderType::Validator), amount1);
1125 assert_eq!(manager.get_total_staked(ProviderType::ModelProvider), amount2);
1126
1127 drop(manager);
1129
1130 let manager2 = StakingManager::with_storage(storage);
1132
1133 let stake1 = manager2.get_stake(&staker1).unwrap();
1135 assert_eq!(stake1.amount, amount1);
1136 assert_eq!(stake1.provider_type, ProviderType::Validator);
1137 assert_eq!(stake1.status, StakeStatus::Active);
1138
1139 let stake2 = manager2.get_stake(&staker2).unwrap();
1140 assert_eq!(stake2.amount, amount2);
1141 assert_eq!(stake2.provider_type, ProviderType::ModelProvider);
1142 assert_eq!(stake2.status, StakeStatus::Active);
1143
1144 assert_eq!(manager2.get_total_staked(ProviderType::Validator), amount1);
1146 assert_eq!(manager2.get_total_staked(ProviderType::ModelProvider), amount2);
1147
1148 std::fs::remove_dir_all(&temp_dir).ok();
1150 }
1151
1152 #[test]
1153 fn test_persistence_config() {
1154 let temp_dir = std::env::temp_dir().join(format!("tenzro_test_staking_config_{}", uuid::Uuid::new_v4()));
1156 std::fs::create_dir_all(&temp_dir).unwrap();
1157
1158 let storage = Arc::new(RocksDbStore::open_default(&temp_dir).unwrap());
1160 let manager = StakingManager::with_storage(storage.clone());
1161
1162 let new_min_stake = DEFAULT_MIN_STAKE * 2;
1164 let new_unbonding = DEFAULT_UNBONDING_PERIOD_MS * 2;
1165 manager.set_min_stake(new_min_stake);
1166 manager.set_unbonding_period(new_unbonding);
1167
1168 drop(manager);
1170
1171 let manager2 = StakingManager::with_storage(storage);
1173 assert_eq!(*manager2.min_stake.read(), new_min_stake);
1174 assert_eq!(*manager2.unbonding_period_ms.read(), new_unbonding);
1175
1176 std::fs::remove_dir_all(&temp_dir).ok();
1178 }
1179
1180 #[test]
1181 fn test_persistence_unstake_and_withdraw() {
1182 let temp_dir = std::env::temp_dir().join(format!("tenzro_test_staking_unstake_{}", uuid::Uuid::new_v4()));
1184 std::fs::create_dir_all(&temp_dir).unwrap();
1185
1186 let storage = Arc::new(RocksDbStore::open_default(&temp_dir).unwrap());
1188 let manager = StakingManager::with_storage(storage.clone());
1189
1190 let staker = Address::new([1u8; 32]);
1192 let amount = DEFAULT_MIN_STAKE;
1193 manager.stake(staker, amount, ProviderType::Validator).unwrap();
1194 manager.unstake(&staker).unwrap();
1195
1196 let stake_before = manager.get_stake(&staker).unwrap();
1198 assert_eq!(stake_before.status, StakeStatus::Unbonding);
1199 let unbonding_at = stake_before.unbonding_at.unwrap();
1200
1201 drop(manager);
1203 let manager2 = StakingManager::with_storage(storage);
1204
1205 let stake_after = manager2.get_stake(&staker).unwrap();
1207 assert_eq!(stake_after.status, StakeStatus::Unbonding);
1208 assert_eq!(stake_after.unbonding_at, Some(unbonding_at));
1209 assert_eq!(stake_after.amount, amount);
1210
1211 std::fs::remove_dir_all(&temp_dir).ok();
1213 }
1214
1215 #[test]
1216 fn test_persistence_slash() {
1217 let temp_dir = std::env::temp_dir().join(format!("tenzro_test_staking_slash_{}", uuid::Uuid::new_v4()));
1219 std::fs::create_dir_all(&temp_dir).unwrap();
1220
1221 let storage = Arc::new(RocksDbStore::open_default(&temp_dir).unwrap());
1223 let manager = StakingManager::with_storage(storage.clone());
1224
1225 let staker = Address::new([1u8; 32]);
1227 let slasher = Address::new([2u8; 32]);
1228 let amount = DEFAULT_MIN_STAKE;
1229 let slash_amount = amount / 10;
1230
1231 manager.stake(staker, amount, ProviderType::Validator).unwrap();
1232 manager.slash(&staker, slash_amount, "Test slash".to_string(), slasher).unwrap();
1233
1234 let stake_before = manager.get_stake(&staker).unwrap();
1236 assert_eq!(stake_before.amount, amount - slash_amount);
1237 assert_eq!(stake_before.slashing_history.len(), 1);
1238
1239 drop(manager);
1241 let manager2 = StakingManager::with_storage(storage);
1242
1243 let stake_after = manager2.get_stake(&staker).unwrap();
1245 assert_eq!(stake_after.amount, amount - slash_amount);
1246 assert_eq!(stake_after.slashing_history.len(), 1);
1247 assert_eq!(stake_after.slashing_history[0].amount, slash_amount);
1248 assert_eq!(stake_after.slashing_history[0].reason, "Test slash");
1249
1250 assert_eq!(manager2.get_total_staked(ProviderType::Validator), amount - slash_amount);
1252
1253 std::fs::remove_dir_all(&temp_dir).ok();
1255 }
1256}