From 1a9e2133223d6efcbc30c8e48cc7d014ddc4320c Mon Sep 17 00:00:00 2001 From: Ahmad Kaouk <56095276+ahmadkaouk@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:19:20 +0100 Subject: [PATCH] fix: use post-treasury-split amount in AVS rewards message (#459) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The rewards message sent to EigenLayer AVS via Snowbridge was reporting the **full inflation amount** (100%) as the distributable rewards, while only **80%** was actually minted to the rewards account (the other 20% goes to treasury). This mismatch could cause underfunding, failed reward claims, or accounting discrepancies on the Ethereum side. ### Root Cause In `on_era_end`, the same `scaled_inflation` value was passed to both `mint_inflation` (which internally splits 80/20) and `generate_era_rewards_utils` (which builds the outbound AVS message). The message therefore claimed more tokens were available for distribution than were actually in the rewards account. ### Fix - Changed `HandleInflation::mint_inflation` to return a self-documenting `InflationMintResult` struct instead of `DispatchResult`: ```rust pub struct InflationMintResult { pub rewards_amount: u128, // minted to rewards account (for AVS distribution) pub treasury_amount: u128, // minted to treasury account } ``` - Restructured `on_era_end` to use `mint_result.rewards_amount` (post-treasury split) when building the AVS message and emitting the event - Added an explicit reward-points check before minting to preserve the existing behavior of skipping inflation when no validators earned rewards ### Files Changed - **`types.rs`** — Added `InflationMintResult` struct; updated `HandleInflation` trait signature - **`inflation.rs`** — Updated handler to return `InflationMintResult`; strengthened unit tests to assert both fields - **`lib.rs`** — Restructured `on_era_end`: check points → mint → build message with `mint_result.rewards_amount` - **`mock.rs`** — Updated mock to return `InflationMintResult` - **Runtime configs** (mainnet, stagenet, testnet) — Updated wrapper impls - **`tests.rs`** — Updated event assertion to expect post-treasury amount ## Test plan - [x] All 76 pallet unit tests pass (`cargo test -p pallet-external-validators-rewards --lib`) - [ ] CI passes --- .../external-validators-rewards/src/lib.rs | 56 +++++++++++++------ .../external-validators-rewards/src/mock.rs | 10 +++- .../external-validators-rewards/src/tests.rs | 8 ++- .../external-validators-rewards/src/types.rs | 28 ++++++++-- operator/runtime/common/src/inflation.rs | 48 +++++++++++++--- operator/runtime/mainnet/src/configs/mod.rs | 8 ++- operator/runtime/stagenet/src/configs/mod.rs | 8 ++- operator/runtime/testnet/src/configs/mod.rs | 8 ++- 8 files changed, 139 insertions(+), 35 deletions(-) diff --git a/operator/pallets/external-validators-rewards/src/lib.rs b/operator/pallets/external-validators-rewards/src/lib.rs index d346c67b..376d3a55 100644 --- a/operator/pallets/external-validators-rewards/src/lib.rs +++ b/operator/pallets/external-validators-rewards/src/lib.rs @@ -625,10 +625,43 @@ pub mod pallet { impl OnEraEnd for Pallet { fn on_era_end(era_index: EraIndex) { // Calculate performance-scaled inflation based on blocks produced. - // This must be done first since we use it for both minting and the rewards message. let base_inflation = T::EraInflationProvider::get(); let scaled_inflation = Self::calculate_scaled_inflation(era_index, base_inflation); + // Check that there are reward points before minting. + // This prevents minting inflation when no validators have earned rewards. + let era_reward_points = RewardPointsForEra::::get(&era_index); + let total_points: u128 = era_reward_points + .individual + .values() + .map(|pts| *pts as u128) + .sum(); + + if total_points.is_zero() { + log::error!( + target: "ext_validators_rewards", + "No reward points in era {}, skipping inflation minting", + era_index + ); + return; + } + + let ethereum_sovereign_account = T::RewardsEthereumSovereignAccount::get(); + + // Mint scaled inflation tokens using the configurable handler. + // Returns an InflationMintResult with the rewards/treasury split. + let mint_result = match T::HandleInflation::mint_inflation( + ðereum_sovereign_account, + scaled_inflation, + ) { + Ok(result) => result, + Err(err) => { + log::error!(target: "ext_validators_rewards", "Failed to handle inflation: {err:?}"); + log::error!(target: "ext_validators_rewards", "Not sending message since there are no rewards to distribute"); + return; + } + }; + // Get era start timestamp from the active era (still the ending era at this point). // Convert from milliseconds to seconds for EigenLayer compatibility. let era_start_timestamp = T::EraIndexProvider::active_era() @@ -636,11 +669,11 @@ pub mod pallet { .map(|ms| (ms / 1000) as u32) .unwrap_or(0); - // Generate era rewards utils with the scaled inflation amount. - // This ensures the message to EigenLayer matches the actual minted amount. - let utils = match RewardPointsForEra::::get(&era_index).generate_era_rewards_utils( + // Generate era rewards utils with the actual rewards amount (post-treasury split). + // This ensures the message to EigenLayer matches the actual minted rewards. + let utils = match era_reward_points.generate_era_rewards_utils( era_index, - scaled_inflation, + mint_result.rewards_amount, era_start_timestamp, ) { Some(utils) => utils, @@ -654,17 +687,6 @@ pub mod pallet { } }; - let ethereum_sovereign_account = T::RewardsEthereumSovereignAccount::get(); - - // Mint scaled inflation tokens using the configurable handler - if let Err(err) = - T::HandleInflation::mint_inflation(ðereum_sovereign_account, scaled_inflation) - { - log::error!(target: "ext_validators_rewards", "Failed to handle inflation: {err:?}"); - log::error!(target: "ext_validators_rewards", "Not sending message since there are no rewards to distribute"); - return; - } - frame_system::Pallet::::register_extra_weight_unchecked( T::WeightInfo::on_era_end(), DispatchClass::Mandatory, @@ -675,7 +697,7 @@ pub mod pallet { message_id, era_index, total_points: utils.total_points, - inflation_amount: scaled_inflation, + inflation_amount: mint_result.rewards_amount, }); } } diff --git a/operator/pallets/external-validators-rewards/src/mock.rs b/operator/pallets/external-validators-rewards/src/mock.rs index 4d854751..3c892f35 100644 --- a/operator/pallets/external-validators-rewards/src/mock.rs +++ b/operator/pallets/external-validators-rewards/src/mock.rs @@ -230,7 +230,10 @@ impl pallet_external_validators_rewards::Config for Test { pub struct InflationMinter; impl HandleInflation for InflationMinter { - fn mint_inflation(rewards_account: &H160, total_amount: u128) -> sp_runtime::DispatchResult { + fn mint_inflation( + rewards_account: &H160, + total_amount: u128, + ) -> Result { use sp_runtime::traits::Zero; if total_amount.is_zero() { @@ -266,7 +269,10 @@ impl HandleInflation for InflationMinter { .map_err(|_| DispatchError::Other("Failed to mint treasury inflation"))?; } - Ok(()) + Ok(crate::types::InflationMintResult { + rewards_amount, + treasury_amount, + }) } } diff --git a/operator/pallets/external-validators-rewards/src/tests.rs b/operator/pallets/external-validators-rewards/src/tests.rs index 986c624c..1a66daa0 100644 --- a/operator/pallets/external-validators-rewards/src/tests.rs +++ b/operator/pallets/external-validators-rewards/src/tests.rs @@ -160,15 +160,19 @@ fn test_on_era_end() { let era_rewards = pallet_external_validators_rewards::RewardPointsForEra::::get(1); let inflation = ::EraInflationProvider::get(); + // The event should contain the rewards amount (post-treasury split), not the full inflation. + // Treasury gets Perbill::from_percent(20).mul_floor(inflation), rewards gets the rest. + let treasury_amount = InflationTreasuryProportion::get().mul_floor(inflation); + let rewards_amount = inflation - treasury_amount; // Use 0 for era_start_timestamp in tests - let rewards_utils = era_rewards.generate_era_rewards_utils(1, inflation, 0); + let rewards_utils = era_rewards.generate_era_rewards_utils(1, rewards_amount, 0); assert!(rewards_utils.is_some()); System::assert_last_event(RuntimeEvent::ExternalValidatorsRewards( crate::Event::RewardsMessageSent { message_id: Default::default(), era_index: 1, total_points: total_points as u128, - inflation_amount: inflation, + inflation_amount: rewards_amount, }, )); }) diff --git a/operator/pallets/external-validators-rewards/src/types.rs b/operator/pallets/external-validators-rewards/src/types.rs index cb62aa47..540e6b94 100644 --- a/operator/pallets/external-validators-rewards/src/types.rs +++ b/operator/pallets/external-validators-rewards/src/types.rs @@ -39,14 +39,34 @@ pub trait SendMessage { fn deliver(ticket: Self::Ticket) -> Result; } -// Trait for handling inflation +/// Result of minting inflation tokens, detailing the split between rewards and treasury. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub struct InflationMintResult { + /// Amount minted to the rewards account (for distribution to validators via AVS). + pub rewards_amount: u128, + /// Amount minted to the treasury account. + pub treasury_amount: u128, +} + +/// Trait for handling inflation minting with a rewards/treasury split. pub trait HandleInflation { - fn mint_inflation(who: &AccountId, amount: u128) -> sp_runtime::DispatchResult; + /// Mint inflation tokens, splitting between rewards and treasury. + /// Returns an `InflationMintResult` detailing the amounts minted to each destination. + fn mint_inflation( + who: &AccountId, + amount: u128, + ) -> Result; } impl HandleInflation for () { - fn mint_inflation(_: &AccountId, _: u128) -> sp_runtime::DispatchResult { - Ok(()) + fn mint_inflation( + _: &AccountId, + _: u128, + ) -> Result { + Ok(InflationMintResult { + rewards_amount: 0, + treasury_amount: 0, + }) } } diff --git a/operator/runtime/common/src/inflation.rs b/operator/runtime/common/src/inflation.rs index 90936b1b..b18332bc 100644 --- a/operator/runtime/common/src/inflation.rs +++ b/operator/runtime/common/src/inflation.rs @@ -165,11 +165,15 @@ where TreasuryProportion: Get, TreasuryAccount: Get, { - /// Mints inflation tokens and splits them between rewards and treasury accounts + /// Mints inflation tokens and splits them between rewards and treasury accounts. + /// Returns an `InflationMintResult` detailing the amounts minted to each destination. pub fn mint_inflation( rewards_account: &crate::AccountId, total_amount: u128, - ) -> sp_runtime::DispatchResult { + ) -> Result< + pallet_external_validators_rewards::types::InflationMintResult, + sp_runtime::DispatchError, + > { use sp_runtime::traits::Zero; if total_amount.is_zero() { @@ -236,7 +240,12 @@ where treasury_amount ); - Ok(()) + Ok( + pallet_external_validators_rewards::types::InflationMintResult { + rewards_amount, + treasury_amount, + }, + ) } } @@ -247,6 +256,7 @@ mod tests { parameter_types, traits::fungible::{Inspect, Mutate, Unbalanced}, }; + use pallet_external_validators_rewards::types::InflationMintResult; use sp_runtime::Perbill; use std::cell::RefCell; @@ -561,7 +571,13 @@ mod tests { let total_inflation = 1_000_000u128; let result = TestHandler::mint_inflation(&rewards_account, total_inflation); - assert!(result.is_ok()); + assert_eq!( + result, + Ok(InflationMintResult { + rewards_amount: 800_000, + treasury_amount: 200_000, + }) + ); let rewards_balance = get_balance(&rewards_account); let treasury_balance = get_balance(&TreasuryAccountId::get()); @@ -579,7 +595,13 @@ mod tests { let total_inflation = 1_000_000u128; let result = TestHandler50Pct::mint_inflation(&rewards_account, total_inflation); - assert!(result.is_ok()); + assert_eq!( + result, + Ok(InflationMintResult { + rewards_amount: 500_000, + treasury_amount: 500_000, + }) + ); let rewards_balance = get_balance(&rewards_account); let treasury_balance = get_balance(&TreasuryAccountId::get()); @@ -596,7 +618,13 @@ mod tests { let total_inflation = 1_000_000u128; let result = TestHandler0Pct::mint_inflation(&rewards_account, total_inflation); - assert!(result.is_ok()); + assert_eq!( + result, + Ok(InflationMintResult { + rewards_amount: total_inflation, + treasury_amount: 0, + }) + ); let rewards_balance = get_balance(&rewards_account); let treasury_balance = get_balance(&TreasuryAccountId::get()); @@ -613,7 +641,13 @@ mod tests { let total_inflation = 1_000_000u128; let result = TestHandler100Pct::mint_inflation(&rewards_account, total_inflation); - assert!(result.is_ok()); + assert_eq!( + result, + Ok(InflationMintResult { + rewards_amount: 0, + treasury_amount: total_inflation, + }) + ); let rewards_balance = get_balance(&rewards_account); let treasury_balance = get_balance(&TreasuryAccountId::get()); diff --git a/operator/runtime/mainnet/src/configs/mod.rs b/operator/runtime/mainnet/src/configs/mod.rs index fe152317..2d97fc46 100644 --- a/operator/runtime/mainnet/src/configs/mod.rs +++ b/operator/runtime/mainnet/src/configs/mod.rs @@ -1460,7 +1460,13 @@ pub struct ExternalRewardsInflationHandler; impl pallet_external_validators_rewards::types::HandleInflation for ExternalRewardsInflationHandler { - fn mint_inflation(who: &AccountId, amount: u128) -> sp_runtime::DispatchResult { + fn mint_inflation( + who: &AccountId, + amount: u128, + ) -> Result< + pallet_external_validators_rewards::types::InflationMintResult, + sp_runtime::DispatchError, + > { datahaven_runtime_common::inflation::ExternalRewardsInflationHandler::< Balances, runtime_params::dynamic_params::runtime_config::InflationTreasuryProportion, diff --git a/operator/runtime/stagenet/src/configs/mod.rs b/operator/runtime/stagenet/src/configs/mod.rs index b07e9a5c..fb4cc4bb 100644 --- a/operator/runtime/stagenet/src/configs/mod.rs +++ b/operator/runtime/stagenet/src/configs/mod.rs @@ -1456,7 +1456,13 @@ pub struct ExternalRewardsInflationHandler; impl pallet_external_validators_rewards::types::HandleInflation for ExternalRewardsInflationHandler { - fn mint_inflation(who: &AccountId, amount: u128) -> sp_runtime::DispatchResult { + fn mint_inflation( + who: &AccountId, + amount: u128, + ) -> Result< + pallet_external_validators_rewards::types::InflationMintResult, + sp_runtime::DispatchError, + > { datahaven_runtime_common::inflation::ExternalRewardsInflationHandler::< Balances, runtime_params::dynamic_params::runtime_config::InflationTreasuryProportion, diff --git a/operator/runtime/testnet/src/configs/mod.rs b/operator/runtime/testnet/src/configs/mod.rs index 2ea41697..5b9305a9 100644 --- a/operator/runtime/testnet/src/configs/mod.rs +++ b/operator/runtime/testnet/src/configs/mod.rs @@ -1460,7 +1460,13 @@ pub struct ExternalRewardsInflationHandler; impl pallet_external_validators_rewards::types::HandleInflation for ExternalRewardsInflationHandler { - fn mint_inflation(who: &AccountId, amount: u128) -> sp_runtime::DispatchResult { + fn mint_inflation( + who: &AccountId, + amount: u128, + ) -> Result< + pallet_external_validators_rewards::types::InflationMintResult, + sp_runtime::DispatchError, + > { datahaven_runtime_common::inflation::ExternalRewardsInflationHandler::< Balances, runtime_params::dynamic_params::runtime_config::InflationTreasuryProportion,