Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion integration-tests/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "runtime-integration-tests"
version = "1.99.0"
version = "1.100.0"
description = "Integration tests"
authors = ["GalacticCouncil"]
edition = "2021"
Expand Down
69 changes: 69 additions & 0 deletions integration-tests/src/fee_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1793,3 +1793,72 @@ fn hdx_referral_slice_is_raw_hdx_and_immediately_claimable() {
);
});
}

// ---------------------------------------------------------------------------
// hold_until_ed: the gigapot eventually receives every buffered HDX fee
// ---------------------------------------------------------------------------

#[test]
fn gigapot_eventually_receives_all_buffered_hdx_fees_when_starting_below_ed() {
TestNet::reset();

Hydra::execute_with(|| {
init_omnipool_with_oracle_for_block_24();

let giga = gigahdx_pot();
let ed = primitives::constants::currency::NATIVE_EXISTENTIAL_DEPOSIT;
let held = || pallet_fee_processor::HeldFees::<Runtime>::get(&giga);
let delivered = || Currencies::free_balance(HDX, &giga);

// Production cannot seed the gigapot; drain the test-genesis seed so it starts
// below ED and its 15% HDX-path slice must be buffered, not delivered.
let seeded = delivered();
assert_ok!(Currencies::update_balance(
RawOrigin::Root.into(),
giga.clone(),
HDX,
-(seeded as i128),
));
assert_eq!(delivered(), 0, "gigapot starts below ED");

// Several small DAI->HDX trades. Each giga slice is far below ED, so it accrues into
// the buffer (physically held in the seeded fee-processor pot) instead of being
// delivered to the gigapot.
for _ in 0..4 {
assert_ok!(Omnipool::sell(
RuntimeOrigin::signed(BOB.into()),
DAI,
HDX,
10 * UNITS,
0
));
assert_eq!(delivered(), 0, "below ED: buffered, nothing delivered to the gigapot");
go_to_block(System::block_number() + 1);
}

// The four sub-ED slices are all buffered (none delivered), and the buffer is below ED.
let buffered = held();
assert_eq!(buffered, 2_371_450, "four sub-ED slices accrued into the buffer");
assert!(buffered < ed, "buffer is still below ED");
assert_eq!(delivered(), 0, "nothing delivered before the buffer crosses ED");

// One large trade makes buffer + new slice >= ED, flushing the whole buffer at once.
assert_ok!(Omnipool::sell(
RuntimeOrigin::signed(BOB.into()),
DAI,
HDX,
50_000_000 * UNITS,
0
));

// The buffer drains fully into the gigapot: HeldFees back to 0 and the gigapot now holds
// every accrued giga fee — the four previously buffered slices plus the flushing slice.
assert_eq!(held(), 0, "buffer fully flushed once the gigapot crosses ED");
assert_eq!(
delivered(),
3_140_987_039_253,
"gigapot receives all buffered fees plus the flushing slice"
);
assert!(delivered() > buffered, "delivery includes the previously buffered fees");
});
}
56 changes: 56 additions & 0 deletions integration-tests/src/gigahdx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2404,6 +2404,62 @@ fn crash_st_hdx_price(oracle: EvmAddress, st_hdx_evm: EvmAddress) {
set_oracle_price_source(oracle, st_hdx_evm, mock);
}

/// Attempt `Pool.borrow(asset, amount)` as `user` without asserting success,
/// returning the raw EVM exit reason so the caller can assert on a revert.
fn try_aave_borrow(pool: EvmAddress, user: EvmAddress, asset: EvmAddress, amount: Balance) -> fp_evm::ExitReason {
let data = EvmDataWriter::new_with_selector(liquidation_worker_support::Function::Borrow)
.write(asset)
.write(amount)
.write(2u32) // variable interest rate mode
.write(0u32) // referral code
.write(user) // onBehalfOf
.build();
Executor::<Runtime>::call(CallContext::new_call(pool, user), data, U256::zero(), 50_000_000).exit_reason
}

#[test]
fn aave_borrow_should_revert_when_asset_is_sthdx() {
// stHDX must be non-borrowable on the GIGAHDX AAVE pool (zero borrow cap /
// IRM returning 0). If it ever became borrowable, a drifting liquidityIndex
// would break the `aToken : stHDX = 1 : 1` invariant and leak unlocked
// aTokens past the lock-manager (see the stHDX invariants in `assets.rs`).
// Enforcement lives in the AAVE reserve config, not in runtime code, so this
// pins the deploy/snapshot setup against silent regression.
TestNet::reset();
hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| {
use crate::liquidation::get_user_account_data;

let (alice, _bob, alice_evm, pool, _oracle, _hollar) = liquidation_test_setup();
let st_hdx_evm = HydraErc20Mapping::asset_address(ST_HDX);

// Large stake → ample GIGAHDX collateral, so a stHDX borrow would clear
// the collateral check if the asset were borrowable. This makes the
// revert attributable to the disabled reserve, not thin collateral.
assert_ok!(GigaHdx::giga_stake(
RuntimeOrigin::signed(alice.clone()),
10_000 * UNITS
));

let data = get_user_account_data(pool, alice_evm).unwrap();
assert!(
data.available_borrows_base > U256::zero(),
"precondition: Alice must have borrowing power so the revert is attributable to stHDX being non-borrowable",
);

let st_hdx_before = Currencies::free_balance(ST_HDX, &alice);

let exit = try_aave_borrow(pool, alice_evm, st_hdx_evm, UNITS);
assert!(
matches!(exit, fp_evm::ExitReason::Revert(_)),
"borrowing stHDX must revert (reserve must be non-borrowable); got {:?}",
exit,
);

// No stHDX may reach the user.
assert_eq!(Currencies::free_balance(ST_HDX, &alice), st_hdx_before);
});
}

/// Executes the same flow as `pallet_liquidation::liquidate_gigahdx` step by
/// step from the test, so we exercise the real building blocks (AAVE pool,
/// LockableAToken precompile, Seize trait, lock refresh) end-to-end against
Expand Down
2 changes: 1 addition & 1 deletion pallets/fee-processor/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "pallet-fee-processor"
version = "1.0.0"
version = "1.1.0"
authors = ["GalacticCouncil"]
edition = "2021"
license = "Apache-2.0"
Expand Down
58 changes: 48 additions & 10 deletions pallets/fee-processor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ pub mod pallet {
use frame_support::traits::tokens::Preservation;
use frame_support::PalletId;
use frame_system::pallet_prelude::*;
use hydradx_traits::fee_processor::{Convert, FeeReceiver};
use hydradx_traits::fee_processor::{Convert, FeeDestination, FeeReceiver};
use sp_runtime::helpers_128bit::multiply_by_rational_with_rounding;
use sp_runtime::traits::AccountIdConversion;
use sp_runtime::{Permill, Rounding, Saturating};
Expand Down Expand Up @@ -71,6 +71,14 @@ pub mod pallet {
#[pallet::getter(fn pending_conversions)]
pub type PendingConversions<T: Config> = CountedStorageMap<_, Blake2_128Concat, T::AssetId, (), OptionQuery>;

/// HDX held in the pot for a `hold_until_ed` receiver whose account is still
/// below the existential deposit. The HDX physically lives in
/// `pot_account_id()`; this map only earmarks how much of it belongs to each
/// destination. Flushed to the destination once `balance + held + slice ≥ ED`.
#[pallet::storage]
#[pallet::getter(fn held_fees)]
pub type HeldFees<T: Config> = StorageMap<_, Blake2_128Concat, T::AccountId, Balance, ValueQuery>;

#[pallet::event]
#[pallet::generate_deposit(pub(super) fn deposit_event)]
pub enum Event<T: Config> {
Expand Down Expand Up @@ -277,35 +285,65 @@ pub mod pallet {
}

/// Sum of percentages of the HDX-target (non-raw) receivers.
fn convert_percentage(destinations: &[(T::AccountId, Permill, bool)]) -> Permill {
fn convert_percentage(destinations: &[FeeDestination<T::AccountId>]) -> Permill {
destinations
.iter()
.filter(|(_, _, accepts_raw)| !accepts_raw)
.fold(Permill::zero(), |acc, (_, pct, _)| acc.saturating_add(*pct))
.filter(|d| !d.accepts_raw)
.fold(Permill::zero(), |acc, d| acc.saturating_add(d.percentage))
}

/// Distribute `total` HDX among the HDX-target `destinations` proportionally to
/// each destination's percentage relative to `total_pct`. Raw-asset receivers are
/// skipped — they were already paid in the original asset.
///
/// `total` is already sitting in `source` (the pot). For a `hold_until_ed`
/// destination whose account would still be below ED after receiving its
/// slice, the slice is left in the pot and tracked in `HeldFees` instead of
/// transferred — avoiding a `Token::BelowMinimum` revert. The buffer is
/// flushed (held + new slice) the moment `balance + held + slice ≥ ED`.
fn distribute_proportionally(
source: &T::AccountId,
total: Balance,
destinations: Vec<(T::AccountId, Permill, bool)>,
destinations: Vec<FeeDestination<T::AccountId>>,
total_pct: Permill,
) -> DispatchResult {
if total == 0 || total_pct.is_zero() {
return Ok(());
}
let hdx = T::HdxAssetId::get();
let ed = T::Currency::minimum_balance(hdx);
let denom = total_pct.deconstruct() as u128;
for (dest, pct, accepts_raw) in destinations {
for FeeDestination {
account,
percentage,
accepts_raw,
hold_until_ed,
} in destinations
{
if accepts_raw {
continue;
}
let numer = pct.deconstruct() as u128;
let amount = multiply_by_rational_with_rounding(total, numer, denom, Rounding::Down)
let numer = percentage.deconstruct() as u128;
let slice = multiply_by_rational_with_rounding(total, numer, denom, Rounding::Down)
.ok_or(Error::<T>::Arithmetic)?;
if amount > 0 {
T::Currency::transfer(T::HdxAssetId::get(), source, &dest, amount, Preservation::Expendable)?;
if slice == 0 {
continue;
}

if hold_until_ed {
// `slice` is already in the pot; flush the accumulated buffer
// only if the account would then reach ED, else keep holding.
let held = HeldFees::<T>::get(&account);
let pending = held.saturating_add(slice);
let balance = T::Currency::balance(hdx, &account);
if balance.saturating_add(pending) >= ed {
T::Currency::transfer(hdx, source, &account, pending, Preservation::Expendable)?;
HeldFees::<T>::remove(&account);
} else {
HeldFees::<T>::insert(&account, pending);
}
} else {
T::Currency::transfer(hdx, source, &account, slice, Preservation::Expendable)?;
}
}
Ok(())
Expand Down
112 changes: 112 additions & 0 deletions pallets/fee-processor/src/tests/hold_until_ed.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
use super::mock::*;
use crate::*;
use frame_support::assert_ok;
use frame_support::traits::fungibles::{Inspect, Mutate};
use frame_support::traits::tokens::Preservation;
use pallet_currencies::fungibles::FungibleCurrencies;

fn balance(asset: AssetId, who: &AccountId) -> u128 {
<FungibleCurrencies<Test> as Inspect<AccountId>>::balance(asset, who)
}

// Empty an account (reaped) so it starts below ED.
fn drain(asset: AssetId, who: &AccountId) {
let bal = balance(asset, who);
if bal > 0 {
assert_ok!(<FungibleCurrencies<Test> as Mutate<AccountId>>::transfer(
asset,
who,
&BOB,
bal,
Preservation::Expendable
));
}
}

// HDX path: HdxStakingFeeReceiver (50%, HDX-target, hold_until_ed) -> HDX_STAKING_POT,
// HdxReferralsFeeReceiver (50%, raw) -> HDX_REFERRALS_POT. With one non-raw receiver
// the pot `take` equals that receiver's slice, so the numbers stay clean. Genesis
// funds HDX_STAKING_POT / HDX_REFERRALS_POT / pot with ONE each.

#[test]
fn hdx_fee_should_hold_staking_slice_when_pot_below_ed() {
ExtBuilder::default().build().execute_with(|| {
set_hdx_existential_deposit(ONE);
drain(HDX, &HDX_STAKING_POT); // receiver below ED

let pot = FeeProcessor::pot_account_id();
let pot_before = balance(HDX, &pot);

// Staking slice = ONE/2 < ED -> held, not delivered.
assert_ok!(Pallet::<Test>::process_trade_fee(FEE_SOURCE, ALICE, HDX, ONE));

assert_eq!(balance(HDX, &HDX_STAKING_POT), 0, "nothing delivered to the receiver");
assert_eq!(HeldFees::<Test>::get(HDX_STAKING_POT), ONE / 2, "slice earmarked");
// The held HDX physically sits in the pot.
assert_eq!(balance(HDX, &pot), pot_before + ONE / 2);
});
}

#[test]
fn hdx_fee_should_flush_staking_buffer_when_accumulation_reaches_ed() {
ExtBuilder::default().build().execute_with(|| {
set_hdx_existential_deposit(ONE);
drain(HDX, &HDX_STAKING_POT);

// First trade: ONE/2 held.
assert_ok!(Pallet::<Test>::process_trade_fee(FEE_SOURCE, ALICE, HDX, ONE));
assert_eq!(balance(HDX, &HDX_STAKING_POT), 0);
assert_eq!(HeldFees::<Test>::get(HDX_STAKING_POT), ONE / 2);

// Second trade: held(ONE/2) + slice(ONE/2) = ONE >= ED -> flush.
assert_ok!(Pallet::<Test>::process_trade_fee(FEE_SOURCE, ALICE, HDX, ONE));
assert_eq!(balance(HDX, &HDX_STAKING_POT), ONE, "buffer flushed to the receiver");
assert_eq!(HeldFees::<Test>::get(HDX_STAKING_POT), 0, "buffer cleared");
});
}

#[test]
fn hdx_fee_should_deliver_immediately_when_pot_already_above_ed() {
ExtBuilder::default().build().execute_with(|| {
set_hdx_existential_deposit(ONE);
// Receiver already at/above ED (genesis ONE) -> even a sub-ED slice flows straight through.
let before = balance(HDX, &HDX_STAKING_POT);

// amount = 2 -> staking slice = 1 (< ED).
assert_ok!(Pallet::<Test>::process_trade_fee(FEE_SOURCE, ALICE, HDX, 2));

assert_eq!(balance(HDX, &HDX_STAKING_POT), before + 1);
assert_eq!(HeldFees::<Test>::get(HDX_STAKING_POT), 0);
});
}

#[test]
fn hdx_fee_should_revert_below_ed_when_hold_until_ed_disabled() {
ExtBuilder::default().build().execute_with(|| {
set_hdx_existential_deposit(ONE);
set_hdx_staking_hold(false);
drain(HDX, &HDX_STAKING_POT);

// Without buffering, the sub-ED slice is transferred straight into the empty
// receiver and reverts — the original failure mode this feature removes.
let err = Pallet::<Test>::process_trade_fee(FEE_SOURCE, ALICE, HDX, ONE).unwrap_err();
assert_eq!(err, sp_runtime::TokenError::BelowMinimum.into());
});
}

#[test]
fn hdx_fee_should_not_revert_when_pot_account_starts_empty() {
ExtBuilder::default().build().execute_with(|| {
set_hdx_existential_deposit(ONE);
let pot = FeeProcessor::pot_account_id();
drain(HDX, &pot);
assert_eq!(balance(HDX, &pot), 0);

// A take >= ED creates the pot on deposit and the slice is delivered without
// reverting. In production the pot is seeded >= ED before deployment, so it is
// never actually empty; this pins the create-on-deposit edge regardless.
let before = balance(HDX, &HDX_STAKING_POT);
assert_ok!(Pallet::<Test>::process_trade_fee(FEE_SOURCE, ALICE, HDX, 2 * ONE));
assert_eq!(balance(HDX, &HDX_STAKING_POT), before + ONE, "slice delivered");
});
}
Loading