From 6e6c56b3229736d3ca7597ff99ef0a4b3d1f8840 Mon Sep 17 00:00:00 2001 From: Igor Papandinas <26460174+ipapandinas@users.noreply.github.com> Date: Fri, 10 Jan 2025 17:28:06 +0100 Subject: [PATCH] feat: move_stake extrinsic implementation --- pallets/dapp-staking/src/benchmarking/mod.rs | 54 ++ pallets/dapp-staking/src/lib.rs | 194 +++++- .../dapp-staking/src/test/testing_utils.rs | 509 ++++++++++++++- pallets/dapp-staking/src/test/tests.rs | 590 +++++++++++++++++- pallets/dapp-staking/src/test/tests_types.rs | 82 +++ pallets/dapp-staking/src/types.rs | 66 +- pallets/dapp-staking/src/weights.rs | 43 ++ primitives/src/dapp_staking.rs | 11 + .../astar/src/weights/pallet_dapp_staking.rs | 17 + .../src/weights/pallet_dapp_staking.rs | 17 + .../shiden/src/weights/pallet_dapp_staking.rs | 17 + 11 files changed, 1565 insertions(+), 35 deletions(-) diff --git a/pallets/dapp-staking/src/benchmarking/mod.rs b/pallets/dapp-staking/src/benchmarking/mod.rs index c8ec3ddef9..be904e1a6a 100644 --- a/pallets/dapp-staking/src/benchmarking/mod.rs +++ b/pallets/dapp-staking/src/benchmarking/mod.rs @@ -839,6 +839,60 @@ mod benchmarks { assert_last_event::(Event::::Force { forcing_type }.into()); } + #[benchmark] + fn move_stake() { + initial_config::(); + + let staker: T::AccountId = whitelisted_caller(); + let owner: T::AccountId = account("dapp_owner", 0, SEED); + let source_contract = T::BenchmarkHelper::get_smart_contract(1); + let destination_contract = T::BenchmarkHelper::get_smart_contract(2); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + source_contract.clone(), + )); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + destination_contract.clone(), + )); + + // To preserve source staking and create destination staking + let amount = T::MinimumLockedAmount::get() + T::MinimumLockedAmount::get(); + T::BenchmarkHelper::set_balance(&staker, amount); + assert_ok!(DappStaking::::lock( + RawOrigin::Signed(staker.clone()).into(), + amount, + )); + + assert_ok!(DappStaking::::stake( + RawOrigin::Signed(staker.clone()).into(), + source_contract.clone(), + amount + )); + + let amount_to_move = T::MinimumLockedAmount::get(); + + #[extrinsic_call] + _( + RawOrigin::Signed(staker.clone()), + source_contract.clone(), + destination_contract.clone(), + Some(amount_to_move.clone()), + ); + + assert_last_event::( + Event::::StakeMoved { + account: staker, + source_contract, + destination_contract, + amount: amount_to_move, + } + .into(), + ); + } + #[benchmark] fn on_initialize_voting_to_build_and_earn() { initial_config::(); diff --git a/pallets/dapp-staking/src/lib.rs b/pallets/dapp-staking/src/lib.rs index 932f79291c..7e2c6d9328 100644 --- a/pallets/dapp-staking/src/lib.rs +++ b/pallets/dapp-staking/src/lib.rs @@ -215,7 +215,7 @@ pub mod pallet { /// retaining eligibility for bonus rewards. Exceeding this limit will result in the /// forfeiture of the bonus rewards for the affected stake. #[pallet::constant] - type MaxBonusMovesPerPeriod: Get + Default + Debug; + type MaxBonusMovesPerPeriod: Get + Default + Debug + Clone; /// Weight info for various calls & operations in the pallet. type WeightInfo: WeightInfo; @@ -322,6 +322,13 @@ pub mod pallet { ExpiredEntriesRemoved { account: T::AccountId, count: u16 }, /// Privileged origin has forced a new era and possibly a subperiod to start from next block. Force { forcing_type: ForcingType }, + /// Account has moved some stake from a source smart contract to a destination smart contract. + StakeMoved { + account: T::AccountId, + source_contract: T::SmartContract, + destination_contract: T::SmartContract, + amount: Balance, + }, } #[pallet::error] @@ -398,6 +405,10 @@ pub mod pallet { NoExpiredEntries, /// Force call is not allowed in production. ForceNotAllowed, + /// Same contract specified as source and destination. + SameContracts, + /// Performing stake move from a registered contract without specifying amount. + InvalidAmount, } /// General information about dApp staking protocol state. @@ -1522,6 +1533,157 @@ pub mod pallet { Self::internal_claim_bonus_reward_for(account, smart_contract) } + + /// Transfers stake between two smart contracts, ensuring period alignment, bonus status preservation if elegible, + /// and adherence to staking limits. Updates all relevant storage and emits a `StakeMoved` event. + #[pallet::call_index(21)] + #[pallet::weight(T::WeightInfo::move_stake())] + pub fn move_stake( + origin: OriginFor, + source_contract: T::SmartContract, + destination_contract: T::SmartContract, + maybe_amount: Option, + ) -> DispatchResult { + Self::ensure_pallet_enabled()?; + let account = ensure_signed(origin)?; + + ensure!( + !source_contract.eq(&destination_contract), + Error::::SameContracts + ); + + let dest_dapp_info = IntegratedDApps::::get(&destination_contract) + .ok_or(Error::::ContractNotFound)?; + + let protocol_state = ActiveProtocolState::::get(); + let current_era = protocol_state.era; + + let mut ledger = Ledger::::get(&account); + + // In case old stake rewards are unclaimed & have expired, clean them up. + let threshold_period = Self::oldest_claimable_period(protocol_state.period_number()); + let _ignore = ledger.maybe_cleanup_expired(threshold_period); + + let mut source_staking_info = StakerInfo::::get(&account, &source_contract) + .ok_or(Error::::NoStakingInfo)?; + + ensure!( + source_staking_info.period_number() == protocol_state.period_number(), + Error::::UnstakeFromPastPeriod + ); + + let maybe_source_dapp_info = IntegratedDApps::::get(&source_contract); + let is_source_unregistered = maybe_source_dapp_info.is_none(); + let bonus_status_snapshot = source_staking_info.bonus_status.clone(); + + let amount_to_move = Self::get_amount_to_move( + &source_staking_info, + maybe_amount, + is_source_unregistered, + )?; + + // 1. + // Prepare Destination Contract Staking Info + let (mut dest_staking_info, is_new_entry) = + match StakerInfo::::get(&account, &destination_contract) { + // Entry with matching period exists + Some(staking_info) + if staking_info.period_number() == protocol_state.period_number() => + { + (staking_info, false) + } + // Entry exists but period doesn't match. Bonus reward might still be claimable. + Some(staking_info) + if staking_info.period_number() >= threshold_period + && staking_info.has_bonus() => + { + return Err(Error::::UnclaimedRewards.into()); + } + // No valid entry exists + _ => ( + SingularStakingInfo::new( + protocol_state.period_number(), + protocol_state.subperiod(), + ), + true, + ), + }; + + // 2. + // Perform 'Move' + let (era_and_amount_pairs, _) = source_staking_info.move_stake( + &mut dest_staking_info, + amount_to_move, + current_era, + protocol_state.subperiod(), + ); + + ensure!( + dest_staking_info.total_staked_amount() >= T::MinimumStakeAmount::get(), + Error::::InsufficientStakeAmount + ); + + if is_new_entry && !is_source_unregistered { + ledger.contract_stake_count.saturating_inc(); + ensure!( + ledger.contract_stake_count <= T::MaxNumberOfStakedContracts::get(), + Error::::TooManyStakedContracts + ); + } + + // 3. + // Handle Bonus Status + // For an unregistered contract the bonus status is preserved. + // For a registered contract, the source unstake has already handled the bonus status logic. + dest_staking_info.bonus_status = if is_source_unregistered { + bonus_status_snapshot + } else { + source_staking_info.bonus_status.clone() + }; + + // 4. + // Update Afected Contract Stakes + if let Some(source_dapp_info) = maybe_source_dapp_info { + // Registered source: perform unstake operations. + let mut source_contract_stake_info = ContractStake::::get(source_dapp_info.id); + source_contract_stake_info.unstake( + era_and_amount_pairs, + protocol_state.period_info, + current_era, + ); + + ContractStake::::insert(&source_dapp_info.id, source_contract_stake_info); + } + + let mut dest_contract_stake_info = ContractStake::::get(&dest_dapp_info.id); + dest_contract_stake_info.stake(amount_to_move, protocol_state.period_info, current_era); + + // 5. + // Update remaining storage entries + if !is_source_unregistered && source_staking_info.is_empty() { + ledger.contract_stake_count.saturating_dec(); + StakerInfo::::remove(&account, &source_contract); + } else if !is_source_unregistered { + StakerInfo::::insert(&account, &source_contract, source_staking_info); + } else { + // Unregistered source: remove staker info directly + StakerInfo::::remove(&account, &source_contract); + } + + StakerInfo::::insert(&account, &destination_contract, dest_staking_info); + + Self::update_ledger(&account, ledger)?; + ContractStake::::insert(&dest_dapp_info.id, dest_contract_stake_info); + + Self::deposit_event(Event::::StakeMoved { + account, + source_contract, + destination_contract, + amount: amount_to_move, + }); + + Ok(()) + } } impl Pallet { @@ -2222,6 +2384,36 @@ pub mod pallet { Self::deposit_event(Event::::MaintenanceMode { enabled }); } + // Helper to get the correct amount to move based on source contract status + pub(crate) fn get_amount_to_move( + source_staking_info: &SingularStakingInfoFor, + maybe_amount: Option, + is_source_unregistered: bool, + ) -> Result { + if is_source_unregistered { + // Unregistered contracts: Move all funds. + Ok(source_staking_info.total_staked_amount()) + } else { + let amount = maybe_amount.ok_or(Error::::InvalidAmount)?; + ensure!(amount > 0, Error::::ZeroAmount); + ensure!( + source_staking_info.total_staked_amount() >= amount, + Error::::UnstakeAmountTooLarge + ); + + // If the remaining stake falls below the minimum, unstake everything. + if source_staking_info + .total_staked_amount() + .saturating_sub(amount) + < T::MinimumStakeAmount::get() + { + Ok(source_staking_info.total_staked_amount()) + } else { + Ok(amount) + } + } + } + /// Ensure the correctness of the state of this pallet. #[cfg(any(feature = "try-runtime", test))] pub fn do_try_state() -> Result<(), sp_runtime::TryRuntimeError> { diff --git a/pallets/dapp-staking/src/test/testing_utils.rs b/pallets/dapp-staking/src/test/testing_utils.rs index c4c7168f97..effa8e3970 100644 --- a/pallets/dapp-staking/src/test/testing_utils.rs +++ b/pallets/dapp-staking/src/test/testing_utils.rs @@ -21,7 +21,7 @@ use crate::types::*; use crate::{ pallet::Config, ActiveProtocolState, BonusStatusFor, ContractStake, CurrentEraInfo, DAppId, DAppTiers, EraRewards, Event, FreezeReason, HistoryCleanupMarker, IntegratedDApps, Ledger, - NextDAppId, PeriodEnd, PeriodEndInfo, StakerInfo, + NextDAppId, PeriodEnd, PeriodEndInfo, StakerInfo, Subperiod, }; use frame_support::{ @@ -33,13 +33,13 @@ use sp_runtime::{traits::Zero, Perbill}; use std::collections::HashMap; use astar_primitives::{ - dapp_staking::{CycleConfiguration, EraNumber, PeriodNumber}, + dapp_staking::{CycleConfiguration, EraNumber, PeriodNumber, StakeAmountMoved}, Balance, BlockNumber, }; /// Helper struct used to store the entire pallet state snapshot. /// Used when comparison of before/after states is required. -#[derive(Debug)] +#[derive(Debug, Clone)] pub(crate) struct MemorySnapshot { active_protocol_state: ProtocolState, next_dapp_id: DAppId, @@ -535,41 +535,38 @@ pub(crate) fn assert_stake( // 2. verify staker info // ===================== // ===================== + + let stake_amount = match stake_subperiod { + Subperiod::Voting => StakeAmountMoved { + voting: amount, + build_and_earn: 0, + }, + Subperiod::BuildAndEarn => StakeAmountMoved { + voting: 0, + build_and_earn: amount, + }, + }; + + verify_staker_info_stake( + pre_snapshot.clone(), + post_snapshot.clone(), + account, + smart_contract, + stake_amount, + ); + match pre_staker_info { // We're just updating an existing entry Some(pre_staker_info) if pre_staker_info.period_number() == stake_period => { - assert_eq!( - post_staker_info.total_staked_amount(), - pre_staker_info.total_staked_amount() + amount, - "Total staked amount must increase by the 'amount'" - ); - assert_eq!( - post_staker_info.staked_amount(stake_subperiod), - pre_staker_info.staked_amount(stake_subperiod) + amount, - "Staked amount must increase by the 'amount'" - ); - assert_eq!(post_staker_info.period_number(), stake_period); assert_eq!( post_staker_info.has_bonus(), pre_staker_info.has_bonus(), - "Staking operation mustn't change bonus reward + "Staking operation mustn't change bonus reward eligibility." ); } // A new entry is created. _ => { - assert_eq!( - post_staker_info.total_staked_amount(), - amount, - "Total staked amount must be equal to exactly the 'amount'" - ); - assert!(amount >= ::MinimumStakeAmount::get()); - assert_eq!( - post_staker_info.staked_amount(stake_subperiod), - amount, - "Staked amount must be equal to exactly the 'amount'" - ); - assert_eq!(post_staker_info.period_number(), stake_period); assert_eq!( post_staker_info.has_bonus(), stake_subperiod == Subperiod::Voting @@ -869,6 +866,24 @@ pub(crate) fn assert_bonus_status( ); } +/// Assert the singular staking info of a staker for a specific smart contract. +pub(crate) fn assert_staker_info( + account: AccountId, + smart_contract: &MockSmartContract, + expected_staker_info: SingularStakingInfoFor, +) { + let snapshot = MemorySnapshot::new(); + let staker_info = snapshot + .staker_info + .get(&(account, *smart_contract)) + .expect("Staker info entry must exist to verify bonus status."); + + assert!( + staker_info.equals(&expected_staker_info), + "Staker infos do not match." + ); +} + /// Claim staker rewards. pub(crate) fn assert_claim_staker_rewards(account: AccountId) { let pre_snapshot = MemorySnapshot::new(); @@ -1625,3 +1640,443 @@ pub(crate) fn is_account_ledger_expired( _ => false, } } + +/// Move stake funds from source contract to destination contract. +pub(crate) fn assert_move_stake( + account: AccountId, + source_contract: &MockSmartContract, + destination_contract: &MockSmartContract, + maybe_amount: Option, +) { + let pre_snapshot = MemorySnapshot::new(); + let is_source_unregistered = IntegratedDApps::::get(&source_contract).is_none(); + + let pre_era_info = pre_snapshot.current_era_info; + let pre_ledger = pre_snapshot.ledger.get(&account).unwrap(); + let pre_staker_info = pre_snapshot + .staker_info + .get(&(account, source_contract.clone())) + .expect("Entry must exist since 'move' is being called on a registered contract."); + let maybe_pre_source_contract_stake = if is_source_unregistered { + None + } else { + Some( + pre_snapshot + .contract_stake + .get(&pre_snapshot.integrated_dapps[&source_contract].id) + .expect("Entry must exist since 'move' is being called."), + ) + }; + let maybe_pre_destination_contract_stake = pre_snapshot + .contract_stake + .get(&pre_snapshot.integrated_dapps[&destination_contract].id); + let maybe_pre_destination_staker_info = pre_snapshot + .staker_info + .get(&(account, *destination_contract)); + + let move_era = pre_snapshot.active_protocol_state.era; + let move_period = pre_snapshot.active_protocol_state.period_number(); + let move_subperiod = pre_snapshot.active_protocol_state.subperiod(); + + let amount_to_move: Balance = + DappStaking::get_amount_to_move(pre_staker_info, maybe_amount, is_source_unregistered) + .expect("Invalid amount to move"); + let is_full_move_from_source = pre_staker_info.total_staked_amount() == amount_to_move; + + // Move from source contract to destination contract & verify event + assert_ok!(DappStaking::move_stake( + RuntimeOrigin::signed(account), + source_contract.clone(), + destination_contract.clone(), + maybe_amount + )); + System::assert_last_event(RuntimeEvent::DappStaking(Event::StakeMoved { + account, + source_contract: source_contract.clone(), + destination_contract: destination_contract.clone(), + amount: amount_to_move, + })); + + // Verify post-state + let post_snapshot = MemorySnapshot::new(); + let post_era_info = post_snapshot.current_era_info; + let post_ledger = post_snapshot.ledger.get(&account).unwrap(); + let maybe_post_source_contract_stake = if is_source_unregistered { + None + } else { + Some( + post_snapshot + .contract_stake + .get(&post_snapshot.integrated_dapps[&source_contract].id) + .expect("Entry must exist since 'move' is being called on a registered contract."), + ) + }; + let post_destination_contract_stake = post_snapshot + .contract_stake + .get(&post_snapshot.integrated_dapps[&destination_contract].id) + .expect("Entry must exist since 'move' is being called."); + + // 1. verify staker info + // ===================== + // ===================== + + let mut source_staker_info = pre_staker_info.clone(); + let new_staker_info_entry = SingularStakingInfoFor::::new(move_period, move_subperiod); + let dest_staker_info = match maybe_pre_destination_staker_info { + Some(staking_info) => staking_info, + _ => &new_staker_info_entry, + }; + + let (_, stake_amount) = source_staker_info.move_stake( + &mut dest_staker_info.clone(), + amount_to_move, + move_era, + move_subperiod, + ); + + verify_staker_info_unstake( + pre_snapshot.clone(), + post_snapshot.clone(), + account, + source_contract, + amount_to_move, + ); + verify_staker_info_stake( + pre_snapshot.clone(), + post_snapshot.clone(), + account, + destination_contract, + stake_amount, + ); + + // 2. verify contract stake (for registered source contract) + // ========================= + // ========================= + + if let (Some(pre_source_contract_stake), Some(post_source_contract_stake)) = ( + maybe_pre_source_contract_stake, + maybe_post_source_contract_stake, + ) { + assert_eq!( + post_source_contract_stake.total_staked_amount(move_period), + pre_source_contract_stake.total_staked_amount(move_period) - amount_to_move, + "Staked amount must decreased by the 'amount_to_move'" + ); + assert_eq!( + post_source_contract_stake.staked_amount(move_period, move_subperiod), + pre_source_contract_stake + .staked_amount(move_period, move_subperiod) + .saturating_sub(amount_to_move), + "Staked amount must decreased by the 'amount_to_move'" + ); + + // A generic check, comparing what was received in the (era, amount) pairs and the impact it had on the contract stake. + let unstaked_amount_era_pairs = + pre_staker_info + .clone() + .unstake(amount_to_move, move_era, move_subperiod); + for (move_era_iter, move_amount_iter) in unstaked_amount_era_pairs { + assert_eq!( + post_source_contract_stake + .get(move_era_iter, move_period) + .unwrap_or_default() // it's possible that full move cleared the entry + .total(), + pre_source_contract_stake + .get(move_era_iter, move_period) + .expect("Must exist") + .total() + - move_amount_iter + ); + } + + // More precise check, independent of the generic check above. + // If next era entry exists, it must be reduced by the unstake amount, nothing less. + if let Some(entry) = pre_source_contract_stake.get(move_era + 1, move_period) { + assert_eq!( + post_source_contract_stake + .get(move_era + 1, move_period) + .unwrap_or_default() + .total(), + entry.total() - amount_to_move + ); + } + } + + if let Some(pre_destination_contract_stake) = maybe_pre_destination_contract_stake { + assert_eq!( + post_destination_contract_stake.total_staked_amount(move_period), + pre_destination_contract_stake.total_staked_amount(move_period) + amount_to_move, + "Staked amount must increase by the 'amount_to_move'" + ); + assert_eq!( + post_destination_contract_stake.staked_amount(move_period, move_subperiod), + pre_destination_contract_stake.staked_amount(move_period, move_subperiod) + + amount_to_move, + "Staked amount must increase by the 'amount_to_move'" + ); + } else { + assert_eq!( + post_destination_contract_stake.total_staked_amount(move_period), + amount_to_move, + "Staked amount must increase by the 'amount_to_move'" + ); + assert_eq!( + post_destination_contract_stake.staked_amount(move_period, move_subperiod), + amount_to_move, + "Staked amount must increase by the 'amount_to_move'" + ); + } + + assert_eq!( + post_destination_contract_stake.latest_stake_period(), + Some(move_period) + ); + assert_eq!( + post_destination_contract_stake.latest_stake_era(), + Some(move_era + 1) + ); + + // 3. verify ledger (unchanged) + // ===================== + // ===================== + assert_eq!( + post_ledger.staked_amount(move_period), + pre_ledger.staked_amount(move_period), + "Stake amount must remain unchanged for a 'move'" + ); + assert_eq!( + post_ledger.stakeable_amount(move_period), + pre_ledger.stakeable_amount(move_period), + "Stakeable amount must remain unchanged for a 'move'" + ); + + let is_new_dest = maybe_pre_destination_staker_info.is_none(); + if is_new_dest { + if is_source_unregistered || is_full_move_from_source { + assert_eq!( + pre_ledger.contract_stake_count, + post_ledger.contract_stake_count, + "Number of contract stakes must remain the same for a 'move' with an unregistered source." + ); + } else { + assert_eq!( + pre_ledger.contract_stake_count.saturating_add(1), + post_ledger.contract_stake_count, + "Number of contract stakes must be incremented for a new destination after 'move'." + ); + } + } else { + if is_full_move_from_source { + if is_source_unregistered { + assert_eq!( + pre_ledger.contract_stake_count, + post_ledger.contract_stake_count, + "Number of contract stakes must remain the same for a full 'move' from an unregistered source." + ); + } else { + assert_eq!( + pre_ledger.contract_stake_count.saturating_sub(1), + post_ledger.contract_stake_count, + "Number of contract stakes must be decreased for a full 'move' from a registered source." + ); + } + } else { + assert_eq!( + pre_ledger.contract_stake_count, post_ledger.contract_stake_count, + "Number of contract stakes must remain the same for a partial 'move'." + ); + } + } + + // 4. verify era info (unchanged) + // ========================= + // ========================= + + assert_eq!( + post_era_info.total_staked_amount(), + pre_era_info.total_staked_amount(), + "Total staked amount for the current era must remain unchanged for a 'move'." + ); + assert_eq!( + post_era_info.total_staked_amount_next_era(), + pre_era_info.total_staked_amount_next_era(), + "Total staked amount for the next era must remain unchanged for a 'move'." + ); +} + +pub(crate) fn verify_staker_info_unstake( + pre_snapshot: MemorySnapshot, + post_snapshot: MemorySnapshot, + account: AccountId, + smart_contract: &MockSmartContract, + amount: Balance, +) { + let pre_staker_info = pre_snapshot + .staker_info + .get(&(account, smart_contract.clone())) + .expect("Entry must exist since 'unstake' is being called."); + + let unstake_era = pre_snapshot.active_protocol_state.era; + let unstake_period = pre_snapshot.active_protocol_state.period_number(); + let unstake_subperiod = pre_snapshot.active_protocol_state.subperiod(); + + let minimum_stake_amount: Balance = ::MinimumStakeAmount::get(); + let is_full_unstake = + pre_staker_info.total_staked_amount().saturating_sub(amount) < minimum_stake_amount; + + // Unstake all if we expect to go below the minimum stake amount + let expected_amount = if is_full_unstake { + pre_staker_info.total_staked_amount() + } else { + amount + }; + + // verify staker info + // ===================== + // ===================== + + // Verify that expected unstake amounts are applied. + if is_full_unstake { + assert!( + !StakerInfo::::contains_key(&account, smart_contract), + "Entry must be deleted since it was a full unstake." + ); + } else { + let post_staker_info = post_snapshot + .staker_info + .get(&(account, *smart_contract)) + .expect( + "Entry must exist since 'stake' operation was successful and it wasn't a full unstake.", + ); + assert_eq!(post_staker_info.period_number(), unstake_period); + assert_eq!( + post_staker_info.total_staked_amount(), + pre_staker_info.total_staked_amount() - expected_amount, + "Total staked amount must decrease by the 'amount'" + ); + assert_eq!( + post_staker_info.staked_amount(unstake_subperiod), + pre_staker_info + .staked_amount(unstake_subperiod) + .saturating_sub(expected_amount), + "Staked amount must decrease by the 'amount'" + ); + + let should_keep_bonus = if pre_staker_info.has_bonus() { + match pre_staker_info.bonus_status { + BonusStatus::SafeMovesRemaining(remaining_moves) if remaining_moves > 0 => true, + _ => match unstake_subperiod { + Subperiod::Voting => { + !post_staker_info.staked_amount(Subperiod::Voting).is_zero() + } + Subperiod::BuildAndEarn => { + post_staker_info.staked_amount(Subperiod::Voting) + == pre_staker_info.staked_amount(Subperiod::Voting) + } + }, + } + } else { + false + }; + + assert_eq!( + post_staker_info.has_bonus(), + should_keep_bonus, + "If 'voting stake' amount is fully unstaked in Voting subperiod or reduced in B&E subperiod, 'BonusStatus' must reflect this." + ); + + if unstake_subperiod == Subperiod::BuildAndEarn + && pre_staker_info.has_bonus() + && post_staker_info.staked_amount(Subperiod::Voting) + < pre_staker_info.staked_amount(Subperiod::Voting) + { + let mut bonus_status_clone = pre_staker_info.bonus_status.clone(); + bonus_status_clone.decrease_moves(); + + assert_eq!( + post_staker_info.bonus_status, bonus_status_clone, + "'BonusStatus' must correctly decrease moves when 'voting stake' is reduced in B&E subperiod." + ); + } + } + + let unstaked_amount_era_pairs = + pre_staker_info + .clone() + .unstake(expected_amount, unstake_era, unstake_subperiod); + assert!(unstaked_amount_era_pairs.len() <= 2 && unstaked_amount_era_pairs.len() > 0); + + // If unstake from next era exists, it must exactly match the expected unstake amount. + unstaked_amount_era_pairs + .iter() + .filter(|(era, _)| *era > unstake_era) + .for_each(|(_, amount)| { + assert_eq!(*amount, expected_amount); + }); +} + +pub(crate) fn verify_staker_info_stake( + pre_snapshot: MemorySnapshot, + post_snapshot: MemorySnapshot, + account: AccountId, + smart_contract: &MockSmartContract, + stake_amount: StakeAmountMoved, +) { + let pre_staker_info = pre_snapshot + .staker_info + .get(&(account, smart_contract.clone())); + + let stake_period = pre_snapshot.active_protocol_state.period_number(); + + // Verify post-state + let post_staker_info = post_snapshot + .staker_info + .get(&(account, *smart_contract)) + .expect("Entry must exist since 'stake' operation was successful."); + + // Verify staker info + // ===================== + // ===================== + match pre_staker_info { + // We're just updating an existing entry + Some(pre_staker_info) if pre_staker_info.period_number() == stake_period => { + assert_eq!( + post_staker_info.total_staked_amount(), + pre_staker_info.total_staked_amount() + stake_amount.total(), + "Total staked amount must increase by the total 'stake_amount'" + ); + assert_eq!( + post_staker_info.staked_amount(Subperiod::Voting), + pre_staker_info.staked_amount(Subperiod::Voting) + stake_amount.voting, + "Voting staked amount must increase by the voting 'stake_amount'" + ); + assert_eq!( + post_staker_info.staked_amount(Subperiod::BuildAndEarn), + pre_staker_info.staked_amount(Subperiod::BuildAndEarn) + + stake_amount.build_and_earn, + "B&E staked amount must increase by the B&E 'stake_amount'" + ); + assert_eq!(post_staker_info.period_number(), stake_period); + } + // A new entry is created. + _ => { + assert_eq!( + post_staker_info.total_staked_amount(), + stake_amount.total(), + "Total staked amount must be equal to exactly the 'amount'" + ); + assert!(stake_amount.total() >= ::MinimumStakeAmount::get()); + assert_eq!( + post_staker_info.staked_amount(Subperiod::Voting), + stake_amount.voting, + "Voting staked amount must be equal to exactly the voting 'stake_amount'" + ); + assert_eq!( + post_staker_info.staked_amount(Subperiod::BuildAndEarn), + stake_amount.build_and_earn, + "B&E staked amount must be equal to exactly the B&E 'stake_amount'" + ); + assert_eq!(post_staker_info.period_number(), stake_period); + } + } +} diff --git a/pallets/dapp-staking/src/test/tests.rs b/pallets/dapp-staking/src/test/tests.rs index 3f01012d6d..660d04199f 100644 --- a/pallets/dapp-staking/src/test/tests.rs +++ b/pallets/dapp-staking/src/test/tests.rs @@ -18,10 +18,11 @@ use crate::test::{mock::*, testing_utils::*}; use crate::{ - pallet::Config, ActiveProtocolState, BonusStatus, ContractStake, DAppId, DAppTierRewardsFor, - DAppTiers, EraRewards, Error, Event, ForcingType, GenesisConfig, IntegratedDApps, Ledger, - NextDAppId, Perbill, PeriodNumber, Permill, Safeguard, StakerInfo, StaticTierParams, Subperiod, - TierConfig, TierThreshold, + pallet::Config, ActiveProtocolState, BonusStatus, BonusStatusFor, ContractStake, DAppId, + DAppTierRewardsFor, DAppTiers, EraRewards, Error, Event, ForcingType, GenesisConfig, + IntegratedDApps, Ledger, NextDAppId, Perbill, PeriodNumber, Permill, Safeguard, + SingularStakingInfoFor, StakeAmount, StakerInfo, StaticTierParams, Subperiod, TierConfig, + TierThreshold, }; use frame_support::{ @@ -3532,3 +3533,584 @@ fn claim_bonus_reward_for_works() { ); }) } + +#[test] +// Tests moving stakes from unregistered contracts to another contract while verifying that: +// - All staked funds are moved from the unregistered contracts (for Some and None amounts). +// - The bonus_status is preserved during the move from an unregistered contract. +// - Subsequent moves preserve the adjusted bonus_status (check with an unstake in B&E before last move). +// - Destination stake compounds successfully if entry already exists. +fn move_stake_from_unregistered_contract_is_ok() { + ExtBuilder::default().build_and_execute(|| { + // 0.1. Setup 1 + // Register smart contracts 1 & 2 & 3, lock&stake some amount on 1, unregister the smart contract 1 + let source_contract = MockSmartContract::wasm(1 as AccountId); + let stopover_contract = MockSmartContract::wasm(2 as AccountId); + let final_contract = MockSmartContract::wasm(3 as AccountId); + assert_register(1, &source_contract); + assert_register(1, &stopover_contract); + assert_register(1, &final_contract); + + let account = 2; + let amount = 300; + let partial_move_amount = 200; + assert_lock(account, amount); + assert_stake(account, &source_contract, amount); + assert_unregister(&source_contract); + + // Advance to B&E subperiod to check that bonus is preserved during a move from an unregistered contract + advance_to_next_subperiod(); + + // 1. First Move + assert_move_stake( + account, + &source_contract, + &stopover_contract, + Some(partial_move_amount), + ); + + // 2. Verify newly created destination staking info + // - Total stake must be moved from unregistered contracts. + // - The new STOPOVER staker info bonus must be preserved with a default bonus status value. + let move_era = ActiveProtocolState::::get().era; + let move_period = ActiveProtocolState::::get().period_number(); + let expected_stopover_staker_info = SingularStakingInfoFor:: { + staked: StakeAmount { + voting: amount, + build_and_earn: 0, + era: move_era + 1, + period: move_period, + }, + ..Default::default() + }; + assert_staker_info( + account, + &stopover_contract, + expected_stopover_staker_info.clone(), + ); + + // 0.2. Setup 2 + // Move again from an unregistered contract in the next era to ensure previous 'voting' & 'b&e' stakes where properly moved. + advance_to_next_era(); + assert_claim_staker_rewards(account); // Require before unstake + + let unstake_amount = 3; + assert_unstake(account, &stopover_contract, unstake_amount); // To decrese BonusStatus by 1 + assert_unregister(&stopover_contract); + + // 2. Second Move + assert_move_stake(account, &stopover_contract, &final_contract, None); + + let move_2_era = ActiveProtocolState::::get().era; + let move_2_period = ActiveProtocolState::::get().period_number(); + let mut expected_bonus_status = BonusStatusFor::::default(); + expected_bonus_status.decrease_moves(); + let expected_final_staker_info = SingularStakingInfoFor:: { + staked: StakeAmount { + voting: amount - unstake_amount, + build_and_earn: 0, + era: move_2_era + 1, + period: move_2_period, + }, + bonus_status: expected_bonus_status, + ..Default::default() + }; + + // The new final decreased staker's bonus must still be preserved. + assert_staker_info(account, &final_contract, expected_final_staker_info); + }) +} + +// Tests moving a stake from a registered contract to another contract under various scenarios: +// - Partial stake move (to a new destination). +// - Full stake move when the remaining stake is below the minimum required. +// - Verifies proper cleanup of the source contract's staking info when fully moved. +// - Tests the impact on the bonus_status when moves occur during B&E subperiod (until bonus is forfeited). +#[test] +fn move_stake_from_registered_contract_is_ok() { + ExtBuilder::default().build_and_execute(|| { + // 0.1. Setup 1 + // Register smart contracts 1 & 2 & 3, lock&stake some amount on 1 + let source_contract = MockSmartContract::wasm(1 as AccountId); + let stopover_contract = MockSmartContract::wasm(2 as AccountId); + let final_contract = MockSmartContract::wasm(3 as AccountId); + assert_register(1, &source_contract); + assert_register(1, &stopover_contract); + assert_register(1, &final_contract); + + let account = 2; + let source_stake_amount = 300; + let final_stake_amount = 100; + assert_lock(account, source_stake_amount + final_stake_amount); + + let stake_era = ActiveProtocolState::::get().era; + let stake_period = ActiveProtocolState::::get().period_number(); + assert_stake(account, &source_contract, source_stake_amount); + assert_stake(account, &final_contract, final_stake_amount); + + // 1. First Partial Move + let partial_move_amount = 200; + assert_move_stake( + account, + &source_contract, + &stopover_contract, + Some(partial_move_amount), + ); + + // 2. Verify newly created destination staking info + // - Partial stake must be moved from SOURCE contract to STOPOVER contract. + // - The new STOPOVER staker info bonus must be preserved with a default bonus status value (still 'Voting' subperiod). + let expected_source_staker_info = SingularStakingInfoFor:: { + staked: StakeAmount { + voting: source_stake_amount - partial_move_amount, + build_and_earn: 0, + era: stake_era + 1, + period: stake_period, + }, + ..Default::default() + }; + assert_staker_info(account, &source_contract, expected_source_staker_info); + + let expected_stopover_staker_info = SingularStakingInfoFor:: { + staked: StakeAmount { + voting: partial_move_amount, + build_and_earn: 0, + era: stake_era + 1, + period: stake_period, + }, + ..Default::default() + }; + assert_staker_info(account, &stopover_contract, expected_stopover_staker_info); + + // 0.2. Setup 2 + // Move again from STOPOVER contract in the next subperiod to an already staked FINAL contract + advance_to_next_subperiod(); + + let min_stake_amount: Balance = ::MinimumStakeAmount::get(); + // Ensure partial stake move but below the minimum allowed remaining stake for this to be treated as a full move + let partial_move_amount_2 = partial_move_amount.saturating_sub(min_stake_amount) + 1; + + // 2. Second Move + assert_move_stake( + account, + &stopover_contract, + &final_contract, + Some(partial_move_amount_2), + ); + + let move_2_era = ActiveProtocolState::::get().era; + let move_2_period = ActiveProtocolState::::get().period_number(); + let mut expected_bonus_status = BonusStatusFor::::default(); + expected_bonus_status.decrease_moves(); + let expected_final_staker_info = SingularStakingInfoFor:: { + previous_staked: StakeAmount { + voting: final_stake_amount, + build_and_earn: 0, + era: stake_era + 1, + period: stake_period, + }, + staked: StakeAmount { + voting: final_stake_amount + partial_move_amount, + build_and_earn: 0, + era: move_2_era + 1, + period: move_2_period, + }, + bonus_status: expected_bonus_status, + }; + + assert_staker_info(account, &final_contract, expected_final_staker_info); + + let max_bonus_moves: u8 = ::MaxBonusMovesPerPeriod::get(); + + // Sanity check + if max_bonus_moves > 0 { + let remaining_moves = max_bonus_moves.saturating_sub(1); // To account for previous unstake + // Move stake until bonus is forfeited + for _ in 0..=remaining_moves { + assert_move_stake(account, &final_contract, &source_contract, Some(1)); + } + + let expected_source_staker_info = SingularStakingInfoFor:: { + previous_staked: StakeAmount { + voting: source_stake_amount - partial_move_amount + + ((max_bonus_moves as u128) - 1), + build_and_earn: 0, + era: stake_era + 1, + period: stake_period, + }, + staked: StakeAmount { + voting: source_stake_amount - partial_move_amount + (max_bonus_moves as u128), + build_and_earn: 0, + era: move_2_era + 1, + period: move_2_period, + }, + bonus_status: BonusStatus::BonusForfeited, + }; + assert_staker_info(account, &source_contract, expected_source_staker_info); + + let expected_final_staker_info = SingularStakingInfoFor:: { + previous_staked: StakeAmount { + voting: final_stake_amount, + build_and_earn: 0, + era: stake_era + 1, + period: stake_period, + }, + staked: StakeAmount { + voting: final_stake_amount + partial_move_amount - (max_bonus_moves as u128), + build_and_earn: 0, + era: move_2_era + 1, + period: move_2_period, + }, + bonus_status: BonusStatus::BonusForfeited, // Maybe to rework: the bonus of an already staked contract is also forfeited after exessive moves + }; + assert_staker_info(account, &final_contract, expected_final_staker_info); + } + }) +} + +#[test] +fn move_stake_for_different_subperiod_stakes_is_ok() { + ExtBuilder::default().build_and_execute(|| { + // Register smart contracts 1 & 2, lock&stake some amount on 1 + let source_contract = MockSmartContract::wasm(1 as AccountId); + let destination_contract = MockSmartContract::wasm(2 as AccountId); + assert_register(1, &source_contract); + assert_register(1, &destination_contract); + + let account = 2; + let total_locked_amount = 400; + let source_initial_stake_amount = 300; + assert_lock(account, total_locked_amount); + assert_stake(account, &source_contract, source_initial_stake_amount); + + // 1. First Partial Move (Voting subperiod) + let partial_move_amount = 200; + assert_move_stake( + account, + &source_contract, + &destination_contract, + Some(partial_move_amount), + ); + + advance_to_next_subperiod(); + + // Second stake during B&E + let stake_2_era = ActiveProtocolState::::get().era; + let stake_2_period = ActiveProtocolState::::get().period_number(); + let source_second_stake_amount = 100; + assert_stake(account, &source_contract, source_second_stake_amount); + + advance_to_next_era(); + let move_2_era = ActiveProtocolState::::get().era; + let move_2_period = ActiveProtocolState::::get().period_number(); + + // 2. Second Partial Move (B&E subperiod) + let partial_move_2_amount = 50; + assert_move_stake( + account, + &source_contract, + &destination_contract, + Some(partial_move_2_amount), + ); + + let expected_source_staker_info = SingularStakingInfoFor:: { + previous_staked: StakeAmount { + voting: source_initial_stake_amount - partial_move_amount, + build_and_earn: 0, + era: stake_2_era, + period: stake_2_period, + }, + staked: StakeAmount { + voting: source_initial_stake_amount - partial_move_amount, + build_and_earn: source_second_stake_amount - partial_move_2_amount, + era: move_2_era, + period: move_2_period, + }, + bonus_status: BonusStatus::default(), + }; + assert_staker_info(account, &source_contract, expected_source_staker_info); + + let expected_destination_staker_info = SingularStakingInfoFor:: { + previous_staked: StakeAmount { + voting: partial_move_amount, + build_and_earn: 0, + era: move_2_era, + period: move_2_period, + }, + staked: StakeAmount { + voting: partial_move_amount, + build_and_earn: partial_move_2_amount, + era: move_2_era + 1, + period: move_2_period, + }, + bonus_status: BonusStatus::default(), + }; + assert_staker_info( + account, + &destination_contract, + expected_destination_staker_info, + ); + }) +} + +#[test] +fn move_for_same_contract_fails() { + ExtBuilder::default().build_and_execute(|| { + let account = 2; + let contract = MockSmartContract::wasm(1 as AccountId); + assert_register(1, &contract); + + assert_noop!( + DappStaking::move_stake(RuntimeOrigin::signed(account), contract, contract, None), + Error::::SameContracts + ); + }) +} + +#[test] +fn move_from_past_period_fails() { + ExtBuilder::default().build_and_execute(|| { + let source_contract = MockSmartContract::wasm(1 as AccountId); + let destination_contract = MockSmartContract::wasm(2 as AccountId); + assert_register(1, &source_contract); + assert_register(1, &destination_contract); + + let account = 2; + let source_stake_amount = 300; + let partial_move_amount = 200; + assert_lock(account, source_stake_amount); + assert_stake(account, &source_contract, source_stake_amount); + + advance_to_next_period(); + // for _ in 0..required_number_of_reward_claims(account) { + // assert_claim_staker_rewards(account); + // } + + // Try to move from the source contract, which is no longer staked on due to period change. + assert_noop!( + DappStaking::move_stake( + RuntimeOrigin::signed(account), + source_contract, + destination_contract, + Some(partial_move_amount) + ), + Error::::UnstakeFromPastPeriod + ); + }) +} + +#[test] +fn move_too_small_amount_fails() { + ExtBuilder::default().build_and_execute(|| { + let source_contract = MockSmartContract::wasm(1 as AccountId); + let destination_contract = MockSmartContract::wasm(2 as AccountId); + assert_register(1, &source_contract); + assert_register(1, &destination_contract); + + let account = 2; + let source_stake_amount = 300; + assert_lock(account, source_stake_amount); + assert_stake(account, &source_contract, source_stake_amount); + + let min_stake_amount: Balance = ::MinimumStakeAmount::get(); + let partial_move_amount = min_stake_amount - 1; + // Move to a new contract with too small amount, expect a failure + assert_noop!( + DappStaking::move_stake( + RuntimeOrigin::signed(account), + source_contract, + destination_contract, + Some(partial_move_amount) + ), + Error::::InsufficientStakeAmount + ); + }) +} + +// Destination contract is not found in IntegratedDApps. +#[test] +fn move_to_invalid_dapp_fails() { + ExtBuilder::default().build_and_execute(|| { + let source_contract = MockSmartContract::wasm(1 as AccountId); + let destination_contract = MockSmartContract::wasm(2 as AccountId); + assert_register(1, &source_contract); + + let account = 2; + assert_lock(account, 300); + + // Try to move to non-existing destination contract + assert_noop!( + DappStaking::move_stake( + RuntimeOrigin::signed(account), + source_contract, + destination_contract, + None + ), + Error::::ContractNotFound + ); + }) +} + +// No staking info exists for the account and the source contract. +#[test] +fn move_from_non_staked_contract_fails() { + ExtBuilder::default().build_and_execute(|| { + let source_contract = MockSmartContract::Wasm(1); + let destination_contract = MockSmartContract::Wasm(2); + assert_register(1, &source_contract); + assert_register(1, &destination_contract); + let account = 2; + assert_lock(account, 300); + + // Try to move from the source contract, which isn't staked on. + assert_noop!( + DappStaking::move_stake( + RuntimeOrigin::signed(account), + source_contract, + destination_contract, + None + ), + Error::::NoStakingInfo + ); + }) +} + +// Registered contract, but the maybe_amount is None or 0. +#[test] +fn move_with_invalid_amount_fails() { + ExtBuilder::default().build_and_execute(|| { + let source_contract = MockSmartContract::Wasm(1); + let destination_contract = MockSmartContract::Wasm(2); + assert_register(1, &source_contract); + assert_register(1, &destination_contract); + + let account = 2; + let source_stake_amount = 300; + assert_lock(account, source_stake_amount); + assert_stake(account, &source_contract, source_stake_amount); + + assert_noop!( + DappStaking::move_stake( + RuntimeOrigin::signed(account), + source_contract, + destination_contract, + None + ), + Error::::InvalidAmount + ); + + assert_noop!( + DappStaking::move_stake( + RuntimeOrigin::signed(account), + source_contract, + destination_contract, + Some(0) + ), + Error::::ZeroAmount + ); + }) +} + +// Move ammount exceeds the staked amount. +#[test] +fn move_with_exceeding_amount_fails() { + ExtBuilder::default().build_and_execute(|| { + let source_contract = MockSmartContract::Wasm(1); + let destination_contract = MockSmartContract::Wasm(2); + assert_register(1, &source_contract); + assert_register(1, &destination_contract); + + let account = 2; + let source_stake_amount = 300; + assert_lock(account, source_stake_amount); + assert_stake(account, &source_contract, source_stake_amount); + + assert_noop!( + DappStaking::move_stake( + RuntimeOrigin::signed(account), + source_contract, + destination_contract, + Some(source_stake_amount + 1) + ), + Error::::UnstakeAmountTooLarge + ); + }) +} + +#[test] +fn move_fails_due_to_too_many_staked_contracts() { + ExtBuilder::default().build_and_execute(|| { + let max_number_of_contracts: u32 = ::MaxNumberOfStakedContracts::get(); + + // Lock amount by staker + let account = 1; + assert_lock(account, 100 as Balance * max_number_of_contracts as Balance); + + // Advance to build&earn subperiod so we ensure 'non-loyal' staking + advance_to_next_subperiod(); + + let source_contract = MockSmartContract::Wasm(1); + assert_register(1, &source_contract); + assert_stake(account, &source_contract, 10); + + // Register smart contracts up to the max allowed number + for id in 2..=max_number_of_contracts { + let smart_contract = MockSmartContract::Wasm(id.into()); + assert_register(2, &MockSmartContract::Wasm(id.into())); + assert_stake(account, &smart_contract, 10); + } + + let excess_destination_smart_contract = + MockSmartContract::Wasm((max_number_of_contracts + 1).into()); + assert_register(2, &excess_destination_smart_contract); + + // Max number of staked contract entries has been exceeded. + assert_noop!( + DappStaking::move_stake( + RuntimeOrigin::signed(account), + source_contract, + excess_destination_smart_contract.clone(), + Some(10) + ), + Error::::TooManyStakedContracts + ); + }) +} + +#[test] +fn move_fails_if_unclaimed_destination_staker_rewards_from_past_remain() { + ExtBuilder::default().build_and_execute(|| { + let source_contract = MockSmartContract::Wasm(1); + let source_2_contract = MockSmartContract::Wasm(2); + let destination_contract = MockSmartContract::Wasm(3); + assert_register(1, &source_contract); + assert_register(1, &source_2_contract); + assert_register(1, &destination_contract); + + let account = 2; + assert_lock(account, 300); + assert_stake(account, &source_contract, 100); + + // To transfer bonus reward elegibility to destination_contract + assert_move_stake(account, &source_contract, &destination_contract, Some(10)); + + // Advance to next period, claim all staker rewards + advance_to_next_period(); + for _ in 0..required_number_of_reward_claims(account) { + assert_claim_staker_rewards(account); + } + + // Try to move again on the same destination contract, expect an error due to unclaimed bonus rewards + advance_to_era(ActiveProtocolState::::get().era + 2); + assert_stake(account, &source_2_contract, 100); + assert_noop!( + DappStaking::move_stake( + RuntimeOrigin::signed(account), + source_2_contract, + destination_contract, + Some(10) + ), + Error::::UnclaimedRewards + ); + }) +} diff --git a/pallets/dapp-staking/src/test/tests_types.rs b/pallets/dapp-staking/src/test/tests_types.rs index eff1bb4a3c..a937d239c0 100644 --- a/pallets/dapp-staking/src/test/tests_types.rs +++ b/pallets/dapp-staking/src/test/tests_types.rs @@ -2502,6 +2502,88 @@ fn singular_staking_info_unstake_era_amount_pairs_are_ok() { } } +#[test] +fn move_stake_basic() { + get_u8_type!(MaxMoves, 1); + type TestSingularStakingInfo = SingularStakingInfo; + + // Setup initial staking info for source and destination + let current_era = 10; + let subperiod = Subperiod::Voting; + let mut source_staking_info = TestSingularStakingInfo::new(1, subperiod); + source_staking_info.staked.add(100, Subperiod::Voting); + source_staking_info.staked.add(50, Subperiod::BuildAndEarn); + source_staking_info.staked.era = current_era; + + let mut destination_staking_info = TestSingularStakingInfo::new(1, subperiod); + + // Move 80 tokens from source to destination + let move_amount = 80; + let (era_and_amount_pairs, stake_moved) = source_staking_info.move_stake( + &mut destination_staking_info, + move_amount, + current_era, + subperiod, + ); + + // Verify source staking info + assert_eq!(source_staking_info.staked.voting, 20); // 100 - 80 + assert_eq!(source_staking_info.staked.build_and_earn, 50); // Unchanged + assert_eq!(source_staking_info.staked.era, current_era); + + // Verify destination staking info + assert_eq!(destination_staking_info.staked.voting, 80); + assert_eq!(destination_staking_info.staked.build_and_earn, 0); + assert_eq!(destination_staking_info.staked.era, current_era + 1); // Stake valid from next era + + // Verify return values + assert_eq!(era_and_amount_pairs.len(), 1); + assert_eq!(era_and_amount_pairs[0], (current_era, move_amount)); + assert_eq!(stake_moved.voting, 80); + assert_eq!(stake_moved.build_and_earn, 0); +} + +#[test] +fn move_stake_full_transfer() { + get_u8_type!(MaxMoves, 1); + type TestSingularStakingInfo = SingularStakingInfo; + + // Setup initial staking info for source and destination + let current_era = 10; + let subperiod = Subperiod::BuildAndEarn; + let mut source_staking_info = TestSingularStakingInfo::new(1, subperiod); + source_staking_info.staked.add(50, Subperiod::Voting); + source_staking_info.staked.add(50, Subperiod::BuildAndEarn); + source_staking_info.staked.era = current_era; + + let mut destination_staking_info = TestSingularStakingInfo::new(1, subperiod); + + // Move all 100 tokens from source to destination + let move_amount = 100; + let (era_and_amount_pairs, stake_moved) = source_staking_info.move_stake( + &mut destination_staking_info, + move_amount, + current_era, + subperiod, + ); + + // Verify source staking info is emptied + assert_eq!(source_staking_info.staked.voting, 0); + assert_eq!(source_staking_info.staked.build_and_earn, 0); + assert_eq!(source_staking_info.staked.era, current_era); + + // Verify destination staking info - full transfer + assert_eq!(destination_staking_info.staked.voting, 50); + assert_eq!(destination_staking_info.staked.build_and_earn, 50); + assert_eq!(destination_staking_info.staked.era, current_era + 1); + + // Verify return values + assert_eq!(era_and_amount_pairs.len(), 1); + assert_eq!(era_and_amount_pairs[0], (current_era, move_amount)); + assert_eq!(stake_moved.voting, 50); + assert_eq!(stake_moved.build_and_earn, 50); +} + #[test] fn bonus_status_transition_between_subperiods_is_ok() { get_u8_type!(MaxMoves, 1); diff --git a/pallets/dapp-staking/src/types.rs b/pallets/dapp-staking/src/types.rs index dcaecaba4b..f1e2f40a9a 100644 --- a/pallets/dapp-staking/src/types.rs +++ b/pallets/dapp-staking/src/types.rs @@ -75,7 +75,9 @@ use sp_runtime::{ pub use sp_std::{collections::btree_map::BTreeMap, fmt::Debug, vec::Vec}; use astar_primitives::{ - dapp_staking::{DAppId, EraNumber, PeriodNumber, RankedTier, TierSlots as TierSlotsFunc}, + dapp_staking::{ + DAppId, EraNumber, PeriodNumber, RankedTier, StakeAmountMoved, TierSlots as TierSlotsFunc, + }, Balance, BlockNumber, }; @@ -1041,6 +1043,16 @@ impl> BonusStatus { pub fn has_bonus(&self) -> bool { matches!(self, BonusStatus::SafeMovesRemaining(_)) } + + /// Custom equality function to ignore the lack of PartialEq and Eq implementation for ConstU8 in MaxBonusMoves. + pub fn equals(&self, other: &Self) -> bool { + match (self, other) { + (BonusStatus::BonusForfeited, BonusStatus::BonusForfeited) => true, + (BonusStatus::SafeMovesRemaining(a), BonusStatus::SafeMovesRemaining(b)) => a == b, + (BonusStatus::_Phantom(_), BonusStatus::_Phantom(_)) => true, + _ => false, + } + } } impl> PartialEq for BonusStatus { @@ -1203,10 +1215,51 @@ impl> SingularStakingInfo { result } + /// Transfers stake between contracts while maintaining subperiod-specific allocations, era consistency, and bonus-safe conditions. + pub fn move_stake( + &mut self, + destination: &mut SingularStakingInfo, + amount: Balance, + current_era: EraNumber, + subperiod: Subperiod, + ) -> (Vec<(EraNumber, Balance)>, StakeAmountMoved) { + let staked_snapshot = self.staked; + let era_and_amount_pairs = self.unstake(amount, current_era, subperiod); + + let voting_stake_moved = staked_snapshot.voting.saturating_sub(self.staked.voting); + let build_earn_stake_moved = staked_snapshot + .build_and_earn + .saturating_sub(self.staked.build_and_earn); + + // Similar to a stake on destination but for the 2 subperiods + destination.previous_staked = destination.staked; + destination.previous_staked.era = current_era; + if destination.previous_staked.total().is_zero() { + destination.previous_staked = Default::default(); + } + destination + .staked + .add(voting_stake_moved, Subperiod::Voting); + destination + .staked + .add(build_earn_stake_moved, Subperiod::BuildAndEarn); + + // Moved stake is only valid from the next era so we keep it consistent here + destination.staked.era = current_era.saturating_add(1); + + ( + era_and_amount_pairs, + StakeAmountMoved { + voting: voting_stake_moved, + build_and_earn: build_earn_stake_moved, + }, + ) + } + /// Updates the bonus_status based on the current subperiod /// For Voting subperiod: bonus_status is forfeited for full unstake /// For B&E: the number of 'bonus safe moves' remaining is reduced for full unstake or for partial unstake if it exceeds the previous ‘voting’ stake used as a reference (the bonus status changes to 'forfeited' if there are no safe moves remaining) - pub fn update_bonus_status(&mut self, subperiod: Subperiod, previous_voting_stake: Balance) { + pub fn update_bonus_status(&mut self, subperiod: Subperiod, voting_stake_snapshot: Balance) { match subperiod { Subperiod::Voting => { if self.staked.voting.is_zero() { @@ -1214,7 +1267,7 @@ impl> SingularStakingInfo { } } Subperiod::BuildAndEarn => { - if self.staked.voting < previous_voting_stake { + if self.staked.voting < voting_stake_snapshot { self.bonus_status.decrease_moves(); } } @@ -1250,6 +1303,13 @@ impl> SingularStakingInfo { pub fn is_empty(&self) -> bool { self.staked.is_empty() } + + /// Custom equality function to ignore the lack of PartialEq and Eq implementation for ConstU8 in MaxBonusMoves. + pub fn equals(&self, other: &Self) -> bool { + self.previous_staked == other.previous_staked + && self.staked == other.staked + && self.bonus_status.equals(&other.bonus_status) + } } impl> Decode for SingularStakingInfo { diff --git a/pallets/dapp-staking/src/weights.rs b/pallets/dapp-staking/src/weights.rs index 31859fc357..fdc55d0796 100644 --- a/pallets/dapp-staking/src/weights.rs +++ b/pallets/dapp-staking/src/weights.rs @@ -68,6 +68,7 @@ pub trait WeightInfo { fn unstake_from_unregistered() -> Weight; fn cleanup_expired_entries(x: u32, ) -> Weight; fn force() -> Weight; + fn move_stake() -> Weight; fn on_initialize_voting_to_build_and_earn() -> Weight; fn on_initialize_build_and_earn_to_voting() -> Weight; fn on_initialize_build_and_earn_to_build_and_earn() -> Weight; @@ -395,6 +396,27 @@ impl WeightInfo for SubstrateWeight { // Minimum execution time: 11_543_000 picoseconds. Weight::from_parts(11_735_000, 0) } + /// Storage: `DappStaking::IntegratedDApps` (r:2 w:0) + /// Proof: `DappStaking::IntegratedDApps` (`max_values`: Some(65535), `max_size`: Some(116), added: 2096, mode: `MaxEncodedLen`) + /// Storage: `DappStaking::Ledger` (r:1 w:1) + /// Proof: `DappStaking::Ledger` (`max_values`: None, `max_size`: Some(310), added: 2785, mode: `MaxEncodedLen`) + /// Storage: `DappStaking::StakerInfo` (r:2 w:2) + /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(179), added: 2654, mode: `MaxEncodedLen`) + /// Storage: `DappStaking::ContractStake` (r:2 w:2) + /// Proof: `DappStaking::ContractStake` (`max_values`: Some(65535), `max_size`: Some(91), added: 2071, mode: `MaxEncodedLen`) + /// Storage: `Balances::Freezes` (r:1 w:1) + /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(67), added: 2542, mode: `MaxEncodedLen`) + /// Storage: `Balances::Locks` (r:1 w:0) + /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(1299), added: 3774, mode: `MaxEncodedLen`) + fn move_stake() -> Weight { + // Proof Size summary in bytes: + // Measured: `536` + // Estimated: `6298` + // Minimum execution time: 59_000_000 picoseconds. + Weight::from_parts(60_000_000, 6298) + .saturating_add(T::DbWeight::get().reads(9_u64)) + .saturating_add(T::DbWeight::get().writes(6_u64)) + } /// Storage: DappStaking CurrentEraInfo (r:1 w:1) /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, mode: MaxEncodedLen) /// Storage: DappStaking EraRewards (r:1 w:1) @@ -809,6 +831,27 @@ impl WeightInfo for () { // Minimum execution time: 11_543_000 picoseconds. Weight::from_parts(11_735_000, 0) } + /// Storage: `DappStaking::IntegratedDApps` (r:2 w:0) + /// Proof: `DappStaking::IntegratedDApps` (`max_values`: Some(65535), `max_size`: Some(116), added: 2096, mode: `MaxEncodedLen`) + /// Storage: `DappStaking::Ledger` (r:1 w:1) + /// Proof: `DappStaking::Ledger` (`max_values`: None, `max_size`: Some(310), added: 2785, mode: `MaxEncodedLen`) + /// Storage: `DappStaking::StakerInfo` (r:2 w:2) + /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(179), added: 2654, mode: `MaxEncodedLen`) + /// Storage: `DappStaking::ContractStake` (r:2 w:2) + /// Proof: `DappStaking::ContractStake` (`max_values`: Some(65535), `max_size`: Some(91), added: 2071, mode: `MaxEncodedLen`) + /// Storage: `Balances::Freezes` (r:1 w:1) + /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(67), added: 2542, mode: `MaxEncodedLen`) + /// Storage: `Balances::Locks` (r:1 w:0) + /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(1299), added: 3774, mode: `MaxEncodedLen`) + fn move_stake() -> Weight { + // Proof Size summary in bytes: + // Measured: `536` + // Estimated: `6298` + // Minimum execution time: 59_000_000 picoseconds. + Weight::from_parts(60_000_000, 6298) + .saturating_add(RocksDbWeight::get().reads(9_u64)) + .saturating_add(RocksDbWeight::get().writes(6_u64)) + } /// Storage: DappStaking CurrentEraInfo (r:1 w:1) /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, mode: MaxEncodedLen) /// Storage: DappStaking EraRewards (r:1 w:1) diff --git a/primitives/src/dapp_staking.rs b/primitives/src/dapp_staking.rs index e44c61caf3..3e7542ffc1 100644 --- a/primitives/src/dapp_staking.rs +++ b/primitives/src/dapp_staking.rs @@ -274,6 +274,17 @@ impl RankedTier { } } +pub struct StakeAmountMoved { + pub voting: Balance, + pub build_and_earn: Balance, +} + +impl StakeAmountMoved { + pub fn total(&self) -> Balance { + self.voting.saturating_add(self.build_and_earn) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/runtime/astar/src/weights/pallet_dapp_staking.rs b/runtime/astar/src/weights/pallet_dapp_staking.rs index 29b26bbffc..7723927d74 100644 --- a/runtime/astar/src/weights/pallet_dapp_staking.rs +++ b/runtime/astar/src/weights/pallet_dapp_staking.rs @@ -40,6 +40,8 @@ // --output=./benchmark-results/astar-dev/dapp_staking_weights.rs // --template=./scripts/templates/weight-template.hbs +// TODO: Dummy values for move_stake, do proper benchmark using gha + #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] #![allow(unused_imports)] @@ -370,6 +372,21 @@ impl WeightInfo for SubstrateWeight { Weight::from_parts(8_948_000, 1486) .saturating_add(T::DbWeight::get().reads(1_u64)) } + /// Storage: `DappStaking::IntegratedDApps` (r:2 w:0) + /// Proof: `DappStaking::IntegratedDApps` (`max_values`: Some(65535), `max_size`: Some(116), added: 2096, mode: `MaxEncodedLen`) + /// Storage: `DappStaking::StakerInfo` (r:2 w:2) + /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(179), added: 2654, mode: `MaxEncodedLen`) + /// Storage: `DappStaking::ContractStake` (r:2 w:2) + /// Proof: `DappStaking::ContractStake` (`max_values`: Some(65535), `max_size`: Some(91), added: 2071, mode: `MaxEncodedLen`) + fn move_stake() -> Weight { + // Proof Size summary in bytes: + // Measured: `373` + // Estimated: `6298` + // Minimum execution time: 38_000_000 picoseconds. + Weight::from_parts(38_000_000, 6298) + .saturating_add(T::DbWeight::get().reads(6_u64)) + .saturating_add(T::DbWeight::get().writes(4_u64)) + } /// Storage: `DappStaking::CurrentEraInfo` (r:1 w:1) /// Proof: `DappStaking::CurrentEraInfo` (`max_values`: Some(1), `max_size`: Some(112), added: 607, mode: `MaxEncodedLen`) /// Storage: `DappStaking::EraRewards` (r:1 w:1) diff --git a/runtime/shibuya/src/weights/pallet_dapp_staking.rs b/runtime/shibuya/src/weights/pallet_dapp_staking.rs index e10cd8d18c..06c2441c13 100644 --- a/runtime/shibuya/src/weights/pallet_dapp_staking.rs +++ b/runtime/shibuya/src/weights/pallet_dapp_staking.rs @@ -40,6 +40,8 @@ // --output=./benchmark-results/shibuya-dev/dapp_staking_weights.rs // --template=./scripts/templates/weight-template.hbs +// TODO: Dummy values for move_stake, do proper benchmark using gha + #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] #![allow(unused_imports)] @@ -370,6 +372,21 @@ impl WeightInfo for SubstrateWeight { Weight::from_parts(8_696_000, 1486) .saturating_add(T::DbWeight::get().reads(1_u64)) } + /// Storage: `DappStaking::IntegratedDApps` (r:2 w:0) + /// Proof: `DappStaking::IntegratedDApps` (`max_values`: Some(65535), `max_size`: Some(116), added: 2096, mode: `MaxEncodedLen`) + /// Storage: `DappStaking::StakerInfo` (r:2 w:2) + /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(179), added: 2654, mode: `MaxEncodedLen`) + /// Storage: `DappStaking::ContractStake` (r:2 w:2) + /// Proof: `DappStaking::ContractStake` (`max_values`: Some(65535), `max_size`: Some(91), added: 2071, mode: `MaxEncodedLen`) + fn move_stake() -> Weight { + // Proof Size summary in bytes: + // Measured: `373` + // Estimated: `6298` + // Minimum execution time: 38_000_000 picoseconds. + Weight::from_parts(38_000_000, 6298) + .saturating_add(T::DbWeight::get().reads(6_u64)) + .saturating_add(T::DbWeight::get().writes(4_u64)) + } /// Storage: `DappStaking::CurrentEraInfo` (r:1 w:1) /// Proof: `DappStaking::CurrentEraInfo` (`max_values`: Some(1), `max_size`: Some(112), added: 607, mode: `MaxEncodedLen`) /// Storage: `DappStaking::EraRewards` (r:1 w:1) diff --git a/runtime/shiden/src/weights/pallet_dapp_staking.rs b/runtime/shiden/src/weights/pallet_dapp_staking.rs index 169284360b..77a9d4a3e7 100644 --- a/runtime/shiden/src/weights/pallet_dapp_staking.rs +++ b/runtime/shiden/src/weights/pallet_dapp_staking.rs @@ -40,6 +40,8 @@ // --output=./benchmark-results/shiden-dev/dapp_staking_weights.rs // --template=./scripts/templates/weight-template.hbs +// TODO: Dummy values for move_stake, do proper benchmark using gha + #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] #![allow(unused_imports)] @@ -370,6 +372,21 @@ impl WeightInfo for SubstrateWeight { Weight::from_parts(8_785_000, 1486) .saturating_add(T::DbWeight::get().reads(1_u64)) } + /// Storage: `DappStaking::IntegratedDApps` (r:2 w:0) + /// Proof: `DappStaking::IntegratedDApps` (`max_values`: Some(65535), `max_size`: Some(116), added: 2096, mode: `MaxEncodedLen`) + /// Storage: `DappStaking::StakerInfo` (r:2 w:2) + /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(179), added: 2654, mode: `MaxEncodedLen`) + /// Storage: `DappStaking::ContractStake` (r:2 w:2) + /// Proof: `DappStaking::ContractStake` (`max_values`: Some(65535), `max_size`: Some(91), added: 2071, mode: `MaxEncodedLen`) + fn move_stake() -> Weight { + // Proof Size summary in bytes: + // Measured: `373` + // Estimated: `6298` + // Minimum execution time: 38_000_000 picoseconds. + Weight::from_parts(38_000_000, 6298) + .saturating_add(T::DbWeight::get().reads(6_u64)) + .saturating_add(T::DbWeight::get().writes(4_u64)) + } /// Storage: `DappStaking::CurrentEraInfo` (r:1 w:1) /// Proof: `DappStaking::CurrentEraInfo` (`max_values`: Some(1), `max_size`: Some(112), added: 607, mode: `MaxEncodedLen`) /// Storage: `DappStaking::EraRewards` (r:1 w:1)