Merge commit 'd803bfe2b1fe7f5e219e50ac20d6801a0a58ac75' as 'vendor/ruvector'
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user