Merge commit 'd803bfe2b1fe7f5e219e50ac20d6801a0a58ac75' as 'vendor/ruvector'
This commit is contained in:
456
vendor/ruvector/crates/ruvector-economy-wasm/src/stake.rs
vendored
Normal file
456
vendor/ruvector/crates/ruvector-economy-wasm/src/stake.rs
vendored
Normal file
@@ -0,0 +1,456 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user