Skip to main content

truthlinked_staking/
lib.rs

1//! Validator staking, unbonding, jailing, and slashing primitives for TruthLinked.
2//!
3//! The staking model keeps validator eligibility, active stake, unbonding queues,
4//! and slash evidence explicit so consensus liveness and stake-weighted decisions
5//! can be audited from state.
6
7use serde::{Deserialize, Serialize};
8use std::collections::{HashMap, HashSet};
9use truthlinked_core::constants::{
10    JAIL_DURATION_BLOCKS, MAX_UNBONDING_ENTRIES, MAX_VALIDATOR_STAKE, MIN_VALIDATOR_STAKE,
11    UNBONDING_TICKS,
12};
13use truthlinked_governance::params as gp;
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16pub enum SlashReason {
17    DoubleSign,
18    InvalidStateRoot,
19    Downtime,
20    InvalidSnapshot,
21    Censorship,
22    /// Validator committed to an oracle response then revealed fabricated data.
23    /// Cryptographically provable: reveal hash != commit hash stored on-chain.
24    OracleLie,
25    /// Validator committed to an oracle request but never revealed within the deadline.
26    /// Liveness infraction. Evidence: request_id that expired unrevealed.
27    OracleSilence {
28        request_id: [u8; 32],
29    },
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct DoubleSignEvidence {
34    pub validator_pubkey: Vec<u8>,
35    pub height: u64,
36    pub batch_hash_1: [u8; 32],
37    pub batch_hash_2: [u8; 32],
38    pub signature_1: Vec<u8>,
39    pub signature_2: Vec<u8>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
43pub struct ValidatorStake {
44    pub active_stake: u64,
45    pub unbonding: Vec<UnbondingEntry>,
46    pub jailed_until: Option<u64>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
50pub struct UnbondingEntry {
51    pub amount: u64,
52    pub completion_tick: u64, // Time-based unbonding tick
53}
54
55impl ValidatorStake {
56    pub fn new(stake: u64) -> Self {
57        Self {
58            active_stake: stake,
59            unbonding: Vec::new(),
60            jailed_until: None,
61        }
62    }
63
64    pub fn total_stake(&self) -> u64 {
65        self.active_stake + self.unbonding.iter().map(|e| e.amount).sum::<u64>()
66    }
67
68    pub fn is_active(&self, current_height: u64) -> bool {
69        if let Some(jail_height) = self.jailed_until {
70            if current_height < jail_height {
71                return false;
72            }
73        }
74        self.active_stake >= MIN_VALIDATOR_STAKE
75    }
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct StakingState {
80    pub validators: std::collections::BTreeMap<Vec<u8>, ValidatorStake>,
81    pub current_height: u64,
82    /// Treasury account that receives slashed tokens when no other validators are eligible.
83    #[serde(default)]
84    pub treasury_account_id: Option<[u8; 32]>,
85}
86
87#[derive(Debug, Clone)]
88pub struct SlashOutcome {
89    pub amount: u64,
90    pub redistribution: Vec<(Vec<u8>, u64)>,
91}
92
93impl StakingState {
94    pub fn new() -> Self {
95        Self {
96            validators: std::collections::BTreeMap::new(),
97            current_height: 0,
98            treasury_account_id: None,
99        }
100    }
101
102    pub fn stake(&mut self, validator_pubkey: Vec<u8>, amount: u64) -> Result<(), String> {
103        if amount < MIN_VALIDATOR_STAKE {
104            return Err(format!(
105                "Stake below minimum: {} < {}",
106                amount, MIN_VALIDATOR_STAKE
107            ));
108        }
109
110        let stake = self
111            .validators
112            .entry(validator_pubkey)
113            .or_insert_with(|| ValidatorStake::new(0));
114
115        let new_stake = stake
116            .active_stake
117            .checked_add(amount)
118            .ok_or("Stake overflow")?;
119
120        if new_stake > MAX_VALIDATOR_STAKE {
121            return Err("Exceeds maximum validator stake".to_string());
122        }
123
124        stake.active_stake = new_stake;
125
126        Ok(())
127    }
128
129    pub fn unstake(&mut self, validator_pubkey: &[u8], amount: u64) -> Result<(), String> {
130        let stake = self
131            .validators
132            .get_mut(validator_pubkey)
133            .ok_or("Validator not found")?;
134
135        // CRITICAL FIX: Auto-withdraw mature entries if at limit
136        if stake.unbonding.len() >= MAX_UNBONDING_ENTRIES {
137            let mut auto_withdrawn = 0u64;
138            stake.unbonding.retain(|entry| {
139                if self.current_height >= entry.completion_tick {
140                    auto_withdrawn += entry.amount;
141                    false
142                } else {
143                    true
144                }
145            });
146
147            if auto_withdrawn > 0 {
148                tracing::info!(
149                    "Auto-withdrew {} from {} mature unbonding entries",
150                    auto_withdrawn,
151                    MAX_UNBONDING_ENTRIES - stake.unbonding.len()
152                );
153            }
154
155            if stake.unbonding.len() >= MAX_UNBONDING_ENTRIES {
156                return Err("Too many unbonding entries, withdraw first".to_string());
157            }
158        }
159
160        if amount > stake.active_stake {
161            return Err("Insufficient active stake".to_string());
162        }
163
164        let remaining = stake.active_stake - amount;
165        if remaining > 0 && remaining < MIN_VALIDATOR_STAKE {
166            return Err("Unstaking would leave stake below minimum (must unstake all)".to_string());
167        }
168
169        stake.active_stake = remaining;
170        stake.unbonding.push(UnbondingEntry {
171            amount,
172            completion_tick: self.current_height + UNBONDING_TICKS,
173        });
174
175        Ok(())
176    }
177
178    pub fn withdraw(&mut self, validator_pubkey: &[u8]) -> Result<u64, String> {
179        let stake = self
180            .validators
181            .get_mut(validator_pubkey)
182            .ok_or("Validator not found")?;
183
184        let mut total_withdrawn = 0u64;
185        stake.unbonding.retain(|entry| {
186            if self.current_height >= entry.completion_tick {
187                total_withdrawn += entry.amount;
188                false
189            } else {
190                true
191            }
192        });
193
194        if total_withdrawn == 0 {
195            return Err("No unbonded stake available".to_string());
196        }
197
198        if stake.active_stake == 0 && stake.unbonding.is_empty() && stake.jailed_until.is_none() {
199            self.validators.remove(validator_pubkey);
200        }
201
202        Ok(total_withdrawn)
203    }
204
205    pub fn get_active_validators(&self) -> HashMap<Vec<u8>, u64> {
206        self.validators
207            .iter()
208            .filter(|(_, stake)| stake.is_active(self.current_height))
209            .map(|(pk, stake)| (pk.clone(), stake.active_stake))
210            .collect()
211    }
212
213    pub fn advance_height(&mut self) {
214        self.current_height += 1;
215    }
216
217    pub fn compute_slash_outcome(
218        &self,
219        validator_pubkey: &[u8],
220        reason: SlashReason,
221        evidence: Option<DoubleSignEvidence>,
222    ) -> Result<SlashOutcome, String> {
223        if reason == SlashReason::DoubleSign {
224            let ev = evidence.ok_or("Double-sign requires evidence")?;
225            self.verify_double_sign_evidence(&ev)?;
226        }
227
228        let stake = self
229            .validators
230            .get(validator_pubkey)
231            .ok_or("Validator not found")?;
232
233        let percentage = match &reason {
234            SlashReason::DoubleSign => gp::get_u64(gp::PARAM_SLASH_PERCENTAGE),
235            SlashReason::InvalidStateRoot => gp::get_u64(gp::PARAM_SLASH_PERCENTAGE),
236            SlashReason::InvalidSnapshot => gp::get_u64(gp::PARAM_SLASH_PERCENTAGE),
237            SlashReason::Downtime => gp::get_u64(gp::PARAM_DOWNTIME_SLASH_PERCENTAGE),
238            SlashReason::Censorship => gp::get_u64(gp::PARAM_CENSORSHIP_SLASH_PERCENTAGE),
239            SlashReason::OracleLie => gp::get_u64(gp::PARAM_ORACLE_LIE_SLASH_PERCENTAGE),
240            SlashReason::OracleSilence { .. } => {
241                gp::get_u64(gp::PARAM_ORACLE_SILENCE_SLASH_PERCENTAGE)
242            }
243        };
244
245        if stake.active_stake == 0 {
246            return Err("No stake to slash".to_string());
247        }
248        let mut slash_amount = (stake.active_stake * percentage) / 100;
249        if slash_amount == 0 {
250            slash_amount = 1;
251        }
252        if slash_amount > stake.active_stake {
253            slash_amount = stake.active_stake;
254        }
255
256        let redistribution = self.compute_redistribution(validator_pubkey, slash_amount)?;
257        Ok(SlashOutcome {
258            amount: slash_amount,
259            redistribution,
260        })
261    }
262
263    pub fn apply_slash_outcome(
264        &mut self,
265        validator_pubkey: &[u8],
266        reason: SlashReason,
267        outcome: &SlashOutcome,
268        evidence: Option<DoubleSignEvidence>,
269    ) -> Result<(), String> {
270        if reason == SlashReason::DoubleSign {
271            let ev = evidence.ok_or("Double-sign requires evidence")?;
272            self.verify_double_sign_evidence(&ev)?;
273        }
274        let stake = self
275            .validators
276            .get_mut(validator_pubkey)
277            .ok_or("Validator not found")?;
278        if outcome.amount == 0 {
279            return Err("No stake to slash".to_string());
280        }
281        if outcome.amount > stake.active_stake {
282            return Err("Slash amount exceeds active stake".to_string());
283        }
284        stake.active_stake = stake.active_stake.saturating_sub(outcome.amount);
285        stake.jailed_until = Some(self.current_height + JAIL_DURATION_BLOCKS);
286
287        tracing::warn!(
288            "Slashed validator {} for {:?}: {} tokens ({}%), jailed until height {}",
289            hex::encode(&validator_pubkey[..8]),
290            reason,
291            outcome.amount,
292            match &reason {
293                SlashReason::DoubleSign => gp::get_u64(gp::PARAM_SLASH_PERCENTAGE),
294                SlashReason::InvalidStateRoot => gp::get_u64(gp::PARAM_SLASH_PERCENTAGE),
295                SlashReason::InvalidSnapshot => gp::get_u64(gp::PARAM_SLASH_PERCENTAGE),
296                SlashReason::Downtime => gp::get_u64(gp::PARAM_DOWNTIME_SLASH_PERCENTAGE),
297                SlashReason::Censorship => gp::get_u64(gp::PARAM_CENSORSHIP_SLASH_PERCENTAGE),
298                SlashReason::OracleLie => gp::get_u64(gp::PARAM_ORACLE_LIE_SLASH_PERCENTAGE),
299                SlashReason::OracleSilence { .. } =>
300                    gp::get_u64(gp::PARAM_ORACLE_SILENCE_SLASH_PERCENTAGE),
301            },
302            self.current_height + JAIL_DURATION_BLOCKS
303        );
304
305        self.apply_redistribution(&outcome.redistribution);
306        Ok(())
307    }
308
309    pub fn slash_validator(
310        &mut self,
311        validator_pubkey: &[u8],
312        reason: SlashReason,
313        evidence: Option<DoubleSignEvidence>,
314    ) -> Result<SlashOutcome, String> {
315        let outcome =
316            self.compute_slash_outcome(validator_pubkey, reason.clone(), evidence.clone())?;
317        self.apply_slash_outcome(validator_pubkey, reason, &outcome, evidence)?;
318        Ok(outcome)
319    }
320
321    fn verify_double_sign_evidence(&self, evidence: &DoubleSignEvidence) -> Result<(), String> {
322        use fips204::ml_dsa_65;
323        use fips204::traits::{SerDes, Verifier};
324
325        // Must be different batches at same height
326        if evidence.batch_hash_1 == evidence.batch_hash_2 {
327            return Err("Evidence must show two different batches".to_string());
328        }
329
330        // Reconstruct attestation messages
331        let msg1 = Self::attestation_message(evidence.height, &evidence.batch_hash_1);
332        let msg2 = Self::attestation_message(evidence.height, &evidence.batch_hash_2);
333
334        // Verify both signatures with validator's pubkey
335        let pk_bytes: [u8; 1952] = evidence
336            .validator_pubkey
337            .as_slice()
338            .try_into()
339            .map_err(|_| "Invalid pubkey length")?;
340        let pk = ml_dsa_65::PublicKey::try_from_bytes(pk_bytes).map_err(|_| "Invalid pubkey")?;
341
342        let sig1: [u8; 3309] = evidence
343            .signature_1
344            .as_slice()
345            .try_into()
346            .map_err(|_| "Invalid signature 1 length")?;
347        let sig2: [u8; 3309] = evidence
348            .signature_2
349            .as_slice()
350            .try_into()
351            .map_err(|_| "Invalid signature 2 length")?;
352
353        if !pk.verify(&msg1, &sig1, b"truthlinked-attestation-v1") {
354            return Err("Signature 1 verification failed".to_string());
355        }
356        if !pk.verify(&msg2, &sig2, b"truthlinked-attestation-v1") {
357            return Err("Signature 2 verification failed".to_string());
358        }
359
360        Ok(())
361    }
362
363    fn attestation_message(height: u64, batch_hash: &[u8; 32]) -> Vec<u8> {
364        let mut msg = Vec::new();
365        msg.extend_from_slice(&height.to_le_bytes());
366        msg.extend_from_slice(batch_hash);
367        msg
368    }
369
370    fn compute_redistribution(
371        &self,
372        slashed_validator: &[u8],
373        total_slashed: u64,
374    ) -> Result<Vec<(Vec<u8>, u64)>, String> {
375        let current_height = self.current_height;
376
377        // Collect all eligible recipients sorted deterministically by pubkey so every
378        // validator node computes the same dust distribution.
379        let mut active_validators: Vec<_> = self
380            .validators
381            .iter()
382            .filter(|(pk, stake)| {
383                pk.as_slice() != slashed_validator && stake.is_active(current_height)
384            })
385            .map(|(pk, stake)| (pk.clone(), stake.active_stake))
386            .collect();
387
388        if active_validators.is_empty() {
389            if let Some(treasury_id) = self.treasury_account_id {
390                tracing::info!(
391                    "  No active validators to redistribute slashed stake - sending to treasury"
392                );
393                return Ok(vec![(treasury_id.to_vec(), total_slashed)]);
394            }
395            tracing::warn!("  No active validators to redistribute slashed stake - tokens burned");
396            return Ok(Vec::new());
397        }
398
399        // Sort deterministically so dust assignment is reproducible across all nodes.
400        active_validators.sort_by(|(a, _), (b, _)| a.cmp(b));
401
402        let total_active_stake: u64 = active_validators.iter().map(|(_, s)| *s).sum();
403        if total_active_stake == 0 {
404            return Ok(Vec::new());
405        }
406
407        // Phase 1: proportional distribution (floor of each share).
408        let mut shares: Vec<(Vec<u8>, u64)> = active_validators
409            .iter()
410            .map(|(pk, validator_stake)| {
411                let share = ((total_slashed as u128 * *validator_stake as u128)
412                    / total_active_stake as u128) as u64;
413                (pk.clone(), share)
414            })
415            .collect();
416
417        let allocated: u64 = shares.iter().map(|(_, s)| s).sum();
418        let mut dust = total_slashed.saturating_sub(allocated);
419
420        // Phase 2: distribute dust one token at a time round-robin by stake descending
421        // (largest stake holders get the first dust tokens - maximally fair because
422        //  they hold the most proportional entitlement that was truncated by floor division).
423        if dust > 0 {
424            // Sort by stake descending for dust allocation.
425            let mut dust_order: Vec<usize> = (0..shares.len()).collect();
426            dust_order.sort_by(|&a, &b| active_validators[b].1.cmp(&active_validators[a].1));
427            let mut idx = 0;
428            while dust > 0 {
429                let slot = dust_order[idx % dust_order.len()];
430                shares[slot].1 = shares[slot].1.saturating_add(1);
431                dust -= 1;
432                idx += 1;
433            }
434        }
435
436        // Phase 3: apply all shares.
437        Ok(shares)
438    }
439
440    fn apply_redistribution(&mut self, shares: &[(Vec<u8>, u64)]) {
441        for (pk, share) in shares {
442            if *share == 0 {
443                continue;
444            }
445            if let Some(stake) = self.validators.get_mut(pk) {
446                stake.active_stake = stake.active_stake.saturating_add(*share);
447                tracing::info!(
448                    " Redistributed {} slashed tokens to validator {}",
449                    share,
450                    hex::encode(&pk[..8.min(pk.len())])
451                );
452            }
453        }
454    }
455
456    /// Slash a validator who committed to an oracle request then revealed fabricated data.
457    /// Cryptographic proof: the stored commit_hash != blake3(validator_pk || request_id || revealed_body).
458    /// This is called from apply_diff when a reveal fails hash verification.
459    pub fn slash_for_oracle_lie(&mut self, validator_pubkey: &[u8]) -> Result<u64, String> {
460        let outcome = self.slash_validator(validator_pubkey, SlashReason::OracleLie, None)?;
461        Ok(outcome.amount)
462    }
463
464    /// Slash every validator that committed to `request_id` but never revealed within
465    /// `ORACLE_REVEAL_DEADLINE_BLOCKS` blocks. Called from apply_diff when a request expires.
466    /// Returns a Vec of (validator_pubkey, amount_slashed).
467    pub fn slash_silent_oracle_validators(
468        &mut self,
469        request_id: [u8; 32],
470        committed_validators: &[Vec<u8>],
471        revealed_validators: &[Vec<u8>],
472    ) -> Vec<(Vec<u8>, u64)> {
473        let revealed: HashSet<Vec<u8>> = revealed_validators.iter().cloned().collect();
474        let mut silent: Vec<Vec<u8>> = committed_validators
475            .iter()
476            .filter(|pk| !revealed.contains(*pk))
477            .cloned()
478            .collect();
479        silent.sort();
480        silent.dedup();
481
482        let mut results = Vec::new();
483
484        for pk in silent {
485            match self.slash_validator(&pk, SlashReason::OracleSilence { request_id }, None) {
486                Ok(outcome) => {
487                    results.push((pk, outcome.amount));
488                }
489                Err(e) => {
490                    tracing::warn!(
491                        "Failed to slash silent oracle validator {}: {}",
492                        hex::encode(&pk[..8.min(pk.len())]),
493                        e
494                    );
495                }
496            }
497        }
498
499        results
500    }
501
502    pub fn unjail(&mut self, validator_pubkey: &[u8]) -> Result<(), String> {
503        let stake = self
504            .validators
505            .get_mut(validator_pubkey)
506            .ok_or("Validator not found")?;
507
508        if let Some(jail_height) = stake.jailed_until {
509            if self.current_height < jail_height {
510                return Err(format!("Still jailed until height {}", jail_height));
511            }
512        }
513
514        if stake.active_stake < MIN_VALIDATOR_STAKE {
515            return Err("Insufficient stake to unjail".to_string());
516        }
517
518        stake.jailed_until = None;
519
520        tracing::info!(
521            " Validator {} unjailed at height {}",
522            hex::encode(&validator_pubkey[..8]),
523            self.current_height
524        );
525
526        Ok(())
527    }
528}