346 lines
10 KiB
Rust
346 lines
10 KiB
Rust
//! rUv (Resource Utility Vouchers) system with CRDT ledger and contribution curve
|
|
//!
|
|
//! This module provides the economic layer for edge-net:
|
|
//! - rUv: Resource Utility Vouchers for compute credits
|
|
//! - CRDT-based ledger for P2P consistency
|
|
//! - Contribution curve for early adopter rewards
|
|
//! - DAG-based quantum-resistant currency for settlements
|
|
|
|
use wasm_bindgen::prelude::*;
|
|
use serde::{Serialize, Deserialize};
|
|
use rustc_hash::FxHashMap; // 30-50% faster than std HashMap
|
|
use uuid::Uuid;
|
|
|
|
pub mod qdag;
|
|
|
|
/// Contribution curve for reward calculation
|
|
pub struct ContributionCurve;
|
|
|
|
impl ContributionCurve {
|
|
/// Maximum multiplier for genesis contributors
|
|
const MAX_BONUS: f32 = 10.0;
|
|
|
|
/// Decay constant in CPU-hours (half-life of bonus)
|
|
const DECAY_CONSTANT: f64 = 1_000_000.0;
|
|
|
|
/// Calculate current multiplier based on network compute
|
|
///
|
|
/// Formula: multiplier = 1 + (MAX_BONUS - 1) * e^(-network_compute / DECAY_CONSTANT)
|
|
///
|
|
/// Returns a value between 1.0 (baseline) and MAX_BONUS (genesis)
|
|
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 rewards 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 tiers for display
|
|
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.0),
|
|
(5_000_000.0, 1.4),
|
|
(10_000_000.0, 1.0),
|
|
]
|
|
}
|
|
}
|
|
|
|
/// Credit event types
|
|
#[derive(Clone, Serialize, Deserialize, Debug)]
|
|
pub enum CreditReason {
|
|
/// Earned from completing a task
|
|
TaskCompleted { task_id: String },
|
|
/// Earned from uptime
|
|
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 },
|
|
}
|
|
|
|
/// A single credit event
|
|
#[derive(Clone, Serialize, Deserialize, Debug)]
|
|
pub struct CreditEvent {
|
|
pub id: String,
|
|
pub node_id: String,
|
|
pub amount: i64, // Can be negative for penalties/spending
|
|
pub reason: CreditReason,
|
|
pub timestamp: u64,
|
|
pub signature: Vec<u8>,
|
|
}
|
|
|
|
/// CRDT-based credit ledger for P2P consistency
|
|
#[wasm_bindgen]
|
|
pub struct WasmCreditLedger {
|
|
node_id: String,
|
|
|
|
// G-Counter: monotonically increasing credits earned - FxHashMap for faster lookups
|
|
earned: FxHashMap<String, u64>,
|
|
|
|
// PN-Counter: credits spent/penalized - FxHashMap for faster lookups
|
|
spent: FxHashMap<String, (u64, u64)>, // (positive, negative)
|
|
|
|
// Local balance cache (avoids recalculation)
|
|
local_balance: u64,
|
|
|
|
// Network compute (for multiplier calculation)
|
|
network_compute: f64,
|
|
|
|
// Stake amount
|
|
staked: u64,
|
|
|
|
// Last sync timestamp
|
|
last_sync: u64,
|
|
}
|
|
|
|
#[wasm_bindgen]
|
|
impl WasmCreditLedger {
|
|
/// Create a new credit ledger
|
|
#[wasm_bindgen(constructor)]
|
|
pub fn new(node_id: String) -> Result<WasmCreditLedger, JsValue> {
|
|
Ok(WasmCreditLedger {
|
|
node_id,
|
|
earned: FxHashMap::default(),
|
|
spent: FxHashMap::default(),
|
|
local_balance: 0,
|
|
network_compute: 0.0,
|
|
staked: 0,
|
|
last_sync: 0,
|
|
})
|
|
}
|
|
|
|
/// Get current balance
|
|
#[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 earned (before spending)
|
|
#[wasm_bindgen(js_name = totalEarned)]
|
|
pub fn total_earned(&self) -> u64 {
|
|
self.earned.values().sum()
|
|
}
|
|
|
|
/// Get total 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 (for multiplier)
|
|
#[wasm_bindgen(js_name = networkCompute)]
|
|
pub fn network_compute(&self) -> f64 {
|
|
self.network_compute
|
|
}
|
|
|
|
/// Get current multiplier
|
|
#[wasm_bindgen(js_name = currentMultiplier)]
|
|
pub fn current_multiplier(&self) -> f32 {
|
|
ContributionCurve::current_multiplier(self.network_compute)
|
|
}
|
|
|
|
/// Credit the ledger (earn credits)
|
|
#[wasm_bindgen]
|
|
pub fn credit(&mut self, amount: u64, reason: &str) -> Result<(), JsValue> {
|
|
let event_id = Uuid::new_v4().to_string();
|
|
|
|
// Update G-Counter
|
|
*self.earned.entry(event_id).or_insert(0) += amount;
|
|
self.local_balance = self.balance();
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Deduct from the ledger (spend credits)
|
|
#[wasm_bindgen]
|
|
pub fn deduct(&mut self, amount: u64) -> Result<(), JsValue> {
|
|
if self.balance() < amount {
|
|
return Err(JsValue::from_str("Insufficient balance"));
|
|
}
|
|
|
|
let event_id = Uuid::new_v4().to_string();
|
|
|
|
// Update PN-Counter (positive side)
|
|
let entry = self.spent.entry(event_id).or_insert((0, 0));
|
|
entry.0 += amount;
|
|
self.local_balance = self.balance();
|
|
|
|
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.local_balance = self.balance();
|
|
|
|
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.local_balance = self.balance();
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Slash staked credits (penalty for bad behavior)
|
|
#[wasm_bindgen]
|
|
pub fn slash(&mut self, amount: u64) -> Result<u64, JsValue> {
|
|
let slash_amount = amount.min(self.staked);
|
|
self.staked -= slash_amount;
|
|
self.local_balance = self.balance();
|
|
|
|
Ok(slash_amount)
|
|
}
|
|
|
|
/// Update network compute (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) - optimized batch processing
|
|
#[wasm_bindgen]
|
|
pub fn merge(&mut self, other_earned: &[u8], other_spent: &[u8]) -> Result<(), JsValue> {
|
|
// Deserialize earned counter
|
|
let earned_map: FxHashMap<String, u64> = serde_json::from_slice(other_earned)
|
|
.map_err(|e| JsValue::from_str(&format!("Failed to parse earned: {}", e)))?;
|
|
|
|
// CRDT merge: take max of each counter (batch operation)
|
|
for (key, value) in earned_map {
|
|
let entry = self.earned.entry(key).or_insert(0);
|
|
*entry = (*entry).max(value);
|
|
}
|
|
|
|
// Deserialize spent counter
|
|
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)))?;
|
|
|
|
// CRDT merge: take max of each counter (batch operation)
|
|
for (key, (pos, neg)) in spent_map {
|
|
let entry = self.spent.entry(key).or_insert((0, 0));
|
|
entry.0 = entry.0.max(pos);
|
|
entry.1 = entry.1.max(neg);
|
|
}
|
|
|
|
// Recalculate balance once after merge (vs per-operation)
|
|
self.local_balance = self.balance();
|
|
self.last_sync = js_sys::Date::now() as u64;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Export earned counter for 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!("Failed to serialize: {}", e)))
|
|
}
|
|
|
|
/// Export spent counter for 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!("Failed to serialize: {}", e)))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_contribution_curve() {
|
|
// Genesis (0 hours) should give max multiplier
|
|
let mult = ContributionCurve::current_multiplier(0.0);
|
|
assert!((mult - 10.0).abs() < 0.01);
|
|
|
|
// At decay constant, should be around 4.3x
|
|
let mult = ContributionCurve::current_multiplier(1_000_000.0);
|
|
assert!(mult > 3.5 && mult < 4.5);
|
|
|
|
// At high compute, should approach 1.0
|
|
let mult = ContributionCurve::current_multiplier(10_000_000.0);
|
|
assert!(mult < 1.1);
|
|
}
|
|
|
|
// Tests requiring WASM environment (UUID with js feature)
|
|
#[cfg(target_arch = "wasm32")]
|
|
#[test]
|
|
fn test_ledger_operations() {
|
|
let mut ledger = WasmCreditLedger::new("test-node".to_string()).unwrap();
|
|
|
|
// Initial balance is 0
|
|
assert_eq!(ledger.balance(), 0);
|
|
|
|
// Credit 100
|
|
ledger.credit(100, "task").unwrap();
|
|
assert_eq!(ledger.balance(), 100);
|
|
|
|
// Deduct 30
|
|
ledger.deduct(30).unwrap();
|
|
assert_eq!(ledger.balance(), 70);
|
|
|
|
// Can't deduct more than balance
|
|
assert!(ledger.deduct(100).is_err());
|
|
}
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
#[test]
|
|
fn test_staking() {
|
|
let mut ledger = WasmCreditLedger::new("test-node".to_string()).unwrap();
|
|
|
|
ledger.credit(100, "task").unwrap();
|
|
|
|
// Stake 50
|
|
ledger.stake(50).unwrap();
|
|
assert_eq!(ledger.balance(), 50);
|
|
assert_eq!(ledger.staked_amount(), 50);
|
|
|
|
// Unstake 20
|
|
ledger.unstake(20).unwrap();
|
|
assert_eq!(ledger.balance(), 70);
|
|
assert_eq!(ledger.staked_amount(), 30);
|
|
|
|
// Slash 10
|
|
let slashed = ledger.slash(10).unwrap();
|
|
assert_eq!(slashed, 10);
|
|
assert_eq!(ledger.staked_amount(), 20);
|
|
}
|
|
}
|