diff --git a/pallets/fee-processor/src/tests/convert.rs b/pallets/fee-processor/src/tests/convert.rs index 484b72bfa..94c785437 100644 --- a/pallets/fee-processor/src/tests/convert.rs +++ b/pallets/fee-processor/src/tests/convert.rs @@ -6,6 +6,66 @@ use frame_support::traits::fungibles::Mutate; use frame_support::traits::Hooks; use frame_support::{assert_noop, assert_ok}; use pallet_currencies::fungibles::FungibleCurrencies; +use sp_runtime::Permill; + +#[test] +fn on_idle_retries_failed_conversion_on_next_block() { + ExtBuilder::default().build().execute_with(|| { + let pot = FeeProcessor::pot_account_id(); + + as Mutate>::mint_into(DOT, &pot, 500 * ONE).unwrap(); + PendingConversions::::insert(DOT, ()); + + // Conversion fails - asset stays pending + set_convert_result(None); + let weight = frame_support::weights::Weight::from_parts(200_000_000, 0); + FeeProcessor::on_idle(1u64, weight); + + assert!(PendingConversions::::contains_key(DOT)); + + // Conversion succeeds - asset removed from pending + set_convert_result(Some(1000 * ONE)); + as Mutate>::mint_into(HDX, &pot, 1000 * ONE).unwrap(); + System::set_block_number(2); + FeeProcessor::on_idle(2u64, weight); + + assert!(!PendingConversions::::contains_key(DOT)); + System::assert_has_event( + Event::Converted { + asset_id: DOT, + amount_in: 500 * ONE, + hdx_out: 1000 * ONE, + } + .into(), + ); + }); +} + +#[test] +fn convert_extrinsic_for_asset_not_in_pending_still_executes() { + ExtBuilder::default().build().execute_with(|| { + let pot = FeeProcessor::pot_account_id(); + + // Fund pot with DOT but do NOT insert into PendingConversions + as Mutate>::mint_into(DOT, &pot, 500 * ONE).unwrap(); + assert!(!PendingConversions::::contains_key(DOT)); + + set_convert_result(Some(1000 * ONE)); + as Mutate>::mint_into(HDX, &pot, 1000 * ONE).unwrap(); + + // do_convert has no guard - succeeds regardless of PendingConversions membership + assert_ok!(FeeProcessor::convert(RuntimeOrigin::signed(ALICE), DOT)); + + System::assert_has_event( + Event::Converted { + asset_id: DOT, + amount_in: 500 * ONE, + hdx_out: 1000 * ONE, + } + .into(), + ); + }); +} #[test] fn convert_extrinsic_works() { @@ -139,3 +199,102 @@ fn on_idle_respects_max_conversions_per_block() { ); }); } + +#[test] +fn distribute_to_pots_uses_total_param_not_actual_pot_balance() { + ExtBuilder::default().build().execute_with(|| { + let pot = FeeProcessor::pot_account_id(); + + // Pot already has extra HDX beyond ED (simulates leftover from previous rounds) + let pre_existing_hdx = 500 * ONE; + as Mutate>::mint_into(HDX, &pot, pre_existing_hdx).unwrap(); + + // Set up DOT for conversion + as Mutate>::mint_into(DOT, &pot, 200 * ONE).unwrap(); + PendingConversions::::insert(DOT, ()); + + // MockConvert returns 1000 ONE of HDX + let hdx_received = 1000 * ONE; + set_convert_result(Some(hdx_received)); + // Fund pot with the HDX that "convert" will produce + as Mutate>::mint_into(HDX, &pot, hdx_received).unwrap(); + + let staking_before = + as frame_support::traits::fungibles::Inspect>::balance( + HDX, + &STAKING_POT, + ); + let referrals_before = + as frame_support::traits::fungibles::Inspect>::balance( + HDX, + &REFERRALS_POT, + ); + + assert_ok!(FeeProcessor::convert(RuntimeOrigin::signed(ALICE), DOT)); + + let staking_after = as frame_support::traits::fungibles::Inspect>::balance( + HDX, + &STAKING_POT, + ); + let referrals_after = + as frame_support::traits::fungibles::Inspect>::balance( + HDX, + &REFERRALS_POT, + ); + + let staking_received = staking_after - staking_before; + let referrals_received = referrals_after - referrals_before; + + // FeeReceivers: StakingFeeReceiver=70%, ReferralsFeeReceiver=30% + // distribute_to_pots uses `hdx_received` (1000 ONE), NOT actual pot balance (1500+ ONE) + assert_eq!(staking_received, Permill::from_percent(70).mul_floor(hdx_received)); + assert_eq!(referrals_received, Permill::from_percent(30).mul_floor(hdx_received)); + + // Pre-existing HDX (500 ONE) + ED (1 ONE) stays on pot - not distributed + let pot_balance_after = + as frame_support::traits::fungibles::Inspect>::balance(HDX, &pot); + assert_eq!(pot_balance_after, ONE + pre_existing_hdx); // only ED + leftover remains + }); +} + +#[test] +fn on_idle_returns_zero_when_weight_below_single_conversion() { + ExtBuilder::default().build().execute_with(|| { + let pot = FeeProcessor::pot_account_id(); + + as Mutate>::mint_into(DOT, &pot, 500 * ONE).unwrap(); + PendingConversions::::insert(DOT, ()); + + set_convert_result(Some(1000 * ONE)); + + // One conversion costs Weight::from_parts(100_000_000, 0); pass one unit less + let below_threshold = frame_support::weights::Weight::from_parts(99_999_999, 0); + let used = FeeProcessor::on_idle(1u64, below_threshold); + + assert!(used.is_zero()); + assert!(PendingConversions::::contains_key(DOT)); + }); +} + +#[test] +fn on_idle_processes_only_one_when_weight_fits_exactly_one() { + ExtBuilder::default().build().execute_with(|| { + let pot = FeeProcessor::pot_account_id(); + + as Mutate>::mint_into(DOT, &pot, 500 * ONE).unwrap(); + as Mutate>::mint_into(DAI, &pot, 300 * ONE).unwrap(); + PendingConversions::::insert(DOT, ()); + PendingConversions::::insert(DAI, ()); + + set_convert_result(Some(1000 * ONE)); + as Mutate>::mint_into(HDX, &pot, 2000 * ONE).unwrap(); + + // Weight for exactly one conversion + let one_conversion = frame_support::weights::Weight::from_parts(100_000_000, 0); + let used = FeeProcessor::on_idle(1u64, one_conversion); + + assert_eq!(used, one_conversion); + // Exactly one asset removed, one still pending + assert_eq!(PendingConversions::::count(), 1); + }); +} diff --git a/pallets/fee-processor/src/tests/mock.rs b/pallets/fee-processor/src/tests/mock.rs index 958e57fac..d6c6b054a 100644 --- a/pallets/fee-processor/src/tests/mock.rs +++ b/pallets/fee-processor/src/tests/mock.rs @@ -1,5 +1,4 @@ use crate as pallet_fee_processor; -use crate::*; use frame_support::{ parameter_types, sp_runtime::{ @@ -42,9 +41,10 @@ pub const FEE_SOURCE: AccountId = 100; pub const STAKING_POT: AccountId = 200; pub const REFERRALS_POT: AccountId = 201; -// HDX path uses same destination accounts but different percentages +// HDX path pots pub const HDX_STAKING_POT: AccountId = 200; -pub const HDX_REFERRALS_POT: AccountId = 201; +pub const HDX_GIGAPOT: AccountId = 202; +pub const HDX_REWARD_POT: AccountId = 203; frame_support::construct_runtime!( pub enum Test { @@ -124,7 +124,6 @@ impl pallet_balances::Config for Test { } impl orml_tokens::Config for Test { - type RuntimeEvent = RuntimeEvent; type Balance = Balance; type Amount = Amount; type CurrencyId = AssetId; @@ -138,7 +137,6 @@ impl orml_tokens::Config for Test { } impl pallet_currencies::Config for Test { - type RuntimeEvent = RuntimeEvent; type MultiCurrency = Tokens; type NativeCurrency = BasicCurrencyAdapter; type Erc20Currency = MockErc20Currency; @@ -146,6 +144,7 @@ impl pallet_currencies::Config for Test { type ReserveAccount = TreasuryAccount; type GetNativeCurrencyId = NativeAssetId; type RegistryInspect = MockBoundErc20; + type EgressHandler = pallet_currencies::MockEgressHandler; type WeightInfo = (); } @@ -157,6 +156,11 @@ thread_local! { static DEPOSIT_CALLS: RefCell> = RefCell::new(Vec::new()); static HDX_PRE_DEPOSIT_CALLS: RefCell> = RefCell::new(Vec::new()); static HDX_DEPOSIT_CALLS: RefCell> = RefCell::new(Vec::new()); + static HDX_GIGAPOT_PRE_DEPOSIT_CALLS: RefCell> = RefCell::new(Vec::new()); + static HDX_GIGAPOT_DEPOSIT_CALLS: RefCell> = RefCell::new(Vec::new()); + static HDX_REWARD_POT_PRE_DEPOSIT_CALLS: RefCell> = RefCell::new(Vec::new()); + static HDX_REWARD_POT_DEPOSIT_CALLS: RefCell> = RefCell::new(Vec::new()); + static PRE_DEPOSIT_FAIL: RefCell = RefCell::new(false); } pub struct MockConvert; @@ -199,6 +203,26 @@ pub fn hdx_deposit_calls() -> Vec { HDX_DEPOSIT_CALLS.with(|c| c.borrow().clone()) } +pub fn hdx_gigapot_pre_deposit_calls() -> Vec<(AccountId, Balance)> { + HDX_GIGAPOT_PRE_DEPOSIT_CALLS.with(|c| c.borrow().clone()) +} + +pub fn hdx_gigapot_deposit_calls() -> Vec { + HDX_GIGAPOT_DEPOSIT_CALLS.with(|c| c.borrow().clone()) +} + +pub fn hdx_reward_pot_pre_deposit_calls() -> Vec<(AccountId, Balance)> { + HDX_REWARD_POT_PRE_DEPOSIT_CALLS.with(|c| c.borrow().clone()) +} + +pub fn hdx_reward_pot_deposit_calls() -> Vec { + HDX_REWARD_POT_DEPOSIT_CALLS.with(|c| c.borrow().clone()) +} + +pub fn set_pre_deposit_fail(fail: bool) { + PRE_DEPOSIT_FAIL.with(|f| *f.borrow_mut() = fail); +} + // --- Mock PriceProvider --- thread_local! { static MOCK_PRICE: RefCell> = RefCell::new(Some(EmaPrice::new(2, 1))); @@ -235,6 +259,9 @@ impl FeeReceiver for StakingFeeReceiver { fn on_pre_fee_deposit(trader: AccountId, amount: Balance) -> Result<(), Self::Error> { PRE_DEPOSIT_CALLS.with(|c| c.borrow_mut().push((trader, amount))); + if PRE_DEPOSIT_FAIL.with(|f| *f.borrow()) { + return Err(DispatchError::Other("pre_deposit_failed")); + } Ok(()) } @@ -268,43 +295,67 @@ impl FeeReceiver for ReferralsFeeReceiver { } } -// --- HDX-specific FeeReceivers (50/50 split) --- +// --- HDX-specific FeeReceivers (70/20/10 split, no referrals) --- -pub struct HdxStakingFeeReceiver; +pub struct HdxGigaHdxFeeReceiver; -impl FeeReceiver for HdxStakingFeeReceiver { +impl FeeReceiver for HdxGigaHdxFeeReceiver { type Error = DispatchError; fn destination() -> AccountId { - HDX_STAKING_POT + HDX_GIGAPOT } fn percentage() -> Permill { - Permill::from_percent(50) + Permill::from_percent(70) } fn on_pre_fee_deposit(trader: AccountId, amount: Balance) -> Result<(), Self::Error> { - HDX_PRE_DEPOSIT_CALLS.with(|c| c.borrow_mut().push((trader, amount))); + HDX_GIGAPOT_PRE_DEPOSIT_CALLS.with(|c| c.borrow_mut().push((trader, amount))); Ok(()) } fn on_fee_received(amount: Balance) -> Result<(), Self::Error> { - HDX_DEPOSIT_CALLS.with(|c| c.borrow_mut().push(amount)); + HDX_GIGAPOT_DEPOSIT_CALLS.with(|c| c.borrow_mut().push(amount)); Ok(()) } } -pub struct HdxReferralsFeeReceiver; +pub struct HdxGigaRewardFeeReceiver; -impl FeeReceiver for HdxReferralsFeeReceiver { +impl FeeReceiver for HdxGigaRewardFeeReceiver { type Error = DispatchError; fn destination() -> AccountId { - HDX_REFERRALS_POT + HDX_REWARD_POT } fn percentage() -> Permill { - Permill::from_percent(50) + Permill::from_percent(20) + } + + fn on_pre_fee_deposit(trader: AccountId, amount: Balance) -> Result<(), Self::Error> { + HDX_REWARD_POT_PRE_DEPOSIT_CALLS.with(|c| c.borrow_mut().push((trader, amount))); + Ok(()) + } + + fn on_fee_received(amount: Balance) -> Result<(), Self::Error> { + HDX_REWARD_POT_DEPOSIT_CALLS.with(|c| c.borrow_mut().push(amount)); + Ok(()) + } +} + +pub struct HdxStakingFeeReceiver; + +impl FeeReceiver for HdxStakingFeeReceiver { + type Error = DispatchError; + + fn destination() -> AccountId { + HDX_STAKING_POT + } + + fn percentage() -> Permill { + Permill::from_percent(10) } fn on_pre_fee_deposit(trader: AccountId, amount: Balance) -> Result<(), Self::Error> { @@ -319,7 +370,6 @@ impl FeeReceiver for HdxReferralsFeeReceiver { } impl pallet_fee_processor::Config for Test { - type RuntimeEvent = RuntimeEvent; type AssetId = AssetId; type Currency = FungibleCurrencies; type Convert = MockConvert; @@ -329,7 +379,7 @@ impl pallet_fee_processor::Config for Test { type LrnaAssetId = LrnaAssetId; type MaxConversionsPerBlock = MaxConversionsPerBlock; type FeeReceivers = (StakingFeeReceiver, ReferralsFeeReceiver); - type HdxFeeReceivers = (HdxStakingFeeReceiver, HdxReferralsFeeReceiver); + type HdxFeeReceivers = (HdxGigaHdxFeeReceiver, HdxGigaRewardFeeReceiver, HdxStakingFeeReceiver); type WeightInfo = (); } @@ -349,6 +399,8 @@ impl Default for ExtBuilder { // ED for pots (STAKING_POT, HDX, ONE), (REFERRALS_POT, HDX, ONE), + (HDX_GIGAPOT, HDX, ONE), + (HDX_REWARD_POT, HDX, ONE), // ED for fee processor pot (FeeProcessor::pot_account_id(), HDX, ONE), ], @@ -395,6 +447,11 @@ impl ExtBuilder { DEPOSIT_CALLS.with(|c| c.borrow_mut().clear()); HDX_PRE_DEPOSIT_CALLS.with(|c| c.borrow_mut().clear()); HDX_DEPOSIT_CALLS.with(|c| c.borrow_mut().clear()); + HDX_GIGAPOT_PRE_DEPOSIT_CALLS.with(|c| c.borrow_mut().clear()); + HDX_GIGAPOT_DEPOSIT_CALLS.with(|c| c.borrow_mut().clear()); + HDX_REWARD_POT_PRE_DEPOSIT_CALLS.with(|c| c.borrow_mut().clear()); + HDX_REWARD_POT_DEPOSIT_CALLS.with(|c| c.borrow_mut().clear()); + PRE_DEPOSIT_FAIL.with(|f| *f.borrow_mut() = false); MOCK_PRICE.with(|p| *p.borrow_mut() = Some(EmaPrice::new(2, 1))); }); ext diff --git a/pallets/fee-processor/src/tests/process_fee.rs b/pallets/fee-processor/src/tests/process_fee.rs index 674388bd1..b1290ad27 100644 --- a/pallets/fee-processor/src/tests/process_fee.rs +++ b/pallets/fee-processor/src/tests/process_fee.rs @@ -1,15 +1,18 @@ use super::mock::*; use crate::*; use frame_support::assert_ok; +use frame_support::storage::with_transaction; use frame_support::traits::fungibles::{Inspect, Mutate}; use pallet_currencies::fungibles::FungibleCurrencies; +use sp_runtime::TransactionOutcome; #[test] fn hdx_fee_distributes_to_pots_immediately() { ExtBuilder::default().build().execute_with(|| { let amount = 1000 * ONE; + let gigapot_before = as Inspect>::balance(HDX, &HDX_GIGAPOT); + let reward_pot_before = as Inspect>::balance(HDX, &HDX_REWARD_POT); let staking_before = as Inspect>::balance(HDX, &HDX_STAKING_POT); - let referrals_before = as Inspect>::balance(HDX, &HDX_REFERRALS_POT); let result = Pallet::::process_trade_fee(FEE_SOURCE, ALICE, HDX, amount); assert!(result.is_ok()); @@ -18,12 +21,14 @@ fn hdx_fee_distributes_to_pots_immediately() { assert_eq!(taken, amount); assert_eq!(pot_account, FeeProcessor::pot_account_id()); + let gigapot_after = as Inspect>::balance(HDX, &HDX_GIGAPOT); + let reward_pot_after = as Inspect>::balance(HDX, &HDX_REWARD_POT); let staking_after = as Inspect>::balance(HDX, &HDX_STAKING_POT); - let referrals_after = as Inspect>::balance(HDX, &HDX_REFERRALS_POT); - // HdxFeeReceivers: 50% to staking, 50% to referrals - assert_eq!(staking_after - staking_before, 500 * ONE); - assert_eq!(referrals_after - referrals_before, 500 * ONE); + // HdxFeeReceivers: 70% gigapot, 20% reward pot, 10% staking + assert_eq!(gigapot_after - gigapot_before, 700 * ONE); + assert_eq!(reward_pot_after - reward_pot_before, 200 * ONE); + assert_eq!(staking_after - staking_before, 100 * ONE); }); } @@ -34,16 +39,32 @@ fn hdx_fee_fires_callbacks_with_correct_amounts() { let _ = Pallet::::process_trade_fee(FEE_SOURCE, ALICE, HDX, amount); - // HDX path uses HdxFeeReceivers (50/50 split) - let pre = hdx_pre_deposit_calls(); - assert_eq!(pre.len(), 2); - assert_eq!(pre[0], (ALICE, 500 * ONE)); - assert_eq!(pre[1], (ALICE, 500 * ONE)); + // HdxGigaHdxFeeReceiver (70%) records to HDX_GIGAPOT_PRE_DEPOSIT_CALLS + let gigapot_pre = hdx_gigapot_pre_deposit_calls(); + assert_eq!(gigapot_pre.len(), 1); + assert_eq!(gigapot_pre[0], (ALICE, 700 * ONE)); - let post = hdx_deposit_calls(); - assert_eq!(post.len(), 2); - assert_eq!(post[0], 500 * ONE); - assert_eq!(post[1], 500 * ONE); + let gigapot_post = hdx_gigapot_deposit_calls(); + assert_eq!(gigapot_post.len(), 1); + assert_eq!(gigapot_post[0], 700 * ONE); + + // HdxGigaRewardFeeReceiver (20%) records to HDX_REWARD_POT_PRE_DEPOSIT_CALLS + let reward_pre = hdx_reward_pot_pre_deposit_calls(); + assert_eq!(reward_pre.len(), 1); + assert_eq!(reward_pre[0], (ALICE, 200 * ONE)); + + let reward_post = hdx_reward_pot_deposit_calls(); + assert_eq!(reward_post.len(), 1); + assert_eq!(reward_post[0], 200 * ONE); + + // HdxStakingFeeReceiver (10%) records to HDX_PRE_DEPOSIT_CALLS + let staking_pre = hdx_pre_deposit_calls(); + assert_eq!(staking_pre.len(), 1); + assert_eq!(staking_pre[0], (ALICE, 100 * ONE)); + + let staking_post = hdx_deposit_calls(); + assert_eq!(staking_post.len(), 1); + assert_eq!(staking_post[0], 100 * ONE); // Non-HDX FeeReceivers should NOT have been called assert!(pre_deposit_calls().is_empty()); @@ -210,22 +231,27 @@ fn hdx_and_non_hdx_use_different_receivers() { ExtBuilder::default().build().execute_with(|| { let pot = FeeProcessor::pot_account_id(); - // --- HDX fee: uses HdxFeeReceivers (50/50) --- + // --- HDX fee: uses HdxFeeReceivers (70/20/10, no referrals) --- let hdx_amount = 1000 * ONE; - let staking_before = as Inspect>::balance(HDX, &STAKING_POT); - let referrals_before = as Inspect>::balance(HDX, &REFERRALS_POT); + let gigapot_before = as Inspect>::balance(HDX, &HDX_GIGAPOT); + let reward_pot_before = as Inspect>::balance(HDX, &HDX_REWARD_POT); + let staking_before = as Inspect>::balance(HDX, &HDX_STAKING_POT); let _ = Pallet::::process_trade_fee(FEE_SOURCE, ALICE, HDX, hdx_amount); - let staking_after_hdx = as Inspect>::balance(HDX, &STAKING_POT); - let referrals_after_hdx = as Inspect>::balance(HDX, &REFERRALS_POT); + let gigapot_after = as Inspect>::balance(HDX, &HDX_GIGAPOT); + let reward_pot_after = as Inspect>::balance(HDX, &HDX_REWARD_POT); + let staking_after_hdx = as Inspect>::balance(HDX, &HDX_STAKING_POT); - // HDX path: 50/50 via HdxFeeReceivers - assert_eq!(staking_after_hdx - staking_before, 500 * ONE); - assert_eq!(referrals_after_hdx - referrals_before, 500 * ONE); + // HDX path: 70% gigapot, 20% reward pot, 10% staking + assert_eq!(gigapot_after - gigapot_before, 700 * ONE); + assert_eq!(reward_pot_after - reward_pot_before, 200 * ONE); + assert_eq!(staking_after_hdx - staking_before, 100 * ONE); - // HDX callbacks fired, non-HDX callbacks did NOT fire - assert_eq!(hdx_pre_deposit_calls().len(), 2); + // HDX callbacks fired (gigapot + reward_pot + staking = 3), non-HDX callbacks did NOT fire + assert_eq!(hdx_gigapot_pre_deposit_calls().len(), 1); + assert_eq!(hdx_reward_pot_pre_deposit_calls().len(), 1); + assert_eq!(hdx_pre_deposit_calls().len(), 1); assert!(pre_deposit_calls().is_empty()); // --- Non-HDX fee (conversion path): uses FeeReceivers (70/30) --- @@ -249,3 +275,59 @@ fn hdx_and_non_hdx_use_different_receivers() { assert_eq!(post[1], 300 * ONE); }); } + +#[test] +fn process_trade_fee_same_asset_twice_does_not_duplicate_pending_count() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(Pallet::::process_trade_fee(FEE_SOURCE, ALICE, DOT, 100 * ONE)); + assert_ok!(Pallet::::process_trade_fee(FEE_SOURCE, ALICE, DOT, 100 * ONE)); + + // CountedStorageMap::insert is idempotent for an existing key + assert_eq!(PendingConversions::::count(), 1); + }); +} + +#[test] +fn process_trade_fee_fails_when_source_has_insufficient_balance() { + ExtBuilder::default().build().execute_with(|| { + let pot = FeeProcessor::pot_account_id(); + let dot_on_pot_before = as Inspect>::balance(DOT, &pot); + + // FEE_SOURCE has 100_000 * ONE DOT; request more than available + let result = Pallet::::process_trade_fee(FEE_SOURCE, ALICE, DOT, 200_000 * ONE); + + assert!(result.is_err()); + // Transfer failed before anything was written + let dot_on_pot_after = as Inspect>::balance(DOT, &pot); + assert_eq!(dot_on_pot_after, dot_on_pot_before); + assert!(!PendingConversions::::contains_key(DOT)); + }); +} + +#[test] +fn process_trade_fee_nothing_changes_when_pre_deposit_callback_fails() { + ExtBuilder::default().build().execute_with(|| { + let amount = 500 * ONE; + let pot = FeeProcessor::pot_account_id(); + let dot_on_pot_before = as Inspect>::balance(DOT, &pot); + + set_pre_deposit_fail(true); + + // Wrap in a transaction to replicate extrinsic dispatch semantics: + // the runtime rolls back all storage changes when a call returns Err. + let result: Result<_, _> = with_transaction(|| { + let r = Pallet::::process_trade_fee(FEE_SOURCE, ALICE, DOT, amount); + if r.is_err() { + TransactionOutcome::Rollback(r.map(|_| ())) + } else { + TransactionOutcome::Commit(r.map(|_| ())) + } + }); + + assert!(result.is_err()); + // Transaction rolled back — pot balance and pending state both unchanged + let dot_on_pot_after = as Inspect>::balance(DOT, &pot); + assert_eq!(dot_on_pot_after, dot_on_pot_before); + assert!(!PendingConversions::::contains_key(DOT)); + }); +} diff --git a/pallets/referrals/src/tests.rs b/pallets/referrals/src/tests.rs index 302f1a52e..c0747759c 100644 --- a/pallets/referrals/src/tests.rs +++ b/pallets/referrals/src/tests.rs @@ -15,6 +15,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +mod accumulator; mod claim; mod flow; mod link; diff --git a/pallets/referrals/src/tests/accumulator.rs b/pallets/referrals/src/tests/accumulator.rs new file mode 100644 index 000000000..2b92be37a --- /dev/null +++ b/pallets/referrals/src/tests/accumulator.rs @@ -0,0 +1,235 @@ +use crate::tests::*; +use pretty_assertions::assert_eq; + +#[test] +fn on_hdx_deposited_bumps_reward_per_share_correctly() { + let total_shares = 1_000 * ONE; + let amount = 500 * ONE; + + ExtBuilder::default() + .with_referrer_shares(vec![(ALICE, total_shares)]) + .build() + .execute_with(|| { + assert_ok!(Referrals::on_hdx_deposited(amount)); + + let expected_rps = U256::from(amount) * U256::from(ONE_E18) / U256::from(total_shares); + assert_eq!(RewardPerShare::::get(), expected_rps); + }); +} + +#[test] +fn on_hdx_deposited_does_nothing_when_total_shares_is_zero() { + ExtBuilder::default().build().execute_with(|| { + assert_ok!(Referrals::on_hdx_deposited(500 * ONE)); + + assert_eq!(RewardPerShare::::get(), U256::zero()); + }); +} + +#[test] +fn on_fee_received_calculates_shares_correctly_for_all_parties() { + let referrer_pct = Permill::from_percent(50); + let trader_pct = Permill::from_percent(30); + let external_pct = Permill::from_percent(20); + let hdx_amount = 1_000 * ONE; + + ExtBuilder::default() + .with_tiers(vec![( + HDX, + Level::Tier0, + FeeDistribution { + referrer: referrer_pct, + trader: trader_pct, + external: external_pct, + }, + )]) + .with_external_account(CHARLIE) + .build() + .execute_with(|| { + let code: ReferralCode<::CodeLength> = b"ALICE1".to_vec().try_into().unwrap(); + assert_ok!(Referrals::register_code(RuntimeOrigin::signed(ALICE), code.clone())); + assert_ok!(Referrals::link_code(RuntimeOrigin::signed(BOB), code)); + + assert_ok!(Referrals::on_fee_received(BOB, hdx_amount)); + + assert_eq!(ReferrerShares::::get(ALICE), referrer_pct.mul_floor(hdx_amount)); + assert_eq!(TraderShares::::get(BOB), trader_pct.mul_floor(hdx_amount)); + assert_eq!(TraderShares::::get(CHARLIE), external_pct.mul_floor(hdx_amount)); + }); +} + +#[test] +fn on_fee_received_skips_referrer_share_when_no_code_linked() { + let trader_pct = Permill::from_percent(30); + let hdx_amount = 1_000 * ONE; + + ExtBuilder::default() + .with_tiers(vec![( + HDX, + Level::None, + FeeDistribution { + referrer: Permill::from_percent(50), + trader: trader_pct, + external: Permill::zero(), + }, + )]) + .build() + .execute_with(|| { + // BOB is not linked to any referrer + assert_ok!(Referrals::on_fee_received(BOB, hdx_amount)); + + assert_eq!(ReferrerShares::::get(ALICE), 0); + assert_eq!(ReferrerShares::::get(BOB), 0); + assert_eq!(TraderShares::::get(BOB), trader_pct.mul_floor(hdx_amount)); + }); +} + +#[test] +fn checkpoint_user_accumulates_pending_rewards_before_share_change() { + let bob_trader_shares = 1_000 * ONE; + let hdx_deposited = 500 * ONE; + + ExtBuilder::default() + .with_trader_shares(vec![(BOB, bob_trader_shares)]) + // Level::None rewards: trader gets 30%, no referrer needed + .with_tiers(vec![( + HDX, + Level::None, + FeeDistribution { + referrer: Permill::zero(), + trader: Permill::from_percent(30), + external: Permill::zero(), + }, + )]) + .build() + .execute_with(|| { + // Bump RPS - BOB now has pending rewards + assert_ok!(Referrals::on_hdx_deposited(hdx_deposited)); + + let rps = RewardPerShare::::get(); + let expected_accumulated = + Balance::try_from(U256::from(bob_trader_shares) * rps / U256::from(ONE_E18)).unwrap(); + + // on_fee_received internally calls checkpoint_user for BOB + // (because trader_shares to add > 0) before mutating his share balance. + assert_ok!(Referrals::on_fee_received(BOB, 1_000 * ONE)); + + assert_eq!(UserAccumulatedRewards::::get(BOB), expected_accumulated); + }); +} + +#[test] +fn claim_rewards_cannot_double_claim_via_debt_mechanism() { + let total_shares = 10_000 * ONE; + let pot_balance = 10_000 * ONE; + let bob_shares = 2_000 * ONE; + let rps = U256::from(pot_balance) * U256::from(ONE_E18) / U256::from(total_shares); + + ExtBuilder::default() + .with_endowed_accounts(vec![(Pallet::::pot_account_id(), HDX, pot_balance)]) + .with_referrer_shares(vec![(BOB, bob_shares), (ALICE, total_shares - bob_shares)]) + .with_reward_per_share(rps) + .build() + .execute_with(|| { + let bob_before = Tokens::free_balance(HDX, &BOB); + + // First claim: BOB receives their proportional share + assert_ok!(Referrals::claim_rewards(RuntimeOrigin::signed(BOB))); + let bob_after_first = Tokens::free_balance(HDX, &BOB); + assert_eq!( + bob_after_first - bob_before, + pot_balance * bob_shares / total_shares, + "first claim must yield correct proportion" + ); + + // Second claim: shares burned, total_user_shares = 0 -> early return + assert_ok!(Referrals::claim_rewards(RuntimeOrigin::signed(BOB))); + let bob_after_second = Tokens::free_balance(HDX, &BOB); + assert_eq!(bob_after_second, bob_after_first, "second claim must yield nothing"); + }); +} + +#[test] +fn on_fee_received_external_account_none_shares_dropped() { + let referrer_pct = Permill::from_percent(50); + let trader_pct = Permill::from_percent(30); + let external_pct = Permill::from_percent(20); + let hdx_amount = 1_000 * ONE; + + ExtBuilder::default() + .with_tiers(vec![( + HDX, + Level::Tier0, + FeeDistribution { + referrer: referrer_pct, + trader: trader_pct, + external: external_pct, + }, + )]) + // No external account configured + .build() + .execute_with(|| { + let code: ReferralCode<::CodeLength> = b"ALICE1".to_vec().try_into().unwrap(); + assert_ok!(Referrals::register_code(RuntimeOrigin::signed(ALICE), code.clone())); + assert_ok!(Referrals::link_code(RuntimeOrigin::signed(BOB), code)); + + assert_ok!(Referrals::on_fee_received(BOB, hdx_amount)); + + let expected_referrer = referrer_pct.mul_floor(hdx_amount); + let expected_trader = trader_pct.mul_floor(hdx_amount); + + assert_eq!(ReferrerShares::::get(ALICE), expected_referrer); + assert_eq!(TraderShares::::get(BOB), expected_trader); + // External percentage is dropped - TotalShares only includes referrer + trader + assert_eq!(TotalShares::::get(), expected_referrer + expected_trader); + }); +} + +#[test] +fn on_hdx_deposited_accumulates_correctly_over_multiple_calls() { + let total_shares = 1_000 * ONE; + let amount1 = 300 * ONE; + let amount2 = 200 * ONE; + + ExtBuilder::default() + .with_referrer_shares(vec![(ALICE, total_shares)]) + .build() + .execute_with(|| { + assert_ok!(Referrals::on_hdx_deposited(amount1)); + assert_ok!(Referrals::on_hdx_deposited(amount2)); + + // Each call increments RPS by amount * ONE_E18 / total_shares; + // the two increments sum to (amount1 + amount2) * ONE_E18 / total_shares. + let expected_rps = + U256::from(amount1 + amount2) * U256::from(ONE_E18) / U256::from(total_shares); + assert_eq!(RewardPerShare::::get(), expected_rps); + }); +} + +#[test] +fn claim_rewards_combines_accumulated_and_pending_rewards() { + let bob_shares = 1_000 * ONE; + // rps chosen so that pending = bob_shares * rps / ONE_E18 = 500 * ONE (with debt = 0) + let rps = U256::from(ONE_E18) / U256::from(2u128); + let pending = Balance::try_from(U256::from(bob_shares) * rps / U256::from(ONE_E18)).unwrap(); + let accumulated = 200 * ONE; + let expected_total = accumulated + pending; + + ExtBuilder::default() + .with_endowed_accounts(vec![(Pallet::::pot_account_id(), HDX, expected_total)]) + .with_referrer_shares(vec![(BOB, bob_shares)]) + .with_reward_per_share(rps) + .with_user_accumulated_rewards(vec![(BOB, accumulated)]) + // UserRewardDebt defaults to zero - full RPS applies as pending + .build() + .execute_with(|| { + let bob_before = Tokens::free_balance(HDX, &BOB); + + assert_ok!(Referrals::claim_rewards(RuntimeOrigin::signed(BOB))); + + assert_eq!(Tokens::free_balance(HDX, &BOB) - bob_before, expected_total); + // Both debt and accumulated cleared after the claim + assert_eq!(UserRewardDebt::::get(BOB), U256::zero()); + assert_eq!(UserAccumulatedRewards::::get(BOB), 0); + }); +}