Skip to content

Commit e3a5912

Browse files
fee processor distribute hdx above ed
1 parent d2b6706 commit e3a5912

12 files changed

Lines changed: 313 additions & 26 deletions

File tree

Cargo.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

integration-tests/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "runtime-integration-tests"
3-
version = "1.99.0"
3+
version = "1.100.0"
44
description = "Integration tests"
55
authors = ["GalacticCouncil"]
66
edition = "2021"

integration-tests/src/gigahdx.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2404,6 +2404,62 @@ fn crash_st_hdx_price(oracle: EvmAddress, st_hdx_evm: EvmAddress) {
24042404
set_oracle_price_source(oracle, st_hdx_evm, mock);
24052405
}
24062406

2407+
/// Attempt `Pool.borrow(asset, amount)` as `user` without asserting success,
2408+
/// returning the raw EVM exit reason so the caller can assert on a revert.
2409+
fn try_aave_borrow(pool: EvmAddress, user: EvmAddress, asset: EvmAddress, amount: Balance) -> fp_evm::ExitReason {
2410+
let data = EvmDataWriter::new_with_selector(liquidation_worker_support::Function::Borrow)
2411+
.write(asset)
2412+
.write(amount)
2413+
.write(2u32) // variable interest rate mode
2414+
.write(0u32) // referral code
2415+
.write(user) // onBehalfOf
2416+
.build();
2417+
Executor::<Runtime>::call(CallContext::new_call(pool, user), data, U256::zero(), 50_000_000).exit_reason
2418+
}
2419+
2420+
#[test]
2421+
fn aave_borrow_should_revert_when_asset_is_sthdx() {
2422+
// stHDX must be non-borrowable on the GIGAHDX AAVE pool (zero borrow cap /
2423+
// IRM returning 0). If it ever became borrowable, a drifting liquidityIndex
2424+
// would break the `aToken : stHDX = 1 : 1` invariant and leak unlocked
2425+
// aTokens past the lock-manager (see the stHDX invariants in `assets.rs`).
2426+
// Enforcement lives in the AAVE reserve config, not in runtime code, so this
2427+
// pins the deploy/snapshot setup against silent regression.
2428+
TestNet::reset();
2429+
hydra_live_ext(PATH_TO_SNAPSHOT).execute_with(|| {
2430+
use crate::liquidation::get_user_account_data;
2431+
2432+
let (alice, _bob, alice_evm, pool, _oracle, _hollar) = liquidation_test_setup();
2433+
let st_hdx_evm = HydraErc20Mapping::asset_address(ST_HDX);
2434+
2435+
// Large stake → ample GIGAHDX collateral, so a stHDX borrow would clear
2436+
// the collateral check if the asset were borrowable. This makes the
2437+
// revert attributable to the disabled reserve, not thin collateral.
2438+
assert_ok!(GigaHdx::giga_stake(
2439+
RuntimeOrigin::signed(alice.clone()),
2440+
10_000 * UNITS
2441+
));
2442+
2443+
let data = get_user_account_data(pool, alice_evm).unwrap();
2444+
assert!(
2445+
data.available_borrows_base > U256::zero(),
2446+
"precondition: Alice must have borrowing power so the revert is attributable to stHDX being non-borrowable",
2447+
);
2448+
2449+
let st_hdx_before = Currencies::free_balance(ST_HDX, &alice);
2450+
2451+
let exit = try_aave_borrow(pool, alice_evm, st_hdx_evm, UNITS);
2452+
assert!(
2453+
matches!(exit, fp_evm::ExitReason::Revert(_)),
2454+
"borrowing stHDX must revert (reserve must be non-borrowable); got {:?}",
2455+
exit,
2456+
);
2457+
2458+
// No stHDX may reach the user.
2459+
assert_eq!(Currencies::free_balance(ST_HDX, &alice), st_hdx_before);
2460+
});
2461+
}
2462+
24072463
/// Executes the same flow as `pallet_liquidation::liquidate_gigahdx` step by
24082464
/// step from the test, so we exercise the real building blocks (AAVE pool,
24092465
/// LockableAToken precompile, Seize trait, lock refresh) end-to-end against

pallets/fee-processor/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "pallet-fee-processor"
3-
version = "1.0.0"
3+
version = "1.1.0"
44
authors = ["GalacticCouncil"]
55
edition = "2021"
66
license = "Apache-2.0"

pallets/fee-processor/src/lib.rs

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ pub mod pallet {
1818
use frame_support::traits::tokens::Preservation;
1919
use frame_support::PalletId;
2020
use frame_system::pallet_prelude::*;
21-
use hydradx_traits::fee_processor::{Convert, FeeReceiver};
21+
use hydradx_traits::fee_processor::{Convert, FeeDestination, FeeReceiver};
2222
use sp_runtime::helpers_128bit::multiply_by_rational_with_rounding;
2323
use sp_runtime::traits::AccountIdConversion;
2424
use sp_runtime::{Permill, Rounding, Saturating};
@@ -71,6 +71,14 @@ pub mod pallet {
7171
#[pallet::getter(fn pending_conversions)]
7272
pub type PendingConversions<T: Config> = CountedStorageMap<_, Blake2_128Concat, T::AssetId, (), OptionQuery>;
7373

74+
/// HDX held in the pot for a `hold_until_ed` receiver whose account is still
75+
/// below the existential deposit. The HDX physically lives in
76+
/// `pot_account_id()`; this map only earmarks how much of it belongs to each
77+
/// destination. Flushed to the destination once `balance + held + slice ≥ ED`.
78+
#[pallet::storage]
79+
#[pallet::getter(fn held_fees)]
80+
pub type HeldFees<T: Config> = StorageMap<_, Blake2_128Concat, T::AccountId, Balance, ValueQuery>;
81+
7482
#[pallet::event]
7583
#[pallet::generate_deposit(pub(super) fn deposit_event)]
7684
pub enum Event<T: Config> {
@@ -208,6 +216,7 @@ pub mod pallet {
208216
};
209217

210218
let pot = Self::pot_account_id();
219+
Self::ensure_pot_exists();
211220
let mut total_taken: Balance = 0;
212221

213222
// Raw-asset receivers: each reports how much of its slice it actually wants
@@ -257,6 +266,7 @@ pub mod pallet {
257266
ensure!(asset_id != T::HdxAssetId::get(), Error::<T>::AlreadyHdx);
258267

259268
let pot = Self::pot_account_id();
269+
Self::ensure_pot_exists();
260270
let balance = T::Currency::balance(asset_id, &pot);
261271

262272
let hdx_received = T::Convert::convert(pot.clone(), asset_id, T::HdxAssetId::get(), balance)?;
@@ -276,36 +286,78 @@ pub mod pallet {
276286
Ok(())
277287
}
278288

289+
/// Give the pot a provider reference if it has none, so it survives at a
290+
/// zero balance between trades. Without this a fresh-chain pot is created
291+
/// and reaped within a single distribution, surfacing as
292+
/// `Token::FundsUnavailable`. Idempotent: a no-op once the pot holds a
293+
/// balance or has already been provided.
294+
fn ensure_pot_exists() {
295+
let pot = Self::pot_account_id();
296+
if frame_system::Pallet::<T>::providers(&pot) == 0 {
297+
let _ = frame_system::Pallet::<T>::inc_providers(&pot);
298+
}
299+
}
300+
279301
/// Sum of percentages of the HDX-target (non-raw) receivers.
280-
fn convert_percentage(destinations: &[(T::AccountId, Permill, bool)]) -> Permill {
302+
fn convert_percentage(destinations: &[FeeDestination<T::AccountId>]) -> Permill {
281303
destinations
282304
.iter()
283-
.filter(|(_, _, accepts_raw)| !accepts_raw)
284-
.fold(Permill::zero(), |acc, (_, pct, _)| acc.saturating_add(*pct))
305+
.filter(|d| !d.accepts_raw)
306+
.fold(Permill::zero(), |acc, d| acc.saturating_add(d.percentage))
285307
}
286308

287309
/// Distribute `total` HDX among the HDX-target `destinations` proportionally to
288310
/// each destination's percentage relative to `total_pct`. Raw-asset receivers are
289311
/// skipped — they were already paid in the original asset.
312+
///
313+
/// `total` is already sitting in `source` (the pot). For a `hold_until_ed`
314+
/// destination whose account would still be below ED after receiving its
315+
/// slice, the slice is left in the pot and tracked in `HeldFees` instead of
316+
/// transferred — avoiding a `Token::BelowMinimum` revert. The buffer is
317+
/// flushed (held + new slice) the moment `balance + held + slice ≥ ED`.
290318
fn distribute_proportionally(
291319
source: &T::AccountId,
292320
total: Balance,
293-
destinations: Vec<(T::AccountId, Permill, bool)>,
321+
destinations: Vec<FeeDestination<T::AccountId>>,
294322
total_pct: Permill,
295323
) -> DispatchResult {
296324
if total == 0 || total_pct.is_zero() {
297325
return Ok(());
298326
}
327+
let hdx = T::HdxAssetId::get();
328+
let ed = T::Currency::minimum_balance(hdx);
299329
let denom = total_pct.deconstruct() as u128;
300-
for (dest, pct, accepts_raw) in destinations {
330+
for FeeDestination {
331+
account,
332+
percentage,
333+
accepts_raw,
334+
hold_until_ed,
335+
} in destinations
336+
{
301337
if accepts_raw {
302338
continue;
303339
}
304-
let numer = pct.deconstruct() as u128;
305-
let amount = multiply_by_rational_with_rounding(total, numer, denom, Rounding::Down)
340+
let numer = percentage.deconstruct() as u128;
341+
let slice = multiply_by_rational_with_rounding(total, numer, denom, Rounding::Down)
306342
.ok_or(Error::<T>::Arithmetic)?;
307-
if amount > 0 {
308-
T::Currency::transfer(T::HdxAssetId::get(), source, &dest, amount, Preservation::Expendable)?;
343+
if slice == 0 {
344+
continue;
345+
}
346+
347+
if hold_until_ed {
348+
// `slice` is already in the pot; flush the accumulated buffer
349+
// only if the account would then reach ED, else keep holding.
350+
let held = HeldFees::<T>::get(&account);
351+
let pending = held.saturating_add(slice);
352+
let balance = T::Currency::balance(hdx, &account);
353+
if balance.saturating_add(pending) >= ed {
354+
T::Currency::transfer(hdx, source, &account, pending, Preservation::Expendable)?;
355+
HeldFees::<T>::remove(&account);
356+
} else {
357+
HeldFees::<T>::insert(&account, pending);
358+
}
359+
} else {
360+
T::Currency::transfer(hdx, source, &account, slice, Preservation::Expendable)?;
309361
}
310362
}
311363
Ok(())
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
use super::mock::*;
2+
use crate::*;
3+
use frame_support::assert_ok;
4+
use frame_support::traits::fungibles::{Inspect, Mutate};
5+
use frame_support::traits::tokens::Preservation;
6+
use pallet_currencies::fungibles::FungibleCurrencies;
7+
8+
fn balance(asset: AssetId, who: &AccountId) -> u128 {
9+
<FungibleCurrencies<Test> as Inspect<AccountId>>::balance(asset, who)
10+
}
11+
12+
// Empty an account (reaped) so it starts below ED.
13+
fn drain(asset: AssetId, who: &AccountId) {
14+
let bal = balance(asset, who);
15+
if bal > 0 {
16+
assert_ok!(<FungibleCurrencies<Test> as Mutate<AccountId>>::transfer(
17+
asset,
18+
who,
19+
&BOB,
20+
bal,
21+
Preservation::Expendable
22+
));
23+
}
24+
}
25+
26+
// HDX path: HdxStakingFeeReceiver (50%, HDX-target, hold_until_ed) -> HDX_STAKING_POT,
27+
// HdxReferralsFeeReceiver (50%, raw) -> HDX_REFERRALS_POT. With one non-raw receiver
28+
// the pot `take` equals that receiver's slice, so the numbers stay clean. Genesis
29+
// funds HDX_STAKING_POT / HDX_REFERRALS_POT / pot with ONE each.
30+
31+
#[test]
32+
fn hdx_fee_should_hold_staking_slice_when_pot_below_ed() {
33+
ExtBuilder::default().build().execute_with(|| {
34+
set_hdx_existential_deposit(ONE);
35+
drain(HDX, &HDX_STAKING_POT); // receiver below ED
36+
37+
let pot = FeeProcessor::pot_account_id();
38+
let pot_before = balance(HDX, &pot);
39+
40+
// Staking slice = ONE/2 < ED -> held, not delivered.
41+
assert_ok!(Pallet::<Test>::process_trade_fee(FEE_SOURCE, ALICE, HDX, ONE));
42+
43+
assert_eq!(balance(HDX, &HDX_STAKING_POT), 0, "nothing delivered to the receiver");
44+
assert_eq!(HeldFees::<Test>::get(HDX_STAKING_POT), ONE / 2, "slice earmarked");
45+
// The held HDX physically sits in the pot.
46+
assert_eq!(balance(HDX, &pot), pot_before + ONE / 2);
47+
});
48+
}
49+
50+
#[test]
51+
fn hdx_fee_should_flush_staking_buffer_when_accumulation_reaches_ed() {
52+
ExtBuilder::default().build().execute_with(|| {
53+
set_hdx_existential_deposit(ONE);
54+
drain(HDX, &HDX_STAKING_POT);
55+
56+
// First trade: ONE/2 held.
57+
assert_ok!(Pallet::<Test>::process_trade_fee(FEE_SOURCE, ALICE, HDX, ONE));
58+
assert_eq!(balance(HDX, &HDX_STAKING_POT), 0);
59+
assert_eq!(HeldFees::<Test>::get(HDX_STAKING_POT), ONE / 2);
60+
61+
// Second trade: held(ONE/2) + slice(ONE/2) = ONE >= ED -> flush.
62+
assert_ok!(Pallet::<Test>::process_trade_fee(FEE_SOURCE, ALICE, HDX, ONE));
63+
assert_eq!(balance(HDX, &HDX_STAKING_POT), ONE, "buffer flushed to the receiver");
64+
assert_eq!(HeldFees::<Test>::get(HDX_STAKING_POT), 0, "buffer cleared");
65+
});
66+
}
67+
68+
#[test]
69+
fn hdx_fee_should_deliver_immediately_when_pot_already_above_ed() {
70+
ExtBuilder::default().build().execute_with(|| {
71+
set_hdx_existential_deposit(ONE);
72+
// Receiver already at/above ED (genesis ONE) -> even a sub-ED slice flows straight through.
73+
let before = balance(HDX, &HDX_STAKING_POT);
74+
75+
// amount = 2 -> staking slice = 1 (< ED).
76+
assert_ok!(Pallet::<Test>::process_trade_fee(FEE_SOURCE, ALICE, HDX, 2));
77+
78+
assert_eq!(balance(HDX, &HDX_STAKING_POT), before + 1);
79+
assert_eq!(HeldFees::<Test>::get(HDX_STAKING_POT), 0);
80+
});
81+
}
82+
83+
#[test]
84+
fn hdx_fee_should_revert_below_ed_when_hold_until_ed_disabled() {
85+
ExtBuilder::default().build().execute_with(|| {
86+
set_hdx_existential_deposit(ONE);
87+
set_hdx_staking_hold(false);
88+
drain(HDX, &HDX_STAKING_POT);
89+
90+
// Without buffering, the sub-ED slice is transferred straight into the empty
91+
// receiver and reverts — the original failure mode this feature removes.
92+
let err = Pallet::<Test>::process_trade_fee(FEE_SOURCE, ALICE, HDX, ONE).unwrap_err();
93+
assert_eq!(err, sp_runtime::TokenError::BelowMinimum.into());
94+
});
95+
}
96+
97+
#[test]
98+
fn hdx_fee_should_not_revert_when_pot_account_starts_empty() {
99+
ExtBuilder::default().build().execute_with(|| {
100+
set_hdx_existential_deposit(ONE);
101+
let pot = FeeProcessor::pot_account_id();
102+
drain(HDX, &pot); // reaped pot mimics a fresh chain
103+
assert_eq!(balance(HDX, &pot), 0);
104+
105+
// `ensure_pot_exists` provider-backs the reaped pot so distributing the full
106+
// `take` through it does not revert with FundsUnavailable. amount = 2*ONE so
107+
// take = ONE (>= ED) is deposited and the slice is delivered to the receiver.
108+
let before = balance(HDX, &HDX_STAKING_POT);
109+
assert_ok!(Pallet::<Test>::process_trade_fee(FEE_SOURCE, ALICE, HDX, 2 * ONE));
110+
assert_eq!(balance(HDX, &HDX_STAKING_POT), before + ONE, "slice delivered");
111+
assert!(
112+
frame_system::Pallet::<Test>::providers(&pot) >= 1,
113+
"pot kept alive by ensure_pot_exists",
114+
);
115+
});
116+
}

0 commit comments

Comments
 (0)