Merge commit 'd803bfe2b1fe7f5e219e50ac20d6801a0a58ac75' as 'vendor/ruvector'
This commit is contained in:
245
vendor/ruvector/crates/ruvector-economy-wasm/src/curve.rs
vendored
Normal file
245
vendor/ruvector/crates/ruvector-economy-wasm/src/curve.rs
vendored
Normal file
@@ -0,0 +1,245 @@
|
||||
//! Contribution Curve for Early Adopter Rewards
|
||||
//!
|
||||
//! Implements an exponential decay curve that rewards early network participants
|
||||
//! with higher multipliers that decay as the network grows.
|
||||
//!
|
||||
//! ```text
|
||||
//! Multiplier
|
||||
//! 10x |*
|
||||
//! | *
|
||||
//! 8x | *
|
||||
//! | *
|
||||
//! 6x | *
|
||||
//! | *
|
||||
//! 4x | *
|
||||
//! | **
|
||||
//! 2x | ***
|
||||
//! | *****
|
||||
//! 1x | ****************************
|
||||
//! +--+--+--+--+--+--+--+--+--+--+--+--+--+--+---> Network Compute (M hours)
|
||||
//! 0 1 2 3 4 5 6 7 8 9 10
|
||||
//! ```
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// Contribution curve calculator for early adopter rewards
|
||||
///
|
||||
/// The multiplier follows an exponential decay formula:
|
||||
/// ```text
|
||||
/// multiplier = 1 + (MAX_BONUS - 1) * e^(-network_compute / DECAY_CONSTANT)
|
||||
/// ```
|
||||
///
|
||||
/// This ensures:
|
||||
/// - Genesis contributors (0 compute) get MAX_BONUS (10x)
|
||||
/// - At DECAY_CONSTANT compute hours, bonus is ~37% remaining (~4.3x)
|
||||
/// - At very high compute, approaches baseline (1x)
|
||||
/// - Never goes below 1x
|
||||
pub struct ContributionCurve;
|
||||
|
||||
impl ContributionCurve {
|
||||
/// Maximum multiplier for genesis contributors
|
||||
pub const MAX_BONUS: f32 = 10.0;
|
||||
|
||||
/// Decay constant in CPU-hours (half-life of bonus decay)
|
||||
pub const DECAY_CONSTANT: f64 = 1_000_000.0;
|
||||
|
||||
/// Calculate current multiplier based on total network compute
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `network_compute_hours` - Total CPU-hours contributed to the network
|
||||
///
|
||||
/// # Returns
|
||||
/// A multiplier between 1.0 (baseline) and MAX_BONUS (genesis)
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use ruvector_economy_wasm::ContributionCurve;
|
||||
///
|
||||
/// // Genesis: 10x multiplier
|
||||
/// assert!((ContributionCurve::current_multiplier(0.0) - 10.0).abs() < 0.01);
|
||||
///
|
||||
/// // At 1M hours: ~4.3x multiplier
|
||||
/// let mult = ContributionCurve::current_multiplier(1_000_000.0);
|
||||
/// assert!(mult > 4.0 && mult < 4.5);
|
||||
///
|
||||
/// // At 10M hours: ~1.0x multiplier
|
||||
/// let mult = ContributionCurve::current_multiplier(10_000_000.0);
|
||||
/// assert!(mult < 1.1);
|
||||
/// ```
|
||||
pub fn current_multiplier(network_compute_hours: f64) -> f32 {
|
||||
let decay = (-network_compute_hours / Self::DECAY_CONSTANT).exp();
|
||||
1.0 + (Self::MAX_BONUS - 1.0) * decay as f32
|
||||
}
|
||||
|
||||
/// Calculate reward with multiplier applied
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `base_reward` - Base reward amount before multiplier
|
||||
/// * `network_compute_hours` - Total network compute for multiplier calculation
|
||||
///
|
||||
/// # Returns
|
||||
/// The reward amount with multiplier applied
|
||||
pub fn calculate_reward(base_reward: u64, network_compute_hours: f64) -> u64 {
|
||||
let multiplier = Self::current_multiplier(network_compute_hours);
|
||||
(base_reward as f32 * multiplier) as u64
|
||||
}
|
||||
|
||||
/// Get multiplier tier information for UI display
|
||||
///
|
||||
/// Returns a vector of (compute_hours, multiplier) tuples representing
|
||||
/// key milestones in the contribution curve.
|
||||
pub fn get_tiers() -> Vec<(f64, f32)> {
|
||||
vec![
|
||||
(0.0, 10.0),
|
||||
(100_000.0, 9.1),
|
||||
(500_000.0, 6.1),
|
||||
(1_000_000.0, 4.3),
|
||||
(2_000_000.0, 2.6),
|
||||
(5_000_000.0, 1.4),
|
||||
(10_000_000.0, 1.0),
|
||||
]
|
||||
}
|
||||
|
||||
/// Get the tier name based on network compute level
|
||||
pub fn get_tier_name(network_compute_hours: f64) -> &'static str {
|
||||
if network_compute_hours < 100_000.0 {
|
||||
"Genesis"
|
||||
} else if network_compute_hours < 500_000.0 {
|
||||
"Pioneer"
|
||||
} else if network_compute_hours < 1_000_000.0 {
|
||||
"Early Adopter"
|
||||
} else if network_compute_hours < 5_000_000.0 {
|
||||
"Established"
|
||||
} else {
|
||||
"Baseline"
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate time remaining until next tier
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `current_compute` - Current network compute hours
|
||||
/// * `hourly_growth` - Estimated hourly compute growth rate
|
||||
///
|
||||
/// # Returns
|
||||
/// Hours until next tier boundary, or None if at baseline
|
||||
pub fn hours_until_next_tier(current_compute: f64, hourly_growth: f64) -> Option<f64> {
|
||||
if hourly_growth <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let tiers = Self::get_tiers();
|
||||
for (threshold, _) in &tiers {
|
||||
if current_compute < *threshold {
|
||||
return Some((*threshold - current_compute) / hourly_growth);
|
||||
}
|
||||
}
|
||||
|
||||
None // Already at baseline
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate contribution multiplier (WASM export)
|
||||
///
|
||||
/// Returns the reward multiplier based on total network compute hours.
|
||||
/// Early adopters get up to 10x rewards, decaying to 1x as network grows.
|
||||
#[wasm_bindgen]
|
||||
pub fn contribution_multiplier(network_compute_hours: f64) -> f32 {
|
||||
ContributionCurve::current_multiplier(network_compute_hours)
|
||||
}
|
||||
|
||||
/// Calculate reward with multiplier (WASM export)
|
||||
#[wasm_bindgen]
|
||||
pub fn calculate_reward(base_reward: u64, network_compute_hours: f64) -> u64 {
|
||||
ContributionCurve::calculate_reward(base_reward, network_compute_hours)
|
||||
}
|
||||
|
||||
/// Get tier name based on compute level (WASM export)
|
||||
#[wasm_bindgen]
|
||||
pub fn get_tier_name(network_compute_hours: f64) -> String {
|
||||
ContributionCurve::get_tier_name(network_compute_hours).to_string()
|
||||
}
|
||||
|
||||
/// Get tier information as JSON (WASM export)
|
||||
#[wasm_bindgen]
|
||||
pub fn get_tiers_json() -> String {
|
||||
let tiers = ContributionCurve::get_tiers();
|
||||
let tier_objs: Vec<_> = tiers
|
||||
.iter()
|
||||
.map(|(hours, mult)| format!(r#"{{"hours":{},"multiplier":{:.1}}}"#, hours, mult))
|
||||
.collect();
|
||||
|
||||
format!("[{}]", tier_objs.join(","))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_genesis_multiplier() {
|
||||
let mult = ContributionCurve::current_multiplier(0.0);
|
||||
assert!(
|
||||
(mult - 10.0).abs() < 0.01,
|
||||
"Genesis should give 10x, got {}",
|
||||
mult
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decay_constant_multiplier() {
|
||||
// At decay constant, e^(-1) ~= 0.368
|
||||
// So multiplier = 1 + 9 * 0.368 = 4.31
|
||||
let mult = ContributionCurve::current_multiplier(1_000_000.0);
|
||||
assert!(
|
||||
mult > 4.0 && mult < 4.5,
|
||||
"At decay constant should be ~4.3x, got {}",
|
||||
mult
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_high_compute_baseline() {
|
||||
let mult = ContributionCurve::current_multiplier(10_000_000.0);
|
||||
assert!(mult < 1.1, "High compute should approach 1x, got {}", mult);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiplier_never_below_one() {
|
||||
let mult = ContributionCurve::current_multiplier(100_000_000.0);
|
||||
assert!(
|
||||
mult >= 1.0,
|
||||
"Multiplier should never go below 1, got {}",
|
||||
mult
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_reward() {
|
||||
let base = 100;
|
||||
let reward = ContributionCurve::calculate_reward(base, 0.0);
|
||||
assert_eq!(
|
||||
reward, 1000,
|
||||
"Genesis 100 base should give 1000, got {}",
|
||||
reward
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tier_names() {
|
||||
assert_eq!(ContributionCurve::get_tier_name(0.0), "Genesis");
|
||||
assert_eq!(ContributionCurve::get_tier_name(100_000.0), "Pioneer");
|
||||
assert_eq!(ContributionCurve::get_tier_name(500_000.0), "Early Adopter");
|
||||
assert_eq!(ContributionCurve::get_tier_name(1_000_000.0), "Established");
|
||||
assert_eq!(ContributionCurve::get_tier_name(10_000_000.0), "Baseline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wasm_export_functions() {
|
||||
assert!((contribution_multiplier(0.0) - 10.0).abs() < 0.01);
|
||||
assert_eq!(calculate_reward(100, 0.0), 1000);
|
||||
assert_eq!(get_tier_name(0.0), "Genesis");
|
||||
assert!(get_tiers_json().contains("Genesis") == false); // JSON format
|
||||
assert!(get_tiers_json().starts_with("["));
|
||||
}
|
||||
}
|
||||
493
vendor/ruvector/crates/ruvector-economy-wasm/src/ledger.rs
vendored
Normal file
493
vendor/ruvector/crates/ruvector-economy-wasm/src/ledger.rs
vendored
Normal file
@@ -0,0 +1,493 @@
|
||||
//! CRDT-based Credit Ledger
|
||||
//!
|
||||
//! Implements a conflict-free replicated data type (CRDT) ledger for P2P consistency.
|
||||
//! Uses G-Counters for earnings (monotonically increasing) and PN-Counters for spending.
|
||||
|
||||
use rustc_hash::FxHashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::curve::ContributionCurve;
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
||||
/// Credit event reasons for audit trail
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub enum CreditReason {
|
||||
/// Earned from completing a task
|
||||
TaskCompleted { task_id: String },
|
||||
/// Earned from uptime bonus
|
||||
UptimeReward { hours: f32 },
|
||||
/// Earned from referral
|
||||
Referral { referee: String },
|
||||
/// Staked for participation
|
||||
Stake { amount: u64, locked: bool },
|
||||
/// Transferred between nodes
|
||||
Transfer {
|
||||
from: String,
|
||||
to: String,
|
||||
memo: String,
|
||||
},
|
||||
/// Penalty for invalid work
|
||||
Penalty { reason: String },
|
||||
}
|
||||
|
||||
/// CRDT-based credit ledger for P2P consistency
|
||||
///
|
||||
/// The ledger uses two types of counters:
|
||||
/// - G-Counter (grow-only) for credits earned - safe for concurrent updates
|
||||
/// - PN-Counter (positive-negative) for credits spent - supports disputes
|
||||
///
|
||||
/// ```text
|
||||
/// Earned (G-Counter): Spent (PN-Counter):
|
||||
/// +----------------+ +--------------------+
|
||||
/// | event_1: 100 | | event_a: (50, 0) | <- (positive, negative)
|
||||
/// | event_2: 200 | | event_b: (30, 10) | <- disputed 10 returned
|
||||
/// | event_3: 150 | +--------------------+
|
||||
/// +----------------+
|
||||
///
|
||||
/// Balance = sum(earned) - sum(spent.positive - spent.negative) - staked
|
||||
/// ```
|
||||
#[wasm_bindgen]
|
||||
#[derive(Clone)]
|
||||
pub struct CreditLedger {
|
||||
/// Node identifier
|
||||
node_id: String,
|
||||
|
||||
/// G-Counter: monotonically increasing credits earned
|
||||
/// Key: event_id, Value: amount credited
|
||||
earned: FxHashMap<String, u64>,
|
||||
|
||||
/// PN-Counter: credits spent/penalized
|
||||
/// Key: event_id, Value: (positive_spent, negative_refund)
|
||||
spent: FxHashMap<String, (u64, u64)>,
|
||||
|
||||
/// Merkle root of current state for quick verification
|
||||
state_root: [u8; 32],
|
||||
|
||||
/// Total network compute hours (for multiplier calculation)
|
||||
network_compute: f64,
|
||||
|
||||
/// Staked credits (locked for participation)
|
||||
staked: u64,
|
||||
|
||||
/// Last sync timestamp (Unix ms)
|
||||
last_sync: u64,
|
||||
|
||||
/// Event counter for generating unique IDs
|
||||
event_counter: u64,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl CreditLedger {
|
||||
/// Create a new credit ledger for a node
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(node_id: String) -> Result<CreditLedger, JsValue> {
|
||||
if node_id.is_empty() {
|
||||
return Err(JsValue::from_str("Node ID cannot be empty"));
|
||||
}
|
||||
|
||||
Ok(CreditLedger {
|
||||
node_id,
|
||||
earned: FxHashMap::default(),
|
||||
spent: FxHashMap::default(),
|
||||
state_root: [0u8; 32],
|
||||
network_compute: 0.0,
|
||||
staked: 0,
|
||||
last_sync: 0,
|
||||
event_counter: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the node ID
|
||||
#[wasm_bindgen(js_name = nodeId)]
|
||||
pub fn node_id(&self) -> String {
|
||||
self.node_id.clone()
|
||||
}
|
||||
|
||||
/// Get current available balance (earned - spent - staked)
|
||||
#[wasm_bindgen]
|
||||
pub fn balance(&self) -> u64 {
|
||||
let total_earned: u64 = self.earned.values().sum();
|
||||
let total_spent: u64 = self
|
||||
.spent
|
||||
.values()
|
||||
.map(|(pos, neg)| pos.saturating_sub(*neg))
|
||||
.sum();
|
||||
|
||||
total_earned
|
||||
.saturating_sub(total_spent)
|
||||
.saturating_sub(self.staked)
|
||||
}
|
||||
|
||||
/// Get total credits ever earned (before spending)
|
||||
#[wasm_bindgen(js_name = totalEarned)]
|
||||
pub fn total_earned(&self) -> u64 {
|
||||
self.earned.values().sum()
|
||||
}
|
||||
|
||||
/// Get total credits spent
|
||||
#[wasm_bindgen(js_name = totalSpent)]
|
||||
pub fn total_spent(&self) -> u64 {
|
||||
self.spent
|
||||
.values()
|
||||
.map(|(pos, neg)| pos.saturating_sub(*neg))
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Get staked amount
|
||||
#[wasm_bindgen(js_name = stakedAmount)]
|
||||
pub fn staked_amount(&self) -> u64 {
|
||||
self.staked
|
||||
}
|
||||
|
||||
/// Get network compute hours
|
||||
#[wasm_bindgen(js_name = networkCompute)]
|
||||
pub fn network_compute(&self) -> f64 {
|
||||
self.network_compute
|
||||
}
|
||||
|
||||
/// Get current contribution multiplier
|
||||
#[wasm_bindgen(js_name = currentMultiplier)]
|
||||
pub fn current_multiplier(&self) -> f32 {
|
||||
ContributionCurve::current_multiplier(self.network_compute)
|
||||
}
|
||||
|
||||
/// Get the state root (Merkle root of ledger state)
|
||||
#[wasm_bindgen(js_name = stateRoot)]
|
||||
pub fn state_root(&self) -> Vec<u8> {
|
||||
self.state_root.to_vec()
|
||||
}
|
||||
|
||||
/// Get state root as hex string
|
||||
#[wasm_bindgen(js_name = stateRootHex)]
|
||||
pub fn state_root_hex(&self) -> String {
|
||||
self.state_root
|
||||
.iter()
|
||||
.map(|b| format!("{:02x}", b))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Credit the ledger (earn credits)
|
||||
///
|
||||
/// This updates the G-Counter which is monotonically increasing.
|
||||
/// Safe for concurrent P2P updates.
|
||||
#[wasm_bindgen]
|
||||
pub fn credit(&mut self, amount: u64, _reason: &str) -> Result<String, JsValue> {
|
||||
if amount == 0 {
|
||||
return Err(JsValue::from_str("Amount must be positive"));
|
||||
}
|
||||
|
||||
// Generate unique event ID
|
||||
self.event_counter += 1;
|
||||
let event_id = format!("{}:{}", self.node_id, self.event_counter);
|
||||
|
||||
// Update G-Counter
|
||||
self.earned.insert(event_id.clone(), amount);
|
||||
|
||||
// Update state root
|
||||
self.recompute_state_root();
|
||||
|
||||
Ok(event_id)
|
||||
}
|
||||
|
||||
/// Credit with multiplier applied (for task rewards)
|
||||
#[wasm_bindgen(js_name = creditWithMultiplier)]
|
||||
pub fn credit_with_multiplier(
|
||||
&mut self,
|
||||
base_amount: u64,
|
||||
reason: &str,
|
||||
) -> Result<String, JsValue> {
|
||||
let multiplier = self.current_multiplier();
|
||||
let amount = (base_amount as f32 * multiplier) as u64;
|
||||
self.credit(amount, reason)
|
||||
}
|
||||
|
||||
/// Deduct from the ledger (spend credits)
|
||||
///
|
||||
/// This updates the PN-Counter positive side.
|
||||
/// Spending can be disputed/refunded by updating the negative side.
|
||||
#[wasm_bindgen]
|
||||
pub fn deduct(&mut self, amount: u64) -> Result<String, JsValue> {
|
||||
if self.balance() < amount {
|
||||
return Err(JsValue::from_str("Insufficient balance"));
|
||||
}
|
||||
|
||||
// Generate unique event ID
|
||||
self.event_counter += 1;
|
||||
let event_id = format!("{}:{}", self.node_id, self.event_counter);
|
||||
|
||||
// Update PN-Counter (positive side)
|
||||
self.spent.insert(event_id.clone(), (amount, 0));
|
||||
|
||||
// Update state root
|
||||
self.recompute_state_root();
|
||||
|
||||
Ok(event_id)
|
||||
}
|
||||
|
||||
/// Refund a previous deduction (dispute resolution)
|
||||
///
|
||||
/// This updates the PN-Counter negative side for the given event.
|
||||
#[wasm_bindgen]
|
||||
pub fn refund(&mut self, event_id: &str, amount: u64) -> Result<(), JsValue> {
|
||||
let entry = self
|
||||
.spent
|
||||
.get_mut(event_id)
|
||||
.ok_or_else(|| JsValue::from_str("Event not found"))?;
|
||||
|
||||
if entry.1 + amount > entry.0 {
|
||||
return Err(JsValue::from_str("Refund exceeds original spend"));
|
||||
}
|
||||
|
||||
entry.1 += amount;
|
||||
self.recompute_state_root();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stake credits for participation
|
||||
#[wasm_bindgen]
|
||||
pub fn stake(&mut self, amount: u64) -> Result<(), JsValue> {
|
||||
if self.balance() < amount {
|
||||
return Err(JsValue::from_str("Insufficient balance for stake"));
|
||||
}
|
||||
|
||||
self.staked += amount;
|
||||
self.recompute_state_root();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unstake credits
|
||||
#[wasm_bindgen]
|
||||
pub fn unstake(&mut self, amount: u64) -> Result<(), JsValue> {
|
||||
if self.staked < amount {
|
||||
return Err(JsValue::from_str("Insufficient staked amount"));
|
||||
}
|
||||
|
||||
self.staked -= amount;
|
||||
self.recompute_state_root();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Slash staked credits (penalty for bad behavior)
|
||||
///
|
||||
/// Returns the actual amount slashed (may be less if stake is insufficient)
|
||||
#[wasm_bindgen]
|
||||
pub fn slash(&mut self, amount: u64) -> Result<u64, JsValue> {
|
||||
let slash_amount = amount.min(self.staked);
|
||||
self.staked -= slash_amount;
|
||||
self.recompute_state_root();
|
||||
|
||||
Ok(slash_amount)
|
||||
}
|
||||
|
||||
/// Update network compute hours (from P2P sync)
|
||||
#[wasm_bindgen(js_name = updateNetworkCompute)]
|
||||
pub fn update_network_compute(&mut self, hours: f64) {
|
||||
self.network_compute = hours;
|
||||
}
|
||||
|
||||
/// Merge with another ledger (CRDT merge operation)
|
||||
///
|
||||
/// This is the core CRDT operation - associative, commutative, and idempotent.
|
||||
/// Safe to apply in any order with any number of concurrent updates.
|
||||
#[wasm_bindgen]
|
||||
pub fn merge(&mut self, other_earned: &[u8], other_spent: &[u8]) -> Result<u32, JsValue> {
|
||||
let mut merged_count = 0u32;
|
||||
|
||||
// Deserialize and merge earned counter (G-Counter: take max)
|
||||
let earned_map: FxHashMap<String, u64> = serde_json::from_slice(other_earned)
|
||||
.map_err(|e| JsValue::from_str(&format!("Failed to parse earned: {}", e)))?;
|
||||
|
||||
for (key, value) in earned_map {
|
||||
let entry = self.earned.entry(key).or_insert(0);
|
||||
if value > *entry {
|
||||
*entry = value;
|
||||
merged_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Deserialize and merge spent counter (PN-Counter: take max of each component)
|
||||
let spent_map: FxHashMap<String, (u64, u64)> = serde_json::from_slice(other_spent)
|
||||
.map_err(|e| JsValue::from_str(&format!("Failed to parse spent: {}", e)))?;
|
||||
|
||||
for (key, (pos, neg)) in spent_map {
|
||||
let entry = self.spent.entry(key).or_insert((0, 0));
|
||||
if pos > entry.0 || neg > entry.1 {
|
||||
entry.0 = entry.0.max(pos);
|
||||
entry.1 = entry.1.max(neg);
|
||||
merged_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Update state and timestamp
|
||||
self.recompute_state_root();
|
||||
self.last_sync = current_timestamp_ms();
|
||||
|
||||
Ok(merged_count)
|
||||
}
|
||||
|
||||
/// Export earned counter for P2P sync
|
||||
#[wasm_bindgen(js_name = exportEarned)]
|
||||
pub fn export_earned(&self) -> Result<Vec<u8>, JsValue> {
|
||||
serde_json::to_vec(&self.earned)
|
||||
.map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
|
||||
}
|
||||
|
||||
/// Export spent counter for P2P sync
|
||||
#[wasm_bindgen(js_name = exportSpent)]
|
||||
pub fn export_spent(&self) -> Result<Vec<u8>, JsValue> {
|
||||
serde_json::to_vec(&self.spent)
|
||||
.map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
|
||||
}
|
||||
|
||||
/// Get event count
|
||||
#[wasm_bindgen(js_name = eventCount)]
|
||||
pub fn event_count(&self) -> usize {
|
||||
self.earned.len() + self.spent.len()
|
||||
}
|
||||
|
||||
/// Verify state root matches current state
|
||||
#[wasm_bindgen(js_name = verifyStateRoot)]
|
||||
pub fn verify_state_root(&self, expected_root: &[u8]) -> bool {
|
||||
if expected_root.len() != 32 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut expected = [0u8; 32];
|
||||
expected.copy_from_slice(expected_root);
|
||||
|
||||
self.state_root == expected
|
||||
}
|
||||
|
||||
/// Recompute the Merkle state root
|
||||
fn recompute_state_root(&mut self) {
|
||||
let mut hasher = Sha256::new();
|
||||
|
||||
// Hash earned entries (sorted for determinism)
|
||||
let mut earned_keys: Vec<_> = self.earned.keys().collect();
|
||||
earned_keys.sort();
|
||||
for key in earned_keys {
|
||||
hasher.update(key.as_bytes());
|
||||
hasher.update(&self.earned[key].to_le_bytes());
|
||||
}
|
||||
|
||||
// Hash spent entries (sorted for determinism)
|
||||
let mut spent_keys: Vec<_> = self.spent.keys().collect();
|
||||
spent_keys.sort();
|
||||
for key in spent_keys {
|
||||
let (pos, neg) = self.spent[key];
|
||||
hasher.update(key.as_bytes());
|
||||
hasher.update(&pos.to_le_bytes());
|
||||
hasher.update(&neg.to_le_bytes());
|
||||
}
|
||||
|
||||
// Hash staked amount
|
||||
hasher.update(&self.staked.to_le_bytes());
|
||||
|
||||
// Finalize
|
||||
self.state_root = hasher.finalize().into();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// All ledger tests require JsValue which only works in WASM
|
||||
// Native tests are in curve.rs and reputation.rs
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod wasm_tests {
|
||||
use super::super::*;
|
||||
use wasm_bindgen_test::*;
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_ledger_creation() {
|
||||
let ledger = CreditLedger::new("node-1".to_string()).unwrap();
|
||||
assert_eq!(ledger.node_id(), "node-1");
|
||||
assert_eq!(ledger.balance(), 0);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_empty_node_id_rejected() {
|
||||
let result = CreditLedger::new("".to_string());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_credit_and_deduct() {
|
||||
let mut ledger = CreditLedger::new("node-1".to_string()).unwrap();
|
||||
|
||||
ledger.credit(100, "task:1").unwrap();
|
||||
assert_eq!(ledger.balance(), 100);
|
||||
assert_eq!(ledger.total_earned(), 100);
|
||||
|
||||
ledger.deduct(30).unwrap();
|
||||
assert_eq!(ledger.balance(), 70);
|
||||
assert_eq!(ledger.total_spent(), 30);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_insufficient_balance() {
|
||||
let mut ledger = CreditLedger::new("node-1".to_string()).unwrap();
|
||||
ledger.credit(50, "task:1").unwrap();
|
||||
|
||||
let result = ledger.deduct(100);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_stake_and_slash() {
|
||||
let mut ledger = CreditLedger::new("node-1".to_string()).unwrap();
|
||||
ledger.credit(200, "task:1").unwrap();
|
||||
|
||||
ledger.stake(100).unwrap();
|
||||
assert_eq!(ledger.balance(), 100);
|
||||
assert_eq!(ledger.staked_amount(), 100);
|
||||
|
||||
let slashed = ledger.slash(30).unwrap();
|
||||
assert_eq!(slashed, 30);
|
||||
assert_eq!(ledger.staked_amount(), 70);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_refund() {
|
||||
let mut ledger = CreditLedger::new("node-1".to_string()).unwrap();
|
||||
ledger.credit(100, "task:1").unwrap();
|
||||
|
||||
let event_id = ledger.deduct(50).unwrap();
|
||||
assert_eq!(ledger.balance(), 50);
|
||||
|
||||
ledger.refund(&event_id, 20).unwrap();
|
||||
assert_eq!(ledger.balance(), 70);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_state_root_changes() {
|
||||
let mut ledger = CreditLedger::new("node-1".to_string()).unwrap();
|
||||
let initial_root = ledger.state_root();
|
||||
|
||||
ledger.credit(100, "task:1").unwrap();
|
||||
let after_credit = ledger.state_root();
|
||||
|
||||
assert_ne!(initial_root, after_credit);
|
||||
}
|
||||
}
|
||||
}
|
||||
92
vendor/ruvector/crates/ruvector-economy-wasm/src/lib.rs
vendored
Normal file
92
vendor/ruvector/crates/ruvector-economy-wasm/src/lib.rs
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
//! # ruvector-economy-wasm
|
||||
//!
|
||||
//! A CRDT-based autonomous credit economy for distributed compute networks.
|
||||
//! Designed for WASM execution with P2P consistency guarantees.
|
||||
//!
|
||||
//! ## Features
|
||||
//!
|
||||
//! - **CRDT Ledger**: G-Counter and PN-Counter for P2P-safe credit tracking
|
||||
//! - **Contribution Curve**: 10x early adopter multiplier decaying to 1x baseline
|
||||
//! - **Stake/Slash Mechanics**: Participation requirements with slashing for bad actors
|
||||
//! - **Reputation Scoring**: Multi-factor reputation based on accuracy, uptime, and stake
|
||||
//! - **Merkle Verification**: State root for quick ledger verification
|
||||
//!
|
||||
//! ## Quick Start (JavaScript)
|
||||
//!
|
||||
//! ```javascript
|
||||
//! import { CreditLedger, ReputationScore, contribution_multiplier } from '@ruvector/economy-wasm';
|
||||
//!
|
||||
//! // Create a new ledger for a node
|
||||
//! const ledger = new CreditLedger("node-123");
|
||||
//!
|
||||
//! // Earn credits
|
||||
//! ledger.credit(100, "task:abc");
|
||||
//! console.log(`Balance: ${ledger.balance()}`);
|
||||
//!
|
||||
//! // Check multiplier for early adopters
|
||||
//! const mult = contribution_multiplier(50000.0); // 50K network compute hours
|
||||
//! console.log(`Multiplier: ${mult}x`); // ~8.5x
|
||||
//!
|
||||
//! // Track reputation
|
||||
//! const rep = new ReputationScore(0.95, 0.98, 1000);
|
||||
//! console.log(`Composite score: ${rep.composite_score()}`);
|
||||
//! ```
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! +------------------------+
|
||||
//! | CreditLedger | <-- CRDT-based P2P-safe ledger
|
||||
//! | +------------------+ |
|
||||
//! | | G-Counter: Earned| | <-- Monotonically increasing
|
||||
//! | | PN-Counter: Spent| | <-- Can handle disputes
|
||||
//! | | Stake: Locked | | <-- Participation requirement
|
||||
//! | | State Root | | <-- Merkle root for verification
|
||||
//! | +------------------+ |
|
||||
//! +------------------------+
|
||||
//! |
|
||||
//! v
|
||||
//! +------------------------+
|
||||
//! | ContributionCurve | <-- Exponential decay: 10x -> 1x
|
||||
//! +------------------------+
|
||||
//! |
|
||||
//! v
|
||||
//! +------------------------+
|
||||
//! | ReputationScore | <-- accuracy * uptime * stake_weight
|
||||
//! +------------------------+
|
||||
//! ```
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
pub mod curve;
|
||||
pub mod ledger;
|
||||
pub mod reputation;
|
||||
pub mod stake;
|
||||
|
||||
pub use curve::{contribution_multiplier, ContributionCurve};
|
||||
pub use ledger::CreditLedger;
|
||||
pub use reputation::ReputationScore;
|
||||
pub use stake::{SlashReason, StakeManager};
|
||||
|
||||
/// Initialize panic hook for better error messages in console
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn init_panic_hook() {
|
||||
#[cfg(feature = "console_error_panic_hook")]
|
||||
console_error_panic_hook::set_once();
|
||||
}
|
||||
|
||||
/// Get the current version of the economy module
|
||||
#[wasm_bindgen]
|
||||
pub fn version() -> String {
|
||||
env!("CARGO_PKG_VERSION").to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_version() {
|
||||
assert_eq!(version(), "0.1.0");
|
||||
}
|
||||
}
|
||||
375
vendor/ruvector/crates/ruvector-economy-wasm/src/reputation.rs
vendored
Normal file
375
vendor/ruvector/crates/ruvector-economy-wasm/src/reputation.rs
vendored
Normal file
@@ -0,0 +1,375 @@
|
||||
//! Reputation Scoring System
|
||||
//!
|
||||
//! Multi-factor reputation based on:
|
||||
//! - Accuracy: Success rate of completed tasks
|
||||
//! - Uptime: Availability and reliability
|
||||
//! - Stake: Skin in the game (economic commitment)
|
||||
//!
|
||||
//! The composite score determines task priority and trust level.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// Reputation score for a network participant
|
||||
///
|
||||
/// Combines multiple factors into a single trust score:
|
||||
/// - accuracy: 0.0 to 1.0 (success rate of verified tasks)
|
||||
/// - uptime: 0.0 to 1.0 (availability ratio)
|
||||
/// - stake: absolute stake amount (economic commitment)
|
||||
///
|
||||
/// The composite score is weighted:
|
||||
/// ```text
|
||||
/// composite = accuracy^2 * uptime * stake_weight
|
||||
///
|
||||
/// where stake_weight = min(1.0, log10(stake + 1) / 6)
|
||||
/// ```
|
||||
///
|
||||
/// This ensures:
|
||||
/// - Accuracy is most important (squared)
|
||||
/// - Uptime provides linear scaling
|
||||
/// - Stake has diminishing returns (log scale)
|
||||
#[wasm_bindgen]
|
||||
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
|
||||
pub struct ReputationScore {
|
||||
/// Task success rate (0.0 - 1.0)
|
||||
accuracy: f32,
|
||||
/// Network availability (0.0 - 1.0)
|
||||
uptime: f32,
|
||||
/// Staked credits
|
||||
stake: u64,
|
||||
/// Number of completed tasks
|
||||
tasks_completed: u64,
|
||||
/// Number of failed/disputed tasks
|
||||
tasks_failed: u64,
|
||||
/// Total uptime in seconds
|
||||
uptime_seconds: u64,
|
||||
/// Total possible uptime in seconds (since registration)
|
||||
total_seconds: u64,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl ReputationScore {
|
||||
/// Create a new reputation score
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(accuracy: f32, uptime: f32, stake: u64) -> ReputationScore {
|
||||
ReputationScore {
|
||||
accuracy: accuracy.clamp(0.0, 1.0),
|
||||
uptime: uptime.clamp(0.0, 1.0),
|
||||
stake,
|
||||
tasks_completed: 0,
|
||||
tasks_failed: 0,
|
||||
uptime_seconds: 0,
|
||||
total_seconds: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with detailed tracking
|
||||
#[wasm_bindgen(js_name = newWithTracking)]
|
||||
pub fn new_with_tracking(
|
||||
tasks_completed: u64,
|
||||
tasks_failed: u64,
|
||||
uptime_seconds: u64,
|
||||
total_seconds: u64,
|
||||
stake: u64,
|
||||
) -> ReputationScore {
|
||||
let accuracy = if tasks_completed + tasks_failed > 0 {
|
||||
tasks_completed as f32 / (tasks_completed + tasks_failed) as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let uptime = if total_seconds > 0 {
|
||||
(uptime_seconds as f32 / total_seconds as f32).min(1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
ReputationScore {
|
||||
accuracy,
|
||||
uptime,
|
||||
stake,
|
||||
tasks_completed,
|
||||
tasks_failed,
|
||||
uptime_seconds,
|
||||
total_seconds,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get accuracy score (0.0 - 1.0)
|
||||
#[wasm_bindgen(getter)]
|
||||
pub fn accuracy(&self) -> f32 {
|
||||
self.accuracy
|
||||
}
|
||||
|
||||
/// Get uptime score (0.0 - 1.0)
|
||||
#[wasm_bindgen(getter)]
|
||||
pub fn uptime(&self) -> f32 {
|
||||
self.uptime
|
||||
}
|
||||
|
||||
/// Get stake amount
|
||||
#[wasm_bindgen(getter)]
|
||||
pub fn stake(&self) -> u64 {
|
||||
self.stake
|
||||
}
|
||||
|
||||
/// Calculate stake weight using logarithmic scaling
|
||||
///
|
||||
/// Uses log10(stake + 1) / 6 capped at 1.0
|
||||
/// This means:
|
||||
/// - 0 stake = 0.0 weight
|
||||
/// - 100 stake = ~0.33 weight
|
||||
/// - 10,000 stake = ~0.67 weight
|
||||
/// - 1,000,000 stake = 1.0 weight (capped)
|
||||
#[wasm_bindgen(js_name = stakeWeight)]
|
||||
pub fn stake_weight(&self) -> f32 {
|
||||
if self.stake == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let log_stake = (self.stake as f64 + 1.0).log10();
|
||||
(log_stake / 6.0).min(1.0) as f32
|
||||
}
|
||||
|
||||
/// Calculate composite reputation score
|
||||
///
|
||||
/// Formula: accuracy^2 * uptime * stake_weight
|
||||
///
|
||||
/// Returns a value between 0.0 and 1.0
|
||||
#[wasm_bindgen(js_name = compositeScore)]
|
||||
pub fn composite_score(&self) -> f32 {
|
||||
self.accuracy.powi(2) * self.uptime * self.stake_weight()
|
||||
}
|
||||
|
||||
/// Get reputation tier based on composite score
|
||||
#[wasm_bindgen(js_name = tierName)]
|
||||
pub fn tier_name(&self) -> String {
|
||||
let score = self.composite_score();
|
||||
|
||||
if score >= 0.9 {
|
||||
"Diamond".to_string()
|
||||
} else if score >= 0.75 {
|
||||
"Platinum".to_string()
|
||||
} else if score >= 0.5 {
|
||||
"Gold".to_string()
|
||||
} else if score >= 0.25 {
|
||||
"Silver".to_string()
|
||||
} else if score >= 0.1 {
|
||||
"Bronze".to_string()
|
||||
} else {
|
||||
"Newcomer".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if node meets minimum reputation for participation
|
||||
#[wasm_bindgen(js_name = meetsMinimum)]
|
||||
pub fn meets_minimum(&self, min_accuracy: f32, min_uptime: f32, min_stake: u64) -> bool {
|
||||
self.accuracy >= min_accuracy && self.uptime >= min_uptime && self.stake >= min_stake
|
||||
}
|
||||
|
||||
/// Record a successful task completion
|
||||
#[wasm_bindgen(js_name = recordSuccess)]
|
||||
pub fn record_success(&mut self) {
|
||||
self.tasks_completed += 1;
|
||||
self.update_accuracy();
|
||||
}
|
||||
|
||||
/// Record a failed/disputed task
|
||||
#[wasm_bindgen(js_name = recordFailure)]
|
||||
pub fn record_failure(&mut self) {
|
||||
self.tasks_failed += 1;
|
||||
self.update_accuracy();
|
||||
}
|
||||
|
||||
/// Update uptime tracking
|
||||
#[wasm_bindgen(js_name = updateUptime)]
|
||||
pub fn update_uptime(&mut self, online_seconds: u64, total_seconds: u64) {
|
||||
self.uptime_seconds = online_seconds;
|
||||
self.total_seconds = total_seconds;
|
||||
if total_seconds > 0 {
|
||||
self.uptime = (online_seconds as f32 / total_seconds as f32).min(1.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update stake amount
|
||||
#[wasm_bindgen(js_name = updateStake)]
|
||||
pub fn update_stake(&mut self, new_stake: u64) {
|
||||
self.stake = new_stake;
|
||||
}
|
||||
|
||||
/// Get tasks completed
|
||||
#[wasm_bindgen(js_name = tasksCompleted)]
|
||||
pub fn tasks_completed(&self) -> u64 {
|
||||
self.tasks_completed
|
||||
}
|
||||
|
||||
/// Get tasks failed
|
||||
#[wasm_bindgen(js_name = tasksFailed)]
|
||||
pub fn tasks_failed(&self) -> u64 {
|
||||
self.tasks_failed
|
||||
}
|
||||
|
||||
/// Get total tasks
|
||||
#[wasm_bindgen(js_name = totalTasks)]
|
||||
pub fn total_tasks(&self) -> u64 {
|
||||
self.tasks_completed + self.tasks_failed
|
||||
}
|
||||
|
||||
/// Check if this reputation is better than another
|
||||
#[wasm_bindgen(js_name = isBetterThan)]
|
||||
pub fn is_better_than(&self, other: &ReputationScore) -> bool {
|
||||
self.composite_score() > other.composite_score()
|
||||
}
|
||||
|
||||
/// Serialize to JSON
|
||||
#[wasm_bindgen(js_name = toJson)]
|
||||
pub fn to_json(&self) -> String {
|
||||
serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
/// Deserialize from JSON
|
||||
#[wasm_bindgen(js_name = fromJson)]
|
||||
pub fn from_json(json: &str) -> Result<ReputationScore, JsValue> {
|
||||
serde_json::from_str(json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Failed to parse JSON: {}", e)))
|
||||
}
|
||||
|
||||
/// Update accuracy from tracked counts
|
||||
fn update_accuracy(&mut self) {
|
||||
let total = self.tasks_completed + self.tasks_failed;
|
||||
if total > 0 {
|
||||
self.accuracy = self.tasks_completed as f32 / total as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate stake weight (WASM export)
|
||||
#[wasm_bindgen]
|
||||
pub fn stake_weight(stake: u64) -> f32 {
|
||||
if stake == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let log_stake = (stake as f64 + 1.0).log10();
|
||||
(log_stake / 6.0).min(1.0) as f32
|
||||
}
|
||||
|
||||
/// Calculate composite reputation score (WASM export)
|
||||
#[wasm_bindgen]
|
||||
pub fn composite_reputation(accuracy: f32, uptime: f32, stake: u64) -> f32 {
|
||||
let rep = ReputationScore::new(accuracy, uptime, stake);
|
||||
rep.composite_score()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_new_reputation() {
|
||||
let rep = ReputationScore::new(0.95, 0.98, 1000);
|
||||
assert!((rep.accuracy() - 0.95).abs() < 0.001);
|
||||
assert!((rep.uptime() - 0.98).abs() < 0.001);
|
||||
assert_eq!(rep.stake(), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clamp_values() {
|
||||
let rep = ReputationScore::new(1.5, -0.5, 100);
|
||||
assert!((rep.accuracy() - 1.0).abs() < 0.001);
|
||||
assert!((rep.uptime() - 0.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stake_weight() {
|
||||
// 0 stake = 0 weight
|
||||
assert_eq!(stake_weight(0), 0.0);
|
||||
|
||||
// 1M stake = 1.0 weight (log10(1M) = 6)
|
||||
let weight = stake_weight(1_000_000);
|
||||
assert!((weight - 1.0).abs() < 0.01);
|
||||
|
||||
// 10K stake = ~0.67 weight (log10(10K) = 4)
|
||||
let weight = stake_weight(10_000);
|
||||
assert!(weight > 0.6 && weight < 0.75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_composite_score() {
|
||||
// Perfect accuracy (1.0), perfect uptime (1.0), max stake weight
|
||||
let rep = ReputationScore::new(1.0, 1.0, 1_000_000);
|
||||
let score = rep.composite_score();
|
||||
assert!((score - 1.0).abs() < 0.01);
|
||||
|
||||
// Zero accuracy = zero score
|
||||
let rep_zero = ReputationScore::new(0.0, 1.0, 1_000_000);
|
||||
assert!(rep_zero.composite_score() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tier_names() {
|
||||
let diamond = ReputationScore::new(1.0, 1.0, 1_000_000);
|
||||
assert_eq!(diamond.tier_name(), "Diamond");
|
||||
|
||||
let newcomer = ReputationScore::new(0.1, 0.1, 10);
|
||||
assert_eq!(newcomer.tier_name(), "Newcomer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_success_failure() {
|
||||
let mut rep = ReputationScore::new(0.5, 1.0, 1000);
|
||||
rep.tasks_completed = 5;
|
||||
rep.tasks_failed = 5;
|
||||
|
||||
rep.record_success();
|
||||
assert_eq!(rep.tasks_completed(), 6);
|
||||
assert!((rep.accuracy() - 6.0 / 11.0).abs() < 0.001);
|
||||
|
||||
rep.record_failure();
|
||||
assert_eq!(rep.tasks_failed(), 6);
|
||||
assert!((rep.accuracy() - 6.0 / 12.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_meets_minimum() {
|
||||
let rep = ReputationScore::new(0.95, 0.98, 1000);
|
||||
|
||||
assert!(rep.meets_minimum(0.9, 0.95, 500));
|
||||
assert!(!rep.meets_minimum(0.99, 0.95, 500)); // Accuracy too low
|
||||
assert!(!rep.meets_minimum(0.9, 0.99, 500)); // Uptime too low
|
||||
assert!(!rep.meets_minimum(0.9, 0.95, 2000)); // Stake too low
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_better_than() {
|
||||
let better = ReputationScore::new(0.95, 0.98, 10000);
|
||||
let worse = ReputationScore::new(0.8, 0.9, 1000);
|
||||
|
||||
assert!(better.is_better_than(&worse));
|
||||
assert!(!worse.is_better_than(&better));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_tracking() {
|
||||
let rep = ReputationScore::new_with_tracking(
|
||||
90, // completed
|
||||
10, // failed
|
||||
3600, // uptime
|
||||
4000, // total
|
||||
5000, // stake
|
||||
);
|
||||
|
||||
assert!((rep.accuracy() - 0.9).abs() < 0.001);
|
||||
assert!((rep.uptime() - 0.9).abs() < 0.001);
|
||||
assert_eq!(rep.stake(), 5000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_serialization() {
|
||||
let rep = ReputationScore::new(0.95, 0.98, 1000);
|
||||
let json = rep.to_json();
|
||||
assert!(json.contains("accuracy"));
|
||||
|
||||
let parsed = ReputationScore::from_json(&json).unwrap();
|
||||
assert!((parsed.accuracy() - rep.accuracy()).abs() < 0.001);
|
||||
}
|
||||
}
|
||||
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