457 lines
13 KiB
Rust
457 lines
13 KiB
Rust
//! Stake/Slash Mechanics
|
|
//!
|
|
//! Implements participation requirements and penalty system:
|
|
//! - Minimum stake to participate in network
|
|
//! - Slash conditions for bad behavior
|
|
//! - Stake delegation support
|
|
//! - Lock periods for stability
|
|
|
|
use rustc_hash::FxHashMap;
|
|
use serde::{Deserialize, Serialize};
|
|
use wasm_bindgen::prelude::*;
|
|
|
|
/// Get current timestamp in milliseconds (works in both WASM and native)
|
|
fn current_timestamp_ms() -> u64 {
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
js_sys::Date::now() as u64
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_millis() as u64
|
|
}
|
|
}
|
|
|
|
/// Reasons for slashing stake
|
|
#[wasm_bindgen]
|
|
#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug)]
|
|
pub enum SlashReason {
|
|
/// Invalid task result
|
|
InvalidResult = 0,
|
|
/// Double-spending attempt
|
|
DoubleSpend = 1,
|
|
/// Sybil attack detected
|
|
SybilAttack = 2,
|
|
/// Excessive downtime
|
|
Downtime = 3,
|
|
/// Spam/flooding
|
|
Spam = 4,
|
|
/// Malicious behavior
|
|
Malicious = 5,
|
|
}
|
|
|
|
impl SlashReason {
|
|
/// Get slash percentage for this reason
|
|
pub fn slash_percentage(&self) -> f32 {
|
|
match self {
|
|
SlashReason::InvalidResult => 0.05, // 5% for errors
|
|
SlashReason::DoubleSpend => 1.0, // 100% for fraud
|
|
SlashReason::SybilAttack => 0.5, // 50% for sybil
|
|
SlashReason::Downtime => 0.01, // 1% for downtime
|
|
SlashReason::Spam => 0.1, // 10% for spam
|
|
SlashReason::Malicious => 0.75, // 75% for malicious
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Stake entry for a node
|
|
#[derive(Clone, Serialize, Deserialize, Debug)]
|
|
pub struct StakeEntry {
|
|
/// Staked amount
|
|
pub amount: u64,
|
|
/// Lock timestamp (Unix ms) - cannot unstake before this
|
|
pub locked_until: u64,
|
|
/// Delegated stake (from other nodes)
|
|
pub delegated: u64,
|
|
/// Nodes that delegated to this one
|
|
pub delegators: Vec<String>,
|
|
/// Slash history
|
|
pub slashes: Vec<SlashEvent>,
|
|
}
|
|
|
|
/// Record of a slash event
|
|
#[derive(Clone, Serialize, Deserialize, Debug)]
|
|
pub struct SlashEvent {
|
|
/// Amount slashed
|
|
pub amount: u64,
|
|
/// Reason for slash
|
|
pub reason: SlashReason,
|
|
/// Timestamp
|
|
pub timestamp: u64,
|
|
/// Evidence (task ID, etc.)
|
|
pub evidence: String,
|
|
}
|
|
|
|
/// Stake manager for the network
|
|
#[wasm_bindgen]
|
|
pub struct StakeManager {
|
|
/// Stakes by node ID
|
|
stakes: FxHashMap<String, StakeEntry>,
|
|
/// Minimum stake to participate
|
|
min_stake: u64,
|
|
/// Default lock period in milliseconds
|
|
default_lock_period: u64,
|
|
/// Total staked across network
|
|
total_staked: u64,
|
|
/// Total slashed
|
|
total_slashed: u64,
|
|
}
|
|
|
|
#[wasm_bindgen]
|
|
impl StakeManager {
|
|
/// Create a new stake manager
|
|
#[wasm_bindgen(constructor)]
|
|
pub fn new() -> StakeManager {
|
|
StakeManager {
|
|
stakes: FxHashMap::default(),
|
|
min_stake: 100, // 100 credits minimum
|
|
default_lock_period: 86_400_000, // 24 hours in ms
|
|
total_staked: 0,
|
|
total_slashed: 0,
|
|
}
|
|
}
|
|
|
|
/// Create with custom parameters
|
|
#[wasm_bindgen(js_name = newWithParams)]
|
|
pub fn new_with_params(min_stake: u64, lock_period_ms: u64) -> StakeManager {
|
|
StakeManager {
|
|
stakes: FxHashMap::default(),
|
|
min_stake,
|
|
default_lock_period: lock_period_ms,
|
|
total_staked: 0,
|
|
total_slashed: 0,
|
|
}
|
|
}
|
|
|
|
/// Get minimum stake requirement
|
|
#[wasm_bindgen(js_name = minStake)]
|
|
pub fn min_stake(&self) -> u64 {
|
|
self.min_stake
|
|
}
|
|
|
|
/// Get total network staked
|
|
#[wasm_bindgen(js_name = totalStaked)]
|
|
pub fn total_staked(&self) -> u64 {
|
|
self.total_staked
|
|
}
|
|
|
|
/// Get total slashed
|
|
#[wasm_bindgen(js_name = totalSlashed)]
|
|
pub fn total_slashed(&self) -> u64 {
|
|
self.total_slashed
|
|
}
|
|
|
|
/// Get stake for a node
|
|
#[wasm_bindgen(js_name = getStake)]
|
|
pub fn get_stake(&self, node_id: &str) -> u64 {
|
|
self.stakes.get(node_id).map(|s| s.amount).unwrap_or(0)
|
|
}
|
|
|
|
/// Get effective stake (own + delegated)
|
|
#[wasm_bindgen(js_name = getEffectiveStake)]
|
|
pub fn get_effective_stake(&self, node_id: &str) -> u64 {
|
|
self.stakes
|
|
.get(node_id)
|
|
.map(|s| s.amount + s.delegated)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
/// Check if node meets minimum stake
|
|
#[wasm_bindgen(js_name = meetsMinimum)]
|
|
pub fn meets_minimum(&self, node_id: &str) -> bool {
|
|
self.get_effective_stake(node_id) >= self.min_stake
|
|
}
|
|
|
|
/// Stake credits for a node
|
|
#[wasm_bindgen]
|
|
pub fn stake(&mut self, node_id: &str, amount: u64) -> Result<(), JsValue> {
|
|
if amount == 0 {
|
|
return Err(JsValue::from_str("Amount must be positive"));
|
|
}
|
|
|
|
let now = current_timestamp_ms();
|
|
let locked_until = now + self.default_lock_period;
|
|
|
|
let entry = self
|
|
.stakes
|
|
.entry(node_id.to_string())
|
|
.or_insert_with(|| StakeEntry {
|
|
amount: 0,
|
|
locked_until: 0,
|
|
delegated: 0,
|
|
delegators: Vec::new(),
|
|
slashes: Vec::new(),
|
|
});
|
|
|
|
entry.amount += amount;
|
|
entry.locked_until = locked_until;
|
|
self.total_staked += amount;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Unstake credits (if lock period has passed)
|
|
#[wasm_bindgen]
|
|
pub fn unstake(&mut self, node_id: &str, amount: u64) -> Result<u64, JsValue> {
|
|
let now = current_timestamp_ms();
|
|
|
|
let entry = self
|
|
.stakes
|
|
.get_mut(node_id)
|
|
.ok_or_else(|| JsValue::from_str("No stake found"))?;
|
|
|
|
if now < entry.locked_until {
|
|
return Err(JsValue::from_str("Stake is locked"));
|
|
}
|
|
|
|
if amount > entry.amount {
|
|
return Err(JsValue::from_str("Insufficient stake"));
|
|
}
|
|
|
|
entry.amount -= amount;
|
|
self.total_staked -= amount;
|
|
|
|
Ok(amount)
|
|
}
|
|
|
|
/// Slash stake for bad behavior
|
|
#[wasm_bindgen]
|
|
pub fn slash(
|
|
&mut self,
|
|
node_id: &str,
|
|
reason: SlashReason,
|
|
evidence: &str,
|
|
) -> Result<u64, JsValue> {
|
|
let now = current_timestamp_ms();
|
|
|
|
let entry = self
|
|
.stakes
|
|
.get_mut(node_id)
|
|
.ok_or_else(|| JsValue::from_str("No stake found"))?;
|
|
|
|
// Calculate slash amount
|
|
let slash_pct = reason.slash_percentage();
|
|
let slash_amount = ((entry.amount as f64) * (slash_pct as f64)) as u64;
|
|
|
|
// Apply slash
|
|
entry.amount = entry.amount.saturating_sub(slash_amount);
|
|
self.total_staked -= slash_amount;
|
|
self.total_slashed += slash_amount;
|
|
|
|
// Record event
|
|
entry.slashes.push(SlashEvent {
|
|
amount: slash_amount,
|
|
reason,
|
|
timestamp: now,
|
|
evidence: evidence.to_string(),
|
|
});
|
|
|
|
Ok(slash_amount)
|
|
}
|
|
|
|
/// Delegate stake to another node
|
|
#[wasm_bindgen]
|
|
pub fn delegate(&mut self, from_node: &str, to_node: &str, amount: u64) -> Result<(), JsValue> {
|
|
// Verify from_node has sufficient stake
|
|
let from_entry = self
|
|
.stakes
|
|
.get_mut(from_node)
|
|
.ok_or_else(|| JsValue::from_str("Delegator has no stake"))?;
|
|
|
|
if from_entry.amount < amount {
|
|
return Err(JsValue::from_str("Insufficient stake to delegate"));
|
|
}
|
|
|
|
// Reduce from_node stake
|
|
from_entry.amount -= amount;
|
|
|
|
// Add to to_node delegated
|
|
let to_entry = self
|
|
.stakes
|
|
.entry(to_node.to_string())
|
|
.or_insert_with(|| StakeEntry {
|
|
amount: 0,
|
|
locked_until: 0,
|
|
delegated: 0,
|
|
delegators: Vec::new(),
|
|
slashes: Vec::new(),
|
|
});
|
|
|
|
to_entry.delegated += amount;
|
|
if !to_entry.delegators.contains(&from_node.to_string()) {
|
|
to_entry.delegators.push(from_node.to_string());
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Undelegate stake
|
|
#[wasm_bindgen]
|
|
pub fn undelegate(
|
|
&mut self,
|
|
from_node: &str,
|
|
to_node: &str,
|
|
amount: u64,
|
|
) -> Result<(), JsValue> {
|
|
// Reduce delegated from to_node
|
|
let to_entry = self
|
|
.stakes
|
|
.get_mut(to_node)
|
|
.ok_or_else(|| JsValue::from_str("Target node not found"))?;
|
|
|
|
if to_entry.delegated < amount {
|
|
return Err(JsValue::from_str("Insufficient delegated amount"));
|
|
}
|
|
|
|
to_entry.delegated -= amount;
|
|
|
|
// Return to from_node
|
|
let from_entry = self
|
|
.stakes
|
|
.entry(from_node.to_string())
|
|
.or_insert_with(|| StakeEntry {
|
|
amount: 0,
|
|
locked_until: 0,
|
|
delegated: 0,
|
|
delegators: Vec::new(),
|
|
slashes: Vec::new(),
|
|
});
|
|
|
|
from_entry.amount += amount;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get lock timestamp for a node
|
|
#[wasm_bindgen(js_name = getLockTimestamp)]
|
|
pub fn get_lock_timestamp(&self, node_id: &str) -> u64 {
|
|
self.stakes
|
|
.get(node_id)
|
|
.map(|s| s.locked_until)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
/// Check if stake is locked
|
|
#[wasm_bindgen(js_name = isLocked)]
|
|
pub fn is_locked(&self, node_id: &str) -> bool {
|
|
let now = current_timestamp_ms();
|
|
self.stakes
|
|
.get(node_id)
|
|
.map(|s| now < s.locked_until)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Get slash count for a node
|
|
#[wasm_bindgen(js_name = getSlashCount)]
|
|
pub fn get_slash_count(&self, node_id: &str) -> usize {
|
|
self.stakes
|
|
.get(node_id)
|
|
.map(|s| s.slashes.len())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
/// Get total amount slashed from a node
|
|
#[wasm_bindgen(js_name = getNodeTotalSlashed)]
|
|
pub fn get_node_total_slashed(&self, node_id: &str) -> u64 {
|
|
self.stakes
|
|
.get(node_id)
|
|
.map(|s| s.slashes.iter().map(|e| e.amount).sum())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
/// Get delegator count
|
|
#[wasm_bindgen(js_name = getDelegatorCount)]
|
|
pub fn get_delegator_count(&self, node_id: &str) -> usize {
|
|
self.stakes
|
|
.get(node_id)
|
|
.map(|s| s.delegators.len())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
/// Get number of stakers
|
|
#[wasm_bindgen(js_name = stakerCount)]
|
|
pub fn staker_count(&self) -> usize {
|
|
self.stakes.len()
|
|
}
|
|
|
|
/// Export stake data as JSON
|
|
#[wasm_bindgen(js_name = exportJson)]
|
|
pub fn export_json(&self) -> String {
|
|
serde_json::to_string(&self.stakes).unwrap_or_else(|_| "{}".to_string())
|
|
}
|
|
}
|
|
|
|
impl Default for StakeManager {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_slash_percentages() {
|
|
assert!((SlashReason::InvalidResult.slash_percentage() - 0.05).abs() < 0.001);
|
|
assert!((SlashReason::DoubleSpend.slash_percentage() - 1.0).abs() < 0.001);
|
|
assert!((SlashReason::SybilAttack.slash_percentage() - 0.5).abs() < 0.001);
|
|
}
|
|
|
|
#[test]
|
|
fn test_default_params() {
|
|
let manager = StakeManager::new();
|
|
assert_eq!(manager.min_stake(), 100);
|
|
}
|
|
|
|
// Tests that use JsValue-returning functions must be gated for WASM
|
|
#[cfg(target_arch = "wasm32")]
|
|
mod wasm_tests {
|
|
use super::*;
|
|
use wasm_bindgen_test::*;
|
|
|
|
#[wasm_bindgen_test]
|
|
fn test_stake_and_unstake() {
|
|
let mut manager = StakeManager::new();
|
|
|
|
manager.stake("node-1", 500).unwrap();
|
|
assert_eq!(manager.get_stake("node-1"), 500);
|
|
assert_eq!(manager.total_staked(), 500);
|
|
assert!(manager.meets_minimum("node-1"));
|
|
|
|
// Cannot unstake immediately (locked)
|
|
assert!(manager.unstake("node-1", 100).is_err());
|
|
}
|
|
|
|
#[wasm_bindgen_test]
|
|
fn test_slash() {
|
|
let mut manager = StakeManager::new();
|
|
manager.stake("node-1", 1000).unwrap();
|
|
|
|
// Slash for invalid result (5%)
|
|
let slashed = manager
|
|
.slash("node-1", SlashReason::InvalidResult, "task:123")
|
|
.unwrap();
|
|
assert_eq!(slashed, 50);
|
|
assert_eq!(manager.get_stake("node-1"), 950);
|
|
assert_eq!(manager.total_slashed(), 50);
|
|
}
|
|
|
|
#[wasm_bindgen_test]
|
|
fn test_delegation() {
|
|
let mut manager = StakeManager::new();
|
|
|
|
manager.stake("node-1", 1000).unwrap();
|
|
manager.delegate("node-1", "node-2", 300).unwrap();
|
|
|
|
assert_eq!(manager.get_stake("node-1"), 700);
|
|
assert_eq!(manager.get_effective_stake("node-2"), 300);
|
|
assert_eq!(manager.get_delegator_count("node-2"), 1);
|
|
}
|
|
}
|
|
}
|