346 lines
10 KiB
Rust
346 lines
10 KiB
Rust
//! Block reward calculations for Synor.
|
|
//!
|
|
//! Synor uses a chromatic halving schedule similar to Kaspa,
|
|
//! with smooth reduction rather than sudden halvings.
|
|
//!
|
|
//! Initial reward: 500 SYNOR per block
|
|
//! Total supply: 70,000,000 SYNOR (fixed)
|
|
|
|
use synor_types::Amount;
|
|
use thiserror::Error;
|
|
|
|
/// Initial block reward in sompi (500 SYNOR).
|
|
pub const INITIAL_REWARD: u64 = 500 * Amount::ONE_SYNOR;
|
|
|
|
/// Halving interval in blocks (~1 year at 10 bps).
|
|
pub const HALVING_INTERVAL: u64 = 315_360_000; // ~1 year
|
|
|
|
/// Number of halvings until reward becomes zero.
|
|
pub const MAX_HALVINGS: u32 = 8;
|
|
|
|
/// Chromatic halving period (smooth reduction).
|
|
pub const CHROMATIC_PERIOD: u64 = 1_000_000; // ~27 hours
|
|
|
|
/// Block reward information.
|
|
#[derive(Clone, Debug)]
|
|
pub struct BlockReward {
|
|
/// Base subsidy (before fees).
|
|
pub subsidy: Amount,
|
|
/// Total fees collected.
|
|
pub fees: Amount,
|
|
/// Total reward (subsidy + fees).
|
|
pub total: Amount,
|
|
/// Current emission epoch.
|
|
pub epoch: u32,
|
|
}
|
|
|
|
impl BlockReward {
|
|
/// Creates a new block reward.
|
|
pub fn new(subsidy: Amount, fees: Amount) -> Self {
|
|
BlockReward {
|
|
subsidy,
|
|
fees,
|
|
total: subsidy.saturating_add(fees),
|
|
epoch: 0,
|
|
}
|
|
}
|
|
|
|
/// Creates a genesis reward.
|
|
pub fn genesis() -> Self {
|
|
BlockReward {
|
|
subsidy: Amount::from_sompi(INITIAL_REWARD),
|
|
fees: Amount::ZERO,
|
|
total: Amount::from_sompi(INITIAL_REWARD),
|
|
epoch: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reward calculator.
|
|
pub struct RewardCalculator {
|
|
/// Initial reward in sompi.
|
|
initial_reward: u64,
|
|
/// Halving interval.
|
|
halving_interval: u64,
|
|
/// Use chromatic (smooth) halving.
|
|
chromatic: bool,
|
|
}
|
|
|
|
impl RewardCalculator {
|
|
/// Creates a new reward calculator.
|
|
pub fn new() -> Self {
|
|
RewardCalculator {
|
|
initial_reward: INITIAL_REWARD,
|
|
halving_interval: HALVING_INTERVAL,
|
|
chromatic: true,
|
|
}
|
|
}
|
|
|
|
/// Creates a calculator for testing.
|
|
pub fn for_testing() -> Self {
|
|
RewardCalculator {
|
|
initial_reward: 500 * 100_000_000, // 500 SYNOR
|
|
halving_interval: 1000, // Quick halvings
|
|
chromatic: true,
|
|
}
|
|
}
|
|
|
|
/// Calculates the block subsidy for a given DAA score.
|
|
pub fn calculate_subsidy(&self, daa_score: u64) -> Amount {
|
|
if self.chromatic {
|
|
self.calculate_chromatic_subsidy(daa_score)
|
|
} else {
|
|
self.calculate_standard_subsidy(daa_score)
|
|
}
|
|
}
|
|
|
|
/// Standard halving (like Bitcoin).
|
|
fn calculate_standard_subsidy(&self, daa_score: u64) -> Amount {
|
|
let halvings = (daa_score / self.halving_interval) as u32;
|
|
|
|
if halvings >= MAX_HALVINGS {
|
|
return Amount::ZERO;
|
|
}
|
|
|
|
let reward = self.initial_reward >> halvings;
|
|
Amount::from_sompi(reward)
|
|
}
|
|
|
|
/// Chromatic halving (smooth reduction like Kaspa).
|
|
fn calculate_chromatic_subsidy(&self, daa_score: u64) -> Amount {
|
|
// Chromatic halving uses a smoother curve
|
|
// Reward = initial * (1/2)^(daa_score / halving_interval)
|
|
// For efficiency, we approximate this
|
|
|
|
let halvings = daa_score / self.halving_interval;
|
|
let position_in_epoch = daa_score % self.halving_interval;
|
|
|
|
if halvings as u32 >= MAX_HALVINGS {
|
|
return Amount::ZERO;
|
|
}
|
|
|
|
// Base reward at this epoch
|
|
let base_reward = self.initial_reward >> halvings;
|
|
|
|
// Chromatic adjustment within epoch
|
|
// Linearly decrease toward next halving
|
|
let next_reward = base_reward / 2;
|
|
let epoch_progress = position_in_epoch as f64 / self.halving_interval as f64;
|
|
|
|
// Smooth interpolation
|
|
let adjusted = base_reward as f64 - (base_reward - next_reward) as f64 * epoch_progress;
|
|
|
|
Amount::from_sompi(adjusted as u64)
|
|
}
|
|
|
|
/// Calculates total reward (subsidy + fees).
|
|
pub fn calculate_reward(&self, daa_score: u64, fees: Amount) -> BlockReward {
|
|
let subsidy = self.calculate_subsidy(daa_score);
|
|
let halvings = (daa_score / self.halving_interval) as u32;
|
|
|
|
BlockReward {
|
|
subsidy,
|
|
fees,
|
|
total: subsidy.saturating_add(fees),
|
|
epoch: halvings.min(MAX_HALVINGS),
|
|
}
|
|
}
|
|
|
|
/// Estimates total supply at a given DAA score.
|
|
pub fn estimate_supply_at(&self, daa_score: u64) -> Amount {
|
|
// This is an approximation
|
|
let mut total = 0u64;
|
|
let mut current_score = 0u64;
|
|
|
|
while current_score < daa_score {
|
|
let subsidy = self.calculate_subsidy(current_score);
|
|
if subsidy == Amount::ZERO {
|
|
break;
|
|
}
|
|
|
|
// Add rewards for a chunk of blocks
|
|
let chunk_size = (daa_score - current_score).min(10000);
|
|
total = total.saturating_add(subsidy.as_sompi() * chunk_size);
|
|
current_score += chunk_size;
|
|
}
|
|
|
|
Amount::from_sompi(total)
|
|
}
|
|
|
|
/// Returns the current epoch (halving count).
|
|
pub fn get_epoch(&self, daa_score: u64) -> u32 {
|
|
((daa_score / self.halving_interval) as u32).min(MAX_HALVINGS)
|
|
}
|
|
|
|
/// Returns the DAA score at which rewards end.
|
|
pub fn emission_end_score(&self) -> u64 {
|
|
self.halving_interval * MAX_HALVINGS as u64
|
|
}
|
|
|
|
/// Returns the initial reward.
|
|
pub fn initial_reward(&self) -> Amount {
|
|
Amount::from_sompi(self.initial_reward)
|
|
}
|
|
}
|
|
|
|
impl Default for RewardCalculator {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for RewardCalculator {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("RewardCalculator")
|
|
.field("initial_reward", &Amount::from_sompi(self.initial_reward))
|
|
.field("halving_interval", &self.halving_interval)
|
|
.field("chromatic", &self.chromatic)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
/// Fee distribution for block rewards.
|
|
#[derive(Clone, Debug)]
|
|
pub struct FeeDistribution {
|
|
/// Amount to burn (deflationary).
|
|
pub burn: Amount,
|
|
/// Amount to stakers.
|
|
pub stakers: Amount,
|
|
/// Amount to community pool.
|
|
pub community: Amount,
|
|
/// Amount to miner/validator.
|
|
pub miner: Amount,
|
|
}
|
|
|
|
impl FeeDistribution {
|
|
/// Distributes fees according to Synor's tokenomics.
|
|
pub fn distribute(fees: Amount) -> Self {
|
|
// 10% burn, 60% stakers, 20% community, 10% miner
|
|
let fee_sompi = fees.as_sompi();
|
|
let burn = Amount::from_sompi(fee_sompi * 10 / 100);
|
|
let stakers = Amount::from_sompi(fee_sompi * 60 / 100);
|
|
let community = Amount::from_sompi(fee_sompi * 20 / 100);
|
|
let miner = fees
|
|
.saturating_sub(burn)
|
|
.saturating_sub(stakers)
|
|
.saturating_sub(community);
|
|
|
|
FeeDistribution {
|
|
burn,
|
|
stakers,
|
|
community,
|
|
miner,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Errors related to rewards.
|
|
#[derive(Debug, Error)]
|
|
pub enum RewardError {
|
|
#[error("Invalid DAA score")]
|
|
InvalidDaaScore,
|
|
|
|
#[error("Reward overflow")]
|
|
Overflow,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_initial_reward() {
|
|
let calc = RewardCalculator::new();
|
|
let reward = calc.calculate_subsidy(0);
|
|
assert_eq!(reward, Amount::from_synor(500));
|
|
}
|
|
|
|
#[test]
|
|
fn test_reward_decreases() {
|
|
let calc = RewardCalculator::for_testing();
|
|
|
|
let reward_0 = calc.calculate_subsidy(0);
|
|
let reward_1000 = calc.calculate_subsidy(1000);
|
|
|
|
// After one halving, reward should be about half
|
|
assert!(reward_1000 < reward_0);
|
|
assert!(reward_1000 >= Amount::from_synor(200)); // Roughly half of 500
|
|
}
|
|
|
|
#[test]
|
|
fn test_chromatic_smooth() {
|
|
let calc = RewardCalculator::for_testing();
|
|
|
|
// Rewards should decrease smoothly within an epoch
|
|
let reward_0 = calc.calculate_subsidy(0);
|
|
let reward_500 = calc.calculate_subsidy(500); // Halfway through epoch
|
|
let reward_999 = calc.calculate_subsidy(999); // End of epoch
|
|
|
|
assert!(reward_0 > reward_500);
|
|
assert!(reward_500 > reward_999);
|
|
}
|
|
|
|
#[test]
|
|
fn test_max_halvings() {
|
|
let calc = RewardCalculator::for_testing();
|
|
|
|
// After max halvings, reward should be zero
|
|
let end_score = calc.emission_end_score();
|
|
let reward = calc.calculate_subsidy(end_score + 1000);
|
|
|
|
assert_eq!(reward, Amount::ZERO);
|
|
}
|
|
|
|
#[test]
|
|
fn test_block_reward() {
|
|
let calc = RewardCalculator::new();
|
|
let fees = Amount::from_synor(1);
|
|
|
|
let reward = calc.calculate_reward(0, fees);
|
|
|
|
assert_eq!(reward.subsidy, Amount::from_synor(500));
|
|
assert_eq!(reward.fees, Amount::from_synor(1));
|
|
assert_eq!(reward.total, Amount::from_synor(501));
|
|
assert_eq!(reward.epoch, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_fee_distribution() {
|
|
let fees = Amount::from_synor(100);
|
|
let dist = FeeDistribution::distribute(fees);
|
|
|
|
// Verify distribution adds up
|
|
let total = dist
|
|
.burn
|
|
.saturating_add(dist.stakers)
|
|
.saturating_add(dist.community)
|
|
.saturating_add(dist.miner);
|
|
|
|
assert_eq!(total, fees);
|
|
|
|
// Verify percentages
|
|
assert_eq!(dist.burn, Amount::from_synor(10));
|
|
assert_eq!(dist.stakers, Amount::from_synor(60));
|
|
assert_eq!(dist.community, Amount::from_synor(20));
|
|
assert_eq!(dist.miner, Amount::from_synor(10));
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_epoch() {
|
|
let calc = RewardCalculator::for_testing();
|
|
|
|
assert_eq!(calc.get_epoch(0), 0);
|
|
assert_eq!(calc.get_epoch(500), 0);
|
|
assert_eq!(calc.get_epoch(1000), 1);
|
|
assert_eq!(calc.get_epoch(2000), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_genesis_reward() {
|
|
let reward = BlockReward::genesis();
|
|
|
|
assert_eq!(reward.subsidy, Amount::from_synor(500));
|
|
assert_eq!(reward.fees, Amount::ZERO);
|
|
assert_eq!(reward.epoch, 0);
|
|
}
|
|
}
|