Skip to main content

tenzro_token/
staking.rs

1//! Staking system for validators and service providers
2//!
3//! This module implements staking mechanics for validators, inference providers,
4//! and TEE providers with unbonding periods and slashing.
5
6use 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
14/// Default unbonding period: 7 days in milliseconds
15pub const DEFAULT_UNBONDING_PERIOD_MS: i64 = 7 * 24 * 60 * 60 * 1000;
16
17/// Default minimum stake: 1000 TNZO
18pub const DEFAULT_MIN_STAKE: u128 = 1000 * 1_000_000_000_000_000_000; // 1000 * 10^18
19
20/// Staking information for a provider
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct StakeInfo {
23    /// Staker address
24    pub staker: Address,
25    /// Amount staked
26    pub amount: u128,
27    /// Provider type
28    pub provider_type: ProviderType,
29    /// Timestamp when staked
30    pub staked_at: Timestamp,
31    /// Unbonding timestamp (if unbonding)
32    pub unbonding_at: Option<Timestamp>,
33    /// Slashing history
34    pub slashing_history: Vec<SlashEvent>,
35    /// Restoration history (governance-authorized un-slashings)
36    #[serde(default)]
37    pub restoration_history: Vec<RestoreEvent>,
38    /// Total rewards earned
39    pub total_rewards: u128,
40    /// Stake status
41    pub status: StakeStatus,
42    /// Kill-switch lifecycle freeze (Agent-Swarm Spec 1).
43    ///
44    /// Set by the node post-execute scan when a `KillSwitchPause` or
45    /// `KillSwitchQuarantine` log is observed for the machine DID that
46    /// owns this stake address; cleared on `ResumedFromPause` /
47    /// `ResumedFromQuarantine`. While frozen, `unstake`, `withdraw`, and
48    /// reward accrual are rejected. `slash` is not gated — terminate
49    /// flow needs to slash even when frozen.
50    #[serde(default)]
51    pub lifecycle_freeze: bool,
52}
53
54impl StakeInfo {
55    /// Creates a new stake info
56    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    /// Checks if the stake is locked (either active or unbonding)
72    pub fn is_locked(&self) -> bool {
73        matches!(self.status, StakeStatus::Active | StakeStatus::Unbonding)
74    }
75
76    /// Checks if unbonding period has completed
77    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    /// Adds a slash event
86    ///
87    /// Note: Caller must verify `self.amount >= event.amount` before calling.
88    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    /// Adds rewards.
95    ///
96    /// Rejected when the stake is under a kill-switch lifecycle freeze
97    /// (Paused / Quarantined). The terminate path slashes via
98    /// `StakingManager::slash` directly and never reaches this method.
99    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/// Slash event record
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct SlashEvent {
117    /// Timestamp of slash
118    pub timestamp: Timestamp,
119    /// Amount slashed
120    pub amount: u128,
121    /// Reason for slashing
122    pub reason: String,
123    /// Slashed by (validator/system address)
124    pub slashed_by: Address,
125}
126
127impl SlashEvent {
128    /// Creates a new slash event
129    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/// Governance-authorized restoration of previously slashed stake.
140///
141/// Used as an audit trail for the (rare) case where a stake was slashed in
142/// error — for example, the testnet equivocation cascade at view=62 on
143/// 2026-04-27, where validators self-equivocated due to a missing persistent
144/// vote-state and were correctly slashed by the protocol, but the underlying
145/// fault was a bug, not Byzantine behavior.
146///
147/// Restoration is gated by an on-chain governance proposal; the
148/// `proposal_id` field binds the restoration to the proposal that
149/// authorized it for full auditability.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct RestoreEvent {
152    /// Timestamp of restoration
153    pub timestamp: Timestamp,
154    /// Amount restored
155    pub amount: u128,
156    /// Governance proposal authorizing the restoration
157    pub proposal_id: String,
158    /// Reason for restoration
159    pub reason: String,
160    /// Address that executed the restoration (typically the governance
161    /// executor / treasury multisig)
162    pub authorized_by: Address,
163}
164
165impl RestoreEvent {
166    /// Creates a new restoration event
167    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/// Status of a stake
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180pub enum StakeStatus {
181    /// Stake is active
182    Active,
183    /// Stake is unbonding
184    Unbonding,
185    /// Stake has been withdrawn
186    Withdrawn,
187}
188
189/// Staking manager
190///
191/// Manages staking for validators and service providers.
192pub struct StakingManager {
193    /// Stakes by address
194    stakes: DashMap<Address, StakeInfo>,
195    /// Total staked per provider type
196    total_staked_by_type: DashMap<ProviderType, u128>,
197    /// Minimum stake required
198    min_stake: parking_lot::RwLock<u128>,
199    /// Unbonding period in milliseconds
200    unbonding_period_ms: parking_lot::RwLock<i64>,
201    /// Optional persistent storage backend
202    storage: Option<Arc<dyn tenzro_storage::KvStore>>,
203}
204
205/// Storage key prefix for individual stakes
206const STAKE_PREFIX: &str = "stake:";
207/// Storage key for staking config
208const STAKING_CONFIG_KEY: &str = "staking:config";
209/// Storage key for staking index
210const STAKING_INDEX_KEY: &str = "staking:index";
211/// Column family to use
212const STAKING_CF: &str = tenzro_storage::CF_STATE;
213
214/// Helper to convert hex string to Address
215fn 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    /// Creates a new staking manager
225    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    /// Creates a new staking manager with persistent storage
236    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        // Load existing state from storage
246        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    /// Persists a single stake to storage
254    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    /// Persists total staked for a provider type
269    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    /// Loads all staking state from storage
279    fn load_from_storage(&self) -> Result<()> {
280        let storage = match &self.storage {
281            Some(s) => s,
282            None => return Ok(()),
283        };
284
285        // Load all stakes by scanning the index
286        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                            // Update total staked
303                            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        // Load staking config
319        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    /// Persists the staker address index
333    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    /// Persists staking config (min_stake and unbonding period)
351    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    /// Stakes tokens for a provider
367    ///
368    /// # Arguments
369    ///
370    /// * `staker` - Address staking
371    /// * `amount` - Amount to stake
372    /// * `provider_type` - Type of provider
373    pub fn stake(&self, staker: Address, amount: u128, provider_type: ProviderType) -> Result<()> {
374        // Validate minimum stake
375        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        // Check if already staked
384        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        // Create stake info
393        let stake_info = StakeInfo::new(staker, amount, provider_type);
394        self.stakes.insert(staker, stake_info.clone());
395
396        // Update total staked
397        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        // Persist to storage
404        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    /// Initiates unstaking (starts unbonding period)
413    ///
414    /// # Arguments
415    ///
416    /// * `staker` - Address unstaking
417    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        // Kill-switch lifecycle freeze (Agent-Swarm Spec 1). Pause /
424        // quarantine block voluntary unstake; only `slash` (called by the
425        // terminate handler) can move funds out of a frozen stake.
426        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        // Check if already unbonding
434        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        // Set unbonding period
447        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        // Clone before persisting to avoid holding the RefMut
453        let stake_clone = stake.clone();
454        drop(stake); // Drop the DashMap RefMut before calling persist
455
456        // Persist updated stake
457        self.persist_stake(staker, &stake_clone);
458
459        info!("Initiated unstaking for {}. Unbonding completes at {}", staker, unbonding_at);
460        Ok(())
461    }
462
463    /// Completes withdrawal after unbonding period
464    ///
465    /// # Arguments
466    ///
467    /// * `staker` - Address withdrawing stake
468    ///
469    /// Returns the withdrawn amount
470    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        // Kill-switch freeze blocks completion of an in-flight unbonding
477        // window — a controller can pause/quarantine a validator that is
478        // already unbonding to keep stake claimable for slash.
479        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        // Check unbonding status
487        if stake.status != StakeStatus::Unbonding {
488            return Err(TokenError::InvalidAmount(
489                "Stake must be unbonding to withdraw".to_string()
490            ));
491        }
492
493        // Check if unbonding period is complete
494        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        // Update status
505        stake.status = StakeStatus::Withdrawn;
506        stake.amount = 0;
507
508        // Clone before persisting to avoid holding the RefMut
509        let stake_clone = stake.clone();
510        drop(stake); // Drop the DashMap RefMut before calling persist
511
512        // Update total staked
513        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        // Persist updated stake
524        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    /// Slashes a stake
532    ///
533    /// # Arguments
534    ///
535    /// * `staker` - Address to slash
536    /// * `amount` - Amount to slash
537    /// * `reason` - Reason for slashing
538    /// * `slashed_by` - Address performing the slash
539    ///
540    /// Note: Slashing enforces the minimum unbonding period. The slashed stake
541    /// cannot be withdrawn until the full unbonding period has elapsed from the
542    /// slash event, even if it was already unbonding.
543    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        // Enforce minimum stake after slashing (safe: checked above that stake.amount >= amount)
557        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 slashing would bring stake below minimum and stake is active,
564        // force it into unbonding state with fresh unbonding period
565        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 already unbonding, reset the unbonding period (penalty for misbehavior)
577        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        // Create slash event
588        let slash_event = SlashEvent::new(amount, reason.clone(), slashed_by);
589
590        // Update stake
591        let provider_type = stake.provider_type;
592        stake.add_slash(slash_event);
593
594        // Clone before persisting to avoid holding the RefMut
595        let stake_clone = stake.clone();
596        drop(stake); // Drop the DashMap RefMut before calling persist
597
598        // Update total staked
599        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        // Persist updated stake
610        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    /// Restores previously-slashed stake under governance authorization.
618    ///
619    /// This is intended for the rare case where the protocol slashed a
620    /// validator due to a bug (e.g., the equivocation cascade at view=62
621    /// on 2026-04-27 caused by a missing persistent vote-state, *not*
622    /// Byzantine intent). The restoration must be authorized by an
623    /// on-chain governance proposal — `proposal_id` is required and
624    /// recorded on the audit trail. The caller is responsible for
625    /// verifying the proposal has passed and that `authorized_by` is the
626    /// legitimate executor of that proposal; this manager does not own
627    /// governance state.
628    ///
629    /// # Arguments
630    ///
631    /// * `staker` - Address whose stake is being restored
632    /// * `amount` - Amount to add back to the stake
633    /// * `proposal_id` - Governance proposal authorizing the restoration
634    ///   (non-empty; required for audit)
635    /// * `reason` - Human-readable reason
636    /// * `authorized_by` - Address executing the restoration (typically
637    ///   the governance executor / treasury multisig)
638    ///
639    /// # Errors
640    ///
641    /// * `Unauthorized` if `proposal_id` is empty.
642    /// * `InvalidAmount` if `amount` is zero or exceeds the total
643    ///   previously-slashed amount on this stake (cannot restore more
644    ///   than was slashed).
645    /// * `StakeNotFound` if no stake exists for `staker`.
646    /// * `ArithmeticOverflow` on amount overflow.
647    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        // Governance gate: require a non-empty proposal_id binding this
656        // restoration to a passed on-chain governance vote.
657        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        // Cap restoration at the cumulative slashed amount minus what has
674        // already been restored — cannot mint more than was slashed.
675        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        // Add amount back to the stake balance.
690        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        // Record the restoration event for audit.
697        let restore_event = RestoreEvent::new(amount, proposal_id.clone(), reason.clone(), authorized_by);
698        stake.restoration_history.push(restore_event);
699
700        // If the stake was forced into Unbonding by a prior slash that
701        // brought it below min_stake, and the restoration brings it back
702        // above min_stake, allow it to return to Active. Withdrawn stakes
703        // stay Withdrawn — restoration into a withdrawn stake is purely
704        // accounting (the funds have already left the system).
705        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        // Clone before persisting to avoid holding the RefMut.
724        let stake_clone = stake.clone();
725        drop(stake);
726
727        // Update total staked only if this stake is in a state that
728        // contributes to the total (Active or Unbonding).
729        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        // Persist updated stake
744        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    /// Freezes a stake under kill-switch authority (Agent-Swarm Spec 1).
757    ///
758    /// Called by the node post-execute scan when a `KillSwitchPause` or
759    /// `KillSwitchQuarantine` log is observed for the machine DID that
760    /// owns this stake address. While frozen, `unstake`, `withdraw`, and
761    /// reward accrual (`add_rewards`) are rejected. Idempotent — freezing
762    /// an already-frozen stake is a no-op.
763    ///
764    /// `slash` is intentionally NOT gated by `lifecycle_freeze`: the
765    /// terminate flow must be able to slash a frozen stake.
766    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    /// Thaws a previously-frozen stake (Agent-Swarm Spec 1).
786    ///
787    /// Called by the node post-execute scan when a `ResumedFromPause` or
788    /// `ResumedFromQuarantine` log is observed. Idempotent — thawing an
789    /// already-thawed stake is a no-op.
790    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    /// Returns stake information for an address
810    pub fn get_stake(&self, staker: &Address) -> Option<StakeInfo> {
811        self.stakes.get(staker).map(|s| s.clone())
812    }
813
814    /// Returns total staked for a provider type
815    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    /// Returns total staked across all provider types
820    pub fn get_total_staked_all(&self) -> u128 {
821        self.total_staked_by_type.iter().map(|entry| *entry.value()).sum()
822    }
823
824    /// Returns the cumulative slash burn across all stakes, net of any
825    /// governance restorations. Computed by summing `slashing_history` and
826    /// subtracting `restore_history` per stake.
827    ///
828    /// Saturating arithmetic — `u128` can hold the full TNZO supply (1B
829    /// scaled by 1e18) many times over, so an overflow here would indicate
830    /// data corruption rather than legitimate flow. Consumed by the
831    /// adaptive-burn observer at every epoch boundary to populate
832    /// `BurnBreakdown.slash`.
833    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    /// Updates minimum stake requirement
854    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    /// Updates unbonding period
861    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    /// Returns all active stakes
868    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; // 10%
932
933        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        // Restore the full slashed amount under a governance proposal.
957        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        // Empty proposal_id must be rejected as Unauthorized.
995        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        // Restoring more than was slashed must fail.
1025        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        // Restoring exact amount must succeed.
1040        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        // Second restoration of any amount must now fail (already fully restored).
1051        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        // Stake exactly at min, then slash a sliver — that forces Unbonding.
1073        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        // After slash: amount < min_stake, status == Unbonding.
1082        let stake_after_slash = manager.get_stake(&staker).unwrap();
1083        assert_eq!(stake_after_slash.status, StakeStatus::Unbonding);
1084
1085        // Restore: amount goes back to min, status returns to Active.
1086        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        // Create a temporary directory for this test
1105        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        // Create storage and staking manager with persistence
1109        let storage = Arc::new(RocksDbStore::open_default(&temp_dir).unwrap());
1110        let manager = StakingManager::with_storage(storage.clone());
1111
1112        // Stake some tokens
1113        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        // Verify stakes were created
1122        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 the manager to simulate shutdown
1128        drop(manager);
1129
1130        // Create a new manager with the same storage - should reload state
1131        let manager2 = StakingManager::with_storage(storage);
1132
1133        // Verify stakes were loaded
1134        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        // Verify total staked was recomputed
1145        assert_eq!(manager2.get_total_staked(ProviderType::Validator), amount1);
1146        assert_eq!(manager2.get_total_staked(ProviderType::ModelProvider), amount2);
1147
1148        // Clean up
1149        std::fs::remove_dir_all(&temp_dir).ok();
1150    }
1151
1152    #[test]
1153    fn test_persistence_config() {
1154        // Create a temporary directory for this test
1155        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        // Create storage and staking manager with persistence
1159        let storage = Arc::new(RocksDbStore::open_default(&temp_dir).unwrap());
1160        let manager = StakingManager::with_storage(storage.clone());
1161
1162        // Update config
1163        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 the manager
1169        drop(manager);
1170
1171        // Create a new manager - should load config
1172        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        // Clean up
1177        std::fs::remove_dir_all(&temp_dir).ok();
1178    }
1179
1180    #[test]
1181    fn test_persistence_unstake_and_withdraw() {
1182        // Create a temporary directory for this test
1183        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        // Create storage and staking manager with persistence
1187        let storage = Arc::new(RocksDbStore::open_default(&temp_dir).unwrap());
1188        let manager = StakingManager::with_storage(storage.clone());
1189
1190        // Stake and unstake
1191        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        // Verify unbonding state before reload
1197        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 and reload
1202        drop(manager);
1203        let manager2 = StakingManager::with_storage(storage);
1204
1205        // Verify unbonding state persisted
1206        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        // Clean up
1212        std::fs::remove_dir_all(&temp_dir).ok();
1213    }
1214
1215    #[test]
1216    fn test_persistence_slash() {
1217        // Create a temporary directory for this test
1218        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        // Create storage and staking manager with persistence
1222        let storage = Arc::new(RocksDbStore::open_default(&temp_dir).unwrap());
1223        let manager = StakingManager::with_storage(storage.clone());
1224
1225        // Stake and slash
1226        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        // Verify slash before reload
1235        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 and reload
1240        drop(manager);
1241        let manager2 = StakingManager::with_storage(storage);
1242
1243        // Verify slash persisted
1244        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        // Verify total staked was updated
1251        assert_eq!(manager2.get_total_staked(ProviderType::Validator), amount - slash_amount);
1252
1253        // Clean up
1254        std::fs::remove_dir_all(&temp_dir).ok();
1255    }
1256}