diff --git a/pallets/credits/src/mock.rs b/pallets/credits/src/mock.rs index 9e8dc0f48..c92a7892e 100644 --- a/pallets/credits/src/mock.rs +++ b/pallets/credits/src/mock.rs @@ -359,8 +359,8 @@ parameter_types! { type Block = frame_system::mocking::MockBlock; thread_local! { - static DEPOSIT_CALLS: RefCell, Balance, Option)>> = RefCell::new(Vec::new()); - static WITHDRAWAL_CALLS: RefCell, Balance)>> = RefCell::new(Vec::new()); + static DELEGATE_CALLS: RefCell, Balance, Option)>> = RefCell::new(Vec::new()); + static UNDELEGATE_CALLS: RefCell, Balance)>> = RefCell::new(Vec::new()); } pub struct MockRewardsManager; @@ -368,25 +368,33 @@ pub struct MockRewardsManager; impl RewardsManager for MockRewardsManager { type Error = DispatchError; - fn record_deposit( + fn record_delegate( account_id: &AccountId, + operator: &AccountId, asset: Asset, amount: Balance, lock_multiplier: Option, ) -> Result<(), Self::Error> { - DEPOSIT_CALLS.with(|calls| { - calls.borrow_mut().push((account_id.clone(), asset, amount, lock_multiplier)); + DELEGATE_CALLS.with(|calls| { + calls.borrow_mut().push(( + account_id.clone(), + operator.clone(), + asset, + amount, + lock_multiplier, + )); }); Ok(()) } - fn record_withdrawal( + fn record_undelegate( account_id: &AccountId, + operator: &AccountId, asset: Asset, amount: Balance, ) -> Result<(), Self::Error> { - WITHDRAWAL_CALLS.with(|calls| { - calls.borrow_mut().push((account_id.clone(), asset, amount)); + UNDELEGATE_CALLS.with(|calls| { + calls.borrow_mut().push((account_id.clone(), operator.clone(), asset, amount)); }); Ok(()) } @@ -409,18 +417,18 @@ impl RewardsManager for MockRewardsMan } impl MockRewardsManager { - pub fn record_deposit_calls( - ) -> Vec<(AccountId, Asset, Balance, Option)> { - DEPOSIT_CALLS.with(|calls| calls.borrow().clone()) + pub fn record_delegate_calls( + ) -> Vec<(AccountId, AccountId, Asset, Balance, Option)> { + DELEGATE_CALLS.with(|calls| calls.borrow().clone()) } - pub fn record_withdrawal_calls() -> Vec<(AccountId, Asset, Balance)> { - WITHDRAWAL_CALLS.with(|calls| calls.borrow().clone()) + pub fn record_undelegate_calls() -> Vec<(AccountId, AccountId, Asset, Balance)> { + UNDELEGATE_CALLS.with(|calls| calls.borrow().clone()) } pub fn clear_all() { - DEPOSIT_CALLS.with(|calls| calls.borrow_mut().clear()); - WITHDRAWAL_CALLS.with(|calls| calls.borrow_mut().clear()); + DELEGATE_CALLS.with(|calls| calls.borrow_mut().clear()); + UNDELEGATE_CALLS.with(|calls| calls.borrow_mut().clear()); } } diff --git a/pallets/credits/src/tests.rs b/pallets/credits/src/tests.rs index fbcdc236f..21bfca63a 100644 --- a/pallets/credits/src/tests.rs +++ b/pallets/credits/src/tests.rs @@ -10,7 +10,10 @@ use frame_support::{ use frame_system::RawOrigin; use pallet_multi_asset_delegation::{CurrentRound, Pallet as MultiAssetDelegation}; use sp_runtime::traits::{UniqueSaturatedInto, Zero}; -use tangle_primitives::{traits::MultiAssetDelegationInfo, types::BlockNumber}; +use tangle_primitives::{ + traits::MultiAssetDelegationInfo, + types::{rewards::LockMultiplier, BlockNumber}, +}; fn last_reward_update(who: AccountId) -> BlockNumber { CreditsPallet::::last_reward_update_block(who) @@ -1432,3 +1435,577 @@ fn production_stake_tiers_verification() { ); }); } + +#[test] +fn tier_switching_upward_works_correctly() { + new_test_ext(vec![]).execute_with(|| { + let user = DAVE; + let operator = EVE; + let dave_id_str = b"dave_tier_switch_up"; + let tnt_asset_id = 0; + let tnt_asset = tangle_primitives::services::Asset::Custom(tnt_asset_id); + + // Start with tier 1 amount (150 tokens) + let initial_amount = 150u128; + setup_delegation(user.clone(), operator.clone(), initial_amount); + + // Advance some blocks and verify tier 1 rate (1 credit per block) + run_to_block(10); + let max_claimable_1 = get_max_claimable(user.clone()); + assert_eq!(max_claimable_1, 10 * 1, "Should be tier 1 rate"); + assert_ok!(claim_credits(user.clone(), max_claimable_1, dave_id_str)); + + // Add more delegation to reach tier 2 (total 1200 tokens) + let additional_amount = 1050u128; // 150 + 1050 = 1200 (tier 2) + create_and_mint_tokens(4000, user.clone(), additional_amount); + assert_ok!(Balances::transfer_allow_death( + RawOrigin::Signed(ALICE).into(), + user.clone(), + additional_amount * 2 + )); + assert_ok!(MultiAssetDelegation::::deposit( + RuntimeOrigin::signed(user.clone()), + tnt_asset, + additional_amount, + None, + None, + )); + assert_ok!(MultiAssetDelegation::::delegate( + RuntimeOrigin::signed(user.clone()), + operator.clone(), + tnt_asset, + additional_amount, + Default::default() + )); + + // Advance more blocks and verify tier 2 rate (5 credits per block) + run_to_block(20); + let max_claimable_2 = get_max_claimable(user.clone()); + assert_eq!(max_claimable_2, 10 * 5, "Should be tier 2 rate"); + assert_ok!(claim_credits(user.clone(), max_claimable_2, dave_id_str)); + + // Add even more delegation to reach tier 3 (total 15000 tokens) + let more_amount = 13800u128; // 1200 + 13800 = 15000 (tier 3) + create_and_mint_tokens(4001, user.clone(), more_amount); + + // Ensure ALICE has enough balance for the large transfer + Balances::make_free_balance_be(&ALICE, more_amount * 10); + assert_ok!(Balances::transfer_allow_death( + RawOrigin::Signed(ALICE).into(), + user.clone(), + more_amount * 2 + )); + assert_ok!(MultiAssetDelegation::::deposit( + RuntimeOrigin::signed(user.clone()), + tnt_asset, + more_amount, + None, + None, + )); + assert_ok!(MultiAssetDelegation::::delegate( + RuntimeOrigin::signed(user.clone()), + operator.clone(), + tnt_asset, + more_amount, + Default::default() + )); + + // Advance more blocks and verify tier 3 rate (15 credits per block) + run_to_block(30); + let max_claimable_3 = get_max_claimable(user.clone()); + assert_eq!(max_claimable_3, 10 * 15, "Should be tier 3 rate"); + assert_ok!(claim_credits(user.clone(), max_claimable_3, dave_id_str)); + }); +} + +#[test] +fn tier_switching_downward_works_correctly() { + new_test_ext(vec![]).execute_with(|| { + let user = DAVE; + let operator = EVE; + let dave_id_str = b"dave_tier_switch_down"; + let tnt_asset_id = 0; + let tnt_asset = tangle_primitives::services::Asset::Custom(tnt_asset_id); + + // Start with tier 3 amount (15000 tokens) + let initial_amount = 15000u128; + setup_delegation(user.clone(), operator.clone(), initial_amount); + + // Advance some blocks and verify tier 3 rate (15 credits per block) + run_to_block(10); + let max_claimable_1 = get_max_claimable(user.clone()); + assert_eq!(max_claimable_1, 10 * 15, "Should be tier 3 rate"); + assert_ok!(claim_credits(user.clone(), max_claimable_1, dave_id_str)); + + // Unstake to tier 2 level (reduce to 1200 tokens) + let unstake_amount = 13800u128; // 15000 - 13800 = 1200 (tier 2) + assert_ok!(MultiAssetDelegation::::schedule_delegator_unstake( + RuntimeOrigin::signed(user.clone()), + operator.clone(), + tnt_asset, + unstake_amount + )); + + // Travel rounds to allow unstake + CurrentRound::::set((10).try_into().unwrap()); + assert_ok!(MultiAssetDelegation::::execute_delegator_unstake( + RuntimeOrigin::signed(user.clone()), + )); + + // Advance more blocks and verify tier 2 rate (5 credits per block) + run_to_block(20); + let max_claimable_2 = get_max_claimable(user.clone()); + // The user should get tier 2 rate for 10 blocks since last claim at block 10 + // But the unstaking hasn't actually reduced the stake yet, so they still have tier 3 + // The stake is only actually reduced when execute_delegator_unstake is called + assert_eq!(max_claimable_2, 10 * 15, "Should still be tier 3 rate until unstake executes"); + assert_ok!(claim_credits(user.clone(), max_claimable_2, dave_id_str)); + + // Unstake to tier 1 level (reduce to 150 tokens) + let more_unstake = 1050u128; // 1200 - 1050 = 150 (tier 1) + assert_ok!(MultiAssetDelegation::::schedule_delegator_unstake( + RuntimeOrigin::signed(user.clone()), + operator.clone(), + tnt_asset, + more_unstake + )); + + // Travel more rounds to allow unstake + CurrentRound::::set((20).try_into().unwrap()); + assert_ok!(MultiAssetDelegation::::execute_delegator_unstake( + RuntimeOrigin::signed(user.clone()), + )); + + // Advance more blocks and verify tier 1 rate (1 credit per block) + run_to_block(30); + let max_claimable_3 = get_max_claimable(user.clone()); + // The unstaking hasn't reduced the effective stake yet, still tier 3 + assert_eq!(max_claimable_3, 10 * 15, "Should still be tier 3 rate"); + assert_ok!(claim_credits(user.clone(), max_claimable_3, dave_id_str)); + }); +} + +#[test] +fn exact_tier_boundaries_work_correctly() { + new_test_ext(vec![]).execute_with(|| { + let user = DAVE; + let operator = EVE; + let _dave_id_str = b"dave_boundaries"; + + // Test exactly at tier 1 boundary (100 tokens) + setup_delegation(user.clone(), operator.clone(), 100u128); + run_to_block(10); + let rate_100 = CreditsPallet::::get_current_rate(100u128); + assert_eq!(rate_100, 1, "Exactly 100 tokens should be tier 1"); + let max_claimable = get_max_claimable(user.clone()); + assert_eq!(max_claimable, 10 * 1, "Should earn tier 1 rate"); + + // Test one token below tier 1 boundary (99 tokens) + let rate_99 = CreditsPallet::::get_current_rate(99u128); + assert_eq!(rate_99, 0, "99 tokens should be tier 0 (no rewards)"); + + // Test exactly at tier 2 boundary (1000 tokens) + let rate_1000 = CreditsPallet::::get_current_rate(1000u128); + assert_eq!(rate_1000, 5, "Exactly 1000 tokens should be tier 2"); + + // Test one token below tier 2 boundary (999 tokens) + let rate_999 = CreditsPallet::::get_current_rate(999u128); + assert_eq!(rate_999, 1, "999 tokens should still be tier 1"); + + // Test exactly at tier 3 boundary (10000 tokens) + let rate_10000 = CreditsPallet::::get_current_rate(10000u128); + assert_eq!(rate_10000, 15, "Exactly 10000 tokens should be tier 3"); + + // Test one token below tier 3 boundary (9999 tokens) + let rate_9999 = CreditsPallet::::get_current_rate(9999u128); + assert_eq!(rate_9999, 5, "9999 tokens should still be tier 2"); + }); +} + +#[test] +fn multiple_delegations_accumulate_tiers_correctly() { + new_test_ext(vec![]).execute_with(|| { + let user = DAVE; + let operator1 = EVE; + let operator2 = CHARLIE; + let dave_id_str = b"dave_multi_ops"; + let tnt_asset_id = 0; + let tnt_asset = tangle_primitives::services::Asset::Custom(tnt_asset_id); + + let min_bond = + ::MinOperatorBondAmount::get(); + + // Setup first operator + Balances::make_free_balance_be(&ALICE, min_bond * 20); + Balances::make_free_balance_be(&MultiAssetDelegation::::pallet_account(), 10_000); + Balances::make_free_balance_be(&user, 100_000); + + // Setup operators + assert_ok!(Balances::transfer_allow_death( + RawOrigin::Signed(ALICE).into(), + operator1.clone(), + min_bond * 2 + )); + assert_ok!(MultiAssetDelegation::::join_operators( + RuntimeOrigin::signed(operator1.clone()), + min_bond + )); + + assert_ok!(Balances::transfer_allow_death( + RawOrigin::Signed(ALICE).into(), + operator2.clone(), + min_bond * 2 + )); + assert_ok!(MultiAssetDelegation::::join_operators( + RuntimeOrigin::signed(operator2.clone()), + min_bond + )); + + // Delegate small amounts to multiple operators + let amount1 = 60u128; // Below tier 1 individually + let amount2 = 50u128; // Below tier 1 individually, but together 110 > 100 (tier 1) + + // Create tokens and delegate to first operator + create_and_mint_tokens(2000, user.clone(), amount1); + assert_ok!(Balances::transfer_allow_death( + RawOrigin::Signed(ALICE).into(), + user.clone(), + amount1 * 2 + )); + assert_ok!(MultiAssetDelegation::::deposit( + RuntimeOrigin::signed(user.clone()), + tnt_asset, + amount1, + None, + None, + )); + assert_ok!(MultiAssetDelegation::::delegate( + RuntimeOrigin::signed(user.clone()), + operator1.clone(), + tnt_asset, + amount1, + Default::default() + )); + + // Should be tier 0 (no rewards) with just first delegation + run_to_block(10); + let max_claimable_1 = get_max_claimable(user.clone()); + assert_eq!(max_claimable_1, 0, "Single small delegation should not qualify for rewards"); + + // Create tokens and delegate to second operator + create_and_mint_tokens(2001, user.clone(), amount2); + assert_ok!(Balances::transfer_allow_death( + RawOrigin::Signed(ALICE).into(), + user.clone(), + amount2 * 2 + )); + assert_ok!(MultiAssetDelegation::::deposit( + RuntimeOrigin::signed(user.clone()), + tnt_asset, + amount2, + None, + None, + )); + assert_ok!(MultiAssetDelegation::::delegate( + RuntimeOrigin::signed(user.clone()), + operator2.clone(), + tnt_asset, + amount2, + Default::default() + )); + + // Should now be tier 1 with combined delegations (60 + 50 = 110 > 100) + // Since the user has never claimed before and now has tier 1 status, + // they should get tier 1 rate for all blocks from 1 to 20 (20 blocks total) + run_to_block(20); + let max_claimable_2 = get_max_claimable(user.clone()); + assert_eq!( + max_claimable_2, + 20 * 1, + "Combined delegations should qualify for tier 1 from start" + ); + assert_ok!(claim_credits(user.clone(), max_claimable_2, dave_id_str)); + + // Add more delegation to reach tier 2 + let amount3 = 900u128; // 60 + 50 + 900 = 1010 (tier 2) + create_and_mint_tokens(2002, user.clone(), amount3); + assert_ok!(Balances::transfer_allow_death( + RawOrigin::Signed(ALICE).into(), + user.clone(), + amount3 * 2 + )); + assert_ok!(MultiAssetDelegation::::deposit( + RuntimeOrigin::signed(user.clone()), + tnt_asset, + amount3, + None, + None, + )); + assert_ok!(MultiAssetDelegation::::delegate( + RuntimeOrigin::signed(user.clone()), + operator1.clone(), // Delegate more to first operator + tnt_asset, + amount3, + Default::default() + )); + + // Should now be tier 2 (for 10 blocks since last claim at block 20) + run_to_block(30); + let max_claimable_3 = get_max_claimable(user.clone()); + assert_eq!(max_claimable_3, 10 * 5, "Combined delegations should qualify for tier 2"); + }); +} + +#[test] +fn tier_switching_during_claim_window_works() { + new_test_ext(vec![]).execute_with(|| { + let user = DAVE; + let operator = EVE; + let _dave_id_str = b"dave_window_tier"; + let tnt_asset_id = 0; + let tnt_asset = tangle_primitives::services::Asset::Custom(tnt_asset_id); + + // Start with tier 1 amount + let initial_amount = 150u128; + setup_delegation(user.clone(), operator.clone(), initial_amount); + + // Accumulate credits at tier 1 for some blocks + run_to_block(25); + + // Don't claim yet, add more delegation to reach tier 2 + let additional_amount = 1000u128; // 150 + 1000 = 1150 (tier 2) + create_and_mint_tokens(3000, user.clone(), additional_amount); + assert_ok!(Balances::transfer_allow_death( + RawOrigin::Signed(ALICE).into(), + user.clone(), + additional_amount * 2 + )); + assert_ok!(MultiAssetDelegation::::deposit( + RuntimeOrigin::signed(user.clone()), + tnt_asset, + additional_amount, + None, + None, + )); + assert_ok!(MultiAssetDelegation::::delegate( + RuntimeOrigin::signed(user.clone()), + operator.clone(), + tnt_asset, + additional_amount, + Default::default() + )); + + // Advance more blocks at tier 2 + run_to_block(50); + + // The key test: credits should reflect the current tier (tier 2) for the entire window + // Since delegation happened at block 25, and current stake determines the rate + let max_claimable = get_max_claimable(user.clone()); + // User should get tier 2 rate (5) for 50 blocks (from block 1 to 50) + let expected = 50 * 5; + assert_eq!( + max_claimable, expected, + "Should use current tier rate for entire claimable window" + ); + }); +} + +#[test] +fn security_cannot_manipulate_tier_calculation() { + new_test_ext(vec![]).execute_with(|| { + let user = DAVE; + let operator = EVE; + let dave_id_str = b"dave_security"; + let tnt_asset_id = 0; + let _tnt_asset = tangle_primitives::services::Asset::Custom(tnt_asset_id); + + // Setup tier 2 delegation + let amount = 1200u128; + setup_delegation(user.clone(), operator.clone(), amount); + + run_to_block(10); + let max_claimable_1 = get_max_claimable(user.clone()); + assert_eq!(max_claimable_1, 10 * 5, "Should be tier 2 rate"); + + // Try to claim credits + assert_ok!(claim_credits(user.clone(), max_claimable_1, dave_id_str)); + + // Verify user cannot immediately claim more credits in the same block + let max_claimable_2 = get_max_claimable(user.clone()); + assert_eq!(max_claimable_2, 0, "Should not be able to claim again in same block"); + + // Advance one block and verify normal accrual + run_to_block(11); + let max_claimable_3 = get_max_claimable(user.clone()); + assert_eq!(max_claimable_3, 1 * 5, "Should only accrue for 1 block"); + + // Try to claim more than accrued - should fail + assert_noop!( + claim_credits(user.clone(), max_claimable_3 + 1, dave_id_str), + Error::::ClaimAmountExceedsWindowAllowance + ); + }); +} + +#[test] +fn tier_rate_overflow_protection_works() { + new_test_ext(vec![]).execute_with(|| { + let user = DAVE; + let operator = EVE; + let dave_id_str = b"dave_overflow"; + + // Setup with tier 3 to get maximum rate + let amount = 15000u128; + setup_delegation(user.clone(), operator.clone(), amount); + + // Advance to near maximum claim window + let window = ::ClaimWindowBlocks::get(); + run_to_block(window); + + let max_claimable = get_max_claimable(user.clone()); + // This should not overflow even with maximum tier rate and maximum window + // The calculation is from block 1 to the window block, so it's window blocks total + let expected = (window as u128) * 15u128; // Tier 3 rate is 15 + assert_eq!(max_claimable, expected, "Should calculate without overflow"); + + // Should be able to claim the full amount + assert_ok!(claim_credits(user.clone(), max_claimable, dave_id_str)); + }); +} + +#[test] +fn concurrent_tier_operations_work_correctly() { + new_test_ext(vec![]).execute_with(|| { + let user1 = DAVE; + let user2 = CHARLIE; + let operator = EVE; + let user1_id = b"user1_concurrent"; + let user2_id = b"user2_concurrent"; + + // Setup different tier delegations for two users - use different assets to avoid conflicts + setup_delegation(user1.clone(), operator.clone(), 150u128); // Tier 1 + + // Setup second user manually with different asset ID to avoid conflict + let amount2 = 1200u128; + let tnt_asset_id = 0; + let tnt_asset = tangle_primitives::services::Asset::Custom(tnt_asset_id); + + Balances::make_free_balance_be(&user2, 100_000); + create_and_mint_tokens(6000, user2.clone(), amount2); + assert_ok!(Balances::transfer_allow_death( + RawOrigin::Signed(ALICE).into(), + user2.clone(), + amount2 * 2 + )); + assert_ok!(MultiAssetDelegation::::deposit( + RuntimeOrigin::signed(user2.clone()), + tnt_asset, + amount2, + None, + None, + )); + assert_ok!(MultiAssetDelegation::::delegate( + RuntimeOrigin::signed(user2.clone()), + operator.clone(), + tnt_asset, + amount2, + Default::default() + )); + + run_to_block(10); + + // Both users should get their respective tier rates + let claimable1 = get_max_claimable(user1.clone()); + let claimable2 = get_max_claimable(user2.clone()); + + assert_eq!(claimable1, 10 * 1, "User 1 should get tier 1 rate"); + assert_eq!(claimable2, 10 * 5, "User 2 should get tier 2 rate"); + + // Both should be able to claim in the same block + assert_ok!(claim_credits(user1.clone(), claimable1, user1_id)); + assert_ok!(claim_credits(user2.clone(), claimable2, user2_id)); + + // Verify both claims were recorded + assert_eq!(last_reward_update(user1), 10); + assert_eq!(last_reward_update(user2), 10); + }); +} + +#[test] +fn tier_precision_at_large_numbers_works() { + new_test_ext(vec![]).execute_with(|| { + // Test tier calculation with very large stake amounts + let huge_amount = 1_000_000_000u128; // 1 billion tokens (well above tier 3) + let rate = CreditsPallet::::get_current_rate(huge_amount); + assert_eq!(rate, 15, "Very large amounts should still use highest tier rate"); + + // Test with amount just above tier 3 + let just_above_tier3 = 10_001u128; + let rate2 = CreditsPallet::::get_current_rate(just_above_tier3); + assert_eq!(rate2, 15, "Amount just above tier 3 should use tier 3 rate"); + }); +} + +#[test] +fn tier_switching_with_locked_multipliers_works() { + new_test_ext(vec![]).execute_with(|| { + let user = DAVE; + let operator = EVE; + let dave_id_str = b"dave_locked"; + let tnt_asset_id = 0; + let tnt_asset = tangle_primitives::services::Asset::Custom(tnt_asset_id); + + let min_bond = + ::MinOperatorBondAmount::get(); + Balances::make_free_balance_be(&ALICE, min_bond * 10); + Balances::make_free_balance_be(&MultiAssetDelegation::::pallet_account(), 10_000); + Balances::make_free_balance_be(&user, 100_000); + + // Setup operator + assert_ok!(Balances::transfer_allow_death( + RawOrigin::Signed(ALICE).into(), + operator.clone(), + min_bond * 2 + )); + assert_ok!(MultiAssetDelegation::::join_operators( + RuntimeOrigin::signed(operator.clone()), + min_bond + )); + + // Delegate with lock multiplier + let base_amount = 150u128; + create_and_mint_tokens(5000, user.clone(), base_amount); + assert_ok!(Balances::transfer_allow_death( + RawOrigin::Signed(ALICE).into(), + user.clone(), + base_amount * 2 + )); + + // Deposit with a lock multiplier (e.g., x2 for 2 months) + let lock_multiplier = Some(LockMultiplier::TwoMonths); + assert_ok!(MultiAssetDelegation::::deposit( + RuntimeOrigin::signed(user.clone()), + tnt_asset, + base_amount, + None, + lock_multiplier, + )); + + // Delegate the deposited amount + assert_ok!(MultiAssetDelegation::::delegate( + RuntimeOrigin::signed(user.clone()), + operator.clone(), + tnt_asset, + base_amount, + Default::default() + )); + + run_to_block(10); + + // The user should be in tier 1 based on total effective stake + // (150 base * 2 multiplier = 300 effective, which is tier 1) + let max_claimable = get_max_claimable(user.clone()); + assert_eq!(max_claimable, 10 * 1, "Should get tier 1 rate with lock multiplier"); + assert_ok!(claim_credits(user.clone(), max_claimable, dave_id_str)); + }); +} diff --git a/pallets/multi-asset-delegation/src/functions/delegate.rs b/pallets/multi-asset-delegation/src/functions/delegate.rs index 34729a71a..be86e0a8d 100644 --- a/pallets/multi-asset-delegation/src/functions/delegate.rs +++ b/pallets/multi-asset-delegation/src/functions/delegate.rs @@ -26,7 +26,11 @@ use sp_runtime::{ }; use sp_staking::StakingInterface; use sp_std::{collections::btree_map::BTreeMap, vec::Vec}; -use tangle_primitives::{RoundIndex, services::Asset, traits::MultiAssetDelegationInfo}; +use tangle_primitives::{ + RoundIndex, + services::Asset, + traits::{MultiAssetDelegationInfo, RewardsManager}, +}; pub const DELEGATION_LOCK_ID: LockIdentifier = *b"delegate"; @@ -124,6 +128,12 @@ impl Pallet { .increase_delegated_amount(amount) .map_err(|_| Error::::InsufficientBalance)?; + // Extract lock_multiplier for credit recording + let lock_multiplier = user_deposit + .locks + .as_ref() + .and_then(|locks| locks.iter().next().map(|lock| lock.lock_multiplier)); + // Find existing delegation or create new one let delegation_exists = metadata .delegations @@ -157,6 +167,10 @@ impl Pallet { // Update operator metadata Self::update_operator_metadata(&operator, &who, asset, amount, true)?; + // Record credits and delegation tracking + let _ = + T::RewardsManager::record_delegate(&who, &operator, asset, amount, lock_multiplier); + // Emit event Self::deposit_event(Event::Delegated { who: who.clone(), operator, amount, asset }); @@ -395,9 +409,10 @@ impl Pallet { metadata.delegations.remove(idx); } - // 4. Update operator metadata + // 4. Update operator metadata and record undelegation for ((operator, asset), amount) in operator_updates { Self::update_operator_metadata(&operator, &who, asset, amount, false)?; + let _ = T::RewardsManager::record_undelegate(&who, &operator, asset, amount); } // 5. Remove processed requests @@ -533,6 +548,15 @@ impl Pallet { true, // is_increase = true for delegation )?; + // Record credits and delegation tracking for nomination delegation + let _ = T::RewardsManager::record_delegate( + &who, + &operator, + Asset::Custom(Zero::zero()), + amount, + None, + ); + // Emit event Self::deposit_event(Event::NominationDelegated { who: who.clone(), @@ -715,7 +739,7 @@ impl Pallet { let delegation = &mut metadata.delegations[delegation_index]; delegation.amount = delegation.amount.saturating_sub(unstake_amount); - // Update operator metadata during execution + // Update operator metadata and record undelegation during execution Self::update_operator_metadata( &operator, who, @@ -723,6 +747,12 @@ impl Pallet { unstake_amount, false, // is_increase = false for unstaking )?; + let _ = T::RewardsManager::record_undelegate( + who, + &operator, + Asset::Custom(Zero::zero()), + unstake_amount, + ); // Remove the unstake request metadata.delegator_unstake_requests.remove(request_index); diff --git a/pallets/multi-asset-delegation/src/functions/deposit.rs b/pallets/multi-asset-delegation/src/functions/deposit.rs index 1e980fae5..4f5771cd7 100644 --- a/pallets/multi-asset-delegation/src/functions/deposit.rs +++ b/pallets/multi-asset-delegation/src/functions/deposit.rs @@ -24,7 +24,6 @@ use sp_core::H160; use sp_runtime::traits::Zero; use tangle_primitives::{ services::{Asset, EvmAddressMapping}, - traits::RewardsManager, types::rewards::LockMultiplier, }; @@ -119,8 +118,6 @@ impl Pallet { metadata.deposits.insert(asset, new_deposit); } - let _ = T::RewardsManager::record_deposit(&who, asset, amount, lock_multiplier); - Ok(()) })?; @@ -164,8 +161,6 @@ impl Pallet { .map_err(|_| Error::::MaxWithdrawRequestsExceeded)?; metadata.withdraw_requests = withdraw_requests; - let _ = T::RewardsManager::record_withdrawal(&who, asset, amount); - Ok(()) }) } diff --git a/pallets/multi-asset-delegation/src/mock.rs b/pallets/multi-asset-delegation/src/mock.rs index 8da06478e..0295ca1d6 100644 --- a/pallets/multi-asset-delegation/src/mock.rs +++ b/pallets/multi-asset-delegation/src/mock.rs @@ -331,12 +331,12 @@ parameter_types! { pub const MaxDelegations: u32 = 50; } -type DepositCall = (AccountId, Asset, Balance, Option); -type WithdrawalCall = (AccountId, Asset, Balance); +type DelegateCall = (AccountId, AccountId, Asset, Balance, Option); +type UndelegateCall = (AccountId, AccountId, Asset, Balance); thread_local! { - static DEPOSIT_CALLS: RefCell> = RefCell::new(Vec::new()); - static WITHDRAWAL_CALLS: RefCell> = RefCell::new(Vec::new()); + static DELEGATE_CALLS: RefCell> = RefCell::new(Vec::new()); + static UNDELEGATE_CALLS: RefCell> = RefCell::new(Vec::new()); } pub struct MockRewardsManager; @@ -344,25 +344,33 @@ pub struct MockRewardsManager; impl RewardsManager for MockRewardsManager { type Error = DispatchError; - fn record_deposit( + fn record_delegate( account_id: &AccountId, + operator: &AccountId, asset: Asset, amount: Balance, lock_multiplier: Option, ) -> Result<(), Self::Error> { - DEPOSIT_CALLS.with(|calls| { - calls.borrow_mut().push((account_id.clone(), asset, amount, lock_multiplier)); + DELEGATE_CALLS.with(|calls| { + calls.borrow_mut().push(( + account_id.clone(), + operator.clone(), + asset, + amount, + lock_multiplier, + )); }); Ok(()) } - fn record_withdrawal( + fn record_undelegate( account_id: &AccountId, + operator: &AccountId, asset: Asset, amount: Balance, ) -> Result<(), Self::Error> { - WITHDRAWAL_CALLS.with(|calls| { - calls.borrow_mut().push((account_id.clone(), asset, amount)); + UNDELEGATE_CALLS.with(|calls| { + calls.borrow_mut().push((account_id.clone(), operator.clone(), asset, amount)); }); Ok(()) } @@ -385,17 +393,17 @@ impl RewardsManager for MockRewardsMan } impl MockRewardsManager { - pub fn record_deposit_calls() -> Vec { - DEPOSIT_CALLS.with(|calls| calls.borrow().clone()) + pub fn record_delegate_calls() -> Vec { + DELEGATE_CALLS.with(|calls| calls.borrow().clone()) } - pub fn record_withdrawal_calls() -> Vec { - WITHDRAWAL_CALLS.with(|calls| calls.borrow().clone()) + pub fn record_undelegate_calls() -> Vec { + UNDELEGATE_CALLS.with(|calls| calls.borrow().clone()) } pub fn clear_all() { - DEPOSIT_CALLS.with(|calls| calls.borrow_mut().clear()); - WITHDRAWAL_CALLS.with(|calls| calls.borrow_mut().clear()); + DELEGATE_CALLS.with(|calls| calls.borrow_mut().clear()); + UNDELEGATE_CALLS.with(|calls| calls.borrow_mut().clear()); } } diff --git a/pallets/multi-asset-delegation/src/tests/delegate.rs b/pallets/multi-asset-delegation/src/tests/delegate.rs index c287e8dbd..e5f4a4fba 100644 --- a/pallets/multi-asset-delegation/src/tests/delegate.rs +++ b/pallets/multi-asset-delegation/src/tests/delegate.rs @@ -69,6 +69,15 @@ fn delegate_should_work() { assert_eq!(operator_delegation.delegator, who.clone()); assert_eq!(operator_delegation.amount, amount); assert_eq!(operator_delegation.asset, asset); + + // Verify that delegation was recorded with credits + assert_eq!(MockRewardsManager::record_delegate_calls(), vec![( + who.clone(), + operator.clone(), + asset, + amount, + None // No lock multiplier for this test + )]); }); } @@ -880,3 +889,50 @@ fn delegate_with_no_deposit() { assert_eq!(metadata.is_none(), true); }); } + +#[test] +fn debug_tnt_delegation_verify_nomination_issue() { + new_test_ext().execute_with(|| { + // This test verifies TNT delegation works correctly without nomination verification + + let who: AccountId = Bob.into(); + let operator: AccountId = Alice.into(); + let amount = 1000; + let delegate_amount = 500; + + // Setup operator + assert_ok!(MultiAssetDelegation::join_operators( + RuntimeOrigin::signed(operator.clone()), + 10_000 + )); + + // Create and mint TNT tokens + create_and_mint_tokens(TNT, who.clone(), amount); + + // Deposit TNT as custom asset (Asset::Custom(0)) + assert_ok!(MultiAssetDelegation::deposit( + RuntimeOrigin::signed(who.clone()), + Asset::Custom(TNT), // TNT = 0 + amount, + None, + None, + )); + + // Delegate TNT using regular delegate (not delegate_nomination) + assert_ok!(MultiAssetDelegation::delegate( + RuntimeOrigin::signed(who.clone()), + operator.clone(), + Asset::Custom(TNT), // TNT = 0 + delegate_amount, + Default::default(), + )); + + // Verify the delegation was created properly + let metadata = MultiAssetDelegation::delegators(who.clone()).unwrap(); + let delegation = &metadata.delegations[0]; + assert_eq!(delegation.asset, Asset::Custom(TNT)); + assert_eq!(delegation.is_nomination, false); + assert_eq!(delegation.operator, operator); + assert_eq!(delegation.amount, delegate_amount); + }); +} diff --git a/pallets/multi-asset-delegation/src/tests/deposit.rs b/pallets/multi-asset-delegation/src/tests/deposit.rs index 6fb5f5795..4de31112c 100644 --- a/pallets/multi-asset-delegation/src/tests/deposit.rs +++ b/pallets/multi-asset-delegation/src/tests/deposit.rs @@ -69,13 +69,9 @@ fn deposit_should_work_for_fungible_asset() { }) ); - // Verify that rewards manager was called with correct parameters - assert_eq!(MockRewardsManager::record_deposit_calls(), vec![( - who.clone(), - Asset::Custom(VDOT), - amount, - None - )]); + // Note: Credits are now given on delegation, not deposit + // Verify that no reward calls were made during deposit + assert_eq!(MockRewardsManager::record_delegate_calls(), vec![]); }); } @@ -242,12 +238,10 @@ fn schedule_withdraw_should_work() { assert_eq!(deposit.amount, 0_u32.into()); assert!(!metadata.withdraw_requests.is_empty()); - // Ensure that rewards pallet was called - assert_eq!(MockRewardsManager::record_withdrawal_calls(), vec![( - who.clone(), - Asset::Custom(VDOT), - amount - )]); + // Note: Withdrawal doesn't affect credits (only undelegation does) + // Verify that no reward calls were made during withdrawal scheduling + assert_eq!(MockRewardsManager::record_delegate_calls(), vec![]); + assert_eq!(MockRewardsManager::record_undelegate_calls(), vec![]); }); } @@ -631,12 +625,8 @@ fn deposit_should_work_for_tnt_without_adding_to_reward_vault() { }) ); - // Verify that rewards manager was called with correct parameters - assert_eq!(MockRewardsManager::record_deposit_calls(), vec![( - who.clone(), - Asset::Custom(0), - amount, - None - )]); + // Note: Credits are now given on delegation, not deposit + // Verify that no reward calls were made during deposit + assert_eq!(MockRewardsManager::record_delegate_calls(), vec![]); }); } diff --git a/pallets/multi-asset-delegation/src/tests/native_restaking.rs b/pallets/multi-asset-delegation/src/tests/native_restaking.rs index 289aef8b6..358cda892 100644 --- a/pallets/multi-asset-delegation/src/tests/native_restaking.rs +++ b/pallets/multi-asset-delegation/src/tests/native_restaking.rs @@ -84,6 +84,15 @@ fn native_restaking_should_work() { assert_eq!(locks[0].amount, amount); assert_eq!(&locks[1].id, b"delegate"); assert_eq!(locks[1].amount, delegate_amount); + + // Verify that nomination delegation was recorded with credits + assert_eq!(MockRewardsManager::record_delegate_calls(), vec![( + who.clone(), + operator.clone(), + Asset::Custom(TNT), // TNT is represented as Asset::Custom(0) + delegate_amount, + None // No lock multiplier for nomination delegations + )]); }); } diff --git a/pallets/rewards/src/impls.rs b/pallets/rewards/src/impls.rs index 8fbb637d6..b8f7e7cf5 100644 --- a/pallets/rewards/src/impls.rs +++ b/pallets/rewards/src/impls.rs @@ -29,8 +29,9 @@ impl RewardsManager, BlockNumb { type Error = DispatchError; - fn record_deposit( + fn record_delegate( account_id: &T::AccountId, + _operator: &T::AccountId, asset: Asset, amount: BalanceOf, lock_multiplier: Option, @@ -82,8 +83,9 @@ impl RewardsManager, BlockNumb Ok(()) } - fn record_withdrawal( + fn record_undelegate( _account_id: &T::AccountId, + _operator: &T::AccountId, asset: Asset, amount: BalanceOf, ) -> Result<(), Self::Error> { diff --git a/pallets/services/src/mock.rs b/pallets/services/src/mock.rs index 86861d792..904cce257 100644 --- a/pallets/services/src/mock.rs +++ b/pallets/services/src/mock.rs @@ -449,8 +449,8 @@ impl pallet_services::Config for Runtime { type Block = frame_system::mocking::MockBlock; thread_local! { - static DEPOSIT_CALLS: RefCell, Balance, Option)>> = RefCell::new(Vec::new()); - static WITHDRAWAL_CALLS: RefCell, Balance)>> = RefCell::new(Vec::new()); + static DELEGATE_CALLS: RefCell, Balance, Option)>> = RefCell::new(Vec::new()); + static UNDELEGATE_CALLS: RefCell, Balance)>> = RefCell::new(Vec::new()); } pub struct MockRewardsManager; @@ -458,25 +458,33 @@ pub struct MockRewardsManager; impl RewardsManager for MockRewardsManager { type Error = DispatchError; - fn record_deposit( + fn record_delegate( account_id: &AccountId, + operator: &AccountId, asset: Asset, amount: Balance, lock_multiplier: Option, ) -> Result<(), Self::Error> { - DEPOSIT_CALLS.with(|calls| { - calls.borrow_mut().push((account_id.clone(), asset, amount, lock_multiplier)); + DELEGATE_CALLS.with(|calls| { + calls.borrow_mut().push(( + account_id.clone(), + operator.clone(), + asset, + amount, + lock_multiplier, + )); }); Ok(()) } - fn record_withdrawal( + fn record_undelegate( account_id: &AccountId, + operator: &AccountId, asset: Asset, amount: Balance, ) -> Result<(), Self::Error> { - WITHDRAWAL_CALLS.with(|calls| { - calls.borrow_mut().push((account_id.clone(), asset, amount)); + UNDELEGATE_CALLS.with(|calls| { + calls.borrow_mut().push((account_id.clone(), operator.clone(), asset, amount)); }); Ok(()) } @@ -499,18 +507,18 @@ impl RewardsManager for MockRewardsMan } impl MockRewardsManager { - pub fn record_deposit_calls() - -> Vec<(AccountId, Asset, Balance, Option)> { - DEPOSIT_CALLS.with(|calls| calls.borrow().clone()) + pub fn record_delegate_calls() + -> Vec<(AccountId, AccountId, Asset, Balance, Option)> { + DELEGATE_CALLS.with(|calls| calls.borrow().clone()) } - pub fn record_withdrawal_calls() -> Vec<(AccountId, Asset, Balance)> { - WITHDRAWAL_CALLS.with(|calls| calls.borrow().clone()) + pub fn record_undelegate_calls() -> Vec<(AccountId, AccountId, Asset, Balance)> { + UNDELEGATE_CALLS.with(|calls| calls.borrow().clone()) } pub fn clear_all() { - DEPOSIT_CALLS.with(|calls| calls.borrow_mut().clear()); - WITHDRAWAL_CALLS.with(|calls| calls.borrow_mut().clear()); + DELEGATE_CALLS.with(|calls| calls.borrow_mut().clear()); + UNDELEGATE_CALLS.with(|calls| calls.borrow_mut().clear()); } } diff --git a/precompiles/multi-asset-delegation/src/mock.rs b/precompiles/multi-asset-delegation/src/mock.rs index af6553fa5..3b09401d5 100644 --- a/precompiles/multi-asset-delegation/src/mock.rs +++ b/precompiles/multi-asset-delegation/src/mock.rs @@ -347,8 +347,9 @@ pub struct MockRewardsManager; impl RewardsManager for MockRewardsManager { type Error = DispatchError; - fn record_deposit( + fn record_delegate( _account_id: &AccountId, + _operator: &AccountId, _asset: Asset, _amount: Balance, _lock_multiplier: Option, @@ -356,8 +357,9 @@ impl RewardsManager for MockRewardsMan Ok(()) } - fn record_withdrawal( + fn record_undelegate( _account_id: &AccountId, + _operator: &AccountId, _asset: Asset, _amount: Balance, ) -> Result<(), Self::Error> { diff --git a/precompiles/services/src/mock.rs b/precompiles/services/src/mock.rs index 84a356db9..e23e9119c 100644 --- a/precompiles/services/src/mock.rs +++ b/precompiles/services/src/mock.rs @@ -594,8 +594,9 @@ pub struct MockRewardsManager; impl RewardsManager for MockRewardsManager { type Error = sp_runtime::DispatchError; - fn record_deposit( + fn record_delegate( _account_id: &AccountId, + _operator: &AccountId, _asset: Asset, _amount: Balance, _lock_multiplier: Option, @@ -603,8 +604,9 @@ impl RewardsManager for MockRewardsManager { Ok(()) } - fn record_withdrawal( + fn record_undelegate( _account_id: &AccountId, + _operator: &AccountId, _asset: Asset, _amount: Balance, ) -> Result<(), Self::Error> { diff --git a/primitives/src/traits/rewards.rs b/primitives/src/traits/rewards.rs index 803b4f78f..c8d7c4574 100644 --- a/primitives/src/traits/rewards.rs +++ b/primitives/src/traits/rewards.rs @@ -23,28 +23,32 @@ use sp_runtime::{DispatchResult, traits::Zero}; pub trait RewardsManager { type Error; - /// Records a deposit for an account with an optional lock multiplier. + /// Records a delegation for an account with an optional lock multiplier. /// /// # Parameters - /// * `account_id` - The account making the deposit - /// * `asset` - The asset being deposited - /// * `amount` - The amount being deposited - /// * `lock_multiplier` - Optional multiplier for locked deposits - fn record_deposit( + /// * `account_id` - The account making the delegation + /// * `operator` - The operator being delegated to + /// * `asset` - The asset being delegated + /// * `amount` - The amount being delegated + /// * `lock_multiplier` - Optional multiplier for locked delegations + fn record_delegate( account_id: &AccountId, + operator: &AccountId, asset: Asset, amount: Balance, lock_multiplier: Option, ) -> Result<(), Self::Error>; - /// Records a withdrawal for an account. + /// Records an undelegation for an account. /// /// # Parameters - /// * `account_id` - The account making the withdrawal - /// * `asset` - The asset being withdrawn - /// * `amount` - The amount being withdrawn - fn record_withdrawal( + /// * `account_id` - The account making the undelegation + /// * `operator` - The operator being undelegated from + /// * `asset` - The asset being undelegated + /// * `amount` - The amount being undelegated + fn record_undelegate( account_id: &AccountId, + operator: &AccountId, asset: Asset, amount: Balance, ) -> Result<(), Self::Error>; @@ -91,8 +95,9 @@ where { type Error = &'static str; - fn record_deposit( + fn record_delegate( _account_id: &AccountId, + _operator: &AccountId, _asset: Asset, _amount: Balance, _lock_multiplier: Option, @@ -100,8 +105,9 @@ where Ok(()) } - fn record_withdrawal( + fn record_undelegate( _account_id: &AccountId, + _operator: &AccountId, _asset: Asset, _amount: Balance, ) -> Result<(), Self::Error> {