From a3d4ac97559fbdfa49edf75711b3407cb788cc9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dino=20Pa=C4=8Dandi?= <3002868+Dinonard@users.noreply.github.com> Date: Wed, 14 Jun 2023 10:25:24 +0200 Subject: [PATCH] dApps staking v3 - part 1 (#155) * dApps staking v3 - inception * First ext call wip * Register & reward_dest implemented * Reward beneficiary & owner changes * Remove register deposit * UNregister & various minor improvements * Progress towards lock * Lock progress * Lock progress * Lock implemented & some code improvements * PR comments * Tests & some changes * Additional UTs * Lock tests * PR comments * Introduce types * Update frame/dapp-staking-v3/src/lib.rs Co-authored-by: Shaun Wang * Address PR review comments --------- Co-authored-by: Shaun Wang --- Cargo.toml | 1 + frame/dapp-staking-v3/Cargo.toml | 42 ++ frame/dapp-staking-v3/src/lib.rs | 460 ++++++++++++++++++ frame/dapp-staking-v3/src/test/mock.rs | 176 +++++++ frame/dapp-staking-v3/src/test/mod.rs | 22 + .../dapp-staking-v3/src/test/testing_utils.rs | 213 ++++++++ frame/dapp-staking-v3/src/test/tests.rs | 364 ++++++++++++++ frame/dapp-staking-v3/src/test/tests_types.rs | 105 ++++ frame/dapp-staking-v3/src/types.rs | 299 ++++++++++++ 9 files changed, 1682 insertions(+) create mode 100644 frame/dapp-staking-v3/Cargo.toml create mode 100644 frame/dapp-staking-v3/src/lib.rs create mode 100644 frame/dapp-staking-v3/src/test/mock.rs create mode 100644 frame/dapp-staking-v3/src/test/mod.rs create mode 100644 frame/dapp-staking-v3/src/test/testing_utils.rs create mode 100644 frame/dapp-staking-v3/src/test/tests.rs create mode 100644 frame/dapp-staking-v3/src/test/tests_types.rs create mode 100644 frame/dapp-staking-v3/src/types.rs diff --git a/Cargo.toml b/Cargo.toml index 3eb8697f..cc3b057d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "frame/collator-selection", "frame/custom-signatures", "frame/dapps-staking", + "frame/dapp-staking-v3", "frame/pallet-xcm", "frame/pallet-xvm", "frame/xc-asset-config", diff --git a/frame/dapp-staking-v3/Cargo.toml b/frame/dapp-staking-v3/Cargo.toml new file mode 100644 index 00000000..27f927fe --- /dev/null +++ b/frame/dapp-staking-v3/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "pallet-dapp-staking-v3" +version = "0.0.1-alpha" +description = "Pallet for dApp staking v3 protocol" +authors.workspace = true +edition.workspace = true +homepage.workspace = true +repository.workspace = true + +[dependencies] +frame-support = { workspace = true } +frame-system = { workspace = true } +num-traits = { workspace = true } +parity-scale-codec = { workspace = true } + +scale-info = { workspace = true } +serde = { workspace = true, optional = true } +sp-arithmetic = { workspace = true } +sp-core = { workspace = true } +sp-io = { workspace = true } +sp-runtime = { workspace = true } +sp-std = { workspace = true } + +[dev-dependencies] +pallet-balances = { workspace = true } + +[features] +default = ["std"] +std = [ + "serde", + "parity-scale-codec/std", + "scale-info/std", + "num-traits/std", + "sp-core/std", + "sp-runtime/std", + "sp-arithmetic/std", + "sp-io/std", + "sp-std/std", + "frame-support/std", + "frame-system/std", + "pallet-balances/std", +] diff --git a/frame/dapp-staking-v3/src/lib.rs b/frame/dapp-staking-v3/src/lib.rs new file mode 100644 index 00000000..d2057651 --- /dev/null +++ b/frame/dapp-staking-v3/src/lib.rs @@ -0,0 +1,460 @@ +// This file is part of Astar. + +// Copyright (C) 2019-2023 Stake Technologies Pte.Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +// Astar is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Astar is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Astar. If not, see . + +//! # dApp Staking v3 Pallet +//! +//! - [`Config`] +//! +//! ## Overview +//! +//! Pallet that implements dapps staking protocol. +//! +//! <> +//! +//! ## Interface +//! +//! ### Dispatchable Function +//! +//! <> +//! +//! ### Other +//! +//! <> +//! + +#![cfg_attr(not(feature = "std"), no_std)] + +use frame_support::{ + pallet_prelude::*, + traits::{Currency, LockIdentifier, LockableCurrency, StorageVersion, WithdrawReasons}, + weights::Weight, +}; +use frame_system::pallet_prelude::*; +use sp_runtime::traits::{BadOrigin, Saturating, Zero}; + +use crate::types::*; +pub use pallet::*; + +#[cfg(test)] +mod test; + +mod types; + +const STAKING_ID: LockIdentifier = *b"dapstake"; + +#[frame_support::pallet] +pub mod pallet { + use super::*; + + /// The current storage version. + const STORAGE_VERSION: StorageVersion = StorageVersion::new(5); + + #[pallet::pallet] + #[pallet::generate_store(pub(crate) trait Store)] + #[pallet::storage_version(STORAGE_VERSION)] + pub struct Pallet(_); + + #[pallet::config] + pub trait Config: frame_system::Config { + /// The overarching event type. + type RuntimeEvent: From> + IsType<::RuntimeEvent>; + + /// Currency used for staking. + type Currency: LockableCurrency; + + /// Describes smart contract in the context required by dApp staking. + type SmartContract: Parameter + Member + MaxEncodedLen; + + /// Privileged origin for managing dApp staking pallet. + type ManagerOrigin: EnsureOrigin<::RuntimeOrigin>; + + /// Maximum number of contracts that can be integrated into dApp staking at once. + /// TODO: maybe this can be reworded or improved later on - but we want a ceiling! + #[pallet::constant] + type MaxNumberOfContracts: Get; + + /// Maximum number of locked chunks that can exist per account at a time. + #[pallet::constant] + type MaxLockedChunks: Get; + + /// Maximum number of unlocking chunks that can exist per account at a time. + #[pallet::constant] + type MaxUnlockingChunks: Get; + + /// Minimum amount an account has to lock in dApp staking in order to participate. + #[pallet::constant] + type MinimumLockedAmount: Get>; + } + + #[pallet::event] + #[pallet::generate_deposit(pub(crate) fn deposit_event)] + pub enum Event { + /// A smart contract has been registered for dApp staking + DAppRegistered { + owner: T::AccountId, + smart_contract: T::SmartContract, + dapp_id: DAppId, + }, + /// dApp reward destination has been updated. + DAppRewardDestinationUpdated { + smart_contract: T::SmartContract, + beneficiary: Option, + }, + /// dApp owner has been changed. + DAppOwnerChanged { + smart_contract: T::SmartContract, + new_owner: T::AccountId, + }, + /// dApp has been unregistered + DAppUnregistered { + smart_contract: T::SmartContract, + era: EraNumber, + }, + /// Account has locked some amount into dApp staking. + Locked { + account: T::AccountId, + amount: BalanceOf, + }, + } + + #[pallet::error] + pub enum Error { + /// Pallet is disabled/in maintenance mode. + Disabled, + /// Smart contract already exists within dApp staking protocol. + ContractAlreadyExists, + /// Maximum number of smart contracts has been reached. + ExceededMaxNumberOfContracts, + /// Not possible to assign a new dApp Id. + /// This should never happen since current type can support up to 65536 - 1 unique dApps. + NewDAppIdUnavailable, + /// Specified smart contract does not exist in dApp staking. + ContractNotFound, + /// Call origin is not dApp owner. + OriginNotOwner, + /// dApp is part of dApp staking but isn't active anymore. + NotOperatedDApp, + /// Performing locking or staking with 0 amount. + ZeroAmount, + /// Total locked amount for staker is below minimum threshold. + LockedAmountBelowThreshold, + /// Cannot add additional locked balance chunks due to size limit. + TooManyLockedBalanceChunks, + } + + /// General information about dApp staking protocol state. + #[pallet::storage] + pub type ActiveProtocolState = + StorageValue<_, ProtocolState>, ValueQuery>; + + /// Counter for unique dApp identifiers. + #[pallet::storage] + pub type NextDAppId = StorageValue<_, DAppId, ValueQuery>; + + /// Map of all dApps integrated into dApp staking protocol. + #[pallet::storage] + pub type IntegratedDApps = CountedStorageMap< + _, + Blake2_128Concat, + T::SmartContract, + DAppInfo, + OptionQuery, + >; + + /// General locked/staked information for each account. + #[pallet::storage] + pub type Ledger = + StorageMap<_, Blake2_128Concat, T::AccountId, AccountLedgerFor, ValueQuery>; + + /// General information about the current era. + #[pallet::storage] + pub type CurrentEraInfo = StorageValue<_, EraInfo>, ValueQuery>; + + #[pallet::call] + impl Pallet { + /// Used to enable or disable maintenance mode. + /// Can only be called by manager origin. + #[pallet::call_index(0)] + #[pallet::weight(Weight::zero())] + pub fn maintenance_mode(origin: OriginFor, enabled: bool) -> DispatchResult { + T::ManagerOrigin::ensure_origin(origin)?; + ActiveProtocolState::::mutate(|state| state.maintenance = enabled); + Ok(()) + } + + /// Used to register a new contract for dApp staking. + /// + /// If successful, smart contract will be assigned a simple, unique numerical identifier. + #[pallet::call_index(1)] + #[pallet::weight(Weight::zero())] + pub fn register( + origin: OriginFor, + owner: T::AccountId, + smart_contract: T::SmartContract, + ) -> DispatchResult { + Self::ensure_pallet_enabled()?; + T::ManagerOrigin::ensure_origin(origin)?; + + ensure!( + !IntegratedDApps::::contains_key(&smart_contract), + Error::::ContractAlreadyExists, + ); + + ensure!( + IntegratedDApps::::count() < T::MaxNumberOfContracts::get().into(), + Error::::ExceededMaxNumberOfContracts + ); + + let dapp_id = NextDAppId::::get(); + // MAX value must never be assigned as a dApp Id since it serves as a sentinel value. + ensure!(dapp_id < DAppId::MAX, Error::::NewDAppIdUnavailable); + + IntegratedDApps::::insert( + &smart_contract, + DAppInfo { + owner: owner.clone(), + id: dapp_id, + state: DAppState::Registered, + reward_destination: None, + }, + ); + + NextDAppId::::put(dapp_id.saturating_add(1)); + + Self::deposit_event(Event::::DAppRegistered { + owner, + smart_contract, + dapp_id, + }); + + Ok(()) + } + + /// Used to modify the reward destination account for a dApp. + /// + /// Caller has to be dApp owner. + /// If set to `None`, rewards will be deposited to the dApp owner. + #[pallet::call_index(2)] + #[pallet::weight(Weight::zero())] + pub fn set_dapp_reward_destination( + origin: OriginFor, + smart_contract: T::SmartContract, + beneficiary: Option, + ) -> DispatchResult { + Self::ensure_pallet_enabled()?; + let dev_account = ensure_signed(origin)?; + + IntegratedDApps::::try_mutate( + &smart_contract, + |maybe_dapp_info| -> DispatchResult { + let dapp_info = maybe_dapp_info + .as_mut() + .ok_or(Error::::ContractNotFound)?; + + ensure!(dapp_info.owner == dev_account, Error::::OriginNotOwner); + + dapp_info.reward_destination = beneficiary.clone(); + + Ok(()) + }, + )?; + + Self::deposit_event(Event::::DAppRewardDestinationUpdated { + smart_contract, + beneficiary, + }); + + Ok(()) + } + + /// Used to change dApp owner. + /// + /// Can be called by dApp owner or dApp staking manager origin. + /// This is useful in two cases: + /// 1. when the dApp owner account is compromised, manager can change the owner to a new account + /// 2. if project wants to transfer ownership to a new account (DAO, multisig, etc.). + #[pallet::call_index(3)] + #[pallet::weight(Weight::zero())] + pub fn set_dapp_owner( + origin: OriginFor, + smart_contract: T::SmartContract, + new_owner: T::AccountId, + ) -> DispatchResult { + Self::ensure_pallet_enabled()?; + let origin = Self::ensure_signed_or_manager(origin)?; + + IntegratedDApps::::try_mutate( + &smart_contract, + |maybe_dapp_info| -> DispatchResult { + let dapp_info = maybe_dapp_info + .as_mut() + .ok_or(Error::::ContractNotFound)?; + + // If manager origin, `None`, no need to check if caller is the owner. + if let Some(caller) = origin { + ensure!(dapp_info.owner == caller, Error::::OriginNotOwner); + } + + dapp_info.owner = new_owner.clone(); + + Ok(()) + }, + )?; + + Self::deposit_event(Event::::DAppOwnerChanged { + smart_contract, + new_owner, + }); + + Ok(()) + } + + /// Unregister dApp from dApp staking protocol, making it ineligible for future rewards. + /// This doesn't remove the dApp completely from the system just yet, but it can no longer be used for staking. + /// + /// Can be called by dApp owner or dApp staking manager origin. + #[pallet::call_index(4)] + #[pallet::weight(Weight::zero())] + pub fn unregister( + origin: OriginFor, + smart_contract: T::SmartContract, + ) -> DispatchResult { + Self::ensure_pallet_enabled()?; + T::ManagerOrigin::ensure_origin(origin)?; + + let current_era = ActiveProtocolState::::get().era; + + IntegratedDApps::::try_mutate( + &smart_contract, + |maybe_dapp_info| -> DispatchResult { + let dapp_info = maybe_dapp_info + .as_mut() + .ok_or(Error::::ContractNotFound)?; + + ensure!( + dapp_info.state == DAppState::Registered, + Error::::NotOperatedDApp + ); + + dapp_info.state = DAppState::Unregistered(current_era); + + Ok(()) + }, + )?; + + // TODO: might require some modification later on, like additional checks to ensure contract can be unregistered. + + Self::deposit_event(Event::::DAppUnregistered { + smart_contract, + era: current_era, + }); + + Ok(()) + } + + /// Locks additional funds into dApp staking. + /// + /// In case caller account doesn't have sufficient balance to cover the specified amount, everything is locked. + /// After adjustment, lock amount must be greater than zero and in total must be equal or greater than the minimum locked amount. + /// + /// It is possible for call to fail due to caller account already having too many locked balance chunks in storage. To solve this, + /// caller should claim pending rewards, before retrying to lock additional funds. + #[pallet::call_index(5)] + #[pallet::weight(Weight::zero())] + pub fn lock( + origin: OriginFor, + #[pallet::compact] amount: BalanceOf, + ) -> DispatchResult { + Self::ensure_pallet_enabled()?; + let account = ensure_signed(origin)?; + + let state = ActiveProtocolState::::get(); + let mut ledger = Ledger::::get(&account); + + // Calculate & check amount available for locking + let available_balance = + T::Currency::free_balance(&account).saturating_sub(ledger.locked_amount()); + let amount_to_lock = available_balance.min(amount); + ensure!(!amount_to_lock.is_zero(), Error::::ZeroAmount); + + // Only lock for the next era onwards. + let lock_era = state.era.saturating_add(1); + ledger + .add_lock_amount(amount_to_lock, lock_era) + .map_err(|_| Error::::TooManyLockedBalanceChunks)?; + ensure!( + ledger.locked_amount() >= T::MinimumLockedAmount::get(), + Error::::LockedAmountBelowThreshold + ); + + Self::update_ledger(&account, ledger); + CurrentEraInfo::::mutate(|era_info| { + era_info.total_locked.saturating_accrue(amount_to_lock); + }); + + Self::deposit_event(Event::::Locked { + account, + amount: amount_to_lock, + }); + + Ok(()) + } + } + + impl Pallet { + /// `Err` if pallet disabled for maintenance, `Ok` otherwise. + pub(crate) fn ensure_pallet_enabled() -> Result<(), Error> { + if ActiveProtocolState::::get().maintenance { + Err(Error::::Disabled) + } else { + Ok(()) + } + } + + /// Ensure that the origin is either the `ManagerOrigin` or a signed origin. + /// + /// In case of manager, `Ok(None)` is returned, and if signed origin `Ok(Some(AccountId))` is returned. + pub(crate) fn ensure_signed_or_manager( + origin: T::RuntimeOrigin, + ) -> Result, BadOrigin> { + if T::ManagerOrigin::ensure_origin(origin.clone()).is_ok() { + return Ok(None); + } + let who = ensure_signed(origin)?; + Ok(Some(who)) + } + + /// Update the account ledger, and dApp staking balance lock. + /// + /// In case account ledger is empty, entries from the DB are removed and lock is released. + pub(crate) fn update_ledger(account: &T::AccountId, ledger: AccountLedgerFor) { + if ledger.is_empty() { + Ledger::::remove(&account); + T::Currency::remove_lock(STAKING_ID, account); + } else { + T::Currency::set_lock( + STAKING_ID, + account, + ledger.locked_amount(), + WithdrawReasons::all(), + ); + Ledger::::insert(account, ledger); + } + } + } +} diff --git a/frame/dapp-staking-v3/src/test/mock.rs b/frame/dapp-staking-v3/src/test/mock.rs new file mode 100644 index 00000000..d4f11189 --- /dev/null +++ b/frame/dapp-staking-v3/src/test/mock.rs @@ -0,0 +1,176 @@ +// This file is part of Astar. + +// Copyright (C) 2019-2023 Stake Technologies Pte.Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +// Astar is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Astar is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Astar. If not, see . + +use crate::{self as pallet_dapp_staking, *}; + +use frame_support::{ + construct_runtime, parameter_types, + traits::{ConstU128, ConstU16, ConstU32}, + weights::Weight, +}; +use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; +use sp_core::H256; +use sp_runtime::{ + testing::Header, + traits::{BlakeTwo256, IdentityLookup}, +}; + +use sp_io::TestExternalities; + +pub(crate) type AccountId = u64; +pub(crate) type BlockNumber = u64; +pub(crate) type Balance = u128; + +pub(crate) const EXISTENTIAL_DEPOSIT: Balance = 2; +pub(crate) const MINIMUM_LOCK_AMOUNT: Balance = 10; + +type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic; +type Block = frame_system::mocking::MockBlock; + +construct_runtime!( + pub struct Test + where + Block = Block, + NodeBlock = Block, + UncheckedExtrinsic = UncheckedExtrinsic, + { + System: frame_system, + Balances: pallet_balances, + DappStaking: pallet_dapp_staking, + } +); + +parameter_types! { + pub const BlockHashCount: u64 = 250; + pub BlockWeights: frame_system::limits::BlockWeights = + frame_system::limits::BlockWeights::simple_max(Weight::from_ref_time(1024)); +} + +impl frame_system::Config for Test { + type BaseCallFilter = frame_support::traits::Everything; + type BlockWeights = (); + type BlockLength = (); + type RuntimeOrigin = RuntimeOrigin; + type Index = u64; + type RuntimeCall = RuntimeCall; + type BlockNumber = BlockNumber; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = AccountId; + type Lookup = IdentityLookup; + type Header = Header; + type RuntimeEvent = RuntimeEvent; + type BlockHashCount = BlockHashCount; + type DbWeight = (); + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = pallet_balances::AccountData; + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = (); + type OnSetCode = (); + type MaxConsumers = frame_support::traits::ConstU32<16>; +} + +impl pallet_balances::Config for Test { + type MaxLocks = ConstU32<4>; + type MaxReserves = (); + type ReserveIdentifier = [u8; 8]; + type Balance = Balance; + type RuntimeEvent = RuntimeEvent; + type DustRemoval = (); + type ExistentialDeposit = ConstU128; + type AccountStore = System; + type WeightInfo = (); +} + +impl pallet_dapp_staking::Config for Test { + type RuntimeEvent = RuntimeEvent; + type Currency = Balances; + type SmartContract = MockSmartContract; + type ManagerOrigin = frame_system::EnsureRoot; + type MaxNumberOfContracts = ConstU16<10>; + type MaxLockedChunks = ConstU32<5>; + type MaxUnlockingChunks = ConstU32<5>; + type MinimumLockedAmount = ConstU128; +} + +#[derive(PartialEq, Eq, Copy, Clone, Encode, Decode, Debug, TypeInfo, MaxEncodedLen, Hash)] +pub enum MockSmartContract { + Wasm(AccountId), + Other(AccountId), +} + +impl Default for MockSmartContract { + fn default() -> Self { + MockSmartContract::Wasm(1) + } +} + +pub struct ExtBuilder; +impl ExtBuilder { + pub fn build() -> TestExternalities { + let mut storage = frame_system::GenesisConfig::default() + .build_storage::() + .unwrap(); + + let balances = vec![1000; 9] + .into_iter() + .enumerate() + .map(|(idx, amount)| (idx as u64 + 1, amount)) + .collect(); + + pallet_balances::GenesisConfig:: { balances: balances } + .assimilate_storage(&mut storage) + .ok(); + + let mut ext = TestExternalities::from(storage); + ext.execute_with(|| { + System::set_block_number(1); + DappStaking::on_initialize(System::block_number()); + }); + + ext + } +} + +/// Run to the specified block number. +/// Function assumes first block has been initialized. +pub(crate) fn _run_to_block(n: u64) { + while System::block_number() < n { + DappStaking::on_finalize(System::block_number()); + System::set_block_number(System::block_number() + 1); + // This is performed outside of dapps staking but we expect it before on_initialize + DappStaking::on_initialize(System::block_number()); + } +} + +/// Run for the specified number of blocks. +/// Function assumes first block has been initialized. +pub(crate) fn _run_for_blocks(n: u64) { + _run_to_block(System::block_number() + n); +} + +/// Advance blocks until the specified era has been reached. +/// +/// Function has no effect if era is already passed. +pub(crate) fn advance_to_era(era: EraNumber) { + // TODO: Properly implement this later when additional logic has been implemented + ActiveProtocolState::::mutate(|state| state.era = era); +} diff --git a/frame/dapp-staking-v3/src/test/mod.rs b/frame/dapp-staking-v3/src/test/mod.rs new file mode 100644 index 00000000..94a09024 --- /dev/null +++ b/frame/dapp-staking-v3/src/test/mod.rs @@ -0,0 +1,22 @@ +// This file is part of Astar. + +// Copyright (C) 2019-2023 Stake Technologies Pte.Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +// Astar is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Astar is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Astar. If not, see . + +mod mock; +mod testing_utils; +mod tests; +mod tests_types; diff --git a/frame/dapp-staking-v3/src/test/testing_utils.rs b/frame/dapp-staking-v3/src/test/testing_utils.rs new file mode 100644 index 00000000..f383e9af --- /dev/null +++ b/frame/dapp-staking-v3/src/test/testing_utils.rs @@ -0,0 +1,213 @@ +// This file is part of Astar. + +// Copyright (C) 2019-2023 Stake Technologies Pte.Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +// Astar is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Astar is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Astar. If not, see . + +use crate::test::mock::*; +use crate::*; + +use frame_support::assert_ok; +use std::collections::HashMap; + +/// Helper struct used to store the entire pallet state snapshot. +/// Used when comparison of before/after states is required. +pub(crate) struct MemorySnapshot { + active_protocol_state: ProtocolState>, + next_dapp_id: DAppId, + current_era_info: EraInfo>, + integrated_dapps: HashMap< + ::SmartContract, + DAppInfo<::AccountId>, + >, + ledger: HashMap<::AccountId, AccountLedgerFor>, +} + +impl MemorySnapshot { + /// Generate a new memory snapshot, capturing entire dApp staking pallet state. + pub fn new() -> Self { + Self { + active_protocol_state: ActiveProtocolState::::get(), + next_dapp_id: NextDAppId::::get(), + current_era_info: CurrentEraInfo::::get(), + integrated_dapps: IntegratedDApps::::iter().collect(), + ledger: Ledger::::iter().collect(), + } + } + + /// Returns locked balance in dApp staking for the specified account. + /// In case no balance is locked, returns zero. + pub fn locked_balance(&self, account: &AccountId) -> Balance { + self.ledger + .get(&account) + .map_or(Balance::zero(), |ledger| ledger.locked_amount()) + } +} + +/// Register contract for staking and assert success. +pub(crate) fn assert_register(owner: AccountId, smart_contract: &MockSmartContract) { + // Init check to ensure smart contract hasn't already been integrated + assert!(!IntegratedDApps::::contains_key(smart_contract)); + let pre_snapshot = MemorySnapshot::new(); + + // Register smart contract + assert_ok!(DappStaking::register( + RuntimeOrigin::root(), + owner, + smart_contract.clone() + )); + System::assert_last_event(RuntimeEvent::DappStaking(Event::DAppRegistered { + owner, + smart_contract: smart_contract.clone(), + dapp_id: pre_snapshot.next_dapp_id, + })); + + // Verify post-state + let dapp_info = IntegratedDApps::::get(smart_contract).unwrap(); + assert_eq!(dapp_info.state, DAppState::Registered); + assert_eq!(dapp_info.owner, owner); + assert_eq!(dapp_info.id, pre_snapshot.next_dapp_id); + assert!(dapp_info.reward_destination.is_none()); + + assert_eq!(pre_snapshot.next_dapp_id + 1, NextDAppId::::get()); + assert_eq!( + pre_snapshot.integrated_dapps.len() + 1, + IntegratedDApps::::count() as usize + ); +} + +/// Update dApp reward destination and assert success +pub(crate) fn assert_set_dapp_reward_destination( + owner: AccountId, + smart_contract: &MockSmartContract, + beneficiary: Option, +) { + // Change reward destination + assert_ok!(DappStaking::set_dapp_reward_destination( + RuntimeOrigin::signed(owner), + smart_contract.clone(), + beneficiary, + )); + System::assert_last_event(RuntimeEvent::DappStaking( + Event::DAppRewardDestinationUpdated { + smart_contract: smart_contract.clone(), + beneficiary: beneficiary, + }, + )); + + // Sanity check & reward destination update + assert_eq!( + IntegratedDApps::::get(&smart_contract) + .unwrap() + .reward_destination, + beneficiary + ); +} + +/// Update dApp owner and assert success. +/// if `caller` is `None`, `Root` origin is used, otherwise standard `Signed` origin is used. +pub(crate) fn assert_set_dapp_owner( + caller: Option, + smart_contract: &MockSmartContract, + new_owner: AccountId, +) { + let origin = caller.map_or(RuntimeOrigin::root(), |owner| RuntimeOrigin::signed(owner)); + + // Change dApp owner + assert_ok!(DappStaking::set_dapp_owner( + origin, + smart_contract.clone(), + new_owner, + )); + System::assert_last_event(RuntimeEvent::DappStaking(Event::DAppOwnerChanged { + smart_contract: smart_contract.clone(), + new_owner, + })); + + // Verify post-state + assert_eq!( + IntegratedDApps::::get(&smart_contract).unwrap().owner, + new_owner + ); +} + +/// Update dApp status to unregistered and assert success. +pub(crate) fn assert_unregister(smart_contract: &MockSmartContract) { + let pre_snapshot = MemorySnapshot::new(); + + // Unregister dApp + assert_ok!(DappStaking::unregister( + RuntimeOrigin::root(), + smart_contract.clone(), + )); + System::assert_last_event(RuntimeEvent::DappStaking(Event::DAppUnregistered { + smart_contract: smart_contract.clone(), + era: pre_snapshot.active_protocol_state.era, + })); + + // Verify post-state + assert_eq!( + IntegratedDApps::::get(&smart_contract).unwrap().state, + DAppState::Unregistered(pre_snapshot.active_protocol_state.era), + ); +} + +/// Lock funds into dApp staking and assert success. +pub(crate) fn assert_lock(account: AccountId, amount: Balance) { + let pre_snapshot = MemorySnapshot::new(); + + let free_balance = Balances::free_balance(&account); + let locked_balance = pre_snapshot.locked_balance(&account); + let available_balance = free_balance + .checked_sub(locked_balance) + .expect("Locked amount cannot be greater than available free balance"); + let expected_lock_amount = available_balance.min(amount); + assert!(!expected_lock_amount.is_zero()); + + // Lock funds + assert_ok!(DappStaking::lock(RuntimeOrigin::signed(account), amount,)); + System::assert_last_event(RuntimeEvent::DappStaking(Event::Locked { + account, + amount: expected_lock_amount, + })); + + // Verify post-state + let post_snapshot = MemorySnapshot::new(); + + assert_eq!( + post_snapshot.locked_balance(&account), + locked_balance + expected_lock_amount, + "Locked balance should be increased by the amount locked." + ); + assert_eq!( + post_snapshot + .ledger + .get(&account) + .expect("Ledger entry has to exist after succcessful lock call") + .era(), + post_snapshot.active_protocol_state.era + 1 + ); + + assert_eq!( + post_snapshot.current_era_info.total_locked, + pre_snapshot.current_era_info.total_locked + expected_lock_amount, + "Total locked balance should be increased by the amount locked." + ); + assert_eq!( + post_snapshot.current_era_info.active_era_locked, + pre_snapshot.current_era_info.active_era_locked, + "Active era locked amount should remain exactly the same." + ); +} diff --git a/frame/dapp-staking-v3/src/test/tests.rs b/frame/dapp-staking-v3/src/test/tests.rs new file mode 100644 index 00000000..381a3851 --- /dev/null +++ b/frame/dapp-staking-v3/src/test/tests.rs @@ -0,0 +1,364 @@ +// This file is part of Astar. + +// Copyright (C) 2019-2023 Stake Technologies Pte.Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +// Astar is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Astar is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Astar. If not, see . + +use crate::test::mock::*; +use crate::test::testing_utils::*; +use crate::{ + pallet as pallet_dapp_staking, ActiveProtocolState, DAppId, Error, IntegratedDApps, NextDAppId, +}; + +use frame_support::{assert_noop, assert_ok, error::BadOrigin, traits::Get}; +use sp_runtime::traits::Zero; + +#[test] +fn maintenace_mode_works() { + ExtBuilder::build().execute_with(|| { + // Check that maintenance mode is disabled by default + assert!(!ActiveProtocolState::::get().maintenance); + + // Enable maintenance mode & check post-state + assert_ok!(DappStaking::maintenance_mode(RuntimeOrigin::root(), true)); + assert!(ActiveProtocolState::::get().maintenance); + + // Call still works, even in maintenance mode + assert_ok!(DappStaking::maintenance_mode(RuntimeOrigin::root(), true)); + assert!(ActiveProtocolState::::get().maintenance); + + // Incorrect origin doesn't work + assert_noop!( + DappStaking::maintenance_mode(RuntimeOrigin::signed(1), false), + BadOrigin + ); + }) +} + +#[test] +fn maintenace_mode_call_filtering_works() { + ExtBuilder::build().execute_with(|| { + // Enable maintenance mode & check post-state + assert_ok!(DappStaking::maintenance_mode(RuntimeOrigin::root(), true)); + assert!(ActiveProtocolState::::get().maintenance); + + assert_noop!( + DappStaking::register(RuntimeOrigin::root(), 1, MockSmartContract::Wasm(1)), + Error::::Disabled + ); + assert_noop!( + DappStaking::set_dapp_reward_destination( + RuntimeOrigin::signed(1), + MockSmartContract::Wasm(1), + Some(2) + ), + Error::::Disabled + ); + assert_noop!( + DappStaking::set_dapp_owner(RuntimeOrigin::signed(1), MockSmartContract::Wasm(1), 2), + Error::::Disabled + ); + assert_noop!( + DappStaking::unregister(RuntimeOrigin::root(), MockSmartContract::Wasm(1)), + Error::::Disabled + ); + assert_noop!( + DappStaking::lock(RuntimeOrigin::signed(1), 100), + Error::::Disabled + ); + }) +} + +#[test] +fn register_is_ok() { + ExtBuilder::build().execute_with(|| { + // Basic test + assert_register(5, &MockSmartContract::Wasm(1)); + + // Register two contracts using the same owner + assert_register(7, &MockSmartContract::Wasm(2)); + assert_register(7, &MockSmartContract::Wasm(3)); + }) +} + +#[test] +fn register_with_incorrect_origin_fails() { + ExtBuilder::build().execute_with(|| { + assert_noop!( + DappStaking::register(RuntimeOrigin::signed(1), 3, MockSmartContract::Wasm(2)), + BadOrigin + ); + }) +} + +#[test] +fn register_already_registered_contract_fails() { + ExtBuilder::build().execute_with(|| { + let smart_contract = MockSmartContract::Wasm(1); + assert_register(2, &smart_contract); + assert_noop!( + DappStaking::register(RuntimeOrigin::root(), 2, smart_contract), + Error::::ContractAlreadyExists + ); + }) +} + +#[test] +fn register_past_max_number_of_contracts_fails() { + ExtBuilder::build().execute_with(|| { + let limit = ::MaxNumberOfContracts::get(); + for id in 1..=limit { + assert_register(1, &MockSmartContract::Wasm(id.into())); + } + + assert_noop!( + DappStaking::register( + RuntimeOrigin::root(), + 2, + MockSmartContract::Wasm((limit + 1).into()) + ), + Error::::ExceededMaxNumberOfContracts + ); + }) +} + +#[test] +fn register_past_sentinel_value_of_id_fails() { + ExtBuilder::build().execute_with(|| { + // hacky approach, but good enough for test + NextDAppId::::put(DAppId::MAX - 1); + + // First register should pass since sentinel value hasn't been reached yet + assert_register(1, &MockSmartContract::Wasm(3)); + + // Second one should fail since we've reached the sentine value and cannot add more contracts + assert_eq!(NextDAppId::::get(), DAppId::MAX); + assert_noop!( + DappStaking::register(RuntimeOrigin::root(), 1, MockSmartContract::Wasm(5)), + Error::::NewDAppIdUnavailable + ); + }) +} + +#[test] +fn set_dapp_reward_destination_for_contract_is_ok() { + ExtBuilder::build().execute_with(|| { + // Prepare & register smart contract + let owner = 1; + let smart_contract = MockSmartContract::Wasm(3); + assert_register(owner, &smart_contract); + + // Update beneficiary + assert!(IntegratedDApps::::get(&smart_contract) + .unwrap() + .reward_destination + .is_none()); + assert_set_dapp_reward_destination(owner, &smart_contract, Some(3)); + assert_set_dapp_reward_destination(owner, &smart_contract, Some(5)); + assert_set_dapp_reward_destination(owner, &smart_contract, None); + }) +} + +#[test] +fn set_dapp_reward_destination_fails() { + ExtBuilder::build().execute_with(|| { + let owner = 1; + let smart_contract = MockSmartContract::Wasm(3); + + // Contract doesn't exist yet + assert_noop!( + DappStaking::set_dapp_reward_destination( + RuntimeOrigin::signed(owner), + smart_contract, + Some(5) + ), + Error::::ContractNotFound + ); + + // Non-owner cannnot change reward destination + assert_register(owner, &smart_contract); + assert_noop!( + DappStaking::set_dapp_reward_destination( + RuntimeOrigin::signed(owner + 1), + smart_contract, + Some(5) + ), + Error::::OriginNotOwner + ); + }) +} + +#[test] +fn set_dapp_owner_is_ok() { + ExtBuilder::build().execute_with(|| { + // Prepare & register smart contract + let owner = 1; + let smart_contract = MockSmartContract::Wasm(3); + assert_register(owner, &smart_contract); + + // Update owner + let new_owner = 7; + assert_set_dapp_owner(Some(owner), &smart_contract, new_owner); + assert_set_dapp_owner(Some(new_owner), &smart_contract, 1337); + + // Ensure manager can bypass owner + assert_set_dapp_owner(None, &smart_contract, owner); + }) +} + +#[test] +fn set_dapp_owner_fails() { + ExtBuilder::build().execute_with(|| { + let owner = 1; + let smart_contract = MockSmartContract::Wasm(3); + + // Contract doesn't exist yet + assert_noop!( + DappStaking::set_dapp_owner(RuntimeOrigin::signed(owner), smart_contract, 5), + Error::::ContractNotFound + ); + + // Ensure non-owner cannot steal ownership + assert_register(owner, &smart_contract); + assert_noop!( + DappStaking::set_dapp_owner( + RuntimeOrigin::signed(owner + 1), + smart_contract, + owner + 1 + ), + Error::::OriginNotOwner + ); + }) +} + +#[test] +fn unregister_is_ok() { + ExtBuilder::build().execute_with(|| { + // Prepare dApp + let owner = 1; + let smart_contract = MockSmartContract::Wasm(3); + assert_register(owner, &smart_contract); + + assert_unregister(&smart_contract); + }) +} + +#[test] +fn unregister_fails() { + ExtBuilder::build().execute_with(|| { + let owner = 1; + let smart_contract = MockSmartContract::Wasm(3); + + // Cannot unregister contract which doesn't exist + assert_noop!( + DappStaking::unregister(RuntimeOrigin::root(), smart_contract), + Error::::ContractNotFound + ); + + // Cannot unregister with incorrect origin + assert_register(owner, &smart_contract); + assert_noop!( + DappStaking::unregister(RuntimeOrigin::signed(owner), smart_contract), + BadOrigin + ); + + // Cannot unregister same contract twice + assert_unregister(&smart_contract); + assert_noop!( + DappStaking::unregister(RuntimeOrigin::root(), smart_contract), + Error::::NotOperatedDApp + ); + }) +} + +#[test] +fn lock_is_ok() { + ExtBuilder::build().execute_with(|| { + // Lock some amount + let locker = 2; + let free_balance = Balances::free_balance(&locker); + assert!(free_balance > 500, "Sanity check"); + assert_lock(locker, 100); + assert_lock(locker, 200); + + // Attempt to lock more than is available + assert_lock(locker, free_balance - 200); + + // Ensure minimum lock amount works + let locker = 3; + assert_lock( + locker, + ::MinimumLockedAmount::get(), + ); + }) +} + +#[test] +fn lock_with_incorrect_amount_fails() { + ExtBuilder::build().execute_with(|| { + // Cannot lock "nothing" + assert_noop!( + DappStaking::lock(RuntimeOrigin::signed(1), Balance::zero()), + Error::::ZeroAmount, + ); + + // Attempting to lock something after everything has been locked is same + // as attempting to lock with "nothing" + let locker = 1; + assert_lock(locker, Balances::free_balance(&locker)); + assert_noop!( + DappStaking::lock(RuntimeOrigin::signed(locker), 1), + Error::::ZeroAmount, + ); + + // Locking just below the minimum amount should fail + let locker = 2; + let minimum_locked_amount: Balance = + ::MinimumLockedAmount::get(); + assert_noop!( + DappStaking::lock(RuntimeOrigin::signed(locker), minimum_locked_amount - 1), + Error::::LockedAmountBelowThreshold, + ); + }) +} + +#[test] +fn lock_with_too_many_chunks_fails() { + ExtBuilder::build().execute_with(|| { + let max_locked_chunks = ::MaxLockedChunks::get(); + let minimum_locked_amount: Balance = + ::MinimumLockedAmount::get(); + + // Fill up the locked chunks to the limit + let locker = 1; + assert_lock(locker, minimum_locked_amount); + for current_era in 1..max_locked_chunks { + advance_to_era(current_era + 1); + assert_lock(locker, 1); + } + + // Ensure we can still lock in the current era since number of chunks should not increase + for _ in 0..10 { + assert_lock(locker, 1); + } + + // Advance to the next era and ensure it's not possible to add additional chunks + advance_to_era(ActiveProtocolState::::get().era + 1); + assert_noop!( + DappStaking::lock(RuntimeOrigin::signed(locker), 1), + Error::::TooManyLockedBalanceChunks, + ); + }) +} diff --git a/frame/dapp-staking-v3/src/test/tests_types.rs b/frame/dapp-staking-v3/src/test/tests_types.rs new file mode 100644 index 00000000..1f2419d7 --- /dev/null +++ b/frame/dapp-staking-v3/src/test/tests_types.rs @@ -0,0 +1,105 @@ +// This file is part of Astar. + +// Copyright (C) 2019-2023 Stake Technologies Pte.Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +// Astar is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Astar is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Astar. If not, see . + +use crate::test::mock::*; +use crate::*; + +// Helper to generate custom `Get` types for testing the `AccountLedger` struct. +macro_rules! get_u32_type { + ($struct_name:ident, $value:expr) => { + struct $struct_name; + impl Get for $struct_name { + fn get() -> u32 { + $value + } + } + }; +} + +#[test] +fn protocol_state_default() { + let protoc_state = ProtocolState::::default(); + + assert_eq!(protoc_state.era, 0); + assert_eq!( + protoc_state.next_era_start, 1, + "Era should start immediately on the first block" + ); +} + +#[test] +fn account_ledger_default() { + get_u32_type!(LockedDummy, 5); + get_u32_type!(UnlockingDummy, 5); + let acc_ledger = AccountLedger::::default(); + + assert!(acc_ledger.is_empty()); + assert!(acc_ledger.locked_amount().is_zero()); + assert!(acc_ledger.era().is_zero()); + assert!(acc_ledger.latest_locked_chunk().is_none()); +} + +#[test] +fn account_ledger_add_lock_amount_works() { + get_u32_type!(LockedDummy, 5); + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = + AccountLedger::::default(); + + // First step, sanity checks + let first_era = 1; + assert!(acc_ledger.locked_amount().is_zero()); + assert!(acc_ledger.add_lock_amount(0, first_era).is_ok()); + assert!(acc_ledger.locked_amount().is_zero()); + + // Adding lock value works as expected + let init_amount = 20; + assert!(acc_ledger.add_lock_amount(init_amount, first_era).is_ok()); + assert_eq!(acc_ledger.locked_amount(), init_amount); + assert_eq!(acc_ledger.era(), first_era); + assert!(!acc_ledger.is_empty()); + assert_eq!( + acc_ledger.latest_locked_chunk(), + Some(&LockedChunk:: { + amount: init_amount, + era: first_era, + }) + ); + + // Add to the same era + let addition = 7; + assert!(acc_ledger.add_lock_amount(addition, first_era).is_ok()); + assert_eq!(acc_ledger.locked_amount(), init_amount + addition); + assert_eq!(acc_ledger.era(), first_era); + + // Add up to storage limit + for i in 2..=LockedDummy::get() { + assert!(acc_ledger.add_lock_amount(addition, first_era + i).is_ok()); + assert_eq!( + acc_ledger.locked_amount(), + init_amount + addition * i as u128 + ); + assert_eq!(acc_ledger.era(), first_era + i); + } + + // Any further additions should fail due to exhausting bounded storage capacity + assert!(acc_ledger + .add_lock_amount(addition, acc_ledger.era() + 1) + .is_err()); + assert!(!acc_ledger.is_empty()); +} diff --git a/frame/dapp-staking-v3/src/types.rs b/frame/dapp-staking-v3/src/types.rs new file mode 100644 index 00000000..958cd496 --- /dev/null +++ b/frame/dapp-staking-v3/src/types.rs @@ -0,0 +1,299 @@ +// This file is part of Astar. + +// Copyright (C) 2019-2023 Stake Technologies Pte.Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later + +// Astar is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Astar is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Astar. If not, see . + +use frame_support::{pallet_prelude::*, traits::Currency, BoundedVec}; +use frame_system::pallet_prelude::*; +use parity_scale_codec::{Decode, Encode}; +use sp_runtime::traits::{AtLeast32BitUnsigned, Zero}; + +use crate::pallet::Config; + +// TODO: instead of using `pub` visiblity for fields, either use `pub(crate)` or add dedicated methods for accessing them. + +/// The balance type used by the currency system. +pub type BalanceOf = + <::Currency as Currency<::AccountId>>::Balance; + +/// Convenience type for `AccountLedger` usage. +pub type AccountLedgerFor = AccountLedger< + BalanceOf, + BlockNumberFor, + ::MaxLockedChunks, + ::MaxUnlockingChunks, +>; + +/// Era number type +pub type EraNumber = u32; +/// Period number type +pub type PeriodNumber = u32; +/// Dapp Id type +pub type DAppId = u16; + +/// Distinct period types in dApp staking protocol. +#[derive(Encode, Decode, MaxEncodedLen, Clone, Copy, Debug, PartialEq, Eq, TypeInfo)] +pub enum PeriodType { + /// Period during which the focus is on voting. + /// Inner value is the era in which the voting period ends. + Voting(#[codec(compact)] EraNumber), + /// Period during which dApps and stakers earn rewards. + /// Inner value is the era in which the Build&Eearn period ends. + BuildAndEarn(#[codec(compact)] EraNumber), +} + +/// Force types to speed up the next era, and even period. +#[derive(Encode, Decode, MaxEncodedLen, Clone, Copy, Debug, PartialEq, Eq, TypeInfo)] +pub enum ForcingTypes { + /// Force the next era to start. + NewEra, + /// Force the current period phase to end, and new one to start + NewEraAndPeriodPhase, +} + +/// General information & state of the dApp staking protocol. +#[derive(Encode, Decode, MaxEncodedLen, Clone, Copy, Debug, PartialEq, Eq, TypeInfo)] +pub struct ProtocolState { + /// Ongoing era number. + #[codec(compact)] + pub era: EraNumber, + /// Block number at which the next era should start. + /// TODO: instead of abusing on-initialize and wasting block-space, + /// I believe we should utilize `pallet-scheduler` to schedule the next era. Make an item for this. + #[codec(compact)] + pub next_era_start: BlockNumber, + /// Ongoing period number. + #[codec(compact)] + pub period: PeriodNumber, + /// Ongoing period type and when is it expected to end. + pub period_type: PeriodType, + /// `true` if pallet is in maintenance mode (disabled), `false` otherwise. + /// TODO: provide some configurable barrier to handle this on the runtime level instead? Make an item for this? + pub maintenance: bool, +} + +impl Default for ProtocolState +where + BlockNumber: AtLeast32BitUnsigned + MaxEncodedLen, +{ + fn default() -> Self { + Self { + era: 0, + next_era_start: BlockNumber::from(1_u32), + period: 0, + period_type: PeriodType::Voting(0), + maintenance: false, + } + } +} + +/// dApp state in which some dApp is in. +#[derive(Encode, Decode, MaxEncodedLen, Clone, Copy, Debug, PartialEq, Eq, TypeInfo)] +pub enum DAppState { + /// dApp is registered and active. + Registered, + /// dApp has been unregistered in the contained era + Unregistered(#[codec(compact)] EraNumber), +} + +/// General information about dApp. +#[derive(Encode, Decode, MaxEncodedLen, Clone, Copy, Debug, PartialEq, Eq, TypeInfo)] +pub struct DAppInfo { + /// Owner of the dApp, default reward beneficiary. + pub owner: AccountId, + /// dApp's unique identifier in dApp staking. + #[codec(compact)] + pub id: DAppId, + /// Current state of the dApp. + pub state: DAppState, + // If `None`, rewards goes to the developer account, otherwise to the account Id in `Some`. + pub reward_destination: Option, +} + +/// How much was locked in a specific era +#[derive(Encode, Decode, MaxEncodedLen, Clone, Copy, Debug, PartialEq, Eq, TypeInfo)] +pub struct LockedChunk { + #[codec(compact)] + pub amount: Balance, + #[codec(compact)] + pub era: EraNumber, +} + +impl Default for LockedChunk +where + Balance: AtLeast32BitUnsigned + MaxEncodedLen + Copy, +{ + fn default() -> Self { + Self { + amount: Balance::zero(), + era: EraNumber::zero(), + } + } +} + +// TODO: would users get better UX if we kept using eras? Using blocks is more precise though. +/// How much was unlocked in some block. +#[derive(Encode, Decode, MaxEncodedLen, Clone, Copy, Debug, PartialEq, Eq, TypeInfo)] +pub struct UnlockingChunk< + Balance: AtLeast32BitUnsigned + MaxEncodedLen + Copy, + BlockNumber: AtLeast32BitUnsigned + MaxEncodedLen, +> { + #[codec(compact)] + pub amount: Balance, + #[codec(compact)] + pub unlock_block: BlockNumber, +} + +impl Default for UnlockingChunk +where + Balance: AtLeast32BitUnsigned + MaxEncodedLen + Copy, + BlockNumber: AtLeast32BitUnsigned + MaxEncodedLen, +{ + fn default() -> Self { + Self { + amount: Balance::zero(), + unlock_block: BlockNumber::zero(), + } + } +} + +/// General info about user's stakes +#[derive(Encode, Decode, MaxEncodedLen, Clone, Debug, PartialEq, Eq, TypeInfo)] +#[scale_info(skip_type_params(LockedLen, UnlockingLen))] +pub struct AccountLedger< + Balance: AtLeast32BitUnsigned + MaxEncodedLen + Copy, + BlockNumber: AtLeast32BitUnsigned + MaxEncodedLen, + LockedLen: Get, + UnlockingLen: Get, +> { + /// How much was staked in each era + pub locked: BoundedVec, LockedLen>, + /// How much started unlocking on a certain block + pub unlocking: BoundedVec, UnlockingLen>, + //TODO, make this a compact struct!!! + /// How much user had staked in some period + // #[codec(compact)] + pub staked: (Balance, PeriodNumber), +} + +impl Default + for AccountLedger +where + Balance: AtLeast32BitUnsigned + MaxEncodedLen + Copy, + BlockNumber: AtLeast32BitUnsigned + MaxEncodedLen, + LockedLen: Get, + UnlockingLen: Get, +{ + fn default() -> Self { + Self { + locked: BoundedVec::, LockedLen>::default(), + unlocking: BoundedVec::, UnlockingLen>::default(), + staked: (Balance::zero(), 0), + } + } +} + +impl + AccountLedger +where + Balance: AtLeast32BitUnsigned + MaxEncodedLen + Copy, + BlockNumber: AtLeast32BitUnsigned + MaxEncodedLen, + LockedLen: Get, + UnlockingLen: Get, +{ + /// Empty if no locked/unlocking/staked info exists. + pub fn is_empty(&self) -> bool { + self.locked.is_empty() && self.unlocking.is_empty() && self.staked.0.is_zero() + } + + /// Returns latest locked chunk if it exists, `None` otherwise + pub fn latest_locked_chunk(&self) -> Option<&LockedChunk> { + self.locked.last() + } + + /// Returns locked amount. + /// If `zero`, means that associated account hasn't locked any funds. + pub fn locked_amount(&self) -> Balance { + self.latest_locked_chunk() + .map_or(Balance::zero(), |locked| locked.amount) + } + + /// Returns latest era in which locked amount was updated or zero in case no lock amount exists + pub fn era(&self) -> EraNumber { + self.latest_locked_chunk() + .map_or(EraNumber::zero(), |locked| locked.era) + } + + /// Adds the specified amount to the total locked amount, if possible. + /// + /// If entry for the specified era already exists, it's updated. + /// + /// If entry for the specified era doesn't exist, it's created and insertion is attempted. + /// In case vector has no more capacity, error is returned, and whole operation is a noop. + pub fn add_lock_amount(&mut self, amount: Balance, era: EraNumber) -> Result<(), ()> { + if amount.is_zero() { + return Ok(()); + } + + let mut locked_chunk = if let Some(&locked_chunk) = self.locked.last() { + locked_chunk + } else { + LockedChunk::default() + }; + + locked_chunk.amount.saturating_accrue(amount); + + if locked_chunk.era == era && !self.locked.is_empty() { + if let Some(last) = self.locked.last_mut() { + *last = locked_chunk; + } + } else { + locked_chunk.era = era; + self.locked.try_push(locked_chunk).map_err(|_| ())?; + } + + Ok(()) + } +} + +/// Rewards pool for lock participants & dApps +#[derive(Encode, Decode, MaxEncodedLen, Clone, Debug, PartialEq, Eq, TypeInfo, Default)] +pub struct RewardInfo { + /// Rewards pool for accounts which have locked funds in dApp staking + #[codec(compact)] + pub participants: Balance, + /// Reward pool for dApps + #[codec(compact)] + pub dapps: Balance, +} + +/// Info about current era, including the rewards, how much is locked, unlocking, etc. +#[derive(Encode, Decode, MaxEncodedLen, Clone, Debug, PartialEq, Eq, TypeInfo, Default)] +pub struct EraInfo { + /// Info about era rewards + pub rewards: RewardInfo, + /// How much balance is considered to be locked in the current era. + /// This value influences the reward distribution. + #[codec(compact)] + pub active_era_locked: Balance, + /// How much balance is locked in dApp staking, in total. + /// For rewards, this amount isn't relevant for the current era, but only from the next one. + #[codec(compact)] + pub total_locked: Balance, + /// How much balance is undergoing unlocking process (still counts into locked amount) + #[codec(compact)] + pub unlocking: Balance, +}