Skip to content

Commit edbbae0

Browse files
review comments
1 parent 25d8059 commit edbbae0

11 files changed

Lines changed: 573 additions & 32 deletions

File tree

integration-tests/src/fee_processor.rs

Lines changed: 353 additions & 22 deletions
Large diffs are not rendered by default.

pallets/fee-processor/src/lib.rs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -132,18 +132,20 @@ pub mod pallet {
132132
fn on_idle(_n: BlockNumberFor<T>, remaining_weight: Weight) -> Weight {
133133
let convert_weight = T::WeightInfo::convert();
134134

135-
if remaining_weight.ref_time() < convert_weight.ref_time() {
135+
// Budget conversions against BOTH weight dimensions: each `do_convert` runs a real
136+
// Omnipool sell with non-trivial proof size, so gating on `ref_time` alone could
137+
// overweight the block's PoV. A zero-cost dimension imposes no limit.
138+
let fits = |budget: u64, cost: u64| if cost == 0 { u64::MAX } else { budget / cost };
139+
let max_conversions = fits(remaining_weight.ref_time(), convert_weight.ref_time())
140+
.min(fits(remaining_weight.proof_size(), convert_weight.proof_size()))
141+
.min(T::MaxConversionsPerBlock::get() as u64);
142+
143+
if max_conversions == 0 {
136144
return Weight::zero();
137145
}
138146

139147
let mut used_weight = Weight::zero();
140148

141-
let max_conversions = remaining_weight
142-
.ref_time()
143-
.checked_div(convert_weight.ref_time())
144-
.unwrap_or(0)
145-
.min(T::MaxConversionsPerBlock::get() as u64);
146-
147149
for asset_id in PendingConversions::<T>::iter_keys().take(max_conversions as usize) {
148150
match Self::do_convert(asset_id) {
149151
Ok(_) => {}

pallets/referrals/src/tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
mod claim;
1919
mod flow;
2020
mod link;
21+
mod migration;
2122
mod mock_amm;
2223
mod register;
2324
mod tiers;
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
use crate::tests::*;
2+
3+
use crate::migration::migrate_to_accumulator;
4+
use sp_core::U256;
5+
6+
const PRECISION: u128 = crate::pallet::PRECISION;
7+
8+
// A legacy chain (pre-accumulator) holds `TotalShares > 0` and a funded pot, but the new
9+
// `RewardPerShare` accumulator defaults to zero. Without the migration a claim computes
10+
// `shares * 0 / PRECISION - 0 = 0` and pays nothing; the migration must seed the accumulator
11+
// so a legacy holder claims exactly the old `shares * (pot - seed) / total_shares`.
12+
#[test]
13+
fn migrate_to_accumulator_should_preserve_legacy_claim_amount() {
14+
let seed = ONE;
15+
let alice_shares = 3_000_000_000u128;
16+
let rewards = 600 * ONE;
17+
let pot = Referrals::pot_account_id();
18+
19+
// The builder mints `seed` into the pot itself, so only the claimable rewards are endowed here.
20+
ExtBuilder::default()
21+
.with_seed_amount(seed)
22+
.with_referrer_shares(vec![(ALICE, alice_shares)])
23+
.with_endowed_accounts(vec![(pot, HDX, rewards)])
24+
.build()
25+
.execute_with(|| {
26+
// Legacy state: accumulator at its default, no debt, shares + funded pot present.
27+
assert!(RewardPerShare::<Test>::get().is_zero());
28+
assert_eq!(TotalShares::<Test>::get(), alice_shares);
29+
30+
migrate_to_accumulator::<Test>();
31+
32+
let expected_rps = U256::from(rewards) * U256::from(PRECISION) / U256::from(alice_shares);
33+
assert_eq!(RewardPerShare::<Test>::get(), expected_rps);
34+
35+
let alice_before = Tokens::free_balance(HDX, &ALICE);
36+
assert_ok!(Referrals::claim_rewards(RuntimeOrigin::signed(ALICE)));
37+
let claimed = Tokens::free_balance(HDX, &ALICE) - alice_before;
38+
39+
assert_eq!(
40+
claimed, rewards,
41+
"legacy holder must claim pot-minus-seed after the accumulator migration"
42+
);
43+
});
44+
}
45+
46+
// The migration must be a no-op when there are no shares (fresh chain / pre-adoption):
47+
// dividing the pot by `total_shares == 0` is undefined, so the accumulator stays at zero.
48+
#[test]
49+
fn migrate_to_accumulator_should_be_noop_when_no_shares() {
50+
let seed = ONE;
51+
let pot = Referrals::pot_account_id();
52+
53+
ExtBuilder::default()
54+
.with_seed_amount(seed)
55+
.with_endowed_accounts(vec![(pot, HDX, 500 * ONE)])
56+
.build()
57+
.execute_with(|| {
58+
assert_eq!(TotalShares::<Test>::get(), 0);
59+
60+
migrate_to_accumulator::<Test>();
61+
62+
assert!(
63+
RewardPerShare::<Test>::get().is_zero(),
64+
"accumulator must stay zero when there are no shares"
65+
);
66+
});
67+
}

runtime/hydradx/src/assets.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2342,7 +2342,14 @@ impl hydradx_traits::fee_processor::FeeReceiver<AccountId, Balance> for Referral
23422342
type Error = sp_runtime::DispatchError;
23432343

23442344
fn destination() -> AccountId {
2345-
pallet_referrals::Pallet::<Runtime>::pot_account_id()
2345+
// With no shareholders the accumulator can't attribute a deposit (it divides by
2346+
// `TotalShares`), so route the slice to the treasury instead of stranding it in the pot
2347+
// where nobody could ever claim it (audit 2026-06-02, finding 5).
2348+
if pallet_referrals::Pallet::<Runtime>::total_shares() == 0 {
2349+
TreasuryAccount::get()
2350+
} else {
2351+
pallet_referrals::Pallet::<Runtime>::pot_account_id()
2352+
}
23462353
}
23472354

23482355
fn percentage() -> Permill {
@@ -2354,6 +2361,11 @@ impl hydradx_traits::fee_processor::FeeReceiver<AccountId, Balance> for Referral
23542361
}
23552362

23562363
fn on_fee_received(amount: Balance) -> Result<(), Self::Error> {
2364+
// Mirror `destination()`: with no shareholders the slice went to the treasury, not the
2365+
// pot, so there is nothing to accrue into the accumulator.
2366+
if pallet_referrals::Pallet::<Runtime>::total_shares() == 0 {
2367+
return Ok(());
2368+
}
23572369
pallet_referrals::Pallet::<Runtime>::on_hdx_deposited(amount)
23582370
}
23592371
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// This file is part of HydraDX-node
2+
3+
// Copyright (C) 2020-2025 Intergalactic, Limited (GIB).
4+
// SPDX-License-Identifier: Apache-2.0
5+
6+
// Licensed under the Apache License, Version 2.0 (the "License");
7+
// you may not use this file except in compliance with the License.
8+
// You may obtain a copy of the License at
9+
//
10+
// http://www.apache.org/licenses/LICENSE-2.0
11+
//
12+
// Unless required by applicable law or agreed to in writing, software
13+
// distributed under the License is distributed on an "AS IS" BASIS,
14+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
// See the License for the specific language governing permissions and
16+
// limitations under the License.
17+
18+
#![cfg(feature = "runtime-benchmarks")]
19+
20+
use crate::{AccountId, AssetId, Balance, Currencies, FeeProcessor, Omnipool, Runtime};
21+
22+
use crate::benchmarking::set_period;
23+
use frame_benchmarking::account;
24+
use frame_system::RawOrigin;
25+
use orml_benchmarking::runtime_benchmarks;
26+
use orml_traits::MultiCurrency;
27+
use sp_runtime::DispatchResult;
28+
use sp_std::vec;
29+
30+
const HDX: AssetId = 0;
31+
const DAI: AssetId = 2;
32+
const ONE: Balance = 1_000_000_000_000;
33+
34+
fn fund(who: AccountId, asset: AssetId, amount: Balance) -> DispatchResult {
35+
Currencies::update_balance(RawOrigin::Root.into(), who, asset, amount as i128)
36+
}
37+
38+
runtime_benchmarks! {
39+
{ Runtime, pallet_fee_processor }
40+
41+
// Worst case: a non-HDX pot balance is sold via Omnipool and distributed to all four
42+
// receivers (the slice routed to the referrals receiver is the heaviest leg).
43+
convert {
44+
crate::benchmarking::omnipool_liquidity_mining::initialize_omnipool(None)?;
45+
46+
// Pre-create every account the conversion touches (receiver pots, treasury, referrals
47+
// pot) so that distributing to a not-yet-existing account — including the tiny fee-on-fee
48+
// slices from the Omnipool sell itself — never trips ED. On the live chain these are all
49+
// genesis-funded.
50+
let seed = 1_000 * ONE;
51+
for p in [
52+
FeeProcessor::pot_account_id(),
53+
pallet_staking::Pallet::<Runtime>::pot_account_id(),
54+
pallet_gigahdx::Pallet::<Runtime>::gigapot_account_id(),
55+
pallet_gigahdx_rewards::Pallet::<Runtime>::reward_accumulator_pot(),
56+
pallet_referrals::Pallet::<Runtime>::pot_account_id(),
57+
crate::Treasury::account_id(),
58+
] {
59+
fund(p, HDX, seed)?;
60+
}
61+
62+
// Warm the EMA oracle with HDX/DAI trades across a period — the conversion's Omnipool
63+
// sell and the dynamic fee both read the oracle. Each HDX->DAI sell also accrues a DAI
64+
// fee into the fee-processor pot (the non-HDX path), which is what `convert` consumes.
65+
let trader: AccountId = account("trader", 0, 0);
66+
fund(trader.clone(), HDX, 100_000 * ONE)?;
67+
Omnipool::sell(RawOrigin::Signed(trader.clone()).into(), HDX, DAI, 100 * ONE, 0)?;
68+
set_period(24);
69+
Omnipool::sell(RawOrigin::Signed(trader.clone()).into(), HDX, DAI, 100 * ONE, 0)?;
70+
71+
let pot = FeeProcessor::pot_account_id();
72+
fund(pot.clone(), DAI, 100 * ONE)?;
73+
74+
let caller: AccountId = account("caller", 1, 0);
75+
fund(caller.clone(), HDX, seed)?;
76+
}: _(RawOrigin::Signed(caller), DAI)
77+
verify {
78+
assert_eq!(
79+
<Currencies as MultiCurrency<AccountId>>::free_balance(DAI, &FeeProcessor::pot_account_id()),
80+
0,
81+
"the entire pot balance must be converted"
82+
);
83+
}
84+
}
85+
86+
// NOTE: no `impl_benchmark_test_suite!` here. `convert` drives a real Omnipool sell whose
87+
// downstream fee distribution touches accounts that only exist in the full chain genesis, so it
88+
// is generated/validated via the benchmarking CLI against the runtime genesis rather than a
89+
// minimal stand-alone externalities.

runtime/hydradx/src/benchmarking/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pub mod duster;
66
pub mod dynamic_evm_fee;
77
pub mod ema_oracle;
88
pub mod evm_accounts;
9+
pub mod fee_processor;
910
pub mod multi_payment;
1011
pub mod omnipool;
1112
pub mod omnipool_liquidity_mining;

runtime/hydradx/src/benchmarking/omnipool_liquidity_mining.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ fn initialize_yield_farm(owner: AccountId, id: GlobalFarmId, asset: AssetId) ->
139139
OmnipoolLiquidityMining::create_yield_farm(RawOrigin::Signed(owner).into(), id, asset, FixedU128::one(), None)
140140
}
141141

142-
fn initialize_omnipool(additional_asset: Option<AssetId>) -> DispatchResult {
142+
pub fn initialize_omnipool(additional_asset: Option<AssetId>) -> DispatchResult {
143143
let stable_amount: Balance = 1_000_000_000_000_000u128;
144144
let native_amount: Balance = 1_000_000_000_000_000u128;
145145
let stable_price: FixedU128 = FixedU128::from((1, 2));

runtime/hydradx/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion {
130130
impl_name: Cow::Borrowed("hydradx"),
131131
authoring_version: 1,
132132
spec_version: 425,
133-
impl_version: 0,
133+
impl_version: 1,
134134
apis: RUNTIME_API_VERSIONS,
135135
transaction_version: 1,
136136
system_version: 1,
@@ -379,6 +379,7 @@ mod benches {
379379
[pallet_omnipool, benchmarking::omnipool::Benchmark]
380380
[pallet_route_executor, benchmarking::route_executor::Benchmark]
381381
[pallet_dca, benchmarking::dca::Benchmark]
382+
[pallet_fee_processor, benchmarking::fee_processor::Benchmark]
382383
[pallet_xyk, benchmarking::xyk::Benchmark]
383384
[pallet_dynamic_evm_fee, benchmarking::dynamic_evm_fee::Benchmark]
384385
[pallet_xyk_liquidity_mining, benchmarking::xyk_liquidity_mining::Benchmark]

runtime/hydradx/src/migrations/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@
1616
use crate::Runtime;
1717

1818
mod cleanup_hyperbridge;
19+
mod referrals_accumulator;
1920

2021
// New migrations which need to be cleaned up after every Runtime upgrade
2122
pub type UnreleasedSingleBlockMigrations = (
2223
pallet_ema_oracle::migrations::v2::MigrateV1ToV2<Runtime, crate::assets::BifrostAccount>,
2324
cleanup_hyperbridge::CleanupHyperbridge,
25+
referrals_accumulator::InitReferralsAccumulator,
2426
);
2527

2628
// These migrations can run on every runtime upgrade

0 commit comments

Comments
 (0)