diff --git a/operator/pallets/external-validators-rewards/src/mock.rs b/operator/pallets/external-validators-rewards/src/mock.rs index e723f649..f1c1a5e0 100644 --- a/operator/pallets/external-validators-rewards/src/mock.rs +++ b/operator/pallets/external-validators-rewards/src/mock.rs @@ -148,6 +148,8 @@ impl ExternalIndexProvider for TimestampProvider { parameter_types! { pub const RewardsEthereumSovereignAccount: u64 = 0xffffffffffffffff; + pub const TreasuryAccount: u64 = 999; + pub const InflationTreasuryProportion: sp_runtime::Perbill = sp_runtime::Perbill::from_percent(20); pub EraInflationProvider: u128 = Mock::mock().era_inflation.unwrap_or(42); } @@ -172,17 +174,43 @@ impl pallet_external_validators_rewards::Config for Test { pub struct InflationMinter; impl HandleInflation for InflationMinter { - fn mint_inflation(account: &u64, amount: u128) -> sp_runtime::DispatchResult { - if amount == 0 { + fn mint_inflation(rewards_account: &u64, total_amount: u128) -> sp_runtime::DispatchResult { + use sp_runtime::traits::Zero; + + if total_amount.is_zero() { log::error!(target: "ext_validators_rewards", "No rewards to distribute"); return Err(DispatchError::Other("No rewards to distribute")); } - ::Currency::mint_into( - account, - amount.into(), - ) - .map(|_| ()) - .map_err(|_| DispatchError::Other("Failed to mint inflation")) + + // Get treasury allocation proportion + let treasury_proportion = InflationTreasuryProportion::get(); + + // Calculate amounts + let treasury_amount = treasury_proportion.mul_floor(total_amount); + let rewards_amount = total_amount.saturating_sub(treasury_amount); + + // Mint rewards to the rewards account + if !rewards_amount.is_zero() { + ::Currency::mint_into( + rewards_account, + rewards_amount, + ) + .map(|_| ()) + .map_err(|_| DispatchError::Other("Failed to mint rewards inflation"))?; + } + + // Mint treasury portion if non-zero + if !treasury_amount.is_zero() { + let treasury_account = TreasuryAccount::get(); + ::Currency::mint_into( + &treasury_account, + treasury_amount, + ) + .map(|_| ()) + .map_err(|_| DispatchError::Other("Failed to mint treasury inflation"))?; + } + + Ok(()) } } @@ -245,7 +273,18 @@ pub fn new_test_ext() -> sp_io::TestExternalities { .build_storage() .unwrap(); - let balances = vec![(1, 100), (2, 100), (3, 100), (4, 100), (5, 100)]; + let balances = vec![ + (1, 100), + (2, 100), + (3, 100), + (4, 100), + (5, 100), + (TreasuryAccount::get(), ExistentialDeposit::get().into()), // Treasury needs existential deposit + ( + RewardsEthereumSovereignAccount::get(), + ExistentialDeposit::get().into(), + ), // Rewards account needs existential deposit + ]; pallet_balances::GenesisConfig:: { balances } .assimilate_storage(&mut t) .unwrap(); diff --git a/operator/pallets/external-validators-rewards/src/tests.rs b/operator/pallets/external-validators-rewards/src/tests.rs index 92e2aefc..1091674a 100644 --- a/operator/pallets/external-validators-rewards/src/tests.rs +++ b/operator/pallets/external-validators-rewards/src/tests.rs @@ -16,6 +16,7 @@ use { crate::{self as pallet_external_validators_rewards, mock::*}, + frame_support::traits::fungible::Mutate, pallet_external_validators::traits::{ActiveEraInfo, OnEraEnd, OnEraStart}, sp_std::collections::btree_map::BTreeMap, }; @@ -211,3 +212,550 @@ fn test_on_era_end_with_zero_points() { ); }) } +#[test] +fn test_inflation_minting() { + new_test_ext().execute_with(|| { + run_to_block(1); + + Mock::mutate(|mock| { + mock.active_era = Some(ActiveEraInfo { + index: 1, + start: None, + }); + // Set inflation amount directly for this test + mock.era_inflation = Some(10_000_000); // 10 million tokens per era + }); + + let rewards_account = RewardsEthereumSovereignAccount::get(); + let initial_rewards_balance = Balances::free_balance(&rewards_account); + + // Reward some validators to create reward points + let points = vec![10u32, 30u32, 50u32]; + let accounts = vec![1u64, 3u64, 5u64]; + let accounts_points: Vec<(u64, crate::RewardPoints)> = accounts + .iter() + .cloned() + .zip(points.iter().cloned()) + .collect(); + ExternalValidatorsRewards::reward_by_ids(accounts_points); + + // Trigger era end which should mint inflation + ExternalValidatorsRewards::on_era_end(1); + + // Verify inflation was minted (80% to rewards, 20% to treasury) + let final_rewards_balance = Balances::free_balance(&rewards_account); + let inflation_amount = + ::EraInflationProvider::get(); + let rewards_amount = inflation_amount * 80 / 100; // 80% goes to rewards + + assert_eq!( + final_rewards_balance, + initial_rewards_balance + rewards_amount, + "Inflation should have been minted to rewards account" + ); + }) +} + +#[test] +fn test_inflation_calculation_with_different_rates() { + new_test_ext().execute_with(|| { + run_to_block(1); + + // Test with different inflation amounts + for inflation_amount in [1_000_000u128, 5_000_000u128, 10_000_000u128] { + Mock::mutate(|mock| { + mock.active_era = Some(ActiveEraInfo { + index: 1, + start: None, + }); + mock.era_inflation = Some(inflation_amount); + }); + + let rewards_account = RewardsEthereumSovereignAccount::get(); + let initial_balance = Balances::free_balance(&rewards_account); + + // Add some reward points + ExternalValidatorsRewards::reward_by_ids([(1, 100)]); + + // Trigger era end + ExternalValidatorsRewards::on_era_end(1); + + // Verify correct amount was minted (80% to rewards, 20% to treasury) + let final_balance = Balances::free_balance(&rewards_account); + let rewards_amount = inflation_amount * 80 / 100; + assert_eq!( + final_balance - initial_balance, + rewards_amount, + "Incorrect inflation amount minted for rate {}", + inflation_amount + ); + + // Clean up for next iteration + Mock::mutate(|mock| { + mock.active_era = Some(ActiveEraInfo { + index: 2, + start: None, + }); + }); + } + }) +} + +#[test] +fn test_no_inflation_with_zero_points() { + new_test_ext().execute_with(|| { + run_to_block(1); + + Mock::mutate(|mock| { + mock.active_era = Some(ActiveEraInfo { + index: 1, + start: None, + }); + mock.era_inflation = Some(10_000_000); + }); + + let rewards_account = RewardsEthereumSovereignAccount::get(); + let initial_balance = Balances::free_balance(&rewards_account); + + // Don't add any reward points (or add zero points) + // This should prevent inflation from being minted + + ExternalValidatorsRewards::on_era_end(1); + + // Verify no inflation was minted because there were no reward points + let final_balance = Balances::free_balance(&rewards_account); + assert_eq!( + final_balance, initial_balance, + "No inflation should be minted when there are no reward points" + ); + }) +} + +#[test] +fn test_inflation_calculation_accuracy() { + new_test_ext().execute_with(|| { + run_to_block(1); + + // Test that the inflation calculation doesn't lose precision + let expected_inflation = 12_345_678_901_234u128; // Large number with precision + + Mock::mutate(|mock| { + mock.active_era = Some(ActiveEraInfo { + index: 1, + start: None, + }); + mock.era_inflation = Some(expected_inflation); + }); + + let rewards_account = RewardsEthereumSovereignAccount::get(); + let initial_balance = Balances::free_balance(&rewards_account); + + // Add reward points + ExternalValidatorsRewards::reward_by_ids([(1, 100), (2, 200)]); + + // Trigger era end + ExternalValidatorsRewards::on_era_end(1); + + // Verify amount was minted (80% to rewards, minor rounding acceptable) + let final_balance = Balances::free_balance(&rewards_account); + let rewards_amount = expected_inflation * 80 / 100; + let actual_minted = final_balance - initial_balance; + // Allow 1 unit difference due to Perbill rounding in treasury calculation + assert!( + actual_minted >= rewards_amount.saturating_sub(1) && + actual_minted <= rewards_amount + 1, + "Inflation calculation should maintain precision (within 1 unit). Expected: {}, Got: {}", + rewards_amount, + actual_minted + ); + }) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Treasury Allocation Tests +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn test_treasury_receives_20_percent_of_inflation() { + new_test_ext().execute_with(|| { + run_to_block(1); + + let base_inflation = 1_000_000u128; + Mock::mutate(|mock| { + mock.active_era = Some(ActiveEraInfo { + index: 1, + start: None, + }); + mock.era_inflation = Some(base_inflation); + }); + + let rewards_account = RewardsEthereumSovereignAccount::get(); + let treasury_account = TreasuryAccount::get(); + + let initial_rewards = Balances::free_balance(&rewards_account); + let initial_treasury = Balances::free_balance(&treasury_account); + + // Add validators to trigger inflation + ExternalValidatorsRewards::reward_by_ids([ + (1, 100), + (2, 100), + (3, 100), + (4, 100), + (5, 100), + ]); + + ExternalValidatorsRewards::on_era_end(1); + + let final_rewards = Balances::free_balance(&rewards_account); + let final_treasury = Balances::free_balance(&treasury_account); + + let rewards_received = final_rewards - initial_rewards; + let treasury_received = final_treasury - initial_treasury; + + // Treasury should receive 20% of total inflation + let expected_treasury = base_inflation * 20 / 100; + let expected_rewards = base_inflation * 80 / 100; + + assert_eq!( + treasury_received, expected_treasury, + "Treasury should receive exactly 20% of inflation" + ); + assert_eq!( + rewards_received, expected_rewards, + "Rewards account should receive exactly 80% of inflation" + ); + assert_eq!( + treasury_received + rewards_received, + base_inflation, + "Total minted should equal base inflation" + ); + }) +} + +#[test] +fn test_treasury_allocation_with_different_amounts() { + new_test_ext().execute_with(|| { + run_to_block(1); + + let treasury_account = TreasuryAccount::get(); + let rewards_account = RewardsEthereumSovereignAccount::get(); + + for (era, inflation) in [(1, 100_000u128), (2, 5_000_000u128), (3, 999_999_999u128)] { + Mock::mutate(|mock| { + mock.active_era = Some(ActiveEraInfo { + index: era, + start: None, + }); + mock.era_inflation = Some(inflation); + }); + + let treasury_before = Balances::free_balance(&treasury_account); + let rewards_before = Balances::free_balance(&rewards_account); + + ExternalValidatorsRewards::reward_by_ids([(1, 100), (2, 100)]); + ExternalValidatorsRewards::on_era_end(era); + + let treasury_after = Balances::free_balance(&treasury_account); + let rewards_after = Balances::free_balance(&rewards_account); + + let treasury_increase = treasury_after - treasury_before; + let rewards_increase = rewards_after - rewards_before; + + // Treasury gets mul_floor of 20%, rewards gets the remainder + // So treasury + rewards should equal total inflation + assert_eq!( + treasury_increase + rewards_increase, + inflation, + "Era {}: Treasury + Rewards should equal total inflation", + era + ); + + // Treasury should be approximately 20% (within 1 unit due to rounding) + let expected_treasury = inflation * 20 / 100; + assert!( + treasury_increase >= expected_treasury.saturating_sub(1) + && treasury_increase <= expected_treasury + 1, + "Era {}: Treasury should get approximately 20%", + era + ); + } + }) +} + +#[test] +fn test_treasury_allocation_maintains_precision() { + new_test_ext().execute_with(|| { + run_to_block(1); + + // Use prime number that doesn't divide evenly by 5 (20%) + let inflation = 1_234_567u128; + + Mock::mutate(|mock| { + mock.active_era = Some(ActiveEraInfo { + index: 1, + start: None, + }); + mock.era_inflation = Some(inflation); + }); + + let treasury_account = TreasuryAccount::get(); + let rewards_account = RewardsEthereumSovereignAccount::get(); + + let treasury_before = Balances::free_balance(&treasury_account); + let rewards_before = Balances::free_balance(&rewards_account); + + ExternalValidatorsRewards::reward_by_ids([(1, 100)]); + ExternalValidatorsRewards::on_era_end(1); + + let treasury_after = Balances::free_balance(&treasury_account); + let rewards_after = Balances::free_balance(&rewards_account); + + let treasury_increase = treasury_after - treasury_before; + let rewards_increase = rewards_after - rewards_before; + let total_minted = treasury_increase + rewards_increase; + + // Total minted should equal total inflation (no rounding loss to exceed inflation) + assert!( + total_minted <= inflation, + "Total minted should not exceed inflation due to rounding" + ); + + // But should be very close (within 1 token for rounding) + assert!( + inflation - total_minted < 100, + "Rounding loss should be minimal" + ); + }) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Edge Case Tests +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn test_single_validator_network() { + new_test_ext().execute_with(|| { + run_to_block(1); + + Mock::mutate(|mock| { + mock.active_era = Some(ActiveEraInfo { + index: 1, + start: None, + }); + mock.era_inflation = Some(1_000_000); + }); + + let rewards_account = RewardsEthereumSovereignAccount::get(); + let initial_balance = Balances::free_balance(&rewards_account); + + // Only one validator participates + ExternalValidatorsRewards::reward_by_ids([(1, 100)]); + + ExternalValidatorsRewards::on_era_end(1); + + let final_balance = Balances::free_balance(&rewards_account); + let inflation_received = final_balance - initial_balance; + + // Single validator should still trigger full inflation (for rewards portion) + assert!( + inflation_received > 0, + "Single validator should receive rewards" + ); + }) +} + +#[test] +fn test_very_large_inflation_no_overflow() { + new_test_ext().execute_with(|| { + run_to_block(1); + + // Use close to u128::MAX to test overflow protection + let large_inflation = u128::MAX / 2; + + Mock::mutate(|mock| { + mock.active_era = Some(ActiveEraInfo { + index: 1, + start: None, + }); + mock.era_inflation = Some(large_inflation); + }); + + let rewards_account = RewardsEthereumSovereignAccount::get(); + let treasury_account = TreasuryAccount::get(); + + let rewards_before = Balances::free_balance(&rewards_account); + let treasury_before = Balances::free_balance(&treasury_account); + + ExternalValidatorsRewards::reward_by_ids([(1, 100)]); + ExternalValidatorsRewards::on_era_end(1); + + let rewards_after = Balances::free_balance(&rewards_account); + let treasury_after = Balances::free_balance(&treasury_account); + + // Should not panic or overflow + assert!(rewards_after >= rewards_before, "Rewards should increase"); + assert!( + treasury_after >= treasury_before, + "Treasury should increase" + ); + + // Total should not exceed input + let total_increase = (rewards_after - rewards_before) + (treasury_after - treasury_before); + assert!( + total_increase <= large_inflation, + "Total minted should not exceed inflation amount" + ); + }) +} + +#[test] +fn test_very_small_inflation_amounts() { + new_test_ext().execute_with(|| { + run_to_block(1); + + // Test with very small amounts + for tiny_amount in [1u128, 2u128, 5u128, 10u128] { + Mock::mutate(|mock| { + mock.active_era = Some(ActiveEraInfo { + index: tiny_amount as u32, + start: None, + }); + mock.era_inflation = Some(tiny_amount); + }); + + let rewards_account = RewardsEthereumSovereignAccount::get(); + let treasury_account = TreasuryAccount::get(); + + let rewards_before = Balances::free_balance(&rewards_account); + let treasury_before = Balances::free_balance(&treasury_account); + + ExternalValidatorsRewards::reward_by_ids([(1, 100)]); + ExternalValidatorsRewards::on_era_end(tiny_amount as u32); + + let rewards_after = Balances::free_balance(&rewards_account); + let treasury_after = Balances::free_balance(&treasury_account); + + let total_minted = + (rewards_after - rewards_before) + (treasury_after - treasury_before); + + // Should handle small amounts gracefully (may round to 0 for treasury) + assert!( + total_minted <= tiny_amount, + "Amount {} should not exceed inflation", + tiny_amount + ); + } + }) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Integration and Regression Tests +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn test_consistent_inflation_across_eras() { + new_test_ext().execute_with(|| { + run_to_block(1); + + let base_inflation = 5_000_000u128; + let rewards_account = RewardsEthereumSovereignAccount::get(); + + // Run multiple eras with identical conditions + for era in 1..=5 { + Mock::mutate(|mock| { + mock.active_era = Some(ActiveEraInfo { + index: era, + start: None, + }); + mock.era_inflation = Some(base_inflation); + }); + + let balance_before = Balances::free_balance(&rewards_account); + + // Same participation every era + ExternalValidatorsRewards::reward_by_ids([(1, 100), (2, 100), (3, 100)]); + + ExternalValidatorsRewards::on_era_end(era); + + let balance_after = Balances::free_balance(&rewards_account); + let inflation = balance_after - balance_before; + + // Each era should mint the same amount given identical conditions + let expected = base_inflation * 80 / 100; // 80% to rewards account + assert_eq!( + inflation, expected, + "Era {}: Inflation should be consistent across eras", + era + ); + } + }) +} + +#[test] +fn test_no_unexpected_balance_changes() { + new_test_ext().execute_with(|| { + run_to_block(1); + + Mock::mutate(|mock| { + mock.active_era = Some(ActiveEraInfo { + index: 1, + start: None, + }); + mock.era_inflation = Some(1_000_000); + }); + + // Check balances of non-participating accounts don't change + let observer_account = 99u64; + let _ = Balances::mint_into(&observer_account, 1000); // Give it some balance + + let observer_balance_before = Balances::free_balance(&observer_account); + + ExternalValidatorsRewards::reward_by_ids([(1, 100), (2, 100)]); + ExternalValidatorsRewards::on_era_end(1); + + let observer_balance_after = Balances::free_balance(&observer_account); + + assert_eq!( + observer_balance_before, observer_balance_after, + "Non-participating accounts should not be affected" + ); + }) +} + +#[test] +fn test_total_issuance_increases_correctly() { + new_test_ext().execute_with(|| { + run_to_block(1); + + let inflation = 10_000_000u128; + + Mock::mutate(|mock| { + mock.active_era = Some(ActiveEraInfo { + index: 1, + start: None, + }); + mock.era_inflation = Some(inflation); + }); + + let total_issuance_before = Balances::total_issuance(); + + ExternalValidatorsRewards::reward_by_ids([ + (1, 100), + (2, 100), + (3, 100), + (4, 100), + (5, 100), + ]); + + ExternalValidatorsRewards::on_era_end(1); + + let total_issuance_after = Balances::total_issuance(); + + // Total issuance should increase by exactly the inflation amount + assert_eq!( + total_issuance_after - total_issuance_before, + inflation, + "Total issuance should increase by inflation amount" + ); + }) +} diff --git a/operator/runtime/common/src/constants.rs b/operator/runtime/common/src/constants.rs index 380566dd..4ebb1979 100644 --- a/operator/runtime/common/src/constants.rs +++ b/operator/runtime/common/src/constants.rs @@ -35,6 +35,10 @@ pub mod time { pub const HOURS: BlockNumber = MINUTES * 60; pub const DAYS: BlockNumber = HOURS * 24; pub const WEEKS: BlockNumber = DAYS * 7; + + /// Milliseconds per year (365.25 days to account for leap years) + /// Used for inflation calculations + pub const MILLISECONDS_PER_YEAR: u128 = 31_557_600_000; } pub mod gas { diff --git a/operator/runtime/common/src/inflation.rs b/operator/runtime/common/src/inflation.rs new file mode 100644 index 00000000..f4c3093a --- /dev/null +++ b/operator/runtime/common/src/inflation.rs @@ -0,0 +1,635 @@ +// Copyright 2025 DataHaven +// This file is part of DataHaven. +// +// DataHaven is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// DataHaven is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with DataHaven. If not, see . + +//! Common inflation handling utilities for validator rewards. +//! +//! This module provides reusable implementations for calculating and minting +//! inflation-based rewards that can be shared across different runtime configurations. + +use crate::constants::time::MILLISECONDS_PER_YEAR; +use frame_support::traits::{fungible::Inspect, Get}; +use sp_runtime::Perbill; + +/// Generic era inflation provider that calculates per-era inflation based on annual inflation rate. +/// +/// # Type Parameters +/// * `R` - Runtime type that provides access to necessary pallets and configuration +/// * `Balances` - Pallet implementing fungible::Inspect for total issuance +/// * `AnnualRate` - Get providing the target annual inflation rate +/// * `SessionsPerEra` - Get providing the number of sessions per era +/// * `BlocksPerSession` - Get providing the number of blocks per session +/// * `MillisecsPerBlock` - Get providing milliseconds per block +/// +/// # Calculation +/// 1. Gets total token issuance from Balances pallet +/// 2. Retrieves annual inflation rate from runtime parameters +/// 3. Calculates eras per year based on era duration +/// 4. Divides annual inflation by eras per year to get per-era amount +pub struct ExternalRewardsEraInflationProvider< + Balances, + AnnualRate, + SessionsPerEra, + BlocksPerSession, + MillisecsPerBlock, +>( + sp_std::marker::PhantomData<( + Balances, + AnnualRate, + SessionsPerEra, + BlocksPerSession, + MillisecsPerBlock, + )>, +); + +impl Get + for ExternalRewardsEraInflationProvider< + Balances, + AnnualRate, + SessionsPerEra, + BlocksPerSession, + MillisecsPerBlock, + > +where + Balances: Inspect, + AnnualRate: Get, + SessionsPerEra: Get, + BlocksPerSession: Get, + MillisecsPerBlock: Get, +{ + fn get() -> u128 { + use sp_runtime::traits::Zero; + + let total_issuance = Balances::total_issuance(); + if total_issuance.is_zero() { + return 0; + } + + let annual_inflation_rate = AnnualRate::get(); + + // Calculate eras per year + // - SessionsPerEra: number of sessions in an era + // - BlocksPerSession: number of blocks in an epoch (session) + // - MillisecsPerBlock: milliseconds per block (6000ms = 6s) + // - Year in milliseconds: 365.25 * 24 * 60 * 60 * 1000 + + let sessions_per_era = SessionsPerEra::get() as u128; + let blocks_per_session = BlocksPerSession::get() as u128; + let millisecs_per_block = MillisecsPerBlock::get() as u128; + + let millisecs_per_era = sessions_per_era + .saturating_mul(blocks_per_session) + .saturating_mul(millisecs_per_block); + + if millisecs_per_era.is_zero() { + log::error!( + target: "ext_validators_rewards", + "Invalid era duration configuration" + ); + return 0; + } + + let eras_per_year = MILLISECONDS_PER_YEAR.saturating_div(millisecs_per_era); + if eras_per_year.is_zero() { + log::error!( + target: "ext_validators_rewards", + "Eras per year is zero, check configuration" + ); + return 0; + } + + // Calculate per-era inflation + let annual_inflation = annual_inflation_rate.mul_floor(total_issuance); + let per_era_inflation = annual_inflation.saturating_div(eras_per_year); + + log::info!( + target: "ext_validators_rewards", + "Per-era inflation: {}", + per_era_inflation + ); + + per_era_inflation + } +} + +/// Generic implementation of inflation handler that mints tokens and splits between rewards and treasury. +/// +/// # Type Parameters +/// * `Balances` - Currency pallet implementing fungible::Mutate for minting +/// * `TreasuryProportion` - Get providing the treasury allocation percentage +/// * `TreasuryAccount` - Get providing the treasury account +/// +/// # Functionality +/// 1. Validates the total amount is non-zero +/// 2. Calculates treasury allocation based on configured proportion +/// 3. Mints rewards portion to the rewards account +/// 4. Mints treasury portion to the treasury account +/// +/// This struct provides a mint_inflation method that can be called from wrapper implementations +/// in your runtime to avoid circular dependencies. +pub struct ExternalRewardsInflationHandler( + sp_std::marker::PhantomData<(Balances, TreasuryProportion, TreasuryAccount)>, +); + +impl + ExternalRewardsInflationHandler +where + Balances: frame_support::traits::fungible::Mutate, + TreasuryProportion: Get, + TreasuryAccount: Get, +{ + /// Mints inflation tokens and splits them between rewards and treasury accounts + pub fn mint_inflation( + rewards_account: &crate::AccountId, + total_amount: u128, + ) -> sp_runtime::DispatchResult { + use sp_runtime::traits::Zero; + + if total_amount.is_zero() { + log::error!( + target: "ext_validators_rewards", + "Attempted to mint zero inflation" + ); + return Err(sp_runtime::DispatchError::Other( + "Cannot mint zero inflation", + )); + } + + // Get treasury allocation proportion + let treasury_proportion = TreasuryProportion::get(); + + // Calculate amounts + let treasury_amount = treasury_proportion.mul_floor(total_amount); + let rewards_amount = total_amount.saturating_sub(treasury_amount); + + log::debug!( + target: "ext_validators_rewards", + "Minting inflation: total={}, treasury={}, rewards={}", + total_amount, + treasury_amount, + rewards_amount + ); + + // Mint rewards to the rewards account + if !rewards_amount.is_zero() { + Balances::mint_into(rewards_account, rewards_amount).map_err(|e| { + log::error!( + target: "ext_validators_rewards", + "Failed to mint rewards inflation: {:?}", + e + ); + sp_runtime::DispatchError::Other("Failed to mint rewards inflation") + })?; + } + + // Mint treasury portion if non-zero + if !treasury_amount.is_zero() { + let treasury_account = TreasuryAccount::get(); + Balances::mint_into(&treasury_account, treasury_amount).map_err(|e| { + log::error!( + target: "ext_validators_rewards", + "Failed to mint treasury inflation: {:?}", + e + ); + sp_runtime::DispatchError::Other("Failed to mint treasury inflation") + })?; + + log::info!( + target: "ext_validators_rewards", + "Successfully minted {} to treasury from inflation", + treasury_amount + ); + } + + log::info!( + target: "ext_validators_rewards", + "Successfully minted {} total inflation ({} to rewards, {} to treasury)", + total_amount, + rewards_amount, + treasury_amount + ); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use frame_support::{ + parameter_types, + traits::fungible::{Inspect, Mutate, Unbalanced}, + }; + use sp_runtime::Perbill; + use std::cell::RefCell; + + // Mock balances storage + thread_local! { + static TOTAL_ISSUANCE: RefCell = const { RefCell::new(0) }; + static BALANCES: RefCell> = RefCell::new(std::collections::HashMap::new()); + } + + struct MockBalances; + + impl Inspect for MockBalances { + type Balance = u128; + + fn total_issuance() -> Self::Balance { + TOTAL_ISSUANCE.with(|v| *v.borrow()) + } + + fn minimum_balance() -> Self::Balance { + 0 + } + + fn balance(_who: &crate::AccountId) -> Self::Balance { + 0 + } + + fn total_balance(_who: &crate::AccountId) -> Self::Balance { + 0 + } + + fn reducible_balance( + _who: &crate::AccountId, + _preservation: frame_support::traits::tokens::Preservation, + _force: frame_support::traits::tokens::Fortitude, + ) -> Self::Balance { + 0 + } + + fn can_deposit( + _who: &crate::AccountId, + _amount: Self::Balance, + _provenance: frame_support::traits::tokens::Provenance, + ) -> frame_support::traits::tokens::DepositConsequence { + frame_support::traits::tokens::DepositConsequence::Success + } + + fn can_withdraw( + _who: &crate::AccountId, + _amount: Self::Balance, + ) -> frame_support::traits::tokens::WithdrawConsequence { + frame_support::traits::tokens::WithdrawConsequence::Success + } + } + + impl Unbalanced for MockBalances { + fn write_balance( + _who: &crate::AccountId, + _amount: Self::Balance, + ) -> Result, sp_runtime::DispatchError> { + Ok(None) + } + + fn set_total_issuance(amount: Self::Balance) -> () { + TOTAL_ISSUANCE.with(|v| *v.borrow_mut() = amount); + } + + fn handle_dust(_dust: frame_support::traits::fungible::Dust) { + // No-op for tests + } + } + + impl Mutate for MockBalances { + fn mint_into( + who: &crate::AccountId, + amount: Self::Balance, + ) -> Result { + BALANCES.with(|b| { + let mut balances = b.borrow_mut(); + let balance = balances.entry(*who).or_insert(0); + *balance = balance.saturating_add(amount); + }); + TOTAL_ISSUANCE.with(|v| { + let mut issuance = v.borrow_mut(); + *issuance = issuance.saturating_add(amount); + }); + Ok(amount) + } + + fn burn_from( + _who: &crate::AccountId, + _amount: Self::Balance, + _preservation: frame_support::traits::tokens::Preservation, + _precision: frame_support::traits::tokens::Precision, + _force: frame_support::traits::tokens::Fortitude, + ) -> Result { + Ok(0) + } + } + + fn treasury_account_id() -> crate::AccountId { + crate::AccountId::from([1u8; 20]) + } + + parameter_types! { + pub TreasuryAccountId: crate::AccountId = treasury_account_id(); + } + + fn reset_balances() { + TOTAL_ISSUANCE.with(|v| *v.borrow_mut() = 0); + BALANCES.with(|b| b.borrow_mut().clear()); + } + + fn get_balance(who: &crate::AccountId) -> u128 { + BALANCES.with(|b| *b.borrow().get(who).unwrap_or(&0)) + } + + fn set_total_issuance(amount: u128) { + TOTAL_ISSUANCE.with(|v| *v.borrow_mut() = amount); + } + + mod era_inflation_provider { + use super::*; + + parameter_types! { + pub const AnnualRate5Percent: Perbill = Perbill::from_percent(5); + pub const AnnualRate10Percent: Perbill = Perbill::from_percent(10); + pub const SessionsPerEra: u32 = 6; + pub const BlocksPerSession: u32 = 600; + pub const MillisecsPerBlock: u64 = 6000; + } + + type TestInflationProvider = ExternalRewardsEraInflationProvider< + MockBalances, + AnnualRate5Percent, + SessionsPerEra, + BlocksPerSession, + MillisecsPerBlock, + >; + + type TestInflationProvider10Pct = ExternalRewardsEraInflationProvider< + MockBalances, + AnnualRate10Percent, + SessionsPerEra, + BlocksPerSession, + MillisecsPerBlock, + >; + + #[test] + fn returns_zero_when_total_issuance_is_zero() { + reset_balances(); + set_total_issuance(0); + assert_eq!(TestInflationProvider::get(), 0); + } + + #[test] + fn calculates_correct_per_era_inflation_5_percent() { + reset_balances(); + // 1 billion tokens total issuance + let total_issuance = 1_000_000_000_000_000_000u128; + set_total_issuance(total_issuance); + + // With 6 sessions per era, 600 blocks per session, 6000ms per block: + // millisecs_per_era = 6 * 600 * 6000 = 21,600,000ms = 6 hours + // eras_per_year = 31,557,600,000 / 21,600,000 = 1461 eras + // annual_inflation = 5% of 1B = 50M + // per_era_inflation = 50M / 1461 ≈ 34,223,312 + + let per_era_inflation = TestInflationProvider::get(); + + // Expected: 5% of total_issuance / number of eras per year + // With the calculation: total_issuance * rate * millisecs_per_era / MILLISECONDS_PER_YEAR + let expected = 34_223_134_839_151u128; + assert_eq!(per_era_inflation, expected); + } + + #[test] + fn calculates_correct_per_era_inflation_10_percent() { + reset_balances(); + let total_issuance = 1_000_000_000_000_000_000u128; + set_total_issuance(total_issuance); + + let per_era_inflation = TestInflationProvider10Pct::get(); + + // Expected: 10% of total_issuance / number of eras per year + let expected = 68_446_269_678_302u128; + assert_eq!(per_era_inflation, expected); + } + + #[test] + fn scales_with_total_issuance() { + reset_balances(); + + // Test with 100M tokens + set_total_issuance(100_000_000_000_000_000u128); + let inflation_100m = TestInflationProvider::get(); + + // Test with 1B tokens (10x more) + set_total_issuance(1_000_000_000_000_000_000u128); + let inflation_1b = TestInflationProvider::get(); + + // Inflation should scale proportionally (allow 1 unit tolerance for rounding) + let expected = inflation_100m * 10; + assert!( + inflation_1b >= expected.saturating_sub(1) && inflation_1b <= expected + 1, + "Expected inflation to be ~{}, got {}", + expected, + inflation_1b + ); + } + + #[test] + fn handles_different_era_durations() { + reset_balances(); + set_total_issuance(1_000_000_000_000_000_000u128); + + parameter_types! { + pub const LongEraSessionsPerEra: u32 = 12; // Double the sessions + } + + type LongEraProvider = ExternalRewardsEraInflationProvider< + MockBalances, + AnnualRate5Percent, + LongEraSessionsPerEra, + BlocksPerSession, + MillisecsPerBlock, + >; + + let standard_era = TestInflationProvider::get(); + let long_era = LongEraProvider::get(); + + // Longer eras should have roughly 2x the inflation per era + // (since there are half as many eras per year) + assert!(long_era > standard_era * 19 / 10); // Allow some rounding tolerance + assert!(long_era < standard_era * 21 / 10); + } + } + + mod inflation_handler { + use super::*; + + parameter_types! { + pub const TreasuryProportion20Pct: Perbill = Perbill::from_percent(20); + pub const TreasuryProportion50Pct: Perbill = Perbill::from_percent(50); + pub const TreasuryProportion0Pct: Perbill = Perbill::from_percent(0); + pub const TreasuryProportion100Pct: Perbill = Perbill::from_percent(100); + } + + type TestHandler = ExternalRewardsInflationHandler< + MockBalances, + TreasuryProportion20Pct, + TreasuryAccountId, + >; + + type TestHandler50Pct = ExternalRewardsInflationHandler< + MockBalances, + TreasuryProportion50Pct, + TreasuryAccountId, + >; + + type TestHandler0Pct = ExternalRewardsInflationHandler< + MockBalances, + TreasuryProportion0Pct, + TreasuryAccountId, + >; + + type TestHandler100Pct = ExternalRewardsInflationHandler< + MockBalances, + TreasuryProportion100Pct, + TreasuryAccountId, + >; + + #[test] + fn rejects_zero_amount() { + reset_balances(); + let rewards_account = crate::AccountId::from([2u8; 20]); + + let result = TestHandler::mint_inflation(&rewards_account, 0); + assert!(result.is_err()); + } + + #[test] + fn splits_inflation_correctly_20_percent_treasury() { + reset_balances(); + let rewards_account = crate::AccountId::from([2u8; 20]); + let total_inflation = 1_000_000u128; + + let result = TestHandler::mint_inflation(&rewards_account, total_inflation); + assert!(result.is_ok()); + + let rewards_balance = get_balance(&rewards_account); + let treasury_balance = get_balance(&TreasuryAccountId::get()); + + // 20% to treasury, 80% to rewards + assert_eq!(treasury_balance, 200_000); + assert_eq!(rewards_balance, 800_000); + assert_eq!(rewards_balance + treasury_balance, total_inflation); + } + + #[test] + fn splits_inflation_correctly_50_percent_treasury() { + reset_balances(); + let rewards_account = crate::AccountId::from([2u8; 20]); + let total_inflation = 1_000_000u128; + + let result = TestHandler50Pct::mint_inflation(&rewards_account, total_inflation); + assert!(result.is_ok()); + + let rewards_balance = get_balance(&rewards_account); + let treasury_balance = get_balance(&TreasuryAccountId::get()); + + // 50% to treasury, 50% to rewards + assert_eq!(treasury_balance, 500_000); + assert_eq!(rewards_balance, 500_000); + } + + #[test] + fn handles_zero_percent_treasury() { + reset_balances(); + let rewards_account = crate::AccountId::from([2u8; 20]); + let total_inflation = 1_000_000u128; + + let result = TestHandler0Pct::mint_inflation(&rewards_account, total_inflation); + assert!(result.is_ok()); + + let rewards_balance = get_balance(&rewards_account); + let treasury_balance = get_balance(&TreasuryAccountId::get()); + + // 0% to treasury, 100% to rewards + assert_eq!(treasury_balance, 0); + assert_eq!(rewards_balance, total_inflation); + } + + #[test] + fn handles_hundred_percent_treasury() { + reset_balances(); + let rewards_account = crate::AccountId::from([2u8; 20]); + let total_inflation = 1_000_000u128; + + let result = TestHandler100Pct::mint_inflation(&rewards_account, total_inflation); + assert!(result.is_ok()); + + let rewards_balance = get_balance(&rewards_account); + let treasury_balance = get_balance(&TreasuryAccountId::get()); + + // 100% to treasury, 0% to rewards + assert_eq!(treasury_balance, total_inflation); + assert_eq!(rewards_balance, 0); + } + + #[test] + fn updates_total_issuance() { + reset_balances(); + let rewards_account = crate::AccountId::from([2u8; 20]); + let total_inflation = 1_000_000u128; + + let issuance_before = MockBalances::total_issuance(); + + let result = TestHandler::mint_inflation(&rewards_account, total_inflation); + assert!(result.is_ok()); + + let issuance_after = MockBalances::total_issuance(); + + assert_eq!(issuance_after, issuance_before + total_inflation); + } + + #[test] + fn handles_large_amounts() { + reset_balances(); + let rewards_account = crate::AccountId::from([2u8; 20]); + let total_inflation = u128::MAX / 2; // Very large amount + + let result = TestHandler::mint_inflation(&rewards_account, total_inflation); + assert!(result.is_ok()); + + let rewards_balance = get_balance(&rewards_account); + let treasury_balance = get_balance(&TreasuryAccountId::get()); + + // Verify proportions are maintained even with large numbers + assert_eq!(rewards_balance + treasury_balance, total_inflation); + } + + #[test] + fn handles_rounding_correctly() { + reset_balances(); + let rewards_account = crate::AccountId::from([2u8; 20]); + // Amount that will cause rounding: 100 with 20% treasury + let total_inflation = 100u128; + + let result = TestHandler::mint_inflation(&rewards_account, total_inflation); + assert!(result.is_ok()); + + let rewards_balance = get_balance(&rewards_account); + let treasury_balance = get_balance(&TreasuryAccountId::get()); + + // 20% of 100 = 20, rewards = 80 + assert_eq!(treasury_balance, 20); + assert_eq!(rewards_balance, 80); + assert_eq!(rewards_balance + treasury_balance, total_inflation); + } + } +} diff --git a/operator/runtime/common/src/lib.rs b/operator/runtime/common/src/lib.rs index 92c20e98..fc614aa7 100644 --- a/operator/runtime/common/src/lib.rs +++ b/operator/runtime/common/src/lib.rs @@ -22,6 +22,7 @@ pub use constants::*; pub mod benchmarking; pub mod deal_with_fees; pub mod impl_on_charge_evm_transaction; +pub mod inflation; pub mod migrations; pub use migrations::*; pub mod safe_mode; diff --git a/operator/runtime/mainnet/src/configs/mod.rs b/operator/runtime/mainnet/src/configs/mod.rs index dc16b9ca..13970526 100644 --- a/operator/runtime/mainnet/src/configs/mod.rs +++ b/operator/runtime/mainnet/src/configs/mod.rs @@ -32,7 +32,7 @@ use super::{ }; use codec::{Decode, Encode, MaxEncodedLen}; use scale_info::TypeInfo; -use sp_runtime::RuntimeDebug; +use sp_runtime::{traits::AccountIdConversion, RuntimeDebug}; /// A description of our proxy types. /// Proxy types are used to restrict the calls that can be made by a proxy account. @@ -897,6 +897,14 @@ parameter_types! { pub const TreasuryId: PalletId = PalletId(*b"pc/trsry"); pub TreasuryAccount: AccountId = Treasury::account_id(); pub const MaxSpendBalance: crate::Balance = crate::Balance::max_value(); + + /// PalletId for the External Validator Rewards account. + /// This account receives minted inflation tokens before they are bridged to Ethereum + /// for distribution to validators via EigenLayer. + /// + /// Governance/Sudo can transfer funds using: pallet_balances::force_transfer + pub const ExternalValidatorRewardsId: PalletId = PalletId(*b"dh/evrew"); + pub ExternalValidatorRewardsAccount: AccountId = ExternalValidatorRewardsId::get().into_account_truncating(); } type RootOrTreasuryCouncilOrigin = EitherOfDiverse< @@ -1416,6 +1424,41 @@ impl Get> for GetWhitelistedValidators { } } +/// Type alias for the era inflation provider using common runtime implementation. +/// +/// Calculates per-era inflation based on: +/// - Total token issuance (from Balances pallet) +/// - Annual inflation rate (from InflationTargetedAnnualRate dynamic parameter) +/// - Era duration calculated from SessionsPerEra, EpochDurationInBlocks, and MILLISECS_PER_BLOCK +pub type ExternalRewardsEraInflationProvider = + datahaven_runtime_common::inflation::ExternalRewardsEraInflationProvider< + Balances, + runtime_params::dynamic_params::runtime_config::InflationTargetedAnnualRate, + SessionsPerEra, + EpochDurationInBlocks, + ConstU64, + >; + +/// Wrapper struct for the inflation handler using common runtime implementation. +/// +/// Handles minting of inflation tokens by: +/// 1. Splitting total inflation between rewards and treasury based on InflationTreasuryProportion +/// 2. Minting rewards portion to the rewards account +/// 3. Minting treasury portion to the treasury account +pub struct ExternalRewardsInflationHandler; + +impl pallet_external_validators_rewards::types::HandleInflation + for ExternalRewardsInflationHandler +{ + fn mint_inflation(who: &AccountId, amount: u128) -> sp_runtime::DispatchResult { + datahaven_runtime_common::inflation::ExternalRewardsInflationHandler::< + Balances, + runtime_params::dynamic_params::runtime_config::InflationTreasuryProportion, + TreasuryAccount, + >::mint_inflation(who, amount) + } +} + // Stub SendMessage implementation for rewards pallet pub struct RewardsSendAdapter; impl pallet_external_validators_rewards::types::SendMessage for RewardsSendAdapter { @@ -1479,16 +1522,21 @@ impl pallet_external_validators_rewards::Config for Runtime { type RuntimeEvent = RuntimeEvent; type EraIndexProvider = ExternalValidators; type HistoryDepth = ConstU32<64>; - type BackingPoints = ConstU32<20>; - type DisputeStatementPoints = ConstU32<20>; - type EraInflationProvider = ConstU128<0>; + + // NOT USED: DataHaven is a solochain with BABE+GRANDPA consensus, not a parachain. + // Backing and dispute points are only relevant for parachain validation. + // These are set to 0 to make it explicit they're unused. + type BackingPoints = ConstU32<0>; + type DisputeStatementPoints = ConstU32<0>; + + type EraInflationProvider = ExternalRewardsEraInflationProvider; type ExternalIndexProvider = ExternalValidators; type GetWhitelistedValidators = GetWhitelistedValidators; type Hashing = Keccak256; type Currency = Balances; - type RewardsEthereumSovereignAccount = TreasuryAccount; + type RewardsEthereumSovereignAccount = ExternalValidatorRewardsAccount; type SendMessage = RewardsSendAdapter; - type HandleInflation = (); + type HandleInflation = ExternalRewardsInflationHandler; type WeightInfo = mainnet_weights::pallet_external_validators_rewards::WeightInfo; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = (); diff --git a/operator/runtime/mainnet/src/configs/runtime_params.rs b/operator/runtime/mainnet/src/configs/runtime_params.rs index e039bb1a..4d1527e3 100644 --- a/operator/runtime/mainnet/src/configs/runtime_params.rs +++ b/operator/runtime/mainnet/src/configs/runtime_params.rs @@ -339,6 +339,24 @@ pub mod dynamic_params { #[allow(non_upper_case_globals)] /// The AVS ethereum address for Datahaven. Via this address we relay slashing requests or other requests. pub static DatahavenAVSAddress: H160 = H160::repeat_byte(0x0); + + // ╔══════════════════════ Validator Rewards Inflation ═══════════════════════╗ + + #[codec(index = 37)] + #[allow(non_upper_case_globals)] + /// Targeted annual inflation rate. + /// Default: 5% per annum + /// This rate is divided across all eras in a year to calculate per-era inflation. + pub static InflationTargetedAnnualRate: Perbill = Perbill::from_percent(5); + + #[codec(index = 38)] + #[allow(non_upper_case_globals)] + /// Proportion of inflation rewards allocated to the treasury. + /// Default: 20% of minted rewards go to treasury, 80% to validator rewards + /// The treasury portion is minted separately and sent to the treasury account. + pub static InflationTreasuryProportion: Perbill = Perbill::from_percent(20); + + // ╚══════════════════════ Validator Rewards Inflation ═══════════════════════╝ } } diff --git a/operator/runtime/stagenet/src/configs/mod.rs b/operator/runtime/stagenet/src/configs/mod.rs index bd569552..9591759a 100644 --- a/operator/runtime/stagenet/src/configs/mod.rs +++ b/operator/runtime/stagenet/src/configs/mod.rs @@ -32,7 +32,7 @@ use super::{ }; use codec::{Decode, Encode, MaxEncodedLen}; use scale_info::TypeInfo; -use sp_runtime::RuntimeDebug; +use sp_runtime::{traits::AccountIdConversion, RuntimeDebug}; /// A description of our proxy types. /// Proxy types are used to restrict the calls that can be made by a proxy account. @@ -900,6 +900,14 @@ parameter_types! { pub const TreasuryId: PalletId = PalletId(*b"pc/trsry"); pub TreasuryAccount: AccountId = Treasury::account_id(); pub const MaxSpendBalance: crate::Balance = crate::Balance::max_value(); + + /// PalletId for the External Validator Rewards account. + /// This account receives minted inflation tokens before they are bridged to Ethereum + /// for distribution to validators via EigenLayer. + /// + /// Governance/Sudo can transfer funds using: pallet_balances::force_transfer + pub const ExternalValidatorRewardsId: PalletId = PalletId(*b"dh/evrew"); + pub ExternalValidatorRewardsAccount: AccountId = ExternalValidatorRewardsId::get().into_account_truncating(); } type RootOrTreasuryCouncilOrigin = EitherOfDiverse< @@ -1421,6 +1429,41 @@ impl Get> for GetWhitelistedValidators { } } +/// Type alias for the era inflation provider using common runtime implementation. +/// +/// Calculates per-era inflation based on: +/// - Total token issuance (from Balances pallet) +/// - Annual inflation rate (from InflationTargetedAnnualRate dynamic parameter) +/// - Era duration calculated from SessionsPerEra, EpochDurationInBlocks, and MILLISECS_PER_BLOCK +pub type ExternalRewardsEraInflationProvider = + datahaven_runtime_common::inflation::ExternalRewardsEraInflationProvider< + Balances, + runtime_params::dynamic_params::runtime_config::InflationTargetedAnnualRate, + SessionsPerEra, + EpochDurationInBlocks, + ConstU64, + >; + +/// Wrapper struct for the inflation handler using common runtime implementation. +/// +/// Handles minting of inflation tokens by: +/// 1. Splitting total inflation between rewards and treasury based on InflationTreasuryProportion +/// 2. Minting rewards portion to the rewards account +/// 3. Minting treasury portion to the treasury account +pub struct ExternalRewardsInflationHandler; + +impl pallet_external_validators_rewards::types::HandleInflation + for ExternalRewardsInflationHandler +{ + fn mint_inflation(who: &AccountId, amount: u128) -> sp_runtime::DispatchResult { + datahaven_runtime_common::inflation::ExternalRewardsInflationHandler::< + Balances, + runtime_params::dynamic_params::runtime_config::InflationTreasuryProportion, + TreasuryAccount, + >::mint_inflation(who, amount) + } +} + // Stub SendMessage implementation for rewards pallet pub struct RewardsSendAdapter; impl pallet_external_validators_rewards::types::SendMessage for RewardsSendAdapter { @@ -1484,17 +1527,22 @@ impl pallet_external_validators_rewards::Config for Runtime { type RuntimeEvent = RuntimeEvent; type EraIndexProvider = ExternalValidators; type HistoryDepth = ConstU32<64>; - type BackingPoints = ConstU32<20>; - type DisputeStatementPoints = ConstU32<20>; - type EraInflationProvider = ConstU128<0>; + + // NOT USED: DataHaven is a solochain with BABE+GRANDPA consensus, not a parachain. + // Backing and dispute points are only relevant for parachain validation. + // These are set to 0 to make it explicit they're unused. + type BackingPoints = ConstU32<0>; + type DisputeStatementPoints = ConstU32<0>; + + type EraInflationProvider = ExternalRewardsEraInflationProvider; type ExternalIndexProvider = ExternalValidators; type GetWhitelistedValidators = GetWhitelistedValidators; type Hashing = Keccak256; type Currency = Balances; - type RewardsEthereumSovereignAccount = TreasuryAccount; + type RewardsEthereumSovereignAccount = ExternalValidatorRewardsAccount; type WeightInfo = stagenet_weights::pallet_external_validators_rewards::WeightInfo; type SendMessage = RewardsSendAdapter; - type HandleInflation = (); + type HandleInflation = ExternalRewardsInflationHandler; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = (); } diff --git a/operator/runtime/stagenet/src/configs/runtime_params.rs b/operator/runtime/stagenet/src/configs/runtime_params.rs index 1280d2ba..333b3dfb 100644 --- a/operator/runtime/stagenet/src/configs/runtime_params.rs +++ b/operator/runtime/stagenet/src/configs/runtime_params.rs @@ -344,6 +344,24 @@ pub mod dynamic_params { #[allow(non_upper_case_globals)] /// The AVS ethereum address for Datahaven. Via this address we relay slashing requests or other requests. pub static DatahavenAVSAddress: H160 = H160::repeat_byte(0x0); + + // ╔══════════════════════ Validator Rewards Inflation ═══════════════════════╗ + + #[codec(index = 37)] + #[allow(non_upper_case_globals)] + /// Targeted annual inflation rate. + /// Default: 5% per annum + /// This rate is divided across all eras in a year to calculate per-era inflation. + pub static InflationTargetedAnnualRate: Perbill = Perbill::from_percent(5); + + #[codec(index = 38)] + #[allow(non_upper_case_globals)] + /// Proportion of inflation rewards allocated to the treasury. + /// Default: 20% of minted rewards go to treasury, 80% to validator rewards + /// The treasury portion is minted separately and sent to the treasury account. + pub static InflationTreasuryProportion: Perbill = Perbill::from_percent(20); + + // ╚══════════════════════ Validator Rewards Inflation ═══════════════════════╝ } } diff --git a/operator/runtime/testnet/src/configs/mod.rs b/operator/runtime/testnet/src/configs/mod.rs index 64a74bdd..faa43057 100644 --- a/operator/runtime/testnet/src/configs/mod.rs +++ b/operator/runtime/testnet/src/configs/mod.rs @@ -32,7 +32,7 @@ use super::{ }; use codec::{Decode, Encode, MaxEncodedLen}; use scale_info::TypeInfo; -use sp_runtime::RuntimeDebug; +use sp_runtime::{traits::AccountIdConversion, RuntimeDebug}; /// A description of our proxy types. /// Proxy types are used to restrict the calls that can be made by a proxy account. @@ -903,6 +903,14 @@ parameter_types! { pub const TreasuryId: PalletId = PalletId(*b"pc/trsry"); pub TreasuryAccount: AccountId = Treasury::account_id(); pub const MaxSpendBalance: crate::Balance = crate::Balance::max_value(); + + /// PalletId for the External Validator Rewards account. + /// This account receives minted inflation tokens before they are bridged to Ethereum + /// for distribution to validators via EigenLayer. + /// + /// Governance/Sudo can transfer funds using: pallet_balances::force_transfer + pub const ExternalValidatorRewardsId: PalletId = PalletId(*b"dh/evrew"); + pub ExternalValidatorRewardsAccount: AccountId = ExternalValidatorRewardsId::get().into_account_truncating(); } type RootOrTreasuryCouncilOrigin = EitherOfDiverse< @@ -1422,6 +1430,41 @@ impl Get> for GetWhitelistedValidators { } } +/// Type alias for the era inflation provider using common runtime implementation. +/// +/// Calculates per-era inflation based on: +/// - Total token issuance (from Balances pallet) +/// - Annual inflation rate (from InflationTargetedAnnualRate dynamic parameter) +/// - Era duration calculated from SessionsPerEra, EpochDurationInBlocks, and MILLISECS_PER_BLOCK +pub type ExternalRewardsEraInflationProvider = + datahaven_runtime_common::inflation::ExternalRewardsEraInflationProvider< + Balances, + runtime_params::dynamic_params::runtime_config::InflationTargetedAnnualRate, + SessionsPerEra, + EpochDurationInBlocks, + ConstU64, + >; + +/// Wrapper struct for the inflation handler using common runtime implementation. +/// +/// Handles minting of inflation tokens by: +/// 1. Splitting total inflation between rewards and treasury based on InflationTreasuryProportion +/// 2. Minting rewards portion to the rewards account +/// 3. Minting treasury portion to the treasury account +pub struct ExternalRewardsInflationHandler; + +impl pallet_external_validators_rewards::types::HandleInflation + for ExternalRewardsInflationHandler +{ + fn mint_inflation(who: &AccountId, amount: u128) -> sp_runtime::DispatchResult { + datahaven_runtime_common::inflation::ExternalRewardsInflationHandler::< + Balances, + runtime_params::dynamic_params::runtime_config::InflationTreasuryProportion, + TreasuryAccount, + >::mint_inflation(who, amount) + } +} + // Stub SendMessage implementation for rewards pallet pub struct RewardsSendAdapter; impl pallet_external_validators_rewards::types::SendMessage for RewardsSendAdapter { @@ -1485,17 +1528,22 @@ impl pallet_external_validators_rewards::Config for Runtime { type RuntimeEvent = RuntimeEvent; type EraIndexProvider = ExternalValidators; type HistoryDepth = ConstU32<64>; - type BackingPoints = ConstU32<20>; - type DisputeStatementPoints = ConstU32<20>; - type EraInflationProvider = ConstU128<0>; + + // NOT USED: DataHaven is a solochain with BABE+GRANDPA consensus, not a parachain. + // Backing and dispute points are only relevant for parachain validation. + // These are set to 0 to make it explicit they're unused. + type BackingPoints = ConstU32<0>; + type DisputeStatementPoints = ConstU32<0>; + + type EraInflationProvider = ExternalRewardsEraInflationProvider; type ExternalIndexProvider = ExternalValidators; type GetWhitelistedValidators = GetWhitelistedValidators; type Hashing = Keccak256; type Currency = Balances; - type RewardsEthereumSovereignAccount = TreasuryAccount; + type RewardsEthereumSovereignAccount = ExternalValidatorRewardsAccount; type WeightInfo = testnet_weights::pallet_external_validators_rewards::WeightInfo; type SendMessage = RewardsSendAdapter; - type HandleInflation = (); + type HandleInflation = ExternalRewardsInflationHandler; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = (); } diff --git a/operator/runtime/testnet/src/configs/runtime_params.rs b/operator/runtime/testnet/src/configs/runtime_params.rs index 0bc9ac09..51a189a8 100644 --- a/operator/runtime/testnet/src/configs/runtime_params.rs +++ b/operator/runtime/testnet/src/configs/runtime_params.rs @@ -340,6 +340,24 @@ pub mod dynamic_params { #[allow(non_upper_case_globals)] /// The AVS ethereum address for Datahaven. Via this address we relay slashing requests or other requests. pub static DatahavenAVSAddress: H160 = H160::repeat_byte(0x0); + + // ╔══════════════════════ Validator Rewards Inflation ═══════════════════════╗ + + #[codec(index = 37)] + #[allow(non_upper_case_globals)] + /// Targeted annual inflation rate. + /// Default: 5% per annum + /// This rate is divided across all eras in a year to calculate per-era inflation. + pub static InflationTargetedAnnualRate: Perbill = Perbill::from_percent(5); + + #[codec(index = 38)] + #[allow(non_upper_case_globals)] + /// Proportion of inflation rewards allocated to the treasury. + /// Default: 20% of minted rewards go to treasury, 80% to validator rewards + /// The treasury portion is minted separately and sent to the treasury account. + pub static InflationTreasuryProportion: Perbill = Perbill::from_percent(20); + + // ╚══════════════════════ Validator Rewards Inflation ═══════════════════════╝ } } diff --git a/test/.papi/descriptors/package.json b/test/.papi/descriptors/package.json index 4b8553bd..3a795e80 100644 --- a/test/.papi/descriptors/package.json +++ b/test/.papi/descriptors/package.json @@ -1,5 +1,5 @@ { - "version": "0.1.0-autogenerated.13402936638329471124", + "version": "0.1.0-autogenerated.427841660215020592", "name": "@polkadot-api/descriptors", "files": [ "dist" diff --git a/test/.papi/metadata/datahaven.scale b/test/.papi/metadata/datahaven.scale index b58d3a09..e28b0323 100644 Binary files a/test/.papi/metadata/datahaven.scale and b/test/.papi/metadata/datahaven.scale differ