fix: use post-treasury-split amount in AVS rewards message (#459)

## 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
This commit is contained in:
Ahmad Kaouk 2026-02-27 10:19:20 +01:00 committed by GitHub
parent f0f99aee6f
commit 1a9e213322
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 139 additions and 35 deletions

View file

@ -625,10 +625,43 @@ pub mod pallet {
impl<T: Config> OnEraEnd for Pallet<T> {
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::<T>::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(
&ethereum_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::<T>::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(&ethereum_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::<T>::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,
});
}
}

View file

@ -230,7 +230,10 @@ impl pallet_external_validators_rewards::Config for Test {
pub struct InflationMinter;
impl HandleInflation<H160> for InflationMinter {
fn mint_inflation(rewards_account: &H160, total_amount: u128) -> sp_runtime::DispatchResult {
fn mint_inflation(
rewards_account: &H160,
total_amount: u128,
) -> Result<crate::types::InflationMintResult, sp_runtime::DispatchError> {
use sp_runtime::traits::Zero;
if total_amount.is_zero() {
@ -266,7 +269,10 @@ impl HandleInflation<H160> for InflationMinter {
.map_err(|_| DispatchError::Other("Failed to mint treasury inflation"))?;
}
Ok(())
Ok(crate::types::InflationMintResult {
rewards_amount,
treasury_amount,
})
}
}

View file

@ -160,15 +160,19 @@ fn test_on_era_end() {
let era_rewards = pallet_external_validators_rewards::RewardPointsForEra::<Test>::get(1);
let inflation =
<Test as pallet_external_validators_rewards::Config>::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,
},
));
})

View file

@ -39,14 +39,34 @@ pub trait SendMessage {
fn deliver(ticket: Self::Ticket) -> Result<H256, SendError>;
}
// 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<AccountId> {
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<InflationMintResult, sp_runtime::DispatchError>;
}
impl<AccountId> HandleInflation<AccountId> for () {
fn mint_inflation(_: &AccountId, _: u128) -> sp_runtime::DispatchResult {
Ok(())
fn mint_inflation(
_: &AccountId,
_: u128,
) -> Result<InflationMintResult, sp_runtime::DispatchError> {
Ok(InflationMintResult {
rewards_amount: 0,
treasury_amount: 0,
})
}
}

View file

@ -165,11 +165,15 @@ where
TreasuryProportion: Get<Perbill>,
TreasuryAccount: Get<crate::AccountId>,
{
/// 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());

View file

@ -1460,7 +1460,13 @@ pub struct ExternalRewardsInflationHandler;
impl pallet_external_validators_rewards::types::HandleInflation<AccountId>
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,

View file

@ -1456,7 +1456,13 @@ pub struct ExternalRewardsInflationHandler;
impl pallet_external_validators_rewards::types::HandleInflation<AccountId>
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,

View file

@ -1460,7 +1460,13 @@ pub struct ExternalRewardsInflationHandler;
impl pallet_external_validators_rewards::types::HandleInflation<AccountId>
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,