1use 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 OracleLie,
25 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, }
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 #[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 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 if evidence.batch_hash_1 == evidence.batch_hash_2 {
327 return Err("Evidence must show two different batches".to_string());
328 }
329
330 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 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 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 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 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 if dust > 0 {
424 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 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 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 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}