diff --git a/pallets/roles/src/impls.rs b/pallets/roles/src/impls.rs index e465d58fb..cb6d23b2f 100644 --- a/pallets/roles/src/impls.rs +++ b/pallets/roles/src/impls.rs @@ -15,7 +15,7 @@ // along with Tangle. If not, see . use super::*; -use sp_runtime::Saturating; +use sp_runtime::{DispatchResult, Percent, Saturating}; use tangle_primitives::{roles::RoleType, traits::roles::RolesHandler}; /// Implements RolesHandler for the pallet. @@ -29,16 +29,7 @@ impl RolesHandler for Pallet { /// # Returns /// Returns `true` if the validator is permitted to work with this job type, otherwise `false`. fn is_validator(address: T::AccountId, role: RoleType) -> bool { - let assigned_role = AccountRolesMapping::::get(address); - match assigned_role { - Some(r) => - if r == role { - return true - }, - None => return false, - } - - false + Self::has_role(address, role) } /// Slash validator stake for the reported offence. The function should be a best effort @@ -63,6 +54,84 @@ impl RolesHandler for Pallet { /// Functions for the pallet. impl Pallet { + /// Add new role to the given account. + /// + /// # Parameters + /// - `account`: The account ID of the validator. + /// - `role`: Selected role type. + pub fn add_role(account: T::AccountId, role: RoleType) -> DispatchResult { + AccountRolesMapping::::try_mutate(&account, |roles| { + if !roles.contains(&role) { + roles.try_push(role.clone()).map_err(|_| Error::::MaxRoles)?; + + Ok(()) + } else { + Err(Error::::HasRoleAssigned.into()) + } + }) + } + + /// Remove role from the given account. + /// + /// # Parameters + /// - `account`: The account ID of the validator. + /// - `role`: Selected role type. + pub fn remove_role(account: T::AccountId, role: RoleType) -> DispatchResult { + AccountRolesMapping::::try_mutate(&account, |roles| { + if roles.contains(&role) { + roles.retain(|r| r != &role); + + Ok(()) + } else { + Err(Error::::NoRoleAssigned.into()) + } + }) + } + + /// Check if the given account has the given role. + /// + /// # Parameters + /// - `account`: The account ID of the validator. + /// - `role`: Selected role type. + /// + /// # Returns + /// Returns `true` if the validator is permitted to work with this job type, otherwise `false`. + pub fn has_role(account: T::AccountId, role: RoleType) -> bool { + let assigned_roles = AccountRolesMapping::::get(account); + match assigned_roles.iter().find(|r| **r == role) { + Some(_) => true, + None => false, + } + } + + /// Check if account can chill, unbound and withdraw funds. + /// + /// # Parameters + /// - `account`: The account ID of the validator. + /// + /// # Returns + /// Returns boolean value. + pub fn can_exit(account: T::AccountId) -> bool { + let assigned_roles = AccountRolesMapping::::get(account); + if assigned_roles.is_empty() { + // Role is cleared, account can chill, unbound and withdraw funds. + return true + } + false + } + + /// Calculate max re-stake amount for the given account. + /// + /// # Parameters + /// - `total_stake`: Total stake of the validator + /// + /// # Returns + /// Returns the max re-stake amount. + pub fn calculate_max_re_stake_amount(total_stake: BalanceOf) -> BalanceOf { + // User can re-stake max 50% of the total stake + Percent::from_percent(50) * total_stake + } + /// Get the total amount of the balance that is locked for the given stash. /// /// # Parameters diff --git a/pallets/roles/src/lib.rs b/pallets/roles/src/lib.rs index ad643a145..d33d35c60 100644 --- a/pallets/roles/src/lib.rs +++ b/pallets/roles/src/lib.rs @@ -22,18 +22,15 @@ use codec::MaxEncodedLen; use frame_support::{ ensure, traits::{Currency, Get}, - CloneNoBound, EqNoBound, PalletId, PartialEqNoBound, RuntimeDebugNoBound, + CloneNoBound, EqNoBound, PartialEqNoBound, RuntimeDebugNoBound, }; pub use pallet::*; use parity_scale_codec::{Decode, Encode}; use scale_info::TypeInfo; -use sp_runtime::{codec, traits::Zero}; +use sp_runtime::{codec, traits::Zero, Saturating}; use sp_std::{convert::TryInto, prelude::*, vec}; -use tangle_primitives::{ - roles::{ReStakingOption, RoleType}, - traits::roles::RolesHandler, -}; +use tangle_primitives::roles::RoleType; mod impls; #[cfg(test)] pub(crate) mod mock; @@ -57,16 +54,29 @@ pub use weights::WeightInfo; pub struct RoleStakingLedger { /// The stash account whose balance is actually locked and at stake. pub stash: T::AccountId, - /// The total amount of the stash's balance that is re-staked for selected services + /// The total amount of the stash's balance that is re-staked for all selected roles. /// This re-staked balance we are currently accounting for new slashing conditions. #[codec(compact)] pub total: BalanceOf, + /// The list of roles and their re-staked amounts. + pub roles: Vec>, +} + +/// The information regarding the re-staked amount for a particular role. +#[derive(PartialEqNoBound, EqNoBound, Encode, Decode, RuntimeDebugNoBound, TypeInfo, Clone)] +#[scale_info(skip_type_params(T))] +pub struct RoleStakingRecord { + /// Role type + pub role: RoleType, + /// The total amount of the stash's balance that is re-staked for selected role. + #[codec(compact)] + pub re_staked: BalanceOf, } impl RoleStakingLedger { /// Initializes the default object using the given `validator`. pub fn default_from(stash: T::AccountId) -> Self { - Self { stash, total: Zero::zero() } + Self { stash, total: Zero::zero(), roles: vec![] } } /// Returns `true` if the stash account has no funds at all. @@ -95,10 +105,10 @@ pub mod pallet { /// The overarching event type. type RuntimeEvent: From> + IsType<::RuntimeEvent>; - type WeightInfo: WeightInfo; - #[pallet::constant] - type PalletId: Get; + type MaxRolesPerAccount: Get; + + type WeightInfo: WeightInfo; } #[pallet::event] @@ -120,8 +130,10 @@ pub mod pallet { HasRoleAssigned, /// No role assigned to provided validator. NoRoleAssigned, + /// Max role limit reached for the account. + MaxRoles, /// Invalid Re-staking amount, should not exceed total staked amount. - InvalidReStakingBond, + ExceedsMaxReStakeValue, /// Re staking amount should be greater than minimum re-staking bond requirement. InsufficientReStakingBond, /// Stash controller account already added to Roles Ledger @@ -137,22 +149,27 @@ pub mod pallet { StorageMap<_, Blake2_128Concat, T::AccountId, RoleStakingLedger>; #[pallet::storage] - #[pallet::getter(fn account_role)] + #[pallet::getter(fn account_roles)] /// Mapping of resource to bridge index - pub type AccountRolesMapping = StorageMap<_, Blake2_256, T::AccountId, RoleType>; + pub type AccountRolesMapping = StorageMap< + _, + Blake2_256, + T::AccountId, + BoundedVec, + ValueQuery, + >; /// The minimum re staking bond to become and maintain the role. #[pallet::storage] #[pallet::getter(fn min_active_bond)] pub(super) type MinReStakingBond = StorageValue<_, BalanceOf, ValueQuery>; - /// Assigns a role to the validator. + /// Assigns roles to the validator. /// /// # Parameters /// /// - `origin`: Origin of the transaction. - /// - `role`: Role to assign to the validator. - /// - `re_stake`: Amount of funds you want to re-stake. + /// - `records`: List of roles user is interested to re-stake. /// /// This function will return error if /// - Account is not a validator account. @@ -162,10 +179,9 @@ pub mod pallet { impl Pallet { #[pallet::weight({0})] #[pallet::call_index(0)] - pub fn assign_role( + pub fn assign_roles( origin: OriginFor, - role: RoleType, - re_stake: ReStakingOption, + records: Vec>, ) -> DispatchResult { let stash_account = ensure_signed(origin)?; // Ensure stash account is a validator. @@ -174,36 +190,49 @@ pub mod pallet { Error::::NotValidator ); - // Check if role is already assigned. - ensure!( - !AccountRolesMapping::::contains_key(&stash_account), - Error::::HasRoleAssigned - ); - - // Check if stash account is already paired/ re-staked. - ensure!(!>::contains_key(&stash_account), Error::::AccountAlreadyPaired); + let mut ledger = Ledger::::get(&stash_account) + .unwrap_or(RoleStakingLedger::::default_from(stash_account.clone())); let staking_ledger = pallet_staking::Ledger::::get(&stash_account).ok_or(Error::::NotValidator)?; - let re_stake_amount = match re_stake { - ReStakingOption::Full => staking_ledger.active, - ReStakingOption::Custom(x) => x.into(), - }; - - // Validate re-staking bond, should be greater than min re-staking bond requirement. - let min_re_staking_bond = MinReStakingBond::::get(); - ensure!(re_stake_amount >= min_re_staking_bond, Error::::InsufficientReStakingBond); - // Validate re-staking bond, should not exceed active staked bond. - ensure!(staking_ledger.active >= re_stake_amount, Error::::InvalidReStakingBond); + let max_re_staking_bond = Self::calculate_max_re_stake_amount(staking_ledger.active); + + // Validate role staking records. + for record in records.clone() { + let role = record.role; + let re_stake_amount = record.re_staked; + // Check if role is already assigned. + ensure!(!Self::has_role(stash_account.clone(), role), Error::::HasRoleAssigned); + + // Re-staking amount of record should meet min re-staking amount requirement. + let min_re_staking_bond = MinReStakingBond::::get(); + ensure!( + re_stake_amount >= min_re_staking_bond, + Error::::InsufficientReStakingBond + ); + + // Total re_staking amount should not exceed max_re_staking_amount. + ensure!( + ledger.total.saturating_add(re_stake_amount) <= max_re_staking_bond, + Error::::ExceedsMaxReStakeValue + ); + + ledger.total = ledger.total.saturating_add(re_stake_amount); + let role_info = RoleStakingRecord { role, re_staked: re_stake_amount }; + ledger.roles.push(role_info); + } - // Update ledger. - let item = RoleStakingLedger { stash: stash_account.clone(), total: re_stake_amount }; - Self::update_ledger(&stash_account, &item); + // Now that records are validated we can add them and update ledger + for record in records { + Self::add_role(stash_account.clone(), record.role)?; + Self::deposit_event(Event::::RoleAssigned { + account: stash_account.clone(), + role: record.role, + }); + } + Self::update_ledger(&stash_account, &ledger); - // Add role mapping for the stash account. - AccountRolesMapping::::insert(&stash_account, role); - Self::deposit_event(Event::::RoleAssigned { account: stash_account.clone(), role }); Ok(()) } @@ -229,16 +258,14 @@ pub mod pallet { ); // check if role is assigned. - ensure!( - Self::is_validator(stash_account.clone(), role.clone()), - Error::::NoRoleAssigned - ); + ensure!(Self::has_role(stash_account.clone(), role), Error::::NoRoleAssigned); + // TODO: Call jobs manager to remove the services. // On successful removal of services, remove the role from the mapping. // Issue link for reference : https://github.com/webb-tools/tangle/issues/292 // Remove role from the mapping. - AccountRolesMapping::::remove(&stash_account); + Self::remove_role(stash_account.clone(), role)?; // Remove stash account related info. Self::kill_stash(&stash_account); @@ -264,7 +291,7 @@ pub mod pallet { pub fn chill(origin: OriginFor) -> DispatchResult { let account = ensure_signed(origin.clone())?; // Ensure no role is assigned to the account before chilling. - ensure!(!AccountRolesMapping::::contains_key(&account), Error::::HasRoleAssigned); + ensure!(Self::can_exit(account), Error::::HasRoleAssigned); // chill pallet_staking::Pallet::::chill(origin) @@ -292,7 +319,7 @@ pub mod pallet { ) -> DispatchResult { let account = ensure_signed(origin.clone())?; // Ensure no role is assigned to the account and is eligible to unbound. - ensure!(!AccountRolesMapping::::contains_key(&account), Error::::HasRoleAssigned); + ensure!(Self::can_exit(account), Error::::HasRoleAssigned); // Unbound funds. let res = pallet_staking::Pallet::::unbond(origin, amount); @@ -313,12 +340,9 @@ pub mod pallet { #[pallet::weight({0})] #[pallet::call_index(4)] pub fn withdraw_unbonded(origin: OriginFor) -> DispatchResult { - let stash_account = ensure_signed(origin.clone())?; + let account = ensure_signed(origin.clone())?; // Ensure no role is assigned to the account and is eligible to withdraw. - ensure!( - !AccountRolesMapping::::contains_key(&stash_account), - Error::::HasRoleAssigned - ); + ensure!(Self::can_exit(account), Error::::HasRoleAssigned); // Withdraw unbound funds. let res = pallet_staking::Pallet::::withdraw_unbonded(origin, 0); diff --git a/pallets/roles/src/mock.rs b/pallets/roles/src/mock.rs index ae4141095..8ac61a502 100644 --- a/pallets/roles/src/mock.rs +++ b/pallets/roles/src/mock.rs @@ -200,13 +200,9 @@ impl pallet_staking::Config for Runtime { type WeightInfo = (); } -parameter_types! { - pub const RolesPalletId: PalletId = PalletId(*b"py/roles"); -} - impl Config for Runtime { type RuntimeEvent = RuntimeEvent; - type PalletId = RolesPalletId; + type MaxRolesPerAccount = ConstU32<2>; type WeightInfo = (); } diff --git a/pallets/roles/src/tests.rs b/pallets/roles/src/tests.rs index f66576194..efdde5155 100644 --- a/pallets/roles/src/tests.rs +++ b/pallets/roles/src/tests.rs @@ -19,14 +19,14 @@ use frame_support::{assert_err, assert_ok}; use mock::*; #[test] -fn test_assign_role() { +fn test_assign_roles() { new_test_ext_raw_authorities(vec![1, 2, 3, 4]).execute_with(|| { // Initially account if funded with 10000 tokens and we are trying to bond 5000 tokens - assert_ok!(Roles::assign_role( - RuntimeOrigin::signed(1), - RoleType::Tss, - ReStakingOption::Custom(5000) - )); + + // Roles user is interested in re-staking. + let role_records = vec![RoleStakingRecord { role: RoleType::Tss, re_staked: 5000 }]; + + assert_ok!(Roles::assign_roles(RuntimeOrigin::signed(1), role_records)); assert_events(vec![RuntimeEvent::Roles(crate::Event::RoleAssigned { account: 1, @@ -34,32 +34,70 @@ fn test_assign_role() { })]); // Lets verify role assigned to account. - assert_eq!(Roles::account_role(1), Some(RoleType::Tss)); + assert_eq!(Roles::has_role(1, RoleType::Tss), true); // Verify ledger mapping - assert_eq!(Roles::ledger(1), Some(RoleStakingLedger { stash: 1, total: 5000 })); + assert_eq!( + Roles::ledger(1), + Some(RoleStakingLedger { + stash: 1, + total: 5000, + roles: vec![RoleStakingRecord { role: RoleType::Tss, re_staked: 5000 }] + }) + ); }); } -// Test that we can assign role with full staking option. +// test assign multiple roles to an account. #[test] -fn test_assign_role_with_full_staking_option() { +fn test_assign_multiple_roles() { new_test_ext_raw_authorities(vec![1, 2, 3, 4]).execute_with(|| { // Initially account if funded with 10000 tokens and we are trying to bond 5000 tokens - assert_ok!(Roles::assign_role( - RuntimeOrigin::signed(1), - RoleType::Tss, - ReStakingOption::Full - )); - assert_events(vec![RuntimeEvent::Roles(crate::Event::RoleAssigned { - account: 1, - role: RoleType::Tss, - })]); + // Roles user is interested in re-staking. + let role_records = vec![ + RoleStakingRecord { role: RoleType::Tss, re_staked: 2500 }, + RoleStakingRecord { role: RoleType::ZkSaas, re_staked: 2500 }, + ]; + + assert_ok!(Roles::assign_roles(RuntimeOrigin::signed(1), role_records)); // Lets verify role assigned to account. - assert_eq!(Roles::account_role(1), Some(RoleType::Tss)); - // Verify ledger mapping - assert_eq!(Roles::ledger(1), Some(RoleStakingLedger { stash: 1, total: 10000 })); + assert_eq!(Roles::has_role(1, RoleType::Tss), true); + + // Lets verify role assigned to account. + assert_eq!(Roles::has_role(1, RoleType::ZkSaas), true); + + assert_eq!( + Roles::ledger(1), + Some(RoleStakingLedger { + stash: 1, + total: 5000, + roles: vec![ + RoleStakingRecord { role: RoleType::Tss, re_staked: 2500 }, + RoleStakingRecord { role: RoleType::ZkSaas, re_staked: 2500 }, + ] + }) + ); + }); +} + +// Test assign roles, should fail if total re-stake value exceeds max re-stake value. +// Max re-stake value is 5000 (50% of total staked value). +#[test] +fn test_assign_roles_should_fail_if_total_re_stake_value_exceeds_max_re_stake_value() { + new_test_ext_raw_authorities(vec![1, 2, 3, 4]).execute_with(|| { + // Initially account if funded with 10000 tokens and we are trying to bond 5000 tokens + + // Roles user is interested in re-staking. + let role_records = vec![ + RoleStakingRecord { role: RoleType::Tss, re_staked: 5000 }, + RoleStakingRecord { role: RoleType::ZkSaas, re_staked: 5000 }, + ]; + // Since max re_stake limit is 5000 it should fail with `ExceedsMaxReStakeValue` error. + assert_err!( + Roles::assign_roles(RuntimeOrigin::signed(1), role_records), + Error::::ExceedsMaxReStakeValue + ); }); } @@ -67,11 +105,11 @@ fn test_assign_role_with_full_staking_option() { fn test_clear_role() { new_test_ext_raw_authorities(vec![1, 2, 3, 4]).execute_with(|| { // Initially account if funded with 10000 tokens and we are trying to bond 5000 tokens - assert_ok!(Roles::assign_role( - RuntimeOrigin::signed(1), - RoleType::Tss, - ReStakingOption::Custom(5000) - )); + + // Roles user is interested in re-staking. + let role_records = vec![RoleStakingRecord { role: RoleType::Tss, re_staked: 5000 }]; + + assert_ok!(Roles::assign_roles(RuntimeOrigin::signed(1), role_records)); // Now lets clear the role assert_ok!(Roles::clear_role(RuntimeOrigin::signed(1), RoleType::Tss)); @@ -82,7 +120,7 @@ fn test_clear_role() { })]); // Role should be removed from account role mappings. - assert_eq!(Roles::account_role(1), None); + assert_eq!(Roles::has_role(1, RoleType::Tss), false); // Ledger should be removed from ledger mappings. assert_eq!(Roles::ledger(1), None); @@ -90,15 +128,15 @@ fn test_clear_role() { } #[test] -fn test_assign_role_should_fail_if_not_validator() { +fn test_assign_roles_should_fail_if_not_validator() { new_test_ext_raw_authorities(vec![1, 2, 3, 4]).execute_with(|| { // we will use account 5 which is not a validator + + // Roles user is interested in re-staking. + let role_records = vec![RoleStakingRecord { role: RoleType::Tss, re_staked: 5000 }]; + assert_err!( - Roles::assign_role( - RuntimeOrigin::signed(5), - RoleType::Tss, - ReStakingOption::Custom(5000) - ), + Roles::assign_roles(RuntimeOrigin::signed(5), role_records), Error::::NotValidator ); }); @@ -109,20 +147,20 @@ fn test_unbound_funds_should_work() { new_test_ext_raw_authorities(vec![1, 2, 3, 4]).execute_with(|| { // Initially validator account has staked 10_000 tokens and wants to re-stake 5000 tokens // for providing TSS services. - assert_ok!(Roles::assign_role( - RuntimeOrigin::signed(1), - RoleType::Tss, - ReStakingOption::Custom(5000) - )); + + // Roles user is interested in re-staking. + let role_records = vec![RoleStakingRecord { role: RoleType::Tss, re_staked: 5000 }]; + + assert_ok!(Roles::assign_roles(RuntimeOrigin::signed(1), role_records)); // Lets verify role is assigned to account. - assert_eq!(Roles::account_role(1), Some(RoleType::Tss)); + assert_eq!(Roles::has_role(1, RoleType::Tss), true); // Lets clear the role. assert_ok!(Roles::clear_role(RuntimeOrigin::signed(1), RoleType::Tss)); // Role should be removed from account role mappings. - assert_eq!(Roles::account_role(1), None); + assert_eq!(Roles::has_role(1, RoleType::Tss), false); // unbound funds. assert_ok!(Roles::unbound_funds(RuntimeOrigin::signed(1), 5000)); @@ -145,14 +183,14 @@ fn test_unbound_funds_should_fail_if_role_assigned() { new_test_ext_raw_authorities(vec![1, 2, 3, 4]).execute_with(|| { // Initially validator account has staked 10_000 tokens and wants to re-stake 5000 tokens // for providing TSS services. - assert_ok!(Roles::assign_role( - RuntimeOrigin::signed(1), - RoleType::Tss, - ReStakingOption::Custom(5000) - )); + + // Roles user is interested in re-staking. + let role_records = vec![RoleStakingRecord { role: RoleType::Tss, re_staked: 5000 }]; + + assert_ok!(Roles::assign_roles(RuntimeOrigin::signed(1), role_records)); // Lets verify role is assigned to account. - assert_eq!(Roles::account_role(1), Some(RoleType::Tss)); + assert_eq!(Roles::has_role(1, RoleType::Tss), true); // Lets try to unbound funds. assert_err!(