Skip to content

Commit 3670a47

Browse files
Merge pull request #1474 from galacticcouncil/fix/referrals-claim-dust-revert
fix: don't revert claim_rewards on un-convertible dust
2 parents 7117a7a + 9bed6c4 commit 3670a47

5 files changed

Lines changed: 73 additions & 14 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pallets/referrals/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-referrals"
3-
version = "1.6.0"
3+
version = "1.6.1"
44
authors = ['GalacticCouncil']
55
edition = "2021"
66
license = "Apache-2.0"

pallets/referrals/src/lib.rs

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,11 @@ pub mod pallet {
298298
from: AssetAmount<T::AssetId>,
299299
to: AssetAmount<T::AssetId>,
300300
},
301+
/// A pending asset could not be converted; skipped, not blocking.
302+
ConversionFailed {
303+
asset_id: T::AssetId,
304+
reason: DispatchError,
305+
},
301306
/// Rewards claimed.
302307
Claimed {
303308
who: T::AccountId,
@@ -482,20 +487,18 @@ pub mod pallet {
482487
let who = ensure_signed(origin)?;
483488
for (asset_id, _) in PendingConversions::<T>::iter() {
484489
let asset_balance = T::Currency::balance(asset_id.clone(), &Self::pot_account_id());
485-
let r = T::Convert::convert(
490+
// Best-effort, like `on_idle`: skip an un-convertible slice rather than revert the
491+
// whole claim. Funds stay in the pot and re-queue on the next fee.
492+
if let Err(reason) = T::Convert::convert(
486493
Self::pot_account_id(),
487494
asset_id.clone(),
488495
T::RewardAsset::get(),
489496
asset_balance,
490-
);
491-
if let Err(error) = r {
492-
// We allow these errors to continue claiming as the current amount of asset that needed to be converted
493-
// has very low impact on the rewards.
494-
if error != Error::<T>::ConversionMinTradingAmountNotReached.into()
495-
&& error != Error::<T>::ConversionZeroAmountReceived.into()
496-
{
497-
return Err(error);
498-
}
497+
) {
498+
Self::deposit_event(Event::ConversionFailed {
499+
asset_id: asset_id.clone(),
500+
reason,
501+
});
499502
}
500503
PendingConversions::<T>::remove(asset_id);
501504
}
@@ -623,12 +626,17 @@ pub mod pallet {
623626
for asset_id in PendingConversions::<T>::iter_keys().take(max_converts as usize) {
624627
let asset_balance = T::Currency::balance(asset_id.clone(), &Self::pot_account_id());
625628
// remove the asset_id from PendingConversions even when the conversion fails
626-
let _ = T::Convert::convert(
629+
if let Err(reason) = T::Convert::convert(
627630
Self::pot_account_id(),
628631
asset_id.clone(),
629632
T::RewardAsset::get(),
630633
asset_balance,
631-
);
634+
) {
635+
Self::deposit_event(Event::ConversionFailed {
636+
asset_id: asset_id.clone(),
637+
reason,
638+
});
639+
}
632640
PendingConversions::<T>::remove(asset_id);
633641
actual_converts += 1;
634642
}

pallets/referrals/src/tests.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ thread_local! {
7070
pub static TIER_VOLUME: RefCell<HashMap<Level, Option<Balance>>> = RefCell::new(HashMap::default());
7171
pub static TIER_REWARDS: RefCell<HashMap<Level, FeeDistribution>> = RefCell::new(HashMap::default());
7272
pub static SEED_AMOUNT: RefCell<Balance> = RefCell::new(Balance::zero());
73+
// Mirrors runtime `MinTradingLimit`: reject sub-minimum amounts. 0 = disabled.
74+
pub static CONVERT_MIN_AMOUNT: RefCell<Balance> = RefCell::new(Balance::zero());
7375
}
7476

7577
construct_runtime!(
@@ -218,6 +220,10 @@ impl Default for ExtBuilder {
218220
v.borrow_mut().clear();
219221
});
220222

223+
CONVERT_MIN_AMOUNT.with(|v| {
224+
*v.borrow_mut() = 0u128;
225+
});
226+
221227
Self {
222228
endowed_accounts: vec![(ALICE, HDX, INITIAL_ALICE_BALANCE)],
223229
referrer_shares: vec![],
@@ -260,6 +266,12 @@ impl ExtBuilder {
260266
});
261267
self
262268
}
269+
pub fn with_convert_min_amount(self, amount: Balance) -> Self {
270+
CONVERT_MIN_AMOUNT.with(|v| {
271+
*v.borrow_mut() = amount;
272+
});
273+
self
274+
}
263275
pub fn with_seed_amount(self, amount: Balance) -> Self {
264276
SEED_AMOUNT.with(|v| {
265277
let mut m = v.borrow_mut();
@@ -364,6 +376,11 @@ impl Convert<AccountId, AssetId, Balance> for AssetConvert {
364376
asset_to: AssetId,
365377
amount: Balance,
366378
) -> Result<Balance, Self::Error> {
379+
let min_amount = CONVERT_MIN_AMOUNT.with(|v| *v.borrow());
380+
if amount > 0 && amount < min_amount {
381+
// Like `ConvertViaOmnipool`: sub-min amounts fail with a non-referrals error.
382+
return Err(DispatchError::Other("InsufficientTradingAmount"));
383+
}
367384
let price = CONVERSION_RATE
368385
.with(|v| v.borrow().get(&(asset_to, asset_from)).copied())
369386
.ok_or(Error::<Test>::ConversionMinTradingAmountNotReached)?;

pallets/referrals/src/tests/claim.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,40 @@ fn claim_rewards_should_remove_assets_from_the_list_when_not_successful() {
7474
});
7575
}
7676

77+
// Regression: sub-`MinTradingLimit` dust in `PendingConversions` must not block reward claims.
78+
#[test]
79+
fn claim_rewards_should_succeed_when_pending_asset_balance_is_below_min_trading_limit() {
80+
ExtBuilder::default()
81+
.with_endowed_accounts(vec![
82+
(Pallet::<Test>::pot_account_id(), HDX, 20_000_000_000_000),
83+
// 500 raw units of DAI — below the converter's min trading limit (1_000).
84+
(Pallet::<Test>::pot_account_id(), DAI, 500),
85+
])
86+
.with_referrer_shares(vec![(BOB, 5_000_000_000_000), (ALICE, 15_000_000_000_000)])
87+
.with_assets(vec![DAI])
88+
// Price present so the converter reaches the min-amount check (not the no-price branch,
89+
// whose error the claim loop happens to tolerate).
90+
.with_conversion_price((HDX, DAI), EmaPrice::new(1_000_000_000_000, 1_000_000_000_000_000_000))
91+
.with_convert_min_amount(1_000)
92+
.build()
93+
.execute_with(|| {
94+
assert_eq!(PendingConversions::<Test>::count(), 1);
95+
96+
// Bob can still claim his share despite the un-convertible dust asset.
97+
assert_ok!(Referrals::claim_rewards(RuntimeOrigin::signed(BOB)));
98+
99+
// Bob received his portion of the HDX reward reserve.
100+
assert_eq!(Tokens::free_balance(HDX, &BOB), 5_000_000_000_000);
101+
// The dust entry is dropped rather than left to block future claims.
102+
assert_eq!(PendingConversions::<Test>::count(), 0);
103+
// The skipped conversion is surfaced as an event, not silently swallowed.
104+
assert!(System::events().iter().any(|r| matches!(
105+
&r.event,
106+
RuntimeEvent::Referrals(Event::ConversionFailed { asset_id, .. }) if *asset_id == DAI
107+
)));
108+
});
109+
}
110+
77111
#[test]
78112
fn claim_rewards_should_calculate_correct_portion_when_claimed() {
79113
ExtBuilder::default()

0 commit comments

Comments
 (0)