Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions crates/synapse-core/src/tokenomics/karma.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ impl KarmaSystem {
/// - Karma < 10.0: **0.1x** (Severe penalty for new accounts/bots).
/// - Karma > 80.0: **5.0x** (Max multiplier for trusted sentience).
/// - Between 10.0 and 80.0: Linear progression.
///
/// The SPEC mentions Km = max(0.1, min(5.0, karmaScore / 1000)), but we use 0-100 scale here.
/// To align, we keep the 0.1 to 5.0 range and the linear interpolation.
pub fn calculate_multiplier(karma: f64) -> f64 {
if karma < 10.0 {
return Self::MIN_MULTIPLIER;
Expand All @@ -26,7 +29,7 @@ impl KarmaSystem {
}

// Linear interpolation between 10.0 (0.1x) and 80.0 (5.0x)
// Slope = (MAX - MIN) / (80.0 - 10.0)
// Slope = (MAX - MIN) / (80.0 - 10.0) = 4.9 / 70.0 = 0.07
let slope = (Self::MAX_MULTIPLIER - Self::MIN_MULTIPLIER) / 70.0;

Self::MIN_MULTIPLIER + ((karma - 10.0) * slope)
Expand All @@ -48,6 +51,7 @@ impl KarmaSystem {
/// Helper to apply update directly to a profile
pub fn apply_update(profile: &mut UserProfile, action_score: f64) {
profile.karma_score = Self::update_karma(profile.karma_score, action_score);
profile.total_contributions += 1;
}
}

Expand All @@ -66,7 +70,6 @@ mod tests {
assert_eq!(KarmaSystem::calculate_multiplier(100.0), KarmaSystem::MAX_MULTIPLIER);

// Test Middle (Linear)
// 45.0 (midpoint) should be around 2.55 like before
let mid = KarmaSystem::calculate_multiplier(45.0);
let expected = 0.1 + (35.0 * (4.9 / 70.0));
assert!((mid - expected).abs() < 1e-10);
Expand Down
315 changes: 147 additions & 168 deletions crates/synapse-core/src/tokenomics/reward_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,159 +5,132 @@ use super::karma::KarmaSystem;

pub struct RewardEngine;

/// Detailed breakdown of a reward calculation split.
/// Used internally or for transparency.
#[derive(Debug, Clone)]
pub struct RewardCalculation {
pub pool_amount: u64,
pub burn_amount: u64,
}

impl RewardEngine {
/// Hardcoded splits per role
const SPLIT_HARDWARE: f64 = 0.10;
const SPLIT_DATA: f64 = 0.40;
const SPLIT_TEACHER: f64 = 0.50;
/// Hardcoded splits per role as per SPEC
pub const SPLIT_HARDWARE: f64 = 0.10;
pub const SPLIT_DATA: f64 = 0.40;
pub const SPLIT_TEACHER: f64 = 0.50;

/// Calculates the split allocation for a TOTAL reward pool based on role rules.
/// This returns the *max potential* pool for that role group from the total.
///
/// # Arguments
/// * `total_reward` - The total tokens available (minted or paid).
/// * `role` - The role to get the fraction for.
///
/// # Returns
/// Struct with `pool_amount` (share) and `burn_amount` (if applicable, though burn usually happens pre-split).
/// NOTE: User request asks for `calculate_split(total, role)`.
pub fn calculate_split(total_reward: u64, role: ContributionRole) -> u64 {
let fraction = match role {
ContributionRole::Hardware => Self::SPLIT_HARDWARE,
ContributionRole::DataProvider => Self::SPLIT_DATA,
ContributionRole::Teacher => Self::SPLIT_TEACHER,
};
(total_reward as f64 * fraction) as u64
}
/// Constant base reward per unit of data/work
pub const BASE_REWARD_UNIT: u64 = 100;

/// Calculates block rewards with specific Service Payment burn logic (80%).
pub fn calculate_block_rewards(
total_input: u64,
contributions: &Vec<Contribution>,
/// Calculates and distributes rewards for a validated contribution.
///
/// Following the SPEC formula:
/// E = B_base * Q * DataSize
/// R_H = E * 0.10
/// R_D = (E * 0.40) * Km(D)
/// R_V = (E * 0.50) / N * Km(V_i)
pub fn distribute_rewards(
contribution: &Contribution,
users: &HashMap<Uuid, UserProfile>,
is_service_payment: bool,
) -> Vec<RewardReceipt> {
let mut receipts = Vec::new();

// 1. Deflation: Service Payment Burn (80%)
let (distributable, initial_burn) = if is_service_payment {
let burn = (total_input as f64 * 0.80) as u64;
(total_input - burn, burn)
} else {
(total_input, 0)
};

if initial_burn > 0 {
receipts.push(RewardReceipt {
amount_minted: 0,
amount_burned: initial_burn,
recipient: "Service_Payment_Burn".to_string(),
});
}
// 1. Calculate Total Emission (E)
// E = Base * Quality * Size
let emission = (Self::BASE_REWARD_UNIT as f64
* contribution.quality_score
* contribution.data_size as f64) as u64;

if contributions.is_empty() {
// Burn remainder if no work done
if distributable > 0 {
receipts.push(RewardReceipt {
amount_minted: 0,
amount_burned: distributable,
recipient: "No_Work_Burn".to_string(),
});
}
return receipts;
if emission == 0 {
return receipts;
}

// 2. Separate Contributions
let mut hw_contribs = Vec::new();
let mut data_contribs = Vec::new();
let mut teacher_contribs = Vec::new();
// 2. Hardware Provider (10% fixed)
let r_h = (emission as f64 * Self::SPLIT_HARDWARE) as u64;
if r_h > 0 {
let recipient = users.get(&contribution.hardware_provider)
.map(|u| u.wallet_address.clone())
.unwrap_or_else(|| "Unknown_Hardware".to_string());

for c in contributions {
match c.role {
ContributionRole::Hardware => hw_contribs.push(c),
ContributionRole::DataProvider => data_contribs.push(c),
ContributionRole::Teacher => teacher_contribs.push(c),
}
receipts.push(RewardReceipt {
amount_minted: r_h,
amount_burned: 0,
recipient,
});
}

// 3. Calculate Pools using `calculate_split` logic
// We use the distributable amount as the base for the split.
// We handle each pool separately.
// 3. Data Provider (40% adjusted by Karma)
let pool_d = (emission as f64 * Self::SPLIT_DATA) as u64;
let km_d = users.get(&contribution.dataset_provider)
.map(|u| KarmaSystem::calculate_multiplier(u.karma_score))
.unwrap_or(KarmaSystem::MIN_MULTIPLIER);

let pool_hw = Self::calculate_split(distributable, ContributionRole::Hardware);
let pool_data = Self::calculate_split(distributable, ContributionRole::DataProvider);
let pool_teacher = Self::calculate_split(distributable, ContributionRole::Teacher);
let r_d = (pool_d as f64 * km_d) as u64;
if r_d > 0 {
let recipient = users.get(&contribution.dataset_provider)
.map(|u| u.wallet_address.clone())
.unwrap_or_else(|| "Unknown_Data".to_string());

let mut total_distributed = 0;
total_distributed += Self::distribute_pool(pool_hw, &hw_contribs, users, &mut receipts);
total_distributed += Self::distribute_pool(pool_data, &data_contribs, users, &mut receipts);
total_distributed += Self::distribute_pool(pool_teacher, &teacher_contribs, users, &mut receipts);

// Burn specific remainder (rounding errors or empty pools)
let remainder = distributable.saturating_sub(total_distributed);
if remainder > 0 {
receipts.push(RewardReceipt {
amount_minted: 0,
amount_burned: remainder,
recipient: "Unused_Pool_Burn".to_string(),
amount_minted: r_d,
amount_burned: 0,
recipient,
});
}

receipts
}

fn distribute_pool(
pool_amount: u64,
contribs: &[&Contribution],
users: &HashMap<Uuid, UserProfile>,
receipts: &mut Vec<RewardReceipt>,
) -> u64 {
if contribs.is_empty() || pool_amount == 0 {
return 0;
// Burn if Km < 1.0 (anti-inflation for low trust)
if km_d < 1.0 {
let burn_d = pool_d.saturating_sub(r_d);
if burn_d > 0 {
receipts.push(RewardReceipt {
amount_minted: 0,
amount_burned: burn_d,
recipient: "Low_Karma_Data_Burn".to_string(),
});
}
}

// Score = Quality * KarmaMultiplier
let mut scores: Vec<(f64, &Contribution)> = Vec::new();
let mut total_score = 0.0;

for c in contribs {
let karma = users.get(&c.contributor_id).map(|u| u.karma_score).unwrap_or(50.0);
let multiplier = KarmaSystem::calculate_multiplier(karma);
let score = c.quality_score * multiplier;

scores.push((score, c));
total_score += score;
// 4. Validators/Teachers (50% divided and adjusted by Karma)
if !contribution.validators.is_empty() {
let pool_v = (emission as f64 * Self::SPLIT_TEACHER) as u64;
let n = contribution.validators.len() as f64;
let share_v = pool_v as f64 / n;

for v_id in &contribution.validators {
let km_v = users.get(v_id)
.map(|u| KarmaSystem::calculate_multiplier(u.karma_score))
.unwrap_or(KarmaSystem::MIN_MULTIPLIER);

let r_v = (share_v * km_v) as u64;
if r_v > 0 {
let recipient = users.get(v_id)
.map(|u| u.wallet_address.clone())
.unwrap_or_else(|| "Unknown_Validator".to_string());

receipts.push(RewardReceipt {
amount_minted: r_v,
amount_burned: 0,
recipient,
});
}

// Burn if Km < 1.0
if km_v < 1.0 {
let burn_v = (share_v * (1.0 - km_v)) as u64;
if burn_v > 0 {
receipts.push(RewardReceipt {
amount_minted: 0,
amount_burned: burn_v,
recipient: "Low_Karma_Validator_Burn".to_string(),
});
}
}
}
}

if total_score <= 1e-6 { return 0; }

let mut distributed_here = 0;
for (score, c) in scores {
let ratio = score / total_score;
let amount = (pool_amount as f64 * ratio) as u64;

if amount > 0 {
let recipient = users.get(&c.contributor_id)
.map(|u| u.wallet_address.clone())
.unwrap_or_else(|| "Unknown".to_string());
receipts
}

receipts.push(RewardReceipt {
amount_minted: amount,
amount_burned: 0,
recipient,
});
distributed_here += amount;
}
/// Processes client payment with 80% deflationary burn.
pub fn process_payment_and_burn(amount: u64) -> RewardReceipt {
let burn_amount = (amount as f64 * 0.80) as u64;
RewardReceipt {
amount_minted: 0,
amount_burned: burn_amount,
recipient: "Deflationary_Burn".to_string(),
}
distributed_here
}
}

Expand All @@ -166,49 +139,55 @@ mod tests {
use super::*;

#[test]
fn test_split_logic() {
let total = 1000;
assert_eq!(RewardEngine::calculate_split(total, ContributionRole::Hardware), 100);
assert_eq!(RewardEngine::calculate_split(total, ContributionRole::DataProvider), 400);
assert_eq!(RewardEngine::calculate_split(total, ContributionRole::Teacher), 500);
}

#[test]
fn test_service_payment_burn() {
let total = 1000;
let contrib_id = Uuid::new_v4();
let user = UserProfile {
wallet_address: "w1".into(),
soulbound_id: Uuid::new_v4(),
karma_score: 80.0,
is_human_verified: true,
balance: 0,
last_action_timestamp: 0,
};
fn test_reward_distribution_spec() {
let mut users = HashMap::new();
users.insert(contrib_id, user);

let contrib = Contribution {
contributor_id: contrib_id,
role: ContributionRole::DataProvider, // 40%
let hw_id = Uuid::new_v4();
let data_id = Uuid::new_v4();
let v_id = Uuid::new_v4();

users.insert(hw_id, UserProfile {
wallet_address: "hw_addr".into(),
karma_score: 50.0,
..UserProfile::new("hw_addr".into())
});
users.insert(data_id, UserProfile {
wallet_address: "data_addr".into(),
karma_score: 80.0, // 5.0x multiplier
..UserProfile::new("data_addr".into())
});
users.insert(v_id, UserProfile {
wallet_address: "v_addr".into(),
karma_score: 10.0, // 0.1x multiplier
..UserProfile::new("v_addr".into())
});

let contribution = Contribution {
id: Uuid::new_v4(),
dataset_provider: data_id,
hardware_provider: hw_id,
validators: vec![v_id],
quality_score: 1.0,
data_size: 100,
};

let receipts = RewardEngine::calculate_block_rewards(
total,
&vec![contrib],
&users,
true // is_service_payment
);

// 1. Initial Burn: 800 (80% of 1000)
let burn_receipt = receipts.iter().find(|r| r.recipient == "Service_Payment_Burn").unwrap();
assert_eq!(burn_receipt.amount_burned, 800);

// 2. Distributable: 200
// Data Pool share: 40% of 200 = 80
// Since only 1 contributor, they get full pool.
let mint_receipt = receipts.iter().find(|r| r.recipient == "w1").unwrap();
assert_eq!(mint_receipt.amount_minted, 80);
let receipts = RewardEngine::distribute_rewards(&contribution, &users);

// Emission = 100 * 1.0 * 100 = 10,000
// HW = 10,000 * 0.10 = 1,000
// Data = (10,000 * 0.40) * 5.0 = 4,000 * 5.0 = 20,000
// Validator = (10,000 * 0.50 / 1) * 0.1 = 5,000 * 0.1 = 500
// Burn V = 5,000 * 0.9 = 4,500

let hw_r = receipts.iter().find(|r| r.recipient == "hw_addr").unwrap();
assert_eq!(hw_r.amount_minted, 1000);

let data_r = receipts.iter().find(|r| r.recipient == "data_addr").unwrap();
assert_eq!(data_r.amount_minted, 20000);

let v_r = receipts.iter().find(|r| r.recipient == "v_addr").unwrap();
assert_eq!(v_r.amount_minted, 500);

let burn_v = receipts.iter().find(|r| r.recipient == "Low_Karma_Validator_Burn").unwrap();
assert_eq!(burn_v.amount_burned, 4500);
}
}
Loading
Loading