diff --git a/Cargo.lock b/Cargo.lock index 574551777e..1c3e62ad02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2435,14 +2435,12 @@ dependencies = [ ] [[package]] -name = "dapps-staking-chain-extension-types" -version = "1.1.0" +name = "dapp-staking-v3-runtime-api" +version = "0.0.1-alpha" dependencies = [ - "frame-support", - "parity-scale-codec", - "scale-info", - "sp-core", - "sp-runtime", + "astar-primitives", + "pallet-dapp-staking-v3", + "sp-api", ] [[package]] @@ -4967,6 +4965,7 @@ dependencies = [ "pallet-balances", "pallet-contracts", "pallet-contracts-primitives", + "pallet-dapp-staking-v3", "pallet-dapps-staking", "pallet-ethereum-checked", "pallet-evm", @@ -6010,6 +6009,7 @@ version = "5.27.0" dependencies = [ "array-bytes 6.1.0", "astar-primitives", + "dapp-staking-v3-runtime-api", "fp-rpc", "fp-self-contained", "frame-benchmarking", @@ -6029,12 +6029,13 @@ dependencies = [ "pallet-balances", "pallet-block-rewards-hybrid", "pallet-chain-extension-assets", - "pallet-chain-extension-dapps-staking", "pallet-chain-extension-unified-accounts", "pallet-chain-extension-xvm", "pallet-collective", "pallet-contracts", "pallet-contracts-primitives", + "pallet-dapp-staking-migration", + "pallet-dapp-staking-v3", "pallet-dapps-staking", "pallet-democracy", "pallet-dynamic-evm-base-fee", @@ -6044,7 +6045,7 @@ dependencies = [ "pallet-evm-precompile-assets-erc20", "pallet-evm-precompile-blake2", "pallet-evm-precompile-bn128", - "pallet-evm-precompile-dapps-staking", + "pallet-evm-precompile-dapp-staking-v3", "pallet-evm-precompile-dispatch", "pallet-evm-precompile-ed25519", "pallet-evm-precompile-modexp", @@ -6055,6 +6056,7 @@ dependencies = [ "pallet-evm-precompile-unified-accounts", "pallet-evm-precompile-xvm", "pallet-grandpa", + "pallet-inflation", "pallet-insecure-randomness-collective-flip", "pallet-preimage", "pallet-proxy", @@ -6073,6 +6075,7 @@ dependencies = [ "scale-info", "smallvec 1.11.0", "sp-api", + "sp-arithmetic", "sp-block-builder", "sp-consensus-aura", "sp-core", @@ -7481,25 +7484,6 @@ dependencies = [ "sp-std", ] -[[package]] -name = "pallet-chain-extension-dapps-staking" -version = "1.1.0" -dependencies = [ - "dapps-staking-chain-extension-types", - "frame-support", - "frame-system", - "log", - "num-traits", - "pallet-contracts", - "pallet-contracts-primitives", - "pallet-dapps-staking", - "parity-scale-codec", - "scale-info", - "sp-core", - "sp-runtime", - "sp-std", -] - [[package]] name = "pallet-chain-extension-unified-accounts" version = "0.1.0" @@ -7633,20 +7617,6 @@ dependencies = [ "wasmparser-nostd", ] -[[package]] -name = "pallet-contracts-migration" -version = "1.0.0" -dependencies = [ - "frame-support", - "frame-system", - "pallet-contracts", - "parity-scale-codec", - "scale-info", - "sp-arithmetic", - "sp-runtime", - "sp-std", -] - [[package]] name = "pallet-contracts-primitives" version = "7.0.0" @@ -7687,6 +7657,48 @@ dependencies = [ "sp-std", ] +[[package]] +name = "pallet-dapp-staking-migration" +version = "1.0.0" +dependencies = [ + "astar-primitives", + "frame-benchmarking", + "frame-support", + "frame-system", + "pallet-balances", + "pallet-dapp-staking-v3", + "pallet-dapps-staking", + "parity-scale-codec", + "scale-info", + "sp-arithmetic", + "sp-core", + "sp-io", + "sp-runtime", + "sp-std", +] + +[[package]] +name = "pallet-dapp-staking-v3" +version = "0.0.1-alpha" +dependencies = [ + "assert_matches", + "astar-primitives", + "frame-benchmarking", + "frame-support", + "frame-system", + "log", + "num-traits", + "pallet-balances", + "parity-scale-codec", + "scale-info", + "serde", + "sp-arithmetic", + "sp-core", + "sp-io", + "sp-runtime", + "sp-std", +] + [[package]] name = "pallet-dapps-staking" version = "3.10.0" @@ -7927,6 +7939,34 @@ dependencies = [ "substrate-bn", ] +[[package]] +name = "pallet-evm-precompile-dapp-staking-v3" +version = "0.1.0" +dependencies = [ + "assert_matches", + "astar-primitives", + "derive_more", + "fp-evm", + "frame-support", + "frame-system", + "log", + "num_enum 0.5.11", + "pallet-balances", + "pallet-dapp-staking-v3", + "pallet-evm", + "pallet-timestamp", + "parity-scale-codec", + "precompile-utils", + "scale-info", + "serde", + "sha3", + "sp-arithmetic", + "sp-core", + "sp-io", + "sp-runtime", + "sp-std", +] + [[package]] name = "pallet-evm-precompile-dapps-staking" version = "3.6.3" @@ -8232,6 +8272,24 @@ dependencies = [ "sp-std", ] +[[package]] +name = "pallet-inflation" +version = "0.1.0" +dependencies = [ + "astar-primitives", + "frame-benchmarking", + "frame-support", + "frame-system", + "log", + "pallet-balances", + "parity-scale-codec", + "scale-info", + "serde", + "sp-core", + "sp-runtime", + "sp-std", +] + [[package]] name = "pallet-insecure-randomness-collective-flip" version = "4.0.0-dev" @@ -13090,6 +13148,7 @@ dependencies = [ "cumulus-primitives-core", "cumulus-primitives-timestamp", "cumulus-primitives-utility", + "dapp-staking-v3-runtime-api", "fp-rpc", "fp-self-contained", "frame-benchmarking", @@ -13111,15 +13170,15 @@ dependencies = [ "pallet-aura", "pallet-authorship", "pallet-balances", - "pallet-block-rewards-hybrid", "pallet-chain-extension-assets", - "pallet-chain-extension-dapps-staking", "pallet-chain-extension-unified-accounts", "pallet-chain-extension-xvm", "pallet-collator-selection", "pallet-collective", "pallet-contracts", "pallet-contracts-primitives", + "pallet-dapp-staking-migration", + "pallet-dapp-staking-v3", "pallet-dapps-staking", "pallet-democracy", "pallet-dynamic-evm-base-fee", @@ -13130,7 +13189,7 @@ dependencies = [ "pallet-evm-precompile-assets-erc20", "pallet-evm-precompile-blake2", "pallet-evm-precompile-bn128", - "pallet-evm-precompile-dapps-staking", + "pallet-evm-precompile-dapp-staking-v3", "pallet-evm-precompile-dispatch", "pallet-evm-precompile-ed25519", "pallet-evm-precompile-modexp", @@ -13142,6 +13201,7 @@ dependencies = [ "pallet-evm-precompile-xcm", "pallet-evm-precompile-xvm", "pallet-identity", + "pallet-inflation", "pallet-insecure-randomness-collective-flip", "pallet-multisig", "pallet-preimage", @@ -13168,6 +13228,7 @@ dependencies = [ "scale-info", "smallvec 1.11.0", "sp-api", + "sp-arithmetic", "sp-block-builder", "sp-consensus-aura", "sp-core", diff --git a/Cargo.toml b/Cargo.toml index 11a7574d51..2e5b80db4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,6 @@ members = [ "primitives", - "chain-extensions/dapps-staking", "chain-extensions/pallet-assets", "chain-extensions/xvm", "chain-extensions/unified-accounts", @@ -275,12 +274,17 @@ orml-xcm-support = { git = "https://github.com/open-web3-stack/open-runtime-modu pallet-block-rewards-hybrid = { path = "./pallets/block-rewards-hybrid", default-features = false } pallet-collator-selection = { path = "./pallets/collator-selection", default-features = false } pallet-dapps-staking = { path = "./pallets/dapps-staking", default-features = false } +pallet-dapp-staking-v3 = { path = "./pallets/dapp-staking-v3", default-features = false } +pallet-dapp-staking-migration = { path = "./pallets/dapp-staking-migration", default-features = false } pallet-xc-asset-config = { path = "./pallets/xc-asset-config", default-features = false } pallet-xvm = { path = "./pallets/xvm", default-features = false } pallet-ethereum-checked = { path = "./pallets/ethereum-checked", default-features = false } +pallet-inflation = { path = "./pallets/inflation", default-features = false } pallet-dynamic-evm-base-fee = { path = "./pallets/dynamic-evm-base-fee", default-features = false } pallet-unified-accounts = { path = "./pallets/unified-accounts", default-features = false } +dapp-staking-v3-runtime-api = { path = "./pallets/dapp-staking-v3/rpc/runtime-api", default-features = false } + astar-primitives = { path = "./primitives", default-features = false } astar-test-utils = { path = "./tests/utils", default-features = false } @@ -290,14 +294,13 @@ pallet-evm-precompile-substrate-ecdsa = { path = "./precompiles/substrate-ecdsa" pallet-evm-precompile-xcm = { path = "./precompiles/xcm", default-features = false } pallet-evm-precompile-xvm = { path = "./precompiles/xvm", default-features = false } pallet-evm-precompile-dapps-staking = { path = "./precompiles/dapps-staking", default-features = false } +pallet-evm-precompile-dapp-staking-v3 = { path = "./precompiles/dapp-staking-v3", default-features = false } pallet-evm-precompile-unified-accounts = { path = "./precompiles/unified-accounts", default-features = false } -pallet-chain-extension-dapps-staking = { path = "./chain-extensions/dapps-staking", default-features = false } pallet-chain-extension-xvm = { path = "./chain-extensions/xvm", default-features = false } pallet-chain-extension-assets = { path = "./chain-extensions/pallet-assets", default-features = false } pallet-chain-extension-unified-accounts = { path = "./chain-extensions/unified-accounts", default-features = false } -dapps-staking-chain-extension-types = { path = "./chain-extensions/types/dapps-staking", default-features = false } xvm-chain-extension-types = { path = "./chain-extensions/types/xvm", default-features = false } assets-chain-extension-types = { path = "./chain-extensions/types/assets", default-features = false } unified-accounts-chain-extension-types = { path = "./chain-extensions/types/unified-accounts", default-features = false } diff --git a/bin/collator/src/local/chain_spec.rs b/bin/collator/src/local/chain_spec.rs index d18ee5c097..8a510b6f67 100644 --- a/bin/collator/src/local/chain_spec.rs +++ b/bin/collator/src/local/chain_spec.rs @@ -20,15 +20,16 @@ use local_runtime::{ wasm_binary_unwrap, AccountId, AuraConfig, AuraId, BalancesConfig, BlockRewardConfig, - CouncilConfig, DemocracyConfig, EVMConfig, GenesisConfig, GrandpaConfig, GrandpaId, - Precompiles, RewardDistributionConfig, Signature, SudoConfig, SystemConfig, - TechnicalCommitteeConfig, TreasuryConfig, VestingConfig, + CouncilConfig, DappStakingConfig, DemocracyConfig, EVMConfig, GenesisConfig, GrandpaConfig, + GrandpaId, InflationConfig, InflationParameters, Precompiles, RewardDistributionConfig, + Signature, SudoConfig, SystemConfig, TechnicalCommitteeConfig, TierThreshold, TreasuryConfig, + VestingConfig, AST, }; use sc_service::ChainType; use sp_core::{crypto::Ss58Codec, sr25519, Pair, Public}; use sp_runtime::{ traits::{IdentifyAccount, Verify}, - Perbill, + Perbill, Permill, }; type AccountPublic = ::Signer; @@ -112,7 +113,7 @@ fn testnet_genesis( balances: endowed_accounts .iter() .cloned() - .map(|k| (k, 1_000_000_000_000_000_000_000_000_000)) + .map(|k| (k, 1_000_000_000 * AST)) .collect(), }, block_reward: BlockRewardConfig { @@ -177,6 +178,39 @@ fn testnet_genesis( }, democracy: DemocracyConfig::default(), treasury: TreasuryConfig::default(), + dapp_staking: DappStakingConfig { + reward_portion: vec![ + Permill::from_percent(40), + Permill::from_percent(30), + Permill::from_percent(20), + Permill::from_percent(10), + ], + slot_distribution: vec![ + Permill::from_percent(10), + Permill::from_percent(20), + Permill::from_percent(30), + Permill::from_percent(40), + ], + tier_thresholds: vec![ + TierThreshold::DynamicTvlAmount { + amount: 100 * AST, + minimum_amount: 80 * AST, + }, + TierThreshold::DynamicTvlAmount { + amount: 50 * AST, + minimum_amount: 40 * AST, + }, + TierThreshold::DynamicTvlAmount { + amount: 20 * AST, + minimum_amount: 20 * AST, + }, + TierThreshold::FixedTvlAmount { amount: 10 * AST }, + ], + slots_per_tier: vec![10, 20, 30, 40], + }, + inflation: InflationConfig { + params: InflationParameters::default(), + }, } } diff --git a/bin/collator/src/parachain/chain_spec/shibuya.rs b/bin/collator/src/parachain/chain_spec/shibuya.rs index ac4478afe1..200d324631 100644 --- a/bin/collator/src/parachain/chain_spec/shibuya.rs +++ b/bin/collator/src/parachain/chain_spec/shibuya.rs @@ -21,17 +21,17 @@ use cumulus_primitives_core::ParaId; use sc_service::ChainType; use shibuya_runtime::{ - wasm_binary_unwrap, AccountId, AuraConfig, AuraId, Balance, BalancesConfig, BlockRewardConfig, - CollatorSelectionConfig, CouncilConfig, DemocracyConfig, EVMChainIdConfig, EVMConfig, - GenesisConfig, ParachainInfoConfig, Precompiles, RewardDistributionConfig, SessionConfig, - SessionKeys, Signature, SudoConfig, SystemConfig, TechnicalCommitteeConfig, TreasuryConfig, - VestingConfig, SBY, + wasm_binary_unwrap, AccountId, AuraConfig, AuraId, Balance, BalancesConfig, + CollatorSelectionConfig, CouncilConfig, DappStakingConfig, DemocracyConfig, EVMChainIdConfig, + EVMConfig, GenesisConfig, InflationConfig, InflationParameters, ParachainInfoConfig, + Precompiles, SessionConfig, SessionKeys, Signature, SudoConfig, SystemConfig, + TechnicalCommitteeConfig, TierThreshold, TreasuryConfig, VestingConfig, SBY, }; use sp_core::{sr25519, Pair, Public}; use sp_runtime::{ traits::{IdentifyAccount, Verify}, - Perbill, + Permill, }; use super::{get_from_seed, Extensions}; @@ -114,17 +114,6 @@ fn make_genesis( }, parachain_info: ParachainInfoConfig { parachain_id }, balances: BalancesConfig { balances }, - block_reward: BlockRewardConfig { - // Make sure sum is 100 - reward_config: RewardDistributionConfig { - treasury_percent: Perbill::from_percent(10), - base_staker_percent: Perbill::from_percent(20), - dapps_percent: Perbill::from_percent(20), - collators_percent: Perbill::from_percent(5), - adjustable_percent: Perbill::from_percent(45), - ideal_dapps_staking_tvl: Perbill::from_percent(40), - }, - }, vesting: VestingConfig { vesting: vec![] }, session: SessionConfig { keys: authorities @@ -174,6 +163,41 @@ fn make_genesis( }, democracy: DemocracyConfig::default(), treasury: TreasuryConfig::default(), + dapp_staking: DappStakingConfig { + reward_portion: vec![ + Permill::from_percent(40), + Permill::from_percent(30), + Permill::from_percent(20), + Permill::from_percent(10), + ], + slot_distribution: vec![ + Permill::from_percent(10), + Permill::from_percent(20), + Permill::from_percent(30), + Permill::from_percent(40), + ], + // TODO: adjust this if needed + tier_thresholds: vec![ + TierThreshold::DynamicTvlAmount { + amount: 100 * SBY, + minimum_amount: 80 * SBY, + }, + TierThreshold::DynamicTvlAmount { + amount: 50 * SBY, + minimum_amount: 40 * SBY, + }, + TierThreshold::DynamicTvlAmount { + amount: 20 * SBY, + minimum_amount: 20 * SBY, + }, + TierThreshold::FixedTvlAmount { amount: 10 * SBY }, + ], + slots_per_tier: vec![10, 20, 30, 40], + }, + // TODO: adjust this if needed + inflation: InflationConfig { + params: InflationParameters::default(), + }, } } diff --git a/chain-extensions/dapps-staking/Cargo.toml b/chain-extensions/dapps-staking/Cargo.toml deleted file mode 100644 index 4169a7bd00..0000000000 --- a/chain-extensions/dapps-staking/Cargo.toml +++ /dev/null @@ -1,43 +0,0 @@ -[package] -name = "pallet-chain-extension-dapps-staking" -version = "1.1.0" -license = "Apache-2.0" -description = "dApps Staking chain extension for WASM contracts" -authors.workspace = true -edition.workspace = true -homepage.workspace = true -repository.workspace = true - -[dependencies] -frame-support = { workspace = true } -frame-system = { workspace = true } -log = { workspace = true } -num-traits = { workspace = true } -pallet-contracts = { workspace = true } -pallet-contracts-primitives = { workspace = true } -parity-scale-codec = { workspace = true } -scale-info = { workspace = true } -sp-core = { workspace = true } -sp-runtime = { workspace = true } -sp-std = { workspace = true } - -# Astar -dapps-staking-chain-extension-types = { workspace = true } -pallet-dapps-staking = { workspace = true } - -[features] -default = ["std"] -std = [ - "parity-scale-codec/std", - "dapps-staking-chain-extension-types/std", - "frame-support/std", - "frame-system/std", - "num-traits/std", - "pallet-contracts/std", - "pallet-contracts-primitives/std", - "pallet-dapps-staking/std", - "scale-info/std", - "sp-std/std", - "sp-core/std", - "sp-runtime/std", -] diff --git a/chain-extensions/dapps-staking/src/lib.rs b/chain-extensions/dapps-staking/src/lib.rs deleted file mode 100644 index 44910e3b0d..0000000000 --- a/chain-extensions/dapps-staking/src/lib.rs +++ /dev/null @@ -1,366 +0,0 @@ -// 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 . - -#![cfg_attr(not(feature = "std"), no_std)] -use sp_runtime::{traits::Zero, DispatchError}; - -use dapps_staking_chain_extension_types::{ - DSError, DappsStakingAccountInput, DappsStakingEraInput, DappsStakingNominationInput, - DappsStakingValueInput, -}; -use frame_support::traits::{Currency, Get}; -use frame_system::RawOrigin; -use pallet_contracts::chain_extension::{ - ChainExtension, Environment, Ext, InitState, RetVal, SysConfig, -}; -use pallet_dapps_staking::{RewardDestination, WeightInfo}; -use parity_scale_codec::Encode; -use sp_std::marker::PhantomData; - -type BalanceOf = <::Currency as Currency< - ::AccountId, ->>::Balance; - -enum DappsStakingFunc { - CurrentEra, - UnbondingPeriod, - EraRewards, - EraStaked, - StakedAmount, - StakedAmountOnContract, - ReadContractStake, - BondAndStake, - UnbondAndUnstake, - WithdrawUnbonded, - ClaimStaker, - ClaimDapp, - SetRewardDestination, - NominationTransfer, -} - -impl TryFrom for DappsStakingFunc { - type Error = DispatchError; - - fn try_from(value: u16) -> Result { - match value { - 1 => Ok(DappsStakingFunc::CurrentEra), - 2 => Ok(DappsStakingFunc::UnbondingPeriod), - 3 => Ok(DappsStakingFunc::EraRewards), - 4 => Ok(DappsStakingFunc::EraStaked), - 5 => Ok(DappsStakingFunc::StakedAmount), - 6 => Ok(DappsStakingFunc::StakedAmountOnContract), - 7 => Ok(DappsStakingFunc::ReadContractStake), - 8 => Ok(DappsStakingFunc::BondAndStake), - 9 => Ok(DappsStakingFunc::UnbondAndUnstake), - 10 => Ok(DappsStakingFunc::WithdrawUnbonded), - 11 => Ok(DappsStakingFunc::ClaimStaker), - 12 => Ok(DappsStakingFunc::ClaimDapp), - 13 => Ok(DappsStakingFunc::SetRewardDestination), - 14 => Ok(DappsStakingFunc::NominationTransfer), - _ => Err(DispatchError::Other( - "DappsStakingExtension: Unimplemented func_id", - )), - } - } -} - -/// Dapps Staking chain extension. -pub struct DappsStakingExtension(PhantomData); - -impl Default for DappsStakingExtension { - fn default() -> Self { - DappsStakingExtension(PhantomData) - } -} - -impl ChainExtension for DappsStakingExtension -where - T: pallet_dapps_staking::Config + pallet_contracts::Config, - ::SmartContract: From<[u8; 32]>, - ::AccountId: From<[u8; 32]>, -{ - fn call(&mut self, env: Environment) -> Result - where - E: Ext, - { - let func_id = env.func_id().try_into()?; - let mut env = env.buf_in_buf_out(); - - match func_id { - DappsStakingFunc::CurrentEra => { - let base_weight = ::DbWeight::get().reads(1); - env.charge_weight(base_weight)?; - - let era_index = pallet_dapps_staking::CurrentEra::::get(); - env.write(&era_index.encode(), false, None)?; - } - - DappsStakingFunc::UnbondingPeriod => { - let base_weight = ::DbWeight::get().reads(1); - env.charge_weight(base_weight)?; - - let unbonding_period = T::UnbondingPeriod::get(); - env.write(&unbonding_period.encode(), false, None)?; - } - - DappsStakingFunc::EraRewards => { - let arg: u32 = env.read_as()?; - - let base_weight = ::DbWeight::get().reads(1); - env.charge_weight(base_weight)?; - - let era_info = pallet_dapps_staking::GeneralEraInfo::::get(arg); - let reward = era_info.map_or(Zero::zero(), |r| { - r.rewards.stakers.saturating_add(r.rewards.dapps) - }); - env.write(&reward.encode(), false, None)?; - } - - DappsStakingFunc::EraStaked => { - let arg: u32 = env.read_as()?; - - let base_weight = ::DbWeight::get().reads(1); - env.charge_weight(base_weight)?; - - let era_info = pallet_dapps_staking::GeneralEraInfo::::get(arg); - let staked_amount = era_info.map_or(Zero::zero(), |r| r.staked); - env.write(&staked_amount.encode(), false, None)?; - } - - DappsStakingFunc::StakedAmount => { - let staker: T::AccountId = env.read_as()?; - - let base_weight = ::DbWeight::get().reads(1); - env.charge_weight(base_weight)?; - - let ledger = pallet_dapps_staking::Ledger::::get(&staker); - env.write(&ledger.locked.encode(), false, None)?; - } - - DappsStakingFunc::StakedAmountOnContract => { - let args: DappsStakingAccountInput = env.read_as()?; - let staker: T::AccountId = args.staker.into(); - let contract: ::SmartContract = - args.contract.into(); - - let base_weight = ::DbWeight::get().reads(1); - env.charge_weight(base_weight)?; - - let staking_info = - pallet_dapps_staking::GeneralStakerInfo::::get(&staker, &contract); - let staked_amount = staking_info.latest_staked_value(); - env.write(&staked_amount.encode(), false, None)?; - } - - DappsStakingFunc::ReadContractStake => { - let contract_bytes: [u8; 32] = env.read_as()?; - let contract: ::SmartContract = - contract_bytes.into(); - - let base_weight = ::DbWeight::get().reads(1); - env.charge_weight(base_weight.saturating_add(base_weight))?; - - let current_era = pallet_dapps_staking::CurrentEra::::get(); - let staking_info = - pallet_dapps_staking::Pallet::::contract_stake_info(&contract, current_era) - .unwrap_or_default(); - let total = TryInto::::try_into(staking_info.total).unwrap_or(0); - env.write(&total.encode(), false, None)?; - } - - DappsStakingFunc::BondAndStake => { - let args: DappsStakingValueInput> = env.read_as()?; - let contract = args.contract.into(); - let value: BalanceOf = args.value; - - let base_weight = ::WeightInfo::bond_and_stake(); - env.charge_weight(base_weight)?; - - let caller = env.ext().address().clone(); - let call_result = pallet_dapps_staking::Pallet::::bond_and_stake( - RawOrigin::Signed(caller).into(), - contract, - value, - ); - return match call_result { - Err(e) => { - let mapped_error = DSError::try_from(e.error)?; - Ok(RetVal::Converging(mapped_error as u32)) - } - Ok(_) => Ok(RetVal::Converging(DSError::Success as u32)), - }; - } - - DappsStakingFunc::UnbondAndUnstake => { - let args: DappsStakingValueInput> = env.read_as()?; - let contract = args.contract.into(); - let value: BalanceOf = args.value; - - let base_weight = - ::WeightInfo::unbond_and_unstake(); - env.charge_weight(base_weight)?; - - let caller = env.ext().address().clone(); - let call_result = pallet_dapps_staking::Pallet::::unbond_and_unstake( - RawOrigin::Signed(caller).into(), - contract, - value, - ); - return match call_result { - Err(e) => { - let mapped_error = DSError::try_from(e.error)?; - Ok(RetVal::Converging(mapped_error as u32)) - } - Ok(_) => Ok(RetVal::Converging(DSError::Success as u32)), - }; - } - - DappsStakingFunc::WithdrawUnbonded => { - let caller = env.ext().address().clone(); - - let base_weight = - ::WeightInfo::withdraw_unbonded(); - env.charge_weight(base_weight)?; - - let call_result = pallet_dapps_staking::Pallet::::withdraw_unbonded( - RawOrigin::Signed(caller).into(), - ); - return match call_result { - Err(e) => { - let mapped_error = DSError::try_from(e.error)?; - Ok(RetVal::Converging(mapped_error as u32)) - } - Ok(_) => Ok(RetVal::Converging(DSError::Success as u32)), - }; - } - - DappsStakingFunc::ClaimStaker => { - let contract_bytes: [u8; 32] = env.read_as()?; - let contract = contract_bytes.into(); - - let base_weight = ::WeightInfo::claim_staker_with_restake() - .max(::WeightInfo::claim_staker_without_restake()); - let charged_weight = env.charge_weight(base_weight)?; - - let caller = env.ext().address().clone(); - let call_result = pallet_dapps_staking::Pallet::::claim_staker( - RawOrigin::Signed(caller).into(), - contract, - ); - - let actual_weight = match call_result { - Ok(e) => e.actual_weight, - Err(e) => e.post_info.actual_weight, - }; - if let Some(actual_weight) = actual_weight { - env.adjust_weight(charged_weight, actual_weight); - } - - return match call_result { - Err(e) => { - let mapped_error = DSError::try_from(e.error)?; - Ok(RetVal::Converging(mapped_error as u32)) - } - Ok(_) => Ok(RetVal::Converging(DSError::Success as u32)), - }; - } - - DappsStakingFunc::ClaimDapp => { - let args: DappsStakingEraInput = env.read_as()?; - let contract = args.contract.into(); - let era: u32 = args.era; - - let base_weight = ::WeightInfo::claim_dapp(); - env.charge_weight(base_weight)?; - - let caller = env.ext().address().clone(); - let call_result = pallet_dapps_staking::Pallet::::claim_dapp( - RawOrigin::Signed(caller).into(), - contract, - era, - ); - return match call_result { - Err(e) => { - let mapped_error = DSError::try_from(e.error)?; - Ok(RetVal::Converging(mapped_error as u32)) - } - Ok(_) => Ok(RetVal::Converging(DSError::Success as u32)), - }; - } - - DappsStakingFunc::SetRewardDestination => { - let reward_destination_raw: u8 = env.read_as()?; - - let base_weight = - ::WeightInfo::set_reward_destination(); - env.charge_weight(base_weight)?; - - // Transform raw value into dapps staking enum - let reward_destination = if reward_destination_raw == 0 { - RewardDestination::FreeBalance - } else if reward_destination_raw == 1 { - RewardDestination::StakeBalance - } else { - let error = DSError::RewardDestinationValueOutOfBounds; - return Ok(RetVal::Converging(error as u32)); - }; - - let caller = env.ext().address().clone(); - let call_result = pallet_dapps_staking::Pallet::::set_reward_destination( - RawOrigin::Signed(caller).into(), - reward_destination, - ); - return match call_result { - Err(e) => { - let mapped_error = DSError::try_from(e.error)?; - Ok(RetVal::Converging(mapped_error as u32)) - } - Ok(_) => Ok(RetVal::Converging(DSError::Success as u32)), - }; - } - - DappsStakingFunc::NominationTransfer => { - let args: DappsStakingNominationInput> = env.read_as()?; - let origin_smart_contract = args.origin_contract.into(); - let target_smart_contract = args.target_contract.into(); - let value: BalanceOf = args.value; - - let base_weight = - ::WeightInfo::nomination_transfer(); - env.charge_weight(base_weight)?; - - let caller = env.ext().address().clone(); - let call_result = pallet_dapps_staking::Pallet::::nomination_transfer( - RawOrigin::Signed(caller).into(), - origin_smart_contract, - value, - target_smart_contract, - ); - return match call_result { - Err(e) => { - let mapped_error = DSError::try_from(e.error)?; - Ok(RetVal::Converging(mapped_error as u32)) - } - Ok(_) => Ok(RetVal::Converging(DSError::Success as u32)), - }; - } - } - - Ok(RetVal::Converging(DSError::Success as u32)) - } -} diff --git a/chain-extensions/types/dapps-staking/Cargo.toml b/chain-extensions/types/dapps-staking/Cargo.toml deleted file mode 100644 index fcd45a0034..0000000000 --- a/chain-extensions/types/dapps-staking/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -name = "dapps-staking-chain-extension-types" -version = "1.1.0" -license = "Apache-2.0" -description = "Types definitions for dapps-staking chain-extension" -authors.workspace = true -edition.workspace = true -homepage.workspace = true -repository.workspace = true - -[dependencies] -frame-support = { workspace = true } -parity-scale-codec = { workspace = true } -scale-info = { workspace = true } -sp-core = { workspace = true } -sp-runtime = { workspace = true } - -[features] -default = ["std"] -std = [ - "parity-scale-codec/std", - "frame-support/std", - "scale-info/std", - "sp-core/std", - "sp-runtime/std", -] diff --git a/chain-extensions/types/dapps-staking/src/lib.rs b/chain-extensions/types/dapps-staking/src/lib.rs deleted file mode 100644 index 585a15213c..0000000000 --- a/chain-extensions/types/dapps-staking/src/lib.rs +++ /dev/null @@ -1,154 +0,0 @@ -// 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 . - -#![cfg_attr(not(feature = "std"), no_std)] -use frame_support::pallet_prelude::MaxEncodedLen; -use parity_scale_codec::{Decode, Encode}; -use sp_runtime::{DispatchError, ModuleError}; - -#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] -#[derive(PartialEq, Eq, Copy, Clone, Encode, Decode, Debug)] -pub enum DSError { - /// Success - Success = 0, - /// Disabled - Disabled = 1, - /// No change in maintenance mode - NoMaintenanceModeChange = 2, - /// Upgrade is too heavy, reduce the weight parameter. - UpgradeTooHeavy = 3, - /// Can not stake with zero value. - StakingWithNoValue = 4, - /// Can not stake with value less than minimum staking value - InsufficientValue = 5, - /// Number of stakers per contract exceeded. - MaxNumberOfStakersExceeded = 6, - /// Targets must be operated contracts - NotOperatedContract = 7, - /// Contract isn't staked. - NotStakedContract = 8, - /// Contract isn't unregistered. - NotUnregisteredContract = 9, - /// Unclaimed rewards should be claimed before withdrawing stake. - UnclaimedRewardsRemaining = 10, - /// Unstaking a contract with zero value - UnstakingWithNoValue = 11, - /// There are no previously unbonded funds that can be unstaked and withdrawn. - NothingToWithdraw = 12, - /// The contract is already registered by other account - AlreadyRegisteredContract = 13, - /// User attempts to register with address which is not contract - ContractIsNotValid = 14, - /// This account was already used to register contract - AlreadyUsedDeveloperAccount = 15, - /// Smart contract not owned by the account id. - NotOwnedContract = 16, - /// Report issue on github if this is ever emitted - UnknownEraReward = 17, - /// Report issue on github if this is ever emitted - UnexpectedStakeInfoEra = 18, - /// Contract has too many unlocking chunks. Withdraw the existing chunks if possible - /// or wait for current chunks to complete unlocking process to withdraw them. - TooManyUnlockingChunks = 19, - /// Contract already claimed in this era and reward is distributed - AlreadyClaimedInThisEra = 20, - /// Era parameter is out of bounds - EraOutOfBounds = 21, - /// Too many active `EraStake` values for (staker, contract) pairing. - /// Claim existing rewards to fix this problem. - TooManyEraStakeValues = 22, - /// To register a contract, pre-approval is needed for this address - RequiredContractPreApproval = 23, - /// Developer's account is already part of pre-approved list - AlreadyPreApprovedDeveloper = 24, - /// Account is not actively staking - NotActiveStaker = 25, - /// Transfering nomination to the same contract - NominationTransferToSameContract = 26, - /// Unexpected reward destination value - RewardDestinationValueOutOfBounds = 27, - /// Unknown error - UnknownError = 99, -} - -impl TryFrom for DSError { - type Error = DispatchError; - - fn try_from(input: DispatchError) -> Result { - let error_text = match input { - DispatchError::Module(ModuleError { message, .. }) => message, - _ => Some("No module error Info"), - }; - return match error_text { - Some("Disabled") => Ok(DSError::Disabled), - Some("NoMaintenanceModeChange") => Ok(DSError::NoMaintenanceModeChange), - Some("UpgradeTooHeavy") => Ok(DSError::UpgradeTooHeavy), - Some("StakingWithNoValue") => Ok(DSError::StakingWithNoValue), - Some("InsufficientValue") => Ok(DSError::InsufficientValue), - Some("MaxNumberOfStakersExceeded") => Ok(DSError::MaxNumberOfStakersExceeded), - Some("NotOperatedContract") => Ok(DSError::NotOperatedContract), - Some("NotStakedContract") => Ok(DSError::NotStakedContract), - Some("NotUnregisteredContract") => Ok(DSError::NotUnregisteredContract), - Some("UnclaimedRewardsRemaining") => Ok(DSError::UnclaimedRewardsRemaining), - Some("UnstakingWithNoValue") => Ok(DSError::UnstakingWithNoValue), - Some("NothingToWithdraw") => Ok(DSError::NothingToWithdraw), - Some("AlreadyRegisteredContract") => Ok(DSError::AlreadyRegisteredContract), - Some("ContractIsNotValid") => Ok(DSError::ContractIsNotValid), - Some("AlreadyUsedDeveloperAccount") => Ok(DSError::AlreadyUsedDeveloperAccount), - Some("NotOwnedContract") => Ok(DSError::NotOwnedContract), - Some("UnknownEraReward") => Ok(DSError::UnknownEraReward), - Some("UnexpectedStakeInfoEra") => Ok(DSError::UnexpectedStakeInfoEra), - Some("TooManyUnlockingChunks") => Ok(DSError::TooManyUnlockingChunks), - Some("AlreadyClaimedInThisEra") => Ok(DSError::AlreadyClaimedInThisEra), - Some("EraOutOfBounds") => Ok(DSError::EraOutOfBounds), - Some("TooManyEraStakeValues") => Ok(DSError::TooManyEraStakeValues), - Some("RequiredContractPreApproval") => Ok(DSError::RequiredContractPreApproval), - Some("AlreadyPreApprovedDeveloper") => Ok(DSError::AlreadyPreApprovedDeveloper), - Some("NotActiveStaker") => Ok(DSError::NotActiveStaker), - Some("NominationTransferToSameContract") => { - Ok(DSError::NominationTransferToSameContract) - } - _ => Ok(DSError::UnknownError), - }; - } -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, Encode, Decode, MaxEncodedLen)] -pub struct DappsStakingValueInput { - pub contract: [u8; 32], - pub value: Balance, -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, Encode, Decode, MaxEncodedLen)] -pub struct DappsStakingAccountInput { - pub contract: [u8; 32], - pub staker: [u8; 32], -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, Encode, Decode, MaxEncodedLen)] -pub struct DappsStakingEraInput { - pub contract: [u8; 32], - pub era: u32, -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, Encode, Decode, MaxEncodedLen)] -pub struct DappsStakingNominationInput { - pub origin_contract: [u8; 32], - pub target_contract: [u8; 32], - pub value: Balance, -} diff --git a/pallets/contracts-migration/Cargo.toml b/pallets/contracts-migration/Cargo.toml deleted file mode 100644 index e0959269df..0000000000 --- a/pallets/contracts-migration/Cargo.toml +++ /dev/null @@ -1,31 +0,0 @@ -[package] -name = "pallet-contracts-migration" -version = "1.0.0" -license = "Apache-2.0" -description = "FRAME pallet for managing multi-block pallet contracts storage migration" -authors.workspace = true -edition.workspace = true -homepage.workspace = true -repository.workspace = true - -[dependencies] -frame-support = { workspace = true } -frame-system = { workspace = true } -pallet-contracts = { workspace = true } -parity-scale-codec = { workspace = true } -scale-info = { workspace = true } -sp-arithmetic = { workspace = true } -sp-runtime = { workspace = true } -sp-std = { workspace = true } - -[features] -default = ["std"] -std = [ - "parity-scale-codec/std", - "scale-info/std", - "sp-std/std", - "frame-support/std", - "frame-system/std", - "pallet-contracts/std", -] -try-runtime = ["frame-support/try-runtime"] diff --git a/pallets/contracts-migration/src/lib.rs b/pallets/contracts-migration/src/lib.rs deleted file mode 100644 index 70873db7fd..0000000000 --- a/pallets/contracts-migration/src/lib.rs +++ /dev/null @@ -1,318 +0,0 @@ -// 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 . - -#![cfg_attr(not(feature = "std"), no_std)] - -/// Purpose of this pallet is to provide multi-stage migration features for pallet-contracts v9 migration. -/// Once it's finished for both `Shibuya` and `Shiden`, it should be deleted. -pub use pallet::*; - -use frame_support::{ - log, - pallet_prelude::*, - storage::{generator::StorageMap, unhashed}, - storage_alias, - traits::Get, - WeakBoundedVec, -}; - -use frame_system::pallet_prelude::*; -use pallet_contracts::Determinism; -use parity_scale_codec::{Decode, Encode, FullCodec}; -use sp_runtime::Saturating; -#[cfg(feature = "try-runtime")] -use sp_std::vec::Vec; - -pub use crate::pallet::CustomMigration; - -const LOG_TARGET: &str = "pallet-contracts-migration"; - -#[frame_support::pallet] -pub mod pallet { - use super::*; - - #[pallet::pallet] - pub struct Pallet(PhantomData); - - #[pallet::config] - pub trait Config: frame_system::Config + pallet_contracts::Config { - /// The overarching event type. - type RuntimeEvent: From> + IsType<::RuntimeEvent>; - } - - #[pallet::storage] - #[pallet::getter(fn migration_state)] - pub type MigrationStateStorage = StorageValue<_, MigrationState, ValueQuery>; - - #[pallet::event] - #[pallet::generate_deposit(pub(crate) fn deposit_event)] - pub enum Event { - /// Number of contracts that were migrated in the migration call - ContractsMigrated(u32), - } - - // The following structs & types were taken from `pallet-contracts` since they aren't exposed outside of the `pallet-contracts` crate. - - #[storage_alias] - type CodeStorage = - StorageMap, Identity, CodeHash, PrefabWasmModule>; - - type CodeHash = ::Hash; - type RelaxedCodeVec = WeakBoundedVec::MaxCodeLen>; - - #[derive(Encode, Decode, RuntimeDebug, MaxEncodedLen)] - pub struct OldPrefabWasmModule { - #[codec(compact)] - pub instruction_weights_version: u32, - #[codec(compact)] - pub initial: u32, - #[codec(compact)] - pub maximum: u32, - pub code: RelaxedCodeVec, - } - - #[derive(Encode, Decode, RuntimeDebug, MaxEncodedLen)] - pub struct PrefabWasmModule { - #[codec(compact)] - pub instruction_weights_version: u32, - #[codec(compact)] - pub initial: u32, - #[codec(compact)] - pub maximum: u32, - pub code: RelaxedCodeVec, - pub determinism: Determinism, - } - - #[pallet::hooks] - impl Hooks> for Pallet { - fn on_initialize(_now: BlockNumberFor) -> Weight { - // This is done in order to account for the read in call filter - >::on_chain_storage_version(); - T::DbWeight::get().reads(1) - } - } - - #[pallet::call] - impl Pallet { - #[pallet::call_index(0)] - #[pallet::weight({ - let max_allowed_call_weight = Pallet::::max_call_weight(); - weight_limit - .unwrap_or(max_allowed_call_weight) - .min(max_allowed_call_weight) - })] - pub fn migrate( - origin: OriginFor, - weight_limit: Option, - ) -> DispatchResultWithPostInfo { - ensure_signed(origin)?; - - let consumed_weight = Self::do_migrate(weight_limit); - - Ok(Some(consumed_weight).into()) - } - } - - impl Pallet { - fn do_migrate(requested_weight_limit: Option) -> Weight { - let version = >::on_chain_storage_version(); - let mut consumed_weight = T::DbWeight::get().reads(1); - - if version != 8 { - log::trace!( - target: LOG_TARGET, - "Version is {:?} so skipping migration procedures.", - version, - ); - Self::deposit_event(Event::::ContractsMigrated(0)); - return consumed_weight; - } - - let max_allowed_call_weight = Self::max_call_weight(); - let weight_limit = requested_weight_limit - .unwrap_or(max_allowed_call_weight) - .min(max_allowed_call_weight); - log::trace!( - target: LOG_TARGET, - "CodeStorage migration weight limit will be {:?}.", - weight_limit, - ); - - let migration_state = MigrationStateStorage::::get().for_iteration(); - - if let MigrationState::CodeStorage(last_processed_key) = migration_state { - // First, get correct iterator. - let key_iter = if let Some(previous_key) = last_processed_key { - CodeStorage::::iter_keys_from(previous_key.into_inner()) - } else { - CodeStorage::::iter_keys() - }; - - let mut counter = 0_u32; - - for key in key_iter { - let key_as_vec = CodeStorage::::storage_map_final_key(key); - let used_weight = - Self::translate(&key_as_vec, |old: OldPrefabWasmModule| { - Some(PrefabWasmModule:: { - instruction_weights_version: old.instruction_weights_version, - initial: old.initial, - maximum: old.maximum, - code: old.code, - determinism: Determinism::Enforced, - }) - }); - - // Increment total consumed weight. - consumed_weight.saturating_accrue(used_weight); - counter += 1; - - // Check if we've consumed enough weight already. - if consumed_weight.any_gt(weight_limit) { - log::trace!( - target: LOG_TARGET, - "CodeStorage migration stopped after consuming {:?} weight and after processing {:?} DB entries.", - consumed_weight, counter, - ); - MigrationStateStorage::::put(MigrationState::CodeStorage(Some( - WeakBoundedVec::force_from(key_as_vec, None), - ))); - consumed_weight.saturating_accrue(T::DbWeight::get().writes(1)); - - Self::deposit_event(Event::::ContractsMigrated(counter)); - - // we want try-runtime to execute the entire migration - if cfg!(feature = "try-runtime") { - return Self::do_migrate(Some(weight_limit)) - .saturating_add(consumed_weight); - } else { - return consumed_weight; - } - } - } - - log::trace!(target: LOG_TARGET, "CodeStorage migration finished.",); - Self::deposit_event(Event::::ContractsMigrated(counter)); - - // Clean up storage value so we can safely remove the pallet later - MigrationStateStorage::::kill(); - StorageVersion::new(9).put::>(); - consumed_weight.saturating_accrue(T::DbWeight::get().writes(2)); - } - - consumed_weight - } - - /// Max allowed weight that migration should be allowed to consume - fn max_call_weight() -> Weight { - // 50% of block should be fine - T::BlockWeights::get().max_block / 2 - } - - /// Used to translate a single value in the DB - /// Returns conservative weight estimate of the operation - fn translate Option>( - key: &[u8], - mut f: F, - ) -> Weight { - let value = match unhashed::get::(key) { - Some(value) => value, - None => { - return Weight::from_parts( - T::DbWeight::get().reads(1).ref_time(), - OldPrefabWasmModule::::max_encoded_len() as u64, - ); - } - }; - - let mut proof_size = value.using_encoded(|o| o.len() as u64); - - match f(value) { - Some(new) => { - proof_size.saturating_accrue(new.using_encoded(|n| n.len() as u64)); - unhashed::put::(key, &new); - } - // Cannot happen in this file - None => unhashed::kill(key), - } - - Weight::from_parts(T::DbWeight::get().reads_writes(1, 1).ref_time(), proof_size) - } - } - - #[derive(PartialEq, Eq, Clone, Encode, Decode, TypeInfo, RuntimeDebug, MaxEncodedLen)] - pub enum MigrationState { - /// No migration in progress - NotInProgress, - /// In the middle of `CodeStorage` migration. The const for max size is an overestimate but that's fine. - CodeStorage(Option>>), - } - - impl MigrationState { - /// Convert `self` into value applicable for iteration - fn for_iteration(self) -> Self { - if self == Self::NotInProgress { - Self::CodeStorage(None) - } else { - self - } - } - } - - impl Default for MigrationState { - fn default() -> Self { - MigrationState::NotInProgress - } - } - - pub struct CustomMigration(PhantomData); - impl frame_support::traits::OnRuntimeUpgrade for CustomMigration { - fn on_runtime_upgrade() -> Weight { - // Ensures that first step only starts the migration with minimal changes in case of production build. - // In case of `try-runtime`, we want predefined limit. - let limit = if cfg!(feature = "try-runtime") { - None - } else { - Some(Weight::zero()) - }; - Pallet::::do_migrate(limit) - } - - #[cfg(feature = "try-runtime")] - fn post_upgrade(_state: Vec) -> Result<(), sp_runtime::TryRuntimeError> { - for value in CodeStorage::::iter_values() { - ensure!( - value.determinism == Determinism::Enforced, - "All pre-existing codes need to be deterministic." - ); - } - - ensure!( - !MigrationStateStorage::::exists(), - "MigrationStateStorage has to be killed at the end of migration." - ); - - ensure!( - >::on_chain_storage_version() == 9, - "pallet-contracts storage version must be 9 at the end of migration" - ); - - Ok(()) - } - } -} diff --git a/pallets/dapp-staking-migration/Cargo.toml b/pallets/dapp-staking-migration/Cargo.toml new file mode 100644 index 0000000000..ab503dbe17 --- /dev/null +++ b/pallets/dapp-staking-migration/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "pallet-dapp-staking-migration" +version = "1.0.0" +license = "GPL-3.0-or-later" +description = "Pallet for managing dApp staking v2 to v3 migration." +authors.workspace = true +edition.workspace = true +homepage.workspace = true +repository.workspace = true + +[dependencies] +frame-benchmarking = { workspace = true, optional = true } +frame-support = { workspace = true } +frame-system = { workspace = true } +parity-scale-codec = { workspace = true } +scale-info = { workspace = true } +sp-io = { workspace = true } +sp-runtime = { workspace = true } +sp-std = { workspace = true } + +astar-primitives = { workspace = true, optional = true } +pallet-dapp-staking-v3 = { workspace = true } +pallet-dapps-staking = { workspace = true } + +[dev-dependencies] +astar-primitives = { workspace = true } +pallet-balances = { workspace = true } +sp-arithmetic = { workspace = true } +sp-core = { workspace = true } + +[features] +default = ["std"] +std = [ + "parity-scale-codec/std", + "scale-info/std", + "sp-std/std", + "sp-io/std", + "frame-support/std", + "frame-system/std", + "pallet-dapp-staking-v3/std", + "pallet-dapps-staking/std", + "frame-benchmarking/std", + "astar-primitives?/std", + "sp-core/std", + "pallet-balances/std", +] +runtime-benchmarks = [ + "frame-benchmarking", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "sp-runtime/runtime-benchmarks", + "pallet-dapp-staking-v3/runtime-benchmarks", + "pallet-dapps-staking/runtime-benchmarks", + "astar-primitives/runtime-benchmarks", +] +try-runtime = ["frame-support/try-runtime", "astar-primitives"] diff --git a/pallets/dapp-staking-migration/src/benchmarking.rs b/pallets/dapp-staking-migration/src/benchmarking.rs new file mode 100644 index 0000000000..d26f810241 --- /dev/null +++ b/pallets/dapp-staking-migration/src/benchmarking.rs @@ -0,0 +1,146 @@ +// 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 super::{Pallet as Migration, *}; + +use frame_benchmarking::{account as benchmark_account, v2::*}; +use frame_support::{assert_ok, traits::Currency}; + +/// Generate an unique smart contract using the provided index as a sort-of indetifier +fn smart_contract(index: u8) -> T::SmartContract { + // This is a hacky approach to provide different smart contracts without touching the smart contract trait. + let mut encoded_smart_contract = T::SmartContract::default().encode(); + *encoded_smart_contract.last_mut().unwrap() = index; + + Decode::decode(&mut TrailingZeroInput::new(encoded_smart_contract.as_ref())) + .expect("Shouldn't occur as long as EVM is the default type.") +} + +/// Initialize the old dApp staking pallet with some storage. +pub(super) fn initial_config() { + let dapps_number = ::MaxNumberOfContracts::get(); + let dapps_number = (dapps_number as u8).min(100); + + // Add some dummy dApps to the old pallet. + for idx in 0..dapps_number { + let developer: T::AccountId = benchmark_account("developer", idx.into(), 123); + ::Currency::make_free_balance_be( + &developer, + ::RegisterDeposit::get() * 2, + ); + let smart_contract = smart_contract::(idx); + assert_ok!(pallet_dapps_staking::Pallet::::register( + RawOrigin::Root.into(), + developer, + smart_contract.clone(), + )); + + let staker: T::AccountId = benchmark_account("staker", idx.into(), 123); + let lock_amount = ::MinimumStakingAmount::get() + .max(::MinimumLockedAmount::get()); + ::Currency::make_free_balance_be( + &staker, + lock_amount * 100, + ); + assert_ok!(pallet_dapps_staking::Pallet::::bond_and_stake( + RawOrigin::Signed(staker.clone()).into(), + smart_contract, + lock_amount, + )); + } +} + +#[benchmarks] +mod benchmarks { + use super::*; + + #[benchmark] + fn migrate_dapps_success() { + initial_config::(); + + #[block] + { + assert!(Migration::::migrate_dapps().is_ok()); + } + } + + #[benchmark] + fn migrate_dapps_noop() { + #[block] + { + assert!(Migration::::migrate_dapps().is_err()); + } + } + + #[benchmark] + fn migrate_ledger_success() { + initial_config::(); + + #[block] + { + assert!(Migration::::migrate_ledger().is_ok()); + } + } + + #[benchmark] + fn migrate_ledger_noop() { + #[block] + { + assert!(Migration::::migrate_ledger().is_err()); + } + } + + #[benchmark] + fn cleanup_old_storage_success() { + initial_config::(); + + #[block] + { + // TODO: for some reason, tests always fail here, nothing gets removed from storage. + // When tested against real runtime, it works just fine. + let _ = Migration::::cleanup_old_storage(1); + } + } + + #[benchmark] + fn cleanup_old_storage_noop() { + let hashed_prefix = twox_128(pallet_dapps_staking::Pallet::::name().as_bytes()); + let _ = clear_prefix(&hashed_prefix, None); + + #[block] + { + assert!(Migration::::cleanup_old_storage(1).is_err()); + } + } + + impl_benchmark_test_suite!( + Pallet, + crate::benchmarking::tests::new_test_ext(), + crate::mock::Test, + ); +} + +#[cfg(test)] +mod tests { + use crate::mock; + use sp_io::TestExternalities; + + pub fn new_test_ext() -> TestExternalities { + mock::ExtBuilder::build() + } +} diff --git a/pallets/dapp-staking-migration/src/lib.rs b/pallets/dapp-staking-migration/src/lib.rs new file mode 100644 index 0000000000..d6fdefe803 --- /dev/null +++ b/pallets/dapp-staking-migration/src/lib.rs @@ -0,0 +1,714 @@ +// 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 . + +#![cfg_attr(not(feature = "std"), no_std)] + +//! ## Summary +//! +//! Purpose of this pallet is to provide multi-stage migration for moving +//! from the old _dapps_staking_v2_ over to the new _dapp_staking_v3_. +//! +//! ## Approach +//! +//! ### Multi-Stage Migration +//! +//! Since a lot of data has to be cleaned up & migrated, it is necessary to do this in multiple steps. +//! To reduce the risk of something going wrong, nothing is done in _mandatory hooks_, like `on_initialize` or `on_idle`. +//! Instead, a dedicated extrinsic call is introduced, which can be called to move the migration forward. +//! As long as this call moves the migration forward, its cost is refunded to the user. +//! Once migration finishes, the extrinsic call will no longer do anything but won't refund the call cost either. +//! +//! ### Migration Steps +//! +//! The general approach used when migrating is: +//! 1. Clean up old pallet's storage using custom code +//! 2. Use dedicated dApp staking v3 extrinsic calls for registering dApps & locking funds. +//! +//! The main benefits of this approach are that we don't duplicate logic that is already present in dApp staking v3, +//! and that we ensure proper events are emitted for each action which will make indexers happy. No special handling will +//! be required to migrate dApps or locked/staked funds over from the old pallet to the new one, from the indexers perspective. +//! +//! ### Final Cleanup +//! +//! The pallet doesn't clean after itself, so when it's removed from the runtime, +//! the old storage should be cleaned up using `RemovePallet` type. +//! + +pub use pallet::*; + +use frame_support::{ + dispatch::PostDispatchInfo, + log, + pallet_prelude::*, + traits::{Get, LockableCurrency, ReservableCurrency}, +}; + +use frame_system::{pallet_prelude::*, RawOrigin}; +use parity_scale_codec::{Decode, Encode}; +use sp_io::{hashing::twox_128, storage::clear_prefix, KillStorageResult}; +use sp_runtime::{ + traits::{TrailingZeroInput, UniqueSaturatedInto}, + Saturating, +}; + +use pallet_dapps_staking::{Ledger as OldLedger, RegisteredDapps as OldRegisteredDapps}; + +#[cfg(feature = "try-runtime")] +use astar_primitives::Balance; +#[cfg(feature = "try-runtime")] +use sp_std::vec::Vec; + +pub use crate::pallet::DappStakingMigrationHandler; + +#[cfg(test)] +mod mock; +#[cfg(test)] +mod tests; + +#[cfg(feature = "runtime-benchmarks")] +mod benchmarking; + +pub mod weights; +pub use weights::WeightInfo; + +const LOG_TARGET: &str = "dapp-staking-migration"; + +#[frame_support::pallet] +pub mod pallet { + use super::*; + + #[pallet::pallet] + pub struct Pallet(PhantomData); + + #[pallet::config] + pub trait Config: + // Tight coupling, but it's fine since pallet is supposed to be just temporary and will be removed after migration. + frame_system::Config + pallet_dapp_staking_v3::Config + pallet_dapps_staking::Config + { + /// The overarching event type. + type RuntimeEvent: From> + IsType<::RuntimeEvent>; + + /// Weight info for various calls & operations in the pallet. + type WeightInfo: WeightInfo; + } + + /// Used to store the current migration state. + #[pallet::storage] + pub type MigrationStateStorage = StorageValue<_, MigrationState, ValueQuery>; + + #[pallet::event] + #[pallet::generate_deposit(pub(crate) fn deposit_event)] + pub enum Event { + /// Number of entries migrated from v2 over to v3 + EntriesMigrated(u32), + /// Number of entries deleted from v2 + EntriesDeleted(u32), + } + + #[pallet::call] + impl Pallet { + /// Attempt to execute migration steps, consuming up to the specified amount of weight. + /// If no weight is specified, max allowed weight is used. + /// + /// Regardless of the specified weight limit, it will be clamped between the minimum & maximum allowed values. + /// This means that even if user specifies `Weight::zero()` as the limit, + /// the call will be charged & executed using the minimum allowed weight. + #[pallet::call_index(0)] + #[pallet::weight({ + Pallet::::clamp_call_weight(*weight_limit) + })] + pub fn migrate( + origin: OriginFor, + weight_limit: Option, + ) -> DispatchResultWithPostInfo { + ensure_signed(origin)?; + + let weight_to_use = Self::clamp_call_weight(weight_limit); + let consumed_weight = Self::do_migrate(weight_to_use); + + // Refund the user in case migration call was needed. + match consumed_weight { + Ok(weight) => Ok(PostDispatchInfo { + actual_weight: Some(weight), + pays_fee: Pays::No, + }), + // No refunds or adjustments! + Err(_) => Ok(().into()), + } + } + } + + #[pallet::hooks] + impl Hooks> for Pallet { + fn integrity_test() { + assert!(Pallet::::max_call_weight().all_gte(Pallet::::min_call_weight())); + + assert!(Pallet::::max_call_weight() + .all_lte(::BlockWeights::get().max_block)); + + assert!(Pallet::::migration_weight_margin().all_lte(Pallet::::min_call_weight())); + } + } + + impl Pallet { + /// Execute migrations steps until the specified weight limit has been consumed. + /// + /// Depending on the number of entries migrated and/or deleted, appropriate events are emited. + /// + /// In case at least some progress is made, `Ok(_)` is returned. + /// If no progress is made, `Err(_)` is returned. + fn do_migrate(weight_limit: Weight) -> Result { + // Find out if migration is still in progress + let init_migration_state = MigrationStateStorage::::get(); + let mut consumed_weight = T::DbWeight::get().reads(1); + + if init_migration_state == MigrationState::Finished { + log::trace!( + target: LOG_TARGET, + "Migration has been finished, skipping any action." + ); + return Err(consumed_weight); + } + + // Ensure we can call dApp staking v3 extrinsics within this call. + consumed_weight.saturating_accrue(T::DbWeight::get().reads_writes(1, 2)); + pallet_dapp_staking_v3::ActiveProtocolState::::mutate(|state| { + state.maintenance = false; + }); + + let mut migration_state = init_migration_state; + let (mut entries_migrated, mut entries_deleted) = (0_u32, 0_u32); + + // Execute migration steps only if we have enough weight to do so. + // + // 1. Migrate registered dApps + // 2. Migrate ledgers + // 3. Cleanup + while weight_limit + .saturating_sub(consumed_weight) + .all_gte(Self::migration_weight_margin()) + { + match migration_state { + MigrationState::NotInProgress | MigrationState::RegisteredDApps => { + migration_state = MigrationState::RegisteredDApps; + + match Self::migrate_dapps() { + Ok(weight) => { + consumed_weight.saturating_accrue(weight); + entries_migrated.saturating_inc(); + } + Err(weight) => { + consumed_weight.saturating_accrue(weight); + migration_state = MigrationState::Ledgers; + } + } + } + MigrationState::Ledgers => match Self::migrate_ledger() { + Ok(weight) => { + consumed_weight.saturating_accrue(weight); + entries_migrated.saturating_inc(); + } + Err(weight) => { + consumed_weight.saturating_accrue(weight); + migration_state = MigrationState::Cleanup; + } + }, + MigrationState::Cleanup => { + // Ensure we don't attempt to delete too much at once. + const SAFETY_MARGIN: u32 = 1000; + let remaining_weight = weight_limit.saturating_sub(consumed_weight); + let capacity = match remaining_weight.checked_div_per_component( + &::WeightInfo::cleanup_old_storage_success(), + ) { + Some(entries_to_delete) => { + SAFETY_MARGIN.min(entries_to_delete.unique_saturated_into()) + } + None => { + // Not enough weight to delete even a single entry + break; + } + }; + + match Self::cleanup_old_storage(capacity) { + Ok((weight, count)) => { + consumed_weight.saturating_accrue(weight); + entries_deleted.saturating_accrue(count); + } + Err((weight, count)) => { + consumed_weight.saturating_accrue(weight); + entries_deleted.saturating_accrue(count); + migration_state = MigrationState::Finished; + } + } + } + MigrationState::Finished => { + // Nothing more to do here + break; + } + } + } + + // Deposit events if needed + if entries_migrated > 0 { + Self::deposit_event(Event::::EntriesMigrated(entries_migrated)); + } + if entries_deleted > 0 { + Self::deposit_event(Event::::EntriesDeleted(entries_deleted)); + } + + // Put the pallet back into maintenance mode. + pallet_dapp_staking_v3::ActiveProtocolState::::mutate(|state| { + state.maintenance = true; + }); + + if migration_state != init_migration_state { + // Already charged in pessimistic manner at the beginning of the function. + MigrationStateStorage::::put(migration_state); + } + + Ok(consumed_weight) + } + + /// Used to migrate `RegisteredDapps` from the _old_ dApps staking v2 pallet over to the new `IntegratedDApps`. + /// + /// Steps: + /// 1. Attempt to `drain` a single DB entry from the old storage. If it's unregistered, move on. + /// 2. Unreserve the old `RegisterDeposit` amount from the developer account. + /// 2. Re-decode old smart contract type into new one. Operation should be infalible in practice since the same underlying type is used. + /// 3. `register` the old-new smart contract into dApp staking v3 pallet. + /// + /// Returns `Ok(_)` if an entry was migrated, `Err(_)` if there are no more entries to migrate. + pub(crate) fn migrate_dapps() -> Result { + match OldRegisteredDapps::::drain().next() { + Some((smart_contract, old_dapp_info)) => { + // In case dApp was unregistered, nothing more to do here + if old_dapp_info.is_unregistered() { + // Not precise, but happens rarely + return Ok(::WeightInfo::migrate_dapps_success()); + } + + // Release reserved funds from the old dApps staking + ::Currency::unreserve( + &old_dapp_info.developer, + ::RegisterDeposit::get(), + ); + + // Trick to get around different associated types which are essentially the same underlying struct. + let new_smart_contract = match Decode::decode(&mut TrailingZeroInput::new( + smart_contract.encode().as_ref(), + )) { + Ok(new_smart_contract) => new_smart_contract, + Err(_) => { + log::error!( + target: LOG_TARGET, + "Failed to decode smart contract: {:?}.", + smart_contract, + ); + + // This should never happen, but if it does, we want to know about it. + #[cfg(feature = "try-runtime")] + panic!("Failed to decode smart contract: {:?}", smart_contract); + #[cfg(not(feature = "try-runtime"))] + // Not precise, but must never happen in production + return Ok(::WeightInfo::migrate_dapps_success()); + } + }; + + match pallet_dapp_staking_v3::Pallet::::register( + RawOrigin::Root.into(), + old_dapp_info.developer.clone(), + new_smart_contract, + ) { + Ok(_) => {} + Err(error) => { + log::error!( + target: LOG_TARGET, + "Failed to register smart contract: {:?} with error: {:?}.", + smart_contract, + error, + ); + + // This should never happen, but if it does, we want to know about it. + #[cfg(feature = "try-runtime")] + panic!( + "Failed to register smart contract: {:?} with error: {:?}.", + smart_contract, error + ); + } + } + + Ok(::WeightInfo::migrate_dapps_success()) + } + None => { + // Nothing more to migrate here + Err(::WeightInfo::migrate_dapps_noop()) + } + } + } + + /// Used to migrate `Ledger` from the _old_ dApps staking v2 pallet over to the new `Ledger`. + /// + /// Steps: + /// 1. Attempt to `drain` a single DB entry from the old storage. + /// 2. Release the old lock from the staker account, in full. + /// 3. Lock (or freeze) the old _staked_ amount into the new dApp staking v3 pallet. + /// + /// **NOTE:** the amount that was undergoing the unbonding process is not migrated but is immediately fully released. + /// + /// Returns `Ok(_)` if an entry was migrated, `Err(_)` if there are no more entries to migrate. + pub(crate) fn migrate_ledger() -> Result { + match OldLedger::::drain().next() { + Some((staker, old_account_ledger)) => { + let locked = old_account_ledger.locked; + + // Old unbonding amount can just be released, to keep things simple. + // Alternative is to re-calculat this into unlocking chunks. + let _total_unbonding = old_account_ledger.unbonding_info.sum(); + + ::Currency::remove_lock( + pallet_dapps_staking::pallet::STAKING_ID, + &staker, + ); + + // No point in attempting to lock the old amount into dApp staking v3 if amount is insufficient. + if locked >= ::MinimumLockedAmount::get() { + match pallet_dapp_staking_v3::Pallet::::lock( + RawOrigin::Signed(staker.clone()).into(), + locked, + ) { + Ok(_) => {} + Err(error) => { + log::error!( + target: LOG_TARGET, + "Failed to lock for staker {:?} with error: {:?}.", + staker, + error, + ); + + // This should never happen, but if it does, we want to know about it. + #[cfg(feature = "try-runtime")] + panic!( + "Failed to lock for staker {:?} with error: {:?}.", + staker, error, + ); + } + } + } + + // In case no lock action, it will be imprecise but it's fine since this + // isn't expected to happen, and even if it does, it's not a big deal. + Ok(::WeightInfo::migrate_ledger_success()) + } + None => { + // Nothing more to migrate here + Err(::WeightInfo::migrate_ledger_noop()) + } + } + } + + /// Used to remove one entry from the old _dapps_staking_v2_ storage. + /// + /// If there are no more entries to remove, returns `Err(_)` with consumed weight and number of deleted entries. + /// Otherwise returns `Ok(_)` with consumed weight and number of consumed enries. + pub(crate) fn cleanup_old_storage(limit: u32) -> Result<(Weight, u32), (Weight, u32)> { + let hashed_prefix = twox_128(pallet_dapps_staking::Pallet::::name().as_bytes()); + + // Repeated calls in the same block don't work, so we set the limit to `Unlimited` in case of `try-runtime` testing. + let inner_limit = if cfg!(feature = "try-runtime") { + None + } else { + Some(limit) + }; + + let (keys_removed, done) = match clear_prefix(&hashed_prefix, inner_limit) { + KillStorageResult::AllRemoved(value) => (value, true), + KillStorageResult::SomeRemaining(value) => (value, false), + }; + + log::trace!( + target: LOG_TARGET, + "Removed {} keys from storage.", + keys_removed + ); + + if !done { + Ok(( + ::WeightInfo::cleanup_old_storage_success() + .saturating_mul(keys_removed.into()), + keys_removed as u32, + )) + } else { + log::trace!(target: LOG_TARGET, "All keys have been removed.",); + Err(( + ::WeightInfo::cleanup_old_storage_noop(), + keys_removed as u32, + )) + } + } + + /// Max allowed weight that migration should be allowed to consume. + pub(crate) fn max_call_weight() -> Weight { + // 50% of block should be fine + T::BlockWeights::get().max_block / 2 + } + + /// Min allowed weight that migration should be allowed to consume. + /// + /// This serves as a safety marging, to prevent accidental overspending, due to + /// inprecision in implementation or benchmarks, when small weight limit is specified. + pub(crate) fn min_call_weight() -> Weight { + // 5% of block should be fine + T::BlockWeights::get().max_block / 10 + } + + /// Calculate call weight to use. + /// + /// In case of `None`, use the max allowed call weight. + /// Otherwise clamp the specified weight between the allowed min & max values. + fn clamp_call_weight(weight: Option) -> Weight { + weight + .unwrap_or(Self::max_call_weight()) + .min(Self::max_call_weight()) + .max(Self::min_call_weight()) + } + + /// Returns the least amount of weight which should be remaining for migration in order to attempt another step. + /// + /// This is used to ensure we don't go over the limit. + fn migration_weight_margin() -> Weight { + // Consider the weight of all steps + ::WeightInfo::migrate_dapps_success() + .max(::WeightInfo::migrate_ledger_success()) + .max(::WeightInfo::cleanup_old_storage_success()) + // and add the weight of updating migration status + .saturating_add(T::DbWeight::get().writes(1)) + } + } + + #[derive(PartialEq, Eq, Clone, Encode, Decode, Copy, TypeInfo, RuntimeDebug, MaxEncodedLen)] + pub enum MigrationState { + /// No migration in progress + NotInProgress, + /// In the middle of `RegisteredDApps` migration. + RegisteredDApps, + /// In the middle of `Ledgers` migration. + Ledgers, + /// In the middle of old v2 storage cleanup + Cleanup, + /// All migrations have been finished + Finished, + } + + impl Default for MigrationState { + fn default() -> Self { + MigrationState::NotInProgress + } + } + + pub struct DappStakingMigrationHandler(PhantomData); + impl frame_support::traits::OnRuntimeUpgrade for DappStakingMigrationHandler { + fn on_runtime_upgrade() -> Weight { + // When upgrade happens, we need to put dApp staking v3 into maintenance mode immediately. + // For the old pallet, since the storage cleanup is going to happen, maintenance mode must be ensured + // by the runtime config itself. + let mut consumed_weight = T::DbWeight::get().reads_writes(1, 2); + pallet_dapp_staking_v3::ActiveProtocolState::::mutate(|state| { + state.maintenance = true; + }); + + // Set the correct init storage version + pallet_dapp_staking_v3::STORAGE_VERSION.put::>(); + + // In case of try-runtime, we want to execute the whole logic, to ensure it works + // with on-chain data. + if cfg!(feature = "try-runtime") { + let mut steps = 0_u32; + while MigrationStateStorage::::get() != MigrationState::Finished { + match Pallet::::do_migrate(crate::Pallet::::max_call_weight()) { + Ok(weight) => { + consumed_weight.saturating_accrue(weight); + steps.saturating_inc(); + } + Err(_) => { + panic!("Must never happen since we check whether state is `Finished` before calling `do_migrate`."); + } + } + } + + log::trace!( + target: LOG_TARGET, + "dApp Staking migration finished after {} steps with total weight of {}.", + steps, + consumed_weight, + ); + + consumed_weight + } else { + consumed_weight + } + } + + #[cfg(feature = "try-runtime")] + fn pre_upgrade() -> Result, sp_runtime::TryRuntimeError> { + // Get dev accounts with registered dapps and their total reserved balance + let developers: Vec<_> = pallet_dapps_staking::RegisteredDapps::::iter() + .filter_map(|(smart_contract, info)| { + if info.state == pallet_dapps_staking::DAppState::Registered { + let reserved = + ::Currency::reserved_balance( + &info.developer, + ); + Some((info.developer, smart_contract, reserved)) + } else { + None + } + }) + .collect(); + + // Get the stakers and their active locked (staked) amount. + + let min_lock_amount: Balance = + ::MinimumLockedAmount::get(); + let stakers: Vec<_> = pallet_dapps_staking::Ledger::::iter() + .filter_map(|(staker, ledger)| { + if ledger.locked >= min_lock_amount { + Some((staker, ledger.locked)) + } else { + None + } + }) + .collect(); + + log::info!( + target: LOG_TARGET, + "Out of {} stakers, {} have sufficient amount to lock.", + pallet_dapps_staking::Ledger::::iter().count(), + stakers.len(), + ); + + let helper = Helper:: { + developers, + stakers, + }; + + Ok(helper.encode()) + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(state: Vec) -> Result<(), sp_runtime::TryRuntimeError> { + use sp_runtime::traits::Zero; + + let helper = Helper::::decode(&mut TrailingZeroInput::new(state.as_ref())) + .map_err(|_| "Cannot decode data from pre_upgrade")?; + + // 1. Ensure that all entries have been unregistered/removed and all dev accounts have been refunded. + // Also check that dApps have been registered in the new pallet. + assert!(pallet_dapps_staking::RegisteredDapps::::iter() + .count() + .is_zero()); + assert_eq!( + pallet_dapp_staking_v3::IntegratedDApps::::iter().count(), + helper.developers.len() + ); + + let register_deposit = ::RegisterDeposit::get(); + for (dev_account, smart_contract, old_reserved) in helper.developers { + let new_reserved = + ::Currency::reserved_balance(&dev_account); + assert_eq!(old_reserved, new_reserved + register_deposit); + + let new_smart_contract: ::SmartContract = + Decode::decode(&mut TrailingZeroInput::new( + smart_contract.encode().as_ref(), + )) + .expect("Must succeed since we're using the same underlying type."); + + let dapp_info = + pallet_dapp_staking_v3::IntegratedDApps::::get(&new_smart_contract) + .expect("Must exist!"); + assert_eq!(dapp_info.owner, dev_account); + } + + // 2. Ensure that all ledger entries have been migrated over to the new pallet. + // Total locked amount in the new pallet must equal the sum of all old locked amounts. + assert!(pallet_dapps_staking::Ledger::::iter().count().is_zero()); + assert_eq!( + pallet_dapp_staking_v3::Ledger::::iter().count(), + helper.stakers.len() + ); + + for (staker, old_locked) in &helper.stakers { + let new_locked = pallet_dapp_staking_v3::Ledger::::get(&staker).locked; + assert_eq!(*old_locked, new_locked); + } + + let total_locked = helper + .stakers + .iter() + .map(|(_, locked)| locked) + .sum::(); + assert_eq!( + pallet_dapp_staking_v3::CurrentEraInfo::::get().total_locked, + total_locked + ); + + log::info!( + target: LOG_TARGET, + "Total locked amount in the new pallet: {:?}.", + total_locked, + ); + + // 3. Check that rest of the storage has been cleaned up. + assert!(!pallet_dapps_staking::PalletDisabled::::exists()); + assert!(!pallet_dapps_staking::CurrentEra::::exists()); + assert!(!pallet_dapps_staking::BlockRewardAccumulator::::exists()); + assert!(!pallet_dapps_staking::ForceEra::::exists()); + assert!(!pallet_dapps_staking::NextEraStartingBlock::::exists()); + assert!(!pallet_dapps_staking::StorageVersion::::exists()); + + assert!(pallet_dapps_staking::RegisteredDevelopers::::iter() + .count() + .is_zero()); + assert!(pallet_dapps_staking::GeneralEraInfo::::iter() + .count() + .is_zero()); + assert!(pallet_dapps_staking::ContractEraStake::::iter() + .count() + .is_zero()); + assert!(pallet_dapps_staking::GeneralStakerInfo::::iter() + .count() + .is_zero()); + + Ok(()) + } + } +} + +#[cfg(feature = "try-runtime")] +/// Used to help with `try-runtime` testing. +#[derive(Encode, Decode)] +struct Helper { + /// Vec of devs, with their associated smart contract & total reserved balance + developers: Vec<( + T::AccountId, + ::SmartContract, + Balance, + )>, + /// Stakers with their total active locked amount (not undergoing the unbonding process) + stakers: Vec<(T::AccountId, Balance)>, +} diff --git a/pallets/dapp-staking-migration/src/mock.rs b/pallets/dapp-staking-migration/src/mock.rs new file mode 100644 index 0000000000..c33aa09620 --- /dev/null +++ b/pallets/dapp-staking-migration/src/mock.rs @@ -0,0 +1,276 @@ +// 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_migration, *}; + +use frame_support::{ + assert_ok, construct_runtime, parameter_types, + traits::{fungible::Mutate as FunMutate, ConstBool, ConstU128, ConstU32, Currency}, + weights::Weight, + PalletId, +}; +use sp_arithmetic::fixed_point::FixedU64; +use sp_core::H256; +use sp_io::TestExternalities; +use sp_runtime::traits::{BlakeTwo256, IdentityLookup}; + +use astar_primitives::{ + dapp_staking::{CycleConfiguration, SmartContract, StakingRewardHandler}, + testing::Header, + Balance, BlockNumber, +}; + +pub(crate) type AccountId = u64; + +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_v3, + DappsStaking: pallet_dapps_staking, + DappStakingMigration: pallet_dapp_staking_migration, + } +); + +parameter_types! { + pub const BlockHashCount: BlockNumber = 250; + pub BlockWeights: frame_system::limits::BlockWeights = + frame_system::limits::BlockWeights::simple_max(Weight::from_parts(1024, 0)); +} + +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 HoldIdentifier = (); + type FreezeIdentifier = RuntimeFreezeReason; + type MaxHolds = ConstU32<0>; + type MaxFreezes = ConstU32<1>; + type WeightInfo = (); +} + +pub struct DummyPriceProvider; +impl pallet_dapp_staking_v3::PriceProvider for DummyPriceProvider { + fn average_price() -> FixedU64 { + FixedU64::from_rational(1, 10) + } +} + +pub struct DummyStakingRewardHandler; +impl StakingRewardHandler for DummyStakingRewardHandler { + fn staker_and_dapp_reward_pools(_total_staked_value: Balance) -> (Balance, Balance) { + ( + Balance::from(1_000_000_000_000_u128), + Balance::from(1_000_000_000_u128), + ) + } + + fn bonus_reward_pool() -> Balance { + Balance::from(3_000_000_u128) + } + + fn payout_reward(beneficiary: &AccountId, reward: Balance) -> Result<(), ()> { + let _ = Balances::mint_into(beneficiary, reward); + Ok(()) + } +} + +pub(crate) type MockSmartContract = SmartContract; + +#[cfg(feature = "runtime-benchmarks")] +pub struct BenchmarkHelper(sp_std::marker::PhantomData<(SC, ACC)>); +#[cfg(feature = "runtime-benchmarks")] +impl pallet_dapp_staking_v3::BenchmarkHelper + for BenchmarkHelper +{ + fn get_smart_contract(id: u32) -> MockSmartContract { + MockSmartContract::Wasm(id as AccountId) + } + + fn set_balance(account: &AccountId, amount: Balance) { + use frame_support::traits::fungible::Unbalanced as FunUnbalanced; + Balances::write_balance(account, amount) + .expect("Must succeed in test/benchmark environment."); + } +} + +pub struct DummyCycleConfiguration; +impl CycleConfiguration for DummyCycleConfiguration { + fn periods_per_cycle() -> u32 { + 4 + } + + fn eras_per_voting_subperiod() -> u32 { + 8 + } + + fn eras_per_build_and_earn_subperiod() -> u32 { + 16 + } + + fn blocks_per_era() -> u32 { + 10 + } +} + +impl pallet_dapp_staking_v3::Config for Test { + type RuntimeEvent = RuntimeEvent; + type RuntimeFreezeReason = RuntimeFreezeReason; + type Currency = Balances; + type SmartContract = MockSmartContract; + type ManagerOrigin = frame_system::EnsureRoot; + type NativePriceProvider = DummyPriceProvider; + type StakingRewardHandler = DummyStakingRewardHandler; + type CycleConfiguration = DummyCycleConfiguration; + type EraRewardSpanLength = ConstU32<8>; + type RewardRetentionInPeriods = ConstU32<2>; + type MaxNumberOfContracts = ConstU32<10>; + type MaxUnlockingChunks = ConstU32<5>; + type MinimumLockedAmount = ConstU128; + type UnlockingPeriod = ConstU32<2>; + type MaxNumberOfStakedContracts = ConstU32<5>; + type MinimumStakeAmount = ConstU128<3>; + type NumberOfTiers = ConstU32<4>; + type WeightInfo = pallet_dapp_staking_v3::weights::SubstrateWeight; + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = BenchmarkHelper; +} + +parameter_types! { + pub const DappsStakingPalletId: PalletId = PalletId(*b"mokdpstk"); +} + +impl pallet_dapps_staking::Config for Test { + type RuntimeEvent = RuntimeEvent; + type Currency = Balances; + type BlockPerEra = ConstU32<10>; + type RegisterDeposit = ConstU128<100>; + type SmartContract = MockSmartContract; + type WeightInfo = pallet_dapps_staking::weights::SubstrateWeight; + type MaxNumberOfStakersPerContract = ConstU32<10>; + type MinimumStakingAmount = ConstU128; + type PalletId = DappsStakingPalletId; + type MinimumRemainingAmount = ConstU128<1>; + type MaxUnlockingChunks = ConstU32<5>; + type UnbondingPeriod = ConstU32<3>; + type MaxEraStakeValues = ConstU32<10>; + type UnregisteredDappRewardRetention = ConstU32<10>; + type ForcePalletDisabled = ConstBool; +} + +impl pallet_dapp_staking_migration::Config for Test { + type RuntimeEvent = RuntimeEvent; + type WeightInfo = crate::weights::SubstrateWeight; +} + +pub struct ExtBuilder; +impl ExtBuilder { + pub fn build() -> TestExternalities { + // Normal behavior is for reward payout to succeed + 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); + }); + + ext + } +} + +/// Initialize old dApps staking storage. +/// +/// This is kept outside of the test ext creation since the same mock is reused +/// in the benchmarks code. +pub fn init() { + let dapps_number = 10_u32; + let staker = dapps_number.into(); + Balances::make_free_balance_be(&staker, 1_000_000_000_000_000_000); + + // Add some dummy dApps to the old pallet & stake on them. + for idx in 0..dapps_number { + let developer = idx.into(); + Balances::make_free_balance_be(&developer, 1_000_000_000_000); + let smart_contract = MockSmartContract::Wasm(idx.into()); + assert_ok!(pallet_dapps_staking::Pallet::::register( + RawOrigin::Root.into(), + developer, + smart_contract.clone(), + )); + assert_ok!(pallet_dapps_staking::Pallet::::bond_and_stake( + RawOrigin::Signed(staker.clone()).into(), + smart_contract, + 1_000, + )); + } +} diff --git a/pallets/dapp-staking-migration/src/tests.rs b/pallets/dapp-staking-migration/src/tests.rs new file mode 100644 index 0000000000..7a4776e1c7 --- /dev/null +++ b/pallets/dapp-staking-migration/src/tests.rs @@ -0,0 +1,177 @@ +// 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::mock::*; +use crate::*; + +use frame_support::{assert_ok, assert_storage_noop}; +use sp_runtime::traits::Zero; + +#[test] +fn sanity_check() { + ExtBuilder::build().execute_with(|| { + assert!(DappStakingMigration::max_call_weight() + .all_gte(DappStakingMigration::min_call_weight())); + }); +} + +#[test] +fn migrate_dapps_check() { + ExtBuilder::build().execute_with(|| { + init(); + + // Cleanup single entry, check pre and post states + let init_old_count = pallet_dapps_staking::RegisteredDapps::::iter().count(); + assert!(init_old_count > 0, "Sanity check."); + + let init_new_count = pallet_dapp_staking_v3::IntegratedDApps::::iter().count(); + assert!(init_new_count.is_zero(), "Sanity check."); + + assert_eq!( + DappStakingMigration::migrate_dapps(), + Ok(::WeightInfo::migrate_dapps_success()) + ); + assert_eq!( + init_old_count, + pallet_dapps_staking::RegisteredDapps::::iter().count() + 1, + "One entry should have been cleaned up." + ); + assert_eq!( + pallet_dapp_staking_v3::IntegratedDApps::::iter().count(), + 1, + "Single new entry should have been added." + ); + + // Cleanup the remaining entries. + for _ in 1..init_old_count { + assert_eq!( + DappStakingMigration::migrate_dapps(), + Ok(::WeightInfo::migrate_dapps_success()) + ); + } + + // Further calls should result in Err + assert_eq!( + DappStakingMigration::migrate_dapps(), + Err(::WeightInfo::migrate_dapps_noop()) + ); + }); +} + +#[test] +fn migrate_ledgers_check() { + ExtBuilder::build().execute_with(|| { + init(); + + // Cleanup all enries, check pre and post states. + let init_old_count = pallet_dapps_staking::Ledger::::iter().count(); + assert!(init_old_count > 0, "Sanity check."); + + let init_new_count = pallet_dapp_staking_v3::Ledger::::iter().count(); + assert!(init_new_count.is_zero(), "Sanity check."); + + assert!(pallet_dapp_staking_v3::CurrentEraInfo::::get() + .total_locked + .is_zero()); + + for x in 0..init_old_count { + assert_eq!( + DappStakingMigration::migrate_ledger(), + Ok(::WeightInfo::migrate_ledger_success()) + ); + + assert_eq!( + init_old_count - x - 1, + pallet_dapps_staking::Ledger::::iter().count(), + "One entry should have been cleaned up." + ); + assert_eq!( + x + 1, + pallet_dapp_staking_v3::Ledger::::iter().count(), + "Single new entry should have been added." + ); + assert!(pallet_dapp_staking_v3::CurrentEraInfo::::get().total_locked > 0); + } + + // Further calls should result in Err + assert_eq!( + DappStakingMigration::migrate_ledger(), + Err(::WeightInfo::migrate_ledger_noop()) + ); + }); +} + +// TODO: this doesn't work since clear_prefix doesn't work in tests for some reason. +#[ignore] +#[test] +fn storage_cleanup_check() { + let mut ext = ExtBuilder::build(); + assert_ok!(ext.commit_all()); + + ext.execute_with(|| { + init(); + + let init_count = (pallet_dapps_staking::RegisteredDapps::::iter().count() + + pallet_dapps_staking::Ledger::::iter().count()) as u32; + + for _ in 0..init_count { + assert_ok!(DappStakingMigration::cleanup_old_storage(init_count)); + } + }); +} + +#[test] +fn migrate_call_works() { + ExtBuilder::build().execute_with(|| { + init(); + let account = 1; + + // Call enough times to clean everything up. + while MigrationStateStorage::::get() != MigrationState::Finished { + assert_ok!(DappStakingMigration::migrate( + frame_system::RawOrigin::Signed(account).into(), + Some(Weight::from_parts(1, 1)) + )); + + assert!( + pallet_dapp_staking_v3::ActiveProtocolState::::get().maintenance, + "Maintenance must always be returned after migrate call finishes." + ); + } + + // Check post-state + assert!(pallet_dapps_staking::RegisteredDapps::::iter() + .count() + .is_zero()); + assert!(pallet_dapps_staking::Ledger::::iter() + .count() + .is_zero()); + assert!(pallet_dapps_staking::RegisteredDevelopers::::iter() + .count() + .is_zero()); + assert!(pallet_dapps_staking::GeneralEraInfo::::iter() + .count() + .is_zero()); + + // Migrate call can still be called, but it shouldn't have any effect. + assert_storage_noop!(assert_ok!(DappStakingMigration::migrate( + frame_system::RawOrigin::Signed(account).into(), + None + ))); + }); +} diff --git a/pallets/dapp-staking-migration/src/weights.rs b/pallets/dapp-staking-migration/src/weights.rs new file mode 100644 index 0000000000..313e4c7070 --- /dev/null +++ b/pallets/dapp-staking-migration/src/weights.rs @@ -0,0 +1,223 @@ + +// 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 . + +//! Autogenerated weights for pallet_dapp_staking_migration +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 4.0.0-dev +//! DATE: 2023-12-20, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! WORST CASE MAP SIZE: `1000000` +//! HOSTNAME: `devserver-01`, CPU: `Intel(R) Xeon(R) E-2236 CPU @ 3.40GHz` +//! EXECUTION: Some(Wasm), WASM-EXECUTION: Compiled, CHAIN: Some("shibuya-dev"), DB CACHE: 1024 + +// Executed Command: +// ./target/release/astar-collator +// benchmark +// pallet +// --chain=shibuya-dev +// --steps=50 +// --repeat=20 +// --pallet=pallet_dapp_staking_migration +// --extrinsic=* +// --execution=wasm +// --wasm-execution=compiled +// --heap-pages=4096 +// --output=./benchmark-results/shibuya-dev/dapp_staking_migration_weights.rs +// --template=./scripts/templates/weight-template.hbs + +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(unused_parens)] +#![allow(unused_imports)] + +use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; +use core::marker::PhantomData; + +/// Weight functions needed for pallet_dapp_staking_migration. +pub trait WeightInfo { + fn migrate_dapps_success() -> Weight; + fn migrate_dapps_noop() -> Weight; + fn migrate_ledger_success() -> Weight; + fn migrate_ledger_noop() -> Weight; + fn cleanup_old_storage_success() -> Weight; + fn cleanup_old_storage_noop() -> Weight; +} + +/// Weights for pallet_dapp_staking_migration using the Substrate node and recommended hardware. +pub struct SubstrateWeight(PhantomData); +impl WeightInfo for SubstrateWeight { + /// Storage: DappsStaking RegisteredDapps (r:2 w:1) + /// Proof: DappsStaking RegisteredDapps (max_values: None, max_size: Some(86), added: 2561, mode: MaxEncodedLen) + /// Storage: System Account (r:1 w:1) + /// Proof: System Account (max_values: None, max_size: Some(128), added: 2603, mode: MaxEncodedLen) + /// Storage: DappStaking IntegratedDApps (r:1 w:1) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, mode: MaxEncodedLen) + /// Storage: DappStaking CounterForIntegratedDApps (r:1 w:1) + /// Proof: DappStaking CounterForIntegratedDApps (max_values: Some(1), max_size: Some(4), added: 499, mode: MaxEncodedLen) + /// Storage: DappStaking NextDAppId (r:1 w:1) + /// Proof: DappStaking NextDAppId (max_values: Some(1), max_size: Some(2), added: 497, mode: MaxEncodedLen) + fn migrate_dapps_success() -> Weight { + // Proof Size summary in bytes: + // Measured: `558` + // Estimated: `6112` + // Minimum execution time: 46_218_000 picoseconds. + Weight::from_parts(47_610_000, 6112) + .saturating_add(T::DbWeight::get().reads(6_u64)) + .saturating_add(T::DbWeight::get().writes(5_u64)) + } + /// Storage: DappsStaking RegisteredDapps (r:1 w:0) + /// Proof: DappsStaking RegisteredDapps (max_values: None, max_size: Some(86), added: 2561, mode: MaxEncodedLen) + fn migrate_dapps_noop() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `3551` + // Minimum execution time: 3_385_000 picoseconds. + Weight::from_parts(3_552_000, 3551) + .saturating_add(T::DbWeight::get().reads(1_u64)) + } + /// Storage: DappsStaking Ledger (r:2 w:1) + /// Proof: DappsStaking Ledger (max_values: None, max_size: Some(266), added: 2741, mode: MaxEncodedLen) + /// Storage: Balances Locks (r:1 w:1) + /// Proof: Balances Locks (max_values: None, max_size: Some(1299), added: 3774, mode: MaxEncodedLen) + /// Storage: Balances Freezes (r:1 w:1) + /// Proof: Balances Freezes (max_values: None, max_size: Some(67), added: 2542, mode: MaxEncodedLen) + /// Storage: System Account (r:1 w:1) + /// Proof: System Account (max_values: None, max_size: Some(128), added: 2603, 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 CurrentEraInfo (r:1 w:1) + /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, mode: MaxEncodedLen) + fn migrate_ledger_success() -> Weight { + // Proof Size summary in bytes: + // Measured: `1748` + // Estimated: `6472` + // Minimum execution time: 69_553_000 picoseconds. + Weight::from_parts(70_319_000, 6472) + .saturating_add(T::DbWeight::get().reads(7_u64)) + .saturating_add(T::DbWeight::get().writes(6_u64)) + } + /// Storage: DappsStaking Ledger (r:1 w:0) + /// Proof: DappsStaking Ledger (max_values: None, max_size: Some(266), added: 2741, mode: MaxEncodedLen) + fn migrate_ledger_noop() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `3731` + // Minimum execution time: 2_918_000 picoseconds. + Weight::from_parts(3_022_000, 3731) + .saturating_add(T::DbWeight::get().reads(1_u64)) + } + /// Storage: DappsStaking Ledger (r:2 w:1) + /// Proof: DappsStaking Ledger (max_values: None, max_size: Some(266), added: 2741, mode: MaxEncodedLen) + fn cleanup_old_storage_success() -> Weight { + // Proof Size summary in bytes: + // Measured: `739` + // Estimated: `6472` + // Minimum execution time: 7_109_000 picoseconds. + Weight::from_parts(7_383_000, 6472) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + fn cleanup_old_storage_noop() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 2_095_000 picoseconds. + Weight::from_parts(2_213_000, 0) + } +} + +// For backwards compatibility and tests +impl WeightInfo for () { + /// Storage: DappsStaking RegisteredDapps (r:2 w:1) + /// Proof: DappsStaking RegisteredDapps (max_values: None, max_size: Some(86), added: 2561, mode: MaxEncodedLen) + /// Storage: System Account (r:1 w:1) + /// Proof: System Account (max_values: None, max_size: Some(128), added: 2603, mode: MaxEncodedLen) + /// Storage: DappStaking IntegratedDApps (r:1 w:1) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, mode: MaxEncodedLen) + /// Storage: DappStaking CounterForIntegratedDApps (r:1 w:1) + /// Proof: DappStaking CounterForIntegratedDApps (max_values: Some(1), max_size: Some(4), added: 499, mode: MaxEncodedLen) + /// Storage: DappStaking NextDAppId (r:1 w:1) + /// Proof: DappStaking NextDAppId (max_values: Some(1), max_size: Some(2), added: 497, mode: MaxEncodedLen) + fn migrate_dapps_success() -> Weight { + // Proof Size summary in bytes: + // Measured: `558` + // Estimated: `6112` + // Minimum execution time: 46_218_000 picoseconds. + Weight::from_parts(47_610_000, 6112) + .saturating_add(RocksDbWeight::get().reads(6_u64)) + .saturating_add(RocksDbWeight::get().writes(5_u64)) + } + /// Storage: DappsStaking RegisteredDapps (r:1 w:0) + /// Proof: DappsStaking RegisteredDapps (max_values: None, max_size: Some(86), added: 2561, mode: MaxEncodedLen) + fn migrate_dapps_noop() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `3551` + // Minimum execution time: 3_385_000 picoseconds. + Weight::from_parts(3_552_000, 3551) + .saturating_add(RocksDbWeight::get().reads(1_u64)) + } + /// Storage: DappsStaking Ledger (r:2 w:1) + /// Proof: DappsStaking Ledger (max_values: None, max_size: Some(266), added: 2741, mode: MaxEncodedLen) + /// Storage: Balances Locks (r:1 w:1) + /// Proof: Balances Locks (max_values: None, max_size: Some(1299), added: 3774, mode: MaxEncodedLen) + /// Storage: Balances Freezes (r:1 w:1) + /// Proof: Balances Freezes (max_values: None, max_size: Some(67), added: 2542, mode: MaxEncodedLen) + /// Storage: System Account (r:1 w:1) + /// Proof: System Account (max_values: None, max_size: Some(128), added: 2603, 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 CurrentEraInfo (r:1 w:1) + /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, mode: MaxEncodedLen) + fn migrate_ledger_success() -> Weight { + // Proof Size summary in bytes: + // Measured: `1748` + // Estimated: `6472` + // Minimum execution time: 69_553_000 picoseconds. + Weight::from_parts(70_319_000, 6472) + .saturating_add(RocksDbWeight::get().reads(7_u64)) + .saturating_add(RocksDbWeight::get().writes(6_u64)) + } + /// Storage: DappsStaking Ledger (r:1 w:0) + /// Proof: DappsStaking Ledger (max_values: None, max_size: Some(266), added: 2741, mode: MaxEncodedLen) + fn migrate_ledger_noop() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `3731` + // Minimum execution time: 2_918_000 picoseconds. + Weight::from_parts(3_022_000, 3731) + .saturating_add(RocksDbWeight::get().reads(1_u64)) + } + /// Storage: DappsStaking Ledger (r:2 w:1) + /// Proof: DappsStaking Ledger (max_values: None, max_size: Some(266), added: 2741, mode: MaxEncodedLen) + fn cleanup_old_storage_success() -> Weight { + // Proof Size summary in bytes: + // Measured: `739` + // Estimated: `6472` + // Minimum execution time: 7_109_000 picoseconds. + Weight::from_parts(7_383_000, 6472) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + fn cleanup_old_storage_noop() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 2_095_000 picoseconds. + Weight::from_parts(2_213_000, 0) + } +} diff --git a/pallets/dapp-staking-v3/Cargo.toml b/pallets/dapp-staking-v3/Cargo.toml new file mode 100644 index 0000000000..c79755828d --- /dev/null +++ b/pallets/dapp-staking-v3/Cargo.toml @@ -0,0 +1,60 @@ +[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 } +log = { 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 } + +astar-primitives = { workspace = true } + +assert_matches = { workspace = true, optional = true } +frame-benchmarking = { workspace = true, optional = true } + +[dev-dependencies] +pallet-balances = { workspace = true } + +[features] +default = ["std"] +std = [ + "serde", + "log/std", + "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", + "astar-primitives/std", + "frame-benchmarking/std", +] +runtime-benchmarks = [ + "frame-benchmarking", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "sp-runtime/runtime-benchmarks", + "astar-primitives/runtime-benchmarks", + "assert_matches", +] +try-runtime = ["frame-support/try-runtime"] diff --git a/pallets/dapp-staking-v3/README.md b/pallets/dapp-staking-v3/README.md new file mode 100644 index 0000000000..48a5b531aa --- /dev/null +++ b/pallets/dapp-staking-v3/README.md @@ -0,0 +1,243 @@ +# dApp Staking v3 + +## Introduction + +Astar and Shiden networks provide a unique way for developers to earn rewards by developing products that native token holders decide to support. + +The principle is simple - stakers lock their tokens to _stake_ on a dApp, and if the dApp attracts enough support, it is rewarded in native currency, derived from the inflation. +In turn stakers are rewarded for locking & staking their tokens. + +## Functionality Overview + +### Eras + +Eras are the basic _time unit_ in dApp staking and their length is measured in the number of blocks. + +They are not expected to last long, e.g. current live networks era length is roughly 1 day (7200 blocks). +After an era ends, it's usually possible to claim rewards for it, if user or dApp are eligible. + +### Periods + +Periods are another _time unit_ in dApp staking. They are expected to be more lengthy than eras. + +Each period consists of two subperiods: +* `Voting` +* `Build&Earn` + +Each period is denoted by a number, which increments each time a new period begins. +Period beginning is marked by the `voting` subperiod, after which follows the `build&earn` period. + +Stakes are **only** valid throughout a period. When new period starts, all stakes are reset to **zero**. This helps prevent projects remaining staked due to intertia of stakers, and makes for a more dynamic staking system. Staker doesn't need to do anything for this to happen, it is automatic. + +Even though stakes are reset, locks (or freezes) of tokens remain. + +#### Voting + +When `Voting` subperiod starts, all _stakes_ are reset to **zero**. +Projects participating in dApp staking are expected to market themselves to (re)attract stakers. + +Stakers must assess whether the project they want to stake on brings value to the ecosystem, and then `vote` for it. +Casting a vote, or staking, during the `Voting` subperiod makes the staker eligible for bonus rewards. so they are encouraged to participate. + +`Voting` subperiod length is expressed in _standard_ era lengths, even though the entire voting subperiod is treated as a single _voting era_. +E.g. if `voting` subperiod lasts for **5 eras**, and each era lasts for **100** blocks, total length of the `voting` subperiod will be **500** blocks. +* Block 1, Era 1 starts, Period 1 starts, `Voting` subperiod starts +* Block 501, Era 2 starts, Period 1 continues, `Build&Earn` subperiod starts + +Neither stakers nor dApps earn rewards during this subperiod - no new rewards are generated after `voting` subperiod ends. + +#### Build&Earn + +`Build&Earn` subperiod consits of one or more eras, therefore its length is expressed in eras. + +After each _era_ ends, eligible stakers and dApps can claim the rewards they earned. Rewards are **only** claimable for the finished eras. + +It is still possible to _stake_ during this period, and stakers are encouraged to do so since this will increase the rewards they earn. +The only exemption is the **final era** of the `build&earn` subperiod - it's not possible to _stake_ then since the stake would be invalid anyhow (stake is only valid from the next era which would be in the next period). + +To continue the previous example where era length is **100** blocks, let's assume that `Build&Earn` subperiod lasts for 10 eras: +* Block 1, Era 1 starts, Period 1 starts, `Voting` subperiod starts +* Block 501, Era 2 starts, Period 1 continues, `Build&Earn` subperiod starts +* Block 601, Era 3 starts, Period 1 continues, `Build&Earn` subperiod continues +* Block 701, Era 4 starts, Period 1 continues, `Build&Earn` subperiod continues +* ... +* Block 1401, Era 11 starts, Period 1 continues, `Build&Earn` subperiod enters the final era +* Block 1501, Era 12 starts, Period 2 starts, `Voting` subperiod starts +* Block 2001, Era 13 starts, Period 2 continues, `Build&Earn` subperiod starts + +### dApps & Smart Contracts + +Protocol is called dApp staking, but internally it essentially works with smart contracts, or even more precise, smart contract addresses. + +Throughout the code, when addressing a particular dApp, it's addressed as `smart contract`. Naming of the types & storage more closely follows `dApp` nomenclature. + +#### Registration + +Projects, or _dApps_, must be registered into protocol to participate. +Only a privileged `ManagerOrigin` can perform dApp registration. +The pallet itself does not make assumptions who the privileged origin is, and it can differ from runtime to runtime. + +Once dApp has been registered, stakers can stake on it immediatelly. + +When contract is registered, it is assigned a unique compact numeric Id - 16 bit unsigned integer. This is important for the inner workings of the pallet, and is not directly exposed to the users. + +There is a limit of how many smart contracts can be registered at once. Once the limit is reached, any additional attempt to register a new contract will fail. + +#### Reward Beneficiary & Ownership + +After a dApp has been registered, it is possible to modify reward beneficiary or even the owner of the dApp. The owner can perform reward delegation and can further transfer ownership. + +#### Unregistration + +dApp can be removed from the procotol by unregistering it. +This is a privileged action that only `ManagerOrigin` can perform. + +After a dApp has been unregistered, it's no longer eligible to receive rewards. +It's still possible however to claim past unclaimed rewards. + +Important to note that even if dApp has been unregistered, it still occupies a _slot_ +in the dApp staking protocol and counts towards maximum number of registered dApps. +This will be improved in the future when dApp data will be cleaned up after some time. + +### Stakers + +#### Locking Tokens + +In order for users to participate in dApp staking, the first step they need to take is lock (or freeze) some native currency. Reserved tokens cannot be locked, but tokens locked by another lock can be re-locked into dApp staking (double locked). + +**NOTE:** Locked funds cannot be used for paying fees, or for transfer. + +In order to participate, user must have a `MinimumLockedAmount` of native currency locked. This doesn't mean that they cannot lock _less_ in a single call, but total locked amount must always be equal or greater than `MinimumLockedAmount`. + +In case amount specified for locking is greater than what user has available, only what's available will be locked. + +#### Unlocking Tokens + +User can at any time decide to unlock their tokens. However, it's not possible to unlock tokens which are staked, so user has to unstake them first. + +Once _unlock_ is successfully executed, the tokens aren't immediately unlocked, but instead must undergo the unlocking process. Once unlocking process has finished, user can _claim_ their unlocked tokens into their free balance. + +There is a limited number of `unlocking chunks` a user can have at any point in time. If limit is reached, user must claim existing unlocked chunks, or wait for them to be unlocked before claiming them to free up space for new chunks. + +In case calling unlocking some amount would take the user below the `MinimumLockedAmount`, **everything** will be unlocked. + +For users who decide they would rather re-lock their tokens then wait for the unlocking process to finish, there's an option to do so. All currently unlocking chunks are consumed, and added back into locked amount. + +#### Staking Tokens + +Locked tokens, which aren't being used for staking, can be used to stake on a dApp. This translates to _voting_ or _nominating_ a dApp to receive rewards derived from the inflation. User can stake on multiple dApps if they want to. + +The staked amount **must be precise**, no adjustment will be made by the pallet in case a too large amount is specified. + +The staked amount is only eligible for rewards from the next era - in other words, only the amount that has been staked for the entire era is eligible to receive rewards. + +It is not possible to stake if there are unclaimed rewards from past eras. User must ensure to first claim their pending rewards, before staking. This is also beneficial to the users since it allows them to lock & stake the earned rewards as well. + +User's stake on a contract must be equal or greater than the `MinimumStakeAmount`. This is similar to the minimum lock amount, but this limit is per contract. + +Although user can stake on multiple smart contracts, the amount is limited. To be more precise, amount of database entries that can exist per user is limited. + +The protocol keeps track of how much was staked by the user in `voting` and `build&earn` subperiod. This is important for the bonus reward calculation. + +It is not possible to stake on a dApp that has been unregistered. +However, if dApp is unregistered after user has staked on it, user will keep earning +rewards for the staked amount. + +#### Unstaking Tokens + +User can at any time decide to unstake staked tokens. There's no _unstaking_ process associated with this action. + +Unlike stake operation, which stakes from the _next_ era, unstake will reduce the staked amount for the current and next era if stake exists. + +Same as with stake operation, it's not possible to unstake anything until unclaimed rewards have been claimed. User must ensure to first claim all rewards, before attempting to unstake. Unstake amount must also be precise as no adjustment will be done to the amount. + +The amount unstaked will always first reduce the amount staked in the ongoing subperiod. E.g. if `voting` subperiod has stake of **100**, and `build&earn` subperiod has stake of **50**, calling unstake with amount **70** during `build&earn` subperiod will see `build&earn` stake amount reduced to **zero**, while `voting` stake will be reduced to **80**. + +If unstake would reduce the staked amount below `MinimumStakeAmount`, everything is unstaked. + +Once period finishes, all stakes are reset back to zero. This means that no unstake operation is needed after period ends to _unstake_ funds - it's done automatically. + +If dApp has been unregistered, a special operation to unstake from unregistered contract must be used. + +#### Claiming Staker Rewards + +Stakers can claim rewards for passed eras during which they were staking. Even if multiple contracts were staked, claim reward call will claim rewards for all of them. + +Only rewards for passed eras can be claimed. It is possible that a successful reward claim call will claim rewards for multiple eras. This can happen if staker hasn't claimed rewards in some time, and many eras have passed since then, accumulating pending rewards. + +To achieve this, the pallet's underyling storage organizes **era reward information** into **spans**. A single span covers multiple eras, e.g. from **1** to **16**. In case user has staked during era 1, and hasn't claimed rewards until era 17, they will be eligible to claim 15 rewards in total (from era 2 to 16). All of this will be done in a single claim reward call. + +In case unclaimed history has built up past one span, multiple reward claim calls will be needed to claim all of the rewards. + +Rewards don't remain available forever, and if not claimed within some time period, they will be treated as expired. This will be a longer period, but will still exist. + +Rewards are calculated using a simple formula: `staker_reward_pool * staker_staked_amount / total_staked_amount`. + +#### Claiming Bonus Reward + +If staker staked on a dApp during the voting subperiod, and didn't reduce their staked amount below what was staked at the end of the voting subperiod, this makes them eligible for the bonus reward. + +Bonus rewards need to be claimed per contract, unlike staker rewards. + +Bonus reward is calculated using a simple formula: `bonus_reward_pool * staker_voting_subperiod_stake / total_voting_subperiod_stake`. + +#### Handling Expired Entries + +There is a limit to how much contracts can a staker stake on at once. +Or to be more precise, for how many contract a database entry can exist at once. + +It's possible that stakers get themselves into a situation where some number of expired database entries associated to +their account has accumulated. In that case, it's required to call a special extrinsic to cleanup these expired entries. + +### Developers + +Main thing for developers to do is develop a good product & attract stakers to stake on them. + +#### Claiming dApp Reward + +If at the end of an build&earn subperiod era dApp has high enough score to enter a tier, it gets rewards assigned to it. +Rewards aren't paid out automatically but must be claimed instead, similar to staker & bonus rewards. + +When dApp reward is being claimed, both smart contract & claim era must be specified. + +dApp reward is calculated based on the tier in which ended. All dApps that end up in one tier will get the exact same reward. + +### Tier System + +At the end of each build&earn subperiod era, dApps are evaluated using a simple metric - total value staked on them. +Based on this metric, they are sorted, and assigned to tiers. + +There is a limited number of tiers, and each tier has a limited capacity of slots. +Each tier also has a _threshold_ which a dApp must satisfy in order to enter it. + +Better tiers bring bigger rewards, so dApps are encouraged to compete for higher tiers and attract staker's support. +For each tier, the reward pool and capacity are fixed. Each dApp within a tier always gets the same amount of reward. +Even if tier capacity hasn't been fully taken, rewards are paid out as if they were. + +For example, if tier 1 has capacity for 10 dApps, and reward pool is **500 ASTR**, it means that each dApp that ends up +in this tier will earn **50 ASTR**. Even if only 3 dApps manage to enter this tier, they will still earn each **50 ASTR**. +The rest, **350 ASTR** in this case, won't be minted (or will be _burned_ if the reader prefers such explanation). + +If there are more dApps eligible for a tier than there is capacity, the dApps with the higher score get the advantage. +dApps which missed out get priority for entry into the next lower tier (if there still is any). + +In the case a dApp doesn't satisfy the entry threshold for any tier, even though there is still capacity, the dApp will simply +be left out of tiers and won't earn **any** reward. + +In a special and unlikely case that two or more dApps have the exact same score and satisfy tier entry threshold, but there isn't enough +leftover tier capacity to accomodate them all, this is considered _undefined_ behavior. Some of the dApps will manage to enter the tier, while +others will be left out. There is no strict rule which defines this behavior - instead dApps are encouraged to ensure their tier entry by +having a larger stake than the other dApp(s). Tehnically, at the moment, the dApp with the lower `dApp Id` will have the advantage over a dApp with +the larger Id. + +### Reward Expiry + +Unclaimed rewards aren't kept indefinitely in storage. Eventually, they expire. +Stakers & developers should make sure they claim those rewards before this happens. + +In case they don't, they will simply miss on the earnings. + +However, this should not be a problem given how the system is designed. +There is no longer _stake&forger_ - users are expected to revisit dApp staking at least at the +beginning of each new period to pick out old or new dApps on which to stake on. +If they don't do that, they miss out on the bonus reward & won't earn staker rewards. \ No newline at end of file diff --git a/pallets/dapp-staking-v3/coverage_extrinsics.sh b/pallets/dapp-staking-v3/coverage_extrinsics.sh new file mode 100755 index 0000000000..6d0c77b3f0 --- /dev/null +++ b/pallets/dapp-staking-v3/coverage_extrinsics.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +targets=("register" "unregister" "set_dapp_reward_beneficiary" "set_dapp_owner" "maintenance_mode" \ + "lock" "unlock" "claim_unlocked" "relock_unlocking" \ + "stake" "unstake" "claim_staker_rewards" "claim_bonus_reward" "claim_dapp_reward" \ + "unstake_from_unregistered" "cleanup_expired_entries" "force" ) + +for target in "${targets[@]}" +do + cargo tarpaulin -p pallet-dapp-staking-v3 -o=html --output-dir=./coverage/$target -- test::tests::$target +done \ No newline at end of file diff --git a/pallets/dapp-staking-v3/coverage_types.sh b/pallets/dapp-staking-v3/coverage_types.sh new file mode 100755 index 0000000000..46710a672c --- /dev/null +++ b/pallets/dapp-staking-v3/coverage_types.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +targets=("protocol_state" "account_ledger" "dapp_info" "period_info" "era_info" \ + "stake_amount" "singular_staking_info" "contract_stake_amount" "era_reward_span" \ + "period_end_info" "era_stake_pair_iter" "tier_threshold" "tier_params" "tier_configuration" \ + "dapp_tier_rewards" ) + +for target in "${targets[@]}" +do + cargo tarpaulin -p pallet-dapp-staking-v3 -o=html --output-dir=./coverage/$target -- $target +done + +# Also need to check the coverage when only running extrinsic tests (disable type tests) + +# Also need similar approach to extrinsic testing, as above + + +# NOTE: this script will be deleted before the final release! \ No newline at end of file diff --git a/pallets/dapp-staking-v3/rpc/runtime-api/Cargo.toml b/pallets/dapp-staking-v3/rpc/runtime-api/Cargo.toml new file mode 100644 index 0000000000..559d5f299b --- /dev/null +++ b/pallets/dapp-staking-v3/rpc/runtime-api/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "dapp-staking-v3-runtime-api" +version = "0.0.1-alpha" +description = "dApp Staking v3 runtime API" +authors.workspace = true +edition.workspace = true +homepage.workspace = true +repository.workspace = true + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +sp-api = { workspace = true } + +astar-primitives = { workspace = true } +pallet-dapp-staking-v3 = { workspace = true } + +[features] +default = ["std"] +std = [ + "sp-api/std", + "pallet-dapp-staking-v3/std", + "astar-primitives/std", +] diff --git a/pallets/dapp-staking-v3/rpc/runtime-api/src/lib.rs b/pallets/dapp-staking-v3/rpc/runtime-api/src/lib.rs new file mode 100644 index 0000000000..dde32bf695 --- /dev/null +++ b/pallets/dapp-staking-v3/rpc/runtime-api/src/lib.rs @@ -0,0 +1,40 @@ +// 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 . + +#![cfg_attr(not(feature = "std"), no_std)] + +use astar_primitives::BlockNumber; +use pallet_dapp_staking_v3::EraNumber; + +sp_api::decl_runtime_apis! { + + /// dApp Staking Api. + /// + /// Used to provide information otherwise not available via RPC. + pub trait DappStakingApi { + + /// For how many standard era lengths does the voting subperiod last. + fn eras_per_voting_subperiod() -> EraNumber; + + /// How many standard eras are there in the build&earn subperiod. + fn eras_per_build_and_earn_subperiod() -> EraNumber; + + /// How many blocks are there per standard era. + fn blocks_per_era() -> BlockNumber; + } +} diff --git a/pallets/dapp-staking-v3/src/benchmarking/mod.rs b/pallets/dapp-staking-v3/src/benchmarking/mod.rs new file mode 100644 index 0000000000..11663401d9 --- /dev/null +++ b/pallets/dapp-staking-v3/src/benchmarking/mod.rs @@ -0,0 +1,1012 @@ +// 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 super::{Pallet as DappStaking, *}; + +use astar_primitives::Balance; +use frame_benchmarking::v2::*; + +use frame_support::assert_ok; +use frame_system::{Pallet as System, RawOrigin}; + +use ::assert_matches::assert_matches; + +mod utils; +use utils::*; + +// A lot of benchmarks which require many blocks, eras or periods to pass have been optimized to utilize +// `force` approach, which skips the required amount of blocks that need to be produced in order to advance. +// +// Without this optimization, benchmarks can take hours to execute for production runtimes. + +#[benchmarks] +mod benchmarks { + use super::*; + + #[benchmark] + fn maintenance_mode() { + initial_config::(); + + #[extrinsic_call] + _(RawOrigin::Root, true); + + assert_last_event::(Event::::MaintenanceMode { enabled: true }.into()); + } + + #[benchmark] + fn register() { + initial_config::(); + + let account: T::AccountId = account("dapp_owner", 0, SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + + #[extrinsic_call] + _(RawOrigin::Root, account.clone(), smart_contract.clone()); + + assert_last_event::( + Event::::DAppRegistered { + owner: account, + smart_contract, + dapp_id: 0, + } + .into(), + ); + } + + #[benchmark] + fn set_dapp_reward_beneficiary() { + initial_config::(); + + let owner: T::AccountId = whitelisted_caller(); + let beneficiary: Option = Some(account("beneficiary", 0, SEED)); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + #[extrinsic_call] + _( + RawOrigin::Signed(owner), + smart_contract.clone(), + beneficiary.clone(), + ); + + assert_last_event::( + Event::::DAppRewardDestinationUpdated { + smart_contract, + beneficiary, + } + .into(), + ); + } + + #[benchmark] + fn set_dapp_owner() { + initial_config::(); + + let init_owner: T::AccountId = whitelisted_caller(); + let new_owner: T::AccountId = account("dapp_owner", 0, SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + init_owner.clone().into(), + smart_contract.clone(), + )); + + #[extrinsic_call] + _( + RawOrigin::Signed(init_owner), + smart_contract.clone(), + new_owner.clone(), + ); + + assert_last_event::( + Event::::DAppOwnerChanged { + smart_contract, + new_owner, + } + .into(), + ); + } + + #[benchmark] + fn unregister() { + initial_config::(); + + let owner: T::AccountId = whitelisted_caller(); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + #[extrinsic_call] + _(RawOrigin::Root, smart_contract.clone()); + + assert_last_event::( + Event::::DAppUnregistered { + smart_contract, + era: ActiveProtocolState::::get().era, + } + .into(), + ); + } + + #[benchmark] + fn lock() { + initial_config::(); + + let staker: T::AccountId = whitelisted_caller(); + let owner: T::AccountId = account("dapp_owner", 0, SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + let amount = T::MinimumLockedAmount::get(); + T::BenchmarkHelper::set_balance(&staker, amount); + + #[extrinsic_call] + _(RawOrigin::Signed(staker.clone()), amount); + + assert_last_event::( + Event::::Locked { + account: staker, + amount, + } + .into(), + ); + } + + #[benchmark] + fn unlock() { + initial_config::(); + + let staker: T::AccountId = whitelisted_caller(); + let owner: T::AccountId = account("dapp_owner", 0, SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + let amount = T::MinimumLockedAmount::get() * 2; + T::BenchmarkHelper::set_balance(&staker, amount); + assert_ok!(DappStaking::::lock( + RawOrigin::Signed(staker.clone()).into(), + amount, + )); + + #[extrinsic_call] + _(RawOrigin::Signed(staker.clone()), 1); + + assert_last_event::( + Event::::Unlocking { + account: staker, + amount: 1, + } + .into(), + ); + } + + #[benchmark] + fn claim_unlocked(x: Linear<0, { T::MaxNumberOfStakedContracts::get() }>) { + // Prepare staker account and lock some amount + let staker: T::AccountId = whitelisted_caller(); + let amount = (T::MinimumStakeAmount::get() + 1) + * Into::::into(max_number_of_contracts::()) + + Into::::into(T::MaxUnlockingChunks::get()); + T::BenchmarkHelper::set_balance(&staker, amount); + assert_ok!(DappStaking::::lock( + RawOrigin::Signed(staker.clone()).into(), + amount, + )); + + // Move over to the build&earn subperiod to ensure 'non-loyal' staking. + // This is needed so we can achieve staker entry cleanup after claiming unlocked tokens. + force_advance_to_next_subperiod::(); + assert_eq!( + ActiveProtocolState::::get().subperiod(), + Subperiod::BuildAndEarn, + "Sanity check - we need to stake during build&earn for entries to be cleaned up in the next era." + ); + + // Register required number of contracts and have staker stake on them. + // This is needed to achieve the cleanup functionality. + for idx in 0..x { + let smart_contract = T::BenchmarkHelper::get_smart_contract(idx as u32); + let owner: T::AccountId = account("dapp_owner", idx.into(), SEED); + + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + assert_ok!(DappStaking::::stake( + RawOrigin::Signed(staker.clone()).into(), + smart_contract, + T::MinimumStakeAmount::get() + 1, + )); + } + + // Unlock some amount - but we want to fill up the whole vector with chunks. + let unlock_amount = 1; + for _ in 0..T::MaxUnlockingChunks::get() { + assert_ok!(DappStaking::::unlock( + RawOrigin::Signed(staker.clone()).into(), + unlock_amount, + )); + run_for_blocks::(One::one()); + } + assert_eq!( + Ledger::::get(&staker).unlocking.len(), + T::MaxUnlockingChunks::get() as usize + ); + let unlock_amount = unlock_amount * Into::::into(T::MaxUnlockingChunks::get()); + + // Hack + // In order to speed up the benchmark, we reduce how long it takes to unlock the chunks + let mut counter = 1; + Ledger::::mutate(&staker, |ledger| { + ledger.unlocking.iter_mut().for_each(|unlocking| { + unlocking.unlock_block = System::::block_number() + counter; + }); + counter += 1; + }); + + // Advance to next period to ensure the old stake entries are cleaned up. + force_advance_to_next_period::(); + + // Additionally, ensure enough blocks have passed so that the unlocking chunk can be claimed. + let unlock_block = Ledger::::get(&staker) + .unlocking + .last() + .expect("At least one entry must exist.") + .unlock_block; + run_to_block::(unlock_block); + + #[extrinsic_call] + _(RawOrigin::Signed(staker.clone())); + + assert_last_event::( + Event::::ClaimedUnlocked { + account: staker, + amount: unlock_amount, + } + .into(), + ); + } + + #[benchmark] + fn relock_unlocking() { + initial_config::(); + + let staker: T::AccountId = whitelisted_caller(); + let owner: T::AccountId = account("dapp_owner", 0, SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + let amount = + T::MinimumLockedAmount::get() * 2 + Into::::into(T::MaxUnlockingChunks::get()); + T::BenchmarkHelper::set_balance(&staker, amount); + assert_ok!(DappStaking::::lock( + RawOrigin::Signed(staker.clone()).into(), + amount, + )); + + // Unlock some amount - but we want to fill up the whole vector with chunks. + let unlock_amount = 1; + for _ in 0..T::MaxUnlockingChunks::get() { + assert_ok!(DappStaking::::unlock( + RawOrigin::Signed(staker.clone()).into(), + unlock_amount, + )); + run_for_blocks::(One::one()); + } + let unlock_amount = unlock_amount * Into::::into(T::MaxUnlockingChunks::get()); + + #[extrinsic_call] + _(RawOrigin::Signed(staker.clone())); + + assert_last_event::( + Event::::Relock { + account: staker, + amount: unlock_amount, + } + .into(), + ); + } + + #[benchmark] + fn stake() { + initial_config::(); + + let staker: T::AccountId = whitelisted_caller(); + let owner: T::AccountId = account("dapp_owner", 0, SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + let amount = T::MinimumLockedAmount::get(); + T::BenchmarkHelper::set_balance(&staker, amount); + assert_ok!(DappStaking::::lock( + RawOrigin::Signed(staker.clone()).into(), + amount, + )); + + #[extrinsic_call] + _( + RawOrigin::Signed(staker.clone()), + smart_contract.clone(), + amount, + ); + + assert_last_event::( + Event::::Stake { + account: staker, + smart_contract, + amount, + } + .into(), + ); + } + + #[benchmark] + fn unstake() { + initial_config::(); + + let staker: T::AccountId = whitelisted_caller(); + let owner: T::AccountId = account("dapp_owner", 0, SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + let amount = T::MinimumLockedAmount::get() + 1; + 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(), + smart_contract.clone(), + amount + )); + + let unstake_amount = 1; + + #[extrinsic_call] + _( + RawOrigin::Signed(staker.clone()), + smart_contract.clone(), + unstake_amount, + ); + + assert_last_event::( + Event::::Unstake { + account: staker, + smart_contract, + amount: unstake_amount, + } + .into(), + ); + } + + #[benchmark] + fn claim_staker_rewards_past_period(x: Linear<1, { T::EraRewardSpanLength::get() }>) { + initial_config::(); + + // Prepare staker & register smart contract + let staker: T::AccountId = whitelisted_caller(); + let owner: T::AccountId = account("dapp_owner", 0, SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + // Lock & stake some amount by the staker + let amount = 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(), + smart_contract.clone(), + amount + )); + + // Advance to era just after the last era covered by the first span + force_advance_to_era::(T::EraRewardSpanLength::get()); + + // Hack - modify staker's stake so it seems as if stake was valid from the 'first stake era'/ + // Also fill up the reward span. + // + // This allows us to easily control how many rewards are claimed, without having to advance large amount of blocks/eras/periods + // to find an appropriate scenario. + let first_stake_era = T::EraRewardSpanLength::get() - x; + Ledger::::mutate(&staker, |ledger| { + ledger.staked = ledger.staked_future.unwrap(); + ledger.staked_future = None; + ledger.staked.era = first_stake_era; + }); + + // Just fill them up, the ledger entry will control how much claims we can make + let mut reward_span = EraRewardSpan::<_>::new(); + for era in 0..(T::EraRewardSpanLength::get()) { + assert_ok!(reward_span.push( + era as EraNumber, + EraReward { + staker_reward_pool: 1_000_000_000_000, + staked: amount, + dapp_reward_pool: 1_000_000_000_000, + }, + )); + } + EraRewards::::insert(&0, reward_span); + + // This ensures we claim from the past period. + force_advance_to_next_period::(); + + // For testing purposes + System::::reset_events(); + + #[extrinsic_call] + claim_staker_rewards(RawOrigin::Signed(staker.clone())); + + // No need to do precise check of values, but predetermiend amount of 'Reward' events is expected. + let dapp_staking_events = dapp_staking_events::(); + assert_eq!(dapp_staking_events.len(), x as usize); + dapp_staking_events.iter().for_each(|e| { + assert_matches!(e, Event::Reward { .. }); + }); + } + + #[benchmark] + fn claim_staker_rewards_ongoing_period(x: Linear<1, { T::EraRewardSpanLength::get() }>) { + initial_config::(); + + // Prepare staker & register smart contract + let staker: T::AccountId = whitelisted_caller(); + let owner: T::AccountId = account("dapp_owner", 0, SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + // Lock & stake some amount by the staker + let amount = 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(), + smart_contract.clone(), + amount + )); + + // Advance to era just after the last era covered by the first span + // This means we'll be able to claim all of the rewards from the previous span. + force_advance_to_era::(T::EraRewardSpanLength::get()); + + // Hack - modify staker's stake so it seems as if stake was valid from the 'first stake era'/ + // Also fill up the reward span. + // + // This allows us to easily control how many rewards are claimed, without having to advance large amount of blocks/eras/periods + // to find an appropriate scenario. + let first_stake_era = T::EraRewardSpanLength::get() - x; + Ledger::::mutate(&staker, |ledger| { + ledger.staked = ledger.staked_future.unwrap(); + ledger.staked_future = None; + ledger.staked.era = first_stake_era; + }); + + // Just fill them up, the ledger entry will control how much claims we can make + let mut reward_span = EraRewardSpan::<_>::new(); + for era in 0..(T::EraRewardSpanLength::get()) { + assert_ok!(reward_span.push( + era as EraNumber, + EraReward { + staker_reward_pool: 1_000_000_000_000, + staked: amount, + dapp_reward_pool: 1_000_000_000_000, + }, + )); + } + EraRewards::::insert(&0, reward_span); + + // For testing purposes + System::::reset_events(); + + #[extrinsic_call] + claim_staker_rewards(RawOrigin::Signed(staker.clone())); + + // No need to do precise check of values, but predetermiend amount of 'Reward' events is expected. + let dapp_staking_events = dapp_staking_events::(); + assert_eq!(dapp_staking_events.len(), x as usize); + dapp_staking_events.iter().for_each(|e| { + assert_matches!(e, Event::Reward { .. }); + }); + } + + #[benchmark] + fn claim_bonus_reward() { + initial_config::(); + + // Prepare staker & register smart contract + let staker: T::AccountId = whitelisted_caller(); + let owner: T::AccountId = account("dapp_owner", 0, SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + // Lock & stake some amount by the staker + let amount = 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(), + smart_contract.clone(), + amount + )); + + // Advance to the next period so we can claim the bonus reward. + force_advance_to_next_period::(); + + #[extrinsic_call] + _(RawOrigin::Signed(staker.clone()), smart_contract.clone()); + + // No need to do precise check of values, but last event must be 'BonusReward'. + assert_matches!( + dapp_staking_events::().last(), + Some(Event::BonusReward { .. }) + ); + } + + #[benchmark] + fn claim_dapp_reward() { + initial_config::(); + + // Register a dApp & stake on it. + // This is the dApp for which we'll claim rewards for. + let owner: T::AccountId = whitelisted_caller(); + let smart_contract = T::BenchmarkHelper::get_smart_contract(0); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + let amount = MIN_TIER_THRESHOLD * 1000; + T::BenchmarkHelper::set_balance(&owner, amount); + assert_ok!(DappStaking::::lock( + RawOrigin::Signed(owner.clone()).into(), + amount, + )); + assert_ok!(DappStaking::::stake( + RawOrigin::Signed(owner.clone()).into(), + smart_contract.clone(), + amount + )); + + // Register & stake up to max number of contracts. + // The reason is we want to have reward vector filled up to the capacity. + for idx in 1..T::MaxNumberOfContracts::get() { + let owner: T::AccountId = account("dapp_owner", idx.into(), SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(idx as u32); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + let staker: T::AccountId = account("staker", idx.into(), SEED); + 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(), + smart_contract.clone(), + amount + )); + } + + // Advance enough eras so dApp reward can be claimed. + force_advance_to_next_subperiod::(); + + // This is a hacky part to ensure we accomodate max number of contracts. + TierConfig::::mutate(|config| { + let max_number_of_contracts: u16 = T::MaxNumberOfContracts::get().try_into().unwrap(); + config.number_of_slots = max_number_of_contracts; + config.slots_per_tier[0] = max_number_of_contracts; + config.slots_per_tier[1..].iter_mut().for_each(|x| *x = 0); + config.tier_thresholds[0] = TierThreshold::FixedTvlAmount { amount: 1 }; + }); + force_advance_to_next_era::(); + let claim_era = ActiveProtocolState::::get().era - 1; + + assert_eq!( + DAppTiers::::get(claim_era) + .expect("Must exist since it's from past build&earn era.") + .dapps + .len(), + T::MaxNumberOfContracts::get() as usize, + "Sanity check to ensure we have filled up the vector completely." + ); + + #[extrinsic_call] + _( + RawOrigin::Signed(owner.clone()), + smart_contract.clone(), + claim_era, + ); + + // No need to do precise check of values, but last event must be 'DAppReward'. + assert_matches!( + dapp_staking_events::().last(), + Some(Event::DAppReward { .. }) + ); + } + + #[benchmark] + fn unstake_from_unregistered() { + initial_config::(); + + let staker: T::AccountId = whitelisted_caller(); + let owner: T::AccountId = account("dapp_owner", 0, SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + let amount = 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(), + smart_contract.clone(), + amount + )); + + assert_ok!(DappStaking::::unregister( + RawOrigin::Root.into(), + smart_contract.clone(), + )); + + #[extrinsic_call] + _(RawOrigin::Signed(staker.clone()), smart_contract.clone()); + + assert_last_event::( + Event::::UnstakeFromUnregistered { + account: staker, + smart_contract, + amount, + } + .into(), + ); + } + + #[benchmark] + fn cleanup_expired_entries(x: Linear<1, { T::MaxNumberOfStakedContracts::get() }>) { + initial_config::(); + + // Move over to the build&earn subperiod to ensure 'non-loyal' staking. + force_advance_to_next_subperiod::(); + + // Prepare staker & lock some amount + let staker: T::AccountId = whitelisted_caller(); + let amount = T::MinimumLockedAmount::get() + * Into::::into(T::MaxNumberOfStakedContracts::get()); + T::BenchmarkHelper::set_balance(&staker, amount); + assert_ok!(DappStaking::::lock( + RawOrigin::Signed(staker.clone()).into(), + amount, + )); + + // Register dApps up the the limit + for idx in 0..x { + let owner: T::AccountId = account("dapp_owner", idx.into(), SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(idx as u32); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + assert_ok!(DappStaking::::stake( + RawOrigin::Signed(staker.clone()).into(), + smart_contract.clone(), + T::MinimumStakeAmount::get(), + )); + } + + // Move over to the next period, marking the entries as expired since they don't have the loyalty flag. + force_advance_to_next_period::(); + + #[extrinsic_call] + _(RawOrigin::Signed(staker.clone())); + + assert_last_event::( + Event::::ExpiredEntriesRemoved { + account: staker, + count: x.try_into().unwrap(), + } + .into(), + ); + } + + #[benchmark] + fn force() { + initial_config::(); + + let forcing_type = ForcingType::Subperiod; + + #[extrinsic_call] + _(RawOrigin::Root, forcing_type); + + assert_last_event::(Event::::Force { forcing_type }.into()); + } + + #[benchmark] + fn on_initialize_voting_to_build_and_earn() { + initial_config::(); + + let state = ActiveProtocolState::::get(); + assert_eq!(state.subperiod(), Subperiod::Voting, "Sanity check."); + + // Register & stake contracts, just so we don't have empty stakes. + prepare_contracts_for_tier_assignment::(max_number_of_contracts::()); + + run_to_block::(state.next_era_start - 1); + DappStaking::::on_finalize(state.next_era_start - 1); + System::::set_block_number(state.next_era_start); + + #[block] + { + DappStaking::::era_and_period_handler(state.next_era_start, TierAssignment::Dummy); + } + + assert_eq!( + ActiveProtocolState::::get().subperiod(), + Subperiod::BuildAndEarn + ); + } + + #[benchmark] + fn on_initialize_build_and_earn_to_voting() { + initial_config::(); + + // Get started + assert_eq!( + ActiveProtocolState::::get().subperiod(), + Subperiod::Voting, + "Sanity check." + ); + + // Register & stake contracts, just so we don't have empty stakes. + prepare_contracts_for_tier_assignment::(max_number_of_contracts::()); + + // Advance to build&earn subperiod + force_advance_to_next_subperiod::(); + let snapshot_state = ActiveProtocolState::::get(); + + // Advance over to the last era of the subperiod, and then again to the last block of that era. + advance_to_era::( + ActiveProtocolState::::get() + .period_info + .next_subperiod_start_era + - 1, + ); + run_to_block::(ActiveProtocolState::::get().next_era_start - 1); + + // Some sanity checks, we should still be in the build&earn subperiod, and in the first period. + assert_eq!( + ActiveProtocolState::::get().subperiod(), + Subperiod::BuildAndEarn + ); + assert_eq!( + ActiveProtocolState::::get().period_number(), + snapshot_state.period_number(), + ); + + let new_era_start_block = ActiveProtocolState::::get().next_era_start; + DappStaking::::on_finalize(new_era_start_block - 1); + System::::set_block_number(new_era_start_block); + + #[block] + { + DappStaking::::era_and_period_handler(new_era_start_block, TierAssignment::Dummy); + } + + assert_eq!( + ActiveProtocolState::::get().subperiod(), + Subperiod::Voting + ); + assert_eq!( + ActiveProtocolState::::get().period_number(), + snapshot_state.period_number() + 1, + ); + } + + #[benchmark] + fn on_initialize_build_and_earn_to_build_and_earn() { + initial_config::(); + + // Register & stake contracts, just so we don't have empty stakes. + prepare_contracts_for_tier_assignment::(max_number_of_contracts::()); + + // Advance to build&earn subperiod + force_advance_to_next_subperiod::(); + let snapshot_state = ActiveProtocolState::::get(); + + // Advance over to the next era, and then again to the last block of that era. + force_advance_to_next_era::(); + run_to_block::(ActiveProtocolState::::get().next_era_start - 1); + + // Some sanity checks, we should still be in the build&earn subperiod, and in the first period. + assert_eq!( + ActiveProtocolState::::get().subperiod(), + Subperiod::BuildAndEarn + ); + assert_eq!( + ActiveProtocolState::::get().period_number(), + snapshot_state.period_number(), + ); + + let new_era_start_block = ActiveProtocolState::::get().next_era_start; + DappStaking::::on_finalize(new_era_start_block - 1); + System::::set_block_number(new_era_start_block); + + #[block] + { + DappStaking::::era_and_period_handler(new_era_start_block, TierAssignment::Dummy); + } + + assert_eq!( + ActiveProtocolState::::get().subperiod(), + Subperiod::BuildAndEarn + ); + assert_eq!( + ActiveProtocolState::::get().period_number(), + snapshot_state.period_number(), + ); + } + + // Investigate why the PoV size is so large here, even after removing read of `IntegratedDApps` storage. + // Relevant file: polkadot-sdk/substrate/utils/frame/benchmarking-cli/src/pallet/writer.rs + // UPDATE: after some investigation, it seems that PoV size benchmarks are very unprecise + // - the worst case measured is usually very far off the actual value that is consumed on chain. + // There's an ongoing item to improve it (mentioned on roundtable meeting). + #[benchmark] + fn dapp_tier_assignment(x: Linear<0, { max_number_of_contracts::() }>) { + // Prepare init config (protocol state, tier params & config, etc.) + initial_config::(); + + // Register & stake contracts, to prepare for tier assignment. + prepare_contracts_for_tier_assignment::(x); + force_advance_to_next_era::(); + + let reward_era = ActiveProtocolState::::get().era; + let reward_period = ActiveProtocolState::::get().period_number(); + let reward_pool = Balance::from(10_000 * UNIT as u128); + + #[block] + { + let (dapp_tiers, _) = + Pallet::::get_dapp_tier_assignment(reward_era, reward_period, reward_pool); + assert_eq!(dapp_tiers.dapps.len(), x as usize); + } + } + + #[benchmark] + fn on_idle_cleanup() { + // Prepare init config (protocol state, tier params & config, etc.) + initial_config::(); + + // Advance to era just after the last era covered by the first span. + // This is sufficient to completely fill up the first span with entries for the ongoing era. + force_advance_to_era::(T::EraRewardSpanLength::get()); + + // Advance enough periods to make cleanup feasible. + let retention_period = T::RewardRetentionInPeriods::get(); + force_advance_to_period::( + ActiveProtocolState::::get().period_number() + retention_period + 2, + ); + + let first_era_span_index = 0; + assert!( + EraRewards::::contains_key(first_era_span_index), + "Sanity check - era reward span entry must exist." + ); + let first_period = 1; + assert!( + PeriodEnd::::contains_key(first_period), + "Sanity check - period end info must exist." + ); + let block_number = System::::block_number(); + + #[block] + { + DappStaking::::on_idle(block_number, Weight::MAX); + } + + assert!( + !EraRewards::::contains_key(first_era_span_index), + "Entry should have been cleaned up." + ); + assert!( + !PeriodEnd::::contains_key(first_period), + "Period end info should have been cleaned up." + ); + } + + impl_benchmark_test_suite!( + Pallet, + crate::benchmarking::tests::new_test_ext(), + crate::test::mock::Test, + ); +} + +#[cfg(test)] +mod tests { + use crate::test::mock; + use sp_io::TestExternalities; + + pub fn new_test_ext() -> TestExternalities { + mock::ExtBuilder::build() + } +} diff --git a/pallets/dapp-staking-v3/src/benchmarking/utils.rs b/pallets/dapp-staking-v3/src/benchmarking/utils.rs new file mode 100644 index 0000000000..28dde52267 --- /dev/null +++ b/pallets/dapp-staking-v3/src/benchmarking/utils.rs @@ -0,0 +1,285 @@ +// 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 super::{Pallet as DappStaking, *}; + +use astar_primitives::Balance; + +use frame_system::Pallet as System; + +/// Run to the specified block number. +/// Function assumes first block has been initialized. +pub(super) fn run_to_block(n: BlockNumberFor) { + 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(super) fn run_for_blocks(n: BlockNumberFor) { + 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(super) fn advance_to_era(era: EraNumber) { + assert!(era >= ActiveProtocolState::::get().era); + while ActiveProtocolState::::get().era < era { + run_for_blocks::(One::one()); + } +} + +/// Advance blocks until the specified era has been reached. +/// +/// Relies on the `force` approach to advance one era per block. +pub(super) fn force_advance_to_era(era: EraNumber) { + assert!(era >= ActiveProtocolState::::get().era); + while ActiveProtocolState::::get().era < era { + assert_ok!(DappStaking::::force( + RawOrigin::Root.into(), + ForcingType::Era + )); + run_for_blocks::(One::one()); + } +} + +/// Advance blocks until next era has been reached. +pub(super) fn _advance_to_next_era() { + advance_to_era::(ActiveProtocolState::::get().era + 1); +} + +/// Advance to next era, in the next block using the `force` approach. +pub(crate) fn force_advance_to_next_era() { + assert_ok!(DappStaking::::force( + RawOrigin::Root.into(), + ForcingType::Era + )); + run_for_blocks::(One::one()); +} + +/// Advance blocks until the specified period has been reached. +/// +/// Function has no effect if period is already passed. +pub(super) fn _advance_to_period(period: PeriodNumber) { + assert!(period >= ActiveProtocolState::::get().period_number()); + while ActiveProtocolState::::get().period_number() < period { + run_for_blocks::(One::one()); + } +} + +/// Advance to the specified period, using the `force` approach. +pub(super) fn force_advance_to_period(period: PeriodNumber) { + assert!(period >= ActiveProtocolState::::get().period_number()); + while ActiveProtocolState::::get().period_number() < period { + force_advance_to_next_subperiod::(); + } +} + +/// Advance blocks until next period has been reached. +pub(super) fn _advance_to_next_period() { + _advance_to_period::(ActiveProtocolState::::get().period_number() + 1); +} + +/// Advance blocks until next period has been reached. +/// +/// Relies on the `force` approach to advance one subperiod per block. +pub(super) fn force_advance_to_next_period() { + let init_period_number = ActiveProtocolState::::get().period_number(); + while ActiveProtocolState::::get().period_number() == init_period_number { + assert_ok!(DappStaking::::force( + RawOrigin::Root.into(), + ForcingType::Subperiod + )); + run_for_blocks::(One::one()); + } +} + +/// Advance blocks until next period type has been reached. +pub(super) fn _advance_to_next_subperiod() { + let subperiod = ActiveProtocolState::::get().subperiod(); + while ActiveProtocolState::::get().subperiod() == subperiod { + run_for_blocks::(One::one()); + } +} + +/// Use the `force` approach to advance to the next subperiod immediately in the next block. +pub(super) fn force_advance_to_next_subperiod() { + assert_ok!(DappStaking::::force( + RawOrigin::Root.into(), + ForcingType::Subperiod + )); + run_for_blocks::(One::one()); +} + +/// All our networks use 18 decimals for native currency so this should be fine. +pub(super) const UNIT: Balance = 1_000_000_000_000_000_000; + +/// Minimum amount that must be staked on a dApp to enter any tier +pub(super) const MIN_TIER_THRESHOLD: Balance = 10 * UNIT; + +/// Number of slots in the tier system. +pub(super) const NUMBER_OF_SLOTS: u32 = 100; + +/// Random seed. +pub(super) const SEED: u32 = 9000; + +/// Assert that the last event equals the provided one. +pub(super) fn assert_last_event(generic_event: ::RuntimeEvent) { + frame_system::Pallet::::assert_last_event(generic_event.into()); +} + +// Return all dApp staking events from the event buffer. +pub(super) fn dapp_staking_events() -> Vec> { + System::::events() + .into_iter() + .map(|r| r.event) + .filter_map(|e| ::RuntimeEvent::from(e).try_into().ok()) + .collect::>() +} + +/// Initialize dApp staking pallet with initial config. +/// +/// **NOTE:** This assumes similar tier configuration for all runtimes. +/// If we decide to change this, we'll need to provide a more generic init function. +pub(super) fn initial_config() { + let era_length = T::CycleConfiguration::blocks_per_era(); + let voting_period_length_in_eras = T::CycleConfiguration::eras_per_voting_subperiod(); + + // Init protocol state + ActiveProtocolState::::put(ProtocolState { + era: 1, + next_era_start: era_length.saturating_mul(voting_period_length_in_eras.into()) + 1, + period_info: PeriodInfo { + number: 1, + subperiod: Subperiod::Voting, + next_subperiod_start_era: 2, + }, + maintenance: false, + }); + + // Init tier params + let tier_params = TierParameters:: { + reward_portion: BoundedVec::try_from(vec![ + Permill::from_percent(40), + Permill::from_percent(30), + Permill::from_percent(20), + Permill::from_percent(10), + ]) + .unwrap(), + slot_distribution: BoundedVec::try_from(vec![ + Permill::from_percent(10), + Permill::from_percent(20), + Permill::from_percent(30), + Permill::from_percent(40), + ]) + .unwrap(), + tier_thresholds: BoundedVec::try_from(vec![ + TierThreshold::DynamicTvlAmount { + amount: 100 * UNIT, + minimum_amount: 80 * UNIT, + }, + TierThreshold::DynamicTvlAmount { + amount: 50 * UNIT, + minimum_amount: 40 * UNIT, + }, + TierThreshold::DynamicTvlAmount { + amount: 20 * UNIT, + minimum_amount: 20 * UNIT, + }, + TierThreshold::FixedTvlAmount { + amount: MIN_TIER_THRESHOLD, + }, + ]) + .unwrap(), + }; + + // Init tier config, based on the initial params + let init_tier_config = TiersConfiguration:: { + number_of_slots: NUMBER_OF_SLOTS.try_into().unwrap(), + slots_per_tier: BoundedVec::try_from(vec![10, 20, 30, 40]).unwrap(), + reward_portion: tier_params.reward_portion.clone(), + tier_thresholds: tier_params.tier_thresholds.clone(), + }; + + assert!(tier_params.is_valid()); + assert!(init_tier_config.is_valid()); + + StaticTierParams::::put(tier_params); + TierConfig::::put(init_tier_config.clone()); +} + +/// Maximum number of contracts that 'makes sense' - considers both contract number limit & number of slots. +pub(super) fn max_number_of_contracts() -> u32 { + T::MaxNumberOfContracts::get().min(NUMBER_OF_SLOTS).into() +} + +/// Registers & staked on the specified number of smart contracts +/// +/// Stake amounts are decided in such a way to maximize tier filling rate. +/// This means that all of the contracts should end up in some tier. +pub(super) fn prepare_contracts_for_tier_assignment(x: u32) { + let developer: T::AccountId = whitelisted_caller(); + for id in 0..x { + let smart_contract = T::BenchmarkHelper::get_smart_contract(id as u32); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + developer.clone().into(), + smart_contract, + )); + } + + let anchor_amount = 1000 * MIN_TIER_THRESHOLD; + let mut amounts: Vec<_> = (0..x) + .map(|i| anchor_amount - UNIT * i as Balance) + .collect(); + trivial_fisher_yates_shuffle(&mut amounts, SEED.into()); + + for id in 0..x { + let amount = amounts[id as usize]; + let staker = account("staker", id.into(), 1337); + T::BenchmarkHelper::set_balance(&staker, amount); + assert_ok!(DappStaking::::lock( + RawOrigin::Signed(staker.clone()).into(), + amount, + )); + + let smart_contract = T::BenchmarkHelper::get_smart_contract(id as u32); + assert_ok!(DappStaking::::stake( + RawOrigin::Signed(staker.clone()).into(), + smart_contract, + amount, + )); + } +} + +/// Reuse from `sassafras` pallet tests. +/// +/// Just a trivial, insecure shuffle for the benchmarks. +fn trivial_fisher_yates_shuffle(vector: &mut Vec, random_seed: u64) { + let mut rng = random_seed as usize; + for i in (1..vector.len()).rev() { + let j = rng % (i + 1); + vector.swap(i, j); + rng = (rng.wrapping_mul(8427637) + 1) as usize; // Some random number generation + } +} diff --git a/pallets/dapp-staking-v3/src/lib.rs b/pallets/dapp-staking-v3/src/lib.rs new file mode 100644 index 0000000000..f9a10da274 --- /dev/null +++ b/pallets/dapp-staking-v3/src/lib.rs @@ -0,0 +1,2028 @@ +// 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 +//! +//! For detailed high level documentation, please refer to the attached README.md file. +//! The crate level docs will cover overal pallet structure & implementation details. +//! +//! ## Overview +//! +//! Pallet that implements the dApp staking v3 protocol. +//! It covers everything from locking, staking, tier configuration & assignment, reward calculation & payout. +//! +//! The `types` module contains all of the types used to implement the pallet. +//! All of these _types_ are exentisvely tested in their dedicated `test_types` module. +//! +//! Rest of the pallet logic is concenrated in the lib.rs file. +//! This logic is tested in the `tests` module, with the help of extensive `testing_utils`. +//! + +#![cfg_attr(not(feature = "std"), no_std)] + +use frame_support::{ + pallet_prelude::*, + traits::{ + fungible::{Inspect as FunInspect, MutateFreeze as FunMutateFreeze}, + OnRuntimeUpgrade, StorageVersion, + }, + weights::Weight, +}; +use frame_system::pallet_prelude::*; +use sp_runtime::{ + traits::{BadOrigin, One, Saturating, UniqueSaturatedInto, Zero}, + Perbill, Permill, +}; +pub use sp_std::vec::Vec; + +use astar_primitives::{ + dapp_staking::{CycleConfiguration, SmartContractHandle, StakingRewardHandler}, + Balance, BlockNumber, +}; + +pub use pallet::*; + +#[cfg(test)] +mod test; + +#[cfg(feature = "runtime-benchmarks")] +mod benchmarking; + +mod types; +pub use types::*; + +pub mod weights; +pub use weights::WeightInfo; + +const LOG_TARGET: &str = "dapp-staking"; + +/// Helper enum for benchmarking. +pub(crate) enum TierAssignment { + /// Real tier assignment calculation should be done. + Real, + /// Dummy tier assignment calculation should be done, e.g. default value should be returned. + #[cfg(feature = "runtime-benchmarks")] + Dummy, +} + +#[doc = include_str!("../README.md")] +#[frame_support::pallet] +pub mod pallet { + use super::*; + + /// The current storage version. + pub const STORAGE_VERSION: StorageVersion = StorageVersion::new(5); + + #[pallet::pallet] + #[pallet::storage_version(STORAGE_VERSION)] + pub struct Pallet(_); + + #[cfg(feature = "runtime-benchmarks")] + pub trait BenchmarkHelper { + fn get_smart_contract(id: u32) -> SmartContract; + + fn set_balance(account: &AccountId, balance: Balance); + } + + #[pallet::config] + pub trait Config: frame_system::Config { + /// The overarching event type. + type RuntimeEvent: From> + + IsType<::RuntimeEvent> + + TryInto>; + + /// The overarching freeze reason. + type RuntimeFreezeReason: From; + + /// Currency used for staking. + /// Reference: + type Currency: FunMutateFreeze< + Self::AccountId, + Id = Self::RuntimeFreezeReason, + Balance = Balance, + >; + + /// Describes smart contract in the context required by dApp staking. + type SmartContract: Parameter + + Member + + MaxEncodedLen + + SmartContractHandle; + + /// Privileged origin for managing dApp staking pallet. + type ManagerOrigin: EnsureOrigin<::RuntimeOrigin>; + + /// Used to provide price information about the native token. + type NativePriceProvider: PriceProvider; + + /// Used to handle reward payouts & reward pool amount fetching. + type StakingRewardHandler: StakingRewardHandler; + + /// Describes era length, subperiods & period length, as well as cycle length. + type CycleConfiguration: CycleConfiguration; + + /// Maximum length of a single era reward span length entry. + #[pallet::constant] + type EraRewardSpanLength: Get; + + /// Number of periods for which we keep rewards available for claiming. + /// After that period, they are no longer claimable. + #[pallet::constant] + type RewardRetentionInPeriods: Get; + + /// Maximum number of contracts that can be integrated into dApp staking at once. + #[pallet::constant] + type MaxNumberOfContracts: 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; + + /// Number of standard eras that need to pass before unlocking chunk can be claimed. + /// Even though it's expressed in 'eras', it's actually measured in number of blocks. + #[pallet::constant] + type UnlockingPeriod: Get; + + /// Maximum amount of stake contract entries an account is allowed to have at once. + #[pallet::constant] + type MaxNumberOfStakedContracts: Get; + + /// Minimum amount staker can stake on a contract. + #[pallet::constant] + type MinimumStakeAmount: Get; + + /// Number of different tiers. + #[pallet::constant] + type NumberOfTiers: Get; + + /// Weight info for various calls & operations in the pallet. + type WeightInfo: WeightInfo; + + /// Helper trait for benchmarks. + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper: BenchmarkHelper; + } + + #[pallet::event] + #[pallet::generate_deposit(pub(crate) fn deposit_event)] + pub enum Event { + /// Maintenance mode has been either enabled or disabled. + MaintenanceMode { enabled: bool }, + /// New era has started. + NewEra { era: EraNumber }, + /// New subperiod has started. + NewSubperiod { + subperiod: Subperiod, + number: PeriodNumber, + }, + /// 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: Balance, + }, + /// Account has started the unlocking process for some amount. + Unlocking { + account: T::AccountId, + amount: Balance, + }, + /// Account has claimed unlocked amount, removing the lock from it. + ClaimedUnlocked { + account: T::AccountId, + amount: Balance, + }, + /// Account has relocked all of the unlocking chunks. + Relock { + account: T::AccountId, + amount: Balance, + }, + /// Account has staked some amount on a smart contract. + Stake { + account: T::AccountId, + smart_contract: T::SmartContract, + amount: Balance, + }, + /// Account has unstaked some amount from a smart contract. + Unstake { + account: T::AccountId, + smart_contract: T::SmartContract, + amount: Balance, + }, + /// Account has claimed some stake rewards. + Reward { + account: T::AccountId, + era: EraNumber, + amount: Balance, + }, + /// Bonus reward has been paid out to a loyal staker. + BonusReward { + account: T::AccountId, + smart_contract: T::SmartContract, + period: PeriodNumber, + amount: Balance, + }, + /// dApp reward has been paid out to a beneficiary. + DAppReward { + beneficiary: T::AccountId, + smart_contract: T::SmartContract, + tier_id: TierId, + era: EraNumber, + amount: Balance, + }, + /// Account has unstaked funds from an unregistered smart contract + UnstakeFromUnregistered { + account: T::AccountId, + smart_contract: T::SmartContract, + amount: Balance, + }, + /// Some expired stake entries have been removed from storage. + 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 }, + } + + #[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 unlocking chunks due to capacity limit. + TooManyUnlockingChunks, + /// Remaining stake prevents entire balance of starting the unlocking process. + RemainingStakePreventsFullUnlock, + /// There are no eligible unlocked chunks to claim. This can happen either if no eligible chunks exist, or if user has no chunks at all. + NoUnlockedChunksToClaim, + /// There are no unlocking chunks available to relock. + NoUnlockingChunks, + /// The amount being staked is too large compared to what's available for staking. + UnavailableStakeFunds, + /// There are unclaimed rewards remaining from past eras or periods. They should be claimed before attempting any stake modification again. + UnclaimedRewards, + /// An unexpected error occured while trying to stake. + InternalStakeError, + /// Total staked amount on contract is below the minimum required value. + InsufficientStakeAmount, + /// Stake operation is rejected since period ends in the next era. + PeriodEndsInNextEra, + /// Unstaking is rejected since the period in which past stake was active has passed. + UnstakeFromPastPeriod, + /// Unstake amount is greater than the staked amount. + UnstakeAmountTooLarge, + /// Account has no staking information for the contract. + NoStakingInfo, + /// An unexpected error occured while trying to unstake. + InternalUnstakeError, + /// Rewards are no longer claimable since they are too old. + RewardExpired, + /// Reward payout has failed due to an unexpected reason. + RewardPayoutFailed, + /// There are no claimable rewards. + NoClaimableRewards, + /// An unexpected error occured while trying to claim staker rewards. + InternalClaimStakerError, + /// Account is has no eligible stake amount for bonus reward. + NotEligibleForBonusReward, + /// An unexpected error occured while trying to claim bonus reward. + InternalClaimBonusError, + /// Claim era is invalid - it must be in history, and rewards must exist for it. + InvalidClaimEra, + /// No dApp tier info exists for the specified era. This can be because era has expired + /// or because during the specified era there were no eligible rewards or protocol wasn't active. + NoDAppTierInfo, + /// dApp reward has already been claimed for this era. + DAppRewardAlreadyClaimed, + /// An unexpected error occured while trying to claim dApp reward. + InternalClaimDAppError, + /// Contract is still active, not unregistered. + ContractStillActive, + /// There are too many contract stake entries for the account. This can be cleaned up by either unstaking or cleaning expired entries. + TooManyStakedContracts, + /// There are no expired entries to cleanup for the account. + NoExpiredEntries, + } + + /// General information about dApp staking protocol state. + #[pallet::storage] + #[pallet::whitelist_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. + /// + /// Even though dApp is integrated, it does not mean it's still actively participating in dApp staking. + /// It might have been unregistered at some point in history. + #[pallet::storage] + pub type IntegratedDApps = CountedStorageMap< + Hasher = Blake2_128Concat, + Key = T::SmartContract, + Value = DAppInfo, + QueryKind = OptionQuery, + MaxValues = ConstU32<{ DAppId::MAX as u32 }>, + >; + + /// General locked/staked information for each account. + #[pallet::storage] + pub type Ledger = + StorageMap<_, Blake2_128Concat, T::AccountId, AccountLedgerFor, ValueQuery>; + + /// Information about how much each staker has staked for each smart contract in some period. + #[pallet::storage] + pub type StakerInfo = StorageDoubleMap< + _, + Blake2_128Concat, + T::AccountId, + Blake2_128Concat, + T::SmartContract, + SingularStakingInfo, + OptionQuery, + >; + + /// Information about how much has been staked on a smart contract in some era or period. + #[pallet::storage] + pub type ContractStake = StorageMap< + Hasher = Twox64Concat, + Key = DAppId, + Value = ContractStakeAmount, + QueryKind = ValueQuery, + MaxValues = ConstU32<{ DAppId::MAX as u32 }>, + >; + + /// General information about the current era. + #[pallet::storage] + pub type CurrentEraInfo = StorageValue<_, EraInfo, ValueQuery>; + + /// Information about rewards for each era. + /// + /// Since each entry is a 'span', covering up to `T::EraRewardSpanLength` entries, only certain era value keys can exist in storage. + /// For the sake of simplicity, valid `era` keys are calculated as: + /// + /// era_key = era - (era % T::EraRewardSpanLength) + /// + /// This means that e.g. in case `EraRewardSpanLength = 8`, only era values 0, 8, 16, 24, etc. can exist in storage. + /// Eras 1-7 will be stored in the same entry as era 0, eras 9-15 will be stored in the same entry as era 8, etc. + #[pallet::storage] + pub type EraRewards = + StorageMap<_, Twox64Concat, EraNumber, EraRewardSpan, OptionQuery>; + + /// Information about period's end. + #[pallet::storage] + pub type PeriodEnd = + StorageMap<_, Twox64Concat, PeriodNumber, PeriodEndInfo, OptionQuery>; + + /// Static tier parameters used to calculate tier configuration. + #[pallet::storage] + pub type StaticTierParams = + StorageValue<_, TierParameters, ValueQuery>; + + /// Tier configuration user for current & preceding eras. + #[pallet::storage] + pub type TierConfig = + StorageValue<_, TiersConfiguration, ValueQuery>; + + /// Information about which tier a dApp belonged to in a specific era. + #[pallet::storage] + pub type DAppTiers = + StorageMap<_, Twox64Concat, EraNumber, DAppTierRewardsFor, OptionQuery>; + + /// History cleanup marker - holds information about which DB entries should be cleaned up next, when applicable. + #[pallet::storage] + pub type HistoryCleanupMarker = StorageValue<_, CleanupMarker, ValueQuery>; + + #[pallet::genesis_config] + #[derive(frame_support::DefaultNoBound)] + pub struct GenesisConfig { + pub reward_portion: Vec, + pub slot_distribution: Vec, + pub tier_thresholds: Vec, + pub slots_per_tier: Vec, + } + + #[pallet::genesis_build] + impl GenesisBuild for GenesisConfig { + fn build(&self) { + // Prepare tier parameters & verify their correctness + let tier_params = TierParameters:: { + reward_portion: BoundedVec::::try_from( + self.reward_portion.clone(), + ) + .expect("Invalid number of reward portions provided."), + slot_distribution: BoundedVec::::try_from( + self.slot_distribution.clone(), + ) + .expect("Invalid number of slot distributions provided."), + tier_thresholds: BoundedVec::::try_from( + self.tier_thresholds.clone(), + ) + .expect("Invalid number of tier thresholds provided."), + }; + assert!( + tier_params.is_valid(), + "Invalid tier parameters values provided." + ); + + // Prepare tier configuration and verify its correctness + let number_of_slots = self.slots_per_tier.iter().fold(0_u16, |acc, &slots| { + acc.checked_add(slots).expect("Overflow") + }); + let tier_config = TiersConfiguration:: { + number_of_slots, + slots_per_tier: BoundedVec::::try_from( + self.slots_per_tier.clone(), + ) + .expect("Invalid number of slots per tier entries provided."), + reward_portion: tier_params.reward_portion.clone(), + tier_thresholds: tier_params.tier_thresholds.clone(), + }; + assert!( + tier_params.is_valid(), + "Invalid tier config values provided." + ); + + // Prepare initial protocol state + let protocol_state = ProtocolState { + era: 1, + next_era_start: Pallet::::blocks_per_voting_period() + .checked_add(1) + .expect("Must not overflow - especially not at genesis."), + period_info: PeriodInfo { + number: 1, + subperiod: Subperiod::Voting, + next_subperiod_start_era: 2, + }, + maintenance: false, + }; + + // Initialize necessary storage items + ActiveProtocolState::::put(protocol_state); + StaticTierParams::::put(tier_params); + TierConfig::::put(tier_config.clone()); + } + } + + #[pallet::hooks] + impl Hooks for Pallet { + fn on_initialize(now: BlockNumber) -> Weight { + Self::era_and_period_handler(now, TierAssignment::Real) + } + + fn on_idle(_block: BlockNumberFor, remaining_weight: Weight) -> Weight { + Self::expired_entry_cleanup(&remaining_weight) + } + + fn integrity_test() { + // dApp staking params + // Sanity checks + assert!(T::EraRewardSpanLength::get() > 0); + assert!(T::RewardRetentionInPeriods::get() > 0); + assert!(T::MaxNumberOfContracts::get() > 0); + assert!(T::MaxUnlockingChunks::get() > 0); + assert!(T::UnlockingPeriod::get() > 0); + assert!(T::MaxNumberOfStakedContracts::get() > 0); + + assert!(T::MinimumLockedAmount::get() > 0); + assert!(T::MinimumStakeAmount::get() > 0); + assert!(T::MinimumLockedAmount::get() >= T::MinimumStakeAmount::get()); + + // Cycle config + assert!(T::CycleConfiguration::periods_per_cycle() > 0); + assert!(T::CycleConfiguration::eras_per_voting_subperiod() > 0); + assert!(T::CycleConfiguration::eras_per_build_and_earn_subperiod() > 0); + assert!(T::CycleConfiguration::blocks_per_era() > 0); + } + } + + /// A reason for freezing funds. + #[pallet::composite_enum] + pub enum FreezeReason { + /// Account is participating in dApp staking. + #[codec(index = 0)] + DAppStaking, + } + + #[pallet::call] + impl Pallet { + /// Used to enable or disable maintenance mode. + /// Can only be called by manager origin. + #[pallet::call_index(0)] + #[pallet::weight(T::WeightInfo::maintenance_mode())] + pub fn maintenance_mode(origin: OriginFor, enabled: bool) -> DispatchResult { + T::ManagerOrigin::ensure_origin(origin)?; + ActiveProtocolState::::mutate(|state| state.maintenance = enabled); + + Self::deposit_event(Event::::MaintenanceMode { enabled }); + Ok(()) + } + + /// Used to register a new contract for dApp staking. + /// + /// If successful, smart contract will be assigned a simple, unique numerical identifier. + /// Owner is set to be initial beneficiary & manager of the dApp. + #[pallet::call_index(1)] + #[pallet::weight(T::WeightInfo::register())] + 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 beneficiary account for a dApp. + /// + /// Caller has to be dApp owner. + /// If set to `None`, rewards will be deposited to the dApp owner. + /// After this call, all existing & future rewards will be paid out to the beneficiary. + #[pallet::call_index(2)] + #[pallet::weight(T::WeightInfo::set_dapp_reward_beneficiary())] + pub fn set_dapp_reward_beneficiary( + 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(T::WeightInfo::set_dapp_owner())] + 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 staking manager origin. + #[pallet::call_index(4)] + #[pallet::weight(T::WeightInfo::unregister())] + 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; + + let mut dapp_info = + IntegratedDApps::::get(&smart_contract).ok_or(Error::::ContractNotFound)?; + + ensure!( + dapp_info.state == DAppState::Registered, + Error::::NotOperatedDApp + ); + + ContractStake::::remove(&dapp_info.id); + + dapp_info.state = DAppState::Unregistered(current_era); + IntegratedDApps::::insert(&smart_contract, dapp_info); + + 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. + /// + /// Locked amount can immediately be used for staking. + #[pallet::call_index(5)] + #[pallet::weight(T::WeightInfo::lock())] + pub fn lock(origin: OriginFor, #[pallet::compact] amount: Balance) -> DispatchResult { + Self::ensure_pallet_enabled()?; + let account = ensure_signed(origin)?; + + let mut ledger = Ledger::::get(&account); + + // Calculate & check amount available for locking + let available_balance = + T::Currency::balance(&account).saturating_sub(ledger.active_locked_amount()); + let amount_to_lock = available_balance.min(amount); + ensure!(!amount_to_lock.is_zero(), Error::::ZeroAmount); + + ledger.add_lock_amount(amount_to_lock); + + ensure!( + ledger.active_locked_amount() >= T::MinimumLockedAmount::get(), + Error::::LockedAmountBelowThreshold + ); + + Self::update_ledger(&account, ledger)?; + CurrentEraInfo::::mutate(|era_info| { + era_info.add_locked(amount_to_lock); + }); + + Self::deposit_event(Event::::Locked { + account, + amount: amount_to_lock, + }); + + Ok(()) + } + + /// Attempts to start the unlocking process for the specified amount. + /// + /// Only the amount that isn't actively used for staking can be unlocked. + /// If the amount is greater than the available amount for unlocking, everything is unlocked. + /// If the remaining locked amount would take the account below the minimum locked amount, everything is unlocked. + #[pallet::call_index(6)] + #[pallet::weight(T::WeightInfo::unlock())] + pub fn unlock(origin: OriginFor, #[pallet::compact] amount: Balance) -> DispatchResult { + Self::ensure_pallet_enabled()?; + let account = ensure_signed(origin)?; + + let state = ActiveProtocolState::::get(); + let mut ledger = Ledger::::get(&account); + + let available_for_unlocking = ledger.unlockable_amount(state.period_info.number); + let amount_to_unlock = available_for_unlocking.min(amount); + + // Ensure we unlock everything if remaining amount is below threshold. + let remaining_amount = ledger + .active_locked_amount() + .saturating_sub(amount_to_unlock); + let amount_to_unlock = if remaining_amount < T::MinimumLockedAmount::get() { + ensure!( + ledger.staked_amount(state.period_info.number).is_zero(), + Error::::RemainingStakePreventsFullUnlock + ); + ledger.active_locked_amount() + } else { + amount_to_unlock + }; + + // Sanity check + ensure!(!amount_to_unlock.is_zero(), Error::::ZeroAmount); + + // Update ledger with new lock and unlocking amounts + ledger.subtract_lock_amount(amount_to_unlock); + + let current_block = frame_system::Pallet::::block_number(); + let unlock_block = current_block.saturating_add(Self::unlocking_period()); + ledger + .add_unlocking_chunk(amount_to_unlock, unlock_block) + .map_err(|_| Error::::TooManyUnlockingChunks)?; + + // Update storage + Self::update_ledger(&account, ledger)?; + CurrentEraInfo::::mutate(|era_info| { + era_info.unlocking_started(amount_to_unlock); + }); + + Self::deposit_event(Event::::Unlocking { + account, + amount: amount_to_unlock, + }); + + Ok(()) + } + + /// Claims all of fully unlocked chunks, removing the lock from them. + #[pallet::call_index(7)] + #[pallet::weight(T::WeightInfo::claim_unlocked(T::MaxNumberOfStakedContracts::get()))] + pub fn claim_unlocked(origin: OriginFor) -> DispatchResultWithPostInfo { + Self::ensure_pallet_enabled()?; + let account = ensure_signed(origin)?; + + let mut ledger = Ledger::::get(&account); + + let current_block = frame_system::Pallet::::block_number(); + let amount = ledger.claim_unlocked(current_block); + ensure!(amount > Zero::zero(), Error::::NoUnlockedChunksToClaim); + + // In case it's full unlock, account is exiting dApp staking, ensure all storage is cleaned up. + let removed_entries = if ledger.is_empty() { + let _ = StakerInfo::::clear_prefix(&account, ledger.contract_stake_count, None); + ledger.contract_stake_count + } else { + 0 + }; + + Self::update_ledger(&account, ledger)?; + CurrentEraInfo::::mutate(|era_info| { + era_info.unlocking_removed(amount); + }); + + Self::deposit_event(Event::::ClaimedUnlocked { account, amount }); + + Ok(Some(T::WeightInfo::claim_unlocked(removed_entries)).into()) + } + + #[pallet::call_index(8)] + #[pallet::weight(T::WeightInfo::relock_unlocking())] + pub fn relock_unlocking(origin: OriginFor) -> DispatchResult { + Self::ensure_pallet_enabled()?; + let account = ensure_signed(origin)?; + + let mut ledger = Ledger::::get(&account); + + ensure!(!ledger.unlocking.is_empty(), Error::::NoUnlockingChunks); + + let amount = ledger.consume_unlocking_chunks(); + + ledger.add_lock_amount(amount); + ensure!( + ledger.active_locked_amount() >= T::MinimumLockedAmount::get(), + Error::::LockedAmountBelowThreshold + ); + + Self::update_ledger(&account, ledger)?; + CurrentEraInfo::::mutate(|era_info| { + era_info.add_locked(amount); + era_info.unlocking_removed(amount); + }); + + Self::deposit_event(Event::::Relock { account, amount }); + + Ok(()) + } + + /// Stake the specified amount on a smart contract. + /// The precise `amount` specified **must** be available for staking. + /// The total amount staked on a dApp must be greater than the minimum required value. + /// + /// Depending on the period type, appropriate stake amount will be updated. During `Voting` subperiod, `voting` stake amount is updated, + /// and same for `Build&Earn` subperiod. + /// + /// Staked amount is only eligible for rewards from the next era onwards. + #[pallet::call_index(9)] + #[pallet::weight(T::WeightInfo::stake())] + pub fn stake( + origin: OriginFor, + smart_contract: T::SmartContract, + #[pallet::compact] amount: Balance, + ) -> DispatchResult { + Self::ensure_pallet_enabled()?; + let account = ensure_signed(origin)?; + + ensure!(amount > 0, Error::::ZeroAmount); + + let dapp_info = + IntegratedDApps::::get(&smart_contract).ok_or(Error::::NotOperatedDApp)?; + ensure!(dapp_info.is_registered(), Error::::NotOperatedDApp); + + let protocol_state = ActiveProtocolState::::get(); + let current_era = protocol_state.era; + ensure!( + !protocol_state + .period_info + .is_next_period(current_era.saturating_add(1)), + Error::::PeriodEndsInNextEra + ); + + 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); + + // 1. + // Increase stake amount for the next era & current period in staker's ledger + ledger + .add_stake_amount(amount, current_era, protocol_state.period_info) + .map_err(|err| match err { + AccountLedgerError::InvalidPeriod | AccountLedgerError::InvalidEra => { + Error::::UnclaimedRewards + } + AccountLedgerError::UnavailableStakeFunds => Error::::UnavailableStakeFunds, + // Defensive check, should never happen + _ => Error::::InternalStakeError, + })?; + + // 2. + // Update `StakerInfo` storage with the new stake amount on the specified contract. + // + // There are two distinct scenarios: + // 1. Existing entry matches the current period number - just update it. + // 2. Entry doesn't exist or it's for an older period - create a new one. + // + // This is ok since we only use this storage entry to keep track of how much each staker + // has staked on each contract in the current period. We only ever need the latest information. + // This is because `AccountLedger` is the one keeping information about how much was staked when. + let (mut new_staking_info, is_new_entry) = + match StakerInfo::::get(&account, &smart_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.is_loyal() => + { + return Err(Error::::UnclaimedRewards.into()); + } + // No valid entry exists + _ => ( + SingularStakingInfo::new( + protocol_state.period_number(), + protocol_state.subperiod(), + ), + true, + ), + }; + new_staking_info.stake(amount, current_era, protocol_state.subperiod()); + ensure!( + new_staking_info.total_staked_amount() >= T::MinimumStakeAmount::get(), + Error::::InsufficientStakeAmount + ); + + if is_new_entry { + ledger.contract_stake_count.saturating_inc(); + ensure!( + ledger.contract_stake_count <= T::MaxNumberOfStakedContracts::get(), + Error::::TooManyStakedContracts + ); + } + + // 3. + // Update `ContractStake` storage with the new stake amount on the specified contract. + let mut contract_stake_info = ContractStake::::get(&dapp_info.id); + contract_stake_info.stake(amount, protocol_state.period_info, current_era); + + // 4. + // Update total staked amount for the next era. + CurrentEraInfo::::mutate(|era_info| { + era_info.add_stake_amount(amount, protocol_state.subperiod()); + }); + + // 5. + // Update remaining storage entries + Self::update_ledger(&account, ledger)?; + StakerInfo::::insert(&account, &smart_contract, new_staking_info); + ContractStake::::insert(&dapp_info.id, contract_stake_info); + + Self::deposit_event(Event::::Stake { + account, + smart_contract, + amount, + }); + + Ok(()) + } + + /// Unstake the specified amount from a smart contract. + /// The `amount` specified **must** not exceed what's staked, otherwise the call will fail. + /// + /// If unstaking the specified `amount` would take staker below the minimum stake threshold, everything is unstaked. + /// + /// Depending on the period type, appropriate stake amount will be updated. + /// In case amount is unstaked during `Voting` subperiod, the `voting` amount is reduced. + /// In case amount is unstaked during `Build&Earn` subperiod, first the `build_and_earn` is reduced, + /// and any spillover is subtracted from the `voting` amount. + #[pallet::call_index(10)] + #[pallet::weight(T::WeightInfo::unstake())] + pub fn unstake( + origin: OriginFor, + smart_contract: T::SmartContract, + #[pallet::compact] amount: Balance, + ) -> DispatchResult { + Self::ensure_pallet_enabled()?; + let account = ensure_signed(origin)?; + + ensure!(amount > 0, Error::::ZeroAmount); + + let dapp_info = + IntegratedDApps::::get(&smart_contract).ok_or(Error::::NotOperatedDApp)?; + ensure!(dapp_info.is_registered(), Error::::NotOperatedDApp); + + let protocol_state = ActiveProtocolState::::get(); + let current_era = protocol_state.era; + + let mut ledger = Ledger::::get(&account); + + // 1. + // Update `StakerInfo` storage with the reduced stake amount on the specified contract. + let (new_staking_info, amount) = match StakerInfo::::get(&account, &smart_contract) { + Some(mut staking_info) => { + ensure!( + staking_info.period_number() == protocol_state.period_number(), + Error::::UnstakeFromPastPeriod + ); + ensure!( + staking_info.total_staked_amount() >= amount, + Error::::UnstakeAmountTooLarge + ); + + // If unstaking would take the total staked amount below the minimum required value, + // unstake everything. + let amount = if staking_info.total_staked_amount().saturating_sub(amount) + < T::MinimumStakeAmount::get() + { + staking_info.total_staked_amount() + } else { + amount + }; + + staking_info.unstake(amount, current_era, protocol_state.subperiod()); + (staking_info, amount) + } + None => { + return Err(Error::::NoStakingInfo.into()); + } + }; + + // 2. + // Reduce stake amount + ledger + .unstake_amount(amount, current_era, protocol_state.period_info) + .map_err(|err| match err { + AccountLedgerError::InvalidPeriod | AccountLedgerError::InvalidEra => { + Error::::UnclaimedRewards + } + // This is a defensive check, which should never happen since we calculate the correct value above. + AccountLedgerError::UnstakeAmountLargerThanStake => { + Error::::UnstakeAmountTooLarge + } + _ => Error::::InternalUnstakeError, + })?; + + // 3. + // Update `ContractStake` storage with the reduced stake amount on the specified contract. + let mut contract_stake_info = ContractStake::::get(&dapp_info.id); + contract_stake_info.unstake(amount, protocol_state.period_info, current_era); + + // 4. + // Update total staked amount for the next era. + CurrentEraInfo::::mutate(|era_info| { + era_info.unstake_amount(amount, protocol_state.subperiod()); + }); + + // 5. + // Update remaining storage entries + ContractStake::::insert(&dapp_info.id, contract_stake_info); + + if new_staking_info.is_empty() { + ledger.contract_stake_count.saturating_dec(); + StakerInfo::::remove(&account, &smart_contract); + } else { + StakerInfo::::insert(&account, &smart_contract, new_staking_info); + } + + Self::update_ledger(&account, ledger)?; + + Self::deposit_event(Event::::Unstake { + account, + smart_contract, + amount, + }); + + Ok(()) + } + + /// Claims some staker rewards, if user has any. + /// In the case of a successfull call, at least one era will be claimed, with the possibility of multiple claims happening. + #[pallet::call_index(11)] + #[pallet::weight({ + let max_span_length = T::EraRewardSpanLength::get(); + T::WeightInfo::claim_staker_rewards_ongoing_period(max_span_length) + .max(T::WeightInfo::claim_staker_rewards_past_period(max_span_length)) + })] + pub fn claim_staker_rewards(origin: OriginFor) -> DispatchResultWithPostInfo { + Self::ensure_pallet_enabled()?; + let account = ensure_signed(origin)?; + + let mut ledger = Ledger::::get(&account); + let staked_period = ledger + .staked_period() + .ok_or(Error::::NoClaimableRewards)?; + + // Check if the rewards have expired + let protocol_state = ActiveProtocolState::::get(); + ensure!( + staked_period >= Self::oldest_claimable_period(protocol_state.period_number()), + Error::::RewardExpired + ); + + // Calculate the reward claim span + let earliest_staked_era = ledger + .earliest_staked_era() + .ok_or(Error::::InternalClaimStakerError)?; + let era_rewards = + EraRewards::::get(Self::era_reward_span_index(earliest_staked_era)) + .ok_or(Error::::NoClaimableRewards)?; + + // The last era for which we can theoretically claim rewards. + // And indicator if we know the period's ending era. + let (last_period_era, period_end) = if staked_period == protocol_state.period_number() { + (protocol_state.era.saturating_sub(1), None) + } else { + PeriodEnd::::get(&staked_period) + .map(|info| (info.final_era, Some(info.final_era))) + .ok_or(Error::::InternalClaimStakerError)? + }; + + // The last era for which we can claim rewards for this account. + let last_claim_era = era_rewards.last_era().min(last_period_era); + + // Get chunks for reward claiming + let rewards_iter = + ledger + .claim_up_to_era(last_claim_era, period_end) + .map_err(|err| match err { + AccountLedgerError::NothingToClaim => Error::::NoClaimableRewards, + _ => Error::::InternalClaimStakerError, + })?; + + // Calculate rewards + let mut rewards: Vec<_> = Vec::new(); + let mut reward_sum = Balance::zero(); + for (era, amount) in rewards_iter { + let era_reward = era_rewards + .get(era) + .ok_or(Error::::InternalClaimStakerError)?; + + // Optimization, and zero-division protection + if amount.is_zero() || era_reward.staked.is_zero() { + continue; + } + let staker_reward = Perbill::from_rational(amount, era_reward.staked) + * era_reward.staker_reward_pool; + + rewards.push((era, staker_reward)); + reward_sum.saturating_accrue(staker_reward); + } + let rewards_len: u32 = rewards.len().unique_saturated_into(); + + T::StakingRewardHandler::payout_reward(&account, reward_sum) + .map_err(|_| Error::::RewardPayoutFailed)?; + + Self::update_ledger(&account, ledger)?; + + rewards.into_iter().for_each(|(era, reward)| { + Self::deposit_event(Event::::Reward { + account: account.clone(), + era, + amount: reward, + }); + }); + + Ok(Some(if period_end.is_some() { + T::WeightInfo::claim_staker_rewards_past_period(rewards_len) + } else { + T::WeightInfo::claim_staker_rewards_ongoing_period(rewards_len) + }) + .into()) + } + + /// Used to claim bonus reward for a smart contract, if eligible. + #[pallet::call_index(12)] + #[pallet::weight(T::WeightInfo::claim_bonus_reward())] + pub fn claim_bonus_reward( + origin: OriginFor, + smart_contract: T::SmartContract, + ) -> DispatchResult { + Self::ensure_pallet_enabled()?; + let account = ensure_signed(origin)?; + + let staker_info = StakerInfo::::get(&account, &smart_contract) + .ok_or(Error::::NoClaimableRewards)?; + let protocol_state = ActiveProtocolState::::get(); + + // Ensure: + // 1. Period for which rewards are being claimed has ended. + // 2. Account has been a loyal staker. + // 3. Rewards haven't expired. + let staked_period = staker_info.period_number(); + ensure!( + staked_period < protocol_state.period_number(), + Error::::NoClaimableRewards + ); + ensure!( + staker_info.is_loyal(), + Error::::NotEligibleForBonusReward + ); + ensure!( + staker_info.period_number() + >= Self::oldest_claimable_period(protocol_state.period_number()), + Error::::RewardExpired + ); + + let period_end_info = + PeriodEnd::::get(&staked_period).ok_or(Error::::InternalClaimBonusError)?; + // Defensive check - we should never get this far in function if no voting period stake exists. + ensure!( + !period_end_info.total_vp_stake.is_zero(), + Error::::InternalClaimBonusError + ); + + let eligible_amount = staker_info.staked_amount(Subperiod::Voting); + let bonus_reward = + Perbill::from_rational(eligible_amount, period_end_info.total_vp_stake) + * period_end_info.bonus_reward_pool; + + T::StakingRewardHandler::payout_reward(&account, bonus_reward) + .map_err(|_| Error::::RewardPayoutFailed)?; + + // Cleanup entry since the reward has been claimed + StakerInfo::::remove(&account, &smart_contract); + Ledger::::mutate(&account, |ledger| { + ledger.contract_stake_count.saturating_dec(); + }); + + Self::deposit_event(Event::::BonusReward { + account: account.clone(), + smart_contract, + period: staked_period, + amount: bonus_reward, + }); + + Ok(()) + } + + /// Used to claim dApp reward for the specified era. + #[pallet::call_index(13)] + #[pallet::weight(T::WeightInfo::claim_dapp_reward())] + pub fn claim_dapp_reward( + origin: OriginFor, + smart_contract: T::SmartContract, + #[pallet::compact] era: EraNumber, + ) -> DispatchResult { + Self::ensure_pallet_enabled()?; + + // To keep in line with legacy behavior, dApp rewards can be claimed by anyone. + let _ = ensure_signed(origin)?; + + let dapp_info = + IntegratedDApps::::get(&smart_contract).ok_or(Error::::ContractNotFound)?; + + // Make sure provided era has ended + let protocol_state = ActiveProtocolState::::get(); + ensure!(era < protocol_state.era, Error::::InvalidClaimEra); + + // 'Consume' dApp reward for the specified era, if possible. + let mut dapp_tiers = DAppTiers::::get(&era).ok_or(Error::::NoDAppTierInfo)?; + ensure!( + dapp_tiers.period >= Self::oldest_claimable_period(protocol_state.period_number()), + Error::::RewardExpired + ); + + let (amount, tier_id) = + dapp_tiers + .try_claim(dapp_info.id) + .map_err(|error| match error { + DAppTierError::NoDAppInTiers => Error::::NoClaimableRewards, + DAppTierError::RewardAlreadyClaimed => Error::::DAppRewardAlreadyClaimed, + _ => Error::::InternalClaimDAppError, + })?; + + // Get reward destination, and deposit the reward. + let beneficiary = dapp_info.reward_beneficiary(); + T::StakingRewardHandler::payout_reward(&beneficiary, amount) + .map_err(|_| Error::::RewardPayoutFailed)?; + + // Write back updated struct to prevent double reward claims + DAppTiers::::insert(&era, dapp_tiers); + + Self::deposit_event(Event::::DAppReward { + beneficiary: beneficiary.clone(), + smart_contract, + tier_id, + era, + amount, + }); + + Ok(()) + } + + /// Used to unstake funds from a contract that was unregistered after an account staked on it. + /// This is required if staker wants to re-stake these funds on another active contract during the ongoing period. + #[pallet::call_index(14)] + #[pallet::weight(T::WeightInfo::unstake_from_unregistered())] + pub fn unstake_from_unregistered( + origin: OriginFor, + smart_contract: T::SmartContract, + ) -> DispatchResult { + Self::ensure_pallet_enabled()?; + let account = ensure_signed(origin)?; + + ensure!( + !Self::is_registered(&smart_contract), + Error::::ContractStillActive + ); + + let protocol_state = ActiveProtocolState::::get(); + let current_era = protocol_state.era; + + // Extract total staked amount on the specified unregistered contract + let amount = match StakerInfo::::get(&account, &smart_contract) { + Some(staking_info) => { + ensure!( + staking_info.period_number() == protocol_state.period_number(), + Error::::UnstakeFromPastPeriod + ); + + staking_info.total_staked_amount() + } + None => { + return Err(Error::::NoStakingInfo.into()); + } + }; + + // Reduce stake amount in ledger + let mut ledger = Ledger::::get(&account); + ledger + .unstake_amount(amount, current_era, protocol_state.period_info) + .map_err(|err| match err { + // These are all defensive checks, which should never fail since we already checked them above. + AccountLedgerError::InvalidPeriod | AccountLedgerError::InvalidEra => { + Error::::UnclaimedRewards + } + _ => Error::::InternalUnstakeError, + })?; + + // Update total staked amount for the next era. + // This means 'fake' stake total amount has been kept until now, even though contract was unregistered. + // Although strange, it's been requested to keep it like this from the team. + CurrentEraInfo::::mutate(|era_info| { + era_info.unstake_amount(amount, protocol_state.subperiod()); + }); + + // Update remaining storage entries + Self::update_ledger(&account, ledger)?; + StakerInfo::::remove(&account, &smart_contract); + + Self::deposit_event(Event::::UnstakeFromUnregistered { + account, + smart_contract, + amount, + }); + + Ok(()) + } + + /// Cleanup expired stake entries for the contract. + /// + /// Entry is considered to be expired if: + /// 1. It's from a past period & the account wasn't a loyal staker, meaning there's no claimable bonus reward. + /// 2. It's from a period older than the oldest claimable period, regardless whether the account was loyal or not. + #[pallet::call_index(15)] + #[pallet::weight(T::WeightInfo::cleanup_expired_entries( + T::MaxNumberOfStakedContracts::get() + ))] + pub fn cleanup_expired_entries(origin: OriginFor) -> DispatchResultWithPostInfo { + Self::ensure_pallet_enabled()?; + let account = ensure_signed(origin)?; + + let protocol_state = ActiveProtocolState::::get(); + let current_period = protocol_state.period_number(); + let threshold_period = Self::oldest_claimable_period(current_period); + + // Find all entries which are from past periods & don't have claimable bonus rewards. + // This is bounded by max allowed number of stake entries per account. + let to_be_deleted: Vec = StakerInfo::::iter_prefix(&account) + .filter_map(|(smart_contract, stake_info)| { + if stake_info.period_number() < current_period && !stake_info.is_loyal() + || stake_info.period_number() < threshold_period + { + Some(smart_contract) + } else { + None + } + }) + .collect(); + let entries_to_delete = to_be_deleted.len(); + + ensure!(!entries_to_delete.is_zero(), Error::::NoExpiredEntries); + + // Remove all expired entries. + for smart_contract in to_be_deleted { + StakerInfo::::remove(&account, &smart_contract); + } + + // Remove expired stake entries from the ledger. + let mut ledger = Ledger::::get(&account); + ledger + .contract_stake_count + .saturating_reduce(entries_to_delete.unique_saturated_into()); + ledger.maybe_cleanup_expired(threshold_period); // Not necessary but we do it for the sake of consistency + Self::update_ledger(&account, ledger)?; + + Self::deposit_event(Event::::ExpiredEntriesRemoved { + account, + count: entries_to_delete.unique_saturated_into(), + }); + + Ok(Some(T::WeightInfo::cleanup_expired_entries( + entries_to_delete.unique_saturated_into(), + )) + .into()) + } + + // TODO: this call should be removed prior to mainnet launch. + // It's super useful for testing purposes, but even though force is used in this pallet & works well, + // it won't apply to the inflation recalculation logic - which is wrong. + // Probably for this call to make sense, an outside logic should handle both inflation & dApp staking state changes. + + /// Used to force a change of era or subperiod. + /// The effect isn't immediate but will happen on the next block. + /// + /// Used for testing purposes, when we want to force an era change, or a subperiod change. + /// Not intended to be used in production, except in case of unforseen circumstances. + /// + /// Can only be called by manager origin. + #[pallet::call_index(16)] + #[pallet::weight(T::WeightInfo::force())] + pub fn force(origin: OriginFor, forcing_type: ForcingType) -> DispatchResult { + Self::ensure_pallet_enabled()?; + T::ManagerOrigin::ensure_origin(origin)?; + + // Ensure a 'change' happens on the next block + ActiveProtocolState::::mutate(|state| { + let current_block = frame_system::Pallet::::block_number(); + state.next_era_start = current_block.saturating_add(One::one()); + + match forcing_type { + ForcingType::Era => (), + ForcingType::Subperiod => { + state.period_info.next_subperiod_start_era = state.era.saturating_add(1); + } + } + }); + + Self::deposit_event(Event::::Force { forcing_type }); + + 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 freeze. + /// + /// In case account ledger is empty, entries from the DB are removed and freeze is thawed. + /// + /// This call can fail if the `freeze` or `thaw` operations fail. This should never happen since + /// runtime definition must ensure it supports necessary freezes. + pub(crate) fn update_ledger( + account: &T::AccountId, + ledger: AccountLedgerFor, + ) -> Result<(), DispatchError> { + if ledger.is_empty() { + Ledger::::remove(&account); + T::Currency::thaw(&FreezeReason::DAppStaking.into(), account)?; + } else { + T::Currency::set_freeze( + &FreezeReason::DAppStaking.into(), + account, + ledger.total_locked_amount(), + )?; + Ledger::::insert(account, ledger); + } + + Ok(()) + } + + /// Returns the number of blocks per voting period. + pub(crate) fn blocks_per_voting_period() -> BlockNumber { + T::CycleConfiguration::blocks_per_era() + .saturating_mul(T::CycleConfiguration::eras_per_voting_subperiod().into()) + } + + /// `true` if smart contract is registered, `false` otherwise. + pub(crate) fn is_registered(smart_contract: &T::SmartContract) -> bool { + IntegratedDApps::::get(smart_contract) + .map_or(false, |dapp_info| dapp_info.is_registered()) + } + + /// Calculates the `EraRewardSpan` index for the specified era. + pub fn era_reward_span_index(era: EraNumber) -> EraNumber { + era.saturating_sub(era % T::EraRewardSpanLength::get()) + } + + /// Return the oldest period for which rewards can be claimed. + /// All rewards before that period are considered to be expired. + pub(crate) fn oldest_claimable_period(current_period: PeriodNumber) -> PeriodNumber { + current_period.saturating_sub(T::RewardRetentionInPeriods::get()) + } + + /// Unlocking period expressed in the number of blocks. + pub fn unlocking_period() -> BlockNumber { + T::CycleConfiguration::blocks_per_era().saturating_mul(T::UnlockingPeriod::get().into()) + } + + /// Assign eligible dApps into appropriate tiers, and calculate reward for each tier. + /// + /// ### Algorithm + /// + /// 1. Read in over all contract stake entries. In case staked amount is zero for the current era, ignore it. + /// This information is used to calculate 'score' per dApp, which is used to determine the tier. + /// + /// 2. Sort the entries by the score, in descending order - the top score dApp comes first. + /// + /// 3. Read in tier configuration. This contains information about how many slots per tier there are, + /// as well as the threshold for each tier. Threshold is the minimum amount of stake required to be eligible for a tier. + /// Iterate over tier thresholds & capacities, starting from the top tier, and assign dApps to them. + /// + /// ```text + //// for each tier: + /// for each unassigned dApp: + /// if tier has capacity && dApp satisfies the tier threshold: + /// add dapp to the tier + /// else: + /// exit loop since no more dApps will satisfy the threshold since they are sorted by score + /// ``` + /// (Sort the entries by dApp ID, in ascending order. This is so we can efficiently search for them using binary search.) + /// + /// 4. Calculate rewards for each tier. + /// This is done by dividing the total reward pool into tier reward pools, + /// after which the tier reward pool is divided by the number of available slots in the tier. + /// + /// The returned object contains information about each dApp that made it into a tier. + /// Alongside tier assignment info, number of read DB contract stake entries is returned. + pub(crate) fn get_dapp_tier_assignment( + era: EraNumber, + period: PeriodNumber, + dapp_reward_pool: Balance, + ) -> (DAppTierRewardsFor, DAppId) { + let mut dapp_stakes = Vec::with_capacity(T::MaxNumberOfContracts::get() as usize); + + // 1. + // Iterate over all staked dApps. + // This is bounded by max amount of dApps we allow to be registered. + let mut counter = 0; + for (dapp_id, stake_amount) in ContractStake::::iter() { + counter.saturating_inc(); + + // Skip dApps which don't have ANY amount staked + let stake_amount = match stake_amount.get(era, period) { + Some(stake_amount) if !stake_amount.total().is_zero() => stake_amount, + _ => continue, + }; + + dapp_stakes.push((dapp_id, stake_amount.total())); + } + + // 2. + // Sort by amount staked, in reverse - top dApp will end in the first place, 0th index. + dapp_stakes.sort_unstable_by(|(_, amount_1), (_, amount_2)| amount_2.cmp(amount_1)); + + // 3. + // Iterate over configured tier and potential dApps. + // Each dApp will be assigned to the best possible tier if it satisfies the required condition, + // and tier capacity hasn't been filled yet. + let mut dapp_tiers = Vec::with_capacity(dapp_stakes.len()); + let tier_config = TierConfig::::get(); + + let mut global_idx = 0; + let mut tier_id = 0; + for (tier_capacity, tier_threshold) in tier_config + .slots_per_tier + .iter() + .zip(tier_config.tier_thresholds.iter()) + { + let max_idx = global_idx + .saturating_add(*tier_capacity as usize) + .min(dapp_stakes.len()); + + // Iterate over dApps until one of two conditions has been met: + // 1. Tier has no more capacity + // 2. dApp doesn't satisfy the tier threshold (since they're sorted, none of the following dApps will satisfy the condition either) + for (dapp_id, stake_amount) in dapp_stakes[global_idx..max_idx].iter() { + if tier_threshold.is_satisfied(*stake_amount) { + global_idx.saturating_inc(); + dapp_tiers.push(DAppTier { + dapp_id: *dapp_id, + tier_id: Some(tier_id), + }); + } else { + break; + } + } + + tier_id.saturating_inc(); + } + + // In case when tier has 1 more free slot, but two dApps with exactly same score satisfy the threshold, + // one of them will be assigned to the tier, and the other one will be assigned to the lower tier, if it exists. + // + // In the current implementation, the dApp with the lower dApp Id has the advantage. + // There is no guarantee this will persist in the future, so it's best for dApps to do their + // best to avoid getting themselves into such situations. + + // 4. Calculate rewards. + let tier_rewards = tier_config + .reward_portion + .iter() + .zip(tier_config.slots_per_tier.iter()) + .map(|(percent, slots)| { + if slots.is_zero() { + Zero::zero() + } else { + *percent * dapp_reward_pool / >::into(*slots) + } + }) + .collect::>(); + + // 5. + // Prepare and return tier & rewards info. + // In case rewards creation fails, we just write the default value. This should never happen though. + ( + DAppTierRewards::::new( + dapp_tiers, + tier_rewards, + period, + ) + .unwrap_or_default(), + counter, + ) + } + + /// Used to handle era & period transitions. + pub(crate) fn era_and_period_handler( + now: BlockNumber, + tier_assignment: TierAssignment, + ) -> Weight { + let mut protocol_state = ActiveProtocolState::::get(); + + // `ActiveProtocolState` is whitelisted, so we need to account for its read. + let mut consumed_weight = T::DbWeight::get().reads(1); + + // We should not modify pallet storage while in maintenance mode. + // This is a safety measure, since maintenance mode is expected to be + // enabled in case some misbehavior or corrupted storage is detected. + if protocol_state.maintenance { + return consumed_weight; + } + + // Nothing to do if it's not new era + if !protocol_state.is_new_era(now) { + return consumed_weight; + } + + // At this point it's clear that an era change will happen + let mut era_info = CurrentEraInfo::::get(); + + let current_era = protocol_state.era; + let next_era = current_era.saturating_add(1); + let (maybe_period_event, era_reward) = match protocol_state.subperiod() { + // Voting subperiod only lasts for one 'prolonged' era + Subperiod::Voting => { + // For the sake of consistency, we put zero reward into storage. There are no rewards for the voting subperiod. + let era_reward = EraReward { + staker_reward_pool: Balance::zero(), + staked: era_info.total_staked_amount(), + dapp_reward_pool: Balance::zero(), + }; + + let next_subperiod_start_era = next_era + .saturating_add(T::CycleConfiguration::eras_per_build_and_earn_subperiod()); + let build_and_earn_start_block = + now.saturating_add(T::CycleConfiguration::blocks_per_era()); + protocol_state.advance_to_next_subperiod( + next_subperiod_start_era, + build_and_earn_start_block, + ); + + era_info.migrate_to_next_era(Some(protocol_state.subperiod())); + + consumed_weight + .saturating_accrue(T::WeightInfo::on_initialize_voting_to_build_and_earn()); + + ( + Some(Event::::NewSubperiod { + subperiod: protocol_state.subperiod(), + number: protocol_state.period_number(), + }), + era_reward, + ) + } + Subperiod::BuildAndEarn => { + let staked = era_info.total_staked_amount(); + let (staker_reward_pool, dapp_reward_pool) = + T::StakingRewardHandler::staker_and_dapp_reward_pools(staked); + let era_reward = EraReward { + staker_reward_pool, + staked, + dapp_reward_pool, + }; + + // Distribute dapps into tiers, write it into storage + // + // To help with benchmarking, it's possible to omit real tier calculation using the `Dummy` approach. + // This must never be used in production code, obviously. + let (dapp_tier_rewards, counter) = match tier_assignment { + TierAssignment::Real => Self::get_dapp_tier_assignment( + current_era, + protocol_state.period_number(), + dapp_reward_pool, + ), + #[cfg(feature = "runtime-benchmarks")] + TierAssignment::Dummy => (DAppTierRewardsFor::::default(), 0), + }; + DAppTiers::::insert(¤t_era, dapp_tier_rewards); + + consumed_weight + .saturating_accrue(T::WeightInfo::dapp_tier_assignment(counter.into())); + + // Switch to `Voting` period if conditions are met. + if protocol_state.period_info.is_next_period(next_era) { + // Store info about period end + let bonus_reward_pool = T::StakingRewardHandler::bonus_reward_pool(); + PeriodEnd::::insert( + &protocol_state.period_number(), + PeriodEndInfo { + bonus_reward_pool, + total_vp_stake: era_info.staked_amount(Subperiod::Voting), + final_era: current_era, + }, + ); + + // For the sake of consistency we treat the whole `Voting` period as a single era. + // This means no special handling is required for this period, it only lasts potentially longer than a single standard era. + let next_subperiod_start_era = next_era.saturating_add(1); + let voting_period_length = Self::blocks_per_voting_period(); + let next_era_start_block = now.saturating_add(voting_period_length); + + protocol_state.advance_to_next_subperiod( + next_subperiod_start_era, + next_era_start_block, + ); + + era_info.migrate_to_next_era(Some(protocol_state.subperiod())); + + // Re-calculate tier configuration for the upcoming new period + let tier_params = StaticTierParams::::get(); + let average_price = T::NativePriceProvider::average_price(); + let new_tier_config = + TierConfig::::get().calculate_new(average_price, &tier_params); + TierConfig::::put(new_tier_config); + + consumed_weight.saturating_accrue( + T::WeightInfo::on_initialize_build_and_earn_to_voting(), + ); + + ( + Some(Event::::NewSubperiod { + subperiod: protocol_state.subperiod(), + number: protocol_state.period_number(), + }), + era_reward, + ) + } else { + let next_era_start_block = + now.saturating_add(T::CycleConfiguration::blocks_per_era()); + protocol_state.next_era_start = next_era_start_block; + + era_info.migrate_to_next_era(None); + + consumed_weight.saturating_accrue( + T::WeightInfo::on_initialize_build_and_earn_to_build_and_earn(), + ); + + (None, era_reward) + } + } + }; + + // Update storage items + protocol_state.era = next_era; + ActiveProtocolState::::put(protocol_state); + + CurrentEraInfo::::put(era_info); + + let era_span_index = Self::era_reward_span_index(current_era); + let mut span = EraRewards::::get(&era_span_index).unwrap_or(EraRewardSpan::new()); + if let Err(_) = span.push(current_era, era_reward) { + // This must never happen but we log the error just in case. + log::error!( + target: LOG_TARGET, + "Failed to push era {} into the era reward span.", + current_era + ); + } + EraRewards::::insert(&era_span_index, span); + + Self::deposit_event(Event::::NewEra { era: next_era }); + if let Some(period_event) = maybe_period_event { + Self::deposit_event(period_event); + } + + consumed_weight + } + + /// Attempt to cleanup some expired entries, if enough remaining weight & applicable entries exist. + /// + /// Returns consumed weight. + fn expired_entry_cleanup(remaining_weight: &Weight) -> Weight { + // Need to be able to process full pass + if remaining_weight.any_lt(T::WeightInfo::on_idle_cleanup()) { + return Weight::zero(); + } + + // Get the cleanup marker + let mut cleanup_marker = HistoryCleanupMarker::::get(); + + // Whitelisted storage, no need to account for the read. + let protocol_state = ActiveProtocolState::::get(); + let latest_expired_period = match protocol_state + .period_number() + .checked_sub(T::RewardRetentionInPeriods::get().saturating_add(1)) + { + Some(latest_expired_period) => latest_expired_period, + None => { + // Protocol hasn't advanced enough to have any expired entries. + return T::WeightInfo::on_idle_cleanup(); + } + }; + + // Get the oldest valid era - any era before it is safe to be cleaned up. + let oldest_valid_era = match PeriodEnd::::get(latest_expired_period) { + Some(period_end_info) => period_end_info.final_era.saturating_add(1), + None => { + // Can happen if it's period 0 or if the entry has already been cleaned up. + return T::WeightInfo::on_idle_cleanup(); + } + }; + + // Attempt to cleanup one expired `EraRewards` entry. + if let Some(era_reward) = EraRewards::::get(cleanup_marker.era_reward_index) { + // If oldest valid era comes AFTER this span, it's safe to delete it. + if era_reward.last_era() < oldest_valid_era { + EraRewards::::remove(cleanup_marker.era_reward_index); + cleanup_marker + .era_reward_index + .saturating_accrue(T::EraRewardSpanLength::get()); + } + } else { + // Should never happen, but if it does, log an error and move on. + log::error!( + target: LOG_TARGET, + "Era rewards span for era {} is missing, but cleanup marker is set.", + cleanup_marker.era_reward_index + ); + } + + // Attempt to cleanup one expired `DAppTiers` entry. + if cleanup_marker.dapp_tiers_index < oldest_valid_era { + DAppTiers::::remove(cleanup_marker.dapp_tiers_index); + cleanup_marker.dapp_tiers_index.saturating_inc(); + } + + // One extra grace period before we cleanup period end info. + // This so we can always read the `final_era` of that period. + if let Some(period_end_cleanup) = latest_expired_period.checked_sub(1) { + PeriodEnd::::remove(period_end_cleanup); + } + + // Store the updated cleanup marker + HistoryCleanupMarker::::put(cleanup_marker); + + // We could try & cleanup more entries, but since it's not a critical operation and can happen whenever, + // we opt for the simpler solution where only 1 entry per block is cleaned up. + // It can be changed though. + + T::WeightInfo::on_idle_cleanup() + } + } +} + +/// `OnRuntimeUpgrade` logic used to set & configure init dApp staking v3 storage items. +pub struct DAppStakingV3InitConfig(PhantomData<(T, G)>); +impl< + T: Config, + G: Get<( + EraNumber, + TierParameters, + TiersConfiguration, + )>, + > OnRuntimeUpgrade for DAppStakingV3InitConfig +{ + fn on_runtime_upgrade() -> Weight { + if Pallet::::on_chain_storage_version() >= STORAGE_VERSION { + return T::DbWeight::get().reads(1); + } + + // 0. Unwrap arguments + let (init_era, tier_params, init_tier_config) = G::get(); + + // 1. Prepare active protocol state + let now = frame_system::Pallet::::block_number(); + let voting_period_length = Pallet::::blocks_per_voting_period(); + + let protocol_state = ProtocolState { + era: init_era, + next_era_start: now.saturating_add(voting_period_length), + period_info: PeriodInfo { + number: 1, + subperiod: Subperiod::Voting, + next_subperiod_start_era: init_era.saturating_add(1), + }, + maintenance: true, + }; + + // 2. Write necessary items into storage + ActiveProtocolState::::put(protocol_state); + StaticTierParams::::put(tier_params); + TierConfig::::put(init_tier_config); + STORAGE_VERSION.put::>(); + + // 3. Emit events to make indexers happy + Pallet::::deposit_event(Event::::NewEra { era: init_era }); + Pallet::::deposit_event(Event::::NewSubperiod { + subperiod: Subperiod::Voting, + number: 1, + }); + + log::info!("dApp Staking v3 storage initialized."); + + T::DbWeight::get().reads_writes(1, 4) + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(_state: Vec) -> Result<(), sp_runtime::TryRuntimeError> { + assert_eq!(Pallet::::on_chain_storage_version(), STORAGE_VERSION); + assert!(ActiveProtocolState::::get().maintenance); + + let number_of_tiers = T::NumberOfTiers::get(); + + let tier_params = StaticTierParams::::get(); + assert_eq!(tier_params.reward_portion.len(), number_of_tiers as usize); + assert!(tier_params.is_valid()); + + let tier_config = TierConfig::::get(); + assert_eq!(tier_config.reward_portion.len(), number_of_tiers as usize); + assert_eq!(tier_config.slots_per_tier.len(), number_of_tiers as usize); + assert_eq!(tier_config.tier_thresholds.len(), number_of_tiers as usize); + + Ok(()) + } +} diff --git a/pallets/dapp-staking-v3/src/test/mock.rs b/pallets/dapp-staking-v3/src/test/mock.rs new file mode 100644 index 0000000000..247605e456 --- /dev/null +++ b/pallets/dapp-staking-v3/src/test/mock.rs @@ -0,0 +1,380 @@ +// 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, + test::testing_utils::{assert_block_bump, assert_on_idle_cleanup, MemorySnapshot}, + *, +}; + +use frame_support::{ + construct_runtime, parameter_types, + traits::{fungible::Mutate as FunMutate, ConstU128, ConstU32}, + weights::Weight, +}; +use sp_arithmetic::fixed_point::FixedU64; +use sp_core::H256; +use sp_io::TestExternalities; +use sp_runtime::{ + traits::{BlakeTwo256, IdentityLookup}, + Permill, +}; +use sp_std::cell::RefCell; + +use astar_primitives::{dapp_staking::SmartContract, testing::Header, Balance, BlockNumber}; + +pub(crate) type AccountId = u64; + +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: BlockNumber = 250; + pub BlockWeights: frame_system::limits::BlockWeights = + frame_system::limits::BlockWeights::simple_max(Weight::from_parts(1024, 0)); +} + +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 HoldIdentifier = (); + type FreezeIdentifier = RuntimeFreezeReason; + type MaxHolds = ConstU32<0>; + type MaxFreezes = ConstU32<1>; + type WeightInfo = (); +} + +pub struct DummyPriceProvider; +impl PriceProvider for DummyPriceProvider { + fn average_price() -> FixedU64 { + FixedU64::from_rational(1, 10) + } +} + +thread_local! { + pub(crate) static DOES_PAYOUT_SUCCEED: RefCell = RefCell::new(false); +} + +pub struct DummyStakingRewardHandler; +impl StakingRewardHandler for DummyStakingRewardHandler { + fn staker_and_dapp_reward_pools(_total_staked_value: Balance) -> (Balance, Balance) { + ( + Balance::from(1_000_000_000_000_u128), + Balance::from(1_000_000_000_u128), + ) + } + + fn bonus_reward_pool() -> Balance { + Balance::from(3_000_000_u128) + } + + fn payout_reward(beneficiary: &AccountId, reward: Balance) -> Result<(), ()> { + if DOES_PAYOUT_SUCCEED.with(|v| v.borrow().clone()) { + let _ = Balances::mint_into(beneficiary, reward); + Ok(()) + } else { + Err(()) + } + } +} + +pub(crate) type MockSmartContract = SmartContract; + +#[cfg(feature = "runtime-benchmarks")] +pub struct BenchmarkHelper(sp_std::marker::PhantomData<(SC, ACC)>); +#[cfg(feature = "runtime-benchmarks")] +impl crate::BenchmarkHelper + for BenchmarkHelper +{ + fn get_smart_contract(id: u32) -> MockSmartContract { + MockSmartContract::wasm(id as AccountId) + } + + fn set_balance(account: &AccountId, amount: Balance) { + use frame_support::traits::fungible::Unbalanced as FunUnbalanced; + Balances::write_balance(account, amount) + .expect("Must succeed in test/benchmark environment."); + } +} + +pub struct DummyCycleConfiguration; +impl CycleConfiguration for DummyCycleConfiguration { + fn periods_per_cycle() -> u32 { + 4 + } + + fn eras_per_voting_subperiod() -> u32 { + 8 + } + + fn eras_per_build_and_earn_subperiod() -> u32 { + 16 + } + + fn blocks_per_era() -> u32 { + 10 + } +} + +impl pallet_dapp_staking::Config for Test { + type RuntimeEvent = RuntimeEvent; + type RuntimeFreezeReason = RuntimeFreezeReason; + type Currency = Balances; + type SmartContract = MockSmartContract; + type ManagerOrigin = frame_system::EnsureRoot; + type NativePriceProvider = DummyPriceProvider; + type StakingRewardHandler = DummyStakingRewardHandler; + type CycleConfiguration = DummyCycleConfiguration; + type EraRewardSpanLength = ConstU32<8>; + type RewardRetentionInPeriods = ConstU32<2>; + type MaxNumberOfContracts = ConstU32<10>; + type MaxUnlockingChunks = ConstU32<5>; + type MinimumLockedAmount = ConstU128; + type UnlockingPeriod = ConstU32<2>; + type MaxNumberOfStakedContracts = ConstU32<5>; + type MinimumStakeAmount = ConstU128<3>; + type NumberOfTiers = ConstU32<4>; + type WeightInfo = weights::SubstrateWeight; + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = BenchmarkHelper; +} + +pub struct ExtBuilder; +impl ExtBuilder { + pub fn build() -> TestExternalities { + // Normal behavior is for reward payout to succeed + DOES_PAYOUT_SUCCEED.with(|v| *v.borrow_mut() = true); + + 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); + + let era_length = ::CycleConfiguration::blocks_per_era(); + let voting_period_length_in_eras = + ::CycleConfiguration::eras_per_voting_subperiod(); + + // Init protocol state + pallet_dapp_staking::ActiveProtocolState::::put(ProtocolState { + era: 1, + next_era_start: era_length.saturating_mul(voting_period_length_in_eras.into()) + 1, + period_info: PeriodInfo { + number: 1, + subperiod: Subperiod::Voting, + next_subperiod_start_era: 2, + }, + maintenance: false, + }); + pallet_dapp_staking::CurrentEraInfo::::put(EraInfo { + total_locked: 0, + unlocking: 0, + current_stake_amount: StakeAmount { + voting: 0, + build_and_earn: 0, + era: 1, + period: 1, + }, + next_stake_amount: StakeAmount { + voting: 0, + build_and_earn: 0, + era: 2, + period: 1, + }, + }); + + // Init tier params + let tier_params = TierParameters::<::NumberOfTiers> { + reward_portion: BoundedVec::try_from(vec![ + Permill::from_percent(40), + Permill::from_percent(30), + Permill::from_percent(20), + Permill::from_percent(10), + ]) + .unwrap(), + slot_distribution: BoundedVec::try_from(vec![ + Permill::from_percent(10), + Permill::from_percent(20), + Permill::from_percent(30), + Permill::from_percent(40), + ]) + .unwrap(), + tier_thresholds: BoundedVec::try_from(vec![ + TierThreshold::DynamicTvlAmount { + amount: 100, + minimum_amount: 80, + }, + TierThreshold::DynamicTvlAmount { + amount: 50, + minimum_amount: 40, + }, + TierThreshold::DynamicTvlAmount { + amount: 20, + minimum_amount: 20, + }, + TierThreshold::FixedTvlAmount { amount: 15 }, + ]) + .unwrap(), + }; + + // Init tier config, based on the initial params + let init_tier_config = TiersConfiguration::<::NumberOfTiers> { + number_of_slots: 40, + slots_per_tier: BoundedVec::try_from(vec![2, 5, 13, 20]).unwrap(), + reward_portion: tier_params.reward_portion.clone(), + tier_thresholds: tier_params.tier_thresholds.clone(), + }; + + pallet_dapp_staking::StaticTierParams::::put(tier_params); + pallet_dapp_staking::TierConfig::::put(init_tier_config.clone()); + + 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: BlockNumber) { + while System::block_number() < n { + DappStaking::on_finalize(System::block_number()); + assert_on_idle_cleanup(); + System::set_block_number(System::block_number() + 1); + // This is performed outside of dapps staking but we expect it before on_initialize + + let pre_snapshot = MemorySnapshot::new(); + DappStaking::on_initialize(System::block_number()); + assert_block_bump(&pre_snapshot); + } +} + +/// Run for the specified number of blocks. +/// Function assumes first block has been initialized. +pub(crate) fn run_for_blocks(n: BlockNumber) { + 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) { + assert!(era >= ActiveProtocolState::::get().era); + while ActiveProtocolState::::get().era < era { + run_for_blocks(1); + } +} + +/// Advance blocks until next era has been reached. +pub(crate) fn advance_to_next_era() { + advance_to_era(ActiveProtocolState::::get().era + 1); +} + +/// Advance blocks until the specified period has been reached. +/// +/// Function has no effect if period is already passed. +pub(crate) fn advance_to_period(period: PeriodNumber) { + assert!(period >= ActiveProtocolState::::get().period_number()); + while ActiveProtocolState::::get().period_number() < period { + run_for_blocks(1); + } +} + +/// Advance blocks until next period has been reached. +pub(crate) fn advance_to_next_period() { + advance_to_period(ActiveProtocolState::::get().period_number() + 1); +} + +/// Advance blocks until next period type has been reached. +pub(crate) fn advance_to_next_subperiod() { + let subperiod = ActiveProtocolState::::get().subperiod(); + while ActiveProtocolState::::get().subperiod() == subperiod { + run_for_blocks(1); + } +} + +// Return all dApp staking events from the event buffer. +pub fn dapp_staking_events() -> Vec> { + System::events() + .into_iter() + .map(|r| r.event) + .filter_map(|e| ::RuntimeEvent::from(e).try_into().ok()) + .collect::>() +} diff --git a/pallets/dapp-staking-v3/src/test/mod.rs b/pallets/dapp-staking-v3/src/test/mod.rs new file mode 100644 index 0000000000..0774935213 --- /dev/null +++ b/pallets/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 . + +pub(crate) mod mock; +mod testing_utils; +mod tests; +mod tests_types; diff --git a/pallets/dapp-staking-v3/src/test/testing_utils.rs b/pallets/dapp-staking-v3/src/test/testing_utils.rs new file mode 100644 index 0000000000..5e2e0af0d5 --- /dev/null +++ b/pallets/dapp-staking-v3/src/test/testing_utils.rs @@ -0,0 +1,1460 @@ +// 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::types::*; +use crate::{ + pallet::Config, ActiveProtocolState, ContractStake, CurrentEraInfo, DAppId, DAppTiers, + EraRewards, Event, FreezeReason, HistoryCleanupMarker, IntegratedDApps, Ledger, NextDAppId, + PeriodEnd, PeriodEndInfo, StakerInfo, +}; + +use frame_support::{ + assert_ok, assert_storage_noop, + traits::{fungible::InspectFreeze, Get, OnIdle}, + weights::Weight, +}; +use sp_runtime::{traits::Zero, Perbill}; +use std::collections::HashMap; + +use astar_primitives::{dapp_staking::CycleConfiguration, Balance, BlockNumber}; + +/// Helper struct used to store the entire pallet state snapshot. +/// Used when comparison of before/after states is required. +#[derive(Debug)] +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>, + staker_info: HashMap< + ( + ::AccountId, + ::SmartContract, + ), + SingularStakingInfo, + >, + contract_stake: HashMap, + era_rewards: HashMap::EraRewardSpanLength>>, + period_end: HashMap, + dapp_tiers: HashMap>, +} + +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(), + staker_info: StakerInfo::::iter() + .map(|(k1, k2, v)| ((k1, k2), v)) + .collect(), + contract_stake: ContractStake::::iter().collect(), + era_rewards: EraRewards::::iter().collect(), + period_end: PeriodEnd::::iter().collect(), + dapp_tiers: DAppTiers::::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.active_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_beneficiary( + owner: AccountId, + smart_contract: &MockSmartContract, + beneficiary: Option, +) { + // Change reward destination + assert_ok!(DappStaking::set_dapp_reward_beneficiary( + 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), + ); + assert!(!ContractStake::::contains_key( + &IntegratedDApps::::get(&smart_contract).unwrap().id + )); +} + +/// 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 init_frozen_balance = Balances::balance_frozen(&FreezeReason::DAppStaking.into(), &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.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!( + init_frozen_balance + expected_lock_amount, + Balances::balance_frozen(&FreezeReason::DAppStaking.into(), &account) + ); +} + +/// Start the unlocking process for locked funds and assert success. +pub(crate) fn assert_unlock(account: AccountId, amount: Balance) { + let pre_snapshot = MemorySnapshot::new(); + let init_frozen_balance = Balances::balance_frozen(&FreezeReason::DAppStaking.into(), &account); + + assert!( + pre_snapshot.ledger.contains_key(&account), + "Cannot unlock for non-existing ledger." + ); + + // Calculate expected unlock amount + let pre_ledger = &pre_snapshot.ledger[&account]; + let expected_unlock_amount = { + // Cannot unlock more than is available + let possible_unlock_amount = pre_ledger + .unlockable_amount(pre_snapshot.active_protocol_state.period_number()) + .min(amount); + + // When unlocking would take account below the minimum lock threshold, unlock everything + let locked_amount = pre_ledger.active_locked_amount(); + let min_locked_amount = ::MinimumLockedAmount::get(); + if locked_amount.saturating_sub(possible_unlock_amount) < min_locked_amount { + locked_amount + } else { + possible_unlock_amount + } + }; + + // Unlock funds + assert_ok!(DappStaking::unlock(RuntimeOrigin::signed(account), amount,)); + System::assert_last_event(RuntimeEvent::DappStaking(Event::Unlocking { + account, + amount: expected_unlock_amount, + })); + + // Verify post-state + let post_snapshot = MemorySnapshot::new(); + + // Verify ledger is as expected + let period_number = pre_snapshot.active_protocol_state.period_number(); + let post_ledger = &post_snapshot.ledger[&account]; + assert_eq!( + pre_ledger.active_locked_amount(), + post_ledger.active_locked_amount() + expected_unlock_amount, + "Active locked amount should be decreased by the amount unlocked." + ); + assert_eq!( + pre_ledger.unlocking_amount() + expected_unlock_amount, + post_ledger.unlocking_amount(), + "Total unlocking amount should be increased by the amount unlocked." + ); + assert_eq!( + pre_ledger.total_locked_amount(), + post_ledger.total_locked_amount(), + "Total locked amount should remain exactly the same since the unlocking chunks are still locked." + ); + assert_eq!( + pre_ledger.unlockable_amount(period_number), + post_ledger.unlockable_amount(period_number) + expected_unlock_amount, + "Unlockable amount should be decreased by the amount unlocked." + ); + + // In case ledger is empty, it should have been removed from the storage + if post_ledger.is_empty() { + assert!(!Ledger::::contains_key(&account)); + } + + // Verify era info post-state + let pre_era_info = &pre_snapshot.current_era_info; + let post_era_info = &post_snapshot.current_era_info; + assert_eq!( + pre_era_info.unlocking + expected_unlock_amount, + post_era_info.unlocking + ); + assert_eq!( + pre_era_info + .total_locked + .saturating_sub(expected_unlock_amount), + post_era_info.total_locked + ); + + assert_eq!( + init_frozen_balance , + Balances::balance_frozen(&FreezeReason::DAppStaking.into(), &account), + "Frozen balance must remain the same since the funds are still locked/frozen, only undergoing the unlocking process." + ); +} + +/// Claims the unlocked funds back into free balance of the user and assert success. +pub(crate) fn assert_claim_unlocked(account: AccountId) { + let pre_snapshot = MemorySnapshot::new(); + + assert!( + pre_snapshot.ledger.contains_key(&account), + "Cannot claim unlocked for non-existing ledger." + ); + + let current_block = System::block_number(); + let mut consumed_chunks = 0; + let mut amount = 0; + for unlock_chunk in pre_snapshot.ledger[&account].clone().unlocking.into_inner() { + if unlock_chunk.unlock_block <= current_block { + amount += unlock_chunk.amount; + consumed_chunks += 1; + } + } + + // Claim unlocked chunks + assert_ok!(DappStaking::claim_unlocked(RuntimeOrigin::signed(account))); + System::assert_last_event(RuntimeEvent::DappStaking(Event::ClaimedUnlocked { + account, + amount, + })); + + // Verify post-state + let post_snapshot = MemorySnapshot::new(); + + let post_ledger = if let Some(ledger) = post_snapshot.ledger.get(&account) { + ledger.clone() + } else { + Default::default() + }; + + assert_eq!( + post_ledger.unlocking.len(), + pre_snapshot.ledger[&account].unlocking.len() - consumed_chunks + ); + assert_eq!( + post_ledger.unlocking_amount(), + pre_snapshot.ledger[&account].unlocking_amount() - amount + ); + assert_eq!( + post_snapshot.current_era_info.unlocking, + pre_snapshot.current_era_info.unlocking - amount + ); + + // In case of full withdrawal from the protocol + if post_ledger.is_empty() { + assert!(!Ledger::::contains_key(&account)); + assert!( + StakerInfo::::iter_prefix_values(&account) + .count() + .is_zero(), + "All stake entries need to be cleaned up." + ); + } +} + +/// Claims the unlocked funds back into free balance of the user and assert success. +pub(crate) fn assert_relock_unlocking(account: AccountId) { + let pre_snapshot = MemorySnapshot::new(); + + assert!( + pre_snapshot.ledger.contains_key(&account), + "Cannot relock unlocking non-existing ledger." + ); + + let amount = pre_snapshot.ledger[&account].unlocking_amount(); + + // Relock unlocking chunks + assert_ok!(DappStaking::relock_unlocking(RuntimeOrigin::signed( + account + ))); + System::assert_last_event(RuntimeEvent::DappStaking(Event::Relock { account, amount })); + + // Verify post-state + let post_snapshot = MemorySnapshot::new(); + + // Account ledger + let post_ledger = &post_snapshot.ledger[&account]; + assert!(post_ledger.unlocking.is_empty()); + assert!(post_ledger.unlocking_amount().is_zero()); + assert_eq!( + post_ledger.active_locked_amount(), + pre_snapshot.ledger[&account].active_locked_amount() + amount + ); + + // Current era info + assert_eq!( + post_snapshot.current_era_info.unlocking, + pre_snapshot.current_era_info.unlocking - amount + ); + assert_eq!( + post_snapshot.current_era_info.total_locked, + pre_snapshot.current_era_info.total_locked + amount + ); +} + +/// Stake some funds on the specified smart contract. +pub(crate) fn assert_stake( + account: AccountId, + smart_contract: &MockSmartContract, + amount: Balance, +) { + let pre_snapshot = MemorySnapshot::new(); + let pre_ledger = pre_snapshot.ledger.get(&account).unwrap(); + let pre_staker_info = pre_snapshot + .staker_info + .get(&(account, smart_contract.clone())); + let pre_contract_stake = pre_snapshot + .contract_stake + .get(&pre_snapshot.integrated_dapps[&smart_contract].id) + .map_or(ContractStakeAmount::default(), |series| series.clone()); + let pre_era_info = pre_snapshot.current_era_info; + + let stake_era = pre_snapshot.active_protocol_state.era + 1; + let stake_period = pre_snapshot.active_protocol_state.period_number(); + let stake_subperiod = pre_snapshot.active_protocol_state.subperiod(); + + // Stake on smart contract & verify event + assert_ok!(DappStaking::stake( + RuntimeOrigin::signed(account), + smart_contract.clone(), + amount + )); + System::assert_last_event(RuntimeEvent::DappStaking(Event::Stake { + account, + smart_contract: smart_contract.clone(), + amount, + })); + + // Verify post-state + let post_snapshot = MemorySnapshot::new(); + let post_ledger = post_snapshot.ledger.get(&account).unwrap(); + let post_staker_info = post_snapshot + .staker_info + .get(&(account, *smart_contract)) + .expect("Entry must exist since 'stake' operation was successfull."); + let post_contract_stake = post_snapshot + .contract_stake + .get(&pre_snapshot.integrated_dapps[&smart_contract].id) + .expect("Entry must exist since 'stake' operation was successfull."); + let post_era_info = post_snapshot.current_era_info; + + // 1. verify ledger + // ===================== + // ===================== + assert_eq!( + post_ledger.staked, pre_ledger.staked, + "Must remain exactly the same." + ); + assert_eq!(post_ledger.staked_future.unwrap().period, stake_period); + assert_eq!( + post_ledger.staked_amount(stake_period), + pre_ledger.staked_amount(stake_period) + amount, + "Stake amount must increase by the 'amount'" + ); + assert_eq!( + post_ledger.stakeable_amount(stake_period), + pre_ledger.stakeable_amount(stake_period) - amount, + "Stakeable amount must decrease by the 'amount'" + ); + + // 2. 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() + 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.is_loyal(), + pre_staker_info.is_loyal(), + "Staking operation mustn't change loyalty flag." + ); + } + // 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.is_loyal(), + stake_subperiod == Subperiod::Voting + ); + } + } + + // 3. verify contract stake + // ========================= + // ========================= + assert_eq!( + post_contract_stake.total_staked_amount(stake_period), + pre_contract_stake.total_staked_amount(stake_period) + amount, + "Staked amount must increase by the 'amount'" + ); + assert_eq!( + post_contract_stake.staked_amount(stake_period, stake_subperiod), + pre_contract_stake.staked_amount(stake_period, stake_subperiod) + amount, + "Staked amount must increase by the 'amount'" + ); + + assert_eq!( + post_contract_stake.latest_stake_period(), + Some(stake_period) + ); + assert_eq!(post_contract_stake.latest_stake_era(), Some(stake_era)); + + // 4. verify era info + // ========================= + // ========================= + assert_eq!( + post_era_info.total_staked_amount(), + pre_era_info.total_staked_amount(), + "Total staked amount for the current era must remain the same." + ); + assert_eq!( + post_era_info.total_staked_amount_next_era(), + pre_era_info.total_staked_amount_next_era() + amount + ); + assert_eq!( + post_era_info.staked_amount_next_era(stake_subperiod), + pre_era_info.staked_amount_next_era(stake_subperiod) + amount + ); +} + +/// Unstake some funds from the specified smart contract. +pub(crate) fn assert_unstake( + account: AccountId, + smart_contract: &MockSmartContract, + amount: Balance, +) { + let pre_snapshot = MemorySnapshot::new(); + let pre_ledger = pre_snapshot.ledger.get(&account).unwrap(); + let pre_staker_info = pre_snapshot + .staker_info + .get(&(account, smart_contract.clone())) + .expect("Entry must exist since 'unstake' is being called."); + let pre_contract_stake = pre_snapshot + .contract_stake + .get(&pre_snapshot.integrated_dapps[&smart_contract].id) + .expect("Entry must exist since 'unstake' is being called."); + let pre_era_info = pre_snapshot.current_era_info; + + 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 amount = if is_full_unstake { + pre_staker_info.total_staked_amount() + } else { + amount + }; + + // Unstake from smart contract & verify event + assert_ok!(DappStaking::unstake( + RuntimeOrigin::signed(account), + smart_contract.clone(), + amount + )); + System::assert_last_event(RuntimeEvent::DappStaking(Event::Unstake { + account, + smart_contract: smart_contract.clone(), + amount, + })); + + // Verify post-state + let post_snapshot = MemorySnapshot::new(); + let post_ledger = post_snapshot.ledger.get(&account).unwrap(); + let post_contract_stake = post_snapshot + .contract_stake + .get(&pre_snapshot.integrated_dapps[&smart_contract].id) + .expect("Entry must exist since 'unstake' operation was successfull."); + let post_era_info = post_snapshot.current_era_info; + + // 1. verify ledger + // ===================== + // ===================== + assert_eq!( + post_ledger.staked_amount(unstake_period), + pre_ledger.staked_amount(unstake_period) - amount, + "Stake amount must decrease by the 'amount'" + ); + assert_eq!( + post_ledger.stakeable_amount(unstake_period), + pre_ledger.stakeable_amount(unstake_period) + amount, + "Stakeable amount must increase by the 'amount'" + ); + + // 2. verify staker info + // ===================== + // ===================== + 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 successfull 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() - 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(amount), + "Staked amount must decrease by the 'amount'" + ); + + let is_loyal = pre_staker_info.is_loyal() + && !(unstake_subperiod == Subperiod::BuildAndEarn + && post_staker_info.staked_amount(Subperiod::Voting) + < pre_staker_info.staked_amount(Subperiod::Voting)); + assert_eq!( + post_staker_info.is_loyal(), + is_loyal, + "If 'Voting' stake amount is reduced in B&E period, loyalty flag must be set to false." + ); + } + + // 3. verify contract stake + // ========================= + // ========================= + assert_eq!( + post_contract_stake.total_staked_amount(unstake_period), + pre_contract_stake.total_staked_amount(unstake_period) - amount, + "Staked amount must decreased by the 'amount'" + ); + assert_eq!( + post_contract_stake.staked_amount(unstake_period, unstake_subperiod), + pre_contract_stake + .staked_amount(unstake_period, unstake_subperiod) + .saturating_sub(amount), + "Staked amount must decreased by the 'amount'" + ); + + // 4. verify era info + // ========================= + // ========================= + + // It's possible next era has staked more than the current era. This is because 'stake' will always stake for the NEXT era. + if pre_era_info.total_staked_amount() < amount { + assert!(post_era_info.total_staked_amount().is_zero()); + } else { + assert_eq!( + post_era_info.total_staked_amount(), + pre_era_info.total_staked_amount() - amount, + "Total staked amount for the current era must decrease by 'amount'." + ); + } + assert_eq!( + post_era_info.total_staked_amount_next_era(), + pre_era_info.total_staked_amount_next_era() - amount, + "Total staked amount for the next era must decrease by 'amount'. No overflow is allowed." + ); + + // Check for unstake underflow. + if unstake_subperiod == Subperiod::BuildAndEarn + && pre_era_info.staked_amount_next_era(Subperiod::BuildAndEarn) < amount + { + let overflow = amount - pre_era_info.staked_amount_next_era(Subperiod::BuildAndEarn); + + assert!(post_era_info + .staked_amount_next_era(Subperiod::BuildAndEarn) + .is_zero()); + assert_eq!( + post_era_info.staked_amount_next_era(Subperiod::Voting), + pre_era_info.staked_amount_next_era(Subperiod::Voting) - overflow + ); + } else { + assert_eq!( + post_era_info.staked_amount_next_era(unstake_subperiod), + pre_era_info.staked_amount_next_era(unstake_subperiod) - amount + ); + } +} + +/// Claim staker rewards. +pub(crate) fn assert_claim_staker_rewards(account: AccountId) { + let pre_snapshot = MemorySnapshot::new(); + let pre_ledger = pre_snapshot.ledger.get(&account).unwrap(); + let pre_total_issuance = ::Currency::total_issuance(); + let pre_free_balance = ::Currency::free_balance(&account); + + // Get the first eligible era for claiming rewards + let first_claim_era = pre_ledger + .earliest_staked_era() + .expect("Entry must exist, otherwise 'claim' is invalid."); + + // Get the apprropriate era rewards span for the 'first era' + let era_span_length: EraNumber = ::EraRewardSpanLength::get(); + let era_span_index = first_claim_era - (first_claim_era % era_span_length); + let era_rewards_span = pre_snapshot + .era_rewards + .get(&era_span_index) + .expect("Entry must exist, otherwise 'claim' is invalid."); + + // Calculate the final era for claiming rewards. Also determine if this will fully claim all staked period rewards. + let claim_period_end = if pre_ledger.staked_period().unwrap() + == pre_snapshot.active_protocol_state.period_number() + { + None + } else { + Some( + pre_snapshot + .period_end + .get(&pre_ledger.staked_period().unwrap()) + .expect("Entry must exist, since it's the current period.") + .final_era, + ) + }; + + let (last_claim_era, is_full_claim) = if claim_period_end.is_none() { + (pre_snapshot.active_protocol_state.era - 1, false) + } else { + let claim_period = pre_ledger.staked_period().unwrap(); + let period_end = pre_snapshot + .period_end + .get(&claim_period) + .expect("Entry must exist, since it's a past period."); + + let last_claim_era = era_rewards_span.last_era().min(period_end.final_era); + let is_full_claim = last_claim_era == period_end.final_era; + (last_claim_era, is_full_claim) + }; + + assert!( + last_claim_era < pre_snapshot.active_protocol_state.era, + "Sanity check." + ); + + // Calculate the expected rewards + let mut rewards = Vec::new(); + for (era, amount) in pre_ledger + .clone() + .claim_up_to_era(last_claim_era, claim_period_end) + .unwrap() + { + let era_reward_info = era_rewards_span + .get(era) + .expect("Entry must exist, otherwise 'claim' is invalid."); + + let reward = Perbill::from_rational(amount, era_reward_info.staked) + * era_reward_info.staker_reward_pool; + if reward.is_zero() { + continue; + } + + rewards.push((era, reward)); + } + let total_reward = rewards + .iter() + .fold(Balance::zero(), |acc, (_, reward)| acc + reward); + + //clean up possible leftover events + System::reset_events(); + + // Claim staker rewards & verify all events + assert_ok!(DappStaking::claim_staker_rewards(RuntimeOrigin::signed( + account + ),)); + + let events = dapp_staking_events(); + assert_eq!(events.len(), rewards.len()); + for (event, (era, reward)) in events.iter().zip(rewards.iter()) { + assert_eq!( + event, + &Event::::Reward { + account, + era: *era, + amount: *reward, + } + ); + } + + // Verify post state + + let post_total_issuance = ::Currency::total_issuance(); + assert_eq!( + post_total_issuance, + pre_total_issuance + total_reward, + "Total issuance must increase by the total reward amount." + ); + + let post_free_balance = ::Currency::free_balance(&account); + assert_eq!( + post_free_balance, + pre_free_balance + total_reward, + "Free balance must increase by the total reward amount." + ); + + let post_snapshot = MemorySnapshot::new(); + let post_ledger = post_snapshot.ledger.get(&account).unwrap(); + + if is_full_claim { + assert_eq!(post_ledger.staked, StakeAmount::default()); + assert!(post_ledger.staked_future.is_none()); + } else { + assert_eq!(post_ledger.staked.era, last_claim_era + 1); + assert!(post_ledger.staked_future.is_none()); + } +} + +/// Claim staker rewards. +pub(crate) fn assert_claim_bonus_reward(account: AccountId, smart_contract: &MockSmartContract) { + let pre_snapshot = MemorySnapshot::new(); + let pre_staker_info = pre_snapshot + .staker_info + .get(&(account, *smart_contract)) + .unwrap(); + let pre_total_issuance = ::Currency::total_issuance(); + let pre_free_balance = ::Currency::free_balance(&account); + + let staked_period = pre_staker_info.period_number(); + let stake_amount = pre_staker_info.staked_amount(Subperiod::Voting); + + let period_end_info = pre_snapshot + .period_end + .get(&staked_period) + .expect("Entry must exist, since it's a past period."); + + let reward = Perbill::from_rational(stake_amount, period_end_info.total_vp_stake) + * period_end_info.bonus_reward_pool; + + // Claim bonus reward & verify event + assert_ok!(DappStaking::claim_bonus_reward( + RuntimeOrigin::signed(account), + smart_contract.clone(), + )); + System::assert_last_event(RuntimeEvent::DappStaking(Event::BonusReward { + account, + smart_contract: *smart_contract, + period: staked_period, + amount: reward, + })); + + // Verify post state + + let post_total_issuance = ::Currency::total_issuance(); + assert_eq!( + post_total_issuance, + pre_total_issuance + reward, + "Total issuance must increase by the reward amount." + ); + + let post_free_balance = ::Currency::free_balance(&account); + assert_eq!( + post_free_balance, + pre_free_balance + reward, + "Free balance must increase by the reward amount." + ); + + assert!( + !StakerInfo::::contains_key(&account, smart_contract), + "Entry must be removed after successful reward claim." + ); + assert_eq!( + pre_snapshot.ledger[&account].contract_stake_count, + Ledger::::get(&account).contract_stake_count + 1, + "Count must be reduced since the staker info entry was removed." + ); +} + +/// Claim dapp reward for a particular era. +pub(crate) fn assert_claim_dapp_reward( + account: AccountId, + smart_contract: &MockSmartContract, + era: EraNumber, +) { + let pre_snapshot = MemorySnapshot::new(); + let dapp_info = pre_snapshot.integrated_dapps.get(smart_contract).unwrap(); + let beneficiary = dapp_info.reward_beneficiary(); + let pre_total_issuance = ::Currency::total_issuance(); + let pre_free_balance = ::Currency::free_balance(beneficiary); + + let (expected_reward, expected_tier_id) = { + let mut info = pre_snapshot + .dapp_tiers + .get(&era) + .expect("Entry must exist.") + .clone(); + + info.try_claim(dapp_info.id).unwrap() + }; + + // Claim dApp reward & verify event + assert_ok!(DappStaking::claim_dapp_reward( + RuntimeOrigin::signed(account), + smart_contract.clone(), + era, + )); + System::assert_last_event(RuntimeEvent::DappStaking(Event::DAppReward { + beneficiary: beneficiary.clone(), + smart_contract: smart_contract.clone(), + tier_id: expected_tier_id, + era, + amount: expected_reward, + })); + + // Verify post-state + + let post_total_issuance = ::Currency::total_issuance(); + assert_eq!( + post_total_issuance, + pre_total_issuance + expected_reward, + "Total issuance must increase by the reward amount." + ); + + let post_free_balance = ::Currency::free_balance(beneficiary); + assert_eq!( + post_free_balance, + pre_free_balance + expected_reward, + "Free balance must increase by the reward amount." + ); + + let post_snapshot = MemorySnapshot::new(); + let mut info = post_snapshot + .dapp_tiers + .get(&era) + .expect("Entry must exist.") + .clone(); + assert_eq!( + info.try_claim(dapp_info.id), + Err(DAppTierError::RewardAlreadyClaimed), + "It must not be possible to claim the same reward twice!.", + ); +} + +/// Unstake some funds from the specified unregistered smart contract. +pub(crate) fn assert_unstake_from_unregistered( + account: AccountId, + smart_contract: &MockSmartContract, +) { + let pre_snapshot = MemorySnapshot::new(); + let pre_ledger = pre_snapshot.ledger.get(&account).unwrap(); + let pre_staker_info = pre_snapshot + .staker_info + .get(&(account, smart_contract.clone())) + .expect("Entry must exist since 'unstake_from_unregistered' is being called."); + let pre_era_info = pre_snapshot.current_era_info; + + let amount = pre_staker_info.total_staked_amount(); + + // Unstake from smart contract & verify event + assert_ok!(DappStaking::unstake_from_unregistered( + RuntimeOrigin::signed(account), + smart_contract.clone(), + )); + System::assert_last_event(RuntimeEvent::DappStaking(Event::UnstakeFromUnregistered { + account, + smart_contract: smart_contract.clone(), + amount, + })); + + // Verify post-state + let post_snapshot = MemorySnapshot::new(); + let post_ledger = post_snapshot.ledger.get(&account).unwrap(); + let post_era_info = post_snapshot.current_era_info; + let period = pre_snapshot.active_protocol_state.period_number(); + let unstake_subperiod = pre_snapshot.active_protocol_state.subperiod(); + + // 1. verify ledger + // ===================== + // ===================== + assert_eq!( + post_ledger.staked_amount(period), + pre_ledger.staked_amount(period) - amount, + "Stake amount must decrease by the 'amount'" + ); + assert_eq!( + post_ledger.stakeable_amount(period), + pre_ledger.stakeable_amount(period) + amount, + "Stakeable amount must increase by the 'amount'" + ); + + // 2. verify staker info + // ===================== + // ===================== + assert!( + !StakerInfo::::contains_key(&account, smart_contract), + "Entry must be deleted since contract is unregistered." + ); + + // 3. verify era info + // ========================= + // ========================= + // It's possible next era has staked more than the current era. This is because 'stake' will always stake for the NEXT era. + if pre_era_info.total_staked_amount() < amount { + assert!(post_era_info.total_staked_amount().is_zero()); + } else { + assert_eq!( + post_era_info.total_staked_amount(), + pre_era_info.total_staked_amount() - amount, + "Total staked amount for the current era must decrease by 'amount'." + ); + } + assert_eq!( + post_era_info.total_staked_amount_next_era(), + pre_era_info.total_staked_amount_next_era() - amount, + "Total staked amount for the next era must decrease by 'amount'. No overflow is allowed." + ); + + // Check for unstake underflow. + if unstake_subperiod == Subperiod::BuildAndEarn + && pre_era_info.staked_amount_next_era(Subperiod::BuildAndEarn) < amount + { + let overflow = amount - pre_era_info.staked_amount_next_era(Subperiod::BuildAndEarn); + + assert!(post_era_info + .staked_amount_next_era(Subperiod::BuildAndEarn) + .is_zero()); + assert_eq!( + post_era_info.staked_amount_next_era(Subperiod::Voting), + pre_era_info.staked_amount_next_era(Subperiod::Voting) - overflow + ); + } else { + assert_eq!( + post_era_info.staked_amount_next_era(unstake_subperiod), + pre_era_info.staked_amount_next_era(unstake_subperiod) - amount + ); + } +} + +/// Cleanup expired DB entries for the account and verify post state. +pub(crate) fn assert_cleanup_expired_entries(account: AccountId) { + let pre_snapshot = MemorySnapshot::new(); + + let current_period = pre_snapshot.active_protocol_state.period_number(); + let threshold_period = DappStaking::oldest_claimable_period(current_period); + + // Find entries which should be kept, and which should be deleted + let mut to_be_deleted = Vec::new(); + let mut to_be_kept = Vec::new(); + pre_snapshot + .staker_info + .iter() + .for_each(|((inner_account, contract), entry)| { + if *inner_account == account { + if entry.period_number() < current_period && !entry.is_loyal() + || entry.period_number() < threshold_period + { + to_be_deleted.push(contract); + } else { + to_be_kept.push(contract); + } + } + }); + + // Cleanup expired entries and verify event + assert_ok!(DappStaking::cleanup_expired_entries(RuntimeOrigin::signed( + account + ))); + System::assert_last_event(RuntimeEvent::DappStaking(Event::ExpiredEntriesRemoved { + account, + count: to_be_deleted.len().try_into().unwrap(), + })); + + // Verify post-state + let post_snapshot = MemorySnapshot::new(); + + // Ensure that correct entries have been kept + assert_eq!(post_snapshot.staker_info.len(), to_be_kept.len()); + to_be_kept.iter().for_each(|contract| { + assert!(post_snapshot + .staker_info + .contains_key(&(account, **contract))); + }); + + // Ensure that ledger has been correctly updated + let pre_ledger = pre_snapshot.ledger.get(&account).unwrap(); + let post_ledger = post_snapshot.ledger.get(&account).unwrap(); + + let num_of_deleted_entries: u32 = to_be_deleted.len().try_into().unwrap(); + assert_eq!( + pre_ledger.contract_stake_count - num_of_deleted_entries, + post_ledger.contract_stake_count + ); +} + +/// Asserts correct transitions of the protocol after a block has been produced. +pub(crate) fn assert_block_bump(pre_snapshot: &MemorySnapshot) { + let current_block_number = System::block_number(); + + // No checks if era didn't change. + if pre_snapshot.active_protocol_state.next_era_start > current_block_number { + return; + } + + // Verify post state + let post_snapshot = MemorySnapshot::new(); + + let is_new_subperiod = pre_snapshot + .active_protocol_state + .period_info + .next_subperiod_start_era + <= post_snapshot.active_protocol_state.era; + + // 1. Verify protocol state + let pre_protoc_state = pre_snapshot.active_protocol_state; + let post_protoc_state = post_snapshot.active_protocol_state; + assert_eq!(post_protoc_state.era, pre_protoc_state.era + 1); + + match pre_protoc_state.subperiod() { + Subperiod::Voting => { + assert_eq!( + post_protoc_state.subperiod(), + Subperiod::BuildAndEarn, + "Voting subperiod only lasts for a single era." + ); + + let eras_per_bep = + ::CycleConfiguration::eras_per_build_and_earn_subperiod(); + assert_eq!( + post_protoc_state.period_info.next_subperiod_start_era, + post_protoc_state.era + eras_per_bep, + "Build&earn must last for the predefined amount of standard eras." + ); + + let standard_era_length = ::CycleConfiguration::blocks_per_era(); + assert_eq!( + post_protoc_state.next_era_start, + current_block_number + standard_era_length, + "Era in build&earn period must last for the predefined amount of blocks." + ); + } + Subperiod::BuildAndEarn => { + if is_new_subperiod { + assert_eq!( + post_protoc_state.subperiod(), + Subperiod::Voting, + "Since we expect a new subperiod, it must be 'Voting'." + ); + assert_eq!( + post_protoc_state.period_number(), + pre_protoc_state.period_number() + 1, + "Ending 'Build&Earn' triggers a new period." + ); + assert_eq!( + post_protoc_state.period_info.next_subperiod_start_era, + post_protoc_state.era + 1, + "Voting era must last for a single era." + ); + + let blocks_per_standard_era = + ::CycleConfiguration::blocks_per_era(); + let eras_per_voting_subperiod = + ::CycleConfiguration::eras_per_voting_subperiod(); + let eras_per_voting_subperiod: BlockNumber = eras_per_voting_subperiod.into(); + let era_length: BlockNumber = blocks_per_standard_era * eras_per_voting_subperiod; + assert_eq!( + post_protoc_state.next_era_start, + current_block_number + era_length, + "The upcoming 'Voting' subperiod must last for the 'standard eras per voting subperiod x standard era length' amount of blocks." + ); + } else { + assert_eq!( + post_protoc_state.period_info, pre_protoc_state.period_info, + "New subperiod hasn't started, hence it should remain 'Build&Earn'." + ); + } + } + } + + // 2. Verify current era info + let pre_era_info = pre_snapshot.current_era_info; + let post_era_info = post_snapshot.current_era_info; + + assert_eq!(post_era_info.total_locked, pre_era_info.total_locked); + assert_eq!(post_era_info.unlocking, pre_era_info.unlocking); + + // New period has started + if is_new_subperiod && pre_protoc_state.subperiod() == Subperiod::BuildAndEarn { + assert_eq!( + post_era_info.current_stake_amount, + StakeAmount { + voting: Zero::zero(), + build_and_earn: Zero::zero(), + era: pre_protoc_state.era + 1, + period: pre_protoc_state.period_number() + 1, + } + ); + assert_eq!( + post_era_info.next_stake_amount, + StakeAmount { + voting: Zero::zero(), + build_and_earn: Zero::zero(), + era: pre_protoc_state.era + 2, + period: pre_protoc_state.period_number() + 1, + } + ); + } else { + assert_eq!( + post_era_info.current_stake_amount, + pre_era_info.next_stake_amount + ); + assert_eq!( + post_era_info.next_stake_amount.total(), + post_era_info.current_stake_amount.total() + ); + assert_eq!( + post_era_info.next_stake_amount.era, + post_protoc_state.era + 1, + ); + assert_eq!( + post_era_info.next_stake_amount.period, + pre_protoc_state.period_number(), + ); + } + + // 3. Verify era reward + let era_span_index = DappStaking::era_reward_span_index(pre_protoc_state.era); + let maybe_pre_era_reward_span = pre_snapshot.era_rewards.get(&era_span_index); + let post_era_reward_span = post_snapshot + .era_rewards + .get(&era_span_index) + .expect("Era reward info must exist after era has finished."); + + // Sanity check + if let Some(pre_era_reward_span) = maybe_pre_era_reward_span { + assert_eq!( + pre_era_reward_span.last_era(), + pre_protoc_state.era - 1, + "If entry exists, it should cover eras up to the previous one, exactly." + ); + } + + assert_eq!( + post_era_reward_span.last_era(), + pre_protoc_state.era, + "Entry must cover the current era." + ); + assert_eq!( + post_era_reward_span + .get(pre_protoc_state.era) + .expect("Above check proved it must exist.") + .staked, + pre_snapshot.current_era_info.total_staked_amount(), + "Total staked amount must be equal to total amount staked at the end of the era." + ); + + // 4. Verify period end + if is_new_subperiod && pre_protoc_state.subperiod() == Subperiod::BuildAndEarn { + let period_end_info = post_snapshot.period_end[&pre_protoc_state.period_number()]; + assert_eq!( + period_end_info.total_vp_stake, + pre_snapshot + .current_era_info + .staked_amount(Subperiod::Voting), + ); + } + + // 5. Verify event(s) + if is_new_subperiod { + let events = dapp_staking_events(); + assert!( + events.len() >= 2, + "At least 2 events should exist from era & subperiod change." + ); + assert_eq!( + events[events.len() - 2], + Event::NewEra { + era: post_protoc_state.era, + } + ); + assert_eq!( + events[events.len() - 1], + Event::NewSubperiod { + subperiod: pre_protoc_state.subperiod().next(), + number: post_protoc_state.period_number(), + } + ) + } else { + System::assert_last_event(RuntimeEvent::DappStaking(Event::NewEra { + era: post_protoc_state.era, + })); + } +} + +/// Verify `on_idle` cleanup. +pub(crate) fn assert_on_idle_cleanup() { + // Pre-data snapshot (limited to speed up testing) + let pre_cleanup_marker = HistoryCleanupMarker::::get(); + let pre_era_rewards: HashMap::EraRewardSpanLength>> = + EraRewards::::iter().collect(); + let pre_period_ends: HashMap = PeriodEnd::::iter().collect(); + + // Calculated the oldest era which is valid (not expired) + let protocol_state = ActiveProtocolState::::get(); + let retention_period: PeriodNumber = ::RewardRetentionInPeriods::get(); + + let oldest_valid_era = match protocol_state + .period_number() + .checked_sub(retention_period + 1) + { + Some(expired_period) if expired_period > 0 => { + pre_period_ends[&expired_period].final_era + 1 + } + _ => { + // No cleanup so no storage changes are expected + assert_storage_noop!(DappStaking::on_idle(System::block_number(), Weight::MAX)); + return; + } + }; + + // Check if any span or tiers cleanup is needed. + let is_era_span_cleanup_expected = + pre_era_rewards[&pre_cleanup_marker.era_reward_index].last_era() < oldest_valid_era; + let is_dapp_tiers_cleanup_expected = pre_cleanup_marker.dapp_tiers_index > 0 + && pre_cleanup_marker.dapp_tiers_index < oldest_valid_era; + + // Check if period end info should be cleaned up + let maybe_period_end_cleanup = match protocol_state + .period_number() + .checked_sub(retention_period + 2) + { + Some(period) if period > 0 => Some(period), + _ => None, + }; + + // Cleanup and verify post state. + + DappStaking::on_idle(System::block_number(), Weight::MAX); + + // Post checks + let post_cleanup_marker = HistoryCleanupMarker::::get(); + + if is_era_span_cleanup_expected { + assert!(!EraRewards::::contains_key( + pre_cleanup_marker.era_reward_index + )); + let span_length: EraNumber = ::EraRewardSpanLength::get(); + assert_eq!( + post_cleanup_marker.era_reward_index, + pre_cleanup_marker.era_reward_index + span_length + ); + } + if is_dapp_tiers_cleanup_expected { + assert!( + !DAppTiers::::contains_key(pre_cleanup_marker.dapp_tiers_index), + "Sanity check." + ); + assert_eq!( + post_cleanup_marker.dapp_tiers_index, + pre_cleanup_marker.dapp_tiers_index + 1 + ) + } + + if let Some(period) = maybe_period_end_cleanup { + assert!(!PeriodEnd::::contains_key(period)); + } +} + +/// Returns from which starting era to which ending era can rewards be claimed for the specified account. +/// +/// If `None` is returned, there is nothing to claim. +/// +/// **NOTE:** Doesn't consider reward expiration. +pub(crate) fn claimable_reward_range(account: AccountId) -> Option<(EraNumber, EraNumber)> { + let ledger = Ledger::::get(&account); + let protocol_state = ActiveProtocolState::::get(); + + let earliest_stake_era = if let Some(era) = ledger.earliest_staked_era() { + era + } else { + return None; + }; + + let last_claim_era = if ledger.staked_period() == Some(protocol_state.period_number()) { + protocol_state.era - 1 + } else { + // Period finished, we can claim up to its final era + let period_end = PeriodEnd::::get(ledger.staked_period().unwrap()).unwrap(); + period_end.final_era + }; + + Some((earliest_stake_era, last_claim_era)) +} + +/// Number of times it's required to call `claim_staker_rewards` to claim all pending rewards. +/// +/// In case no rewards are pending, return **zero**. +pub(crate) fn required_number_of_reward_claims(account: AccountId) -> u32 { + let range = if let Some(range) = claimable_reward_range(account) { + range + } else { + return 0; + }; + + let era_span_length: EraNumber = ::EraRewardSpanLength::get(); + let first = DappStaking::era_reward_span_index(range.0) + .checked_div(era_span_length) + .unwrap(); + let second = DappStaking::era_reward_span_index(range.1) + .checked_div(era_span_length) + .unwrap(); + + second - first + 1 +} diff --git a/pallets/dapp-staking-v3/src/test/tests.rs b/pallets/dapp-staking-v3/src/test/tests.rs new file mode 100644 index 0000000000..12a06baebc --- /dev/null +++ b/pallets/dapp-staking-v3/src/test/tests.rs @@ -0,0 +1,2581 @@ +// 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::*, testing_utils::*}; +use crate::{ + pallet::Config, ActiveProtocolState, DAppId, EraRewards, Error, Event, ForcingType, + IntegratedDApps, Ledger, NextDAppId, PeriodNumber, StakerInfo, Subperiod, TierConfig, +}; + +use frame_support::{ + assert_noop, assert_ok, assert_storage_noop, + error::BadOrigin, + traits::{fungible::Unbalanced as FunUnbalanced, Currency, Get, OnFinalize, OnInitialize}, +}; +use sp_runtime::traits::Zero; + +use astar_primitives::{ + dapp_staking::{CycleConfiguration, SmartContractHandle}, + Balance, BlockNumber, +}; + +#[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)); + System::assert_last_event(RuntimeEvent::DappStaking(Event::MaintenanceMode { + enabled: true, + })); + assert!(ActiveProtocolState::::get().maintenance); + + // Call still works, even in maintenance mode + assert_ok!(DappStaking::maintenance_mode(RuntimeOrigin::root(), false)); + System::assert_last_event(RuntimeEvent::DappStaking(Event::MaintenanceMode { + enabled: false, + })); + 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_storage_noop!(DappStaking::on_initialize(1)); + assert_noop!( + DappStaking::register(RuntimeOrigin::root(), 1, MockSmartContract::Wasm(1)), + Error::::Disabled + ); + assert_noop!( + DappStaking::set_dapp_reward_beneficiary( + 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 + ); + assert_noop!( + DappStaking::unlock(RuntimeOrigin::signed(1), 100), + Error::::Disabled + ); + assert_noop!( + DappStaking::claim_unlocked(RuntimeOrigin::signed(1)), + Error::::Disabled + ); + assert_noop!( + DappStaking::relock_unlocking(RuntimeOrigin::signed(1)), + Error::::Disabled + ); + assert_noop!( + DappStaking::stake( + RuntimeOrigin::signed(1), + MockSmartContract::wasm(1 as AccountId), + 100 + ), + Error::::Disabled + ); + assert_noop!( + DappStaking::unstake( + RuntimeOrigin::signed(1), + MockSmartContract::wasm(1 as AccountId), + 100 + ), + Error::::Disabled + ); + assert_noop!( + DappStaking::claim_staker_rewards(RuntimeOrigin::signed(1)), + Error::::Disabled + ); + assert_noop!( + DappStaking::claim_bonus_reward( + RuntimeOrigin::signed(1), + MockSmartContract::wasm(1 as AccountId) + ), + Error::::Disabled + ); + assert_noop!( + DappStaking::claim_dapp_reward( + RuntimeOrigin::signed(1), + MockSmartContract::wasm(1 as AccountId), + 1 + ), + Error::::Disabled + ); + assert_noop!( + DappStaking::unstake_from_unregistered( + RuntimeOrigin::signed(1), + MockSmartContract::wasm(1 as AccountId) + ), + Error::::Disabled + ); + assert_noop!( + DappStaking::cleanup_expired_entries(RuntimeOrigin::signed(1)), + Error::::Disabled + ); + assert_noop!( + DappStaking::force(RuntimeOrigin::root(), ForcingType::Era), + Error::::Disabled + ); + }) +} + +#[test] +fn on_initialize_is_noop_if_no_era_change() { + ExtBuilder::build().execute_with(|| { + let protocol_state = ActiveProtocolState::::get(); + let current_block_number = System::block_number(); + + assert!( + current_block_number < protocol_state.next_era_start, + "Sanity check, otherwise test doesn't make sense." + ); + + // Sanity check + assert_storage_noop!(DappStaking::on_finalize(current_block_number)); + + // If no era change, on_initialize should be a noop + assert_storage_noop!(DappStaking::on_initialize(current_block_number + 1)); + }) +} + +#[test] +fn on_initialize_base_state_change_works() { + ExtBuilder::build().execute_with(|| { + // Sanity check + let protocol_state = ActiveProtocolState::::get(); + assert_eq!(protocol_state.era, 1); + assert_eq!(protocol_state.period_number(), 1); + assert_eq!(protocol_state.subperiod(), Subperiod::Voting); + assert_eq!(System::block_number(), 1); + + let blocks_per_voting_period = DappStaking::blocks_per_voting_period(); + assert_eq!( + protocol_state.next_era_start, + blocks_per_voting_period + 1, + "Counting starts from block 1, hence the '+ 1'." + ); + + // Advance eras until we reach the Build&Earn period part + run_to_block(protocol_state.next_era_start - 1); + let protocol_state = ActiveProtocolState::::get(); + assert_eq!( + protocol_state.subperiod(), + Subperiod::Voting, + "Period type should still be the same." + ); + assert_eq!(protocol_state.era, 1); + + run_for_blocks(1); + let protocol_state = ActiveProtocolState::::get(); + assert_eq!(protocol_state.subperiod(), Subperiod::BuildAndEarn); + assert_eq!(protocol_state.era, 2); + assert_eq!(protocol_state.period_number(), 1); + + // Advance eras just until we reach the next voting period + let eras_per_bep_period = + ::CycleConfiguration::eras_per_build_and_earn_subperiod(); + let blocks_per_era: BlockNumber = ::CycleConfiguration::blocks_per_era(); + for era in 2..(2 + eras_per_bep_period - 1) { + let pre_block = System::block_number(); + advance_to_next_era(); + assert_eq!(System::block_number(), pre_block + blocks_per_era); + let protocol_state = ActiveProtocolState::::get(); + assert_eq!(protocol_state.subperiod(), Subperiod::BuildAndEarn); + assert_eq!(protocol_state.period_number(), 1); + assert_eq!(protocol_state.era, era + 1); + } + + // Finaly advance over to the next era and ensure we're back to voting period + advance_to_next_era(); + let protocol_state = ActiveProtocolState::::get(); + assert_eq!(protocol_state.subperiod(), Subperiod::Voting); + assert_eq!(protocol_state.era, 2 + eras_per_bep_period); + assert_eq!( + protocol_state.next_era_start, + System::block_number() + blocks_per_voting_period + ); + assert_eq!(protocol_state.period_number(), 2); + }) +} + +#[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_beneficiary_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_beneficiary(owner, &smart_contract, Some(3)); + assert_set_dapp_reward_beneficiary(owner, &smart_contract, Some(5)); + assert_set_dapp_reward_beneficiary(owner, &smart_contract, None); + }) +} + +#[test] +fn set_dapp_reward_beneficiary_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_beneficiary( + 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_beneficiary( + 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_no_stake_is_ok() { + ExtBuilder::build().execute_with(|| { + // Prepare dApp + let owner = 1; + let smart_contract = MockSmartContract::Wasm(3); + assert_register(owner, &smart_contract); + + // Nothing staked on contract, just unregister it. + assert_unregister(&smart_contract); + }) +} + +#[test] +fn unregister_with_active_stake_is_ok() { + ExtBuilder::build().execute_with(|| { + // Prepare dApp + let owner = 1; + let smart_contract = MockSmartContract::Wasm(3); + assert_register(owner, &smart_contract); + assert_lock(owner, 100); + assert_stake(owner, &smart_contract, 100); + + // Some amount is staked, unregister must still work. + 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 unlock_basic_example_is_ok() { + ExtBuilder::build().execute_with(|| { + // Lock some amount + let account = 2; + let lock_amount = 101; + assert_lock(account, lock_amount); + + // Unlock some amount in the same era that it was locked + let first_unlock_amount = 7; + assert_unlock(account, first_unlock_amount); + + // Advance era and unlock additional amount + advance_to_next_era(); + assert_unlock(account, first_unlock_amount); + + // Lock a bit more, and unlock again + assert_lock(account, lock_amount); + assert_unlock(account, first_unlock_amount); + }) +} + +#[test] +fn unlock_with_remaining_amount_below_threshold_is_ok() { + ExtBuilder::build().execute_with(|| { + // Lock some amount in a few eras + let account = 2; + let lock_amount = 101; + assert_lock(account, lock_amount); + advance_to_next_era(); + assert_lock(account, lock_amount); + advance_to_era(ActiveProtocolState::::get().era + 3); + + // Unlock such amount that remaining amount is below threshold, resulting in full unlock + let minimum_locked_amount: Balance = ::MinimumLockedAmount::get(); + let ledger = Ledger::::get(&account); + assert_unlock( + account, + ledger.active_locked_amount() - minimum_locked_amount + 1, + ); + }) +} + +#[test] +fn unlock_with_amount_higher_than_avaiable_is_ok() { + ExtBuilder::build().execute_with(|| { + // Lock some amount in a few eras + let account = 2; + let lock_amount = 101; + assert_lock(account, lock_amount); + advance_to_next_era(); + assert_lock(account, lock_amount); + + // Register contract & stake on it + let smart_contract = MockSmartContract::Wasm(1); + assert_register(1, &smart_contract); + let stake_amount = 91; + assert_stake(account, &smart_contract, stake_amount); + + // Try to unlock more than is available, due to active staked amount + assert_unlock(account, lock_amount - stake_amount + 1); + + // Ensure there is no effect of staked amount once we move to the following period + assert_lock(account, lock_amount - stake_amount); // restore previous state + advance_to_period(ActiveProtocolState::::get().period_number() + 1); + assert_unlock(account, lock_amount - stake_amount + 1); + }) +} + +#[test] +fn unlock_advanced_examples_are_ok() { + ExtBuilder::build().execute_with(|| { + // Lock some amount + let account = 2; + let lock_amount = 101; + assert_lock(account, lock_amount); + + // Unlock some amount in the same era that it was locked + let unlock_amount = 7; + assert_unlock(account, unlock_amount); + + // Advance era and unlock additional amount + advance_to_next_era(); + assert_unlock(account, unlock_amount * 2); + + // Advance few more eras, and unlock everything + advance_to_era(ActiveProtocolState::::get().era + 7); + assert_unlock(account, lock_amount); + assert!(Ledger::::get(&account) + .active_locked_amount() + .is_zero()); + + // Advance one more era and ensure we can still lock & unlock + advance_to_next_era(); + assert_lock(account, lock_amount); + assert_unlock(account, unlock_amount); + }) +} + +#[test] +fn unlock_everything_with_active_stake_fails() { + ExtBuilder::build().execute_with(|| { + let account = 2; + let lock_amount = 101; + assert_lock(account, lock_amount); + advance_to_next_era(); + + // We stake so the amount is just below the minimum locked amount, causing full unlock impossible. + let minimum_locked_amount: Balance = ::MinimumLockedAmount::get(); + let stake_amount = minimum_locked_amount - 1; + + // Register contract & stake on it + let smart_contract = MockSmartContract::Wasm(1); + assert_register(1, &smart_contract); + assert_stake(account, &smart_contract, stake_amount); + + // Try to unlock more than is available, due to active staked amount + assert_noop!( + DappStaking::unlock(RuntimeOrigin::signed(account), lock_amount), + Error::::RemainingStakePreventsFullUnlock, + ); + }) +} + +#[test] +fn unlock_with_zero_amount_fails() { + ExtBuilder::build().execute_with(|| { + let account = 2; + let lock_amount = 101; + assert_lock(account, lock_amount); + advance_to_next_era(); + + // Unlock with zero fails + assert_noop!( + DappStaking::unlock(RuntimeOrigin::signed(account), 0), + Error::::ZeroAmount, + ); + + // Stake everything, so available unlock amount is always zero + let smart_contract = MockSmartContract::Wasm(1); + assert_register(1, &smart_contract); + assert_stake(account, &smart_contract, lock_amount); + + // Try to unlock anything, expect zero amount error + assert_noop!( + DappStaking::unlock(RuntimeOrigin::signed(account), lock_amount), + Error::::ZeroAmount, + ); + }) +} + +#[test] +fn unlock_with_exceeding_unlocking_chunks_storage_limits_fails() { + ExtBuilder::build().execute_with(|| { + // Lock some amount in a few eras + let account = 2; + let lock_amount = 103; + assert_lock(account, lock_amount); + + let unlock_amount = 3; + for _ in 0..::MaxUnlockingChunks::get() { + run_for_blocks(1); + assert_unlock(account, unlock_amount); + } + + // We can still unlock in the current era, theoretically + for _ in 0..5 { + assert_unlock(account, unlock_amount); + } + + // Following unlock should fail due to exceeding storage limits + run_for_blocks(1); + assert_noop!( + DappStaking::unlock(RuntimeOrigin::signed(account), unlock_amount), + Error::::TooManyUnlockingChunks, + ); + }) +} + +#[test] +fn claim_unlocked_is_ok() { + ExtBuilder::build().execute_with(|| { + let unlocking_blocks = DappStaking::unlocking_period(); + + // Lock some amount in a few eras + let account = 2; + let lock_amount = 103; + assert_lock(account, lock_amount); + + // Basic example + let unlock_amount = 3; + assert_unlock(account, unlock_amount); + run_for_blocks(unlocking_blocks); + assert_claim_unlocked(account); + + // Advanced example + let max_unlocking_chunks: u32 = ::MaxUnlockingChunks::get(); + for _ in 0..max_unlocking_chunks { + run_for_blocks(1); + assert_unlock(account, unlock_amount); + } + + // Leave two blocks remaining after the claim + run_for_blocks(unlocking_blocks - 2); + assert_claim_unlocked(account); + + // Claim last two blocks together + run_for_blocks(2); + assert_claim_unlocked(account); + assert!(Ledger::::get(&account).unlocking.is_empty()); + + // Unlock everything + assert_unlock(account, lock_amount); + run_for_blocks(unlocking_blocks); + assert_claim_unlocked(account); + assert!(!Ledger::::contains_key(&account)); + }) +} + +#[test] +fn claim_unlocked_no_eligible_chunks_fails() { + ExtBuilder::build().execute_with(|| { + // Sanity check + let account = 2; + assert_noop!( + DappStaking::claim_unlocked(RuntimeOrigin::signed(account)), + Error::::NoUnlockedChunksToClaim, + ); + + // Cannot claim if unlock period hasn't passed yet + let lock_amount = 103; + assert_lock(account, lock_amount); + let unlocking_blocks = DappStaking::unlocking_period(); + run_for_blocks(unlocking_blocks - 1); + assert_noop!( + DappStaking::claim_unlocked(RuntimeOrigin::signed(account)), + Error::::NoUnlockedChunksToClaim, + ); + }) +} + +#[test] +fn relock_unlocking_is_ok() { + ExtBuilder::build().execute_with(|| { + // Lock some amount + let account = 2; + let lock_amount = 91; + assert_lock(account, lock_amount); + + // Prepare some unlock chunks + let unlock_amount = 5; + assert_unlock(account, unlock_amount); + run_for_blocks(2); + assert_unlock(account, unlock_amount); + + assert_relock_unlocking(account); + + let max_unlocking_chunks: u32 = ::MaxUnlockingChunks::get(); + for _ in 0..max_unlocking_chunks { + run_for_blocks(1); + assert_unlock(account, unlock_amount); + } + + assert_relock_unlocking(account); + }) +} + +#[test] +fn relock_unlocking_no_chunks_fails() { + ExtBuilder::build().execute_with(|| { + assert_noop!( + DappStaking::relock_unlocking(RuntimeOrigin::signed(1)), + Error::::NoUnlockingChunks, + ); + }) +} + +#[test] +fn relock_unlocking_insufficient_lock_amount_fails() { + ExtBuilder::build().execute_with(|| { + let minimum_locked_amount: Balance = ::MinimumLockedAmount::get(); + + // lock amount should be above the threshold + let account = 2; + assert_lock(account, minimum_locked_amount + 1); + + // Create two unlocking chunks + assert_unlock(account, 1); + run_for_blocks(1); + assert_unlock(account, minimum_locked_amount); + + // This scenario can only be achieved if minimum staking amount increases on live network. + // Otherwise we always have a guarantee that the latest unlocking chunk at least covers the + // minimum staking amount. + // To test this, we will do a "dirty trick", and swap the two unlocking chunks that were just created. + // This shoudl ensure that the latest unlocking chunk is below the minimum staking amount. + Ledger::::mutate(&account, |ledger| { + ledger.unlocking = ledger + .unlocking + .clone() + .try_mutate(|inner| { + let temp_block = inner[0].unlock_block; + inner[0].unlock_block = inner[1].unlock_block; + inner[1].unlock_block = temp_block; + inner.swap(0, 1); + }) + .expect("No size manipulation, only element swap."); + }); + + // Make sure only one chunk is left + let unlocking_blocks = DappStaking::unlocking_period(); + run_for_blocks(unlocking_blocks - 1); + assert_claim_unlocked(account); + + assert_noop!( + DappStaking::relock_unlocking(RuntimeOrigin::signed(account)), + Error::::LockedAmountBelowThreshold, + ); + }) +} + +#[test] +fn stake_basic_example_is_ok() { + ExtBuilder::build().execute_with(|| { + // Register smart contract & lock some amount + let dev_account = 1; + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(dev_account, &smart_contract); + + let account = 2; + let lock_amount = 300; + assert_lock(account, lock_amount); + + // Stake some amount, and then some more in the same era. + let (stake_1, stake_2) = (31, 29); + assert_stake(account, &smart_contract, stake_1); + assert_stake(account, &smart_contract, stake_2); + }) +} + +#[test] +fn stake_after_expiry_is_ok() { + ExtBuilder::build().execute_with(|| { + // Register smart contract + let dev_account = 1; + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(dev_account, &smart_contract); + + // Lock & stake some amount + let account = 2; + let lock_amount = 300; + let (stake_amount_1, stake_amount_2) = (200, 100); + assert_lock(account, lock_amount); + assert_stake(account, &smart_contract, stake_amount_1); + + // Advance so far that the stake rewards expire. + let reward_retention_in_periods: PeriodNumber = + ::RewardRetentionInPeriods::get(); + advance_to_period( + ActiveProtocolState::::get().period_number() + reward_retention_in_periods + 1, + ); + + // Sanity check that the rewards have expired + assert_noop!( + DappStaking::claim_staker_rewards(RuntimeOrigin::signed(account)), + Error::::RewardExpired, + ); + + // Calling stake again should work, expired stake entries should be cleaned up + assert_stake(account, &smart_contract, stake_amount_2); + assert_stake(account, &smart_contract, stake_amount_1); + }) +} + +#[test] +fn stake_with_zero_amount_fails() { + ExtBuilder::build().execute_with(|| { + // Register smart contract & lock some amount + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(1, &smart_contract); + let account = 2; + assert_lock(account, 300); + + assert_noop!( + DappStaking::stake(RuntimeOrigin::signed(account), smart_contract, 0), + Error::::ZeroAmount, + ); + }) +} + +#[test] +fn stake_on_invalid_dapp_fails() { + ExtBuilder::build().execute_with(|| { + let account = 2; + assert_lock(account, 300); + + // Try to stake on non-existing contract + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_noop!( + DappStaking::stake(RuntimeOrigin::signed(account), smart_contract, 100), + Error::::NotOperatedDApp + ); + + // Try to stake on unregistered smart contract + assert_register(1, &smart_contract); + assert_unregister(&smart_contract); + assert_noop!( + DappStaking::stake(RuntimeOrigin::signed(account), smart_contract, 100), + Error::::NotOperatedDApp + ); + }) +} + +#[test] +fn stake_in_final_era_fails() { + ExtBuilder::build().execute_with(|| { + // Register smart contract & lock some amount + let smart_contract = MockSmartContract::wasm(1 as AccountId); + let account = 2; + assert_register(1, &smart_contract); + assert_lock(account, 300); + + // Force Build&Earn period + ActiveProtocolState::::mutate(|state| { + state.period_info.subperiod = Subperiod::BuildAndEarn; + state.period_info.next_subperiod_start_era = state.era + 1; + }); + + // Try to stake in the final era of the period, which should fail. + assert_noop!( + DappStaking::stake(RuntimeOrigin::signed(account), smart_contract, 100), + Error::::PeriodEndsInNextEra + ); + }) +} + +#[test] +fn stake_fails_if_unclaimed_staker_rewards_from_past_remain() { + ExtBuilder::build().execute_with(|| { + // Register smart contract & lock some amount + let smart_contract = MockSmartContract::wasm(1 as AccountId); + let account = 2; + assert_register(1, &smart_contract); + assert_lock(account, 300); + + // Stake some amount, then force a few eras + assert_stake(account, &smart_contract, 100); + advance_to_era(ActiveProtocolState::::get().era + 2); + + // Stake must fail due to unclaimed rewards + assert_noop!( + DappStaking::stake(RuntimeOrigin::signed(account), smart_contract, 100), + Error::::UnclaimedRewards + ); + + // Should also fail in the next period + advance_to_next_period(); + assert_noop!( + DappStaking::stake(RuntimeOrigin::signed(account), smart_contract, 100), + Error::::UnclaimedRewards + ); + }) +} + +#[test] +fn stake_fails_if_claimable_bonus_rewards_from_past_remain() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let smart_contract = MockSmartContract::wasm(1 as AccountId); + let account = 2; + assert_register(1, &smart_contract); + assert_lock(account, 300); + assert_stake(account, &smart_contract, 100); + + // 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 stake again on the same contract, expect an error due to unclaimed bonus rewards + advance_to_era(ActiveProtocolState::::get().era + 2); + assert_noop!( + DappStaking::stake(RuntimeOrigin::signed(account), smart_contract, 100), + Error::::UnclaimedRewards + ); + }) +} + +#[test] +fn stake_fails_if_not_enough_stakeable_funds_available() { + ExtBuilder::build().execute_with(|| { + // Register smart contracts & lock some amount + let smart_contract_1 = MockSmartContract::Wasm(1); + let smart_contract_2 = MockSmartContract::Wasm(2); + let account = 3; + assert_register(1, &smart_contract_1); + assert_register(2, &smart_contract_2); + let lock_amount = 100; + assert_lock(account, lock_amount); + + // Stake some amount on the first contract, and second contract + assert_stake(account, &smart_contract_1, 50); + assert_stake(account, &smart_contract_2, 40); + + // Try to stake more than is available, expect failure + assert_noop!( + DappStaking::stake(RuntimeOrigin::signed(account), smart_contract_1.clone(), 11), + Error::::UnavailableStakeFunds + ); + assert_noop!( + DappStaking::stake(RuntimeOrigin::signed(account), smart_contract_2.clone(), 11), + Error::::UnavailableStakeFunds + ); + + // Stake exactly up to available funds, expect a pass + assert_stake(account, &smart_contract_2, 10); + }) +} + +#[test] +fn stake_fails_due_to_too_small_staking_amount() { + ExtBuilder::build().execute_with(|| { + // Register smart contract & lock some amount + let smart_contract_1 = MockSmartContract::Wasm(1); + let smart_contract_2 = MockSmartContract::Wasm(2); + let account = 3; + assert_register(1, &smart_contract_1); + assert_register(2, &smart_contract_2); + assert_lock(account, 300); + + // Stake with too small amount, expect a failure + let min_stake_amount: Balance = ::MinimumStakeAmount::get(); + assert_noop!( + DappStaking::stake( + RuntimeOrigin::signed(account), + smart_contract_1.clone(), + min_stake_amount - 1 + ), + Error::::InsufficientStakeAmount + ); + + // Staking with minimum amount must work. Also, after a successful stake, we can stake with arbitrary small amount on the contract. + assert_stake(account, &smart_contract_1, min_stake_amount); + assert_stake(account, &smart_contract_1, 1); + + // Even though account is staking already, trying to stake with too small amount on a different + // smart contract should once again fail. + assert_noop!( + DappStaking::stake( + RuntimeOrigin::signed(account), + smart_contract_2.clone(), + min_stake_amount - 1 + ), + Error::::InsufficientStakeAmount + ); + }) +} + +#[test] +fn stake_fails_due_to_too_many_staked_contracts() { + ExtBuilder::build().execute_with(|| { + 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(); + + // Register smart contracts up to the max allowed number + for id in 1..=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_smart_contract = MockSmartContract::Wasm((max_number_of_contracts + 1).into()); + assert_register(2, &excess_smart_contract); + + // Max number of staked contract entries has been exceeded. + assert_noop!( + DappStaking::stake( + RuntimeOrigin::signed(account), + excess_smart_contract.clone(), + 10 + ), + Error::::TooManyStakedContracts + ); + + // Advance into next period, error should still happen + advance_to_next_period(); + for _ in 0..required_number_of_reward_claims(account) { + assert_claim_staker_rewards(account); + } + assert_noop!( + DappStaking::stake( + RuntimeOrigin::signed(account), + excess_smart_contract.clone(), + 10 + ), + Error::::TooManyStakedContracts + ); + }) +} + +#[test] +fn unstake_basic_example_is_ok() { + ExtBuilder::build().execute_with(|| { + // Register smart contract & lock some amount + let dev_account = 1; + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(dev_account, &smart_contract); + + let account = 2; + let lock_amount = 400; + assert_lock(account, lock_amount); + + // Prep step - stake some amount + let stake_amount_1 = 83; + assert_stake(account, &smart_contract, stake_amount_1); + + // Unstake some amount, in the current era. + let unstake_amount_1 = 3; + assert_unstake(account, &smart_contract, unstake_amount_1); + }) +} + +#[test] +fn unstake_with_leftover_amount_below_minimum_works() { + ExtBuilder::build().execute_with(|| { + // Register smart contract & lock some amount + let dev_account = 1; + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(dev_account, &smart_contract); + + let account = 2; + let amount = 300; + assert_lock(account, amount); + + let min_stake_amount: Balance = ::MinimumStakeAmount::get(); + assert_stake(account, &smart_contract, min_stake_amount); + + // Unstake some amount, bringing it below the minimum + assert_unstake(account, &smart_contract, 1); + }) +} + +#[test] +fn unstake_with_zero_amount_fails() { + ExtBuilder::build().execute_with(|| { + // Register smart contract & lock some amount + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(1, &smart_contract); + let account = 2; + assert_lock(account, 300); + assert_stake(account, &smart_contract, 100); + + assert_noop!( + DappStaking::unstake(RuntimeOrigin::signed(account), smart_contract, 0), + Error::::ZeroAmount, + ); + }) +} + +#[test] +fn unstake_on_invalid_dapp_fails() { + ExtBuilder::build().execute_with(|| { + let account = 2; + assert_lock(account, 300); + + // Try to unstake from non-existing contract + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_noop!( + DappStaking::unstake(RuntimeOrigin::signed(account), smart_contract, 100), + Error::::NotOperatedDApp + ); + + // Try to unstake from unregistered smart contract + assert_register(1, &smart_contract); + assert_stake(account, &smart_contract, 100); + assert_unregister(&smart_contract); + assert_noop!( + DappStaking::unstake(RuntimeOrigin::signed(account), smart_contract, 100), + Error::::NotOperatedDApp + ); + }) +} + +#[test] +fn unstake_with_exceeding_amount_fails() { + ExtBuilder::build().execute_with(|| { + // Register smart contracts & lock some amount + let smart_contract_1 = MockSmartContract::Wasm(1); + let smart_contract_2 = MockSmartContract::Wasm(2); + assert_register(1, &smart_contract_1); + assert_register(1, &smart_contract_2); + let account = 2; + assert_lock(account, 300); + + // 1st scenario - stake some amount on the first contract, and try to unstake more than was staked + let stake_amount_1 = 100; + assert_stake(account, &smart_contract_1, stake_amount_1); + assert_noop!( + DappStaking::unstake( + RuntimeOrigin::signed(account), + smart_contract_1, + stake_amount_1 + 1 + ), + Error::::UnstakeAmountTooLarge + ); + + // 2nd scenario - have some stake on two distinct contracts, but unstaking more than staked per contract still fails + let stake_amount_2 = 50; + assert_stake(account, &smart_contract_2, stake_amount_2); + assert_noop!( + DappStaking::unstake( + RuntimeOrigin::signed(account), + smart_contract_2, + stake_amount_2 + 1 + ), + Error::::UnstakeAmountTooLarge + ); + }) +} + +#[test] +fn unstake_from_non_staked_contract_fails() { + ExtBuilder::build().execute_with(|| { + // Register smart contracts & lock some amount + let smart_contract_1 = MockSmartContract::Wasm(1); + let smart_contract_2 = MockSmartContract::Wasm(2); + assert_register(1, &smart_contract_1); + assert_register(1, &smart_contract_2); + let account = 2; + assert_lock(account, 300); + + // Stake some amount on the first contract. + let stake_amount = 100; + assert_stake(account, &smart_contract_1, stake_amount); + + // Try to unstake from the 2nd contract, which isn't staked on. + assert_noop!( + DappStaking::unstake(RuntimeOrigin::signed(account), smart_contract_2, 1,), + Error::::NoStakingInfo + ); + }) +} + +#[test] +fn unstake_with_unclaimed_rewards_fails() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let smart_contract = MockSmartContract::Wasm(1); + assert_register(1, &smart_contract); + let account = 2; + assert_lock(account, 300); + let stake_amount = 100; + assert_stake(account, &smart_contract, stake_amount); + + // Advance 1 era, try to unstake and it should work since we're modifying the current era stake. + advance_to_next_era(); + assert_unstake(account, &smart_contract, 1); + + // Advance 1 more era, creating claimable rewards. Unstake should fail now. + advance_to_next_era(); + assert_noop!( + DappStaking::unstake(RuntimeOrigin::signed(account), smart_contract, 1), + Error::::UnclaimedRewards + ); + }) +} + +#[test] +fn unstake_from_past_period_fails() { + ExtBuilder::build().execute_with(|| { + // Register smart contract & lock some amount + let smart_contract = MockSmartContract::Wasm(1); + assert_register(1, &smart_contract); + let account = 2; + assert_lock(account, 300); + + // Stake some amount, and advance to the next period + let stake_amount = 100; + assert_stake(account, &smart_contract, stake_amount); + advance_to_next_period(); + + assert_noop!( + DappStaking::unstake(RuntimeOrigin::signed(account), smart_contract, stake_amount), + Error::::UnstakeFromPastPeriod + ); + }) +} + +#[test] +fn claim_staker_rewards_basic_example_is_ok() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let dev_account = 1; + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(dev_account, &smart_contract); + + let account = 2; + let lock_amount = 300; + assert_lock(account, lock_amount); + let stake_amount = 93; + assert_stake(account, &smart_contract, stake_amount); + + // Advance into Build&Earn period, and allow one era to pass. Claim reward for 1 era. + advance_to_era(ActiveProtocolState::::get().era + 2); + assert_claim_staker_rewards(account); + + // Advance a few more eras, and claim multiple rewards this time. + advance_to_era(ActiveProtocolState::::get().era + 3); + assert_eq!( + ActiveProtocolState::::get().period_number(), + 1, + "Sanity check, we must still be in the 1st period." + ); + assert_claim_staker_rewards(account); + + // Advance into the next period, make sure we can still claim old rewards. + advance_to_next_period(); + for _ in 0..required_number_of_reward_claims(account) { + assert_claim_staker_rewards(account); + } + }) +} + +#[test] +fn claim_staker_rewards_double_call_fails() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let dev_account = 1; + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(dev_account, &smart_contract); + + let account = 2; + let lock_amount = 300; + assert_lock(account, lock_amount); + let stake_amount = 93; + assert_stake(account, &smart_contract, stake_amount); + + // Advance into the next period, claim all eligible rewards + advance_to_next_period(); + for _ in 0..required_number_of_reward_claims(account) { + assert_claim_staker_rewards(account); + } + + assert_noop!( + DappStaking::claim_staker_rewards(RuntimeOrigin::signed(account)), + Error::::NoClaimableRewards, + ); + }) +} + +#[test] +fn claim_staker_rewards_no_claimable_rewards_fails() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let dev_account = 1; + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(dev_account, &smart_contract); + + let account = 2; + let lock_amount = 300; + assert_lock(account, lock_amount); + + // 1st scenario - try to claim with no stake at all. + assert_noop!( + DappStaking::claim_staker_rewards(RuntimeOrigin::signed(account)), + Error::::NoClaimableRewards, + ); + + // 2nd scenario - stake some amount, and try to claim in the same era. + // It's important this is the 1st era, when no `EraRewards` entry exists. + assert_eq!(ActiveProtocolState::::get().era, 1, "Sanity check"); + assert!(EraRewards::::iter().next().is_none(), "Sanity check"); + let stake_amount = 93; + assert_stake(account, &smart_contract, stake_amount); + assert_noop!( + DappStaking::claim_staker_rewards(RuntimeOrigin::signed(account)), + Error::::NoClaimableRewards, + ); + + // 3rd scenario - move over to the next era, but we still expect failure because + // stake is valid from era 2 (current era), and we're trying to claim rewards for era 1. + advance_to_next_era(); + assert!(EraRewards::::iter().next().is_some(), "Sanity check"); + assert_noop!( + DappStaking::claim_staker_rewards(RuntimeOrigin::signed(account)), + Error::::NoClaimableRewards, + ); + }) +} + +#[test] +fn claim_staker_rewards_after_expiry_fails() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let dev_account = 1; + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(dev_account, &smart_contract); + + let account = 2; + let lock_amount = 300; + assert_lock(account, lock_amount); + let stake_amount = 93; + assert_stake(account, &smart_contract, stake_amount); + + let reward_retention_in_periods: PeriodNumber = + ::RewardRetentionInPeriods::get(); + + // Advance to the block just before the 'expiry' period starts + advance_to_period( + ActiveProtocolState::::get().period_number() + reward_retention_in_periods, + ); + advance_to_next_subperiod(); + advance_to_era( + ActiveProtocolState::::get() + .period_info + .next_subperiod_start_era + - 1, + ); + assert_claim_staker_rewards(account); + + // Ensure we're still in the first period for the sake of test validity + assert_eq!( + Ledger::::get(&account).staked.period, + 1, + "Sanity check." + ); + + // Trigger next period, rewards should be marked as expired + advance_to_next_era(); + assert_eq!( + ActiveProtocolState::::get().period_number(), + reward_retention_in_periods + 2 + ); + assert_noop!( + DappStaking::claim_staker_rewards(RuntimeOrigin::signed(account)), + Error::::RewardExpired, + ); + }) +} + +#[test] +fn claim_staker_rewards_fails_due_to_payout_failure() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(1, &smart_contract); + + let account = 2; + let amount = 300; + assert_lock(account, amount); + assert_stake(account, &smart_contract, amount); + + // Advance into Build&Earn period, and allow one era to pass. + advance_to_era(ActiveProtocolState::::get().era + 2); + + // Disable successfull reward payout + DOES_PAYOUT_SUCCEED.with(|v| *v.borrow_mut() = false); + assert_noop!( + DappStaking::claim_staker_rewards(RuntimeOrigin::signed(account)), + Error::::RewardPayoutFailed, + ); + + // Re-enable it again, claim should work again + DOES_PAYOUT_SUCCEED.with(|v| *v.borrow_mut() = true); + assert_claim_staker_rewards(account); + }) +} + +#[test] +fn claim_bonus_reward_works() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let dev_account = 1; + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(dev_account, &smart_contract); + + let account = 2; + let lock_amount = 300; + assert_lock(account, lock_amount); + let stake_amount = 93; + assert_stake(account, &smart_contract, stake_amount); + + // 1st scenario - advance to the next period, first claim bonus reward, then staker rewards + advance_to_next_period(); + assert_claim_bonus_reward(account, &smart_contract); + for _ in 0..required_number_of_reward_claims(account) { + assert_claim_staker_rewards(account); + } + + // 2nd scenario - stake again, advance to next period, this time first claim staker rewards, then bonus reward. + assert_stake(account, &smart_contract, stake_amount); + advance_to_next_period(); + for _ in 0..required_number_of_reward_claims(account) { + assert_claim_staker_rewards(account); + } + assert!( + Ledger::::get(&account).staked.is_empty(), + "Sanity check." + ); + assert_claim_bonus_reward(account, &smart_contract); + }) +} + +#[test] +fn claim_bonus_reward_double_call_fails() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let dev_account = 1; + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(dev_account, &smart_contract); + + let account = 2; + let lock_amount = 300; + assert_lock(account, lock_amount); + let stake_amount = 93; + assert_stake(account, &smart_contract, stake_amount); + + // Advance to the next period, claim bonus reward, then try to do it again + advance_to_next_period(); + assert_claim_bonus_reward(account, &smart_contract); + + assert_noop!( + DappStaking::claim_bonus_reward(RuntimeOrigin::signed(account), smart_contract), + Error::::NoClaimableRewards, + ); + }) +} + +#[test] +fn claim_bonus_reward_when_nothing_to_claim_fails() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let dev_account = 1; + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(dev_account, &smart_contract); + + let account = 2; + let lock_amount = 300; + assert_lock(account, lock_amount); + + // 1st - try to claim bonus reward when no stake is present + assert_noop!( + DappStaking::claim_bonus_reward(RuntimeOrigin::signed(account), smart_contract), + Error::::NoClaimableRewards, + ); + + // 2nd - try to claim bonus reward for the ongoing period + let stake_amount = 93; + assert_stake(account, &smart_contract, stake_amount); + assert_noop!( + DappStaking::claim_bonus_reward(RuntimeOrigin::signed(account), smart_contract), + Error::::NoClaimableRewards, + ); + }) +} + +#[test] +fn claim_bonus_reward_with_only_build_and_earn_stake_fails() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let dev_account = 1; + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(dev_account, &smart_contract); + + let account = 2; + let lock_amount = 300; + assert_lock(account, lock_amount); + + // Stake in Build&Earn period type, advance to next era and try to claim bonus reward + advance_to_next_subperiod(); + assert_eq!( + ActiveProtocolState::::get().subperiod(), + Subperiod::BuildAndEarn, + "Sanity check." + ); + let stake_amount = 93; + assert_stake(account, &smart_contract, stake_amount); + + advance_to_next_period(); + assert_noop!( + DappStaking::claim_bonus_reward(RuntimeOrigin::signed(account), smart_contract), + Error::::NotEligibleForBonusReward, + ); + }) +} + +#[test] +fn claim_bonus_reward_after_expiry_fails() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let dev_account = 1; + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(dev_account, &smart_contract); + + let account = 2; + let lock_amount = 300; + assert_lock(account, lock_amount); + assert_stake(account, &smart_contract, lock_amount); + + // 1st scenario - Advance to one period before the expiry, claim should still work. + let reward_retention_in_periods: PeriodNumber = + ::RewardRetentionInPeriods::get(); + advance_to_period( + ActiveProtocolState::::get().period_number() + reward_retention_in_periods, + ); + assert_claim_bonus_reward(account, &smart_contract); + for _ in 0..required_number_of_reward_claims(account) { + assert_claim_staker_rewards(account); + } + + // 2nd scenario - advance past the expiry, call must fail + assert_stake(account, &smart_contract, lock_amount); + advance_to_period( + ActiveProtocolState::::get().period_number() + reward_retention_in_periods + 1, + ); + assert_noop!( + DappStaking::claim_bonus_reward(RuntimeOrigin::signed(account), smart_contract), + Error::::RewardExpired, + ); + }) +} + +#[test] +fn claim_bonus_reward_fails_due_to_payout_failure() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(1, &smart_contract); + + let account = 2; + let amount = 300; + assert_lock(account, amount); + assert_stake(account, &smart_contract, amount); + + // Advance to next period so we can claim bonus reward + advance_to_next_period(); + + // Disable successfull reward payout + DOES_PAYOUT_SUCCEED.with(|v| *v.borrow_mut() = false); + assert_noop!( + DappStaking::claim_bonus_reward(RuntimeOrigin::signed(account), smart_contract), + Error::::RewardPayoutFailed, + ); + + // Re-enable it again, claim should work again + DOES_PAYOUT_SUCCEED.with(|v| *v.borrow_mut() = true); + assert_claim_bonus_reward(account, &smart_contract); + }) +} + +#[test] +fn claim_dapp_reward_works() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let dev_account = 1; + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(dev_account, &smart_contract); + + let account = 2; + let amount = 300; + assert_lock(account, amount); + assert_stake(account, &smart_contract, amount); + + // Advance 2 eras so we have an entry for reward claiming + advance_to_era(ActiveProtocolState::::get().era + 2); + assert_eq!(ActiveProtocolState::::get().era, 3, "Sanity check"); + + assert_claim_dapp_reward( + account, + &smart_contract, + ActiveProtocolState::::get().era - 1, + ); + + // Advance to next era, and ensure rewards can be paid out to a custom beneficiary + let new_beneficiary = 17; + assert_set_dapp_reward_beneficiary(dev_account, &smart_contract, Some(new_beneficiary)); + advance_to_next_era(); + assert_claim_dapp_reward( + account, + &smart_contract, + ActiveProtocolState::::get().era - 1, + ); + }) +} + +#[test] +fn claim_dapp_reward_from_non_existing_contract_fails() { + ExtBuilder::build().execute_with(|| { + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_noop!( + DappStaking::claim_dapp_reward(RuntimeOrigin::signed(1), smart_contract, 1), + Error::::ContractNotFound, + ); + }) +} + +#[test] +fn claim_dapp_reward_from_invalid_era_fails() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(1, &smart_contract); + + let account = 2; + let amount = 300; + assert_lock(account, amount); + assert_stake(account, &smart_contract, amount); + + // Advance 2 eras and try to claim from the ongoing era. + advance_to_era(ActiveProtocolState::::get().era + 2); + assert_noop!( + DappStaking::claim_dapp_reward( + RuntimeOrigin::signed(1), + smart_contract, + ActiveProtocolState::::get().era + ), + Error::::InvalidClaimEra, + ); + + // Try to claim from the era which corresponds to the voting period. No tier info should + assert_noop!( + DappStaking::claim_dapp_reward(RuntimeOrigin::signed(1), smart_contract, 1), + Error::::NoDAppTierInfo, + ); + }) +} + +#[test] +fn claim_dapp_reward_if_dapp_not_in_any_tier_fails() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let smart_contract_1 = MockSmartContract::Wasm(3); + let smart_contract_2 = MockSmartContract::Wasm(5); + assert_register(1, &smart_contract_1); + assert_register(1, &smart_contract_2); + + let account = 2; + let amount = 300; + assert_lock(account, amount); + assert_stake(account, &smart_contract_1, amount); + + // Advance 2 eras and try to claim reward for non-staked dApp. + advance_to_era(ActiveProtocolState::::get().era + 2); + let account = 2; + let claim_era = ActiveProtocolState::::get().era - 1; + assert_noop!( + DappStaking::claim_dapp_reward( + RuntimeOrigin::signed(account), + smart_contract_2, + claim_era + ), + Error::::NoClaimableRewards, + ); + // Staked dApp should still be able to claim. + assert_claim_dapp_reward(account, &smart_contract_1, claim_era); + }) +} + +#[test] +fn claim_dapp_reward_twice_for_same_era_fails() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(1, &smart_contract); + + let account = 2; + let amount = 300; + assert_lock(account, amount); + assert_stake(account, &smart_contract, amount); + + // Advance 3 eras and claim rewards. + advance_to_era(ActiveProtocolState::::get().era + 3); + + // We can only claim reward ONCE for a particular era + let claim_era_1 = ActiveProtocolState::::get().era - 2; + assert_claim_dapp_reward(account, &smart_contract, claim_era_1); + assert_noop!( + DappStaking::claim_dapp_reward( + RuntimeOrigin::signed(account), + smart_contract, + claim_era_1 + ), + Error::::DAppRewardAlreadyClaimed, + ); + + // We can still claim for another valid era + let claim_era_2 = claim_era_1 + 1; + assert_claim_dapp_reward(account, &smart_contract, claim_era_2); + }) +} + +#[test] +fn claim_dapp_reward_for_expired_era_fails() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(1, &smart_contract); + + let account = 2; + let amount = 300; + assert_lock(account, amount); + assert_stake(account, &smart_contract, amount); + + let reward_retention_in_periods: PeriodNumber = + ::RewardRetentionInPeriods::get(); + + // Advance to period before the rewards expire. Claim reward must still work. + advance_to_period( + ActiveProtocolState::::get().period_number() + reward_retention_in_periods, + ); + assert_claim_dapp_reward(account, &smart_contract, 2); + + // Advance to the next era, expiring some rewards. + advance_to_next_period(); + assert_noop!( + DappStaking::claim_dapp_reward(RuntimeOrigin::signed(account), smart_contract, 3), + Error::::RewardExpired, + ); + }) +} + +#[test] +fn claim_dapp_reward_fails_due_to_payout_failure() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(1, &smart_contract); + + let account = 2; + let amount = 300; + assert_lock(account, amount); + assert_stake(account, &smart_contract, amount); + + // Advance 2 eras so we have an entry for reward claiming + advance_to_era(ActiveProtocolState::::get().era + 2); + + // Disable successfull reward payout + DOES_PAYOUT_SUCCEED.with(|v| *v.borrow_mut() = false); + assert_noop!( + DappStaking::claim_dapp_reward( + RuntimeOrigin::signed(account), + smart_contract, + ActiveProtocolState::::get().era - 1 + ), + Error::::RewardPayoutFailed, + ); + + // Re-enable it again, claim should work again + DOES_PAYOUT_SUCCEED.with(|v| *v.borrow_mut() = true); + assert_claim_dapp_reward( + account, + &smart_contract, + ActiveProtocolState::::get().era - 1, + ); + }) +} + +#[test] +fn unstake_from_unregistered_is_ok() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(1, &smart_contract); + + let account = 2; + let amount = 300; + assert_lock(account, amount); + assert_stake(account, &smart_contract, amount); + + // Unregister the smart contract, and unstake from it. + assert_unregister(&smart_contract); + assert_unstake_from_unregistered(account, &smart_contract); + }) +} + +#[test] +fn unstake_from_unregistered_fails_for_active_contract() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(1, &smart_contract); + + let account = 2; + let amount = 300; + assert_lock(account, amount); + assert_stake(account, &smart_contract, amount); + + assert_noop!( + DappStaking::unstake_from_unregistered(RuntimeOrigin::signed(account), smart_contract), + Error::::ContractStillActive + ); + }) +} + +#[test] +fn unstake_from_unregistered_fails_for_not_staked_contract() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(1, &smart_contract); + assert_unregister(&smart_contract); + + assert_noop!( + DappStaking::unstake_from_unregistered(RuntimeOrigin::signed(2), smart_contract), + Error::::NoStakingInfo + ); + }) +} + +#[test] +fn unstake_from_unregistered_fails_for_past_period() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(1, &smart_contract); + + let account = 2; + let amount = 300; + assert_lock(account, amount); + assert_stake(account, &smart_contract, amount); + + // Unregister smart contract & advance to next period + assert_unregister(&smart_contract); + advance_to_next_period(); + + assert_noop!( + DappStaking::unstake_from_unregistered(RuntimeOrigin::signed(account), smart_contract), + Error::::UnstakeFromPastPeriod + ); + }) +} + +#[test] +fn cleanup_expired_entries_is_ok() { + ExtBuilder::build().execute_with(|| { + // Register smart contracts + let contracts: Vec<_> = (1..=5).map(|id| MockSmartContract::Wasm(id)).collect(); + contracts.iter().for_each(|smart_contract| { + assert_register(1, smart_contract); + }); + let account = 2; + assert_lock(account, 1000); + + // Scenario: + // - 1st contract will be staked in the period that expires due to exceeded reward retention + // - 2nd contract will be staked in the period on the edge of expiry, with loyalty flag + // - 3rd contract will be be staked in the period on the edge of expiry, without loyalty flag + // - 4th contract will be staked in the period right before the current one, with loyalty flag + // - 5th contract will be staked in the period right before the current one, without loyalty flag + // + // Expectation: 1, 3, 5 should be removed, 2 & 4 should remain + + // 1st + assert_stake(account, &contracts[0], 13); + + // 2nd & 3rd + advance_to_next_period(); + for _ in 0..required_number_of_reward_claims(account) { + assert_claim_staker_rewards(account); + } + assert_stake(account, &contracts[1], 17); + advance_to_next_subperiod(); + + assert_stake(account, &contracts[2], 19); + + // 4th & 5th + let reward_retention_in_periods: PeriodNumber = + ::RewardRetentionInPeriods::get(); + assert!( + reward_retention_in_periods >= 2, + "Sanity check, otherwise the test doesn't make sense." + ); + advance_to_period(reward_retention_in_periods + 1); + for _ in 0..required_number_of_reward_claims(account) { + assert_claim_staker_rewards(account); + } + assert_stake(account, &contracts[3], 23); + advance_to_next_subperiod(); + assert_stake(account, &contracts[4], 29); + + // Finally do the test + advance_to_next_period(); + assert_cleanup_expired_entries(account); + + // Additional sanity check according to the described scenario + assert!(!StakerInfo::::contains_key(account, &contracts[0])); + assert!(!StakerInfo::::contains_key(account, &contracts[2])); + assert!(!StakerInfo::::contains_key(account, &contracts[4])); + + assert!(StakerInfo::::contains_key(account, &contracts[1])); + assert!(StakerInfo::::contains_key(account, &contracts[3])); + }) +} + +#[test] +fn cleanup_expired_entries_fails_with_no_entries() { + ExtBuilder::build().execute_with(|| { + // Register smart contracts + let (contract_1, contract_2) = (MockSmartContract::Wasm(1), MockSmartContract::Wasm(2)); + assert_register(1, &contract_1); + assert_register(1, &contract_2); + + let account = 2; + assert_lock(account, 1000); + assert_stake(account, &contract_1, 13); + assert_stake(account, &contract_2, 17); + + // Advance only one period, rewards should still be valid. + let reward_retention_in_periods: PeriodNumber = + ::RewardRetentionInPeriods::get(); + assert!( + reward_retention_in_periods >= 1, + "Sanity check, otherwise the test doesn't make sense." + ); + advance_to_next_period(); + + assert_noop!( + DappStaking::cleanup_expired_entries(RuntimeOrigin::signed(account)), + Error::::NoExpiredEntries + ); + }) +} + +#[test] +fn force_era_works() { + ExtBuilder::build().execute_with(|| { + // 1. Force new era in the voting subperiod + let init_state = ActiveProtocolState::::get(); + assert!( + init_state.next_era_start > System::block_number() + 1, + "Sanity check, new era cannot start in next block, otherwise the test doesn't guarantee it tests what's expected." + ); + assert_eq!( + init_state.subperiod(), + Subperiod::Voting, + "Sanity check." + ); + assert_ok!(DappStaking::force(RuntimeOrigin::root(), ForcingType::Era)); + System::assert_last_event(RuntimeEvent::DappStaking(Event::Force { + forcing_type: ForcingType::Era, + })); + + // Verify state change + assert_eq!( + ActiveProtocolState::::get().next_era_start, + System::block_number() + 1, + ); + assert_eq!( + ActiveProtocolState::::get().next_subperiod_start_era(), + init_state.next_subperiod_start_era(), + ); + + // Go to the next block, and ensure new era is started + run_for_blocks(1); + assert_eq!( + ActiveProtocolState::::get().era, + init_state.era + 1, + "New era must be started." + ); + assert_eq!( + ActiveProtocolState::::get().subperiod(), + Subperiod::BuildAndEarn, + ); + + // 2. Force new era in the build&earn subperiod + let init_state = ActiveProtocolState::::get(); + assert!( + init_state.next_era_start > System::block_number() + 1, + "Sanity check, new era cannot start in next block, otherwise the test doesn't guarantee it tests what's expected." + ); + assert!(init_state.next_subperiod_start_era() > init_state.era + 1, "Sanity check, otherwise the test doesn't guarantee it tests what's expected."); + assert_ok!(DappStaking::force(RuntimeOrigin::root(), ForcingType::Era)); + System::assert_last_event(RuntimeEvent::DappStaking(Event::Force { + forcing_type: ForcingType::Era, + })); + + // Verify state change + assert_eq!( + ActiveProtocolState::::get().next_era_start, + System::block_number() + 1, + ); + assert_eq!( + ActiveProtocolState::::get().next_subperiod_start_era(), + init_state.next_subperiod_start_era(), + "Only era is bumped, but we don't expect to switch over to the next subperiod." + ); + + run_for_blocks(1); + assert_eq!( + ActiveProtocolState::::get().era, + init_state.era + 1, + "New era must be started." + ); + assert_eq!( + ActiveProtocolState::::get().subperiod(), + Subperiod::BuildAndEarn, + "We're expected to remain in the same subperiod." + ); + }) +} + +#[test] +fn force_subperiod_works() { + ExtBuilder::build().execute_with(|| { + // 1. Force new subperiod in the voting subperiod + let init_state = ActiveProtocolState::::get(); + assert!( + init_state.next_era_start > System::block_number() + 1, + "Sanity check, new era cannot start in next block, otherwise the test doesn't guarantee it tests what's expected." + ); + assert_eq!( + init_state.subperiod(), + Subperiod::Voting, + "Sanity check." + ); + assert_ok!(DappStaking::force(RuntimeOrigin::root(), ForcingType::Subperiod)); + System::assert_last_event(RuntimeEvent::DappStaking(Event::Force { + forcing_type: ForcingType::Subperiod, + })); + + // Verify state change + assert_eq!( + ActiveProtocolState::::get().next_era_start, + System::block_number() + 1, + ); + assert_eq!( + ActiveProtocolState::::get().next_subperiod_start_era(), + init_state.era + 1, + "The switch to the next subperiod must happen in the next era." + ); + + // Go to the next block, and ensure new era is started + run_for_blocks(1); + assert_eq!( + ActiveProtocolState::::get().era, + init_state.era + 1, + "New era must be started." + ); + assert_eq!( + ActiveProtocolState::::get().subperiod(), + Subperiod::BuildAndEarn, + "New subperiod must be started." + ); + assert_eq!(ActiveProtocolState::::get().period_number(), init_state.period_number(), "Period must remain the same."); + + // 2. Force new era in the build&earn subperiod + let init_state = ActiveProtocolState::::get(); + assert!( + init_state.next_era_start > System::block_number() + 1, + "Sanity check, new era cannot start in next block, otherwise the test doesn't guarantee it tests what's expected." + ); + assert!(init_state.next_subperiod_start_era() > init_state.era + 1, "Sanity check, otherwise the test doesn't guarantee it tests what's expected."); + assert_ok!(DappStaking::force(RuntimeOrigin::root(), ForcingType::Subperiod)); + System::assert_last_event(RuntimeEvent::DappStaking(Event::Force { + forcing_type: ForcingType::Subperiod, + })); + + // Verify state change + assert_eq!( + ActiveProtocolState::::get().next_era_start, + System::block_number() + 1, + ); + assert_eq!( + ActiveProtocolState::::get().next_subperiod_start_era(), + init_state.era + 1, + "The switch to the next subperiod must happen in the next era." + ); + + run_for_blocks(1); + assert_eq!( + ActiveProtocolState::::get().era, + init_state.era + 1, + "New era must be started." + ); + assert_eq!( + ActiveProtocolState::::get().subperiod(), + Subperiod::Voting, + "New subperiod must be started." + ); + assert_eq!(ActiveProtocolState::::get().period_number(), init_state.period_number() + 1, "New period must be started."); + }) +} + +#[test] +fn force_with_incorrect_origin_fails() { + ExtBuilder::build().execute_with(|| { + assert_noop!( + DappStaking::force(RuntimeOrigin::signed(1), ForcingType::Era), + BadOrigin + ); + }) +} + +#[test] +fn get_dapp_tier_assignment_basic_example_works() { + ExtBuilder::build().execute_with(|| { + // This test will rely on the configuration inside the mock file. + // If that changes, this test will have to be updated as well. + + // Scenario: + // - 1st tier is filled up, with one dApp satisfying the threshold but not making it due to lack of tier capacity + // - 2nd tier has 2 dApps - 1 that could make it into the 1st tier and one that's supposed to be in the 2nd tier + // - 3rd tier has no dApps + // - 4th tier has 2 dApps + // - 1 dApp doesn't make it into any tier + + // Register smart contracts + let tier_config = TierConfig::::get(); + let number_of_smart_contracts = tier_config.slots_per_tier[0] + 1 + 1 + 0 + 2 + 1; + let smart_contracts: Vec<_> = (1..=number_of_smart_contracts) + .map(|x| { + let smart_contract = MockSmartContract::Wasm(x.into()); + assert_register(x.into(), &smart_contract); + smart_contract + }) + .collect(); + let mut dapp_index: usize = 0; + + fn lock_and_stake(account: usize, smart_contract: &MockSmartContract, amount: Balance) { + let account = account.try_into().unwrap(); + Balances::make_free_balance_be(&account, amount); + assert_lock(account, amount); + assert_stake(account, smart_contract, amount); + } + + // 1st tier is completely filled up, with 1 more dApp not making it inside + for x in 0..tier_config.slots_per_tier[0] as Balance { + lock_and_stake( + dapp_index, + &smart_contracts[dapp_index], + tier_config.tier_thresholds[0].threshold() + x + 1, + ); + dapp_index += 1; + } + // One that won't make it into the 1st tier. + lock_and_stake( + dapp_index, + &smart_contracts[dapp_index], + tier_config.tier_thresholds[0].threshold(), + ); + dapp_index += 1; + + // 2nd tier - 1 dedicated dApp + lock_and_stake( + dapp_index, + &smart_contracts[dapp_index], + tier_config.tier_thresholds[0].threshold() - 1, + ); + dapp_index += 1; + + // 3rd tier is empty + // 4th tier has 2 dApps + for x in 0..2 { + lock_and_stake( + dapp_index, + &smart_contracts[dapp_index], + tier_config.tier_thresholds[3].threshold() + x, + ); + dapp_index += 1; + } + + // One dApp doesn't make it into any tier + lock_and_stake( + dapp_index, + &smart_contracts[dapp_index], + tier_config.tier_thresholds[3].threshold() - 1, + ); + + // Finally, the actual test + let protocol_state = ActiveProtocolState::::get(); + let dapp_reward_pool = 1000000; + let (tier_assignment, counter) = DappStaking::get_dapp_tier_assignment( + protocol_state.era + 1, + protocol_state.period_number(), + dapp_reward_pool, + ); + + // Basic checks + let number_of_tiers: u32 = ::NumberOfTiers::get(); + assert_eq!(tier_assignment.period, protocol_state.period_number()); + assert_eq!(tier_assignment.rewards.len(), number_of_tiers as usize); + assert_eq!( + tier_assignment.dapps.len(), + number_of_smart_contracts as usize - 1, + "One contract doesn't make it into any tier." + ); + assert_eq!(counter, number_of_smart_contracts); + + // 1st tier checks + let (entry_1, entry_2) = (tier_assignment.dapps[0], tier_assignment.dapps[1]); + assert_eq!(entry_1.dapp_id, 0); + assert_eq!(entry_1.tier_id, Some(0)); + + assert_eq!(entry_2.dapp_id, 1); + assert_eq!(entry_2.tier_id, Some(0)); + + // 2nd tier checks + let (entry_3, entry_4) = (tier_assignment.dapps[2], tier_assignment.dapps[3]); + assert_eq!(entry_3.dapp_id, 2); + assert_eq!(entry_3.tier_id, Some(1)); + + assert_eq!(entry_4.dapp_id, 3); + assert_eq!(entry_4.tier_id, Some(1)); + + // 4th tier checks + let (entry_5, entry_6) = (tier_assignment.dapps[4], tier_assignment.dapps[5]); + assert_eq!(entry_5.dapp_id, 4); + assert_eq!(entry_5.tier_id, Some(3)); + + assert_eq!(entry_6.dapp_id, 5); + assert_eq!(entry_6.tier_id, Some(3)); + + // Sanity check - last dapp should not exists in the tier assignment + assert!(tier_assignment + .dapps + .binary_search_by(|x| x.dapp_id.cmp(&(dapp_index.try_into().unwrap()))) + .is_err()); + + // Check that rewards are calculated correctly + tier_config + .reward_portion + .iter() + .zip(tier_config.slots_per_tier.iter()) + .enumerate() + .for_each(|(idx, (reward_portion, slots))| { + let total_tier_allocation = *reward_portion * dapp_reward_pool; + let tier_reward: Balance = total_tier_allocation / (*slots as Balance); + + assert_eq!(tier_assignment.rewards[idx], tier_reward,); + }); + }) +} + +#[test] +fn get_dapp_tier_assignment_zero_slots_per_tier_works() { + ExtBuilder::build().execute_with(|| { + // This test will rely on the configuration inside the mock file. + // If that changes, this test might have to be updated as well. + + // Ensure that first tier has 0 slots. + TierConfig::::mutate(|config| { + let slots_in_first_tier = config.slots_per_tier[0]; + config.number_of_slots = config.number_of_slots - slots_in_first_tier; + config.slots_per_tier[0] = 0; + }); + + // Calculate tier assignment (we don't need dApps for this test) + let protocol_state = ActiveProtocolState::::get(); + let dapp_reward_pool = 1000000; + let (tier_assignment, counter) = DappStaking::get_dapp_tier_assignment( + protocol_state.era, + protocol_state.period_number(), + dapp_reward_pool, + ); + + // Basic checks + let number_of_tiers: u32 = ::NumberOfTiers::get(); + assert_eq!(tier_assignment.period, protocol_state.period_number()); + assert_eq!(tier_assignment.rewards.len(), number_of_tiers as usize); + assert!(tier_assignment.dapps.is_empty()); + assert!(counter.is_zero()); + + assert!( + tier_assignment.rewards[0].is_zero(), + "1st tier has no slots so no rewards should be assigned to it." + ); + + // Regardless of that, other tiers shouldn't benefit from this + assert!(tier_assignment.rewards.iter().sum::() < dapp_reward_pool); + }) +} + +#[test] +fn advance_for_some_periods_works() { + ExtBuilder::build().execute_with(|| { + advance_to_period(10); + }) +} + +//////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////// +/////// More complex & composite scenarios, maybe move them into a separate file + +#[test] +fn unlock_after_staked_period_ends_is_ok() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(1, &smart_contract); + + let account = 2; + let amount = 101; + assert_lock(account, amount); + assert_stake(account, &smart_contract, amount); + + // Advance to the next period, and ensure stake is reset and can be fully unlocked + advance_to_next_period(); + assert!(Ledger::::get(&account) + .staked_amount(ActiveProtocolState::::get().period_number()) + .is_zero()); + assert_unlock(account, amount); + assert_eq!(Ledger::::get(&account).unlocking_amount(), amount); + }) +} + +#[test] +fn unstake_from_a_contract_staked_in_past_period_fails() { + ExtBuilder::build().execute_with(|| { + // Register smart contract & lock some amount + let smart_contract_1 = MockSmartContract::Wasm(1); + let smart_contract_2 = MockSmartContract::Wasm(2); + assert_register(1, &smart_contract_1); + assert_register(1, &smart_contract_2); + let account = 2; + assert_lock(account, 300); + + // Stake some amount on the 2nd contract. + let stake_amount = 100; + assert_stake(account, &smart_contract_2, stake_amount); + + // Advance to the next period, and stake on the 1st contract. + advance_to_next_period(); + for _ in 0..required_number_of_reward_claims(account) { + assert_claim_staker_rewards(account); + } + + // Try to unstake from the 2nd contract, which is no longer staked on due to period change. + assert_noop!( + DappStaking::unstake(RuntimeOrigin::signed(account), smart_contract_2, 1,), + Error::::UnstakeFromPastPeriod + ); + + // Staking on the 1st contract should succeed since we haven't staked on it before so there are no bonus rewards to claim + assert_stake(account, &smart_contract_1, stake_amount); + + // Even with active stake on the 1st contract, unstake from 2nd should still fail since period change reset its stake. + assert_noop!( + DappStaking::unstake(RuntimeOrigin::signed(account), smart_contract_2, 1,), + Error::::UnstakeFromPastPeriod + ); + }) +} + +#[test] +fn stake_and_unstake_after_reward_claim_is_ok() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let dev_account = 1; + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(dev_account, &smart_contract); + + let account = 2; + let amount = 400; + assert_lock(account, amount); + assert_stake(account, &smart_contract, amount - 100); + + // Advance 2 eras so we have claimable rewards. Both stake & unstake should fail. + advance_to_era(ActiveProtocolState::::get().era + 2); + assert_noop!( + DappStaking::stake(RuntimeOrigin::signed(account), smart_contract, 1), + Error::::UnclaimedRewards + ); + assert_noop!( + DappStaking::unstake(RuntimeOrigin::signed(account), smart_contract, 1), + Error::::UnclaimedRewards + ); + + // Claim rewards, unstake should work now. + for _ in 0..required_number_of_reward_claims(account) { + assert_claim_staker_rewards(account); + } + assert_stake(account, &smart_contract, 1); + assert_unstake(account, &smart_contract, 1); + }) +} + +#[test] +fn stake_after_period_ends_with_max_staked_contracts() { + ExtBuilder::build().execute_with(|| { + 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); + + // Register smart contracts up to the max allowed number + for id in 1..=max_number_of_contracts { + let smart_contract = MockSmartContract::Wasm(id.into()); + assert_register(2, &smart_contract); + assert_stake(account, &smart_contract, 10); + } + + // Advance to the next period, and claim ALL rewards + advance_to_next_period(); + for _ in 0..required_number_of_reward_claims(account) { + assert_claim_staker_rewards(account); + } + for id in 1..=max_number_of_contracts { + let smart_contract = MockSmartContract::Wasm(id.into()); + assert_claim_bonus_reward(account, &smart_contract); + } + + // Make sure it's possible to stake again + for id in 1..=max_number_of_contracts { + let smart_contract = MockSmartContract::Wasm(id.into()); + assert_stake(account, &smart_contract, 10); + } + }) +} + +#[test] +fn post_unlock_balance_cannot_be_transfered() { + ExtBuilder::build().execute_with(|| { + let staker = 2; + + // Lock some of the free balance + let init_free_balance = Balances::free_balance(&staker); + let lock_amount = init_free_balance / 3; + assert_lock(staker, lock_amount); + + // Make sure second account is empty + let other_account = 42; + assert_ok!(Balances::write_balance(&other_account, 0)); + + // 1. Ensure we can only transfer what is not locked/frozen. + assert_ok!(Balances::transfer_all( + RuntimeOrigin::signed(staker), + other_account, + true + )); + assert_eq!( + Balances::free_balance(&other_account), + init_free_balance - lock_amount, + "Only what is locked can be transferred." + ); + + // 2. Start the 'unlocking process' for the locked amount, but ensure it still cannot be transferred. + assert_unlock(staker, lock_amount); + + assert_ok!(Balances::write_balance(&other_account, 0)); + assert_ok!(Balances::transfer_all( + RuntimeOrigin::signed(staker), + other_account, + true + )); + assert!( + Balances::free_balance(&other_account).is_zero(), + "Nothing could have been transferred since it's still locked/frozen." + ); + + // 3. Claim the unlocked chunk, and ensure it can be transferred afterwards. + run_to_block(Ledger::::get(&staker).unlocking[0].unlock_block); + assert_claim_unlocked(staker); + + assert_ok!(Balances::write_balance(&other_account, 0)); + assert_ok!(Balances::transfer_all( + RuntimeOrigin::signed(staker), + other_account, + false + )); + assert_eq!( + Balances::free_balance(&other_account), + lock_amount, + "Everything should have been transferred." + ); + assert!(Balances::free_balance(&staker).is_zero()); + }) +} diff --git a/pallets/dapp-staking-v3/src/test/tests_types.rs b/pallets/dapp-staking-v3/src/test/tests_types.rs new file mode 100644 index 0000000000..8bf9183b69 --- /dev/null +++ b/pallets/dapp-staking-v3/src/test/tests_types.rs @@ -0,0 +1,2746 @@ +// 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 astar_primitives::Balance; +use frame_support::assert_ok; +use sp_arithmetic::fixed_point::FixedU64; +use sp_runtime::Permill; + +use crate::*; + +// Helper to generate custom `Get` types for testing the `AccountLedger` struct. +macro_rules! get_u32_type { + ($struct_name:ident, $value:expr) => { + #[derive(Encode, Decode, MaxEncodedLen, Clone, Copy, Debug, PartialEq, Eq, TypeInfo)] + struct $struct_name; + impl Get for $struct_name { + fn get() -> u32 { + $value + } + } + }; +} + +#[test] +fn subperiod_sanity_check() { + assert_eq!(Subperiod::Voting.next(), Subperiod::BuildAndEarn); + assert_eq!(Subperiod::BuildAndEarn.next(), Subperiod::Voting); +} + +#[test] +fn period_info_basic_checks() { + let period_number = 2; + let next_subperiod_start_era = 5; + let info = PeriodInfo { + number: period_number, + subperiod: Subperiod::Voting, + next_subperiod_start_era: next_subperiod_start_era, + }; + + // Sanity checks + assert_eq!(info.number, period_number); + assert_eq!(info.subperiod, Subperiod::Voting); + assert_eq!(info.next_subperiod_start_era, next_subperiod_start_era); + + // Voting period checks + assert!(!info.is_next_period(next_subperiod_start_era - 1)); + assert!(!info.is_next_period(next_subperiod_start_era)); + assert!(!info.is_next_period(next_subperiod_start_era + 1)); + for era in vec![ + next_subperiod_start_era - 1, + next_subperiod_start_era, + next_subperiod_start_era + 1, + ] { + assert!( + !info.is_next_period(era), + "Cannot trigger 'true' in the Voting period type." + ); + } + + // Build&Earn period checks + let info = PeriodInfo { + number: period_number, + subperiod: Subperiod::BuildAndEarn, + next_subperiod_start_era: next_subperiod_start_era, + }; + assert!(!info.is_next_period(next_subperiod_start_era - 1)); + assert!(info.is_next_period(next_subperiod_start_era)); + assert!(info.is_next_period(next_subperiod_start_era + 1)); +} + +#[test] +fn protocol_state_default() { + let protocol_state = ProtocolState::default(); + + assert_eq!(protocol_state.era, 0); + assert_eq!( + protocol_state.next_era_start, 1, + "Era should start immediately on the first block" + ); +} + +#[test] +fn protocol_state_basic_checks() { + let mut protocol_state = ProtocolState::default(); + let period_number = 5; + let next_subperiod_start_era = 11; + let next_era_start = 31; + protocol_state.period_info = PeriodInfo { + number: period_number, + subperiod: Subperiod::Voting, + next_subperiod_start_era: next_subperiod_start_era, + }; + protocol_state.next_era_start = next_era_start; + + assert_eq!(protocol_state.period_number(), period_number); + assert_eq!(protocol_state.subperiod(), Subperiod::Voting); + + // New era check + assert!(!protocol_state.is_new_era(next_era_start - 1)); + assert!(protocol_state.is_new_era(next_era_start)); + assert!(protocol_state.is_new_era(next_era_start + 1)); + + // Toggle new period type check - 'Voting' to 'BuildAndEarn' + let next_subperiod_start_era_1 = 23; + let next_era_start_1 = 41; + protocol_state.advance_to_next_subperiod(next_subperiod_start_era_1, next_era_start_1); + assert_eq!(protocol_state.subperiod(), Subperiod::BuildAndEarn); + assert_eq!( + protocol_state.period_number(), + period_number, + "Switching from 'Voting' to 'BuildAndEarn' should not trigger period bump." + ); + assert_eq!( + protocol_state.next_subperiod_start_era(), + next_subperiod_start_era_1 + ); + assert!(!protocol_state.is_new_era(next_era_start_1 - 1)); + assert!(protocol_state.is_new_era(next_era_start_1)); + + // Toggle from 'BuildAndEarn' over to 'Voting' + let next_subperiod_start_era_2 = 24; + let next_era_start_2 = 91; + protocol_state.advance_to_next_subperiod(next_subperiod_start_era_2, next_era_start_2); + assert_eq!(protocol_state.subperiod(), Subperiod::Voting); + assert_eq!( + protocol_state.period_number(), + period_number + 1, + "Switching from 'BuildAndEarn' to 'Voting' must trigger period bump." + ); + assert_eq!( + protocol_state.next_subperiod_start_era(), + next_subperiod_start_era_2 + ); + assert!(protocol_state.is_new_era(next_era_start_2)); +} + +#[test] +fn dapp_info_basic_checks() { + let owner = 1; + let beneficiary = 3; + + let mut dapp_info = DAppInfo { + owner, + id: 7, + state: DAppState::Registered, + reward_destination: None, + }; + + // Owner receives reward in case no beneficiary is set + assert_eq!(*dapp_info.reward_beneficiary(), owner); + + // Beneficiary receives rewards in case it is set + dapp_info.reward_destination = Some(beneficiary); + assert_eq!(*dapp_info.reward_beneficiary(), beneficiary); + + // Check if dApp is registered + assert!(dapp_info.is_registered()); + + dapp_info.state = DAppState::Unregistered(10); + assert!(!dapp_info.is_registered()); +} + +#[test] +fn unlocking_chunk_basic_check() { + // Sanity check + let unlocking_chunk = UnlockingChunk::default(); + assert!(unlocking_chunk.amount.is_zero()); + assert!(unlocking_chunk.unlock_block.is_zero()); +} + +#[test] +fn account_ledger_default() { + get_u32_type!(UnlockingDummy, 5); + let acc_ledger = AccountLedger::::default(); + + assert!(acc_ledger.is_empty()); + assert!(acc_ledger.active_locked_amount().is_zero()); +} + +#[test] +fn account_ledger_add_lock_amount_works() { + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = AccountLedger::::default(); + + // First step, sanity checks + assert!(acc_ledger.active_locked_amount().is_zero()); + assert!(acc_ledger.total_locked_amount().is_zero()); + acc_ledger.add_lock_amount(0); + assert!(acc_ledger.active_locked_amount().is_zero()); + + // Adding lock value works as expected + let init_amount = 20; + acc_ledger.add_lock_amount(init_amount); + assert_eq!(acc_ledger.active_locked_amount(), init_amount); + assert_eq!(acc_ledger.total_locked_amount(), init_amount); + assert!(!acc_ledger.is_empty()); +} + +#[test] +fn account_ledger_subtract_lock_amount_basic_usage_works() { + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = AccountLedger::::default(); + + // Sanity check scenario + // Cannot reduce if there is nothing locked, should be a noop + acc_ledger.subtract_lock_amount(0); + acc_ledger.subtract_lock_amount(10); + assert!(acc_ledger.is_empty()); + + // First basic scenario + // Add some lock amount, then reduce it + let lock_amount_1 = 19; + let unlock_amount = 7; + acc_ledger.add_lock_amount(lock_amount_1); + acc_ledger.subtract_lock_amount(unlock_amount); + assert_eq!( + acc_ledger.total_locked_amount(), + lock_amount_1 - unlock_amount + ); + assert_eq!( + acc_ledger.active_locked_amount(), + lock_amount_1 - unlock_amount + ); + assert_eq!(acc_ledger.unlocking_amount(), 0); + + // Second basic scenario + let lock_amount_1 = lock_amount_1 - unlock_amount; + let lock_amount_2 = 31; + acc_ledger.add_lock_amount(lock_amount_2 - lock_amount_1); + assert_eq!(acc_ledger.active_locked_amount(), lock_amount_2); + + // Subtract from the first era and verify state is as expected + acc_ledger.subtract_lock_amount(unlock_amount); + assert_eq!( + acc_ledger.active_locked_amount(), + lock_amount_2 - unlock_amount + ); +} + +#[test] +fn account_ledger_add_unlocking_chunk_works() { + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = AccountLedger::::default(); + + // Base sanity check + let default_unlocking_chunk = UnlockingChunk::default(); + assert!(default_unlocking_chunk.amount.is_zero()); + assert!(default_unlocking_chunk.unlock_block.is_zero()); + + // Sanity check scenario + // Cannot reduce if there is nothing locked, should be a noop + assert!(acc_ledger.add_unlocking_chunk(0, 0).is_ok()); + assert!(acc_ledger.unlocking.len().is_zero()); + assert!(acc_ledger.is_empty()); + + // Basic scenario + let unlock_amount = 17; + let block_number = 29; + assert!(acc_ledger + .add_unlocking_chunk(unlock_amount, block_number) + .is_ok()); + assert_eq!( + acc_ledger.unlocking, + vec![UnlockingChunk { + amount: unlock_amount, + unlock_block: block_number + }] + ); + assert_eq!(acc_ledger.unlocking_amount(), unlock_amount); + + // Unlock additional amount in the same block + assert!(acc_ledger + .add_unlocking_chunk(unlock_amount, block_number) + .is_ok()); + assert_eq!( + acc_ledger.unlocking, + vec![UnlockingChunk { + amount: unlock_amount * 2, + unlock_block: block_number + }] + ); + assert_eq!(acc_ledger.unlocking_amount(), unlock_amount * 2); + + // Add unlocking chunks up to vector capacity + let mut total_unlocking = acc_ledger.unlocking_amount(); + for i in 2..=UnlockingDummy::get() { + let new_unlock_amount = unlock_amount + i as u128; + assert!(acc_ledger + .add_unlocking_chunk(new_unlock_amount, block_number + i) + .is_ok()); + total_unlocking += new_unlock_amount; + assert_eq!(acc_ledger.unlocking_amount(), total_unlocking); + assert_eq!( + acc_ledger.unlocking[i as usize - 1].amount, + new_unlock_amount + ); + } + + // Any further addition should fail, resulting in a noop + let acc_ledger_snapshot = acc_ledger.clone(); + assert_eq!( + acc_ledger.add_unlocking_chunk(1, block_number + UnlockingDummy::get() + 1), + Err(AccountLedgerError::NoCapacity) + ); + assert_eq!(acc_ledger, acc_ledger_snapshot); +} + +#[test] +fn account_ledger_staked_amount_works() { + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = AccountLedger::::default(); + + // Sanity check + assert!(acc_ledger.staked_amount(0).is_zero()); + assert!(acc_ledger.staked_amount(1).is_zero()); + + // Period matches + let amount_1 = 29; + let period = 5; + acc_ledger.staked = StakeAmount { + voting: amount_1, + build_and_earn: 0, + era: 1, + period, + }; + assert_eq!(acc_ledger.staked_amount(period), amount_1); + + // Period doesn't match + assert!(acc_ledger.staked_amount(period - 1).is_zero()); + assert!(acc_ledger.staked_amount(period + 1).is_zero()); + + // Add future entry + let amount_2 = 17; + acc_ledger.staked_future = Some(StakeAmount { + voting: 0, + build_and_earn: amount_2, + era: 2, + period, + }); + assert_eq!(acc_ledger.staked_amount(period), amount_2); + assert!(acc_ledger.staked_amount(period - 1).is_zero()); + assert!(acc_ledger.staked_amount(period + 1).is_zero()); +} + +#[test] +fn account_ledger_staked_amount_for_type_works() { + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = AccountLedger::::default(); + + // 1st scenario - 'current' entry is set, 'future' is None + let (voting_1, build_and_earn_1, period) = (31, 43, 2); + acc_ledger.staked = StakeAmount { + voting: voting_1, + build_and_earn: build_and_earn_1, + era: 10, + period, + }; + acc_ledger.staked_future = None; + + // Correct period should return staked amounts + assert_eq!( + acc_ledger.staked_amount_for_type(Subperiod::Voting, period), + voting_1 + ); + assert_eq!( + acc_ledger.staked_amount_for_type(Subperiod::BuildAndEarn, period), + build_and_earn_1 + ); + + // Inocrrect period should simply return 0 + assert!(acc_ledger + .staked_amount_for_type(Subperiod::Voting, period - 1) + .is_zero()); + assert!(acc_ledger + .staked_amount_for_type(Subperiod::BuildAndEarn, period - 1) + .is_zero()); + + // 2nd scenario - both entries are set, but 'future' must be relevant one. + let (voting_2, build_and_earn_2, period) = (13, 19, 2); + acc_ledger.staked_future = Some(StakeAmount { + voting: voting_2, + build_and_earn: build_and_earn_2, + era: 20, + period, + }); + + // Correct period should return staked amounts + assert_eq!( + acc_ledger.staked_amount_for_type(Subperiod::Voting, period), + voting_2 + ); + assert_eq!( + acc_ledger.staked_amount_for_type(Subperiod::BuildAndEarn, period), + build_and_earn_2 + ); + + // Inocrrect period should simply return 0 + assert!(acc_ledger + .staked_amount_for_type(Subperiod::Voting, period - 1) + .is_zero()); + assert!(acc_ledger + .staked_amount_for_type(Subperiod::BuildAndEarn, period - 1) + .is_zero()); +} + +#[test] +fn account_ledger_stakeable_amount_works() { + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = AccountLedger::::default(); + + // Sanity check for empty ledger + assert!(acc_ledger.stakeable_amount(1).is_zero()); + + // 1st scenario - some locked amount, no staking chunks + let period_1 = 1; + let locked_amount = 19; + acc_ledger.add_lock_amount(locked_amount); + assert_eq!( + acc_ledger.stakeable_amount(period_1), + locked_amount, + "Stakeable amount has to be equal to the locked amount" + ); + + // Second scenario - some staked amount is introduced, period is still valid + let era_1 = 1; + let staked_amount = 7; + acc_ledger.staked = StakeAmount { + voting: 0, + build_and_earn: staked_amount, + era: era_1, + period: period_1, + }; + + assert_eq!( + acc_ledger.stakeable_amount(period_1), + locked_amount - staked_amount, + "Total stakeable amount should be equal to the locked amount minus what is already staked." + ); + + // Third scenario - continuation of the previous, but we move to the next period. + assert_eq!( + acc_ledger.stakeable_amount(period_1 + 1), + locked_amount, + "Stakeable amount has to be equal to the locked amount since old period staking isn't valid anymore" + ); +} + +#[test] +fn account_ledger_staked_era_period_works() { + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = AccountLedger::::default(); + + let (era_1, period) = (10, 2); + let stake_amount_1 = StakeAmount { + voting: 13, + build_and_earn: 17, + era: era_1, + period, + }; + + // Sanity check, empty ledger + assert!(acc_ledger.staked_period().is_none()); + assert!(acc_ledger.earliest_staked_era().is_none()); + + // 1st scenario - only 'current' entry is set + acc_ledger.staked = stake_amount_1; + acc_ledger.staked_future = None; + + assert_eq!(acc_ledger.staked_period(), Some(period)); + assert_eq!(acc_ledger.earliest_staked_era(), Some(era_1)); + + // 2nd scenario - only 'future' is set + let era_2 = era_1 + 7; + let stake_amount_2 = StakeAmount { + voting: 13, + build_and_earn: 17, + era: era_2, + period, + }; + acc_ledger.staked = Default::default(); + acc_ledger.staked_future = Some(stake_amount_2); + + assert_eq!(acc_ledger.staked_period(), Some(period)); + assert_eq!(acc_ledger.earliest_staked_era(), Some(era_2)); + + // 3rd scenario - both entries are set + acc_ledger.staked = stake_amount_1; + acc_ledger.staked_future = Some(stake_amount_2); + + assert_eq!(acc_ledger.staked_period(), Some(period)); + assert_eq!(acc_ledger.earliest_staked_era(), Some(era_1)); +} + +#[test] +fn account_ledger_add_stake_amount_basic_example_works() { + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = AccountLedger::::default(); + + // Sanity check + let period_number = 2; + assert!(acc_ledger + .add_stake_amount( + 0, + 0, + PeriodInfo { + number: period_number, + subperiod: Subperiod::Voting, + next_subperiod_start_era: 0 + } + ) + .is_ok()); + assert!(acc_ledger.staked.is_empty()); + assert!(acc_ledger.staked_future.is_none()); + + // 1st scenario - stake some amount in Voting period, and ensure values are as expected. + let era_1 = 1; + let period_1 = 1; + let period_info_1 = PeriodInfo { + number: period_1, + subperiod: Subperiod::Voting, + next_subperiod_start_era: 100, + }; + let lock_amount = 17; + let stake_amount = 11; + acc_ledger.add_lock_amount(lock_amount); + + assert!(acc_ledger + .add_stake_amount(stake_amount, era_1, period_info_1) + .is_ok()); + + assert!( + acc_ledger.staked.is_empty(), + "Current era must remain unchanged." + ); + assert_eq!( + acc_ledger + .staked_future + .expect("Must exist after stake.") + .period, + period_1 + ); + assert_eq!(acc_ledger.staked_future.unwrap().voting, stake_amount); + assert!(acc_ledger.staked_future.unwrap().build_and_earn.is_zero()); + assert_eq!(acc_ledger.staked_amount(period_1), stake_amount); + assert_eq!( + acc_ledger.staked_amount_for_type(Subperiod::Voting, period_1), + stake_amount + ); + assert!(acc_ledger + .staked_amount_for_type(Subperiod::BuildAndEarn, period_1) + .is_zero()); + + // Second scenario - stake some more, but to the next period type + let snapshot = acc_ledger.staked; + let period_info_2 = PeriodInfo { + number: period_1, + subperiod: Subperiod::BuildAndEarn, + next_subperiod_start_era: 100, + }; + let era_2 = era_1 + 1; + assert!(acc_ledger.add_stake_amount(1, era_2, period_info_2).is_ok()); + assert_eq!(acc_ledger.staked_amount(period_1), stake_amount + 1); + assert_eq!( + acc_ledger.staked_amount_for_type(Subperiod::Voting, period_1), + stake_amount + ); + assert_eq!( + acc_ledger.staked_amount_for_type(Subperiod::BuildAndEarn, period_1), + 1 + ); + assert_eq!(acc_ledger.staked, snapshot); +} + +#[test] +fn account_ledger_add_stake_amount_advanced_example_works() { + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = AccountLedger::::default(); + + // 1st scenario - stake some amount, and ensure values are as expected. + let era_1 = 1; + let period_1 = 1; + let period_info_1 = PeriodInfo { + number: period_1, + subperiod: Subperiod::Voting, + next_subperiod_start_era: 100, + }; + let lock_amount = 17; + let stake_amount_1 = 11; + acc_ledger.add_lock_amount(lock_amount); + + // We only have entry for the current era + acc_ledger.staked = StakeAmount { + voting: stake_amount_1, + build_and_earn: 0, + era: era_1, + period: period_1, + }; + + let stake_amount_2 = 2; + let acc_ledger_snapshot = acc_ledger.clone(); + assert!(acc_ledger + .add_stake_amount(stake_amount_2, era_1, period_info_1) + .is_ok()); + assert_eq!( + acc_ledger.staked_amount(period_1), + stake_amount_1 + stake_amount_2 + ); + assert_eq!( + acc_ledger.staked, acc_ledger_snapshot.staked, + "This entry must remain unchanged." + ); + assert_eq!( + acc_ledger.staked_amount_for_type(Subperiod::Voting, period_1), + stake_amount_1 + stake_amount_2 + ); + assert_eq!( + acc_ledger + .staked_future + .unwrap() + .for_type(Subperiod::Voting), + stake_amount_1 + stake_amount_2 + ); + assert_eq!(acc_ledger.staked_future.unwrap().era, era_1 + 1); +} + +#[test] +fn account_ledger_add_stake_amount_invalid_era_or_period_fails() { + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = AccountLedger::::default(); + + // Prep actions + let era_1 = 5; + let period_1 = 2; + let period_info_1 = PeriodInfo { + number: period_1, + subperiod: Subperiod::Voting, + next_subperiod_start_era: 100, + }; + let lock_amount = 13; + let stake_amount = 7; + acc_ledger.add_lock_amount(lock_amount); + assert!(acc_ledger + .add_stake_amount(stake_amount, era_1, period_info_1) + .is_ok()); + + // Try to add to era after next, it should fail. + assert_eq!( + acc_ledger.add_stake_amount(1, era_1 + 2, period_info_1), + Err(AccountLedgerError::InvalidEra) + ); + + // Try to add to the next period, it should fail. + assert_eq!( + acc_ledger.add_stake_amount( + 1, + era_1, + PeriodInfo { + number: period_1 + 1, + subperiod: Subperiod::Voting, + next_subperiod_start_era: 100 + } + ), + Err(AccountLedgerError::InvalidPeriod) + ); + + // Alternative situation - no future entry, only current era + acc_ledger.staked = StakeAmount { + voting: 0, + build_and_earn: stake_amount, + era: era_1, + period: period_1, + }; + acc_ledger.staked_future = None; + + assert_eq!( + acc_ledger.add_stake_amount(1, era_1 + 1, period_info_1), + Err(AccountLedgerError::InvalidEra) + ); + assert_eq!( + acc_ledger.add_stake_amount( + 1, + era_1, + PeriodInfo { + number: period_1 + 1, + subperiod: Subperiod::Voting, + next_subperiod_start_era: 100 + } + ), + Err(AccountLedgerError::InvalidPeriod) + ); +} + +#[test] +fn account_ledger_add_stake_amount_too_large_amount_fails() { + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = AccountLedger::::default(); + + // Sanity check + assert_eq!( + acc_ledger.add_stake_amount( + 10, + 1, + PeriodInfo { + number: 1, + subperiod: Subperiod::Voting, + next_subperiod_start_era: 100 + } + ), + Err(AccountLedgerError::UnavailableStakeFunds) + ); + + // Lock some amount, and try to stake more than that + let era_1 = 5; + let period_1 = 2; + let period_info_1 = PeriodInfo { + number: period_1, + subperiod: Subperiod::Voting, + next_subperiod_start_era: 100, + }; + let lock_amount = 13; + acc_ledger.add_lock_amount(lock_amount); + assert_eq!( + acc_ledger.add_stake_amount(lock_amount + 1, era_1, period_info_1), + Err(AccountLedgerError::UnavailableStakeFunds) + ); + + // Additional check - have some active stake, and then try to overstake + assert!(acc_ledger + .add_stake_amount(lock_amount - 2, era_1, period_info_1) + .is_ok()); + assert_eq!( + acc_ledger.add_stake_amount(3, era_1, period_info_1), + Err(AccountLedgerError::UnavailableStakeFunds) + ); +} + +#[test] +fn account_ledger_unstake_amount_basic_scenario_works() { + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = AccountLedger::::default(); + + // Prep actions + let amount_1 = 19; + let era_1 = 2; + let period_1 = 1; + let period_info_1 = PeriodInfo { + number: period_1, + subperiod: Subperiod::BuildAndEarn, + next_subperiod_start_era: 100, + }; + acc_ledger.add_lock_amount(amount_1); + + let mut acc_ledger_2 = acc_ledger.clone(); + + // 'Current' staked entry will remain empty. + assert!(acc_ledger + .add_stake_amount(amount_1, era_1, period_info_1) + .is_ok()); + + // Only 'current' entry has some values, future is set to None. + acc_ledger_2.staked = StakeAmount { + voting: 0, + build_and_earn: amount_1, + era: era_1, + period: period_1, + }; + acc_ledger_2.staked_future = None; + + for mut acc_ledger in vec![acc_ledger, acc_ledger_2] { + // Sanity check + assert!(acc_ledger.unstake_amount(0, era_1, period_info_1).is_ok()); + + // 1st scenario - unstake some amount from the current era. + let unstake_amount_1 = 3; + assert!(acc_ledger + .unstake_amount(unstake_amount_1, era_1, period_info_1) + .is_ok()); + assert_eq!( + acc_ledger.staked_amount(period_1), + amount_1 - unstake_amount_1 + ); + + // 2nd scenario - perform full unstake + assert!(acc_ledger + .unstake_amount(amount_1 - unstake_amount_1, era_1, period_info_1) + .is_ok()); + assert!(acc_ledger.staked_amount(period_1).is_zero()); + assert!(acc_ledger.staked.is_empty()); + assert_eq!(acc_ledger.staked, StakeAmount::default()); + assert!(acc_ledger.staked_future.is_none()); + } +} +#[test] +fn account_ledger_unstake_amount_advanced_scenario_works() { + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = AccountLedger::::default(); + + // Prep actions + let amount_1 = 19; + let era_1 = 2; + let period_1 = 1; + let period_info_1 = PeriodInfo { + number: period_1, + subperiod: Subperiod::BuildAndEarn, + next_subperiod_start_era: 100, + }; + acc_ledger.add_lock_amount(amount_1); + + // We have two entries at once + acc_ledger.staked = StakeAmount { + voting: amount_1 - 1, + build_and_earn: 0, + era: era_1, + period: period_1, + }; + acc_ledger.staked_future = Some(StakeAmount { + voting: amount_1 - 1, + build_and_earn: 1, + era: era_1 + 1, + period: period_1, + }); + + // 1st scenario - unstake some amount from the current era, both entries should be affected. + let unstake_amount_1 = 3; + assert!(acc_ledger + .unstake_amount(unstake_amount_1, era_1, period_info_1) + .is_ok()); + assert_eq!( + acc_ledger.staked_amount(period_1), + amount_1 - unstake_amount_1 + ); + + assert_eq!( + acc_ledger.staked.for_type(Subperiod::Voting), + amount_1 - 1 - 3 + ); + assert_eq!( + acc_ledger + .staked_future + .unwrap() + .for_type(Subperiod::Voting), + amount_1 - 3 + ); + assert!(acc_ledger + .staked_future + .unwrap() + .for_type(Subperiod::BuildAndEarn) + .is_zero()); + + // 2nd scenario - perform full unstake + assert!(acc_ledger + .unstake_amount(amount_1 - unstake_amount_1, era_1, period_info_1) + .is_ok()); + assert!(acc_ledger.staked_amount(period_1).is_zero()); + assert_eq!(acc_ledger.staked, StakeAmount::default()); + assert!(acc_ledger.staked_future.is_none()); + + // 3rd scenario - try to stake again, ensure it works + let era_2 = era_1 + 7; + let amount_2 = amount_1 - 5; + assert!(acc_ledger + .add_stake_amount(amount_2, era_2, period_info_1) + .is_ok()); + assert_eq!(acc_ledger.staked_amount(period_1), amount_2); + assert_eq!(acc_ledger.staked, StakeAmount::default()); + assert_eq!( + acc_ledger + .staked_future + .unwrap() + .for_type(Subperiod::BuildAndEarn), + amount_2 + ); +} + +#[test] +fn account_ledger_unstake_from_invalid_era_fails() { + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = AccountLedger::::default(); + + // Prep actions + let amount_1 = 13; + let era_1 = 2; + let period_1 = 1; + let period_info_1 = PeriodInfo { + number: period_1, + subperiod: Subperiod::BuildAndEarn, + next_subperiod_start_era: 100, + }; + acc_ledger.add_lock_amount(amount_1); + assert!(acc_ledger + .add_stake_amount(amount_1, era_1, period_info_1) + .is_ok()); + + // Try to unstake from the current & next era, it should work. + assert!(acc_ledger.unstake_amount(1, era_1, period_info_1).is_ok()); + assert!(acc_ledger + .unstake_amount(1, era_1 + 1, period_info_1) + .is_ok()); + + // Try to unstake from the stake era + 2, it should fail since it would mean we have unclaimed rewards. + assert_eq!( + acc_ledger.unstake_amount(1, era_1 + 2, period_info_1), + Err(AccountLedgerError::InvalidEra) + ); + + // Try to unstake from the next period, it should fail. + assert_eq!( + acc_ledger.unstake_amount( + 1, + era_1, + PeriodInfo { + number: period_1 + 1, + subperiod: Subperiod::Voting, + next_subperiod_start_era: 100 + } + ), + Err(AccountLedgerError::InvalidPeriod) + ); + + // Alternative situation - no future entry, only current era + acc_ledger.staked = StakeAmount { + voting: 0, + build_and_earn: 1, + era: era_1, + period: period_1, + }; + acc_ledger.staked_future = None; + + assert_eq!( + acc_ledger.unstake_amount(1, era_1 + 1, period_info_1), + Err(AccountLedgerError::InvalidEra) + ); + assert_eq!( + acc_ledger.unstake_amount( + 1, + era_1, + PeriodInfo { + number: period_1 + 1, + subperiod: Subperiod::Voting, + next_subperiod_start_era: 100 + } + ), + Err(AccountLedgerError::InvalidPeriod) + ); +} + +#[test] +fn account_ledger_unstake_too_much_fails() { + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = AccountLedger::::default(); + + // Prep actions + let amount_1 = 23; + let era_1 = 2; + let period_1 = 1; + let period_info_1 = PeriodInfo { + number: period_1, + subperiod: Subperiod::BuildAndEarn, + next_subperiod_start_era: 100, + }; + acc_ledger.add_lock_amount(amount_1); + assert!(acc_ledger + .add_stake_amount(amount_1, era_1, period_info_1) + .is_ok()); + + assert_eq!( + acc_ledger.unstake_amount(amount_1 + 1, era_1, period_info_1), + Err(AccountLedgerError::UnstakeAmountLargerThanStake) + ); +} + +#[test] +fn account_ledger_unlockable_amount_works() { + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = AccountLedger::::default(); + + // Sanity check scenario + assert!(acc_ledger.unlockable_amount(0).is_zero()); + + // Nothing is staked + let lock_amount = 29; + let lock_era = 3; + acc_ledger.add_lock_amount(lock_amount); + assert_eq!(acc_ledger.unlockable_amount(0), lock_amount); + + // Some amount is staked, period matches + let stake_period = 5; + let stake_amount = 17; + let period_info = PeriodInfo { + number: stake_period, + subperiod: Subperiod::Voting, + next_subperiod_start_era: 100, + }; + assert!(acc_ledger + .add_stake_amount(stake_amount, lock_era, period_info) + .is_ok()); + assert_eq!( + acc_ledger.unlockable_amount(stake_period), + lock_amount - stake_amount + ); + + // Period doesn't match + assert_eq!(acc_ledger.unlockable_amount(stake_period - 1), lock_amount); + assert_eq!(acc_ledger.unlockable_amount(stake_period + 2), lock_amount); + + // Absurd example, for the sake of completeness - staked without any lock + acc_ledger.locked = Balance::zero(); + assert!(acc_ledger.unlockable_amount(stake_period).is_zero()); + assert!(acc_ledger.unlockable_amount(stake_period - 2).is_zero()); + assert!(acc_ledger.unlockable_amount(stake_period + 1).is_zero()); +} + +#[test] +fn account_ledger_claim_unlocked_works() { + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = AccountLedger::::default(); + + // Sanity check scenario + assert!(acc_ledger.claim_unlocked(0).is_zero()); + + // Add a chunk, assert it can be claimed correctly + let amount = 19; + let block_number = 1; + assert_ok!(acc_ledger.add_unlocking_chunk(amount, block_number)); + assert!(acc_ledger.claim_unlocked(0).is_zero()); + assert_eq!(acc_ledger.claim_unlocked(block_number), amount); + assert!(acc_ledger.unlocking.is_empty()); + + // Add multiple chunks, assert claim works correctly + let (amount1, amount2, amount3) = (7, 13, 19); + let (block1, block2, block3) = (1, 3, 5); + + // Prepare unlocking chunks + assert_ok!(acc_ledger.add_unlocking_chunk(amount1, block1)); + assert_ok!(acc_ledger.add_unlocking_chunk(amount2, block2)); + assert_ok!(acc_ledger.add_unlocking_chunk(amount3, block3)); + + // Only claim 1 chunk + assert_eq!(acc_ledger.claim_unlocked(block1 + 1), amount1); + assert_eq!(acc_ledger.unlocking.len(), 2); + + // Claim remaining two chunks + assert_eq!(acc_ledger.claim_unlocked(block3 + 1), amount2 + amount3); + assert!(acc_ledger.unlocking.is_empty()); +} + +#[test] +fn account_ledger_consume_unlocking_chunks_works() { + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = AccountLedger::::default(); + + // Sanity check scenario + assert!(acc_ledger.consume_unlocking_chunks().is_zero()); + + // Add multiple chunks, cal should return correct amount + let (amount1, amount2) = (7, 13); + assert_ok!(acc_ledger.add_unlocking_chunk(amount1, 1)); + assert_ok!(acc_ledger.add_unlocking_chunk(amount2, 2)); + + assert_eq!(acc_ledger.consume_unlocking_chunks(), amount1 + amount2); + assert!(acc_ledger.unlocking.is_empty()); +} + +#[test] +fn account_ledger_expired_cleanup_works() { + get_u32_type!(UnlockingDummy, 5); + + // 1st scenario - nothing is expired + let mut acc_ledger = AccountLedger::::default(); + acc_ledger.staked = StakeAmount { + voting: 3, + build_and_earn: 7, + era: 100, + period: 5, + }; + acc_ledger.staked_future = Some(StakeAmount { + voting: 3, + build_and_earn: 13, + era: 101, + period: 5, + }); + + let acc_ledger_snapshot = acc_ledger.clone(); + + assert!(!acc_ledger.maybe_cleanup_expired(acc_ledger.staked.period - 1)); + assert_eq!( + acc_ledger, acc_ledger_snapshot, + "No change must happen since period hasn't expired." + ); + + assert!(!acc_ledger.maybe_cleanup_expired(acc_ledger.staked.period)); + assert_eq!( + acc_ledger, acc_ledger_snapshot, + "No change must happen since period hasn't expired." + ); + + // 2nd scenario - stake has expired + assert!(acc_ledger.maybe_cleanup_expired(acc_ledger.staked.period + 1)); + assert!(acc_ledger.staked.is_empty()); + assert!(acc_ledger.staked_future.is_none()); +} + +#[test] +fn account_ledger_claim_up_to_era_only_staked_without_cleanup_works() { + get_u32_type!(UnlockingDummy, 5); + let stake_era = 100; + + let acc_ledger_snapshot = { + let mut acc_ledger = AccountLedger::::default(); + acc_ledger.staked = StakeAmount { + voting: 3, + build_and_earn: 7, + era: stake_era, + period: 5, + }; + acc_ledger + }; + + // 1st scenario - claim one era, period hasn't ended yet + { + let mut acc_ledger = acc_ledger_snapshot.clone(); + let mut result_iter = acc_ledger + .claim_up_to_era(stake_era, None) + .expect("Must provide iter with exactly one era."); + + // Iter values are correct + assert_eq!( + result_iter.next(), + Some((stake_era, acc_ledger_snapshot.staked.total())) + ); + assert!(result_iter.next().is_none()); + + // Ledger values are correct + let mut expected_stake_amount = acc_ledger_snapshot.staked; + expected_stake_amount.era += 1; + assert_eq!( + acc_ledger.staked, expected_stake_amount, + "Only era should be bumped by 1." + ); + assert!( + acc_ledger.staked_future.is_none(), + "Was and should remain None." + ); + } + + // 2nd scenario - claim multiple eras (5), period hasn't ended yet + { + let mut acc_ledger = acc_ledger_snapshot.clone(); + let mut result_iter = acc_ledger + .claim_up_to_era(stake_era + 4, None) // staked era + 4 additional eras + .expect("Must provide iter with 5 values."); + + // Iter values are correct + for inc in 0..5 { + assert_eq!( + result_iter.next(), + Some((stake_era + inc, acc_ledger_snapshot.staked.total())) + ); + } + assert!(result_iter.next().is_none()); + + // Ledger values are correct + let mut expected_stake_amount = acc_ledger_snapshot.staked; + expected_stake_amount.era += 5; + assert_eq!( + acc_ledger.staked, expected_stake_amount, + "Only era should be bumped by 5." + ); + assert!( + acc_ledger.staked_future.is_none(), + "Was and should remain None." + ); + } +} + +#[test] +fn account_ledger_claim_up_to_era_only_staked_with_cleanup_works() { + get_u32_type!(UnlockingDummy, 5); + let stake_era = 100; + + let acc_ledger_snapshot = { + let mut acc_ledger = AccountLedger::::default(); + acc_ledger.staked = StakeAmount { + voting: 3, + build_and_earn: 7, + era: stake_era, + period: 5, + }; + acc_ledger + }; + + // 1st scenario - claim one era, period has ended + { + let mut acc_ledger = acc_ledger_snapshot.clone(); + let mut result_iter = acc_ledger + .claim_up_to_era(stake_era, Some(stake_era)) + .expect("Must provide iter with exactly one era."); + + // Iter values are correct + assert_eq!( + result_iter.next(), + Some((stake_era, acc_ledger_snapshot.staked.total())) + ); + assert!(result_iter.next().is_none()); + + // Ledger values are cleaned up + assert!( + acc_ledger.staked.is_empty(), + "Period has ended so stake entry should be cleaned up." + ); + assert!( + acc_ledger.staked_future.is_none(), + "Was and should remain None." + ); + } + + // 2nd scenario - claim multiple eras (5), period has ended + { + let mut acc_ledger = acc_ledger_snapshot.clone(); + let mut result_iter = acc_ledger + .claim_up_to_era(stake_era + 4, Some(stake_era)) // staked era + 4 additional eras + .expect("Must provide iter with 5 values."); + + for inc in 0..5 { + assert_eq!( + result_iter.next(), + Some((stake_era + inc, acc_ledger_snapshot.staked.total())) + ); + } + assert!(result_iter.next().is_none()); + + // Ledger values are cleaned up + assert!( + acc_ledger.staked.is_empty(), + "Period has ended so stake entry should be cleaned up." + ); + assert!( + acc_ledger.staked_future.is_none(), + "Was and should remain None." + ); + } + + // 3rd scenario - claim one era, period has ended in some future era + { + let mut acc_ledger = acc_ledger_snapshot.clone(); + let mut result_iter = acc_ledger + .claim_up_to_era(stake_era, Some(stake_era + 1)) + .expect("Must provide iter with exactly one era."); + + // Iter values are correct + assert_eq!( + result_iter.next(), + Some((stake_era, acc_ledger_snapshot.staked.total())) + ); + assert!(result_iter.next().is_none()); + + // Ledger values are correctly updated + let mut expected_stake_amount = acc_ledger_snapshot.staked; + expected_stake_amount.era += 1; + assert_eq!( + acc_ledger.staked, expected_stake_amount, + "Entry must exist since we still haven't reached the period end era." + ); + assert!( + acc_ledger.staked_future.is_none(), + "Was and should remain None." + ); + } +} + +#[test] +fn account_ledger_claim_up_to_era_only_staked_future_without_cleanup_works() { + get_u32_type!(UnlockingDummy, 5); + let stake_era = 50; + + let acc_ledger_snapshot = { + let mut acc_ledger = AccountLedger::::default(); + acc_ledger.staked_future = Some(StakeAmount { + voting: 5, + build_and_earn: 11, + era: stake_era, + period: 4, + }); + acc_ledger + }; + + // 1st scenario - claim one era, period hasn't ended yet + { + let mut acc_ledger = acc_ledger_snapshot.clone(); + let mut result_iter = acc_ledger + .claim_up_to_era(stake_era, None) + .expect("Must provide iter with exactly one era."); + + // Iter values are correct + assert_eq!( + result_iter.next(), + Some(( + stake_era, + acc_ledger_snapshot.staked_future.unwrap().total() + )) + ); + assert!(result_iter.next().is_none()); + + // Ledger values are correct + let mut expected_stake_amount = acc_ledger_snapshot.staked_future.unwrap(); + expected_stake_amount.era += 1; + assert_eq!( + acc_ledger.staked, expected_stake_amount, + "Era must be bumped by 1, and entry must switch from staked_future over to staked." + ); + assert!( + acc_ledger.staked_future.is_none(), + "staked_future must be cleaned up after the claim." + ); + } + + // 2nd scenario - claim multiple eras (5), period hasn't ended yet + { + let mut acc_ledger = acc_ledger_snapshot.clone(); + let mut result_iter = acc_ledger + .claim_up_to_era(stake_era + 4, None) // staked era + 4 additional eras + .expect("Must provide iter with 5 entries."); + + // Iter values are correct + for inc in 0..5 { + assert_eq!( + result_iter.next(), + Some(( + stake_era + inc, + acc_ledger_snapshot.staked_future.unwrap().total() + )) + ); + } + assert!(result_iter.next().is_none()); + + // Ledger values are correct + let mut expected_stake_amount = acc_ledger_snapshot.staked_future.unwrap(); + expected_stake_amount.era += 5; + assert_eq!( + acc_ledger.staked, expected_stake_amount, + "Era must be bumped by 5, and entry must switch from staked_future over to staked." + ); + assert!( + acc_ledger.staked_future.is_none(), + "staked_future must be cleaned up after the claim." + ); + } +} + +#[test] +fn account_ledger_claim_up_to_era_only_staked_future_with_cleanup_works() { + get_u32_type!(UnlockingDummy, 5); + let stake_era = 50; + + let acc_ledger_snapshot = { + let mut acc_ledger = AccountLedger::::default(); + acc_ledger.staked_future = Some(StakeAmount { + voting: 2, + build_and_earn: 17, + era: stake_era, + period: 3, + }); + acc_ledger + }; + + // 1st scenario - claim one era, period has ended + { + let mut acc_ledger = acc_ledger_snapshot.clone(); + let mut result_iter = acc_ledger + .claim_up_to_era(stake_era, Some(stake_era)) + .expect("Must provide iter with exactly one era."); + + // Iter values are correct + assert_eq!( + result_iter.next(), + Some(( + stake_era, + acc_ledger_snapshot.staked_future.unwrap().total() + )) + ); + assert!(result_iter.next().is_none()); + + // Ledger values are cleaned up + assert!( + acc_ledger.staked.is_empty(), + "Period has ended so stake entry should be cleaned up." + ); + assert!( + acc_ledger.staked_future.is_none(), + "Was and should remain None." + ); + } + + // 2nd scenario - claim multiple eras (5), period has ended + { + let mut acc_ledger = acc_ledger_snapshot.clone(); + let mut result_iter = acc_ledger + .claim_up_to_era(stake_era + 4, Some(stake_era)) // staked era + 4 additional eras + .expect("Must provide iter with 5 entries."); + + for inc in 0..5 { + assert_eq!( + result_iter.next(), + Some(( + stake_era + inc, + acc_ledger_snapshot.staked_future.unwrap().total() + )) + ); + } + assert!(result_iter.next().is_none()); + + // Ledger values are cleaned up + assert!( + acc_ledger.staked.is_empty(), + "Period has ended so stake entry should be cleaned up." + ); + assert!( + acc_ledger.staked_future.is_none(), + "Was and should remain None." + ); + } + + // 3rd scenario - claim one era, period has ended in some future era + { + let mut acc_ledger = acc_ledger_snapshot.clone(); + let mut result_iter = acc_ledger + .claim_up_to_era(stake_era, Some(stake_era + 1)) + .expect("Must provide iter with exactly one era."); + + // Iter values are correct + assert_eq!( + result_iter.next(), + Some(( + stake_era, + acc_ledger_snapshot.staked_future.unwrap().total() + )) + ); + assert!(result_iter.next().is_none()); + + // Ledger values are correctly updated + let mut expected_stake_amount = acc_ledger_snapshot.staked_future.unwrap(); + expected_stake_amount.era += 1; + assert_eq!( + acc_ledger.staked, expected_stake_amount, + "Entry must exist since we still haven't reached the period end era." + ); + assert!( + acc_ledger.staked_future.is_none(), + "Was and should remain None." + ); + } +} + +#[test] +fn account_ledger_claim_up_to_era_staked_and_staked_future_works() { + get_u32_type!(UnlockingDummy, 5); + let stake_era_1 = 100; + let stake_era_2 = stake_era_1 + 1; + + let acc_ledger_snapshot = { + let mut acc_ledger = AccountLedger::::default(); + acc_ledger.staked = StakeAmount { + voting: 3, + build_and_earn: 7, + era: stake_era_1, + period: 5, + }; + acc_ledger.staked_future = Some(StakeAmount { + voting: 3, + build_and_earn: 11, + era: stake_era_2, + period: 5, + }); + acc_ledger + }; + + // 1st scenario - claim only one era + { + let mut acc_ledger = acc_ledger_snapshot.clone(); + let mut result_iter = acc_ledger + .claim_up_to_era(stake_era_1, None) + .expect("Must provide iter with exactly one era."); + + // Iter values are correct + assert_eq!( + result_iter.next(), + Some((stake_era_1, acc_ledger_snapshot.staked.total())) + ); + assert!(result_iter.next().is_none()); + + // Ledger values are correct + let mut expected_stake_amount = acc_ledger_snapshot.staked; + expected_stake_amount.era += 1; + assert_eq!( + acc_ledger.staked, + acc_ledger_snapshot.staked_future.unwrap(), + "staked_future entry must be moved over to staked." + ); + assert!( + acc_ledger.staked_future.is_none(), + "staked_future is cleaned up since it's been moved over to staked entry." + ); + } + + // 2nd scenario - claim multiple eras (3), period hasn't ended yet, do the cleanup + { + let mut acc_ledger = acc_ledger_snapshot.clone(); + let mut result_iter = acc_ledger + .claim_up_to_era(stake_era_2 + 1, None) // staked era + 2 additional eras + .expect("Must provide iter with exactly two entries."); + + // Iter values are correct + assert_eq!( + result_iter.next(), + Some((stake_era_1, acc_ledger_snapshot.staked.total())) + ); + assert_eq!( + result_iter.next(), + Some(( + stake_era_2, + acc_ledger_snapshot.staked_future.unwrap().total() + )) + ); + assert_eq!( + result_iter.next(), + Some(( + stake_era_2 + 1, + acc_ledger_snapshot.staked_future.unwrap().total() + )) + ); + assert!(result_iter.next().is_none()); + + // Ledger values are correct + let mut expected_stake_amount = acc_ledger_snapshot.staked_future.unwrap(); + expected_stake_amount.era += 2; + assert_eq!( + acc_ledger.staked, expected_stake_amount, + "staked_future must move over to staked, and era must be incremented by 2." + ); + assert!( + acc_ledger.staked_future.is_none(), + "staked_future is cleaned up since it's been moved over to staked entry." + ); + } +} + +#[test] +fn account_ledger_claim_up_to_era_fails_for_historic_eras() { + get_u32_type!(UnlockingDummy, 5); + let stake_era = 50; + + // Only staked entry + { + let mut acc_ledger = AccountLedger::::default(); + acc_ledger.staked = StakeAmount { + voting: 2, + build_and_earn: 17, + era: stake_era, + period: 3, + }; + assert_eq!( + acc_ledger.claim_up_to_era(stake_era - 1, None), + Err(AccountLedgerError::NothingToClaim) + ); + } + + // Only staked-future entry + { + let mut acc_ledger = AccountLedger::::default(); + acc_ledger.staked_future = Some(StakeAmount { + voting: 2, + build_and_earn: 17, + era: stake_era, + period: 3, + }); + assert_eq!( + acc_ledger.claim_up_to_era(stake_era - 1, None), + Err(AccountLedgerError::NothingToClaim) + ); + } + + // Both staked and staked-future entries + { + let mut acc_ledger = AccountLedger::::default(); + acc_ledger.staked = StakeAmount { + voting: 2, + build_and_earn: 17, + era: stake_era, + period: 3, + }; + acc_ledger.staked_future = Some(StakeAmount { + voting: 2, + build_and_earn: 19, + era: stake_era + 1, + period: 3, + }); + assert_eq!( + acc_ledger.claim_up_to_era(stake_era - 1, None), + Err(AccountLedgerError::NothingToClaim) + ); + } +} + +#[test] +fn era_stake_pair_iter_works() { + // 1st scenario - only span is given + let (era_1, last_era, amount) = (2, 5, 11); + let mut iter_1 = EraStakePairIter::new((era_1, last_era, amount), None).unwrap(); + for era in era_1..=last_era { + assert_eq!(iter_1.next(), Some((era, amount))); + } + assert!(iter_1.next().is_none()); + + // 2nd scenario - first value & span are given + let (maybe_era_1, maybe_first_amount) = (1, 7); + let maybe_first = Some((maybe_era_1, maybe_first_amount)); + let mut iter_2 = EraStakePairIter::new((era_1, last_era, amount), maybe_first).unwrap(); + + assert_eq!(iter_2.next(), Some((maybe_era_1, maybe_first_amount))); + for era in era_1..=last_era { + assert_eq!(iter_2.next(), Some((era, amount))); + } +} + +#[test] +fn era_stake_pair_iter_returns_error_for_illegal_data() { + // 1st scenario - spans are reversed; first era comes AFTER the last era + let (era_1, last_era, amount) = (2, 5, 11); + assert!(EraStakePairIter::new((last_era, era_1, amount), None).is_err()); + + // 2nd scenario - maybe_first covers the same era as the span + assert!(EraStakePairIter::new((era_1, last_era, amount), Some((era_1, 10))).is_err()); + + // 3rd scenario - maybe_first is before the span, but not exactly 1 era before the first era in the span + assert!(EraStakePairIter::new((era_1, last_era, amount), Some((era_1 - 2, 10))).is_err()); + + assert!( + EraStakePairIter::new((era_1, last_era, amount), Some((era_1 - 1, 10))).is_ok(), + "Sanity check." + ); +} + +#[test] +fn era_info_lock_unlock_works() { + let mut era_info = EraInfo::default(); + + // Sanity check + assert!(era_info.total_locked.is_zero()); + assert!(era_info.unlocking.is_zero()); + + // Basic add lock + let lock_amount = 7; + era_info.add_locked(lock_amount); + assert_eq!(era_info.total_locked, lock_amount); + era_info.add_locked(lock_amount); + assert_eq!(era_info.total_locked, lock_amount * 2); + + // Basic unlocking started + let unlock_amount = 2; + era_info.total_locked = 17; + let era_info_snapshot = era_info; + + // First unlock & checks + era_info.unlocking_started(unlock_amount); + assert_eq!( + era_info.total_locked, + era_info_snapshot.total_locked - unlock_amount + ); + assert_eq!(era_info.unlocking, unlock_amount); + + // Second unlock and checks + era_info.unlocking_started(unlock_amount); + assert_eq!( + era_info.total_locked, + era_info_snapshot.total_locked - unlock_amount * 2 + ); + assert_eq!(era_info.unlocking, unlock_amount * 2); + + // Claim unlocked chunks + let old_era_info = era_info.clone(); + era_info.unlocking_removed(1); + assert_eq!(era_info.unlocking, old_era_info.unlocking - 1); + assert_eq!(era_info.total_locked, old_era_info.total_locked); +} + +#[test] +fn era_info_stake_works() { + let mut era_info = EraInfo::default(); + + // Sanity check + assert!(era_info.total_locked.is_zero()); + + // Add some voting period stake + let vp_stake_amount = 7; + era_info.add_stake_amount(vp_stake_amount, Subperiod::Voting); + assert_eq!(era_info.total_staked_amount_next_era(), vp_stake_amount); + assert_eq!( + era_info.staked_amount_next_era(Subperiod::Voting), + vp_stake_amount + ); + assert!( + era_info.total_staked_amount().is_zero(), + "Calling stake makes it available only from the next era." + ); + + // Add some build&earn period stake + let bep_stake_amount = 13; + era_info.add_stake_amount(bep_stake_amount, Subperiod::BuildAndEarn); + assert_eq!( + era_info.total_staked_amount_next_era(), + vp_stake_amount + bep_stake_amount + ); + assert_eq!( + era_info.staked_amount_next_era(Subperiod::BuildAndEarn), + bep_stake_amount + ); + assert!( + era_info.total_staked_amount().is_zero(), + "Calling stake makes it available only from the next era." + ); +} + +#[test] +fn era_info_unstake_works() { + let mut era_info = EraInfo::default(); + + // Make dummy era info with stake amounts + let vp_stake_amount = 15; + let bep_stake_amount_1 = 23; + let bep_stake_amount_2 = bep_stake_amount_1 + 6; + let period_number = 1; + let era = 2; + era_info.current_stake_amount = StakeAmount { + voting: vp_stake_amount, + build_and_earn: bep_stake_amount_1, + era, + period: period_number, + }; + era_info.next_stake_amount = StakeAmount { + voting: vp_stake_amount, + build_and_earn: bep_stake_amount_2, + era: era + 1, + period: period_number, + }; + let total_staked = era_info.total_staked_amount(); + let total_staked_next_era = era_info.total_staked_amount_next_era(); + + // 1st scenario - unstake some amount, no overflow + let unstake_amount_1 = bep_stake_amount_1; + era_info.unstake_amount(unstake_amount_1, Subperiod::BuildAndEarn); + + // Current era + assert_eq!( + era_info.total_staked_amount(), + total_staked - unstake_amount_1 + ); + assert_eq!(era_info.staked_amount(Subperiod::Voting), vp_stake_amount); + assert!(era_info.staked_amount(Subperiod::BuildAndEarn).is_zero()); + + // Next era + assert_eq!( + era_info.total_staked_amount_next_era(), + total_staked_next_era - unstake_amount_1 + ); + assert_eq!( + era_info.staked_amount_next_era(Subperiod::Voting), + vp_stake_amount + ); + assert_eq!( + era_info.staked_amount_next_era(Subperiod::BuildAndEarn), + bep_stake_amount_2 - unstake_amount_1 + ); + + // 2nd scenario - unstake some more, but with overflow + let overflow = 2; + let unstake_amount_2 = bep_stake_amount_2 - unstake_amount_1 + overflow; + era_info.unstake_amount(unstake_amount_2, Subperiod::BuildAndEarn); + + // Current era + assert_eq!( + era_info.total_staked_amount(), + total_staked - unstake_amount_1 - unstake_amount_2 + ); + + // Next era + assert_eq!( + era_info.total_staked_amount_next_era(), + vp_stake_amount - overflow + ); + assert_eq!( + era_info.staked_amount_next_era(Subperiod::Voting), + vp_stake_amount - overflow + ); + assert!(era_info + .staked_amount_next_era(Subperiod::BuildAndEarn) + .is_zero()); +} + +#[test] +fn era_info_migrate_to_next_era_works() { + // Make dummy era info with stake amounts + let era_info_snapshot = EraInfo { + total_locked: 456, + unlocking: 13, + current_stake_amount: StakeAmount { + voting: 13, + build_and_earn: 29, + era: 2, + period: 1, + }, + next_stake_amount: StakeAmount { + voting: 13, + build_and_earn: 41, + era: 3, + period: 1, + }, + }; + + // 1st scenario - rollover to next era, no subperiod change + { + let mut era_info = era_info_snapshot; + era_info.migrate_to_next_era(None); + + assert_eq!(era_info.total_locked, era_info_snapshot.total_locked); + assert_eq!(era_info.unlocking, era_info_snapshot.unlocking); + assert_eq!( + era_info.current_stake_amount, + era_info_snapshot.next_stake_amount + ); + + let mut new_next_stake_amount = era_info_snapshot.next_stake_amount; + new_next_stake_amount.era += 1; + assert_eq!(era_info.next_stake_amount, new_next_stake_amount); + } + + // 2nd scenario - rollover to next era, change from Voting into Build&Earn subperiod + { + let mut era_info = era_info_snapshot; + era_info.migrate_to_next_era(Some(Subperiod::BuildAndEarn)); + + assert_eq!(era_info.total_locked, era_info_snapshot.total_locked); + assert_eq!(era_info.unlocking, era_info_snapshot.unlocking); + assert_eq!( + era_info.current_stake_amount, + era_info_snapshot.next_stake_amount + ); + + let mut new_next_stake_amount = era_info_snapshot.next_stake_amount; + new_next_stake_amount.era += 1; + assert_eq!(era_info.next_stake_amount, new_next_stake_amount); + } + + // 3rd scenario - rollover to next era, change from Build&Earn to Voting subperiod + { + let mut era_info = era_info_snapshot; + era_info.migrate_to_next_era(Some(Subperiod::Voting)); + + assert_eq!(era_info.total_locked, era_info_snapshot.total_locked); + assert_eq!(era_info.unlocking, era_info_snapshot.unlocking); + assert_eq!( + era_info.current_stake_amount, + StakeAmount { + voting: Zero::zero(), + build_and_earn: Zero::zero(), + era: era_info_snapshot.current_stake_amount.era + 1, + period: era_info_snapshot.current_stake_amount.period + 1, + } + ); + assert_eq!( + era_info.next_stake_amount, + StakeAmount { + voting: Zero::zero(), + build_and_earn: Zero::zero(), + era: era_info_snapshot.current_stake_amount.era + 2, + period: era_info_snapshot.current_stake_amount.period + 1, + } + ); + } +} + +#[test] +fn stake_amount_works() { + let mut stake_amount = StakeAmount::default(); + + // Sanity check + assert!(stake_amount.total().is_zero()); + assert!(stake_amount.for_type(Subperiod::Voting).is_zero()); + assert!(stake_amount.for_type(Subperiod::BuildAndEarn).is_zero()); + + // Stake some amount in voting period + let vp_stake_1 = 11; + stake_amount.add(vp_stake_1, Subperiod::Voting); + assert_eq!(stake_amount.total(), vp_stake_1); + assert_eq!(stake_amount.for_type(Subperiod::Voting), vp_stake_1); + assert!(stake_amount.for_type(Subperiod::BuildAndEarn).is_zero()); + + // Stake some amount in build&earn period + let bep_stake_1 = 13; + stake_amount.add(bep_stake_1, Subperiod::BuildAndEarn); + assert_eq!(stake_amount.total(), vp_stake_1 + bep_stake_1); + assert_eq!(stake_amount.for_type(Subperiod::Voting), vp_stake_1); + assert_eq!(stake_amount.for_type(Subperiod::BuildAndEarn), bep_stake_1); + + // Unstake some amount from voting period + let vp_unstake_1 = 5; + stake_amount.subtract(5, Subperiod::Voting); + assert_eq!( + stake_amount.total(), + vp_stake_1 + bep_stake_1 - vp_unstake_1 + ); + assert_eq!( + stake_amount.for_type(Subperiod::Voting), + vp_stake_1 - vp_unstake_1 + ); + assert_eq!(stake_amount.for_type(Subperiod::BuildAndEarn), bep_stake_1); + + // Unstake some amount from build&earn period + let bep_unstake_1 = 2; + stake_amount.subtract(bep_unstake_1, Subperiod::BuildAndEarn); + assert_eq!( + stake_amount.total(), + vp_stake_1 + bep_stake_1 - vp_unstake_1 - bep_unstake_1 + ); + assert_eq!( + stake_amount.for_type(Subperiod::Voting), + vp_stake_1 - vp_unstake_1 + ); + assert_eq!( + stake_amount.for_type(Subperiod::BuildAndEarn), + bep_stake_1 - bep_unstake_1 + ); + + // Unstake some more from build&earn period, and chip away from the voting period + let total_stake = vp_stake_1 + bep_stake_1 - vp_unstake_1 - bep_unstake_1; + let bep_unstake_2 = bep_stake_1 - bep_unstake_1 + 1; + stake_amount.subtract(bep_unstake_2, Subperiod::BuildAndEarn); + assert_eq!(stake_amount.total(), total_stake - bep_unstake_2); + assert_eq!( + stake_amount.for_type(Subperiod::Voting), + vp_stake_1 - vp_unstake_1 - 1 + ); + assert!(stake_amount.for_type(Subperiod::BuildAndEarn).is_zero()); +} + +#[test] +fn singular_staking_info_basics_are_ok() { + let period_number = 3; + let subperiod = Subperiod::Voting; + let mut staking_info = SingularStakingInfo::new(period_number, subperiod); + + // Sanity checks + assert_eq!(staking_info.period_number(), period_number); + assert!(staking_info.is_loyal()); + assert!(staking_info.total_staked_amount().is_zero()); + assert!(staking_info.is_empty()); + assert!(staking_info.era().is_zero()); + assert!(!SingularStakingInfo::new(period_number, Subperiod::BuildAndEarn).is_loyal()); + + // Add some staked amount during `Voting` period + let era_1 = 7; + let vote_stake_amount_1 = 11; + staking_info.stake(vote_stake_amount_1, era_1, Subperiod::Voting); + assert_eq!(staking_info.total_staked_amount(), vote_stake_amount_1); + assert_eq!( + staking_info.staked_amount(Subperiod::Voting), + vote_stake_amount_1 + ); + assert!(staking_info + .staked_amount(Subperiod::BuildAndEarn) + .is_zero()); + assert_eq!( + staking_info.era(), + era_1 + 1, + "Stake era should remain valid." + ); + + // Add some staked amount during `BuildAndEarn` period + let era_2 = 9; + let bep_stake_amount_1 = 23; + staking_info.stake(bep_stake_amount_1, era_2, Subperiod::BuildAndEarn); + assert_eq!( + staking_info.total_staked_amount(), + vote_stake_amount_1 + bep_stake_amount_1 + ); + assert_eq!( + staking_info.staked_amount(Subperiod::Voting), + vote_stake_amount_1 + ); + assert_eq!( + staking_info.staked_amount(Subperiod::BuildAndEarn), + bep_stake_amount_1 + ); + assert_eq!(staking_info.era(), era_2 + 1); +} + +#[test] +fn singular_staking_info_unstake_during_voting_is_ok() { + let period_number = 3; + let subperiod = Subperiod::Voting; + let mut staking_info = SingularStakingInfo::new(period_number, subperiod); + + // Prep actions + let era_1 = 2; + let vote_stake_amount_1 = 11; + staking_info.stake(vote_stake_amount_1, era_1, Subperiod::Voting); + + // Unstake some amount during `Voting` period, loyalty should remain as expected. + let unstake_amount_1 = 5; + assert_eq!( + staking_info.unstake(unstake_amount_1, era_1, Subperiod::Voting), + (unstake_amount_1, Balance::zero()) + ); + assert_eq!( + staking_info.total_staked_amount(), + vote_stake_amount_1 - unstake_amount_1 + ); + assert!(staking_info.is_loyal()); + assert_eq!( + staking_info.era(), + era_1 + 1, + "Stake era should remain valid." + ); + + // Fully unstake, attempting to undersaturate, and ensure loyalty flag is still true. + let era_2 = era_1 + 2; + let remaining_stake = staking_info.total_staked_amount(); + assert_eq!( + staking_info.unstake(remaining_stake + 1, era_2, Subperiod::Voting), + (remaining_stake, Balance::zero()) + ); + assert!(staking_info.total_staked_amount().is_zero()); + assert!(staking_info.is_loyal()); + assert_eq!(staking_info.era(), era_2); +} + +#[test] +fn singular_staking_info_unstake_during_bep_is_ok() { + let period_number = 3; + let subperiod = Subperiod::Voting; + let mut staking_info = SingularStakingInfo::new(period_number, subperiod); + + // Prep actions + let era_1 = 3; + let vote_stake_amount_1 = 11; + staking_info.stake(vote_stake_amount_1, era_1 - 1, Subperiod::Voting); + let bep_stake_amount_1 = 23; + staking_info.stake(bep_stake_amount_1, era_1, Subperiod::BuildAndEarn); + + // 1st scenario - Unstake some of the amount staked during B&E period + let unstake_1 = 5; + assert_eq!( + staking_info.unstake(5, era_1, Subperiod::BuildAndEarn), + (Balance::zero(), unstake_1) + ); + assert_eq!( + staking_info.total_staked_amount(), + vote_stake_amount_1 + bep_stake_amount_1 - unstake_1 + ); + assert_eq!( + staking_info.staked_amount(Subperiod::Voting), + vote_stake_amount_1 + ); + assert_eq!( + staking_info.staked_amount(Subperiod::BuildAndEarn), + bep_stake_amount_1 - unstake_1 + ); + assert!(staking_info.is_loyal()); + assert_eq!( + staking_info.era(), + era_1 + 1, + "Stake era should remain valid." + ); + + // 2nd scenario - unstake all of the amount staked during B&E period, and then some more. + // The point is to take a chunk from the voting period stake too. + let current_total_stake = staking_info.total_staked_amount(); + let current_bep_stake = staking_info.staked_amount(Subperiod::BuildAndEarn); + let voting_stake_overflow = 2; + let unstake_2 = current_bep_stake + voting_stake_overflow; + let era_2 = era_1 + 3; + + assert_eq!( + staking_info.unstake(unstake_2, era_2, Subperiod::BuildAndEarn), + (voting_stake_overflow, current_bep_stake) + ); + assert_eq!( + staking_info.total_staked_amount(), + current_total_stake - unstake_2 + ); + assert_eq!( + staking_info.staked_amount(Subperiod::Voting), + vote_stake_amount_1 - voting_stake_overflow + ); + assert!(staking_info + .staked_amount(Subperiod::BuildAndEarn) + .is_zero()); + assert!( + !staking_info.is_loyal(), + "Loyalty flag should have been removed due to non-zero voting period unstake" + ); + assert_eq!(staking_info.era(), era_2); +} + +#[test] +fn contract_stake_amount_basic_get_checks_work() { + // Sanity checks for empty struct + let contract_stake = ContractStakeAmount { + staked: Default::default(), + staked_future: None, + }; + assert!(contract_stake.is_empty()); + assert!(contract_stake.latest_stake_period().is_none()); + assert!(contract_stake.latest_stake_era().is_none()); + assert!(contract_stake.total_staked_amount(0).is_zero()); + assert!(contract_stake.staked_amount(0, Subperiod::Voting).is_zero()); + assert!(contract_stake + .staked_amount(0, Subperiod::BuildAndEarn) + .is_zero()); + + let era = 3; + let period = 2; + let amount = StakeAmount { + voting: 11, + build_and_earn: 17, + era, + period, + }; + let contract_stake = ContractStakeAmount { + staked: amount, + staked_future: None, + }; + assert!(!contract_stake.is_empty()); + + // Checks for illegal periods + for illegal_period in [period - 1, period + 1] { + assert!(contract_stake.total_staked_amount(illegal_period).is_zero()); + assert!(contract_stake + .staked_amount(illegal_period, Subperiod::Voting) + .is_zero()); + assert!(contract_stake + .staked_amount(illegal_period, Subperiod::BuildAndEarn) + .is_zero()); + } + + // Check for the valid period + assert_eq!(contract_stake.latest_stake_period(), Some(period)); + assert_eq!(contract_stake.latest_stake_era(), Some(era)); + assert_eq!(contract_stake.total_staked_amount(period), amount.total()); + assert_eq!( + contract_stake.staked_amount(period, Subperiod::Voting), + amount.voting + ); + assert_eq!( + contract_stake.staked_amount(period, Subperiod::BuildAndEarn), + amount.build_and_earn + ); +} + +#[test] +fn contract_stake_amount_advanced_get_checks_work() { + let (era_1, era_2) = (4, 7); + let period = 2; + let amount_1 = StakeAmount { + voting: 11, + build_and_earn: 0, + era: era_1, + period, + }; + let amount_2 = StakeAmount { + voting: 11, + build_and_earn: 13, + era: era_2, + period, + }; + + let contract_stake = ContractStakeAmount { + staked: amount_1, + staked_future: Some(amount_2), + }; + + // Sanity checks - all values from the 'future' entry should be relevant + assert!(!contract_stake.is_empty()); + assert_eq!(contract_stake.latest_stake_period(), Some(period)); + assert_eq!(contract_stake.latest_stake_era(), Some(era_2)); + assert_eq!(contract_stake.total_staked_amount(period), amount_2.total()); + assert_eq!( + contract_stake.staked_amount(period, Subperiod::Voting), + amount_2.voting + ); + assert_eq!( + contract_stake.staked_amount(period, Subperiod::BuildAndEarn), + amount_2.build_and_earn + ); + + // 1st scenario - get existing entries + assert_eq!(contract_stake.get(era_1, period), Some(amount_1)); + assert_eq!(contract_stake.get(era_2, period), Some(amount_2)); + + // 2nd scenario - get non-existing entries for covered eras + let era_3 = era_2 - 1; + let entry_1 = contract_stake.get(era_3, 2).expect("Has to be Some"); + assert_eq!(entry_1.total(), amount_1.total()); + assert_eq!(entry_1.era, era_3); + assert_eq!(entry_1.period, period); + + let era_4 = era_2 + 1; + let entry_1 = contract_stake.get(era_4, period).expect("Has to be Some"); + assert_eq!(entry_1.total(), amount_2.total()); + assert_eq!(entry_1.era, era_4); + assert_eq!(entry_1.period, period); + + // 3rd scenario - get non-existing entries for covered eras but mismatching period + assert!(contract_stake.get(8, period + 1).is_none()); + + // 4th scenario - get non-existing entries for non-covered eras + assert!(contract_stake.get(3, period).is_none()); +} + +#[test] +fn contract_stake_amount_stake_is_ok() { + let mut contract_stake = ContractStakeAmount::default(); + + // 1st scenario - stake some amount and verify state change + let era_1 = 3; + let stake_era_1 = era_1 + 1; + let period_1 = 5; + let period_info_1 = PeriodInfo { + number: period_1, + subperiod: Subperiod::Voting, + next_subperiod_start_era: 20, + }; + let amount_1 = 31; + contract_stake.stake(amount_1, period_info_1, era_1); + assert!(!contract_stake.is_empty()); + assert!( + contract_stake.staked.is_empty(), + "Only future entry should be modified." + ); + assert!(contract_stake.staked_future.is_some()); + + assert!( + contract_stake.get(era_1, period_1).is_none(), + "Entry for current era must not exist." + ); + let entry_1_1 = contract_stake.get(stake_era_1, period_1).unwrap(); + assert_eq!( + entry_1_1.era, stake_era_1, + "Stake is only valid from next era." + ); + assert_eq!(entry_1_1.total(), amount_1); + assert_eq!(entry_1_1.for_type(Subperiod::Voting), amount_1); + assert!(entry_1_1.for_type(Subperiod::BuildAndEarn).is_zero()); + + // 2nd scenario - stake some more to the same era but different period type, and verify state change. + let period_info_1 = PeriodInfo { + number: period_1, + subperiod: Subperiod::BuildAndEarn, + next_subperiod_start_era: 20, + }; + contract_stake.stake(amount_1, period_info_1, era_1); + let entry_1_2 = contract_stake.get(stake_era_1, period_1).unwrap(); + assert_eq!(entry_1_2.era, stake_era_1); + assert_eq!(entry_1_2.total(), amount_1 * 2); + assert_eq!(entry_1_2.for_type(Subperiod::Voting), amount_1); + assert_eq!(entry_1_2.for_type(Subperiod::BuildAndEarn), amount_1); + assert!( + contract_stake.staked.is_empty(), + "Only future entry should be modified." + ); + assert!(contract_stake.staked_future.is_some()); + + // 3rd scenario - stake more to the next era, while still in the same period. + let era_2 = era_1 + 2; + let stake_era_2 = era_2 + 1; + let amount_2 = 37; + contract_stake.stake(amount_2, period_info_1, era_2); + let entry_2_1 = contract_stake.get(stake_era_1, period_1).unwrap(); + let entry_2_2 = contract_stake.get(stake_era_2, period_1).unwrap(); + assert_eq!(entry_2_1, entry_1_2, "Old entry must remain unchanged."); + assert_eq!(entry_2_2.era, stake_era_2); + assert_eq!(entry_2_2.period, period_1); + assert_eq!( + entry_2_2.total(), + entry_2_1.total() + amount_2, + "Since it's the same period, stake amount must carry over from the previous entry." + ); + assert!( + !contract_stake.staked.is_empty(), + "staked should keep the old future entry" + ); + assert!(contract_stake.staked_future.is_some()); + + // 4th scenario - stake some more to the next era, but this time also bump the period. + let era_3 = era_2 + 3; + let stake_era_3 = era_3 + 1; + let period_2 = period_1 + 1; + let period_info_2 = PeriodInfo { + number: period_2, + subperiod: Subperiod::BuildAndEarn, + next_subperiod_start_era: 20, + }; + let amount_3 = 41; + + contract_stake.stake(amount_3, period_info_2, era_3); + assert!( + contract_stake.get(stake_era_1, period_1).is_none(), + "Old period must be removed." + ); + assert!( + contract_stake.get(stake_era_2, period_1).is_none(), + "Old period must be removed." + ); + let entry_3_1 = contract_stake.get(stake_era_3, period_2).unwrap(); + assert_eq!(entry_3_1.era, stake_era_3); + assert_eq!(entry_3_1.period, period_2); + assert_eq!( + entry_3_1.total(), + amount_3, + "No carry over from previous entry since period has changed." + ); + assert!( + contract_stake.staked.is_empty(), + "New period, all stakes should be reset so 'staked' should be empty." + ); + assert!(contract_stake.staked_future.is_some()); + + // 5th scenario - stake to the next era + let era_4 = era_3 + 1; + let stake_era_4 = era_4 + 1; + let amount_4 = 5; + contract_stake.stake(amount_4, period_info_2, era_4); + let entry_4_1 = contract_stake.get(stake_era_3, period_2).unwrap(); + let entry_4_2 = contract_stake.get(stake_era_4, period_2).unwrap(); + assert_eq!(entry_4_1, entry_3_1, "Old entry must remain unchanged."); + assert_eq!(entry_4_2.era, stake_era_4); + assert_eq!(entry_4_2.period, period_2); + assert_eq!(entry_4_2.total(), amount_3 + amount_4); + assert!( + !contract_stake.staked.is_empty(), + "staked should keep the old future entry" + ); + assert!(contract_stake.staked_future.is_some()); +} + +#[test] +fn contract_stake_amount_unstake_is_ok() { + let mut contract_stake = ContractStakeAmount::default(); + + // Prep action - create a stake entry + let era_1 = 2; + let period = 3; + let period_info = PeriodInfo { + number: period, + subperiod: Subperiod::Voting, + next_subperiod_start_era: 20, + }; + let stake_amount = 100; + contract_stake.stake(stake_amount, period_info, era_1); + + // 1st scenario - unstake in the same era + let amount_1 = 5; + contract_stake.unstake(amount_1, period_info, era_1); + assert_eq!( + contract_stake.total_staked_amount(period), + stake_amount - amount_1 + ); + assert_eq!( + contract_stake.staked_amount(period, Subperiod::Voting), + stake_amount - amount_1 + ); + assert!(contract_stake.staked.is_empty()); + assert!(contract_stake.staked_future.is_some()); + + // 2nd scenario - unstake in the next era + let period_info = PeriodInfo { + number: period, + subperiod: Subperiod::BuildAndEarn, + next_subperiod_start_era: 40, + }; + let era_2 = era_1 + 1; + + contract_stake.unstake(amount_1, period_info, era_2); + assert_eq!( + contract_stake.total_staked_amount(period), + stake_amount - amount_1 * 2 + ); + assert_eq!( + contract_stake.staked_amount(period, Subperiod::Voting), + stake_amount - amount_1 * 2 + ); + assert!( + !contract_stake.staked.is_empty(), + "future entry should be moved over to the current entry" + ); + assert!( + contract_stake.staked_future.is_none(), + "future entry should be cleaned up since it refers to the current era" + ); + + // 3rd scenario - bump up unstake eras by more than 1, entries should be aligned to the current era + let era_3 = era_2 + 3; + let amount_2 = 7; + contract_stake.unstake(amount_2, period_info, era_3); + assert_eq!( + contract_stake.total_staked_amount(period), + stake_amount - amount_1 * 2 - amount_2 + ); + assert_eq!( + contract_stake.staked_amount(period, Subperiod::Voting), + stake_amount - amount_1 * 2 - amount_2 + ); + assert_eq!( + contract_stake.staked.era, era_3, + "Should be aligned to the current era." + ); + assert!( + contract_stake.staked_future.is_none(), + "future enry should remain 'None'" + ); + + // 4th scenario - do a full unstake with existing future entry, expect a cleanup + contract_stake.stake(stake_amount, period_info, era_3); + contract_stake.unstake( + contract_stake.total_staked_amount(period), + period_info, + era_3, + ); + assert!(contract_stake.staked.is_empty()); + assert!(contract_stake.staked_future.is_none()); +} + +#[test] +fn era_reward_span_push_and_get_works() { + get_u32_type!(SpanLength, 8); + let mut era_reward_span = EraRewardSpan::::new(); + + // Sanity checks + assert!(era_reward_span.is_empty()); + assert!(era_reward_span.len().is_zero()); + assert!(era_reward_span.first_era().is_zero()); + assert!(era_reward_span.last_era().is_zero()); + + // Insert some values and verify state change + let era_1 = 5; + let era_reward_1 = EraReward { + staker_reward_pool: 23, + staked: 41, + dapp_reward_pool: 17, + }; + assert!(era_reward_span.push(era_1, era_reward_1).is_ok()); + assert_eq!(era_reward_span.len(), 1); + assert_eq!(era_reward_span.first_era(), era_1); + assert_eq!(era_reward_span.last_era(), era_1); + + // Insert another value and verify state change + let era_2 = era_1 + 1; + let era_reward_2 = EraReward { + staker_reward_pool: 37, + staked: 53, + dapp_reward_pool: 19, + }; + assert!(era_reward_span.push(era_2, era_reward_2).is_ok()); + assert_eq!(era_reward_span.len(), 2); + assert_eq!(era_reward_span.first_era(), era_1); + assert_eq!(era_reward_span.last_era(), era_2); + + // Get the values and verify they are as expected + assert_eq!(era_reward_span.get(era_1), Some(&era_reward_1)); + assert_eq!(era_reward_span.get(era_2), Some(&era_reward_2)); + + // Try and get the values outside of the span + assert!(era_reward_span + .get(era_reward_span.first_era() - 1) + .is_none()); + assert!(era_reward_span + .get(era_reward_span.last_era() + 1) + .is_none()); +} + +#[test] +fn era_reward_span_fails_when_expected() { + // Capacity is only 2 to make testing easier + get_u32_type!(SpanLength, 2); + let mut era_reward_span = EraRewardSpan::::new(); + + // Push first values to get started + let era_1 = 5; + let era_reward = EraReward { + staker_reward_pool: 23, + staked: 41, + dapp_reward_pool: 17, + }; + assert!(era_reward_span.push(era_1, era_reward).is_ok()); + + // Attempting to push incorrect era results in an error + for wrong_era in &[era_1 - 1, era_1, era_1 + 2] { + assert_eq!( + era_reward_span.push(*wrong_era, era_reward), + Err(EraRewardSpanError::InvalidEra) + ); + } + + // Pushing above capacity results in an error + let era_2 = era_1 + 1; + assert!(era_reward_span.push(era_2, era_reward).is_ok()); + let era_3 = era_2 + 1; + assert_eq!( + era_reward_span.push(era_3, era_reward), + Err(EraRewardSpanError::NoCapacity) + ); +} + +#[test] +fn tier_threshold_is_ok() { + let amount = 100; + + // Fixed TVL + let fixed_threshold = TierThreshold::FixedTvlAmount { amount }; + assert!(fixed_threshold.is_satisfied(amount)); + assert!(fixed_threshold.is_satisfied(amount + 1)); + assert!(!fixed_threshold.is_satisfied(amount - 1)); + + // Dynamic TVL + let dynamic_threshold = TierThreshold::DynamicTvlAmount { + amount, + minimum_amount: amount / 2, // not important + }; + assert!(dynamic_threshold.is_satisfied(amount)); + assert!(dynamic_threshold.is_satisfied(amount + 1)); + assert!(!dynamic_threshold.is_satisfied(amount - 1)); +} + +#[test] +fn tier_params_check_is_ok() { + // Prepare valid params + get_u32_type!(TiersNum, 3); + let params = TierParameters:: { + reward_portion: BoundedVec::try_from(vec![ + Permill::from_percent(60), + Permill::from_percent(30), + Permill::from_percent(10), + ]) + .unwrap(), + slot_distribution: BoundedVec::try_from(vec![ + Permill::from_percent(10), + Permill::from_percent(20), + Permill::from_percent(70), + ]) + .unwrap(), + tier_thresholds: BoundedVec::try_from(vec![ + TierThreshold::DynamicTvlAmount { + amount: 1000, + minimum_amount: 100, + }, + TierThreshold::DynamicTvlAmount { + amount: 100, + minimum_amount: 10, + }, + TierThreshold::FixedTvlAmount { amount: 10 }, + ]) + .unwrap(), + }; + assert!(params.is_valid()); + + // 1st scenario - sums are below 100%, and that is ok + let mut new_params = params.clone(); + new_params.reward_portion = BoundedVec::try_from(vec![ + Permill::from_percent(59), + Permill::from_percent(30), + Permill::from_percent(10), + ]) + .unwrap(); + new_params.slot_distribution = BoundedVec::try_from(vec![ + Permill::from_percent(10), + Permill::from_percent(19), + Permill::from_percent(70), + ]) + .unwrap(); + assert!(params.is_valid()); + + // 2nd scenario - reward portion is too much + let mut new_params = params.clone(); + new_params.reward_portion = BoundedVec::try_from(vec![ + Permill::from_percent(61), + Permill::from_percent(30), + Permill::from_percent(10), + ]) + .unwrap(); + assert!(!new_params.is_valid()); + + // 3rd scenario - tier distribution is too much + let mut new_params = params.clone(); + new_params.slot_distribution = BoundedVec::try_from(vec![ + Permill::from_percent(10), + Permill::from_percent(20), + Permill::from_percent(71), + ]) + .unwrap(); + assert!(!new_params.is_valid()); + + // 4th scenario - incorrect vector length + let mut new_params = params.clone(); + new_params.tier_thresholds = + BoundedVec::try_from(vec![TierThreshold::FixedTvlAmount { amount: 10 }]).unwrap(); + assert!(!new_params.is_valid()); +} + +#[test] +fn tier_configuration_basic_tests() { + // TODO: this should be expanded & improved later + get_u32_type!(TiersNum, 4); + let params = TierParameters:: { + reward_portion: BoundedVec::try_from(vec![ + Permill::from_percent(40), + Permill::from_percent(30), + Permill::from_percent(20), + Permill::from_percent(10), + ]) + .unwrap(), + slot_distribution: BoundedVec::try_from(vec![ + Permill::from_percent(10), + Permill::from_percent(20), + Permill::from_percent(30), + Permill::from_percent(40), + ]) + .unwrap(), + tier_thresholds: BoundedVec::try_from(vec![ + TierThreshold::DynamicTvlAmount { + amount: 1000, + minimum_amount: 800, + }, + TierThreshold::DynamicTvlAmount { + amount: 500, + minimum_amount: 350, + }, + TierThreshold::DynamicTvlAmount { + amount: 100, + minimum_amount: 70, + }, + TierThreshold::FixedTvlAmount { amount: 50 }, + ]) + .unwrap(), + }; + assert!(params.is_valid(), "Example params must be valid!"); + + // Create a configuration with some values + let init_config = TiersConfiguration:: { + number_of_slots: 100, + slots_per_tier: BoundedVec::try_from(vec![10, 20, 30, 40]).unwrap(), + reward_portion: params.reward_portion.clone(), + tier_thresholds: params.tier_thresholds.clone(), + }; + assert!(init_config.is_valid(), "Init config must be valid!"); + + // Create a new config, based on a new price + let high_price = FixedU64::from_rational(20, 100); // in production will be expressed in USD + let new_config = init_config.calculate_new(high_price, ¶ms); + assert!(new_config.is_valid()); + + let low_price = FixedU64::from_rational(1, 100); // in production will be expressed in USD + let new_config = init_config.calculate_new(low_price, ¶ms); + assert!(new_config.is_valid()); + + // TODO: expand tests, add more sanity checks (e.g. tier 3 requirement should never be lower than tier 4, etc.) +} + +#[test] +fn dapp_tier_rewards_basic_tests() { + get_u32_type!(NumberOfDApps, 8); + get_u32_type!(NumberOfTiers, 3); + + // Example dApps & rewards + let dapps = vec![ + DAppTier { + dapp_id: 1, + tier_id: Some(0), + }, + DAppTier { + dapp_id: 2, + tier_id: Some(0), + }, + DAppTier { + dapp_id: 3, + tier_id: Some(1), + }, + DAppTier { + dapp_id: 5, + tier_id: Some(1), + }, + DAppTier { + dapp_id: 6, + tier_id: Some(2), + }, + ]; + let tier_rewards = vec![300, 20, 1]; + let period = 2; + + let mut dapp_tier_rewards = DAppTierRewards::::new( + dapps.clone(), + tier_rewards.clone(), + period, + ) + .expect("Bounds are respected."); + + // 1st scenario - claim reward for a dApps + let tier_id = dapps[0].tier_id.unwrap(); + assert_eq!( + dapp_tier_rewards.try_claim(dapps[0].dapp_id), + Ok((tier_rewards[tier_id as usize], tier_id)) + ); + + let tier_id = dapps[3].tier_id.unwrap(); + assert_eq!( + dapp_tier_rewards.try_claim(dapps[3].dapp_id), + Ok((tier_rewards[tier_id as usize], tier_id)) + ); + + // 2nd scenario - try to claim already claimed reward + assert_eq!( + dapp_tier_rewards.try_claim(dapps[0].dapp_id), + Err(DAppTierError::RewardAlreadyClaimed), + "Cannot claim the same reward twice." + ); + + // 3rd scenario - claim for a dApp that is not in the list + assert_eq!( + dapp_tier_rewards.try_claim(4), + Err(DAppTierError::NoDAppInTiers), + "dApp doesn't exist in the list so no rewards can be claimed." + ); +} diff --git a/pallets/dapp-staking-v3/src/types.rs b/pallets/dapp-staking-v3/src/types.rs new file mode 100644 index 0000000000..cf70f0a6aa --- /dev/null +++ b/pallets/dapp-staking-v3/src/types.rs @@ -0,0 +1,1775 @@ +// 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 Module Types +//! +//! Contains various types, structs & enums used by the dApp staking implementation. +//! The main purpose of this is to abstract complexity away from the extrinsic call implementation, +//! and even more importantly to make the code more testable. +//! +//! # Overview +//! +//! The following is a high level overview of the implemented structs, enums & types. +//! For details, please refer to the documentation and code of each individual type. +//! +//! ## General Protocol Information +//! +//! * `EraNumber` - numeric Id of an era. +//! * `PeriodNumber` - numeric Id of a period. +//! * `Subperiod` - an enum describing which subperiod is active in the current period. +//! * `PeriodInfo` - contains information about the ongoing period, like period number, current subperiod and when will the current subperiod end. +//! * `PeriodEndInfo` - contains information about a finished past period, like the final era of the period, total amount staked & bonus reward pool. +//! * `ProtocolState` - contains the most general protocol state info: current era number, block when the era ends, ongoing period info, and whether protocol is in maintenance mode. +//! +//! ## DApp Information +//! +//! * `DAppId` - a compact unique numeric Id of a dApp. +//! * `DAppInfo` - contains general information about a dApp, like owner and reward beneficiary, Id and state. +//! * `ContractStakeAmount` - contains information about how much is staked on a particular contract. +//! +//! ## Staker Information +//! +//! * `UnlockingChunk` - describes some amount undergoing the unlocking process. +//! * `StakeAmount` - contains information about the staked amount in a particular era, and period. +//! * `AccountLedger` - keeps track of total locked & staked balance, unlocking chunks and number of stake entries. +//! * `SingularStakingInfo` - contains information about a particular staker's stake on a specific smart contract. Used to track loyalty. +//! +//! ## Era Information +//! +//! * `EraInfo` - contains information about the ongoing era, like how much is locked & staked. +//! * `EraReward` - contains information about a finished era, like reward pools and total staked amount. +//! * `EraRewardSpan` - a composite of multiple `EraReward` objects, used to describe a range of finished eras. +//! +//! ## Tier Information +//! +//! * `TierThreshold` - an enum describing tier entry thresholds. +//! * `TierParameters` - contains static information about tiers, like init thresholds, reward & slot distribution. +//! * `TiersConfiguration` - contains dynamic information about tiers, derived from `TierParameters` and onchain data. +//! * `DAppTier` - a compact struct describing a dApp's tier. +//! * `DAppTierRewards` - composite of `DAppTier` objects, describing the entire reward distribution for a particular era. +//! + +use frame_support::{pallet_prelude::*, BoundedVec}; +use parity_scale_codec::{Decode, Encode}; +use sp_arithmetic::fixed_point::FixedU64; +use sp_runtime::{ + traits::{CheckedAdd, UniqueSaturatedInto, Zero}, + FixedPointNumber, Permill, Saturating, +}; +pub use sp_std::{fmt::Debug, vec::Vec}; + +use astar_primitives::{Balance, BlockNumber}; + +use crate::pallet::Config; + +// Convenience type for `AccountLedger` usage. +pub type AccountLedgerFor = AccountLedger<::MaxUnlockingChunks>; + +// Convenience type for `DAppTierRewards` usage. +pub type DAppTierRewardsFor = + DAppTierRewards<::MaxNumberOfContracts, ::NumberOfTiers>; + +// Convenience type for `EraRewardSpan` usage. +pub type EraRewardSpanFor = EraRewardSpan<::EraRewardSpanLength>; + +// Convenience type for `DAppInfo` usage. +pub type DAppInfoFor = DAppInfo<::AccountId>; + +/// Era number type +pub type EraNumber = u32; +/// Period number type +pub type PeriodNumber = u32; +/// Dapp Id type +pub type DAppId = u16; +/// Tier Id type +pub type TierId = u8; + +/// Simple enum representing errors possible when using sparse bounded vector. +#[derive(Debug, PartialEq, Eq)] +pub enum AccountLedgerError { + /// Old or future era values cannot be added. + InvalidEra, + /// Bounded storage capacity exceeded. + NoCapacity, + /// Invalid period specified. + InvalidPeriod, + /// Stake amount is to large in respect to what's available. + UnavailableStakeFunds, + /// Unstake amount is to large in respect to what's staked. + UnstakeAmountLargerThanStake, + /// Nothing to claim. + NothingToClaim, + /// Rewards have already been claimed + AlreadyClaimed, + /// Attempt to crate the iterator failed due to incorrect data. + InvalidIterator, +} + +/// Distinct subperiods in dApp staking protocol. +#[derive(Encode, Decode, MaxEncodedLen, Clone, Copy, Debug, PartialEq, Eq, TypeInfo)] +pub enum Subperiod { + /// Subperiod during which the focus is on voting. + Voting, + /// Subperiod during which dApps and stakers earn rewards. + BuildAndEarn, +} + +impl Subperiod { + /// Next subperiod, after `self`. + pub fn next(&self) -> Self { + match self { + Subperiod::Voting => Subperiod::BuildAndEarn, + Subperiod::BuildAndEarn => Subperiod::Voting, + } + } +} + +/// Info about the ongoing period. +#[derive(Encode, Decode, MaxEncodedLen, Clone, Copy, Debug, PartialEq, Eq, TypeInfo)] +pub struct PeriodInfo { + /// Period number. + #[codec(compact)] + pub number: PeriodNumber, + /// Subperiod type. + pub subperiod: Subperiod, + /// Era in which the new subperiod starts. + #[codec(compact)] + pub next_subperiod_start_era: EraNumber, +} + +impl PeriodInfo { + /// `true` if the provided era belongs to the next period, `false` otherwise. + /// It's only possible to provide this information correctly for the ongoing `BuildAndEarn` subperiod. + pub fn is_next_period(&self, era: EraNumber) -> bool { + self.subperiod == Subperiod::BuildAndEarn && self.next_subperiod_start_era <= era + } +} + +/// Information describing relevant information for a finished period. +#[derive(Encode, Decode, MaxEncodedLen, Clone, Copy, Debug, PartialEq, Eq, TypeInfo)] +pub struct PeriodEndInfo { + /// Bonus reward pool allocated for 'loyal' stakers + #[codec(compact)] + pub bonus_reward_pool: Balance, + /// Total amount staked (remaining) from the voting period. + #[codec(compact)] + pub total_vp_stake: Balance, + /// Final era, inclusive, in which the period ended. + #[codec(compact)] + pub final_era: EraNumber, +} + +/// Force types to speed up the next era, and even period. +#[derive(Encode, Decode, MaxEncodedLen, Clone, Copy, Debug, PartialEq, Eq, TypeInfo)] +pub enum ForcingType { + /// Force the next era to start. + Era, + /// Force the current subperiod to end, and new one to start. It will also force a new era to start. + Subperiod, +} + +/// 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. + #[codec(compact)] + pub next_era_start: BlockNumber, + /// Information about the ongoing period. + pub period_info: PeriodInfo, + /// `true` if pallet is in maintenance mode (disabled), `false` otherwise. + pub maintenance: bool, +} + +impl Default for ProtocolState { + fn default() -> Self { + Self { + era: 0, + next_era_start: 1, + period_info: PeriodInfo { + number: 0, + subperiod: Subperiod::Voting, + next_subperiod_start_era: 2, + }, + maintenance: false, + } + } +} + +impl ProtocolState { + /// Current subperiod. + pub fn subperiod(&self) -> Subperiod { + self.period_info.subperiod + } + + /// Current period number. + pub fn period_number(&self) -> PeriodNumber { + self.period_info.number + } + + /// Ending era of current period + pub fn next_subperiod_start_era(&self) -> EraNumber { + self.period_info.next_subperiod_start_era + } + + /// Checks whether a new era should be triggered, based on the provided _current_ block number argument + /// or possibly other protocol state parameters. + pub fn is_new_era(&self, now: BlockNumber) -> bool { + self.next_era_start <= now + } + + /// Triggers the next subperiod, updating appropriate parameters. + pub fn advance_to_next_subperiod( + &mut self, + next_subperiod_start_era: EraNumber, + next_era_start: BlockNumber, + ) { + let period_number = if self.subperiod() == Subperiod::BuildAndEarn { + self.period_number().saturating_add(1) + } else { + self.period_number() + }; + + self.period_info = PeriodInfo { + number: period_number, + subperiod: self.subperiod().next(), + next_subperiod_start_era, + }; + self.next_era_start = next_era_start; + } +} + +/// 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, +} + +impl DAppInfo { + /// Reward destination account for this dApp. + pub fn reward_beneficiary(&self) -> &AccountId { + match &self.reward_destination { + Some(account_id) => account_id, + None => &self.owner, + } + } + + /// `true` if dApp is registered, `false` otherwise. + pub fn is_registered(&self) -> bool { + self.state == DAppState::Registered + } +} + +/// How much was unlocked in some block. +#[derive(Encode, Decode, MaxEncodedLen, Clone, Copy, Debug, PartialEq, Eq, TypeInfo)] +pub struct UnlockingChunk { + /// Amount undergoing the unlocking period. + #[codec(compact)] + pub amount: Balance, + /// Block in which the unlocking period is finished for this chunk. + #[codec(compact)] + pub unlock_block: BlockNumber, +} + +impl Default for UnlockingChunk { + fn default() -> Self { + Self { + amount: Balance::zero(), + unlock_block: BlockNumber::zero(), + } + } +} + +/// General info about an account's lock & stakes. +/// +/// ## Overview +/// +/// The most complex part about this type are the `staked` and `staked_future` fields. +/// To understand why the two fields exist and how they are used, it's important to consider some facts: +/// * when an account _stakes_, the staked amount is only eligible for rewards from the next era +/// * all stakes are reset when a period ends - but this is done in a lazy fashion, account ledgers aren't directly updated +/// * `stake` and `unstake` operations are allowed only if the account has claimed all pending rewards +/// +/// In order to keep track of current era stake, and _next era_ stake, two fields are needed. +/// Since it's not allowed to stake/unstake if there are pending rewards, it's guaranteed that the `staked` and `staked_future` eras are **always consecutive**. +/// In order to understand if _stake_ is still valid, it's enough to check the `period` field of either `staked` or `staked_future`. +/// +/// ## Example +/// +/// ### Scenario 1 +/// +/// * current era is **20**, and current period is **1** +/// * `staked` is equal to: `{ voting: 100, build_and_earn: 50, era: 5, period: 1 }` +/// * `staked_future` is equal to: `{ voting: 100, build_and_earn: 100, era: 6, period: 1 }` +/// +/// The correct way to interpret this is: +/// * account had staked **150** in total in era 5 +/// * account had increased their stake to **200** in total in era 6 +/// * since then, era 6, account hadn't staked or unstaked anything or hasn't claimed any rewards +/// * since we're in era **20** and period is still **1**, the account's stake for eras **7** to **20** is still **200** +/// +/// ### Scenario 2 +/// +/// * current era is **20**, and current period is **1** +/// * `staked` is equal to: `{ voting: 0, build_and_earn: 0, era: 0, period: 0 }` +/// * `staked_future` is equal to: `{ voting: 0, build_and_earn: 350, era: 13, period: 1 }` +/// +/// The correct way to interpret this is: +/// * `staked` entry is _empty_ +/// * account had called `stake` during era 12, and staked **350** for the next era +/// * account hadn't staked, unstaked or claimed rewards since then +/// * since we're in era **20** and period is still **1**, the account's stake for eras **13** to **20** is still **350** +/// +/// ### Scenario 3 +/// +/// * current era is **30**, and current period is **2** +/// * period **1** ended after era **24**, and period **2** started in era **25** +/// * `staked` is equal to: `{ voting: 100, build_and_earn: 300, era: 20, period: 1 }` +/// * `staked_future` is equal to `None` +/// +/// The correct way to interpret this is: +/// * in era **20**, account had claimed rewards for the past eras, so only the `staked` entry remained +/// * since then, account hadn't staked, unstaked or claimed rewards +/// * period 1 ended in era **24**, which means that after that era, the `staked` entry is no longer valid +/// * account had staked **400** in total from era **20** up to era **24** (inclusive) +/// * account's stake in era **25** is **zero** +/// +#[derive( + Encode, + Decode, + MaxEncodedLen, + RuntimeDebugNoBound, + PartialEqNoBound, + EqNoBound, + CloneNoBound, + TypeInfo, +)] +#[scale_info(skip_type_params(UnlockingLen))] +pub struct AccountLedger> { + /// How much active locked amount an account has. This can be used for staking. + #[codec(compact)] + pub locked: Balance, + /// Vector of all the unlocking chunks. This is also considered _locked_ but cannot be used for staking. + pub unlocking: BoundedVec, + /// Primary field used to store how much was staked in a particular era. + pub staked: StakeAmount, + /// Secondary field used to store 'stake' information for the 'next era'. + /// This is needed since stake amount is only applicable from the next era after it's been staked. + /// + /// Both `stake` and `staked_future` must ALWAYS refer to the same period. + /// If `staked_future` is `Some`, it will always be **EXACTLY** one era after the `staked` field era. + pub staked_future: Option, + /// Number of contract stake entries in storage. + #[codec(compact)] + pub contract_stake_count: u32, +} + +impl Default for AccountLedger +where + UnlockingLen: Get, +{ + fn default() -> Self { + Self { + locked: Balance::zero(), + unlocking: BoundedVec::default(), + staked: StakeAmount::default(), + staked_future: None, + contract_stake_count: Zero::zero(), + } + } +} + +impl AccountLedger +where + UnlockingLen: Get, +{ + /// Empty if no locked/unlocking/staked info exists. + pub fn is_empty(&self) -> bool { + self.locked.is_zero() + && self.unlocking.is_empty() + && self.staked.total().is_zero() + && self.staked_future.is_none() + } + + /// Returns active locked amount. + /// If `zero`, means that associated account hasn't got any active locked funds. + /// + /// It is possible that some funds are undergoing the unlocking period, but they aren't considered active in that case. + pub fn active_locked_amount(&self) -> Balance { + self.locked + } + + /// Returns unlocking amount. + /// If `zero`, means that associated account hasn't got any unlocking chunks. + pub fn unlocking_amount(&self) -> Balance { + self.unlocking.iter().fold(Balance::zero(), |sum, chunk| { + sum.saturating_add(chunk.amount) + }) + } + + /// Total locked amount by the user. + /// Includes both active locked amount & unlocking amount. + pub fn total_locked_amount(&self) -> Balance { + self.active_locked_amount() + .saturating_add(self.unlocking_amount()) + } + + /// Adds the specified amount to the total locked amount. + pub fn add_lock_amount(&mut self, amount: Balance) { + self.locked.saturating_accrue(amount); + } + + /// Subtracts the specified amount of the total locked amount. + pub fn subtract_lock_amount(&mut self, amount: Balance) { + self.locked.saturating_reduce(amount); + } + + /// Adds the specified amount to the unlocking chunks. + /// + /// If entry for the specified block already exists, it's updated. + /// + /// If entry for the specified block 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_unlocking_chunk( + &mut self, + amount: Balance, + unlock_block: BlockNumber, + ) -> Result<(), AccountLedgerError> { + if amount.is_zero() { + return Ok(()); + } + + let idx = self + .unlocking + .binary_search_by(|chunk| chunk.unlock_block.cmp(&unlock_block)); + + match idx { + Ok(idx) => { + self.unlocking[idx].amount.saturating_accrue(amount); + } + Err(idx) => { + let new_unlocking_chunk = UnlockingChunk { + amount, + unlock_block, + }; + self.unlocking + .try_insert(idx, new_unlocking_chunk) + .map_err(|_| AccountLedgerError::NoCapacity)?; + } + } + + Ok(()) + } + + /// Amount available for unlocking. + pub fn unlockable_amount(&self, current_period: PeriodNumber) -> Balance { + self.active_locked_amount() + .saturating_sub(self.staked_amount(current_period)) + } + + /// Claims all of the fully unlocked chunks, and returns the total claimable amount. + pub fn claim_unlocked(&mut self, current_block_number: BlockNumber) -> Balance { + let mut total = Balance::zero(); + + self.unlocking.retain(|chunk| { + if chunk.unlock_block <= current_block_number { + total.saturating_accrue(chunk.amount); + false + } else { + true + } + }); + + total + } + + /// Consumes all of the unlocking chunks, and returns the total amount being unlocked. + pub fn consume_unlocking_chunks(&mut self) -> Balance { + let amount = self.unlocking.iter().fold(Balance::zero(), |sum, chunk| { + sum.saturating_add(chunk.amount) + }); + self.unlocking = Default::default(); + + amount + } + + /// Amount that is available for staking. + /// + /// This is equal to the total active locked amount, minus the staked amount already active. + pub fn stakeable_amount(&self, active_period: PeriodNumber) -> Balance { + self.active_locked_amount() + .saturating_sub(self.staked_amount(active_period)) + } + + /// Amount that is staked, in respect to currently active period. + pub fn staked_amount(&self, active_period: PeriodNumber) -> Balance { + // First check the 'future' entry, afterwards check the 'first' entry + match self.staked_future { + Some(stake_amount) if stake_amount.period == active_period => stake_amount.total(), + _ => match self.staked { + stake_amount if stake_amount.period == active_period => stake_amount.total(), + _ => Balance::zero(), + }, + } + } + + /// How much is staked for the specified subperiod, in respect to the specified era. + pub fn staked_amount_for_type(&self, subperiod: Subperiod, period: PeriodNumber) -> Balance { + // First check the 'future' entry, afterwards check the 'first' entry + match self.staked_future { + Some(stake_amount) if stake_amount.period == period => stake_amount.for_type(subperiod), + _ => match self.staked { + stake_amount if stake_amount.period == period => stake_amount.for_type(subperiod), + _ => Balance::zero(), + }, + } + } + + /// Check for stake/unstake operation era & period arguments. + /// + /// Ensures that the provided era & period are valid according to the current ledger state. + fn stake_unstake_argument_check( + &self, + current_era: EraNumber, + current_period_info: &PeriodInfo, + ) -> Result<(), AccountLedgerError> { + if !self.staked.is_empty() { + // In case entry for the current era exists, it must match the era exactly. + if self.staked.era != current_era { + return Err(AccountLedgerError::InvalidEra); + } + if self.staked.period != current_period_info.number { + return Err(AccountLedgerError::InvalidPeriod); + } + // In case it doesn't (i.e. first time staking), then the future era must either be the current or the next era. + } else if let Some(stake_amount) = self.staked_future { + if stake_amount.era != current_era.saturating_add(1) && stake_amount.era != current_era + { + return Err(AccountLedgerError::InvalidEra); + } + if stake_amount.period != current_period_info.number { + return Err(AccountLedgerError::InvalidPeriod); + } + } + Ok(()) + } + + /// Adds the specified amount to total staked amount, if possible. + /// + /// Staking can only be done for the ongoing period, and era. + /// 1. The `period` requirement enforces staking in the ongoing period. + /// 2. The `era` requirement enforces staking in the ongoing era. + /// + /// The 2nd condition is needed to prevent stakers from building a significant history of stakes, + /// without claiming the rewards. So if a historic era exists as an entry, stakers will first need to claim + /// the pending rewards, before they can stake again. + /// + /// Additionally, the staked amount must not exceed what's available for staking. + pub fn add_stake_amount( + &mut self, + amount: Balance, + current_era: EraNumber, + current_period_info: PeriodInfo, + ) -> Result<(), AccountLedgerError> { + if amount.is_zero() { + return Ok(()); + } + + self.stake_unstake_argument_check(current_era, ¤t_period_info)?; + + if self.stakeable_amount(current_period_info.number) < amount { + return Err(AccountLedgerError::UnavailableStakeFunds); + } + + // Update existing entry if it exists, otherwise create it. + match self.staked_future.as_mut() { + Some(stake_amount) => { + stake_amount.add(amount, current_period_info.subperiod); + } + None => { + let mut stake_amount = self.staked; + stake_amount.era = current_era.saturating_add(1); + stake_amount.period = current_period_info.number; + stake_amount.add(amount, current_period_info.subperiod); + self.staked_future = Some(stake_amount); + } + } + + Ok(()) + } + + /// Subtracts the specified amount from the total staked amount, if possible. + /// + /// Unstake can only be called if the entry for the current era exists. + /// In case historic entry exists, rewards first need to be claimed, before unstaking is possible. + /// Similar as with stake functionality, this is to prevent staker from building a significant history of stakes. + pub fn unstake_amount( + &mut self, + amount: Balance, + current_era: EraNumber, + current_period_info: PeriodInfo, + ) -> Result<(), AccountLedgerError> { + if amount.is_zero() { + return Ok(()); + } + + self.stake_unstake_argument_check(current_era, ¤t_period_info)?; + + // User must be precise with their unstake amount. + if self.staked_amount(current_period_info.number) < amount { + return Err(AccountLedgerError::UnstakeAmountLargerThanStake); + } + + self.staked.subtract(amount, current_period_info.subperiod); + + // Convenience cleanup + if self.staked.is_empty() { + self.staked = Default::default(); + } + + if let Some(mut stake_amount) = self.staked_future { + stake_amount.subtract(amount, current_period_info.subperiod); + + self.staked_future = if stake_amount.is_empty() { + None + } else { + Some(stake_amount) + }; + } + + Ok(()) + } + + /// Period for which account has staking information or `None` if no staking information exists. + pub fn staked_period(&self) -> Option { + if self.staked.is_empty() { + self.staked_future.map(|stake_amount| stake_amount.period) + } else { + Some(self.staked.period) + } + } + + /// Earliest era for which the account has staking information or `None` if no staking information exists. + pub fn earliest_staked_era(&self) -> Option { + if self.staked.is_empty() { + self.staked_future.map(|stake_amount| stake_amount.era) + } else { + Some(self.staked.era) + } + } + + /// Cleanup staking information if it has expired. + /// + /// # Args + /// `valid_threshold_period` - last period for which entries can still be considered valid. + /// + /// `true` if any change was made, `false` otherwise. + pub fn maybe_cleanup_expired(&mut self, valid_threshold_period: PeriodNumber) -> bool { + match self.staked_period() { + Some(staked_period) if staked_period < valid_threshold_period => { + self.staked = Default::default(); + self.staked_future = None; + true + } + _ => false, + } + } + + /// 'Claim' rewards up to the specified era. + /// Returns an iterator over the `(era, amount)` pairs, where `amount` + /// describes the staked amount eligible for reward in the appropriate era. + /// + /// If `period_end` is provided, it's used to determine whether all applicable chunks have been claimed. + pub fn claim_up_to_era( + &mut self, + era: EraNumber, + period_end: Option, + ) -> Result { + // Main entry exists, but era isn't 'in history' + if !self.staked.is_empty() { + ensure!(era >= self.staked.era, AccountLedgerError::NothingToClaim); + } else if let Some(stake_amount) = self.staked_future { + // Future entry exists, but era isn't 'in history' + ensure!(era >= stake_amount.era, AccountLedgerError::NothingToClaim); + } + + // There are multiple options: + // 1. We only have future entry, no current entry + // 2. We have both current and future entry, but are only claiming 1 era + // 3. We have both current and future entry, and are claiming multiple eras + // 4. We only have current entry, no future entry + let (span, maybe_first) = if let Some(stake_amount) = self.staked_future { + if self.staked.is_empty() { + ((stake_amount.era, era, stake_amount.total()), None) + } else if self.staked.era == era { + ((era, era, self.staked.total()), None) + } else { + ( + (stake_amount.era, era, stake_amount.total()), + Some((self.staked.era, self.staked.total())), + ) + } + } else { + ((self.staked.era, era, self.staked.total()), None) + }; + + let result = EraStakePairIter::new(span, maybe_first) + .map_err(|_| AccountLedgerError::InvalidIterator)?; + + // Rollover future to 'current' stake amount + if let Some(stake_amount) = self.staked_future.take() { + self.staked = stake_amount; + } + self.staked.era = era.saturating_add(1); + + // Make sure to clean up the entries if all rewards for the period have been claimed. + match period_end { + Some(period_end_era) if era >= period_end_era => { + self.staked = Default::default(); + self.staked_future = None; + } + _ => (), + } + + Ok(result) + } +} + +/// Helper internal struct for iterating over `(era, stake amount)` pairs. +/// +/// Due to how `AccountLedger` is implemented, few scenarios are possible when claming rewards: +/// +/// 1. `staked` has some amount, `staked_future` is `None` +/// * `maybe_first` is `None`, span describes the entire range +/// 2. `staked` has nothing, `staked_future` is some and has some amount +/// * `maybe_first` is `None`, span describes the entire range +/// 3. `staked` has some amount, `staked_future` has some amount +/// * `maybe_first` is `Some` and covers the `staked` entry, span describes the entire range except the first pair. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct EraStakePairIter { + /// Denotes whether the first entry is different than the others. + maybe_first: Option<(EraNumber, Balance)>, + /// Starting era of the span. + start_era: EraNumber, + /// Ending era of the span. + end_era: EraNumber, + /// Staked amount in the span. + amount: Balance, +} + +impl EraStakePairIter { + /// Create new iterator struct for `(era, staked amount)` pairs. + pub fn new( + span: (EraNumber, EraNumber, Balance), + maybe_first: Option<(EraNumber, Balance)>, + ) -> Result { + // First era must be smaller or equal to the last era. + if span.0 > span.1 { + return Err(()); + } + // If 'maybe_first' is defined, it must exactly match the `span.0 - 1` era value. + match maybe_first { + Some((era, _)) if span.0.saturating_sub(era) != 1 => { + return Err(()); + } + _ => (), + } + + Ok(Self { + maybe_first, + start_era: span.0, + end_era: span.1, + amount: span.2, + }) + } +} + +impl Iterator for EraStakePairIter { + type Item = (EraNumber, Balance); + + fn next(&mut self) -> Option { + // Fist cover the scenario where we have a unique first value + if let Some((era, amount)) = self.maybe_first.take() { + return Some((era, amount)); + } + + // Afterwards, just keep returning the same amount for different eras + if self.start_era <= self.end_era { + let value = (self.start_era, self.amount); + self.start_era.saturating_inc(); + return Some(value); + } else { + None + } + } +} + +/// Describes stake amount in an particular era/period. +#[derive(Encode, Decode, MaxEncodedLen, Copy, Clone, Debug, PartialEq, Eq, TypeInfo, Default)] +pub struct StakeAmount { + /// Amount of staked funds accounting for the voting period. + #[codec(compact)] + pub voting: Balance, + /// Amount of staked funds accounting for the build&earn period. + #[codec(compact)] + pub build_and_earn: Balance, + /// Era to which this stake amount refers to. + #[codec(compact)] + pub era: EraNumber, + /// Period to which this stake amount refers to. + #[codec(compact)] + pub period: PeriodNumber, +} + +impl StakeAmount { + /// `true` if nothing is staked, `false` otherwise + pub fn is_empty(&self) -> bool { + self.voting.is_zero() && self.build_and_earn.is_zero() + } + + /// Total amount staked in both subperiods. + pub fn total(&self) -> Balance { + self.voting.saturating_add(self.build_and_earn) + } + + /// Amount staked for the specified subperiod. + pub fn for_type(&self, subperiod: Subperiod) -> Balance { + match subperiod { + Subperiod::Voting => self.voting, + Subperiod::BuildAndEarn => self.build_and_earn, + } + } + + /// Stake the specified `amount` for the specified `subperiod`. + pub fn add(&mut self, amount: Balance, subperiod: Subperiod) { + match subperiod { + Subperiod::Voting => self.voting.saturating_accrue(amount), + Subperiod::BuildAndEarn => self.build_and_earn.saturating_accrue(amount), + } + } + + /// Unstake the specified `amount` for the specified `subperiod`. + /// + /// In case subperiod is `Voting`, the amount is subtracted from the voting period. + /// + /// In case subperiod is `Build&Earn`, the amount is first subtracted from the + /// build&earn amount, and any rollover is subtracted from the voting period. + pub fn subtract(&mut self, amount: Balance, subperiod: Subperiod) { + match subperiod { + Subperiod::Voting => self.voting.saturating_reduce(amount), + Subperiod::BuildAndEarn => { + if self.build_and_earn >= amount { + self.build_and_earn.saturating_reduce(amount); + } else { + // Rollover from build&earn to voting, is guaranteed to be larger than zero due to previous check + // E.g. voting = 10, build&earn = 5, amount = 7 + // underflow = build&earn - amount = 5 - 7 = -2 + // voting = 10 - 2 = 8 + // build&earn = 0 + let remainder = amount.saturating_sub(self.build_and_earn); + self.build_and_earn = Balance::zero(); + self.voting.saturating_reduce(remainder); + } + } + } + } +} + +/// Info about current era, including the rewards, how much is locked, unlocking, etc. +#[derive(Encode, Decode, MaxEncodedLen, Copy, Clone, Debug, PartialEq, Eq, TypeInfo, Default)] +pub struct EraInfo { + /// How much balance is locked in dApp staking. + /// Does not include the amount that is undergoing the unlocking period. + #[codec(compact)] + pub total_locked: Balance, + /// How much balance is undergoing unlocking process. + /// This amount still counts into locked amount. + #[codec(compact)] + pub unlocking: Balance, + /// Stake amount valid for the ongoing era. + pub current_stake_amount: StakeAmount, + /// Stake amount valid from the next era. + pub next_stake_amount: StakeAmount, +} + +impl EraInfo { + /// Update with the new amount that has just been locked. + pub fn add_locked(&mut self, amount: Balance) { + self.total_locked.saturating_accrue(amount); + } + + /// Update with the new amount that has just started undergoing the unlocking period. + pub fn unlocking_started(&mut self, amount: Balance) { + self.total_locked.saturating_reduce(amount); + self.unlocking.saturating_accrue(amount); + } + + /// Update with the new amount that has been removed from unlocking. + pub fn unlocking_removed(&mut self, amount: Balance) { + self.unlocking.saturating_reduce(amount); + } + + /// Add the specified `amount` to the appropriate stake amount, based on the `Subperiod`. + pub fn add_stake_amount(&mut self, amount: Balance, subperiod: Subperiod) { + self.next_stake_amount.add(amount, subperiod); + } + + /// Subtract the specified `amount` from the appropriate stake amount, based on the `Subperiod`. + pub fn unstake_amount(&mut self, amount: Balance, subperiod: Subperiod) { + self.current_stake_amount.subtract(amount, subperiod); + self.next_stake_amount.subtract(amount, subperiod); + } + + /// Total staked amount in this era. + pub fn total_staked_amount(&self) -> Balance { + self.current_stake_amount.total() + } + + /// Staked amount of specified `type` in this era. + pub fn staked_amount(&self, subperiod: Subperiod) -> Balance { + self.current_stake_amount.for_type(subperiod) + } + + /// Total staked amount in the next era. + pub fn total_staked_amount_next_era(&self) -> Balance { + self.next_stake_amount.total() + } + + /// Staked amount of specifeid `type` in the next era. + pub fn staked_amount_next_era(&self, subperiod: Subperiod) -> Balance { + self.next_stake_amount.for_type(subperiod) + } + + /// Updates `Self` to reflect the transition to the next era. + /// + /// ## Args + /// `next_subperiod` - `None` if no subperiod change, `Some(type)` if `type` is starting from the next era. + pub fn migrate_to_next_era(&mut self, next_subperiod: Option) { + match next_subperiod { + // If next era marks start of new voting period period, it means we're entering a new period + Some(Subperiod::Voting) => { + for stake_amount in [&mut self.current_stake_amount, &mut self.next_stake_amount] { + stake_amount.voting = Zero::zero(); + stake_amount.build_and_earn = Zero::zero(); + stake_amount.era.saturating_inc(); + stake_amount.period.saturating_inc(); + } + } + Some(Subperiod::BuildAndEarn) | None => { + self.current_stake_amount = self.next_stake_amount; + self.next_stake_amount.era.saturating_inc(); + } + }; + } +} + +/// Information about how much a particular staker staked on a particular smart contract. +/// +/// Keeps track of amount staked in the 'voting period', as well as 'build&earn period'. +#[derive(Encode, Decode, MaxEncodedLen, Copy, Clone, Debug, PartialEq, Eq, TypeInfo, Default)] +pub struct SingularStakingInfo { + /// Staked amount + staked: StakeAmount, + /// Indicates whether a staker is a loyal staker or not. + loyal_staker: bool, +} + +impl SingularStakingInfo { + /// Creates new instance of the struct. + /// + /// ## Args + /// + /// `period` - period number for which this entry is relevant. + /// `subperiod` - subperiod during which this entry is created. + pub fn new(period: PeriodNumber, subperiod: Subperiod) -> Self { + Self { + staked: StakeAmount { + voting: Balance::zero(), + build_and_earn: Balance::zero(), + era: 0, + period, + }, + // Loyalty staking is only possible if stake is first made during the voting period. + loyal_staker: subperiod == Subperiod::Voting, + } + } + + /// Stake the specified amount on the contract, for the specified subperiod. + pub fn stake(&mut self, amount: Balance, current_era: EraNumber, subperiod: Subperiod) { + self.staked.add(amount, subperiod); + // Stake is only valid from the next era so we keep it consistent here + self.staked.era = current_era.saturating_add(1); + } + + /// Unstakes some of the specified amount from the contract. + /// + /// In case the `amount` being unstaked is larger than the amount staked in the `voting period`, + /// and `voting period` has passed, this will remove the _loyalty_ flag from the staker. + /// + /// Returns the amount that was unstaked from the `voting period` stake, and from the `build&earn period` stake. + pub fn unstake( + &mut self, + amount: Balance, + current_era: EraNumber, + subperiod: Subperiod, + ) -> (Balance, Balance) { + let snapshot = self.staked; + + self.staked.subtract(amount, subperiod); + // Keep the latest era for which the entry is valid + self.staked.era = self.staked.era.max(current_era); + + self.loyal_staker = self.loyal_staker + && (subperiod == Subperiod::Voting + || subperiod == Subperiod::BuildAndEarn && self.staked.voting == snapshot.voting); + + // Amount that was unstaked + ( + snapshot.voting.saturating_sub(self.staked.voting), + snapshot + .build_and_earn + .saturating_sub(self.staked.build_and_earn), + ) + } + + /// Total staked on the contract by the user. Both subperiod stakes are included. + pub fn total_staked_amount(&self) -> Balance { + self.staked.total() + } + + /// Returns amount staked in the specified period. + pub fn staked_amount(&self, subperiod: Subperiod) -> Balance { + self.staked.for_type(subperiod) + } + + /// If `true` staker has staked during voting period and has never reduced their sta + pub fn is_loyal(&self) -> bool { + self.loyal_staker + } + + /// Period for which this entry is relevant. + pub fn period_number(&self) -> PeriodNumber { + self.staked.period + } + + /// Era in which the entry was last time updated + pub fn era(&self) -> EraNumber { + self.staked.era + } + + /// `true` if no stake exists, `false` otherwise. + pub fn is_empty(&self) -> bool { + self.staked.is_empty() + } +} + +/// Composite type that holds information about how much was staked on a contract in up to two distinct eras. +/// +/// This is needed since 'stake' operation only makes the staked amount valid from the next era. +/// In a situation when `stake` is called in era `N`, the staked amount is valid from era `N+1`, hence the need for 'future' entry. +/// +/// **NOTE:** The 'future' entry term is only valid in the era when `stake` is called. It's possible contract stake isn't changed in consecutive eras, +/// so we might end up in a situation where era is `N + 10` but `staked` entry refers to era `N` and `staked_future` entry refers to era `N+1`. +/// This is still valid since these values are expected to be updated lazily. +#[derive(Encode, Decode, MaxEncodedLen, RuntimeDebug, PartialEq, Eq, Clone, TypeInfo, Default)] +pub struct ContractStakeAmount { + /// Staked amount in the 'current' era. + pub staked: StakeAmount, + /// Staked amount in the next or 'future' era. + pub staked_future: Option, +} + +impl ContractStakeAmount { + /// `true` if series is empty, `false` otherwise. + pub fn is_empty(&self) -> bool { + self.staked.is_empty() && self.staked_future.is_none() + } + + /// Latest period for which stake entry exists. + pub fn latest_stake_period(&self) -> Option { + if let Some(stake_amount) = self.staked_future { + Some(stake_amount.period) + } else if !self.staked.is_empty() { + Some(self.staked.period) + } else { + None + } + } + + /// Latest era for which stake entry exists. + pub fn latest_stake_era(&self) -> Option { + if let Some(stake_amount) = self.staked_future { + Some(stake_amount.era) + } else if !self.staked.is_empty() { + Some(self.staked.era) + } else { + None + } + } + + /// Returns the `StakeAmount` type for the specified era & period, if it exists. + pub fn get(&self, era: EraNumber, period: PeriodNumber) -> Option { + let mut maybe_result = match (self.staked, self.staked_future) { + (_, Some(staked_future)) if staked_future.era <= era => { + if staked_future.period == period { + Some(staked_future) + } else { + None + } + } + (staked, _) if staked.era <= era && staked.period == period => Some(staked), + _ => None, + }; + + if let Some(result) = maybe_result.as_mut() { + result.era = era; + } + + maybe_result + } + + /// Total staked amount on the contract, in the active period. + pub fn total_staked_amount(&self, active_period: PeriodNumber) -> Balance { + match (self.staked, self.staked_future) { + (_, Some(staked_future)) if staked_future.period == active_period => { + staked_future.total() + } + (staked, _) if staked.period == active_period => staked.total(), + _ => Balance::zero(), + } + } + + /// Staked amount on the contract, for specified subperiod, in the active period. + pub fn staked_amount(&self, active_period: PeriodNumber, subperiod: Subperiod) -> Balance { + match (self.staked, self.staked_future) { + (_, Some(staked_future)) if staked_future.period == active_period => { + staked_future.for_type(subperiod) + } + (staked, _) if staked.period == active_period => staked.for_type(subperiod), + _ => Balance::zero(), + } + } + + /// Stake the specified `amount` on the contract, for the specified `subperiod` and `era`. + pub fn stake(&mut self, amount: Balance, period_info: PeriodInfo, current_era: EraNumber) { + let stake_era = current_era.saturating_add(1); + + match self.staked_future.as_mut() { + // Future entry matches the era, just updated it and return + Some(stake_amount) if stake_amount.era == stake_era => { + stake_amount.add(amount, period_info.subperiod); + return; + } + // Future entry has older era, but periods match so overwrite the 'current' entry with it + Some(stake_amount) if stake_amount.period == period_info.number => { + self.staked = *stake_amount; + } + // Otherwise do nothing + _ => (), + } + + // Prepare new entry + let mut new_entry = match self.staked { + // 'current' entry period matches so we use it as base for the new entry + stake_amount if stake_amount.period == period_info.number => stake_amount, + // otherwise just create a dummy new entry + _ => Default::default(), + }; + new_entry.add(amount, period_info.subperiod); + new_entry.era = stake_era; + new_entry.period = period_info.number; + + self.staked_future = Some(new_entry); + + // Convenience cleanup + if self.staked.period < period_info.number { + self.staked = Default::default(); + } + } + + /// Unstake the specified `amount` from the contract, for the specified `subperiod` and `era`. + pub fn unstake(&mut self, amount: Balance, period_info: PeriodInfo, current_era: EraNumber) { + // First align entries - we only need to keep track of the current era, and the next one + match self.staked_future { + // Future entry exists, but it covers current or older era. + Some(stake_amount) + if stake_amount.era <= current_era && stake_amount.period == period_info.number => + { + self.staked = stake_amount; + self.staked.era = current_era; + self.staked_future = None; + } + _ => (), + } + + // Current entry is from the right period, but older era. Shift it to the current era. + if self.staked.era < current_era && self.staked.period == period_info.number { + self.staked.era = current_era; + } + + // Subtract both amounts + self.staked.subtract(amount, period_info.subperiod); + if let Some(stake_amount) = self.staked_future.as_mut() { + stake_amount.subtract(amount, period_info.subperiod); + } + + // Conevnience cleanup + if self.staked.is_empty() { + self.staked = Default::default(); + } + if let Some(stake_amount) = self.staked_future { + if stake_amount.is_empty() { + self.staked_future = None; + } + } + } +} + +/// Information required for staker reward payout for a particular era. +#[derive(Encode, Decode, MaxEncodedLen, Clone, Copy, Debug, PartialEq, Eq, TypeInfo, Default)] +pub struct EraReward { + /// Total reward pool for staker rewards + #[codec(compact)] + pub staker_reward_pool: Balance, + /// Total amount which was staked at the end of an era + #[codec(compact)] + pub staked: Balance, + /// Total reward pool for dApp rewards + #[codec(compact)] + pub dapp_reward_pool: Balance, +} + +#[derive(Encode, Decode, MaxEncodedLen, Clone, Copy, Debug, PartialEq, Eq, TypeInfo)] +pub enum EraRewardSpanError { + /// Provided era is invalid. Must be exactly one era after the last one in the span. + InvalidEra, + /// Span has no more capacity for additional entries. + NoCapacity, +} + +/// Used to efficiently store era span information. +#[derive( + Encode, + Decode, + MaxEncodedLen, + RuntimeDebugNoBound, + PartialEqNoBound, + EqNoBound, + CloneNoBound, + TypeInfo, +)] +#[scale_info(skip_type_params(SL))] +pub struct EraRewardSpan> { + /// Span of EraRewardInfo entries. + span: BoundedVec, + /// The first era in the span. + #[codec(compact)] + first_era: EraNumber, + /// The final era in the span. + #[codec(compact)] + last_era: EraNumber, +} + +impl EraRewardSpan +where + SL: Get, +{ + /// Create new instance of the `EraRewardSpan` + pub fn new() -> Self { + Self { + span: Default::default(), + first_era: 0, + last_era: 0, + } + } + + /// First era covered in the span. + pub fn first_era(&self) -> EraNumber { + self.first_era + } + + /// Last era covered in the span + pub fn last_era(&self) -> EraNumber { + self.last_era + } + + /// Span length. + pub fn len(&self) -> usize { + self.span.len() + } + + /// `true` if span is empty, `false` otherwise. + pub fn is_empty(&self) -> bool { + self.span.is_empty() + } + + /// Push new `EraReward` entry into the span. + /// If span is non-empty, the provided `era` must be exactly one era after the last one in the span. + pub fn push( + &mut self, + era: EraNumber, + era_reward: EraReward, + ) -> Result<(), EraRewardSpanError> { + // First entry, no checks, just set eras to the provided value. + if self.span.is_empty() { + self.first_era = era; + self.last_era = era; + self.span + .try_push(era_reward) + // Defensive check, should never happen since it means capacity is 'zero'. + .map_err(|_| EraRewardSpanError::NoCapacity) + } else { + // Defensive check to ensure next era rewards refers to era after the last one in the span. + if era != self.last_era.saturating_add(1) { + return Err(EraRewardSpanError::InvalidEra); + } + + self.last_era = era; + self.span + .try_push(era_reward) + .map_err(|_| EraRewardSpanError::NoCapacity) + } + } + + /// Get the `EraReward` entry for the specified `era`. + /// + /// In case `era` is not covered by the span, `None` is returned. + pub fn get(&self, era: EraNumber) -> Option<&EraReward> { + match era.checked_sub(self.first_era()) { + Some(index) => self.span.get(index as usize), + None => None, + } + } +} + +/// Description of tier entry requirement. +#[derive(Encode, Decode, MaxEncodedLen, Copy, Clone, Debug, PartialEq, Eq, TypeInfo)] +#[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] +pub enum TierThreshold { + /// Entry into tier is mandated by minimum amount of staked funds. + /// Value is fixed, and is not expected to change in between periods. + FixedTvlAmount { amount: Balance }, + /// Entry into tier is mandated by minimum amount of staked funds. + /// Value is expected to dynamically change in-between periods, depending on the system parameters. + /// The `amount` should never go below the `minimum_amount`. + DynamicTvlAmount { + amount: Balance, + minimum_amount: Balance, + }, +} + +impl TierThreshold { + /// Used to check if stake amount satisfies the threshold or not. + pub fn is_satisfied(&self, stake: Balance) -> bool { + match self { + Self::FixedTvlAmount { amount } => stake >= *amount, + Self::DynamicTvlAmount { amount, .. } => stake >= *amount, + } + } + + /// Return threshold for the tier. + pub fn threshold(&self) -> Balance { + match self { + Self::FixedTvlAmount { amount } => *amount, + Self::DynamicTvlAmount { amount, .. } => *amount, + } + } +} + +/// Top level description of tier slot parameters used to calculate tier configuration. +#[derive( + Encode, + Decode, + MaxEncodedLen, + RuntimeDebugNoBound, + PartialEqNoBound, + EqNoBound, + CloneNoBound, + TypeInfo, +)] +#[scale_info(skip_type_params(NT))] +pub struct TierParameters> { + /// Reward distribution per tier, in percentage. + /// First entry refers to the first tier, and so on. + /// The sum of all values must not exceed 100%. + /// In case it is less, portion of rewards will never be distributed. + pub reward_portion: BoundedVec, + /// Distribution of number of slots per tier, in percentage. + /// First entry refers to the first tier, and so on. + /// The sum of all values must not exceed 100%. + /// In case it is less, slot capacity will never be fully filled. + pub slot_distribution: BoundedVec, + /// Requirements for entry into each tier. + /// First entry refers to the first tier, and so on. + pub tier_thresholds: BoundedVec, +} + +impl> TierParameters { + /// Check if configuration is valid. + /// All vectors are expected to have exactly the amount of entries as `number_of_tiers`. + pub fn is_valid(&self) -> bool { + // Reward portions sum should not exceed 100%. + if self + .reward_portion + .iter() + .fold(Some(Permill::zero()), |acc, permill| match acc { + Some(acc) => acc.checked_add(permill), + None => None, + }) + .is_none() + { + return false; + } + + // Slot distribution sum should not exceed 100%. + if self + .slot_distribution + .iter() + .fold(Some(Permill::zero()), |acc, permill| match acc { + Some(acc) => acc.checked_add(permill), + None => None, + }) + .is_none() + { + return false; + } + + let number_of_tiers: usize = NT::get() as usize; + number_of_tiers == self.reward_portion.len() + && number_of_tiers == self.slot_distribution.len() + && number_of_tiers == self.tier_thresholds.len() + } +} + +impl> Default for TierParameters { + fn default() -> Self { + Self { + reward_portion: BoundedVec::default(), + slot_distribution: BoundedVec::default(), + tier_thresholds: BoundedVec::default(), + } + } +} + +/// Configuration of dApp tiers. +#[derive( + Encode, + Decode, + MaxEncodedLen, + RuntimeDebugNoBound, + PartialEqNoBound, + EqNoBound, + CloneNoBound, + TypeInfo, +)] +#[scale_info(skip_type_params(NT))] +pub struct TiersConfiguration> { + /// Total number of slots. + #[codec(compact)] + pub number_of_slots: u16, + /// Number of slots per tier. + /// First entry refers to the first tier, and so on. + pub slots_per_tier: BoundedVec, + /// Reward distribution per tier, in percentage. + /// First entry refers to the first tier, and so on. + /// The sum of all values must be exactly equal to 1. + pub reward_portion: BoundedVec, + /// Requirements for entry into each tier. + /// First entry refers to the first tier, and so on. + pub tier_thresholds: BoundedVec, +} + +impl> Default for TiersConfiguration { + fn default() -> Self { + Self { + number_of_slots: 0, + slots_per_tier: BoundedVec::default(), + reward_portion: BoundedVec::default(), + tier_thresholds: BoundedVec::default(), + } + } +} + +// Some TODOs: +// Some parts regarding tiers should be refactored. +// * There's no need to keep thresholds in two separate storage items since the calculation can always be done compared to the +// anchor value of 5 cents. This still needs to be checked & investigated, but it's worth a try. + +impl> TiersConfiguration { + /// Check if parameters are valid. + pub fn is_valid(&self) -> bool { + let number_of_tiers: usize = NT::get() as usize; + number_of_tiers == self.slots_per_tier.len() + // All vecs length must match number of tiers. + && number_of_tiers == self.reward_portion.len() + && number_of_tiers == self.tier_thresholds.len() + // Total number of slots must match the sum of slots per tier. + && self.slots_per_tier.iter().fold(0, |acc, x| acc + x) == self.number_of_slots + } + + /// Calculate new `TiersConfiguration`, based on the old settings, current native currency price and tier configuration. + pub fn calculate_new(&self, native_price: FixedU64, params: &TierParameters) -> Self { + // It must always be at least 1 slot. + let new_number_of_slots = Self::calculate_number_of_slots(native_price).max(1); + + // Calculate how much each tier gets slots. + let new_slots_per_tier: Vec = params + .slot_distribution + .clone() + .into_inner() + .iter() + .map(|percent| *percent * new_number_of_slots as u128) + .map(|x| x.unique_saturated_into()) + .collect(); + let new_slots_per_tier = + BoundedVec::::try_from(new_slots_per_tier).unwrap_or_default(); + + // Update tier thresholds. + // In case number of slots increase, we decrease thresholds required to enter the tier. + // In case number of slots decrease, we increase the threshold required to enter the tier. + // + // According to formula: %_threshold = (100% / (100% - delta_%_slots) - 1) * 100% + // + // where delta_%_slots is simply: (old_num_slots - new_num_slots) / old_num_slots + // + // When these entries are put into the threshold formula, we get: + // = 1 / ( 1 - (old_num_slots - new_num_slots) / old_num_slots ) - 1 + // = 1 / ( new / old) - 1 + // = old / new - 1 + // = (old - new) / new + // + // This number can be negative. In order to keep all operations in unsigned integer domain, + // formulas are adjusted like: + // + // 1. Number of slots has increased, threshold is expected to decrease + // %_threshold = (new_num_slots - old_num_slots) / new_num_slots + // new_threshold = old_threshold * (1 - %_threshold) + // + // 2. Number of slots has decreased, threshold is expected to increase + // %_threshold = (old_num_slots - new_num_slots) / new_num_slots + // new_threshold = old_threshold * (1 + %_threshold) + // + let new_tier_thresholds = if new_number_of_slots > self.number_of_slots { + let delta_threshold_decrease = FixedU64::from_rational( + (new_number_of_slots - self.number_of_slots).into(), + new_number_of_slots.into(), + ); + + let mut new_tier_thresholds = self.tier_thresholds.clone(); + new_tier_thresholds + .iter_mut() + .for_each(|threshold| match threshold { + TierThreshold::DynamicTvlAmount { + amount, + minimum_amount, + } => { + *amount = amount + .saturating_sub(delta_threshold_decrease.saturating_mul_int(*amount)); + *amount = *amount.max(minimum_amount); + } + _ => (), + }); + + new_tier_thresholds + } else if new_number_of_slots < self.number_of_slots { + let delta_threshold_increase = FixedU64::from_rational( + (self.number_of_slots - new_number_of_slots).into(), + new_number_of_slots.into(), + ); + + let mut new_tier_thresholds = self.tier_thresholds.clone(); + new_tier_thresholds + .iter_mut() + .for_each(|threshold| match threshold { + TierThreshold::DynamicTvlAmount { amount, .. } => { + *amount = amount + .saturating_add(delta_threshold_increase.saturating_mul_int(*amount)); + } + _ => (), + }); + + new_tier_thresholds + } else { + self.tier_thresholds.clone() + }; + + Self { + number_of_slots: new_number_of_slots, + slots_per_tier: new_slots_per_tier, + reward_portion: params.reward_portion.clone(), + tier_thresholds: new_tier_thresholds, + } + } + + /// Calculate number of slots, based on the provided native token price. + pub fn calculate_number_of_slots(native_price: FixedU64) -> u16 { + // floor(1000 x price + 50), formula proposed in Tokenomics 2.0 document. + let result: u64 = native_price.saturating_mul_int(1000).saturating_add(50); + + result.unique_saturated_into() + } +} + +/// Used to represent into which tier does a particular dApp fall into. +/// +/// In case tier Id is `None`, it means that either reward was already claimed, or dApp is not eligible for rewards. +#[derive(Encode, Decode, MaxEncodedLen, Copy, Clone, Debug, PartialEq, Eq, TypeInfo)] +pub struct DAppTier { + /// Unique dApp id in dApp staking protocol. + #[codec(compact)] + pub dapp_id: DAppId, + /// `Some(tier_id)` if dApp belongs to tier and has unclaimed rewards, `None` otherwise. + pub tier_id: Option, +} + +/// Information about all of the dApps that got into tiers, and tier rewards +#[derive( + Encode, + Decode, + MaxEncodedLen, + RuntimeDebugNoBound, + PartialEqNoBound, + EqNoBound, + CloneNoBound, + TypeInfo, +)] +#[scale_info(skip_type_params(MD, NT))] +pub struct DAppTierRewards, NT: Get> { + /// DApps and their corresponding tiers (or `None` if they have been claimed in the meantime) + pub dapps: BoundedVec, + /// Rewards for each tier. First entry refers to the first tier, and so on. + pub rewards: BoundedVec, + /// Period during which this struct was created. + #[codec(compact)] + pub period: PeriodNumber, +} + +impl, NT: Get> Default for DAppTierRewards { + fn default() -> Self { + Self { + dapps: BoundedVec::default(), + rewards: BoundedVec::default(), + period: 0, + } + } +} + +impl, NT: Get> DAppTierRewards { + /// Attempt to construct `DAppTierRewards` struct. + /// If the provided arguments exceed the allowed capacity, return an error. + pub fn new( + dapps: Vec, + rewards: Vec, + period: PeriodNumber, + ) -> Result { + // Sort by dApp ID, in ascending order (unstable sort should be faster, and stability is "guaranteed" due to lack of duplicated Ids). + let mut dapps = dapps; + dapps.sort_unstable_by(|first, second| first.dapp_id.cmp(&second.dapp_id)); + + let dapps = BoundedVec::try_from(dapps).map_err(|_| ())?; + let rewards = BoundedVec::try_from(rewards).map_err(|_| ())?; + Ok(Self { + dapps, + rewards, + period, + }) + } + + /// Consume reward for the specified dapp id, returning its amount and tier Id. + /// In case dapp isn't applicable for rewards, or they have already been consumed, returns `None`. + pub fn try_claim(&mut self, dapp_id: DAppId) -> Result<(Balance, TierId), DAppTierError> { + // Check if dApp Id exists. + let dapp_idx = self + .dapps + .binary_search_by(|entry| entry.dapp_id.cmp(&dapp_id)) + .map_err(|_| DAppTierError::NoDAppInTiers)?; + + match self.dapps.get_mut(dapp_idx) { + Some(dapp_tier) => { + if let Some(tier_id) = dapp_tier.tier_id.take() { + Ok(( + self.rewards + .get(tier_id as usize) + .map_or(Balance::zero(), |x| *x), + tier_id, + )) + } else { + Err(DAppTierError::RewardAlreadyClaimed) + } + } + // unreachable code, at this point it was proved that index exists + _ => Err(DAppTierError::InternalError), + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum DAppTierError { + /// Specified dApp Id doesn't exist in any tier. + NoDAppInTiers, + /// Reward has already been claimed for this dApp. + RewardAlreadyClaimed, + /// Internal, unexpected error occured. + InternalError, +} + +/// Describes which entries are next in line for cleanup. +#[derive(Encode, Decode, MaxEncodedLen, Copy, Clone, Debug, PartialEq, Eq, TypeInfo, Default)] +pub struct CleanupMarker { + /// Era reward span index that should be checked & cleaned up next. + #[codec(compact)] + pub era_reward_index: EraNumber, + /// dApp tier rewards index that should be checked & cleaned up next. + #[codec(compact)] + pub dapp_tiers_index: EraNumber, +} + +/////////////////////////////////////////////////////////////////////// +//////////// MOVE THIS TO SOME PRIMITIVES CRATE LATER //////////// +/////////////////////////////////////////////////////////////////////// + +/// Interface for fetching price of the native token. +/// +/// TODO: discussion about below +/// The assumption is that the underlying implementation will ensure +/// this price is averaged and/or weighted over a certain period of time. +/// Alternative is to provide e.g. number of blocks for which an approximately averaged value is needed, +/// and let the underlying implementation take care converting block range into value best represening it. +pub trait PriceProvider { + /// Get the price of the native token. + fn average_price() -> FixedU64; +} diff --git a/pallets/dapp-staking-v3/src/weights.rs b/pallets/dapp-staking-v3/src/weights.rs new file mode 100644 index 0000000000..e98d798707 --- /dev/null +++ b/pallets/dapp-staking-v3/src/weights.rs @@ -0,0 +1,840 @@ + +// 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 . + +//! Autogenerated weights for pallet_dapp_staking_v3 +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 4.0.0-dev +//! DATE: 2023-12-20, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! WORST CASE MAP SIZE: `1000000` +//! HOSTNAME: `devserver-01`, CPU: `Intel(R) Xeon(R) E-2236 CPU @ 3.40GHz` +//! EXECUTION: Some(Wasm), WASM-EXECUTION: Compiled, CHAIN: Some("shibuya-dev"), DB CACHE: 1024 + +// Executed Command: +// ./target/release/astar-collator +// benchmark +// pallet +// --chain=shibuya-dev +// --steps=50 +// --repeat=20 +// --pallet=pallet_dapp_staking_v3 +// --extrinsic=* +// --execution=wasm +// --wasm-execution=compiled +// --heap-pages=4096 +// --output=./benchmark-results/shibuya-dev/dapp_staking_v3_weights.rs +// --template=./scripts/templates/weight-template.hbs + +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(unused_parens)] +#![allow(unused_imports)] + +use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; +use core::marker::PhantomData; + +/// Weight functions needed for pallet_dapp_staking_v3. +pub trait WeightInfo { + fn maintenance_mode() -> Weight; + fn register() -> Weight; + fn set_dapp_reward_beneficiary() -> Weight; + fn set_dapp_owner() -> Weight; + fn unregister() -> Weight; + fn lock() -> Weight; + fn unlock() -> Weight; + fn claim_unlocked(x: u32, ) -> Weight; + fn relock_unlocking() -> Weight; + fn stake() -> Weight; + fn unstake() -> Weight; + fn claim_staker_rewards_past_period(x: u32, ) -> Weight; + fn claim_staker_rewards_ongoing_period(x: u32, ) -> Weight; + fn claim_bonus_reward() -> Weight; + fn claim_dapp_reward() -> Weight; + fn unstake_from_unregistered() -> Weight; + fn cleanup_expired_entries(x: u32, ) -> Weight; + fn force() -> 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; + fn dapp_tier_assignment(x: u32, ) -> Weight; + fn on_idle_cleanup() -> Weight; +} + +/// Weights for pallet_dapp_staking_v3 using the Substrate node and recommended hardware. +pub struct SubstrateWeight(PhantomData); +impl WeightInfo for SubstrateWeight { + fn maintenance_mode() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 8_474_000 picoseconds. + Weight::from_parts(8_711_000, 0) + } + /// Storage: DappStaking IntegratedDApps (r:1 w:1) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, mode: MaxEncodedLen) + /// Storage: DappStaking CounterForIntegratedDApps (r:1 w:1) + /// Proof: DappStaking CounterForIntegratedDApps (max_values: Some(1), max_size: Some(4), added: 499, mode: MaxEncodedLen) + /// Storage: DappStaking NextDAppId (r:1 w:1) + /// Proof: DappStaking NextDAppId (max_values: Some(1), max_size: Some(2), added: 497, mode: MaxEncodedLen) + fn register() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `3091` + // Minimum execution time: 16_360_000 picoseconds. + Weight::from_parts(16_697_000, 3091) + .saturating_add(T::DbWeight::get().reads(3_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) + } + /// Storage: DappStaking IntegratedDApps (r:1 w:1) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, mode: MaxEncodedLen) + fn set_dapp_reward_beneficiary() -> Weight { + // Proof Size summary in bytes: + // Measured: `75` + // Estimated: `3091` + // Minimum execution time: 12_927_000 picoseconds. + Weight::from_parts(13_229_000, 3091) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: DappStaking IntegratedDApps (r:1 w:1) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, mode: MaxEncodedLen) + fn set_dapp_owner() -> Weight { + // Proof Size summary in bytes: + // Measured: `75` + // Estimated: `3091` + // Minimum execution time: 13_610_000 picoseconds. + Weight::from_parts(13_851_000, 3091) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: DappStaking IntegratedDApps (r:1 w:1) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, mode: MaxEncodedLen) + /// Storage: DappStaking ContractStake (r:0 w:1) + /// Proof: DappStaking ContractStake (max_values: Some(65535), max_size: Some(91), added: 2071, mode: MaxEncodedLen) + fn unregister() -> Weight { + // Proof Size summary in bytes: + // Measured: `75` + // Estimated: `3091` + // Minimum execution time: 16_704_000 picoseconds. + Weight::from_parts(16_952_000, 3091) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) + } + /// Storage: DappStaking Ledger (r:1 w:1) + /// Proof: DappStaking Ledger (max_values: None, max_size: Some(310), added: 2785, 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) + /// Storage: DappStaking CurrentEraInfo (r:1 w:1) + /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, mode: MaxEncodedLen) + fn lock() -> Weight { + // Proof Size summary in bytes: + // Measured: `12` + // Estimated: `4764` + // Minimum execution time: 31_680_000 picoseconds. + Weight::from_parts(32_075_000, 4764) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) + } + /// Storage: DappStaking Ledger (r:1 w:1) + /// Proof: DappStaking Ledger (max_values: None, max_size: Some(310), added: 2785, 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) + /// Storage: DappStaking CurrentEraInfo (r:1 w:1) + /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, mode: MaxEncodedLen) + fn unlock() -> Weight { + // Proof Size summary in bytes: + // Measured: `156` + // Estimated: `4764` + // Minimum execution time: 34_576_000 picoseconds. + Weight::from_parts(34_777_000, 4764) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) + } + /// Storage: DappStaking Ledger (r:1 w:1) + /// Proof: DappStaking Ledger (max_values: None, max_size: Some(310), added: 2785, 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) + /// Storage: DappStaking CurrentEraInfo (r:1 w:1) + /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, mode: MaxEncodedLen) + /// The range of component `x` is `[0, 8]`. + fn claim_unlocked(x: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `187` + // Estimated: `4764` + // Minimum execution time: 33_562_000 picoseconds. + Weight::from_parts(34_600_552, 4764) + // Standard Error: 5_079 + .saturating_add(Weight::from_parts(193_345, 0).saturating_mul(x.into())) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) + } + /// Storage: DappStaking Ledger (r:1 w:1) + /// Proof: DappStaking Ledger (max_values: None, max_size: Some(310), added: 2785, 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) + /// Storage: DappStaking CurrentEraInfo (r:1 w:1) + /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, mode: MaxEncodedLen) + fn relock_unlocking() -> Weight { + // Proof Size summary in bytes: + // Measured: `182` + // Estimated: `4764` + // Minimum execution time: 36_436_000 picoseconds. + Weight::from_parts(37_262_000, 4764) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) + } + /// Storage: DappStaking IntegratedDApps (r:1 w:0) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, 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:1 w:1) + /// Proof: DappStaking StakerInfo (max_values: None, max_size: Some(138), added: 2613, mode: MaxEncodedLen) + /// Storage: DappStaking ContractStake (r:1 w:1) + /// Proof: DappStaking ContractStake (max_values: Some(65535), max_size: Some(91), added: 2071, mode: MaxEncodedLen) + /// Storage: DappStaking CurrentEraInfo (r:1 w:1) + /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, 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 stake() -> Weight { + // Proof Size summary in bytes: + // Measured: `250` + // Estimated: `4764` + // Minimum execution time: 43_866_000 picoseconds. + Weight::from_parts(44_468_000, 4764) + .saturating_add(T::DbWeight::get().reads(7_u64)) + .saturating_add(T::DbWeight::get().writes(5_u64)) + } + /// Storage: DappStaking IntegratedDApps (r:1 w:0) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, 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:1 w:1) + /// Proof: DappStaking StakerInfo (max_values: None, max_size: Some(138), added: 2613, mode: MaxEncodedLen) + /// Storage: DappStaking ContractStake (r:1 w:1) + /// Proof: DappStaking ContractStake (max_values: Some(65535), max_size: Some(91), added: 2071, mode: MaxEncodedLen) + /// Storage: DappStaking CurrentEraInfo (r:1 w:1) + /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, 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 unstake() -> Weight { + // Proof Size summary in bytes: + // Measured: `427` + // Estimated: `4764` + // Minimum execution time: 47_368_000 picoseconds. + Weight::from_parts(48_049_000, 4764) + .saturating_add(T::DbWeight::get().reads(7_u64)) + .saturating_add(T::DbWeight::get().writes(5_u64)) + } + /// Storage: DappStaking Ledger (r:1 w:1) + /// Proof: DappStaking Ledger (max_values: None, max_size: Some(310), added: 2785, mode: MaxEncodedLen) + /// Storage: DappStaking EraRewards (r:1 w:0) + /// Proof: DappStaking EraRewards (max_values: None, max_size: Some(789), added: 3264, mode: MaxEncodedLen) + /// Storage: DappStaking PeriodEnd (r:1 w:0) + /// Proof: DappStaking PeriodEnd (max_values: None, max_size: Some(48), added: 2523, 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) + /// The range of component `x` is `[1, 16]`. + fn claim_staker_rewards_past_period(x: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `560` + // Estimated: `4764` + // Minimum execution time: 51_230_000 picoseconds. + Weight::from_parts(48_696_805, 4764) + // Standard Error: 6_139 + .saturating_add(Weight::from_parts(3_374_191, 0).saturating_mul(x.into())) + .saturating_add(T::DbWeight::get().reads(5_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) + } + /// Storage: DappStaking Ledger (r:1 w:1) + /// Proof: DappStaking Ledger (max_values: None, max_size: Some(310), added: 2785, mode: MaxEncodedLen) + /// Storage: DappStaking EraRewards (r:1 w:0) + /// Proof: DappStaking EraRewards (max_values: None, max_size: Some(789), added: 3264, 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) + /// The range of component `x` is `[1, 16]`. + fn claim_staker_rewards_ongoing_period(x: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `501` + // Estimated: `4764` + // Minimum execution time: 45_030_000 picoseconds. + Weight::from_parts(43_179_071, 4764) + // Standard Error: 5_547 + .saturating_add(Weight::from_parts(3_296_864, 0).saturating_mul(x.into())) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) + } + /// Storage: DappStaking StakerInfo (r:1 w:1) + /// Proof: DappStaking StakerInfo (max_values: None, max_size: Some(138), added: 2613, mode: MaxEncodedLen) + /// Storage: DappStaking PeriodEnd (r:1 w:0) + /// Proof: DappStaking PeriodEnd (max_values: None, max_size: Some(48), added: 2523, mode: MaxEncodedLen) + /// Storage: DappStaking Ledger (r:1 w:1) + /// Proof: DappStaking Ledger (max_values: None, max_size: Some(310), added: 2785, mode: MaxEncodedLen) + fn claim_bonus_reward() -> Weight { + // Proof Size summary in bytes: + // Measured: `267` + // Estimated: `3775` + // Minimum execution time: 42_248_000 picoseconds. + Weight::from_parts(42_687_000, 3775) + .saturating_add(T::DbWeight::get().reads(3_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) + } + /// Storage: DappStaking IntegratedDApps (r:1 w:0) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, mode: MaxEncodedLen) + /// Storage: DappStaking DAppTiers (r:1 w:1) + /// Proof: DappStaking DAppTiers (max_values: None, max_size: Some(2083), added: 4558, mode: MaxEncodedLen) + fn claim_dapp_reward() -> Weight { + // Proof Size summary in bytes: + // Measured: `3021` + // Estimated: `5548` + // Minimum execution time: 50_968_000 picoseconds. + Weight::from_parts(51_778_000, 5548) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: DappStaking IntegratedDApps (r:1 w:0) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, mode: MaxEncodedLen) + /// Storage: DappStaking StakerInfo (r:1 w:1) + /// Proof: DappStaking StakerInfo (max_values: None, max_size: Some(138), added: 2613, 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 CurrentEraInfo (r:1 w:1) + /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, 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 unstake_from_unregistered() -> Weight { + // Proof Size summary in bytes: + // Measured: `389` + // Estimated: `4764` + // Minimum execution time: 42_329_000 picoseconds. + Weight::from_parts(42_737_000, 4764) + .saturating_add(T::DbWeight::get().reads(6_u64)) + .saturating_add(T::DbWeight::get().writes(4_u64)) + } + /// Storage: DappStaking StakerInfo (r:9 w:8) + /// Proof: DappStaking StakerInfo (max_values: None, max_size: Some(138), added: 2613, mode: MaxEncodedLen) + /// Storage: DappStaking Ledger (r:1 w:1) + /// Proof: DappStaking Ledger (max_values: None, max_size: Some(310), added: 2785, 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) + /// The range of component `x` is `[1, 8]`. + fn cleanup_expired_entries(x: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `255 + x * (69 ±0)` + // Estimated: `4764 + x * (2613 ±0)` + // Minimum execution time: 42_222_000 picoseconds. + Weight::from_parts(38_945_386, 4764) + // Standard Error: 14_325 + .saturating_add(Weight::from_parts(5_044_310, 0).saturating_mul(x.into())) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(x.into()))) + .saturating_add(T::DbWeight::get().writes(2_u64)) + .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(x.into()))) + .saturating_add(Weight::from_parts(0, 2613).saturating_mul(x.into())) + } + fn force() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 9_971_000 picoseconds. + Weight::from_parts(10_190_000, 0) + } + /// 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) + /// Proof: DappStaking EraRewards (max_values: None, max_size: Some(789), added: 3264, mode: MaxEncodedLen) + fn on_initialize_voting_to_build_and_earn() -> Weight { + // Proof Size summary in bytes: + // Measured: `16` + // Estimated: `4254` + // Minimum execution time: 17_308_000 picoseconds. + Weight::from_parts(17_774_000, 4254) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(2_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 StaticTierParams (r:1 w:0) + /// Proof: DappStaking StaticTierParams (max_values: Some(1), max_size: Some(167), added: 662, mode: MaxEncodedLen) + /// Storage: DappStaking TierConfig (r:1 w:1) + /// Proof: DappStaking TierConfig (max_values: Some(1), max_size: Some(161), added: 656, mode: MaxEncodedLen) + /// Storage: DappStaking EraRewards (r:1 w:1) + /// Proof: DappStaking EraRewards (max_values: None, max_size: Some(789), added: 3264, mode: MaxEncodedLen) + /// Storage: DappStaking PeriodEnd (r:0 w:1) + /// Proof: DappStaking PeriodEnd (max_values: None, max_size: Some(48), added: 2523, mode: MaxEncodedLen) + /// Storage: DappStaking DAppTiers (r:0 w:1) + /// Proof: DappStaking DAppTiers (max_values: None, max_size: Some(2083), added: 4558, mode: MaxEncodedLen) + fn on_initialize_build_and_earn_to_voting() -> Weight { + // Proof Size summary in bytes: + // Measured: `550` + // Estimated: `4254` + // Minimum execution time: 39_768_000 picoseconds. + Weight::from_parts(40_422_000, 4254) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(5_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) + /// Proof: DappStaking EraRewards (max_values: None, max_size: Some(789), added: 3264, mode: MaxEncodedLen) + /// Storage: DappStaking DAppTiers (r:0 w:1) + /// Proof: DappStaking DAppTiers (max_values: None, max_size: Some(2083), added: 4558, mode: MaxEncodedLen) + fn on_initialize_build_and_earn_to_build_and_earn() -> Weight { + // Proof Size summary in bytes: + // Measured: `68` + // Estimated: `4254` + // Minimum execution time: 20_976_000 picoseconds. + Weight::from_parts(21_507_000, 4254) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) + } + /// Storage: DappStaking ContractStake (r:101 w:0) + /// Proof: DappStaking ContractStake (max_values: Some(65535), max_size: Some(91), added: 2071, mode: MaxEncodedLen) + /// Storage: DappStaking TierConfig (r:1 w:0) + /// Proof: DappStaking TierConfig (max_values: Some(1), max_size: Some(161), added: 656, mode: MaxEncodedLen) + /// The range of component `x` is `[0, 100]`. + fn dapp_tier_assignment(x: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `152 + x * (32 ±0)` + // Estimated: `3061 + x * (2071 ±0)` + // Minimum execution time: 7_374_000 picoseconds. + Weight::from_parts(10_826_637, 3061) + // Standard Error: 3_374 + .saturating_add(Weight::from_parts(2_291_643, 0).saturating_mul(x.into())) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(x.into()))) + .saturating_add(Weight::from_parts(0, 2071).saturating_mul(x.into())) + } + /// Storage: DappStaking HistoryCleanupMarker (r:1 w:1) + /// Proof: DappStaking HistoryCleanupMarker (max_values: Some(1), max_size: Some(8), added: 503, mode: MaxEncodedLen) + /// Storage: DappStaking PeriodEnd (r:1 w:1) + /// Proof: DappStaking PeriodEnd (max_values: None, max_size: Some(48), added: 2523, mode: MaxEncodedLen) + /// Storage: DappStaking EraRewards (r:1 w:1) + /// Proof: DappStaking EraRewards (max_values: None, max_size: Some(789), added: 3264, mode: MaxEncodedLen) + /// Storage: DappStaking DAppTiers (r:0 w:1) + /// Proof: DappStaking DAppTiers (max_values: None, max_size: Some(2083), added: 4558, mode: MaxEncodedLen) + fn on_idle_cleanup() -> Weight { + // Proof Size summary in bytes: + // Measured: `473` + // Estimated: `4254` + // Minimum execution time: 14_500_000 picoseconds. + Weight::from_parts(14_969_000, 4254) + .saturating_add(T::DbWeight::get().reads(3_u64)) + .saturating_add(T::DbWeight::get().writes(4_u64)) + } +} + +// For backwards compatibility and tests +impl WeightInfo for () { + fn maintenance_mode() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 8_474_000 picoseconds. + Weight::from_parts(8_711_000, 0) + } + /// Storage: DappStaking IntegratedDApps (r:1 w:1) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, mode: MaxEncodedLen) + /// Storage: DappStaking CounterForIntegratedDApps (r:1 w:1) + /// Proof: DappStaking CounterForIntegratedDApps (max_values: Some(1), max_size: Some(4), added: 499, mode: MaxEncodedLen) + /// Storage: DappStaking NextDAppId (r:1 w:1) + /// Proof: DappStaking NextDAppId (max_values: Some(1), max_size: Some(2), added: 497, mode: MaxEncodedLen) + fn register() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `3091` + // Minimum execution time: 16_360_000 picoseconds. + Weight::from_parts(16_697_000, 3091) + .saturating_add(RocksDbWeight::get().reads(3_u64)) + .saturating_add(RocksDbWeight::get().writes(3_u64)) + } + /// Storage: DappStaking IntegratedDApps (r:1 w:1) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, mode: MaxEncodedLen) + fn set_dapp_reward_beneficiary() -> Weight { + // Proof Size summary in bytes: + // Measured: `75` + // Estimated: `3091` + // Minimum execution time: 12_927_000 picoseconds. + Weight::from_parts(13_229_000, 3091) + .saturating_add(RocksDbWeight::get().reads(1_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + /// Storage: DappStaking IntegratedDApps (r:1 w:1) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, mode: MaxEncodedLen) + fn set_dapp_owner() -> Weight { + // Proof Size summary in bytes: + // Measured: `75` + // Estimated: `3091` + // Minimum execution time: 13_610_000 picoseconds. + Weight::from_parts(13_851_000, 3091) + .saturating_add(RocksDbWeight::get().reads(1_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + /// Storage: DappStaking IntegratedDApps (r:1 w:1) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, mode: MaxEncodedLen) + /// Storage: DappStaking ContractStake (r:0 w:1) + /// Proof: DappStaking ContractStake (max_values: Some(65535), max_size: Some(91), added: 2071, mode: MaxEncodedLen) + fn unregister() -> Weight { + // Proof Size summary in bytes: + // Measured: `75` + // Estimated: `3091` + // Minimum execution time: 16_704_000 picoseconds. + Weight::from_parts(16_952_000, 3091) + .saturating_add(RocksDbWeight::get().reads(1_u64)) + .saturating_add(RocksDbWeight::get().writes(2_u64)) + } + /// Storage: DappStaking Ledger (r:1 w:1) + /// Proof: DappStaking Ledger (max_values: None, max_size: Some(310), added: 2785, 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) + /// Storage: DappStaking CurrentEraInfo (r:1 w:1) + /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, mode: MaxEncodedLen) + fn lock() -> Weight { + // Proof Size summary in bytes: + // Measured: `12` + // Estimated: `4764` + // Minimum execution time: 31_680_000 picoseconds. + Weight::from_parts(32_075_000, 4764) + .saturating_add(RocksDbWeight::get().reads(4_u64)) + .saturating_add(RocksDbWeight::get().writes(3_u64)) + } + /// Storage: DappStaking Ledger (r:1 w:1) + /// Proof: DappStaking Ledger (max_values: None, max_size: Some(310), added: 2785, 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) + /// Storage: DappStaking CurrentEraInfo (r:1 w:1) + /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, mode: MaxEncodedLen) + fn unlock() -> Weight { + // Proof Size summary in bytes: + // Measured: `156` + // Estimated: `4764` + // Minimum execution time: 34_576_000 picoseconds. + Weight::from_parts(34_777_000, 4764) + .saturating_add(RocksDbWeight::get().reads(4_u64)) + .saturating_add(RocksDbWeight::get().writes(3_u64)) + } + /// Storage: DappStaking Ledger (r:1 w:1) + /// Proof: DappStaking Ledger (max_values: None, max_size: Some(310), added: 2785, 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) + /// Storage: DappStaking CurrentEraInfo (r:1 w:1) + /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, mode: MaxEncodedLen) + /// The range of component `x` is `[0, 8]`. + fn claim_unlocked(x: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `187` + // Estimated: `4764` + // Minimum execution time: 33_562_000 picoseconds. + Weight::from_parts(34_600_552, 4764) + // Standard Error: 5_079 + .saturating_add(Weight::from_parts(193_345, 0).saturating_mul(x.into())) + .saturating_add(RocksDbWeight::get().reads(4_u64)) + .saturating_add(RocksDbWeight::get().writes(3_u64)) + } + /// Storage: DappStaking Ledger (r:1 w:1) + /// Proof: DappStaking Ledger (max_values: None, max_size: Some(310), added: 2785, 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) + /// Storage: DappStaking CurrentEraInfo (r:1 w:1) + /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, mode: MaxEncodedLen) + fn relock_unlocking() -> Weight { + // Proof Size summary in bytes: + // Measured: `182` + // Estimated: `4764` + // Minimum execution time: 36_436_000 picoseconds. + Weight::from_parts(37_262_000, 4764) + .saturating_add(RocksDbWeight::get().reads(4_u64)) + .saturating_add(RocksDbWeight::get().writes(3_u64)) + } + /// Storage: DappStaking IntegratedDApps (r:1 w:0) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, 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:1 w:1) + /// Proof: DappStaking StakerInfo (max_values: None, max_size: Some(138), added: 2613, mode: MaxEncodedLen) + /// Storage: DappStaking ContractStake (r:1 w:1) + /// Proof: DappStaking ContractStake (max_values: Some(65535), max_size: Some(91), added: 2071, mode: MaxEncodedLen) + /// Storage: DappStaking CurrentEraInfo (r:1 w:1) + /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, 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 stake() -> Weight { + // Proof Size summary in bytes: + // Measured: `250` + // Estimated: `4764` + // Minimum execution time: 43_866_000 picoseconds. + Weight::from_parts(44_468_000, 4764) + .saturating_add(RocksDbWeight::get().reads(7_u64)) + .saturating_add(RocksDbWeight::get().writes(5_u64)) + } + /// Storage: DappStaking IntegratedDApps (r:1 w:0) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, 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:1 w:1) + /// Proof: DappStaking StakerInfo (max_values: None, max_size: Some(138), added: 2613, mode: MaxEncodedLen) + /// Storage: DappStaking ContractStake (r:1 w:1) + /// Proof: DappStaking ContractStake (max_values: Some(65535), max_size: Some(91), added: 2071, mode: MaxEncodedLen) + /// Storage: DappStaking CurrentEraInfo (r:1 w:1) + /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, 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 unstake() -> Weight { + // Proof Size summary in bytes: + // Measured: `427` + // Estimated: `4764` + // Minimum execution time: 47_368_000 picoseconds. + Weight::from_parts(48_049_000, 4764) + .saturating_add(RocksDbWeight::get().reads(7_u64)) + .saturating_add(RocksDbWeight::get().writes(5_u64)) + } + /// Storage: DappStaking Ledger (r:1 w:1) + /// Proof: DappStaking Ledger (max_values: None, max_size: Some(310), added: 2785, mode: MaxEncodedLen) + /// Storage: DappStaking EraRewards (r:1 w:0) + /// Proof: DappStaking EraRewards (max_values: None, max_size: Some(789), added: 3264, mode: MaxEncodedLen) + /// Storage: DappStaking PeriodEnd (r:1 w:0) + /// Proof: DappStaking PeriodEnd (max_values: None, max_size: Some(48), added: 2523, 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) + /// The range of component `x` is `[1, 16]`. + fn claim_staker_rewards_past_period(x: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `560` + // Estimated: `4764` + // Minimum execution time: 51_230_000 picoseconds. + Weight::from_parts(48_696_805, 4764) + // Standard Error: 6_139 + .saturating_add(Weight::from_parts(3_374_191, 0).saturating_mul(x.into())) + .saturating_add(RocksDbWeight::get().reads(5_u64)) + .saturating_add(RocksDbWeight::get().writes(2_u64)) + } + /// Storage: DappStaking Ledger (r:1 w:1) + /// Proof: DappStaking Ledger (max_values: None, max_size: Some(310), added: 2785, mode: MaxEncodedLen) + /// Storage: DappStaking EraRewards (r:1 w:0) + /// Proof: DappStaking EraRewards (max_values: None, max_size: Some(789), added: 3264, 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) + /// The range of component `x` is `[1, 16]`. + fn claim_staker_rewards_ongoing_period(x: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `501` + // Estimated: `4764` + // Minimum execution time: 45_030_000 picoseconds. + Weight::from_parts(43_179_071, 4764) + // Standard Error: 5_547 + .saturating_add(Weight::from_parts(3_296_864, 0).saturating_mul(x.into())) + .saturating_add(RocksDbWeight::get().reads(4_u64)) + .saturating_add(RocksDbWeight::get().writes(2_u64)) + } + /// Storage: DappStaking StakerInfo (r:1 w:1) + /// Proof: DappStaking StakerInfo (max_values: None, max_size: Some(138), added: 2613, mode: MaxEncodedLen) + /// Storage: DappStaking PeriodEnd (r:1 w:0) + /// Proof: DappStaking PeriodEnd (max_values: None, max_size: Some(48), added: 2523, mode: MaxEncodedLen) + /// Storage: DappStaking Ledger (r:1 w:1) + /// Proof: DappStaking Ledger (max_values: None, max_size: Some(310), added: 2785, mode: MaxEncodedLen) + fn claim_bonus_reward() -> Weight { + // Proof Size summary in bytes: + // Measured: `267` + // Estimated: `3775` + // Minimum execution time: 42_248_000 picoseconds. + Weight::from_parts(42_687_000, 3775) + .saturating_add(RocksDbWeight::get().reads(3_u64)) + .saturating_add(RocksDbWeight::get().writes(2_u64)) + } + /// Storage: DappStaking IntegratedDApps (r:1 w:0) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, mode: MaxEncodedLen) + /// Storage: DappStaking DAppTiers (r:1 w:1) + /// Proof: DappStaking DAppTiers (max_values: None, max_size: Some(2083), added: 4558, mode: MaxEncodedLen) + fn claim_dapp_reward() -> Weight { + // Proof Size summary in bytes: + // Measured: `3021` + // Estimated: `5548` + // Minimum execution time: 50_968_000 picoseconds. + Weight::from_parts(51_778_000, 5548) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + /// Storage: DappStaking IntegratedDApps (r:1 w:0) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, mode: MaxEncodedLen) + /// Storage: DappStaking StakerInfo (r:1 w:1) + /// Proof: DappStaking StakerInfo (max_values: None, max_size: Some(138), added: 2613, 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 CurrentEraInfo (r:1 w:1) + /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, 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 unstake_from_unregistered() -> Weight { + // Proof Size summary in bytes: + // Measured: `389` + // Estimated: `4764` + // Minimum execution time: 42_329_000 picoseconds. + Weight::from_parts(42_737_000, 4764) + .saturating_add(RocksDbWeight::get().reads(6_u64)) + .saturating_add(RocksDbWeight::get().writes(4_u64)) + } + /// Storage: DappStaking StakerInfo (r:9 w:8) + /// Proof: DappStaking StakerInfo (max_values: None, max_size: Some(138), added: 2613, mode: MaxEncodedLen) + /// Storage: DappStaking Ledger (r:1 w:1) + /// Proof: DappStaking Ledger (max_values: None, max_size: Some(310), added: 2785, 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) + /// The range of component `x` is `[1, 8]`. + fn cleanup_expired_entries(x: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `255 + x * (69 ±0)` + // Estimated: `4764 + x * (2613 ±0)` + // Minimum execution time: 42_222_000 picoseconds. + Weight::from_parts(38_945_386, 4764) + // Standard Error: 14_325 + .saturating_add(Weight::from_parts(5_044_310, 0).saturating_mul(x.into())) + .saturating_add(RocksDbWeight::get().reads(4_u64)) + .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(x.into()))) + .saturating_add(RocksDbWeight::get().writes(2_u64)) + .saturating_add(RocksDbWeight::get().writes((1_u64).saturating_mul(x.into()))) + .saturating_add(Weight::from_parts(0, 2613).saturating_mul(x.into())) + } + fn force() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 9_971_000 picoseconds. + Weight::from_parts(10_190_000, 0) + } + /// 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) + /// Proof: DappStaking EraRewards (max_values: None, max_size: Some(789), added: 3264, mode: MaxEncodedLen) + fn on_initialize_voting_to_build_and_earn() -> Weight { + // Proof Size summary in bytes: + // Measured: `16` + // Estimated: `4254` + // Minimum execution time: 17_308_000 picoseconds. + Weight::from_parts(17_774_000, 4254) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().writes(2_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 StaticTierParams (r:1 w:0) + /// Proof: DappStaking StaticTierParams (max_values: Some(1), max_size: Some(167), added: 662, mode: MaxEncodedLen) + /// Storage: DappStaking TierConfig (r:1 w:1) + /// Proof: DappStaking TierConfig (max_values: Some(1), max_size: Some(161), added: 656, mode: MaxEncodedLen) + /// Storage: DappStaking EraRewards (r:1 w:1) + /// Proof: DappStaking EraRewards (max_values: None, max_size: Some(789), added: 3264, mode: MaxEncodedLen) + /// Storage: DappStaking PeriodEnd (r:0 w:1) + /// Proof: DappStaking PeriodEnd (max_values: None, max_size: Some(48), added: 2523, mode: MaxEncodedLen) + /// Storage: DappStaking DAppTiers (r:0 w:1) + /// Proof: DappStaking DAppTiers (max_values: None, max_size: Some(2083), added: 4558, mode: MaxEncodedLen) + fn on_initialize_build_and_earn_to_voting() -> Weight { + // Proof Size summary in bytes: + // Measured: `550` + // Estimated: `4254` + // Minimum execution time: 39_768_000 picoseconds. + Weight::from_parts(40_422_000, 4254) + .saturating_add(RocksDbWeight::get().reads(4_u64)) + .saturating_add(RocksDbWeight::get().writes(5_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) + /// Proof: DappStaking EraRewards (max_values: None, max_size: Some(789), added: 3264, mode: MaxEncodedLen) + /// Storage: DappStaking DAppTiers (r:0 w:1) + /// Proof: DappStaking DAppTiers (max_values: None, max_size: Some(2083), added: 4558, mode: MaxEncodedLen) + fn on_initialize_build_and_earn_to_build_and_earn() -> Weight { + // Proof Size summary in bytes: + // Measured: `68` + // Estimated: `4254` + // Minimum execution time: 20_976_000 picoseconds. + Weight::from_parts(21_507_000, 4254) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().writes(3_u64)) + } + /// Storage: DappStaking ContractStake (r:101 w:0) + /// Proof: DappStaking ContractStake (max_values: Some(65535), max_size: Some(91), added: 2071, mode: MaxEncodedLen) + /// Storage: DappStaking TierConfig (r:1 w:0) + /// Proof: DappStaking TierConfig (max_values: Some(1), max_size: Some(161), added: 656, mode: MaxEncodedLen) + /// The range of component `x` is `[0, 100]`. + fn dapp_tier_assignment(x: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `152 + x * (32 ±0)` + // Estimated: `3061 + x * (2071 ±0)` + // Minimum execution time: 7_374_000 picoseconds. + Weight::from_parts(10_826_637, 3061) + // Standard Error: 3_374 + .saturating_add(Weight::from_parts(2_291_643, 0).saturating_mul(x.into())) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(x.into()))) + .saturating_add(Weight::from_parts(0, 2071).saturating_mul(x.into())) + } + /// Storage: DappStaking HistoryCleanupMarker (r:1 w:1) + /// Proof: DappStaking HistoryCleanupMarker (max_values: Some(1), max_size: Some(8), added: 503, mode: MaxEncodedLen) + /// Storage: DappStaking PeriodEnd (r:1 w:1) + /// Proof: DappStaking PeriodEnd (max_values: None, max_size: Some(48), added: 2523, mode: MaxEncodedLen) + /// Storage: DappStaking EraRewards (r:1 w:1) + /// Proof: DappStaking EraRewards (max_values: None, max_size: Some(789), added: 3264, mode: MaxEncodedLen) + /// Storage: DappStaking DAppTiers (r:0 w:1) + /// Proof: DappStaking DAppTiers (max_values: None, max_size: Some(2083), added: 4558, mode: MaxEncodedLen) + fn on_idle_cleanup() -> Weight { + // Proof Size summary in bytes: + // Measured: `473` + // Estimated: `4254` + // Minimum execution time: 14_500_000 picoseconds. + Weight::from_parts(14_969_000, 4254) + .saturating_add(RocksDbWeight::get().reads(3_u64)) + .saturating_add(RocksDbWeight::get().writes(4_u64)) + } +} diff --git a/pallets/dapps-staking/src/lib.rs b/pallets/dapps-staking/src/lib.rs index d9da78d259..8ad9ae89c0 100644 --- a/pallets/dapps-staking/src/lib.rs +++ b/pallets/dapps-staking/src/lib.rs @@ -111,7 +111,7 @@ const MAX_ASSUMED_VEC_LEN: u32 = 10; /// DApp State descriptor #[derive(Copy, Clone, PartialEq, Eq, Encode, Decode, RuntimeDebug, TypeInfo, MaxEncodedLen)] -enum DAppState { +pub enum DAppState { /// Contract is registered and active. Registered, /// Contract has been unregistered and is inactive. @@ -122,9 +122,9 @@ enum DAppState { #[derive(Clone, PartialEq, Eq, Encode, Decode, RuntimeDebug, TypeInfo, MaxEncodedLen)] pub struct DAppInfo { /// Developer (owner) account - developer: AccountId, + pub developer: AccountId, /// Current DApp State - state: DAppState, + pub state: DAppState, } impl DAppInfo { @@ -137,7 +137,7 @@ impl DAppInfo { } /// `true` if dApp has been unregistered, `false` otherwise - fn is_unregistered(&self) -> bool { + pub fn is_unregistered(&self) -> bool { matches!(self.state, DAppState::Unregistered(_)) } } @@ -475,7 +475,7 @@ impl UnbondingInfo { } /// Returns sum of all unlocking chunks. - fn sum(&self) -> Balance { + pub fn sum(&self) -> Balance { self.unlocking_chunks .iter() .map(|chunk| chunk.amount) @@ -551,7 +551,7 @@ pub struct AccountLedger { #[codec(compact)] pub locked: Balance, /// Information about unbonding chunks. - unbonding_info: UnbondingInfo, + pub unbonding_info: UnbondingInfo, /// Instruction on how to handle reward payout reward_destination: RewardDestination, } diff --git a/pallets/dapps-staking/src/mock.rs b/pallets/dapps-staking/src/mock.rs index bdc6a0e532..3ecb3e0a3b 100644 --- a/pallets/dapps-staking/src/mock.rs +++ b/pallets/dapps-staking/src/mock.rs @@ -30,7 +30,7 @@ use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; use sp_io::TestExternalities; use sp_runtime::{ testing::Header, - traits::{BlakeTwo256, ConstU32, IdentityLookup}, + traits::{BlakeTwo256, ConstBool, ConstU32, IdentityLookup}, }; pub(crate) type AccountId = u64; @@ -166,6 +166,7 @@ impl pallet_dapps_staking::Config for TestRuntime { type UnbondingPeriod = UnbondingPeriod; type MaxEraStakeValues = MaxEraStakeValues; type UnregisteredDappRewardRetention = ConstU32; + type ForcePalletDisabled = ConstBool; } #[derive( diff --git a/pallets/dapps-staking/src/pallet/mod.rs b/pallets/dapps-staking/src/pallet/mod.rs index c2a4ac2d18..7b65ea2d0f 100644 --- a/pallets/dapps-staking/src/pallet/mod.rs +++ b/pallets/dapps-staking/src/pallet/mod.rs @@ -37,7 +37,7 @@ use sp_runtime::{ }; use sp_std::{convert::From, mem}; -const STAKING_ID: LockIdentifier = *b"dapstake"; +pub const STAKING_ID: LockIdentifier = *b"dapstake"; #[frame_support::pallet] #[allow(clippy::module_inception)] @@ -115,6 +115,10 @@ pub mod pallet { #[pallet::constant] type UnregisteredDappRewardRetention: Get; + /// Can be used to force pallet into permanent maintenance mode. + #[pallet::constant] + type ForcePalletDisabled: Get; + /// The overarching event type. type RuntimeEvent: From> + IsType<::RuntimeEvent>; @@ -165,13 +169,13 @@ pub mod pallet { /// Simple map where developer account points to their smart contract #[pallet::storage] #[pallet::getter(fn registered_contract)] - pub(crate) type RegisteredDevelopers = + pub type RegisteredDevelopers = StorageMap<_, Blake2_128Concat, T::AccountId, T::SmartContract>; /// Simple map where smart contract points to basic info about it (e.g. developer address, state) #[pallet::storage] #[pallet::getter(fn dapp_info)] - pub(crate) type RegisteredDapps = + pub type RegisteredDapps = StorageMap<_, Blake2_128Concat, T::SmartContract, DAppInfo>; /// General information about an era like TVL, total staked value, rewards. @@ -207,7 +211,7 @@ pub mod pallet { /// Stores the current pallet storage version. #[pallet::storage] #[pallet::getter(fn storage_version)] - pub(crate) type StorageVersion = StorageValue<_, Version, ValueQuery>; + pub type StorageVersion = StorageValue<_, Version, ValueQuery>; #[pallet::event] #[pallet::generate_deposit(pub(crate) fn deposit_event)] @@ -302,7 +306,7 @@ pub mod pallet { // Runtime upgrade should be timed so we ensure that we complete it before // a new era is triggered. This code is just a safety net to ensure nothing is broken // if we fail to do that. - if PalletDisabled::::get() { + if PalletDisabled::::get() || T::ForcePalletDisabled::get() { return T::DbWeight::get().reads(1); } @@ -1135,7 +1139,7 @@ pub mod pallet { /// `Err` if pallet disabled for maintenance, `Ok` otherwise pub fn ensure_pallet_enabled() -> Result<(), Error> { - if PalletDisabled::::get() { + if PalletDisabled::::get() || T::ForcePalletDisabled::get() { Err(Error::::Disabled) } else { Ok(()) diff --git a/pallets/inflation/Cargo.toml b/pallets/inflation/Cargo.toml new file mode 100644 index 0000000000..a82a1480ad --- /dev/null +++ b/pallets/inflation/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "pallet-inflation" +version = "0.1.0" +license = "GPL-3.0-or-later" +description = "Manages inflation rate & inflation distribution" +authors.workspace = true +edition.workspace = true +homepage.workspace = true +repository.workspace = true + +[dependencies] +log = { workspace = true } +parity-scale-codec = { workspace = true } +serde = { workspace = true } + +astar-primitives = { workspace = true } +frame-support = { workspace = true } +frame-system = { workspace = true } +scale-info = { workspace = true } +sp-runtime = { workspace = true } +sp-std = { workspace = true } + +frame-benchmarking = { workspace = true, optional = true } + +[dev-dependencies] +pallet-balances = { workspace = true } +sp-core = { workspace = true } + +[features] +default = ["std"] +std = [ + "parity-scale-codec/std", + "log/std", + "sp-core/std", + "scale-info/std", + "serde/std", + "sp-std/std", + "frame-support/std", + "frame-system/std", + "pallet-balances/std", + "astar-primitives/std", +] +runtime-benchmarks = [ + "frame-benchmarking", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "sp-runtime/runtime-benchmarks", + "astar-primitives/runtime-benchmarks", +] +try-runtime = ["frame-support/try-runtime"] diff --git a/pallets/inflation/src/benchmarking.rs b/pallets/inflation/src/benchmarking.rs new file mode 100644 index 0000000000..113c5f32a7 --- /dev/null +++ b/pallets/inflation/src/benchmarking.rs @@ -0,0 +1,168 @@ +// 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 super::*; + +use frame_benchmarking::v2::*; +use frame_system::{Pallet as System, RawOrigin}; + +/// Assert that the last event equals the provided one. +fn assert_last_event(generic_event: ::RuntimeEvent) { + System::::assert_last_event(generic_event.into()); +} + +// Set up initial config in the database, so it's not empty. +fn initial_config() { + // Some dummy inflation params + let params = InflationParameters { + max_inflation_rate: Perquintill::from_percent(7), + treasury_part: Perquintill::from_percent(5), + collators_part: Perquintill::from_percent(3), + dapps_part: Perquintill::from_percent(20), + base_stakers_part: Perquintill::from_percent(25), + adjustable_stakers_part: Perquintill::from_percent(35), + bonus_part: Perquintill::from_percent(12), + ideal_staking_rate: Perquintill::from_percent(50), + }; + assert!(params.is_valid()); + + // Some dummy inflation config + let total_issuance = T::Currency::total_issuance(); + let issuance_safety_cap = + total_issuance.saturating_add(params.max_inflation_rate * total_issuance); + let config = InflationConfiguration { + recalculation_block: 123, + issuance_safety_cap, + collator_reward_per_block: 11111, + treasury_reward_per_block: 33333, + dapp_reward_pool_per_era: 55555, + base_staker_reward_pool_per_era: 77777, + adjustable_staker_reward_pool_per_era: 99999, + bonus_reward_pool_per_period: 123987, + ideal_staking_rate: Perquintill::from_percent(50), + }; + + InflationParams::::put(params); + ActiveInflationConfig::::put(config); + + // Create some issuance so it's not zero + let dummy_account = whitelisted_caller(); + T::Currency::make_free_balance_be(&dummy_account, 1_000_000_000_000_000_000_000); +} + +#[benchmarks] +mod benchmarks { + use super::*; + + #[benchmark] + fn force_set_inflation_params() { + initial_config::(); + + let params = InflationParameters::default(); + assert!(params.is_valid()); + + #[extrinsic_call] + _(RawOrigin::Root, params); + + assert_last_event::(Event::::InflationParametersForceChanged.into()); + } + + #[benchmark] + fn force_set_inflation_config() { + initial_config::(); + let config = InflationConfiguration::default(); + + #[extrinsic_call] + _(RawOrigin::Root, config.clone()); + + assert_last_event::(Event::::InflationConfigurationForceChanged { config }.into()); + } + + #[benchmark] + fn force_inflation_recalculation() { + initial_config::(); + + #[extrinsic_call] + _(RawOrigin::Root); + + let config = ActiveInflationConfig::::get(); + assert_last_event::(Event::::ForcedInflationRecalculation { config }.into()); + } + + #[benchmark] + fn hook_with_recalculation() { + initial_config::(); + + ActiveInflationConfig::::mutate(|config| { + config.recalculation_block = 0; + }); + let init_issuance = T::Currency::total_issuance(); + + let block = 1; + #[block] + { + Pallet::::on_initialize(block); + Pallet::::on_finalize(block); + } + + assert!(ActiveInflationConfig::::get().recalculation_block > 0); + + // The 'sane' assumption is that at least something will be issued for treasury & collators + assert!(T::Currency::total_issuance() > init_issuance); + } + + #[benchmark] + fn hook_without_recalculation() { + initial_config::(); + + ActiveInflationConfig::::mutate(|config| { + config.recalculation_block = 2; + }); + let init_config = ActiveInflationConfig::::get(); + let init_issuance = T::Currency::total_issuance(); + + // Has to be at least 2 blocks less than the recalculation block. + let block = 0; + #[block] + { + Pallet::::on_initialize(block); + Pallet::::on_finalize(block); + } + + assert_eq!(ActiveInflationConfig::::get(), init_config); + + // The 'sane' assumption is that at least something will be issued for treasury & collators + assert!(T::Currency::total_issuance() > init_issuance); + } + + impl_benchmark_test_suite!( + Pallet, + crate::benchmarking::tests::new_test_ext(), + crate::mock::Test, + ); +} + +#[cfg(test)] +mod tests { + use crate::mock; + use frame_support::sp_io::TestExternalities; + + pub fn new_test_ext() -> TestExternalities { + mock::ExternalityBuilder::build() + } +} diff --git a/pallets/inflation/src/lib.rs b/pallets/inflation/src/lib.rs new file mode 100644 index 0000000000..b643fe5e32 --- /dev/null +++ b/pallets/inflation/src/lib.rs @@ -0,0 +1,638 @@ +// 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 . + +//! # Inflation Handler Pallet +//! +//! ## Overview +//! +//! This pallet's main responsibility is handling inflation calculation & distribution. +//! +//! Inflation configuration is calculated periodically, according to the inflation parameters. +//! Based on this configuration, rewards are paid out - either per block or on demand. +//! +//! ## Cycles, Periods, Eras +//! +//! At the start of each cycle, the inflation configuration is recalculated. +//! +//! Cycle can be considered as a 'year' in the Astar network. +//! When cycle starts, inflation is calculated according to the total issuance at that point in time. +//! E.g. if 'yearly' inflation is set to be 7%, and total issuance is 200 ASTR, then the max inflation for that cycle will be 14 ASTR. +//! +//! Each cycle consists of one or more `periods`. +//! Periods are integral part of dApp staking protocol, allowing dApps to promotove themselves, attract stakers and earn rewards. +//! At the end of each period, all stakes are reset, and dApps need to repeat the process. +//! +//! Each period consists of two subperiods: `Voting` and `Build&Earn`. +//! Length of these subperiods is expressed in eras. An `era` is the core _time unit_ in dApp staking protocol. +//! When an era ends, in `Build&Earn` subperiod, rewards for dApps are calculated & assigned. +//! +//! Era's length is expressed in blocks. E.g. an era can last for 7200 blocks, which is approximately 1 day for 12 second block time. +//! +//! `Build&Earn` subperiod length is expressed in eras. E.g. if `Build&Earn` subperiod lasts for 5 eras, it means that during that subperiod, +//! dApp rewards will be calculated & assigned 5 times in total. Also, 5 distinct eras will change during that subperiod. If e.g. `Build&Earn` started at era 100, +//! with 5 eras per `Build&Earn` subperiod, then the subperiod will end at era 105. +//! +//! `Voting` subperiod always comes before `Build&Earn` subperiod. Its length is also expressed in eras, although it has to be interpreted a bit differently. +//! Even though `Voting` can last for more than 1 era in respect of length, it always takes exactly 1 era. +//! What this means is that if `Voting` lasts for 3 eras, and each era lasts 7200 blocks, then `Voting` will last for 21600 blocks. +//! But unlike `Build&Earn` subperiod, `Voting` will only take up one 'numerical' era. So if `Voting` starts at era 110, it will end at era 11. +//! +//! #### Example +//! * Cycle length: 4 periods +//! * `Voting` length: 10 eras +//! * `Build&Earn` length: 81 eras +//! * Era length: 7200 blocks +//! +//! This would mean that cycle lasts for roughly 364 days (4 * (10 + 81)). +//! +//! ## Recalculation +//! +//! When new cycle begins, inflation configuration is recalculated according to the inflation parameters & total issuance at that point in time. +//! Based on the max inflation rate, rewards for different network actors are calculated. +//! +//! Some rewards are calculated to be paid out per block, while some are per era or per period. +//! +//! ## Rewards +//! +//! ### Staker & Treasury Rewards +//! +//! These are paid out at the begininng of each block & are fixed amounts. +//! +//! ### Staker Rewards +//! +//! Staker rewards are paid out per staker, _on-demand_. +//! However, reward pool for an era is calculated at the end of each era. +//! +//! `era_reward_pool = base_staker_reward_pool_per_era + adjustable_staker_reward_pool_per_era` +//! +//! While the base staker reward pool is fixed, the adjustable part is calculated according to the total value staked & the ideal staking rate. +//! +//! ### dApp Rewards +//! +//! dApp rewards are paid out per dApp, _on-demand_. The reward is decided by the dApp staking protocol, or the tier system to be more precise. +//! This pallet only provides the total reward pool for all dApps per era. +//! +//! # Interface +//! +//! ## StakingRewardHandler +//! +//! This pallet implements `StakingRewardHandler` trait, which is used by the dApp staking protocol to get reward pools & distribute rewards. +//! + +#![cfg_attr(not(feature = "std"), no_std)] + +pub use pallet::*; + +use astar_primitives::{ + dapp_staking::{CycleConfiguration, StakingRewardHandler}, + Balance, BlockNumber, +}; +use frame_support::{ + pallet_prelude::*, + traits::{Currency, GetStorageVersion, OnRuntimeUpgrade}, +}; +use frame_system::{ensure_root, pallet_prelude::*}; +use sp_runtime::{traits::CheckedAdd, Perquintill}; +use sp_std::marker::PhantomData; + +pub mod weights; +pub use weights::WeightInfo; + +#[cfg(any(feature = "runtime-benchmarks"))] +pub mod benchmarking; + +#[cfg(test)] +mod mock; +#[cfg(test)] +mod tests; + +#[frame_support::pallet] +pub mod pallet { + + use super::*; + + /// The current storage version. + pub const STORAGE_VERSION: StorageVersion = StorageVersion::new(1); + + #[pallet::pallet] + #[pallet::storage_version(STORAGE_VERSION)] + pub struct Pallet(PhantomData); + + // Negative imbalance type of this pallet. + pub(crate) type NegativeImbalanceOf = <::Currency as Currency< + ::AccountId, + >>::NegativeImbalance; + + #[pallet::config] + pub trait Config: frame_system::Config { + /// The currency trait. + /// This has been soft-deprecated but it still needs to be used here in order to access `NegativeImbalance` + /// which is defined in the currency trait. + type Currency: Currency; + + /// Handler for 'per-block' payouts. + type PayoutPerBlock: PayoutPerBlock>; + + /// Cycle ('year') configuration - covers periods, subperiods, eras & blocks. + type CycleConfiguration: CycleConfiguration; + + /// The overarching event type. + type RuntimeEvent: From> + IsType<::RuntimeEvent>; + + /// Weight information for extrinsics in this pallet. + type WeightInfo: WeightInfo; + } + + #[pallet::event] + #[pallet::generate_deposit(pub(crate) fn deposit_event)] + pub enum Event { + /// Inflation parameters have been force changed. This will have effect on the next inflation recalculation. + InflationParametersForceChanged, + /// Inflation configuration has been force changed. This will have an immediate effect from this block. + InflationConfigurationForceChanged { config: InflationConfiguration }, + /// Inflation recalculation has been forced. + ForcedInflationRecalculation { config: InflationConfiguration }, + /// New inflation configuration has been set. + NewInflationConfiguration { config: InflationConfiguration }, + } + + #[pallet::error] + pub enum Error { + /// Sum of all parts must be one whole (100%). + InvalidInflationParameters, + } + + /// Active inflation configuration parameteres. + /// They describe current rewards, when inflation needs to be recalculated, etc. + #[pallet::storage] + #[pallet::whitelist_storage] + pub type ActiveInflationConfig = StorageValue<_, InflationConfiguration, ValueQuery>; + + /// Static inflation parameters - used to calculate active inflation configuration at certain points in time. + #[pallet::storage] + pub type InflationParams = StorageValue<_, InflationParameters, ValueQuery>; + + #[pallet::genesis_config] + #[cfg_attr(feature = "std", derive(Default))] + pub struct GenesisConfig { + pub params: InflationParameters, + } + + /// This should be executed **AFTER** other pallets that cause issuance to increase have been initialized. + #[pallet::genesis_build] + impl GenesisBuild for GenesisConfig { + fn build(&self) { + assert!(self.params.is_valid()); + + let now = frame_system::Pallet::::block_number(); + let config = Pallet::::recalculate_inflation(now); + + ActiveInflationConfig::::put(config); + InflationParams::::put(self.params); + } + } + + #[pallet::hooks] + impl Hooks for Pallet { + fn on_initialize(now: BlockNumber) -> Weight { + Self::payout_block_rewards(); + + let recalculation_weight = + if Self::is_recalculation_in_next_block(now, &ActiveInflationConfig::::get()) { + T::WeightInfo::hook_with_recalculation() + } else { + T::WeightInfo::hook_without_recalculation() + }; + + // Benchmarks won't acount for whitelisted storage access so this needs to be added manually. + // + // ActiveInflationConfig - 1 DB read + let whitelisted_weight = ::DbWeight::get().reads(1); + + recalculation_weight.saturating_add(whitelisted_weight) + } + + fn on_finalize(now: BlockNumber) { + // Recalculation is done at the block right before the re-calculation is supposed to happen. + // This is to ensure all the rewards are paid out according to the new inflation configuration from next block. + // + // If this was done in `on_initialize`, collator & treasury would receive incorrect rewards for that one block. + // + // This should be done as late as possible, to ensure all operations that modify issuance are done. + if Self::is_recalculation_in_next_block(now, &ActiveInflationConfig::::get()) { + let config = Self::recalculate_inflation(now); + ActiveInflationConfig::::put(config.clone()); + + Self::deposit_event(Event::::NewInflationConfiguration { config }); + } + } + + fn integrity_test() { + assert!(T::CycleConfiguration::periods_per_cycle() > 0); + assert!(T::CycleConfiguration::eras_per_voting_subperiod() > 0); + assert!(T::CycleConfiguration::eras_per_build_and_earn_subperiod() > 0); + assert!(T::CycleConfiguration::blocks_per_era() > 0); + } + } + + #[pallet::call] + impl Pallet { + /// Used to force-set the inflation parameters. + /// The parameters must be valid, all parts summing up to one whole (100%), otherwise the call will fail. + /// + /// Must be called by `root` origin. + /// + /// Purpose of the call is testing & handling unforseen circumstances. + #[pallet::call_index(0)] + #[pallet::weight(T::WeightInfo::force_set_inflation_params())] + pub fn force_set_inflation_params( + origin: OriginFor, + params: InflationParameters, + ) -> DispatchResult { + ensure_root(origin)?; + + ensure!(params.is_valid(), Error::::InvalidInflationParameters); + InflationParams::::put(params); + + Self::deposit_event(Event::::InflationParametersForceChanged); + + Ok(().into()) + } + + /// Used to force inflation recalculation. + /// This is done in the same way as it would be done in an appropriate block, but this call forces it. + /// + /// Must be called by `root` origin. + /// + /// Purpose of the call is testing & handling unforseen circumstances. + #[pallet::call_index(1)] + #[pallet::weight(T::WeightInfo::force_inflation_recalculation())] + pub fn force_inflation_recalculation(origin: OriginFor) -> DispatchResult { + ensure_root(origin)?; + + let config = Self::recalculate_inflation(frame_system::Pallet::::block_number()); + + ActiveInflationConfig::::put(config.clone()); + + Self::deposit_event(Event::::ForcedInflationRecalculation { config }); + + Ok(().into()) + } + + /// Used to force-set the inflation configuration. + /// The parameters aren't checked for validity, since essentially anything can be valid. + /// + /// Must be called by `root` origin. + /// + /// Purpose of the call is testing & handling unforseen circumstances. + /// + /// **NOTE:** and a TODO, remove this before deploying on mainnet. + #[pallet::call_index(2)] + #[pallet::weight(T::WeightInfo::force_set_inflation_config())] + pub fn force_set_inflation_config( + origin: OriginFor, + config: InflationConfiguration, + ) -> DispatchResult { + ensure_root(origin)?; + + ActiveInflationConfig::::put(config.clone()); + + Self::deposit_event(Event::::InflationConfigurationForceChanged { config }); + + Ok(().into()) + } + } + + impl Pallet { + /// Used to check if inflation recalculation is supposed to happen on the next block. + /// + /// This will be true even if recalculation is overdue, e.g. it should have happened in the current or older block. + fn is_recalculation_in_next_block( + now: BlockNumber, + config: &InflationConfiguration, + ) -> bool { + config.recalculation_block.saturating_sub(now) <= 1 + } + + /// Payout block rewards to the beneficiaries. + /// + /// Return the total amount issued. + fn payout_block_rewards() -> Balance { + let config = ActiveInflationConfig::::get(); + + let collator_amount = T::Currency::issue(config.collator_reward_per_block); + let treasury_amount = T::Currency::issue(config.treasury_reward_per_block); + + T::PayoutPerBlock::collators(collator_amount); + T::PayoutPerBlock::treasury(treasury_amount); + + config.collator_reward_per_block + config.treasury_reward_per_block + } + + /// Recalculates the inflation based on the total issuance & inflation parameters. + /// + /// Returns the new inflation configuration. + pub(crate) fn recalculate_inflation(now: BlockNumber) -> InflationConfiguration { + let params = InflationParams::::get(); + let total_issuance = T::Currency::total_issuance(); + + // 1. Calculate maximum emission over the period before the next recalculation. + let max_emission = params.max_inflation_rate * total_issuance; + let issuance_safety_cap = total_issuance.saturating_add(max_emission); + + // 2. Calculate distribution of max emission between different purposes. + let treasury_emission = params.treasury_part * max_emission; + let collators_emission = params.collators_part * max_emission; + let dapps_emission = params.dapps_part * max_emission; + let base_stakers_emission = params.base_stakers_part * max_emission; + let adjustable_stakers_emission = params.adjustable_stakers_part * max_emission; + let bonus_emission = params.bonus_part * max_emission; + + // 3. Calculate concrete rewards per block, era or period + + // 3.0 Convert all 'per cycle' values to the correct type (Balance). + // Also include a safety check that none of the values is zero since this would cause a division by zero. + // The configuration & integration tests must ensure this never happens, so the following code is just an additional safety measure. + let blocks_per_cycle = match T::CycleConfiguration::blocks_per_cycle() { + 0 => Balance::MAX, + blocks_per_cycle => Balance::from(blocks_per_cycle), + }; + + let build_and_earn_eras_per_cycle = + match T::CycleConfiguration::build_and_earn_eras_per_cycle() { + 0 => Balance::MAX, + build_and_earn_eras_per_cycle => Balance::from(build_and_earn_eras_per_cycle), + }; + + let periods_per_cycle = match T::CycleConfiguration::periods_per_cycle() { + 0 => Balance::MAX, + periods_per_cycle => Balance::from(periods_per_cycle), + }; + + // 3.1. Collator & Treausry rewards per block + let collator_reward_per_block = collators_emission / blocks_per_cycle; + let treasury_reward_per_block = treasury_emission / blocks_per_cycle; + + // 3.2. dApp reward pool per era + let dapp_reward_pool_per_era = dapps_emission / build_and_earn_eras_per_cycle; + + // 3.3. Staking reward pools per era + let base_staker_reward_pool_per_era = + base_stakers_emission / build_and_earn_eras_per_cycle; + let adjustable_staker_reward_pool_per_era = + adjustable_stakers_emission / build_and_earn_eras_per_cycle; + + // 3.4. Bonus reward pool per period + let bonus_reward_pool_per_period = bonus_emission / periods_per_cycle; + + // 4. Block at which the inflation must be recalculated. + let recalculation_block = now.saturating_add(T::CycleConfiguration::blocks_per_cycle()); + + // 5. Return calculated values + InflationConfiguration { + recalculation_block, + issuance_safety_cap, + collator_reward_per_block, + treasury_reward_per_block, + dapp_reward_pool_per_era, + base_staker_reward_pool_per_era, + adjustable_staker_reward_pool_per_era, + bonus_reward_pool_per_period, + ideal_staking_rate: params.ideal_staking_rate, + } + } + + /// Check if payout cap limit would be reached after payout. + fn is_payout_cap_limit_exceeded(payout: Balance) -> bool { + let config = ActiveInflationConfig::::get(); + let total_issuance = T::Currency::total_issuance(); + + let new_issuance = total_issuance.saturating_add(payout); + + if new_issuance > config.issuance_safety_cap { + log::error!("Issuance cap has been exceeded. Please report this issue ASAP!"); + } + + // Allow for 1% safety cap overflow, to prevent bad UX for users in case of rounding errors. + // This will be removed in the future once we know everything is working as expected. + let relaxed_issuance_safety_cap = config + .issuance_safety_cap + .saturating_mul(101) + .saturating_div(100); + + new_issuance > relaxed_issuance_safety_cap + } + } + + impl StakingRewardHandler for Pallet { + fn staker_and_dapp_reward_pools(total_value_staked: Balance) -> (Balance, Balance) { + let config = ActiveInflationConfig::::get(); + let total_issuance = T::Currency::total_issuance(); + + // First calculate the adjustable part of the staker reward pool, according to formula: + // adjustable_part = max_adjustable_part * min(1, total_staked_percent / ideal_staked_percent) + // (These operations are overflow & zero-division safe) + let staked_ratio = Perquintill::from_rational(total_value_staked, total_issuance); + let adjustment_factor = staked_ratio / config.ideal_staking_rate; + + let adjustable_part = adjustment_factor * config.adjustable_staker_reward_pool_per_era; + let staker_reward_pool = config + .base_staker_reward_pool_per_era + .saturating_add(adjustable_part); + + (staker_reward_pool, config.dapp_reward_pool_per_era) + } + + fn bonus_reward_pool() -> Balance { + ActiveInflationConfig::::get().bonus_reward_pool_per_period + } + + fn payout_reward(account: &T::AccountId, reward: Balance) -> Result<(), ()> { + // This is a safety measure to prevent excessive minting. + ensure!(!Self::is_payout_cap_limit_exceeded(reward), ()); + + // This can fail only if the amount is below existential deposit & the account doesn't exist, + // or if the account has no provider references. + // In both cases, the reward is lost but this can be ignored since it's extremelly unlikely + // to appear and doesn't bring any real harm. + T::Currency::deposit_creating(account, reward); + Ok(()) + } + } +} + +/// Configuration of the inflation. +/// Contains information about rewards, when inflation is recalculated, etc. +#[derive(Encode, Decode, MaxEncodedLen, Default, Copy, Clone, Debug, PartialEq, Eq, TypeInfo)] +#[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] +pub struct InflationConfiguration { + /// Block number at which the inflation must be recalculated, based on the total issuance at that block. + #[codec(compact)] + pub recalculation_block: BlockNumber, + /// Maximum amount of issuance we can have during this cycle. + #[codec(compact)] + pub issuance_safety_cap: Balance, + /// Reward for collator who produced the block. Always deposited the collator in full. + #[codec(compact)] + pub collator_reward_per_block: Balance, + /// Part of the inflation going towards the treasury. Always deposited in full. + #[codec(compact)] + pub treasury_reward_per_block: Balance, + /// dApp reward pool per era - based on this the tier rewards are calculated. + /// There's no guarantee that this whole amount will be minted & distributed. + #[codec(compact)] + pub dapp_reward_pool_per_era: Balance, + /// Base staker reward pool per era - this is always provided to stakers, regardless of the total value staked. + #[codec(compact)] + pub base_staker_reward_pool_per_era: Balance, + /// Adjustabke staker rewards, based on the total value staked. + /// This is provided to the stakers according to formula: 'pool * min(1, total_staked / ideal_staked)'. + #[codec(compact)] + pub adjustable_staker_reward_pool_per_era: Balance, + /// Bonus reward pool per period, for loyal stakers. + #[codec(compact)] + pub bonus_reward_pool_per_period: Balance, + /// The ideal staking rate, in respect to total issuance. + /// Used to derive exact amount of adjustable staker rewards. + #[codec(compact)] + pub ideal_staking_rate: Perquintill, +} + +/// Inflation parameters. +/// +/// The parts of the inflation that go towards different purposes must add up to exactly 100%. +#[derive(Encode, Decode, MaxEncodedLen, Copy, Clone, Debug, PartialEq, Eq, TypeInfo)] +#[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] +pub struct InflationParameters { + /// Maximum possible inflation rate, based on the total issuance at some point in time. + /// From this value, all the other inflation parameters are derived. + #[codec(compact)] + pub max_inflation_rate: Perquintill, + /// Portion of the inflation that goes towards the treasury. + #[codec(compact)] + pub treasury_part: Perquintill, + /// Portion of the inflation that goes towards collators. + #[codec(compact)] + pub collators_part: Perquintill, + /// Portion of the inflation that goes towards dApp rewards (tier rewards). + #[codec(compact)] + pub dapps_part: Perquintill, + /// Portion of the inflation that goes towards base staker rewards. + #[codec(compact)] + pub base_stakers_part: Perquintill, + /// Portion of the inflation that can go towards the adjustable staker rewards. + /// These rewards are adjusted based on the total value staked. + #[codec(compact)] + pub adjustable_stakers_part: Perquintill, + /// Portion of the inflation that goes towards bonus staker rewards (loyalty rewards). + #[codec(compact)] + pub bonus_part: Perquintill, + /// The ideal staking rate, in respect to total issuance. + /// Used to derive exact amount of adjustable staker rewards. + #[codec(compact)] + pub ideal_staking_rate: Perquintill, +} + +impl InflationParameters { + /// `true` if sum of all percentages is `one whole`, `false` otherwise. + pub fn is_valid(&self) -> bool { + let variables = [ + &self.treasury_part, + &self.collators_part, + &self.dapps_part, + &self.base_stakers_part, + &self.adjustable_stakers_part, + &self.bonus_part, + ]; + + variables + .iter() + .fold(Some(Perquintill::zero()), |acc, part| { + if let Some(acc) = acc { + acc.checked_add(*part) + } else { + None + } + }) + == Some(Perquintill::one()) + } +} + +// Default inflation parameters, just to make sure genesis builder is happy +impl Default for InflationParameters { + fn default() -> Self { + Self { + max_inflation_rate: Perquintill::from_percent(7), + treasury_part: Perquintill::from_percent(5), + collators_part: Perquintill::from_percent(3), + dapps_part: Perquintill::from_percent(20), + base_stakers_part: Perquintill::from_percent(25), + adjustable_stakers_part: Perquintill::from_percent(35), + bonus_part: Perquintill::from_percent(12), + ideal_staking_rate: Perquintill::from_percent(50), + } + } +} + +/// Defines functions used to payout the beneficiaries of block rewards +pub trait PayoutPerBlock { + /// Payout reward to the treasury. + fn treasury(reward: Imbalance); + + /// Payout reward to the collator responsible for producing the block. + fn collators(reward: Imbalance); +} + +/// `OnRuntimeUpgrade` logic for integrating this pallet into the live network. +#[cfg(feature = "try-runtime")] +use sp_std::vec::Vec; +pub struct PalletInflationInitConfig(PhantomData<(T, P)>); +impl> OnRuntimeUpgrade for PalletInflationInitConfig { + fn on_runtime_upgrade() -> Weight { + if Pallet::::on_chain_storage_version() >= STORAGE_VERSION { + return T::DbWeight::get().reads(1); + } + + // 1. Get & set inflation parameters + let inflation_params = P::get(); + InflationParams::::put(inflation_params.clone()); + + // 2. Calculation inflation config, set it & depossit event + let now = frame_system::Pallet::::block_number(); + let config = Pallet::::recalculate_inflation(now); + ActiveInflationConfig::::put(config.clone()); + + Pallet::::deposit_event(Event::::NewInflationConfiguration { config }); + + // 3. Set version + STORAGE_VERSION.put::>(); + + log::info!("Inflation pallet storage initialized."); + + T::WeightInfo::hook_with_recalculation() + .saturating_add(T::DbWeight::get().reads_writes(1, 2)) + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(_state: Vec) -> Result<(), sp_runtime::TryRuntimeError> { + assert_eq!(Pallet::::on_chain_storage_version(), STORAGE_VERSION); + assert!(InflationParams::::get().is_valid()); + + Ok(()) + } +} diff --git a/pallets/inflation/src/mock.rs b/pallets/inflation/src/mock.rs new file mode 100644 index 0000000000..3cac1bca84 --- /dev/null +++ b/pallets/inflation/src/mock.rs @@ -0,0 +1,208 @@ +// 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_inflation, ActiveInflationConfig, CycleConfiguration, InflationConfiguration, + InflationParameters, InflationParams, NegativeImbalanceOf, PayoutPerBlock, +}; + +use frame_support::{ + construct_runtime, parameter_types, + sp_io::TestExternalities, + traits::Currency, + traits::{ConstU128, ConstU32, Hooks}, + weights::Weight, + PalletId, +}; + +use sp_core::H256; +use sp_runtime::{ + traits::{AccountIdConversion, BlakeTwo256, IdentityLookup}, + Perquintill, +}; + +use astar_primitives::{testing::Header, Balance, BlockNumber}; +pub(crate) type AccountId = u64; + +/// Initial inflation params set by the mock. +pub const INIT_PARAMS: InflationParameters = InflationParameters { + max_inflation_rate: Perquintill::from_percent(7), + treasury_part: Perquintill::from_percent(5), + collators_part: Perquintill::from_percent(3), + dapps_part: Perquintill::from_percent(20), + base_stakers_part: Perquintill::from_percent(25), + adjustable_stakers_part: Perquintill::from_percent(35), + bonus_part: Perquintill::from_percent(12), + ideal_staking_rate: Perquintill::from_percent(50), +}; + +/// Initial inflation config set by the mock. +pub const INIT_CONFIG: InflationConfiguration = InflationConfiguration { + recalculation_block: 100, + issuance_safety_cap: 1_000_000, + collator_reward_per_block: 1000, + treasury_reward_per_block: 1500, + dapp_reward_pool_per_era: 3000, + base_staker_reward_pool_per_era: 5000, + adjustable_staker_reward_pool_per_era: 7000, + bonus_reward_pool_per_period: 4000, + ideal_staking_rate: Perquintill::from_percent(50), +}; + +type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic; +type Block = frame_system::mocking::MockBlock; + +parameter_types! { + pub const BlockHashCount: BlockNumber = 250; + pub BlockWeights: frame_system::limits::BlockWeights = + frame_system::limits::BlockWeights::simple_max(Weight::from_parts(1024, 0)); +} + +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<1>; + type AccountStore = System; + type WeightInfo = (); + type HoldIdentifier = (); + type FreezeIdentifier = (); + type MaxHolds = ConstU32<0>; + type MaxFreezes = ConstU32<0>; +} +// Dummy accounts used to simulate reward beneficiaries balances +pub(crate) const TREASURY_POT: PalletId = PalletId(*b"moktrsry"); +pub(crate) const COLLATOR_POT: PalletId = PalletId(*b"mokcolat"); + +pub struct DummyPayoutPerBlock; +impl PayoutPerBlock> for DummyPayoutPerBlock { + fn treasury(reward: NegativeImbalanceOf) { + Balances::resolve_creating(&TREASURY_POT.into_account_truncating(), reward); + } + + fn collators(reward: NegativeImbalanceOf) { + Balances::resolve_creating(&COLLATOR_POT.into_account_truncating(), reward); + } +} + +pub struct DummyCycleConfiguration; +impl CycleConfiguration for DummyCycleConfiguration { + fn periods_per_cycle() -> u32 { + 5 + } + + fn eras_per_voting_subperiod() -> u32 { + 2 + } + + fn eras_per_build_and_earn_subperiod() -> u32 { + 17 + } + + fn blocks_per_era() -> u32 { + 11 + } +} + +impl pallet_inflation::Config for Test { + type Currency = Balances; + type PayoutPerBlock = DummyPayoutPerBlock; + type CycleConfiguration = DummyCycleConfiguration; + type RuntimeEvent = RuntimeEvent; + type WeightInfo = (); +} + +construct_runtime!( + pub struct Test + where + Block = Block, + NodeBlock = Block, + UncheckedExtrinsic = UncheckedExtrinsic, + { + System: frame_system, + Balances: pallet_balances, + Inflation: pallet_inflation, + } +); + +pub struct ExternalityBuilder; +impl ExternalityBuilder { + pub fn build() -> TestExternalities { + let mut storage = frame_system::GenesisConfig::default() + .build_storage::() + .unwrap(); + + // This will cause some initial issuance + pallet_balances::GenesisConfig:: { + balances: vec![(1, 9000), (2, 800), (3, 10000)], + } + .assimilate_storage(&mut storage) + .ok(); + + let mut ext = TestExternalities::from(storage); + ext.execute_with(|| { + // Set initial pallet inflation values + ActiveInflationConfig::::put(INIT_CONFIG); + InflationParams::::put(INIT_PARAMS); + + System::set_block_number(1); + Inflation::on_initialize(1); + }); + ext + } +} + +/// Advance to the specified block number. +/// Function assumes first block has been initialized. +pub(crate) fn advance_to_block(n: BlockNumber) { + while System::block_number() < n { + Inflation::on_finalize(System::block_number()); + System::set_block_number(System::block_number() + 1); + Inflation::on_initialize(System::block_number()); + } +} diff --git a/pallets/inflation/src/tests.rs b/pallets/inflation/src/tests.rs new file mode 100644 index 0000000000..518aa369c4 --- /dev/null +++ b/pallets/inflation/src/tests.rs @@ -0,0 +1,473 @@ +// 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 super::{pallet::Error, Event, *}; +use frame_support::{ + assert_noop, assert_ok, assert_storage_noop, + traits::{GenesisBuild, Hooks}, +}; +use mock::*; +use sp_runtime::{ + traits::{AccountIdConversion, BadOrigin, Zero}, + Perquintill, +}; + +#[test] +fn default_params_are_valid() { + assert!(InflationParameters::default().is_valid()); +} + +#[test] +fn force_set_inflation_params_work() { + ExternalityBuilder::build().execute_with(|| { + let mut new_params = InflationParams::::get(); + new_params.max_inflation_rate = Perquintill::from_percent(20); + assert!(new_params != InflationParams::::get(), "Sanity check"); + + // Execute call, ensure it works + assert_ok!(Inflation::force_set_inflation_params( + RuntimeOrigin::root(), + new_params + )); + System::assert_last_event(Event::InflationParametersForceChanged.into()); + + assert_eq!(InflationParams::::get(), new_params); + }) +} + +#[test] +fn force_set_inflation_params_fails() { + ExternalityBuilder::build().execute_with(|| { + let mut new_params = InflationParams::::get(); + new_params.base_stakers_part = Zero::zero(); + assert!( + !new_params.is_valid(), + "Must be invalid for check to make sense." + ); + + // Make sure it's not possible to force-set invalid params + assert_noop!( + Inflation::force_set_inflation_params(RuntimeOrigin::root(), new_params), + Error::::InvalidInflationParameters + ); + + // Make sure action is privileged + assert_noop!( + Inflation::force_set_inflation_params(RuntimeOrigin::signed(1).into(), new_params,), + BadOrigin + ); + }) +} + +#[test] +fn force_set_inflation_config_work() { + ExternalityBuilder::build().execute_with(|| { + let mut new_config = ActiveInflationConfig::::get(); + new_config.recalculation_block = new_config.recalculation_block + 50; + + // Execute call, ensure it works + assert_ok!(Inflation::force_set_inflation_config( + RuntimeOrigin::root(), + new_config + )); + System::assert_last_event( + Event::InflationConfigurationForceChanged { config: new_config }.into(), + ); + + assert_eq!(ActiveInflationConfig::::get(), new_config); + }) +} + +#[test] +fn force_set_inflation_config_fails() { + ExternalityBuilder::build().execute_with(|| { + let mut new_config = ActiveInflationConfig::::get(); + new_config.recalculation_block = new_config.recalculation_block + 50; + + // Make sure action is privileged + assert_noop!( + Inflation::force_set_inflation_config(RuntimeOrigin::signed(1), new_config), + BadOrigin + ); + }) +} + +#[test] +fn force_inflation_recalculation_work() { + ExternalityBuilder::build().execute_with(|| { + let old_config = ActiveInflationConfig::::get(); + + // Execute call, ensure it works + assert_ok!(Inflation::force_inflation_recalculation( + RuntimeOrigin::root(), + )); + + let new_config = ActiveInflationConfig::::get(); + assert!( + old_config != new_config, + "Config should change, otherwise test doesn't make sense." + ); + + System::assert_last_event( + Event::ForcedInflationRecalculation { config: new_config }.into(), + ); + }) +} +#[test] +fn force_inflation_fails_due_to_unprivileged_origin() { + ExternalityBuilder::build().execute_with(|| { + // Make sure action is privileged + assert_noop!( + Inflation::force_inflation_recalculation(RuntimeOrigin::signed(1)), + BadOrigin + ); + }) +} + +#[test] +fn inflation_recalculation_occurs_when_exepcted() { + ExternalityBuilder::build().execute_with(|| { + let init_config = ActiveInflationConfig::::get(); + + // Make sure `on_finalize` calls before the expected change are storage noops + advance_to_block(init_config.recalculation_block - 3); + assert_storage_noop!(Inflation::on_finalize(init_config.recalculation_block - 3)); + Inflation::on_initialize( + init_config.recalculation_block - 2 + ); + assert_storage_noop!(Inflation::on_finalize(init_config.recalculation_block - 2)); + Inflation::on_initialize( + init_config.recalculation_block - 1 + ); + + // One block before recalculation, on_finalize should calculate new inflation config + let init_config = ActiveInflationConfig::::get(); + let init_total_issuance = Balances::total_issuance(); + + // Finally trigger inflation recalculation. + Inflation::on_finalize(init_config.recalculation_block - 1); + + let new_config = ActiveInflationConfig::::get(); + assert!( + new_config != init_config, + "Recalculation must happen at this point." + ); + System::assert_last_event(Event::NewInflationConfiguration { config: new_config }.into()); + + assert_eq!( + Balances::total_issuance(), + init_total_issuance, + "Total issuance must not change when inflation is recalculated - nothing is minted until it's needed." + ); + + assert_eq!(new_config.issuance_safety_cap, init_total_issuance + InflationParams::::get().max_inflation_rate * init_total_issuance); + }) +} + +#[test] +fn on_initialize_reward_payout_works() { + ExternalityBuilder::build().execute_with(|| { + // Save initial state, before the payout + let config = ActiveInflationConfig::::get(); + + let init_issuance = Balances::total_issuance(); + let init_collator_pot = Balances::free_balance(&COLLATOR_POT.into_account_truncating()); + let init_treasury_pot = Balances::free_balance(&TREASURY_POT.into_account_truncating()); + + // Execute payout + Inflation::on_initialize(1); + + // Verify state post payout + let expected_reward = config.collator_reward_per_block + config.treasury_reward_per_block; + + // Balance changes are as expected + assert_eq!(Balances::total_issuance(), init_issuance + expected_reward); + assert_eq!( + Balances::free_balance(&COLLATOR_POT.into_account_truncating()), + init_collator_pot + config.collator_reward_per_block + ); + assert_eq!( + Balances::free_balance(&TREASURY_POT.into_account_truncating()), + init_treasury_pot + config.treasury_reward_per_block + ); + }) +} + +#[test] +fn inflation_parameters_validity_check_works() { + // Params to be used as anchor for the tests + let base_params = INIT_PARAMS; + assert!(base_params.is_valid(), "Sanity check."); + + // Reduction of some param, it should invalidate the whole config + let mut params = base_params; + params.base_stakers_part = params.base_stakers_part - Perquintill::from_percent(1); + assert!(!params.is_valid(), "Sum is below 100%, must fail."); + + // Increase of some param, it should invalidate the whole config + let mut params = base_params; + params.base_stakers_part = params.base_stakers_part + Perquintill::from_percent(1); + assert!(!params.is_valid(), "Sum is above 100%, must fail."); + + // Excessive increase of some param, it should invalidate the whole config + let mut params = base_params; + params.treasury_part = Perquintill::from_percent(100); + assert!(!params.is_valid(), "Sum is above 100%, must fail."); + + // Some param can be zero, as long as sum remains 100% + let mut params = base_params; + params.base_stakers_part = params.base_stakers_part + params.adjustable_stakers_part; + params.adjustable_stakers_part = Zero::zero(); + assert!(params.is_valid()); +} + +#[test] +fn inflation_recalucation_works() { + ExternalityBuilder::build().execute_with(|| { + let total_issuance = Balances::total_issuance(); + let params = InflationParams::::get(); + let now = System::block_number(); + + // Calculate new config + let new_config = Inflation::recalculate_inflation(now); + let max_emission = params.max_inflation_rate * total_issuance; + + // Verify basics are ok + assert_eq!( + new_config.recalculation_block, + now + ::CycleConfiguration::blocks_per_cycle() + ); + assert_eq!( + new_config.issuance_safety_cap, + total_issuance + max_emission, + ); + + // Verify collator rewards are as expected + assert_eq!( + new_config.collator_reward_per_block, + params.collators_part * max_emission + / Balance::from(::CycleConfiguration::blocks_per_cycle()), + ); + + // Verify treasury rewards are as expected + assert_eq!( + new_config.treasury_reward_per_block, + params.treasury_part * max_emission + / Balance::from(::CycleConfiguration::blocks_per_cycle()), + ); + + // Verify dApp rewards are as expected + assert_eq!( + new_config.dapp_reward_pool_per_era, + params.dapps_part * max_emission + / Balance::from( + ::CycleConfiguration::build_and_earn_eras_per_cycle() + ), + ); + + // Verify base & adjustable staker rewards are as expected + assert_eq!( + new_config.base_staker_reward_pool_per_era, + params.base_stakers_part * max_emission + / Balance::from( + ::CycleConfiguration::build_and_earn_eras_per_cycle() + ), + ); + assert_eq!( + new_config.adjustable_staker_reward_pool_per_era, + params.adjustable_stakers_part * max_emission + / Balance::from( + ::CycleConfiguration::build_and_earn_eras_per_cycle() + ), + ); + + // Verify bonus rewards are as expected + assert_eq!( + new_config.bonus_reward_pool_per_period, + params.bonus_part * max_emission + / Balance::from(::CycleConfiguration::periods_per_cycle()), + ); + }) +} + +#[test] +fn stakers_and_dapp_reward_pool_is_ok() { + ExternalityBuilder::build().execute_with(|| { + let total_issuance = Balances::total_issuance(); + let config = ActiveInflationConfig::::get(); + + // 1st scenario - no staked value + let (staker_pool, dapp_pool) = Inflation::staker_and_dapp_reward_pools(Zero::zero()); + assert_eq!(staker_pool, config.base_staker_reward_pool_per_era); + assert_eq!(dapp_pool, config.dapp_reward_pool_per_era); + + // 2nd scenario - there is some staked value, larger than zero, but less than ideal + let test_rate = config.ideal_staking_rate - Perquintill::from_percent(11); + let (staker_pool, dapp_pool) = + Inflation::staker_and_dapp_reward_pools(test_rate * total_issuance); + + assert_eq!( + staker_pool, + config.base_staker_reward_pool_per_era + + test_rate / config.ideal_staking_rate + * config.adjustable_staker_reward_pool_per_era + ); + assert_eq!(dapp_pool, config.dapp_reward_pool_per_era); + + // 3rd scenario - we're exactly at the ideal staking rate + let (staker_pool, dapp_pool) = + Inflation::staker_and_dapp_reward_pools(config.ideal_staking_rate * total_issuance); + + assert_eq!( + staker_pool, + config.base_staker_reward_pool_per_era + config.adjustable_staker_reward_pool_per_era + ); + assert_eq!(dapp_pool, config.dapp_reward_pool_per_era); + + // 4th scenario - we're above ideal staking rate, should be the same as at the ideal staking rate regarding the pools + let test_rate = config.ideal_staking_rate + Perquintill::from_percent(13); + let (staker_pool, dapp_pool) = + Inflation::staker_and_dapp_reward_pools(test_rate * total_issuance); + + assert_eq!( + staker_pool, + config.base_staker_reward_pool_per_era + config.adjustable_staker_reward_pool_per_era + ); + assert_eq!(dapp_pool, config.dapp_reward_pool_per_era); + + // 5th scenario - ideal staking rate is zero, entire adjustable amount is always used. + ActiveInflationConfig::::mutate(|config| { + config.ideal_staking_rate = Zero::zero(); + }); + + let (staker_pool, dapp_pool) = + Inflation::staker_and_dapp_reward_pools(Perquintill::from_percent(5) * total_issuance); + + assert_eq!( + staker_pool, + config.base_staker_reward_pool_per_era + config.adjustable_staker_reward_pool_per_era + ); + assert_eq!(dapp_pool, config.dapp_reward_pool_per_era); + }) +} + +#[test] +fn bonus_reward_pool_is_ok() { + ExternalityBuilder::build().execute_with(|| { + let config = ActiveInflationConfig::::get(); + + let bonus_pool = Inflation::bonus_reward_pool(); + assert_eq!(bonus_pool, config.bonus_reward_pool_per_period); + }) +} + +#[test] +fn basic_payout_reward_is_ok() { + ExternalityBuilder::build().execute_with(|| { + // Prepare reward payout params + let config = ActiveInflationConfig::::get(); + let account = 1; + let reward = config.issuance_safety_cap - Balances::total_issuance(); + let init_balance = Balances::free_balance(&account); + let init_issuance = Balances::total_issuance(); + + // Payout reward and verify balances are as expected + assert_ok!(Inflation::payout_reward(&account, reward)); + + assert_eq!(Balances::free_balance(&account), init_balance + reward); + assert_eq!(Balances::total_issuance(), init_issuance + reward); + }) +} + +#[test] +fn payout_reward_with_exceeded_cap_but_not_exceeded_relaxed_cap_is_ok() { + ExternalityBuilder::build().execute_with(|| { + // Prepare reward payout params + let config = ActiveInflationConfig::::get(); + let account = 1; + + let relaxed_cap = config.issuance_safety_cap * 101 / 100; + let reward = relaxed_cap - Balances::total_issuance(); + let init_balance = Balances::free_balance(&account); + let init_issuance = Balances::total_issuance(); + + // Payout reward and verify balances are as expected + assert_ok!(Inflation::payout_reward(&account, reward)); + + assert_eq!(Balances::free_balance(&account), init_balance + reward); + assert_eq!(Balances::total_issuance(), init_issuance + reward); + }) +} + +#[test] +fn payout_reward_fails_when_relaxed_cap_is_exceeded() { + ExternalityBuilder::build().execute_with(|| { + // Prepare reward payout params + let config = ActiveInflationConfig::::get(); + let account = 1; + + let relaxed_cap = config.issuance_safety_cap * 101 / 100; + let reward = relaxed_cap - Balances::total_issuance() + 1; + + // Payout should be a failure, with storage noop. + assert_noop!(Inflation::payout_reward(&account, reward), ()); + }) +} + +#[test] +fn cylcle_configuration_works() { + ExternalityBuilder::build().execute_with(|| { + type CycleConfig = ::CycleConfiguration; + + let eras_per_period = CycleConfig::eras_per_voting_subperiod() + + CycleConfig::eras_per_build_and_earn_subperiod(); + assert_eq!(CycleConfig::eras_per_period(), eras_per_period); + + let eras_per_cycle = eras_per_period * CycleConfig::periods_per_cycle(); + assert_eq!(CycleConfig::eras_per_cycle(), eras_per_cycle); + + let blocks_per_cycle = eras_per_cycle * CycleConfig::blocks_per_era(); + assert_eq!(CycleConfig::blocks_per_cycle(), blocks_per_cycle); + + let build_and_earn_eras_per_cycle = + CycleConfig::eras_per_build_and_earn_subperiod() * CycleConfig::periods_per_cycle(); + assert_eq!( + CycleConfig::build_and_earn_eras_per_cycle(), + build_and_earn_eras_per_cycle + ); + }) +} + +#[test] +fn test_genesis_build() { + ExternalityBuilder::build().execute_with(|| { + let genesis_config = InflationConfig::default(); + assert!(genesis_config.params.is_valid()); + + // Prep actions + ActiveInflationConfig::::kill(); + InflationParams::::kill(); + + // Execute genesis build + >::build(&genesis_config); + + // Verify state is as expected + assert_eq!(InflationParams::::get(), genesis_config.params); + assert!(ActiveInflationConfig::::get().recalculation_block > 0); + }) +} diff --git a/pallets/inflation/src/weights.rs b/pallets/inflation/src/weights.rs new file mode 100644 index 0000000000..08a53283c4 --- /dev/null +++ b/pallets/inflation/src/weights.rs @@ -0,0 +1,168 @@ + +// 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 . + +//! Autogenerated weights for pallet_inflation +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 4.0.0-dev +//! DATE: 2023-12-20, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! WORST CASE MAP SIZE: `1000000` +//! HOSTNAME: `devserver-01`, CPU: `Intel(R) Xeon(R) E-2236 CPU @ 3.40GHz` +//! EXECUTION: Some(Wasm), WASM-EXECUTION: Compiled, CHAIN: Some("shibuya-dev"), DB CACHE: 1024 + +// Executed Command: +// ./target/release/astar-collator +// benchmark +// pallet +// --chain=shibuya-dev +// --steps=50 +// --repeat=20 +// --pallet=pallet_inflation +// --extrinsic=* +// --execution=wasm +// --wasm-execution=compiled +// --heap-pages=4096 +// --output=./benchmark-results/shibuya-dev/inflation_weights.rs +// --template=./scripts/templates/weight-template.hbs + +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(unused_parens)] +#![allow(unused_imports)] + +use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; +use core::marker::PhantomData; + +/// Weight functions needed for pallet_inflation. +pub trait WeightInfo { + fn force_set_inflation_params() -> Weight; + fn force_set_inflation_config() -> Weight; + fn force_inflation_recalculation() -> Weight; + fn hook_with_recalculation() -> Weight; + fn hook_without_recalculation() -> Weight; +} + +/// Weights for pallet_inflation using the Substrate node and recommended hardware. +pub struct SubstrateWeight(PhantomData); +impl WeightInfo for SubstrateWeight { + /// Storage: Inflation InflationParams (r:0 w:1) + /// Proof: Inflation InflationParams (max_values: Some(1), max_size: Some(64), added: 559, mode: MaxEncodedLen) + fn force_set_inflation_params() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 9_036_000 picoseconds. + Weight::from_parts(9_186_000, 0) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + fn force_set_inflation_config() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 9_073_000 picoseconds. + Weight::from_parts(9_411_000, 0) + } + /// Storage: Inflation InflationParams (r:1 w:0) + /// Proof: Inflation InflationParams (max_values: Some(1), max_size: Some(64), added: 559, mode: MaxEncodedLen) + fn force_inflation_recalculation() -> Weight { + // Proof Size summary in bytes: + // Measured: `58` + // Estimated: `1549` + // Minimum execution time: 14_839_000 picoseconds. + Weight::from_parts(15_198_000, 1549) + .saturating_add(T::DbWeight::get().reads(1_u64)) + } + /// Storage: System Account (r:2 w:1) + /// Proof: System Account (max_values: None, max_size: Some(128), added: 2603, mode: MaxEncodedLen) + /// Storage: Inflation InflationParams (r:1 w:0) + /// Proof: Inflation InflationParams (max_values: Some(1), max_size: Some(64), added: 559, mode: MaxEncodedLen) + fn hook_with_recalculation() -> Weight { + // Proof Size summary in bytes: + // Measured: `232` + // Estimated: `6196` + // Minimum execution time: 31_965_000 picoseconds. + Weight::from_parts(32_498_000, 6196) + .saturating_add(T::DbWeight::get().reads(3_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: System Account (r:2 w:1) + /// Proof: System Account (max_values: None, max_size: Some(128), added: 2603, mode: MaxEncodedLen) + fn hook_without_recalculation() -> Weight { + // Proof Size summary in bytes: + // Measured: `174` + // Estimated: `6196` + // Minimum execution time: 22_235_000 picoseconds. + Weight::from_parts(22_378_000, 6196) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } +} + +// For backwards compatibility and tests +impl WeightInfo for () { + /// Storage: Inflation InflationParams (r:0 w:1) + /// Proof: Inflation InflationParams (max_values: Some(1), max_size: Some(64), added: 559, mode: MaxEncodedLen) + fn force_set_inflation_params() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 9_036_000 picoseconds. + Weight::from_parts(9_186_000, 0) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + fn force_set_inflation_config() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 9_073_000 picoseconds. + Weight::from_parts(9_411_000, 0) + } + /// Storage: Inflation InflationParams (r:1 w:0) + /// Proof: Inflation InflationParams (max_values: Some(1), max_size: Some(64), added: 559, mode: MaxEncodedLen) + fn force_inflation_recalculation() -> Weight { + // Proof Size summary in bytes: + // Measured: `58` + // Estimated: `1549` + // Minimum execution time: 14_839_000 picoseconds. + Weight::from_parts(15_198_000, 1549) + .saturating_add(RocksDbWeight::get().reads(1_u64)) + } + /// Storage: System Account (r:2 w:1) + /// Proof: System Account (max_values: None, max_size: Some(128), added: 2603, mode: MaxEncodedLen) + /// Storage: Inflation InflationParams (r:1 w:0) + /// Proof: Inflation InflationParams (max_values: Some(1), max_size: Some(64), added: 559, mode: MaxEncodedLen) + fn hook_with_recalculation() -> Weight { + // Proof Size summary in bytes: + // Measured: `232` + // Estimated: `6196` + // Minimum execution time: 31_965_000 picoseconds. + Weight::from_parts(32_498_000, 6196) + .saturating_add(RocksDbWeight::get().reads(3_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + /// Storage: System Account (r:2 w:1) + /// Proof: System Account (max_values: None, max_size: Some(128), added: 2603, mode: MaxEncodedLen) + fn hook_without_recalculation() -> Weight { + // Proof Size summary in bytes: + // Measured: `174` + // Estimated: `6196` + // Minimum execution time: 22_235_000 picoseconds. + Weight::from_parts(22_378_000, 6196) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } +} diff --git a/precompiles/dapp-staking-v3/Cargo.toml b/precompiles/dapp-staking-v3/Cargo.toml new file mode 100644 index 0000000000..5c40d95a5f --- /dev/null +++ b/precompiles/dapp-staking-v3/Cargo.toml @@ -0,0 +1,62 @@ +[package] +name = "pallet-evm-precompile-dapp-staking-v3" +version = "0.1.0" +license = "GPL-3.0-or-later" +description = "dApp Staking EVM precompiles" +authors.workspace = true +edition.workspace = true +homepage.workspace = true +repository.workspace = true + +[dependencies] +log = { workspace = true } +num_enum = { workspace = true } +parity-scale-codec = { workspace = true } +scale-info = { workspace = true } + +frame-support = { workspace = true } +frame-system = { workspace = true } + +sp-core = { workspace = true } +sp-runtime = { workspace = true } +sp-std = { workspace = true } + +# Astar +astar-primitives = { workspace = true } +pallet-dapp-staking-v3 = { workspace = true } +precompile-utils = { workspace = true, default-features = false } + +# Frontier +fp-evm = { workspace = true } +pallet-evm = { workspace = true } + +[dev-dependencies] +assert_matches = { workspace = true } +derive_more = { workspace = true } +pallet-balances = { workspace = true, features = ["std"] } +pallet-timestamp = { workspace = true } +precompile-utils = { workspace = true, features = ["testing"] } +serde = { workspace = true } +sha3 = { workspace = true } +sp-arithmetic = { workspace = true } +sp-io = { workspace = true } + +[features] +default = ["std"] +std = [ + "parity-scale-codec/std", + "scale-info/std", + "astar-primitives/std", + "sp-std/std", + "sp-core/std", + "sp-runtime/std", + "fp-evm/std", + "frame-support/std", + "frame-system/std", + "pallet-dapp-staking-v3/std", + "pallet-evm/std", + "precompile-utils/std", + "pallet-balances/std", + "sp-arithmetic/std", +] +runtime-benchmarks = ["pallet-dapp-staking-v3/runtime-benchmarks"] diff --git a/precompiles/dapp-staking-v3/DappsStakingV1.sol b/precompiles/dapp-staking-v3/DappsStakingV1.sol new file mode 100644 index 0000000000..9b13429413 --- /dev/null +++ b/precompiles/dapp-staking-v3/DappsStakingV1.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: BSD-3-Clause + +pragma solidity >=0.8.0; + +/// Predeployed at the address 0x0000000000000000000000000000000000005001 +/// For better understanding check the source code: +/// repo: https://github.com/AstarNetwork/Astar +/// +/// **NOTE:** This is a soft-deprecated interface used by the old dApps staking v2. +/// It is still supported by the network, but doesn't reflect how dApp staking v3 should be used. +/// Please refer to the `v2` interface for the latest version of the dApp staking contract. +/// +/// It is possible that dApp staking feature will once again evolve in the future so all developers are encouraged +/// to keep their smart contracts which utilize dApp staking precompile interface as upgradable, or implement their logic +/// in such a way it's relatively simple to migrate to the new version of the interface. +interface DappsStaking { + + // Types + + /// Instruction how to handle reward payout for staker. + /// `FreeBalance` - Reward will be paid out to the staker (free balance). + /// `StakeBalance` - Reward will be paid out to the staker and is immediately restaked (locked balance) + enum RewardDestination {FreeBalance, StakeBalance} + + // Storage getters + + /// @notice Read current era. + /// @return era: The current era + function read_current_era() external view returns (uint256); + + /// @notice Read the unbonding period (or unlocking period) in the number of eras. + /// @return period: The unbonding period in eras + function read_unbonding_period() external view returns (uint256); + + /// @notice Read Total network reward for the given era - sum of staker & dApp rewards. + /// @return reward: Total network reward for the given era + function read_era_reward(uint32 era) external view returns (uint128); + + /// @notice Read Total staked amount for the given era + /// @return staked: Total staked amount for the given era + function read_era_staked(uint32 era) external view returns (uint128); + + /// @notice Read Staked amount for the staker + /// @param staker: The staker address in form of 20 or 32 hex bytes + /// @return amount: Staked amount by the staker + function read_staked_amount(bytes calldata staker) external view returns (uint128); + + /// @notice Read Staked amount on a given contract for the staker + /// @param contract_id: The smart contract address used for staking + /// @param staker: The staker address in form of 20 or 32 hex bytes + /// @return amount: Staked amount by the staker + function read_staked_amount_on_contract(address contract_id, bytes calldata staker) external view returns (uint128); + + /// @notice Read the staked amount from the era when the amount was last staked/unstaked + /// @return total: The most recent total staked amount on contract + function read_contract_stake(address contract_id) external view returns (uint128); + + + // Extrinsic calls + + /// @notice Register is root origin only and not allowed via evm precompile. + /// This should always fail. + function register(address) external returns (bool); + + /// @notice Stake provided amount on the contract. + function bond_and_stake(address, uint128) external returns (bool); + + /// @notice Start unbonding process and unstake balance from the contract. + function unbond_and_unstake(address, uint128) external returns (bool); + + /// @notice Withdraw all funds that have completed the unbonding process. + function withdraw_unbonded() external returns (bool); + + /// @notice Claim earned staker rewards for the oldest unclaimed era. + /// In order to claim multiple eras, this call has to be called multiple times. + /// Staker account is derived from the caller address. + /// @param smart_contract: The smart contract address used for staking + function claim_staker(address smart_contract) external returns (bool); + + /// @notice Claim one era of unclaimed dapp rewards for the specified contract and era. + /// @param smart_contract: The smart contract address used for staking + /// @param era: The era to be claimed + function claim_dapp(address smart_contract, uint128 era) external returns (bool); + + /// @notice Set reward destination for staker rewards + /// @param reward_destination: The instruction on how the reward payout should be handled + function set_reward_destination(RewardDestination reward_destination) external returns (bool); + + /// @notice Withdraw staked funds from an unregistered contract. + /// @param smart_contract: The smart contract address used for staking + function withdraw_from_unregistered(address smart_contract) external returns (bool); + + /// @notice Transfer part or entire nomination from origin smart contract to target smart contract + /// @param origin_smart_contract: The origin smart contract address + /// @param amount: The amount to transfer from origin to target + /// @param target_smart_contract: The target smart contract address + function nomination_transfer(address origin_smart_contract, uint128 amount, address target_smart_contract) external returns (bool); +} diff --git a/precompiles/dapp-staking-v3/DappsStakingV2.sol b/precompiles/dapp-staking-v3/DappsStakingV2.sol new file mode 100644 index 0000000000..40c55af6c4 --- /dev/null +++ b/precompiles/dapp-staking-v3/DappsStakingV2.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: BSD-3-Clause + +pragma solidity >=0.8.0; + +/// Predeployed at the address 0x0000000000000000000000000000000000005001 +/// For better understanding check the source code: +/// repo: https://github.com/AstarNetwork/Astar +/// code: pallets/dapp-staking-v3 +interface DAppStaking { + + // Types + + /// Describes the subperiod in which the protocol currently is. + enum Subperiod {Voting, BuildAndEarn} + + /// Describes current smart contract types supported by the network. + enum SmartContractType {EVM, WASM} + + /// @notice Describes protocol state. + /// @param era: Ongoing era number. + /// @param period: Ongoing period number. + /// @param subperiod: Ongoing subperiod type. + struct ProtocolState { + uint256 era; + uint256 period; + Subperiod subperiod; + } + + /// @notice Used to describe smart contract. Astar supports both EVM & WASM smart contracts + /// so it's important to differentiate between the two. This approach also allows + /// easy extensibility in the future. + /// @param contract_type: Type of the smart contract to be used + struct SmartContract { + SmartContractType contract_type; + bytes contract_address; + } + + // Storage getters + + /// @notice Get the current protocol state. + /// @return (current era, current period number, current subperiod type). + function protocol_state() external view returns (ProtocolState memory); + + /// @notice Get the unlocking period expressed in the number of blocks. + /// @return period: The unlocking period expressed in the number of blocks. + function unlocking_period() external view returns (uint256); + + + // Extrinsic calls + + /// @notice Lock the given amount of tokens into dApp staking protocol. + /// @param amount: The amount of tokens to be locked. + function lock(uint128 amount) external returns (bool); + + /// @notice Start the unlocking process for the given amount of tokens. + /// @param amount: The amount of tokens to be unlocked. + function unlock(uint128 amount) external returns (bool); + + /// @notice Claims unlocked tokens, if there are any + function claim_unlocked() external returns (bool); + + /// @notice Stake the given amount of tokens on the specified smart contract. + /// The amount specified must be precise, otherwise the call will fail. + /// @param smart_contract: The smart contract to be staked on. + /// @param amount: The amount of tokens to be staked. + function stake(SmartContract calldata smart_contract, uint128 amount) external returns (bool); + + /// @notice Unstake the given amount of tokens from the specified smart contract. + /// The amount specified must be precise, otherwise the call will fail. + /// @param smart_contract: The smart contract to be unstaked from. + /// @param amount: The amount of tokens to be unstaked. + function unstake(SmartContract calldata smart_contract, uint128 amount) external returns (bool); + + /// @notice Claims one or more pending staker rewards. + function claim_staker_rewards() external returns (bool); + + /// @notice Claim the bonus reward for the specified smart contract. + /// @param smart_contract: The smart contract for which the bonus reward should be claimed. + function claim_bonus_reward(SmartContract calldata smart_contract) external returns (bool); + + /// @notice Claim dApp reward for the specified smart contract & era. + /// @param smart_contract: The smart contract for which the dApp reward should be claimed. + /// @param era: The era for which the dApp reward should be claimed. + function claim_dapp_reward(SmartContract calldata smart_contract, uint256 era) external returns (bool); + + /// @notice Unstake all funds from the unregistered smart contract. + /// @param smart_contract: The smart contract which was unregistered and from which all funds should be unstaked. + function unstake_from_unregistered(SmartContract calldata smart_contract) external returns (bool); + + /// @notice Used to cleanup all expired contract stake entries from the caller. + function cleanup_expired_entries() external returns (bool); +} diff --git a/precompiles/dapp-staking-v3/src/lib.rs b/precompiles/dapp-staking-v3/src/lib.rs new file mode 100644 index 0000000000..5178395be5 --- /dev/null +++ b/precompiles/dapp-staking-v3/src/lib.rs @@ -0,0 +1,832 @@ +// 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 . + +//! Astar dApp staking interface. + +#![cfg_attr(not(feature = "std"), no_std)] + +use fp_evm::PrecompileHandle; +use num_enum::{IntoPrimitive, TryFromPrimitive}; +use parity_scale_codec::MaxEncodedLen; + +use frame_support::{ + dispatch::{Dispatchable, GetDispatchInfo, PostDispatchInfo}, + ensure, + traits::ConstU32, +}; + +use pallet_evm::AddressMapping; +use precompile_utils::{ + prelude::*, + solidity::{ + codec::{Reader, Writer}, + Codec, + }, +}; +use sp_core::{Get, H160, U256}; +use sp_runtime::traits::Zero; +use sp_std::{marker::PhantomData, prelude::*}; +extern crate alloc; + +use astar_primitives::{dapp_staking::SmartContractHandle, AccountId, Balance}; +use pallet_dapp_staking_v3::{ + AccountLedgerFor, ActiveProtocolState, ContractStake, ContractStakeAmount, CurrentEraInfo, + DAppInfoFor, EraInfo, EraRewardSpanFor, EraRewards, IntegratedDApps, Ledger, + Pallet as DAppStaking, ProtocolState, SingularStakingInfo, StakerInfo, Subperiod, +}; + +pub const STAKER_BYTES_LIMIT: u32 = 32; +type GetStakerBytesLimit = ConstU32; + +pub type DynamicAddress = BoundedBytes; + +#[cfg(test)] +mod test; + +/// Helper struct used to encode protocol state. +#[derive(Debug, Clone, solidity::Codec)] +pub(crate) struct PrecompileProtocolState { + era: U256, + period: U256, + subperiod: u8, +} + +/// Helper struct used to encode different smart contract types for the v2 interface. +#[derive(Debug, Clone, solidity::Codec)] +pub struct SmartContractV2 { + contract_type: SmartContractTypes, + address: DynamicAddress, +} + +/// Convenience type for smart contract type handling. +#[derive(Clone, Debug, Eq, PartialEq, IntoPrimitive, TryFromPrimitive)] +#[repr(u8)] +pub(crate) enum SmartContractTypes { + Evm, + Wasm, +} + +impl Codec for SmartContractTypes { + fn read(reader: &mut Reader) -> MayRevert { + let value256: U256 = reader + .read() + .map_err(|_| RevertReason::read_out_of_bounds(Self::signature()))?; + + let value_as_u8: u8 = value256 + .try_into() + .map_err(|_| RevertReason::value_is_too_large(Self::signature()))?; + + value_as_u8 + .try_into() + .map_err(|_| RevertReason::custom("Unknown smart contract type").into()) + } + + fn write(writer: &mut Writer, value: Self) { + let value_as_u8: u8 = value.into(); + U256::write(writer, value_as_u8.into()); + } + + fn has_static_size() -> bool { + true + } + + fn signature() -> String { + "uint8".into() + } +} + +pub struct DappStakingV3Precompile(PhantomData); +#[precompile_utils::precompile] +impl DappStakingV3Precompile +where + R: pallet_evm::Config + + pallet_dapp_staking_v3::Config + + frame_system::Config, + ::RuntimeOrigin: From>, + R::RuntimeCall: Dispatchable + GetDispatchInfo, + R::RuntimeCall: From>, +{ + // v1 functions + + /// Read the ongoing `era` number. + #[precompile::public("read_current_era()")] + #[precompile::view] + fn read_current_era(handle: &mut impl PrecompileHandle) -> EvmResult { + // TODO: benchmark this function so we can measure ref time & PoV correctly + // Storage item: ActiveProtocolState: + // Twox64(8) + ProtocolState::max_encoded_len + handle.record_db_read::(8 + ProtocolState::max_encoded_len())?; + + let current_era = ActiveProtocolState::::get().era; + + Ok(current_era.into()) + } + + /// Read the `unbonding period` or `unlocking period` expressed in the number of eras. + #[precompile::public("read_unbonding_period()")] + #[precompile::view] + fn read_unbonding_period(_: &mut impl PrecompileHandle) -> EvmResult { + // constant, no DB read + Ok(::UnlockingPeriod::get().into()) + } + + /// Read the total assigned reward pool for the given era. + /// + /// Total amount is sum of staker & dApp rewards. + #[precompile::public("read_era_reward(uint32)")] + #[precompile::view] + fn read_era_reward(handle: &mut impl PrecompileHandle, era: u32) -> EvmResult { + // TODO: benchmark this function so we can measure ref time & PoV correctly + // Storage item: EraRewards: + // Twox64Concat(8) + EraIndex(4) + EraRewardSpanFor::max_encoded_len + handle.record_db_read::(12 + EraRewardSpanFor::::max_encoded_len())?; + + // Get the appropriate era reward span + let era_span_index = DAppStaking::::era_reward_span_index(era); + let reward_span = + EraRewards::::get(&era_span_index).unwrap_or(EraRewardSpanFor::::new()); + + // Sum up staker & dApp reward pools for the era + let reward = reward_span.get(era).map_or(Zero::zero(), |r| { + r.staker_reward_pool.saturating_add(r.dapp_reward_pool) + }); + + Ok(reward) + } + + /// Read the total staked amount for the given era. + /// + /// In case era is very far away in history, it's possible that the information is not available. + /// In that case, zero is returned. + /// + /// This is safe to use for current era and the next one. + #[precompile::public("read_era_staked(uint32)")] + #[precompile::view] + fn read_era_staked(handle: &mut impl PrecompileHandle, era: u32) -> EvmResult { + // TODO: benchmark this function so we can measure ref time & PoV correctly + // Storage item: ActiveProtocolState: + // Twox64(8) + ProtocolState::max_encoded_len + handle.record_db_read::(8 + ProtocolState::max_encoded_len())?; + + let current_era = ActiveProtocolState::::get().era; + + // There are few distinct scenenarios: + // 1. Era is in the past so the value might exist. + // 2. Era is current or the next one, in which case we definitely have that information. + // 3. Era is from the future (more than the next era), in which case we don't have that information. + if era < current_era { + // TODO: benchmark this function so we can measure ref time & PoV correctly + // Storage item: EraRewards: + // Twox64Concat(8) + Twox64Concat(8 + EraIndex(4)) + EraRewardSpanFor::max_encoded_len + handle.record_db_read::(20 + EraRewardSpanFor::::max_encoded_len())?; + + let era_span_index = DAppStaking::::era_reward_span_index(era); + let reward_span = + EraRewards::::get(&era_span_index).unwrap_or(EraRewardSpanFor::::new()); + + let staked = reward_span.get(era).map_or(Zero::zero(), |r| r.staked); + + Ok(staked.into()) + } else if era == current_era || era == current_era.saturating_add(1) { + // TODO: benchmark this function so we can measure ref time & PoV correctly + // Storage item: CurrentEraInfo: + // Twox64Concat(8) + EraInfo::max_encoded_len + handle.record_db_read::(8 + EraInfo::max_encoded_len())?; + + let current_era_info = CurrentEraInfo::::get(); + + if era == current_era { + Ok(current_era_info.current_stake_amount.total()) + } else { + Ok(current_era_info.next_stake_amount.total()) + } + } else { + Err(RevertReason::custom("Era is in the future").into()) + } + } + + /// Read the total staked amount by the given account. + #[precompile::public("read_staked_amount(bytes)")] + #[precompile::view] + fn read_staked_amount( + handle: &mut impl PrecompileHandle, + staker: DynamicAddress, + ) -> EvmResult { + // TODO: benchmark this function so we can measure ref time & PoV correctly + // Storage item: ActiveProtocolState: + // Twox64(8) + ProtocolState::max_encoded_len + // Storage item: Ledger: + // Blake2_128Concat(16 + SmartContract::max_encoded_len) + Ledger::max_encoded_len + handle.record_db_read::( + 24 + AccountLedgerFor::::max_encoded_len() + + ProtocolState::max_encoded_len() + + ::SmartContract::max_encoded_len(), + )?; + + let staker = Self::parse_input_address(staker.into())?; + + // read the account's ledger + let ledger = Ledger::::get(&staker); + log::trace!(target: "ds-precompile", "read_staked_amount for account: {:?}, ledger: {:?}", staker, ledger); + + // Make sure to check staked amount against the ongoing period (past period stakes are reset to zero). + let current_period_number = ActiveProtocolState::::get().period_number(); + + Ok(ledger.staked_amount(current_period_number)) + } + + /// Read the total staked amount by the given staker on the given contract. + #[precompile::public("read_staked_amount_on_contract(address,bytes)")] + #[precompile::view] + fn read_staked_amount_on_contract( + handle: &mut impl PrecompileHandle, + contract_h160: Address, + staker: DynamicAddress, + ) -> EvmResult { + // TODO: benchmark this function so we can measure ref time & PoV correctly + // Storage item: ActiveProtocolState: + // Twox64(8) + ProtocolState::max_encoded_len + // Storage item: StakerInfo: + // Blake2_128Concat(16 + SmartContract::max_encoded_len) + SingularStakingInfo::max_encoded_len + handle.record_db_read::( + 24 + ProtocolState::max_encoded_len() + + ::SmartContract::max_encoded_len() + + SingularStakingInfo::max_encoded_len(), + )?; + + let smart_contract = + ::SmartContract::evm(contract_h160.into()); + + // parse the staker account + let staker = Self::parse_input_address(staker.into())?; + + // Get staking info for the staker/contract combination + let staking_info = StakerInfo::::get(&staker, &smart_contract).unwrap_or_default(); + log::trace!(target: "ds-precompile", "read_staked_amount_on_contract for account:{:?}, staking_info: {:?}", staker, staking_info); + + // Ensure that the staking info is checked against the current period (stakes from past periods are reset) + let current_period_number = ActiveProtocolState::::get().period_number(); + + if staking_info.period_number() == current_period_number { + Ok(staking_info.total_staked_amount()) + } else { + Ok(0_u128) + } + } + + /// Read the total amount staked on the given contract right now. + #[precompile::public("read_contract_stake(address)")] + #[precompile::view] + fn read_contract_stake( + handle: &mut impl PrecompileHandle, + contract_h160: Address, + ) -> EvmResult { + // TODO: benchmark this function so we can measure ref time & PoV correctly + // Storage item: ActiveProtocolState: + // Twox64(8) + ProtocolState::max_encoded_len + // Storage item: IntegratedDApps: + // Blake2_128Concat(16 + SmartContract::max_encoded_len) + DAppInfoFor::max_encoded_len + // Storage item: ContractStake: + // Twox64Concat(8) + EraIndex(4) + ContractStakeAmount::max_encoded_len + handle.record_db_read::( + 36 + ProtocolState::max_encoded_len() + + ::SmartContract::max_encoded_len() + + DAppInfoFor::::max_encoded_len() + + ContractStakeAmount::max_encoded_len(), + )?; + + let smart_contract = + ::SmartContract::evm(contract_h160.into()); + + let current_period_number = ActiveProtocolState::::get().period_number(); + let dapp_info = match IntegratedDApps::::get(&smart_contract) { + Some(dapp_info) => dapp_info, + None => { + // If the contract is not registered, return 0 to keep the legacy behavior. + return Ok(0_u128); + } + }; + + // call pallet-dapps-staking + let contract_stake = ContractStake::::get(&dapp_info.id); + + Ok(contract_stake.total_staked_amount(current_period_number)) + } + + /// Register contract with the dapp-staking pallet + /// Register is root origin only. This should always fail when called via evm precompile. + #[precompile::public("register(address)")] + fn register(_: &mut impl PrecompileHandle, _address: Address) -> EvmResult { + // register is root-origin call. it should always fail when called via evm precompiles. + Err(RevertReason::custom("register via evm precompile is not allowed").into()) + } + + /// Lock & stake some amount on the specified contract. + /// + /// In case existing `stakeable` is sufficient to cover the given `amount`, only the `stake` operation is performed. + /// Otherwise, best effort is done to lock the additional amount so `stakeable` amount can cover the given `amount`. + #[precompile::public("bond_and_stake(address,uint128)")] + fn bond_and_stake( + handle: &mut impl PrecompileHandle, + contract_h160: Address, + amount: u128, + ) -> EvmResult { + // TODO: benchmark this function so we can measure ref time & PoV correctly + // Storage item: ActiveProtocolState: + // Twox64(8) + ProtocolState::max_encoded_len + // Storage item: Ledger: + // Blake2_128Concat(16 + SmartContract::max_encoded_len()) + Ledger::max_encoded_len + handle.record_db_read::( + 24 + AccountLedgerFor::::max_encoded_len() + + ProtocolState::max_encoded_len() + + ::SmartContract::max_encoded_len(), + )?; + + let smart_contract = + ::SmartContract::evm(contract_h160.into()); + log::trace!(target: "ds-precompile", "bond_and_stake {:?}, {:?}", smart_contract, amount); + + // Read total locked & staked amounts + let origin = R::AddressMapping::into_account_id(handle.context().caller); + let protocol_state = ActiveProtocolState::::get(); + let ledger = Ledger::::get(&origin); + + // Check if stakeable amount is enough to cover the given `amount` + let stakeable_amount = ledger.stakeable_amount(protocol_state.period_number()); + + // If it isn't, we need to first lock the additional amount. + if stakeable_amount < amount { + let delta = amount.saturating_sub(stakeable_amount); + + let lock_call = pallet_dapp_staking_v3::Call::::lock { amount: delta }; + RuntimeHelper::::try_dispatch(handle, Some(origin.clone()).into(), lock_call)?; + } + + // Now, with best effort, we can try & stake the given `value`. + let stake_call = pallet_dapp_staking_v3::Call::::stake { + smart_contract, + amount, + }; + RuntimeHelper::::try_dispatch(handle, Some(origin).into(), stake_call)?; + + Ok(true) + } + + /// Start unbonding process and unstake balance from the contract. + #[precompile::public("unbond_and_unstake(address,uint128)")] + fn unbond_and_unstake( + handle: &mut impl PrecompileHandle, + contract_h160: Address, + amount: u128, + ) -> EvmResult { + // TODO: benchmark this function so we can measure ref time & PoV correctly + // Storage item: ActiveProtocolState: + // Twox64(8) + ProtocolState::max_encoded_len + // Storage item: StakerInfo: + // Blake2_128Concat(16 + SmartContract::max_encoded_len) + SingularStakingInfo::max_encoded_len + handle.record_db_read::( + 24 + ProtocolState::max_encoded_len() + + ::SmartContract::max_encoded_len() + + SingularStakingInfo::max_encoded_len(), + )?; + + let smart_contract = + ::SmartContract::evm(contract_h160.into()); + let origin = R::AddressMapping::into_account_id(handle.context().caller); + log::trace!(target: "ds-precompile", "unbond_and_unstake {:?}, {:?}", smart_contract, amount); + + // Find out if there is something staked on the contract + let protocol_state = ActiveProtocolState::::get(); + let staker_info = StakerInfo::::get(&origin, &smart_contract).unwrap_or_default(); + + // If there is, we need to unstake it before calling `unlock` + if staker_info.period_number() == protocol_state.period_number() { + let unstake_call = pallet_dapp_staking_v3::Call::::unstake { + smart_contract, + amount, + }; + RuntimeHelper::::try_dispatch(handle, Some(origin.clone()).into(), unstake_call)?; + } + + // Now we can try and `unlock` the given `amount` + let unlock_call = pallet_dapp_staking_v3::Call::::unlock { amount }; + RuntimeHelper::::try_dispatch(handle, Some(origin).into(), unlock_call)?; + + Ok(true) + } + + /// Claim back the unbonded (or unlocked) funds. + #[precompile::public("withdraw_unbonded()")] + fn withdraw_unbonded(handle: &mut impl PrecompileHandle) -> EvmResult { + let origin = R::AddressMapping::into_account_id(handle.context().caller); + let call = pallet_dapp_staking_v3::Call::::claim_unlocked {}; + + RuntimeHelper::::try_dispatch(handle, Some(origin).into(), call)?; + + Ok(true) + } + + /// Claim dApp rewards for the given era + #[precompile::public("claim_dapp(address,uint128)")] + fn claim_dapp( + handle: &mut impl PrecompileHandle, + contract_h160: Address, + era: u128, + ) -> EvmResult { + let smart_contract = + ::SmartContract::evm(contract_h160.into()); + + // parse era + let era = era + .try_into() + .map_err::(|_| RevertReason::value_is_too_large("era type").into()) + .in_field("era")?; + + log::trace!(target: "ds-precompile", "claim_dapp {:?}, era {:?}", smart_contract, era); + + let origin = R::AddressMapping::into_account_id(handle.context().caller); + let call = pallet_dapp_staking_v3::Call::::claim_dapp_reward { + smart_contract, + era, + }; + + RuntimeHelper::::try_dispatch(handle, Some(origin).into(), call)?; + + Ok(true) + } + + /// Claim staker rewards. + /// + /// Smart contract argument is legacy & is ignored in the new implementation. + #[precompile::public("claim_staker(address)")] + fn claim_staker( + handle: &mut impl PrecompileHandle, + _contract_h160: Address, + ) -> EvmResult { + let origin = R::AddressMapping::into_account_id(handle.context().caller); + let call = pallet_dapp_staking_v3::Call::::claim_staker_rewards {}; + + RuntimeHelper::::try_dispatch(handle, Some(origin).into(), call)?; + + Ok(true) + } + + /// Set claim reward destination for the caller. + /// + /// This call has been deprecated by dApp staking v3. + #[precompile::public("set_reward_destination(uint8)")] + fn set_reward_destination(_: &mut impl PrecompileHandle, _destination: u8) -> EvmResult { + Err(RevertReason::custom("Setting reward destination is no longer supported.").into()) + } + + /// Withdraw staked funds from the unregistered contract + #[precompile::public("withdraw_from_unregistered(address)")] + fn withdraw_from_unregistered( + handle: &mut impl PrecompileHandle, + contract_h160: Address, + ) -> EvmResult { + let smart_contract = + ::SmartContract::evm(contract_h160.into()); + log::trace!(target: "ds-precompile", "withdraw_from_unregistered {:?}", smart_contract); + + let origin = R::AddressMapping::into_account_id(handle.context().caller); + let call = pallet_dapp_staking_v3::Call::::unstake_from_unregistered { smart_contract }; + + RuntimeHelper::::try_dispatch(handle, Some(origin).into(), call)?; + + Ok(true) + } + + /// Transfers stake from one contract to another. + /// This is a legacy functionality that is no longer supported via direct call to dApp staking v3. + /// However, it can be achieved by chaining `unstake` and `stake` calls. + #[precompile::public("nomination_transfer(address,uint128,address)")] + fn nomination_transfer( + handle: &mut impl PrecompileHandle, + origin_contract_h160: Address, + amount: u128, + target_contract_h160: Address, + ) -> EvmResult { + // TODO: benchmark this function so we can measure ref time & PoV correctly + // Storage item: StakerInfo: + // Blake2_128Concat(16 + SmartContract::max_encoded_len) + SingularStakingInfo::max_encoded_len + handle.record_db_read::( + 16 + ::SmartContract::max_encoded_len() + + SingularStakingInfo::max_encoded_len(), + )?; + + let origin_smart_contract = + ::SmartContract::evm(origin_contract_h160.into()); + let target_smart_contract = + ::SmartContract::evm(target_contract_h160.into()); + log::trace!(target: "ds-precompile", "nomination_transfer {:?} {:?} {:?}", origin_smart_contract, amount, target_smart_contract); + + // Find out how much staker has staked on the origin contract + let origin = R::AddressMapping::into_account_id(handle.context().caller); + let staker_info = StakerInfo::::get(&origin, &origin_smart_contract).unwrap_or_default(); + + // We don't care from which period the staked amount is, the logic takes care of the situation + // if value comes from the past period. + let staked_amount = staker_info.total_staked_amount(); + let minimum_allowed_stake_amount = + ::MinimumStakeAmount::get(); + + // In case the remaining staked amount on the origin contract is less than the minimum allowed stake amount, + // everything will be unstaked. To keep in line with legacy `nomination_transfer` behavior, we should transfer + // the entire amount from the origin to target contract. + // + // In case value comes from the past period, we don't care, since the `unstake` call will fall apart. + let stake_amount = if staked_amount > 0 + && staked_amount.saturating_sub(amount) < minimum_allowed_stake_amount + { + staked_amount + } else { + amount + }; + + // First call unstake from the origin smart contract + let unstake_call = pallet_dapp_staking_v3::Call::::unstake { + smart_contract: origin_smart_contract, + amount, + }; + RuntimeHelper::::try_dispatch(handle, Some(origin.clone()).into(), unstake_call)?; + + // Then call stake on the target smart contract + let stake_call = pallet_dapp_staking_v3::Call::::stake { + smart_contract: target_smart_contract, + amount: stake_amount, + }; + RuntimeHelper::::try_dispatch(handle, Some(origin).into(), stake_call)?; + + Ok(true) + } + + // v2 functions + + /// Read the current protocol state. + #[precompile::public("protocol_state()")] + #[precompile::view] + fn protocol_state(handle: &mut impl PrecompileHandle) -> EvmResult { + // TODO: benchmark this function so we can measure ref time & PoV correctly + // Storage item: ActiveProtocolState: + // Twox64(8) + ProtocolState::max_encoded_len + handle.record_db_read::(8 + ProtocolState::max_encoded_len())?; + + let protocol_state = ActiveProtocolState::::get(); + + Ok(PrecompileProtocolState { + era: protocol_state.era.into(), + period: protocol_state.period_number().into(), + subperiod: subperiod_id(&protocol_state.subperiod()), + }) + } + + /// Read the `unbonding period` or `unlocking period` expressed in the number of eras. + #[precompile::public("unlocking_period()")] + #[precompile::view] + fn unlocking_period(_: &mut impl PrecompileHandle) -> EvmResult { + // constant, no DB read + Ok(DAppStaking::::unlocking_period().into()) + } + + /// Attempt to lock the given amount into the dApp staking protocol. + #[precompile::public("lock(uint128)")] + fn lock(handle: &mut impl PrecompileHandle, amount: u128) -> EvmResult { + // Prepare call & dispatch it + let origin = R::AddressMapping::into_account_id(handle.context().caller); + let lock_call = pallet_dapp_staking_v3::Call::::lock { amount }; + RuntimeHelper::::try_dispatch(handle, Some(origin).into(), lock_call)?; + + Ok(true) + } + + /// Attempt to unlock the given amount from the dApp staking protocol. + #[precompile::public("unlock(uint128)")] + fn unlock(handle: &mut impl PrecompileHandle, amount: u128) -> EvmResult { + // Prepare call & dispatch it + let origin = R::AddressMapping::into_account_id(handle.context().caller); + let unlock_call = pallet_dapp_staking_v3::Call::::unlock { amount }; + RuntimeHelper::::try_dispatch(handle, Some(origin).into(), unlock_call)?; + + Ok(true) + } + + /// Attempts to claim unlocking chunks which have undergone the entire unlocking period. + #[precompile::public("claim_unlocked()")] + fn claim_unlocked(handle: &mut impl PrecompileHandle) -> EvmResult { + // Prepare call & dispatch it + let origin = R::AddressMapping::into_account_id(handle.context().caller); + let claim_unlocked_call = pallet_dapp_staking_v3::Call::::claim_unlocked {}; + RuntimeHelper::::try_dispatch(handle, Some(origin).into(), claim_unlocked_call)?; + + Ok(true) + } + + /// Attempts to stake the given amount on the given smart contract. + #[precompile::public("stake((uint8,bytes),uint128)")] + fn stake( + handle: &mut impl PrecompileHandle, + smart_contract: SmartContractV2, + amount: Balance, + ) -> EvmResult { + let smart_contract = Self::decode_smart_contract(smart_contract)?; + + // Prepare call & dispatch it + let origin = R::AddressMapping::into_account_id(handle.context().caller); + let stake_call = pallet_dapp_staking_v3::Call::::stake { + smart_contract, + amount, + }; + RuntimeHelper::::try_dispatch(handle, Some(origin).into(), stake_call)?; + + Ok(true) + } + + /// Attempts to unstake the given amount from the given smart contract. + #[precompile::public("unstake((uint8,bytes),uint128)")] + fn unstake( + handle: &mut impl PrecompileHandle, + smart_contract: SmartContractV2, + amount: Balance, + ) -> EvmResult { + let smart_contract = Self::decode_smart_contract(smart_contract)?; + + // Prepare call & dispatch it + let origin = R::AddressMapping::into_account_id(handle.context().caller); + let unstake_call = pallet_dapp_staking_v3::Call::::unstake { + smart_contract, + amount, + }; + RuntimeHelper::::try_dispatch(handle, Some(origin).into(), unstake_call)?; + + Ok(true) + } + + /// Attempts to claim one or more pending staker rewards. + #[precompile::public("claim_staker_rewards()")] + fn claim_staker_rewards(handle: &mut impl PrecompileHandle) -> EvmResult { + // Prepare call & dispatch it + let origin = R::AddressMapping::into_account_id(handle.context().caller); + let claim_staker_rewards_call = pallet_dapp_staking_v3::Call::::claim_staker_rewards {}; + RuntimeHelper::::try_dispatch(handle, Some(origin).into(), claim_staker_rewards_call)?; + + Ok(true) + } + + /// Attempts to claim bonus reward for being a loyal staker of the given dApp. + #[precompile::public("claim_bonus_reward((uint8,bytes))")] + fn claim_bonus_reward( + handle: &mut impl PrecompileHandle, + smart_contract: SmartContractV2, + ) -> EvmResult { + let smart_contract = Self::decode_smart_contract(smart_contract)?; + + // Prepare call & dispatch it + let origin = R::AddressMapping::into_account_id(handle.context().caller); + let claim_bonus_reward_call = + pallet_dapp_staking_v3::Call::::claim_bonus_reward { smart_contract }; + RuntimeHelper::::try_dispatch(handle, Some(origin).into(), claim_bonus_reward_call)?; + + Ok(true) + } + + /// Attempts to claim dApp reward for the given dApp in the given era. + #[precompile::public("claim_bonus_reward((uint8,bytes),uint256)")] + fn claim_dapp_reward( + handle: &mut impl PrecompileHandle, + smart_contract: SmartContractV2, + era: U256, + ) -> EvmResult { + let smart_contract = Self::decode_smart_contract(smart_contract)?; + let era = era + .try_into() + .map_err::(|_| RevertReason::value_is_too_large("Era number.").into()) + .in_field("era")?; + + // Prepare call & dispatch it + let origin = R::AddressMapping::into_account_id(handle.context().caller); + let claim_dapp_reward_call = pallet_dapp_staking_v3::Call::::claim_dapp_reward { + smart_contract, + era, + }; + RuntimeHelper::::try_dispatch(handle, Some(origin).into(), claim_dapp_reward_call)?; + + Ok(true) + } + + /// Attempts to unstake everything from an unregistered contract. + #[precompile::public("unstake_from_unregistered((uint8,bytes))")] + fn unstake_from_unregistered( + handle: &mut impl PrecompileHandle, + smart_contract: SmartContractV2, + ) -> EvmResult { + let smart_contract = Self::decode_smart_contract(smart_contract)?; + + // Prepare call & dispatch it + let origin = R::AddressMapping::into_account_id(handle.context().caller); + let unstake_from_unregistered_call = + pallet_dapp_staking_v3::Call::::unstake_from_unregistered { smart_contract }; + RuntimeHelper::::try_dispatch( + handle, + Some(origin).into(), + unstake_from_unregistered_call, + )?; + + Ok(true) + } + + /// Attempts to cleanup expired entries for the staker. + #[precompile::public("cleanup_expired_entries()")] + fn cleanup_expired_entries(handle: &mut impl PrecompileHandle) -> EvmResult { + // Prepare call & dispatch it + let origin = R::AddressMapping::into_account_id(handle.context().caller); + let cleanup_expired_entries_call = + pallet_dapp_staking_v3::Call::::cleanup_expired_entries {}; + RuntimeHelper::::try_dispatch( + handle, + Some(origin).into(), + cleanup_expired_entries_call, + )?; + + Ok(true) + } + + // Utility functions + + /// Helper method to decode smart contract struct for v2 calls + pub(crate) fn decode_smart_contract( + smart_contract: SmartContractV2, + ) -> EvmResult<::SmartContract> { + let smart_contract = match smart_contract.contract_type { + SmartContractTypes::Evm => { + ensure!( + smart_contract.address.as_bytes().len() == 20, + revert("Invalid address length for Astar EVM smart contract.") + ); + let h160_address = H160::from_slice(smart_contract.address.as_bytes()); + ::SmartContract::evm(h160_address) + } + SmartContractTypes::Wasm => { + ensure!( + smart_contract.address.as_bytes().len() == 32, + revert("Invalid address length for Astar WASM smart contract.") + ); + let mut staker_bytes = [0_u8; 32]; + staker_bytes[..].clone_from_slice(&smart_contract.address.as_bytes()); + + ::SmartContract::wasm(staker_bytes.into()) + } + }; + + Ok(smart_contract) + } + + /// Helper method to parse H160 or SS58 address + pub(crate) fn parse_input_address(staker_vec: Vec) -> EvmResult { + let staker: R::AccountId = match staker_vec.len() { + // public address of the ss58 account has 32 bytes + 32 => { + let mut staker_bytes = [0_u8; 32]; + staker_bytes[..].clone_from_slice(&staker_vec[0..32]); + + staker_bytes.into() + } + // public address of the H160 account has 20 bytes + 20 => { + let mut staker_bytes = [0_u8; 20]; + staker_bytes[..].clone_from_slice(&staker_vec[0..20]); + + R::AddressMapping::into_account_id(staker_bytes.into()) + } + _ => { + // Return err if account length is wrong + return Err(revert("Error while parsing staker's address")); + } + }; + + Ok(staker) + } +} + +/// Numeric Id of the subperiod enum value. +pub(crate) fn subperiod_id(subperiod: &Subperiod) -> u8 { + match subperiod { + Subperiod::Voting => 0, + Subperiod::BuildAndEarn => 1, + } +} diff --git a/precompiles/dapp-staking-v3/src/test/mock.rs b/precompiles/dapp-staking-v3/src/test/mock.rs new file mode 100644 index 0000000000..2015ba6736 --- /dev/null +++ b/precompiles/dapp-staking-v3/src/test/mock.rs @@ -0,0 +1,461 @@ +// 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::*; + +use fp_evm::{IsPrecompileResult, Precompile}; +use frame_support::{ + assert_ok, construct_runtime, parameter_types, + traits::{ + fungible::{Mutate as FunMutate, Unbalanced as FunUnbalanced}, + ConstU128, ConstU64, GenesisBuild, Hooks, + }, + weights::{RuntimeDbWeight, Weight}, +}; +use frame_system::RawOrigin; +use pallet_evm::{ + AddressMapping, EnsureAddressNever, EnsureAddressRoot, PrecompileResult, PrecompileSet, +}; +use sp_arithmetic::{fixed_point::FixedU64, Permill}; +use sp_core::{H160, H256}; +use sp_io::TestExternalities; +use sp_runtime::traits::{BlakeTwo256, ConstU32, IdentityLookup}; +extern crate alloc; + +use astar_primitives::{ + dapp_staking::{CycleConfiguration, SmartContract, StakingRewardHandler}, + testing::Header, + AccountId, Balance, BlockNumber, +}; +use pallet_dapp_staking_v3::{EraNumber, PeriodNumber, PriceProvider, TierThreshold}; + +type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic; +type Block = frame_system::mocking::MockBlock; + +pub struct AddressMapper; +impl AddressMapping for AddressMapper { + fn into_account_id(account: H160) -> AccountId { + let mut account_id = [0u8; 32]; + account_id[0..20].clone_from_slice(&account.as_bytes()); + + account_id + .try_into() + .expect("H160 is 20 bytes long so it must fit into 32 bytes; QED") + } +} + +pub const READ_WEIGHT: u64 = 3; +pub const WRITE_WEIGHT: u64 = 7; + +parameter_types! { + pub const BlockHashCount: BlockNumber = 250; + pub BlockWeights: frame_system::limits::BlockWeights = + frame_system::limits::BlockWeights::simple_max(Weight::from_parts(1024, 0)); + pub const TestWeights: RuntimeDbWeight = RuntimeDbWeight { + read: READ_WEIGHT, + write: WRITE_WEIGHT, + }; +} + +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<1>; + type AccountStore = System; + type HoldIdentifier = (); + type FreezeIdentifier = RuntimeFreezeReason; + type MaxHolds = ConstU32<0>; + type MaxFreezes = ConstU32<1>; + type WeightInfo = (); +} + +pub fn precompile_address() -> H160 { + H160::from_low_u64_be(0x5001) +} + +#[derive(Debug, Clone, Copy)] +pub struct DappStakingPrecompile(PhantomData); +impl PrecompileSet for DappStakingPrecompile +where + R: pallet_evm::Config, + DappStakingV3Precompile: Precompile, +{ + fn execute(&self, handle: &mut impl PrecompileHandle) -> Option { + match handle.code_address() { + a if a == precompile_address() => Some(DappStakingV3Precompile::::execute(handle)), + _ => None, + } + } + + fn is_precompile(&self, address: sp_core::H160, _gas: u64) -> IsPrecompileResult { + IsPrecompileResult::Answer { + is_precompile: address == precompile_address(), + extra_cost: 0, + } + } +} + +pub type PrecompileCall = DappStakingV3PrecompileCall; + +parameter_types! { + pub PrecompilesValue: DappStakingPrecompile = DappStakingPrecompile(Default::default()); + pub WeightPerGas: Weight = Weight::from_parts(1, 0); +} + +impl pallet_evm::Config for Test { + type FeeCalculator = (); + type GasWeightMapping = pallet_evm::FixedGasWeightMapping; + type WeightPerGas = WeightPerGas; + type CallOrigin = EnsureAddressRoot; + type WithdrawOrigin = EnsureAddressNever; + type AddressMapping = AddressMapper; + type Currency = Balances; + type RuntimeEvent = RuntimeEvent; + type Runner = pallet_evm::runner::stack::Runner; + type PrecompilesType = DappStakingPrecompile; + type PrecompilesValue = PrecompilesValue; + type Timestamp = Timestamp; + type ChainId = (); + type OnChargeTransaction = (); + type BlockGasLimit = (); + type BlockHashMapping = pallet_evm::SubstrateBlockHashMapping; + type FindAuthor = (); + type OnCreate = (); + type WeightInfo = (); + type GasLimitPovSizeRatio = ConstU64<4>; +} + +impl pallet_timestamp::Config for Test { + type Moment = u64; + type OnTimestampSet = (); + type MinimumPeriod = ConstU64<5>; + type WeightInfo = (); +} + +type MockSmartContract = SmartContract<::AccountId>; + +pub struct DummyPriceProvider; +impl PriceProvider for DummyPriceProvider { + fn average_price() -> FixedU64 { + FixedU64::from_rational(1, 10) + } +} + +pub struct DummyStakingRewardHandler; +impl StakingRewardHandler for DummyStakingRewardHandler { + fn staker_and_dapp_reward_pools(_total_staked_value: Balance) -> (Balance, Balance) { + ( + Balance::from(1_000_000_000_000_u128), + Balance::from(1_000_000_000_u128), + ) + } + + fn bonus_reward_pool() -> Balance { + Balance::from(3_000_000_u128) + } + + fn payout_reward(beneficiary: &AccountId, reward: Balance) -> Result<(), ()> { + let _ = Balances::mint_into(beneficiary, reward); + Ok(()) + } +} + +pub struct DummyCycleConfiguration; +impl CycleConfiguration for DummyCycleConfiguration { + fn periods_per_cycle() -> u32 { + 4 + } + + fn eras_per_voting_subperiod() -> u32 { + 8 + } + + fn eras_per_build_and_earn_subperiod() -> u32 { + 16 + } + + fn blocks_per_era() -> u32 { + 10 + } +} + +// Just to satsify the trait bound +#[cfg(feature = "runtime-benchmarks")] +pub struct BenchmarkHelper(sp_std::marker::PhantomData<(SC, ACC)>); +#[cfg(feature = "runtime-benchmarks")] +impl pallet_dapp_staking_v3::BenchmarkHelper + for BenchmarkHelper +{ + fn get_smart_contract(id: u32) -> MockSmartContract { + MockSmartContract::evm(H160::from_low_u64_be(id as u64)) + } + + fn set_balance(_account: &AccountId, _amount: Balance) {} +} + +impl pallet_dapp_staking_v3::Config for Test { + type RuntimeEvent = RuntimeEvent; + type RuntimeFreezeReason = RuntimeFreezeReason; + type Currency = Balances; + type SmartContract = MockSmartContract; + type ManagerOrigin = frame_system::EnsureRoot; + type NativePriceProvider = DummyPriceProvider; + type StakingRewardHandler = DummyStakingRewardHandler; + type CycleConfiguration = DummyCycleConfiguration; + type EraRewardSpanLength = ConstU32<8>; + type RewardRetentionInPeriods = ConstU32<2>; + type MaxNumberOfContracts = ConstU32<10>; + type MaxUnlockingChunks = ConstU32<5>; + type MinimumLockedAmount = ConstU128<10>; + type UnlockingPeriod = ConstU32<2>; + type MaxNumberOfStakedContracts = ConstU32<5>; + type MinimumStakeAmount = ConstU128<3>; + type NumberOfTiers = ConstU32<4>; + type WeightInfo = pallet_dapp_staking_v3::weights::SubstrateWeight; + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = BenchmarkHelper; +} + +construct_runtime!( + pub struct Test + where + Block = Block, + NodeBlock = Block, + UncheckedExtrinsic = UncheckedExtrinsic, + { + System: frame_system, + Balances: pallet_balances, + Evm: pallet_evm, + Timestamp: pallet_timestamp, + DappStaking: pallet_dapp_staking_v3, + } +); + +pub struct ExternalityBuilder; +impl ExternalityBuilder { + pub fn build() -> TestExternalities { + let mut storage = frame_system::GenesisConfig::default() + .build_storage::() + .unwrap(); + + >::assimilate_storage( + &pallet_dapp_staking_v3::GenesisConfig { + reward_portion: vec![ + Permill::from_percent(40), + Permill::from_percent(30), + Permill::from_percent(20), + Permill::from_percent(10), + ], + slot_distribution: vec![ + Permill::from_percent(10), + Permill::from_percent(20), + Permill::from_percent(30), + Permill::from_percent(40), + ], + tier_thresholds: vec![ + TierThreshold::DynamicTvlAmount { + amount: 100, + minimum_amount: 80, + }, + TierThreshold::DynamicTvlAmount { + amount: 50, + minimum_amount: 40, + }, + TierThreshold::DynamicTvlAmount { + amount: 20, + minimum_amount: 20, + }, + TierThreshold::FixedTvlAmount { amount: 10 }, + ], + slots_per_tier: vec![10, 20, 30, 40], + }, + &mut storage, + ) + .ok(); + + let mut ext = TestExternalities::from(storage); + ext.execute_with(|| { + System::set_block_number(1); + + let alice_native = AddressMapper::into_account_id(ALICE); + assert_ok!( + ::Currency::write_balance( + &alice_native, + 1000_000_000_000_000_000_000 as Balance, + ) + ); + }); + ext + } +} + +pub fn precompiles() -> DappStakingPrecompile { + PrecompilesValue::get() +} + +// Utility functions + +pub const ALICE: H160 = H160::repeat_byte(0xAA); + +/// Used to register a smart contract, and stake some funds on it. +pub fn register_and_stake( + account: H160, + smart_contract: ::SmartContract, + amount: Balance, +) { + let alice_native = AddressMapper::into_account_id(account); + + // 1. Register smart contract + assert_ok!(DappStaking::register( + RawOrigin::Root.into(), + alice_native.clone(), + smart_contract.clone() + )); + + // 2. Lock some amount + assert_ok!(DappStaking::lock( + RawOrigin::Signed(alice_native.clone()).into(), + amount, + )); + + // 3. Stake the locked amount + assert_ok!(DappStaking::stake( + RawOrigin::Signed(alice_native.clone()).into(), + smart_contract.clone(), + amount, + )); +} + +/// Utility function used to create `DynamicAddress` out of the given `H160` address. +/// The first one is simply byte representation of the H160 address. +/// The second one is byte representation of the derived `AccountId` from the H160 address. +pub fn into_dynamic_addresses(address: H160) -> [DynamicAddress; 2] { + [ + address.as_bytes().try_into().unwrap(), + >::as_ref(&AddressMapper::into_account_id(address)) + .try_into() + .unwrap(), + ] +} + +/// Initialize first block. +/// This method should only be called once in a UT otherwise the first block will get initialized multiple times. +pub fn initialize() { + // This assert prevents method misuse + assert_eq!(System::block_number(), 1 as BlockNumber); + DappStaking::on_initialize(System::block_number()); + run_to_block(2); +} + +/// Run to the specified block number. +/// Function assumes first block has been initialized. +pub(crate) fn run_to_block(n: BlockNumber) { + while System::block_number() < n { + DappStaking::on_finalize(System::block_number()); + System::set_block_number(System::block_number() + 1); + 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: BlockNumber) { + 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) { + assert!(era >= ActiveProtocolState::::get().era); + while ActiveProtocolState::::get().era < era { + run_for_blocks(1); + } +} + +/// Advance blocks until next era has been reached. +pub(crate) fn advance_to_next_era() { + advance_to_era(ActiveProtocolState::::get().era + 1); +} + +/// Advance blocks until next period type has been reached. +pub(crate) fn advance_to_next_subperiod() { + let subperiod = ActiveProtocolState::::get().subperiod(); + while ActiveProtocolState::::get().subperiod() == subperiod { + run_for_blocks(1); + } +} + +/// Advance blocks until the specified period has been reached. +/// +/// Function has no effect if period is already passed. +pub(crate) fn advance_to_period(period: PeriodNumber) { + assert!(period >= ActiveProtocolState::::get().period_number()); + while ActiveProtocolState::::get().period_number() < period { + run_for_blocks(1); + } +} + +/// Advance blocks until next period has been reached. +pub(crate) fn advance_to_next_period() { + advance_to_period(ActiveProtocolState::::get().period_number() + 1); +} + +// Return all dApp staking events from the event buffer. +pub fn dapp_staking_events() -> Vec> { + System::events() + .into_iter() + .map(|r| r.event) + .filter_map(|e| { + ::RuntimeEvent::from(e) + .try_into() + .ok() + }) + .collect::>() +} diff --git a/precompiles/dapp-staking-v3/src/test/mod.rs b/precompiles/dapp-staking-v3/src/test/mod.rs new file mode 100644 index 0000000000..a33eb22954 --- /dev/null +++ b/precompiles/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 tests_v1; +mod tests_v2; +mod types; diff --git a/precompiles/dapp-staking-v3/src/test/tests_v1.rs b/precompiles/dapp-staking-v3/src/test/tests_v1.rs new file mode 100644 index 0000000000..8d93b56198 --- /dev/null +++ b/precompiles/dapp-staking-v3/src/test/tests_v1.rs @@ -0,0 +1,867 @@ +// 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 . + +extern crate alloc; +use crate::{test::mock::*, *}; +use frame_support::assert_ok; +use frame_system::RawOrigin; +use precompile_utils::testing::*; +use sp_core::H160; +use sp_runtime::traits::Zero; + +use assert_matches::assert_matches; + +use pallet_dapp_staking_v3::{ActiveProtocolState, EraNumber, EraRewards}; + +#[test] +fn read_current_era_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + precompiles() + .prepare_test( + Alice, + precompile_address(), + PrecompileCall::read_current_era {}, + ) + .expect_no_logs() + .execute_returns(ActiveProtocolState::::get().era); + + // advance a few eras, check value again + advance_to_era(7); + precompiles() + .prepare_test( + Alice, + precompile_address(), + PrecompileCall::read_current_era {}, + ) + .expect_no_logs() + .execute_returns(ActiveProtocolState::::get().era); + }); +} + +#[test] +fn read_unbonding_period_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + let unlocking_period_in_eras: EraNumber = + ::UnlockingPeriod::get(); + + precompiles() + .prepare_test( + Alice, + precompile_address(), + PrecompileCall::read_unbonding_period {}, + ) + .expect_no_logs() + .execute_returns(unlocking_period_in_eras); + }); +} + +#[test] +fn read_era_reward_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + // Check historic era for rewards + let era = 3; + advance_to_era(era + 1); + + let span_index = DAppStaking::::era_reward_span_index(era); + + let era_rewards_span = EraRewards::::get(span_index).expect("Entry must exist."); + let expected_reward = era_rewards_span + .get(era) + .map(|r| r.staker_reward_pool + r.dapp_reward_pool) + .expect("It's history era so it must exist."); + assert!(expected_reward > 0, "Sanity check."); + + precompiles() + .prepare_test( + Alice, + precompile_address(), + PrecompileCall::read_era_reward { era }, + ) + .expect_no_logs() + .execute_returns(expected_reward); + + // Check current era for rewards, must be zero + precompiles() + .prepare_test( + Alice, + precompile_address(), + PrecompileCall::read_era_reward { era: era + 1 }, + ) + .expect_no_logs() + .execute_returns(Balance::zero()); + }); +} + +#[test] +fn read_era_staked_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + let staker_h160 = ALICE; + let smart_contract_address = H160::repeat_byte(0xFA); + let smart_contract = + ::SmartContract::evm(smart_contract_address); + let amount = 1_000_000_000_000; + register_and_stake(staker_h160, smart_contract.clone(), amount); + let anchor_era = ActiveProtocolState::::get().era; + + // 1. Current era stake must be zero, since stake is only valid from the next era. + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::read_era_staked { era: anchor_era }, + ) + .expect_no_logs() + .execute_returns(Balance::zero()); + + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::read_era_staked { + era: anchor_era + 1, + }, + ) + .expect_no_logs() + .execute_returns(amount); + + // 2. Advance to next era, and check next era after the anchor. + advance_to_era(anchor_era + 5); + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::read_era_staked { + era: anchor_era + 1, + }, + ) + .expect_no_logs() + .execute_returns(amount); + + // 3. Check era after the next one, must throw an error. + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::read_era_staked { + era: ActiveProtocolState::::get().era + 2, + }, + ) + .expect_no_logs() + .execute_reverts(|output| output == b"Era is in the future"); + }); +} + +#[test] +fn read_staked_amount_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + let staker_h160 = ALICE; + let dynamic_addresses = into_dynamic_addresses(staker_h160); + + // 1. Sanity checks - must be zero before anything is staked. + for staker in &dynamic_addresses { + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::read_staked_amount { + staker: staker.clone(), + }, + ) + .expect_no_logs() + .execute_returns(Balance::zero()); + } + + // 2. Stake some amount and check again + let smart_contract_address = H160::repeat_byte(0xFA); + let smart_contract = + ::SmartContract::evm(smart_contract_address); + let amount = 1_000_000_000_000; + register_and_stake(staker_h160, smart_contract.clone(), amount); + for staker in &dynamic_addresses { + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::read_staked_amount { + staker: staker.clone(), + }, + ) + .expect_no_logs() + .execute_returns(amount); + } + + // 3. Advance into next period, it should be reset back to zero + advance_to_next_period(); + for staker in &dynamic_addresses { + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::read_staked_amount { + staker: staker.clone(), + }, + ) + .expect_no_logs() + .execute_returns(Balance::zero()); + } + }); +} + +#[test] +fn read_staked_amount_on_contract_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + let staker_h160 = ALICE; + let smart_contract_address = H160::repeat_byte(0xFA); + let smart_contract = + ::SmartContract::evm(smart_contract_address); + let dynamic_addresses = into_dynamic_addresses(staker_h160); + + // 1. Sanity checks - must be zero before anything is staked. + for staker in &dynamic_addresses { + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::read_staked_amount_on_contract { + contract_h160: smart_contract_address.into(), + staker: staker.clone(), + }, + ) + .expect_no_logs() + .execute_returns(Balance::zero()); + } + + // 2. Stake some amount and check again + let amount = 1_000_000_000_000; + register_and_stake(staker_h160, smart_contract.clone(), amount); + for staker in &dynamic_addresses { + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::read_staked_amount_on_contract { + contract_h160: smart_contract_address.into(), + staker: staker.clone(), + }, + ) + .expect_no_logs() + .execute_returns(amount); + } + + // 3. Advance into next period, it should be reset back to zero + advance_to_next_period(); + for staker in &dynamic_addresses { + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::read_staked_amount_on_contract { + contract_h160: smart_contract_address.into(), + staker: staker.clone(), + }, + ) + .expect_no_logs() + .execute_returns(Balance::zero()); + } + }); +} + +#[test] +fn read_contract_stake_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + let staker_h160 = ALICE; + let smart_contract_address = H160::repeat_byte(0xFA); + + // 1. Sanity checks - must be zero before anything is staked. + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::read_contract_stake { + contract_h160: smart_contract_address.into(), + }, + ) + .expect_no_logs() + .execute_returns(Balance::zero()); + + // 2. Stake some amount and check again + let smart_contract = + ::SmartContract::evm(smart_contract_address); + let amount = 1_000_000_000_000; + register_and_stake(staker_h160, smart_contract.clone(), amount); + + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::read_contract_stake { + contract_h160: smart_contract_address.into(), + }, + ) + .expect_no_logs() + .execute_returns(amount); + + // 3. Advance into next period, it should be reset back to zero + advance_to_next_period(); + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::read_contract_stake { + contract_h160: smart_contract_address.into(), + }, + ) + .expect_no_logs() + .execute_returns(Balance::zero()); + }); +} + +#[test] +fn register_is_unsupported() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + precompiles() + .prepare_test( + ALICE, + precompile_address(), + PrecompileCall::register { + _address: Default::default(), + }, + ) + .expect_no_logs() + .execute_reverts(|output| output == b"register via evm precompile is not allowed"); + }); +} + +#[test] +fn set_reward_destination_is_unsupported() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + precompiles() + .prepare_test( + ALICE, + precompile_address(), + PrecompileCall::set_reward_destination { _destination: 0 }, + ) + .expect_no_logs() + .execute_reverts(|output| { + output == b"Setting reward destination is no longer supported." + }); + }); +} + +#[test] +fn bond_and_stake_with_two_calls_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + // Register a dApp for staking + let staker_h160 = ALICE; + let smart_contract_address = H160::repeat_byte(0xFA); + let smart_contract = + ::SmartContract::evm(smart_contract_address); + assert_ok!(DappStaking::register( + RawOrigin::Root.into(), + AddressMapper::into_account_id(staker_h160), + smart_contract.clone() + )); + + // Lock some amount, but not enough to cover the `bond_and_stake` call. + let pre_lock_amount = 500; + let stake_amount = 1_000_000; + assert_ok!(DappStaking::lock( + RawOrigin::Signed(AddressMapper::into_account_id(staker_h160)).into(), + pre_lock_amount, + )); + + // Execute legacy call, expect missing funds to be locked. + System::reset_events(); + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::bond_and_stake { + contract_h160: smart_contract_address.into(), + amount: stake_amount, + }, + ) + .expect_no_logs() + .execute_returns(true); + + let events = dapp_staking_events(); + assert_eq!(events.len(), 2); + let additional_lock_amount = stake_amount - pre_lock_amount; + assert_matches!( + events[0].clone(), + pallet_dapp_staking_v3::Event::Locked { + amount, + .. + } if amount == additional_lock_amount + ); + assert_matches!( + events[1].clone(), + pallet_dapp_staking_v3::Event::Stake { + smart_contract, + amount, + .. + } if smart_contract == smart_contract && amount == stake_amount + ); + }); +} + +#[test] +fn bond_and_stake_with_single_call_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + // Register a dApp for staking + let staker_h160 = ALICE; + let smart_contract_address = H160::repeat_byte(0xFA); + let smart_contract = + ::SmartContract::evm(smart_contract_address); + assert_ok!(DappStaking::register( + RawOrigin::Root.into(), + AddressMapper::into_account_id(staker_h160), + smart_contract.clone() + )); + + // Lock enough amount to cover `bond_and_stake` call. + let amount = 3000; + assert_ok!(DappStaking::lock( + RawOrigin::Signed(AddressMapper::into_account_id(staker_h160)).into(), + amount, + )); + + // Execute legacy call, expect only single stake to be executed. + System::reset_events(); + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::bond_and_stake { + contract_h160: smart_contract_address.into(), + amount, + }, + ) + .expect_no_logs() + .execute_returns(true); + + let events = dapp_staking_events(); + assert_eq!(events.len(), 1); + assert_matches!( + events[0].clone(), + pallet_dapp_staking_v3::Event::Stake { + smart_contract, + amount, + .. + } if smart_contract == smart_contract && amount == amount + ); + }); +} + +#[test] +fn unbond_and_unstake_with_two_calls_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + // Register a dApp for staking + let staker_h160 = ALICE; + let smart_contract_address = H160::repeat_byte(0xFA); + let smart_contract = + ::SmartContract::evm(smart_contract_address); + let amount = 1_000_000_000_000; + register_and_stake(staker_h160, smart_contract.clone(), amount); + + // Execute legacy call, expect funds to first unstaked, and then unlocked + System::reset_events(); + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::unbond_and_unstake { + contract_h160: smart_contract_address.into(), + amount, + }, + ) + .expect_no_logs() + .execute_returns(true); + + let events = dapp_staking_events(); + assert_eq!(events.len(), 2); + assert_matches!( + events[0].clone(), + pallet_dapp_staking_v3::Event::Unstake { + smart_contract, + amount, + .. + }if smart_contract == smart_contract && amount == amount + ); + assert_matches!( + events[1].clone(), + pallet_dapp_staking_v3::Event::Unlocking { amount, .. } if amount == amount + ); + }); +} + +#[test] +fn unbond_and_unstake_with_single_calls_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + // Register a dApp for staking + let staker_h160 = ALICE; + let smart_contract_address = H160::repeat_byte(0xFA); + let smart_contract = + ::SmartContract::evm(smart_contract_address); + let amount = 1_000_000_000_000; + register_and_stake(staker_h160, smart_contract.clone(), amount); + + // Unstake the entire amount, so only unlock call is expected. + assert_ok!(DappStaking::unstake( + RawOrigin::Signed(AddressMapper::into_account_id(staker_h160)).into(), + smart_contract.clone(), + amount, + )); + + // Execute legacy call, expect funds to be unlocked + System::reset_events(); + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::unbond_and_unstake { + contract_h160: smart_contract_address.into(), + amount, + }, + ) + .expect_no_logs() + .execute_returns(true); + + let events = dapp_staking_events(); + assert_eq!(events.len(), 1); + assert_matches!( + events[0].clone(), + pallet_dapp_staking_v3::Event::Unlocking { amount, .. } if amount == amount + ); + }); +} + +#[test] +fn withdraw_unbonded_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + // Register a dApp for staking + let staker_h160 = ALICE; + let staker_native = AddressMapper::into_account_id(staker_h160); + let smart_contract_address = H160::repeat_byte(0xFA); + let smart_contract = + ::SmartContract::evm(smart_contract_address); + let amount = 1_000_000_000_000; + register_and_stake(staker_h160, smart_contract.clone(), amount); + + // Unlock some amount + assert_ok!(DappStaking::unstake( + RawOrigin::Signed(staker_native.clone()).into(), + smart_contract.clone(), + amount, + )); + let unlock_amount = amount / 7; + assert_ok!(DappStaking::unlock( + RawOrigin::Signed(staker_native.clone()).into(), + unlock_amount, + )); + + // Advance enough into time so unlocking chunk can be claimed + let unlock_block = Ledger::::get(&staker_native).unlocking[0].unlock_block; + run_to_block(unlock_block); + + // Execute legacy call, expect unlocked funds to be claimed back + System::reset_events(); + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::withdraw_unbonded {}, + ) + .expect_no_logs() + .execute_returns(true); + + let events = dapp_staking_events(); + assert_eq!(events.len(), 1); + assert_matches!( + events[0].clone(), + pallet_dapp_staking_v3::Event::ClaimedUnlocked { + amount, + .. + } if amount == unlock_amount + ); + }); +} + +#[test] +fn claim_dapp_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + // Register a dApp for staking + let staker_h160 = ALICE; + let smart_contract_address = H160::repeat_byte(0xFA); + let smart_contract = + ::SmartContract::evm(smart_contract_address); + let amount = 1_000_000_000_000; + register_and_stake(staker_h160, smart_contract.clone(), amount); + + // Advance enough eras so we can claim dApp reward + advance_to_era(3); + let claim_era = 2; + + // Execute legacy call, expect dApp rewards to be claimed + System::reset_events(); + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::claim_dapp { + contract_h160: smart_contract_address.into(), + era: claim_era, + }, + ) + .expect_no_logs() + .execute_returns(true); + + let events = dapp_staking_events(); + assert_eq!(events.len(), 1); + assert_matches!( + events[0].clone(), + pallet_dapp_staking_v3::Event::DAppReward { + era, + smart_contract, + .. + } if era as u128 == claim_era && smart_contract == smart_contract + ); + }); +} + +#[test] +fn claim_staker_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + // Register a dApp for staking + let staker_h160 = ALICE; + let smart_contract_address = H160::repeat_byte(0xFA); + let smart_contract = + ::SmartContract::evm(smart_contract_address); + let amount = 1_000_000_000_000; + register_and_stake(staker_h160, smart_contract.clone(), amount); + + // Advance enough eras so we can claim dApp reward + let target_era = 5; + advance_to_era(target_era); + let number_of_claims = (2..target_era).count(); + + // Execute legacy call, expect dApp rewards to be claimed + System::reset_events(); + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::claim_staker { + _contract_h160: smart_contract_address.into(), + }, + ) + .expect_no_logs() + .execute_returns(true); + + // We expect multiple reward to be claimed + let events = dapp_staking_events(); + assert_eq!(events.len(), number_of_claims as usize); + for era in 2..target_era { + assert_matches!( + events[era as usize - 2].clone(), + pallet_dapp_staking_v3::Event::Reward { era, .. } if era == era + ); + } + }); +} + +#[test] +fn withdraw_from_unregistered_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + // Register a dApp for staking + let staker_h160 = ALICE; + let smart_contract_address = H160::repeat_byte(0xFA); + let smart_contract = + ::SmartContract::evm(smart_contract_address); + let amount = 1_000_000_000_000; + register_and_stake(staker_h160, smart_contract.clone(), amount); + + // Unregister the dApp + assert_ok!(DappStaking::unregister( + RawOrigin::Root.into(), + smart_contract.clone() + )); + + // Execute legacy call, expect funds to be unstaked & withdrawn + System::reset_events(); + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::withdraw_from_unregistered { + contract_h160: smart_contract_address.into(), + }, + ) + .expect_no_logs() + .execute_returns(true); + + let events = dapp_staking_events(); + assert_eq!(events.len(), 1); + assert_matches!( + events[0].clone(), + pallet_dapp_staking_v3::Event::UnstakeFromUnregistered { + smart_contract, + amount, + .. + } if smart_contract == smart_contract && amount == amount + ); + }); +} + +#[test] +fn nomination_transfer_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + // Register the first dApp, and stke on it. + let staker_h160 = ALICE; + let staker_native = AddressMapper::into_account_id(staker_h160); + let smart_contract_address_1 = H160::repeat_byte(0xFA); + let smart_contract_1 = + ::SmartContract::evm(smart_contract_address_1); + let amount = 1_000_000_000_000; + register_and_stake(staker_h160, smart_contract_1.clone(), amount); + + // Register the second dApp. + let smart_contract_address_2 = H160::repeat_byte(0xBF); + let smart_contract_2 = + ::SmartContract::evm(smart_contract_address_2); + assert_ok!(DappStaking::register( + RawOrigin::Root.into(), + staker_native.clone(), + smart_contract_2.clone() + )); + + // 1st scenario - transfer enough amount from the first to second dApp to cover the stake, + // but not enough for full unstake. + let minimum_stake_amount: Balance = + ::MinimumStakeAmount::get(); + + System::reset_events(); + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::nomination_transfer { + origin_contract_h160: smart_contract_address_1.into(), + amount: minimum_stake_amount, + target_contract_h160: smart_contract_address_2.into(), + }, + ) + .expect_no_logs() + .execute_returns(true); + + // We expect the same amount to be staked on the second contract + let events = dapp_staking_events(); + assert_eq!(events.len(), 2); + assert_matches!( + events[0].clone(), + pallet_dapp_staking_v3::Event::Unstake { + smart_contract, + amount, + .. + } if smart_contract == smart_contract_1 && amount == minimum_stake_amount + ); + assert_matches!( + events[1].clone(), + pallet_dapp_staking_v3::Event::Stake { + smart_contract, + amount, + .. + } if smart_contract == smart_contract_2 && amount == minimum_stake_amount + ); + + // 2nd scenario - transfer almost the entire amount from the first to second dApp. + // The amount is large enough to trigger full unstake of the first contract. + let unstake_amount = amount - minimum_stake_amount - 1; + let expected_stake_unstake_amount = amount - minimum_stake_amount; + + System::reset_events(); + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::nomination_transfer { + origin_contract_h160: smart_contract_address_1.into(), + amount: unstake_amount, + target_contract_h160: smart_contract_address_2.into(), + }, + ) + .expect_no_logs() + .execute_returns(true); + + // We expect the same amount to be staked on the second contract + let events = dapp_staking_events(); + assert_eq!(events.len(), 2); + assert_matches!( + events[0].clone(), + pallet_dapp_staking_v3::Event::Unstake { + smart_contract, + amount, + .. + } if smart_contract == smart_contract_1 && amount == expected_stake_unstake_amount + ); + assert_matches!( + events[1].clone(), + pallet_dapp_staking_v3::Event::Stake { + smart_contract, + amount, + .. + } if smart_contract == smart_contract_2 && amount == expected_stake_unstake_amount + ); + }); +} diff --git a/precompiles/dapp-staking-v3/src/test/tests_v2.rs b/precompiles/dapp-staking-v3/src/test/tests_v2.rs new file mode 100644 index 0000000000..5977417b5e --- /dev/null +++ b/precompiles/dapp-staking-v3/src/test/tests_v2.rs @@ -0,0 +1,526 @@ +// 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 . + +extern crate alloc; +use crate::{test::mock::*, *}; +use frame_support::assert_ok; +use frame_system::RawOrigin; +use precompile_utils::testing::*; +use sp_core::H160; + +use assert_matches::assert_matches; + +use astar_primitives::{dapp_staking::CycleConfiguration, BlockNumber}; +use pallet_dapp_staking_v3::{ActiveProtocolState, EraNumber}; + +#[test] +fn protocol_state_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + // Prepare some mixed state in the future so not all entries are 'zero' + advance_to_next_period(); + advance_to_next_era(); + + let state = ActiveProtocolState::::get(); + + let expected_outcome = PrecompileProtocolState { + era: state.era.into(), + period: state.period_number().into(), + subperiod: subperiod_id(&state.subperiod()), + }; + + precompiles() + .prepare_test( + Alice, + precompile_address(), + PrecompileCall::protocol_state {}, + ) + .expect_no_logs() + .execute_returns(expected_outcome); + }); +} + +#[test] +fn unlocking_period_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + let unlocking_period_in_eras: EraNumber = + ::UnlockingPeriod::get(); + let era_length: BlockNumber = + ::CycleConfiguration::blocks_per_era(); + + let expected_outcome = era_length * unlocking_period_in_eras; + + precompiles() + .prepare_test( + Alice, + precompile_address(), + PrecompileCall::unlocking_period {}, + ) + .expect_no_logs() + .execute_returns(expected_outcome); + }); +} + +#[test] +fn lock_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + // Lock some amount and verify event + let amount = 1234; + System::reset_events(); + precompiles() + .prepare_test(ALICE, precompile_address(), PrecompileCall::lock { amount }) + .expect_no_logs() + .execute_returns(true); + + let events = dapp_staking_events(); + assert_eq!(events.len(), 1); + assert_matches!( + events[0].clone(), + pallet_dapp_staking_v3::Event::Locked { + amount, + .. + } if amount == amount + ); + }); +} + +#[test] +fn unlock_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + let lock_amount = 1234; + assert_ok!(DappStaking::lock( + RawOrigin::Signed(AddressMapper::into_account_id(ALICE)).into(), + lock_amount, + )); + + // Unlock some amount and verify event + System::reset_events(); + let unlock_amount = 1234 / 7; + precompiles() + .prepare_test( + ALICE, + precompile_address(), + PrecompileCall::unlock { + amount: unlock_amount, + }, + ) + .expect_no_logs() + .execute_returns(true); + + let events = dapp_staking_events(); + assert_eq!(events.len(), 1); + assert_matches!( + events[0].clone(), + pallet_dapp_staking_v3::Event::Unlocking { + amount, + .. + } if amount == unlock_amount + ); + }); +} + +#[test] +fn claim_unlocked_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + // Lock/unlock some amount to create unlocking chunk + let amount = 1234; + assert_ok!(DappStaking::lock( + RawOrigin::Signed(AddressMapper::into_account_id(ALICE)).into(), + amount, + )); + assert_ok!(DappStaking::unlock( + RawOrigin::Signed(AddressMapper::into_account_id(ALICE)).into(), + amount, + )); + + // Advance enough into time so unlocking chunk can be claimed + let unlock_block = + Ledger::::get(&AddressMapper::into_account_id(ALICE)).unlocking[0].unlock_block; + run_to_block(unlock_block); + + // Claim unlocked chunk and verify event + System::reset_events(); + precompiles() + .prepare_test( + ALICE, + precompile_address(), + PrecompileCall::claim_unlocked {}, + ) + .expect_no_logs() + .execute_returns(true); + + let events = dapp_staking_events(); + assert_eq!(events.len(), 1); + assert_matches!( + events[0].clone(), + pallet_dapp_staking_v3::Event::ClaimedUnlocked { + amount, + .. + } if amount == amount + ); + }); +} + +#[test] +fn stake_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + // Register a dApp for staking + let staker_h160 = ALICE; + let smart_contract_h160 = H160::repeat_byte(0xFA); + let smart_contract = + ::SmartContract::evm(smart_contract_h160); + assert_ok!(DappStaking::register( + RawOrigin::Root.into(), + AddressMapper::into_account_id(staker_h160), + smart_contract.clone() + )); + + // Lock some amount which will be used for staking + let amount = 2000; + assert_ok!(DappStaking::lock( + RawOrigin::Signed(AddressMapper::into_account_id(staker_h160)).into(), + amount, + )); + + let smart_contract_v2 = SmartContractV2 { + contract_type: SmartContractTypes::Evm, + address: smart_contract_h160.as_bytes().try_into().unwrap(), + }; + + // Stake some amount and verify event + System::reset_events(); + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::stake { + smart_contract: smart_contract_v2, + amount, + }, + ) + .expect_no_logs() + .execute_returns(true); + + let events = dapp_staking_events(); + assert_eq!(events.len(), 1); + assert_matches!( + events[0].clone(), + pallet_dapp_staking_v3::Event::Stake { + smart_contract, + amount, + .. + } if smart_contract == smart_contract && amount == amount + ); + }); +} + +#[test] +fn unstake_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + // Register a dApp for staking + let staker_h160 = ALICE; + let smart_contract_address = [0xAF; 32]; + let smart_contract = ::SmartContract::wasm( + smart_contract_address.into(), + ); + assert_ok!(DappStaking::register( + RawOrigin::Root.into(), + AddressMapper::into_account_id(staker_h160), + smart_contract.clone() + )); + + // Lock & stake some amount + let amount = 2000; + assert_ok!(DappStaking::lock( + RawOrigin::Signed(AddressMapper::into_account_id(staker_h160)).into(), + amount, + )); + assert_ok!(DappStaking::stake( + RawOrigin::Signed(AddressMapper::into_account_id(staker_h160)).into(), + smart_contract.clone(), + amount, + )); + + let smart_contract_v2 = SmartContractV2 { + contract_type: SmartContractTypes::Wasm, + address: smart_contract_address.into(), + }; + + // Unstake some amount and verify event + System::reset_events(); + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::unstake { + smart_contract: smart_contract_v2, + amount, + }, + ) + .expect_no_logs() + .execute_returns(true); + + let events = dapp_staking_events(); + assert_eq!(events.len(), 1); + assert_matches!( + events[0].clone(), + pallet_dapp_staking_v3::Event::Unstake { + smart_contract, + amount, + .. + } if smart_contract == smart_contract && amount == amount + ); + }); +} + +#[test] +fn claim_staker_rewards_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + // Register a dApp and stake on it + let staker_h160 = ALICE; + let smart_contract_address = [0xAF; 32]; + let smart_contract = ::SmartContract::wasm( + smart_contract_address.into(), + ); + let amount = 1234; + register_and_stake(staker_h160, smart_contract.clone(), amount); + + // Advance a few eras so we can claim a few rewards + let target_era = 7; + advance_to_era(target_era); + let number_of_claims = (2..target_era).count(); + + // Claim staker rewards and verify events + System::reset_events(); + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::claim_staker_rewards {}, + ) + .expect_no_logs() + .execute_returns(true); + + // We expect multiple reward to be claimed + let events = dapp_staking_events(); + assert_eq!(events.len(), number_of_claims as usize); + for era in 2..target_era { + assert_matches!( + events[era as usize - 2].clone(), + pallet_dapp_staking_v3::Event::Reward { era, .. } if era == era + ); + } + }); +} + +#[test] +fn claim_bonus_reward_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + // Register a dApp and stake on it, loyally + let staker_h160 = ALICE; + let smart_contract_address = [0xAF; 32]; + let smart_contract = ::SmartContract::wasm( + smart_contract_address.into(), + ); + let amount = 1234; + register_and_stake(staker_h160, smart_contract.clone(), amount); + + // Advance to the next period + advance_to_next_period(); + + let smart_contract_v2 = SmartContractV2 { + contract_type: SmartContractTypes::Wasm, + address: smart_contract_address.into(), + }; + + // Claim bonus reward and verify event + System::reset_events(); + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::claim_bonus_reward { + smart_contract: smart_contract_v2, + }, + ) + .expect_no_logs() + .execute_returns(true); + + let events = dapp_staking_events(); + assert_eq!(events.len(), 1); + assert_matches!( + events[0].clone(), + pallet_dapp_staking_v3::Event::BonusReward { smart_contract, .. } if smart_contract == smart_contract + ); + }); +} + +#[test] +fn claim_dapp_reward_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + // Register a dApp and stake on it + let staker_h160 = ALICE; + let smart_contract_address = [0xAF; 32]; + let smart_contract = ::SmartContract::wasm( + smart_contract_address.into(), + ); + let amount = 1234; + register_and_stake(staker_h160, smart_contract.clone(), amount); + + // Advance to 3rd era so we claim rewards for the 2nd era + advance_to_era(3); + + let smart_contract_v2 = SmartContractV2 { + contract_type: SmartContractTypes::Wasm, + address: smart_contract_address.into(), + }; + + // Claim dApp reward and verify event + let claim_era: EraNumber = 2; + System::reset_events(); + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::claim_dapp_reward { + smart_contract: smart_contract_v2, + era: claim_era.into(), + }, + ) + .expect_no_logs() + .execute_returns(true); + + let events = dapp_staking_events(); + assert_eq!(events.len(), 1); + assert_matches!( + events[0].clone(), + pallet_dapp_staking_v3::Event::DAppReward { era, smart_contract, .. } if era == claim_era && smart_contract == smart_contract + ); + }); +} + +#[test] +fn unstake_from_unregistered_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + // Register a dApp and stake on it + let staker_h160 = ALICE; + let smart_contract_address = [0xAF; 32]; + let smart_contract = ::SmartContract::wasm( + smart_contract_address.into(), + ); + let amount = 1234; + register_and_stake(staker_h160, smart_contract.clone(), amount); + + // Unregister the dApp + assert_ok!(DappStaking::unregister( + RawOrigin::Root.into(), + smart_contract.clone() + )); + + let smart_contract_v2 = SmartContractV2 { + contract_type: SmartContractTypes::Wasm, + address: smart_contract_address.into(), + }; + + // Unstake from the unregistered dApp and verify event + System::reset_events(); + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::unstake_from_unregistered { + smart_contract: smart_contract_v2, + }, + ) + .expect_no_logs() + .execute_returns(true); + + let events = dapp_staking_events(); + assert_eq!(events.len(), 1); + assert_matches!( + events[0].clone(), + pallet_dapp_staking_v3::Event::UnstakeFromUnregistered { smart_contract, amount, .. } if smart_contract == smart_contract && amount == amount + ); + }); +} + +#[test] +fn cleanup_expired_entries_is_ok() { + ExternalityBuilder::build().execute_with(|| { + initialize(); + + // Advance over to the Build&Earn subperiod + advance_to_next_subperiod(); + assert_eq!( + ActiveProtocolState::::get().subperiod(), + Subperiod::BuildAndEarn, + "Sanity check." + ); + + // Register a dApp and stake on it + let staker_h160 = ALICE; + let smart_contract_address = [0xAF; 32]; + let smart_contract = ::SmartContract::wasm( + smart_contract_address.into(), + ); + let amount = 1234; + register_and_stake(staker_h160, smart_contract.clone(), amount); + + // Advance over to the next period so the entry for dApp becomes expired + advance_to_next_period(); + + // Cleanup single expired entry and verify event + System::reset_events(); + precompiles() + .prepare_test( + staker_h160, + precompile_address(), + PrecompileCall::cleanup_expired_entries {}, + ) + .expect_no_logs() + .execute_returns(true); + + let events = dapp_staking_events(); + assert_eq!(events.len(), 1); + assert_matches!( + events[0].clone(), + pallet_dapp_staking_v3::Event::ExpiredEntriesRemoved { count, .. } if count == 1 + ); + }); +} diff --git a/precompiles/dapp-staking-v3/src/test/types.rs b/precompiles/dapp-staking-v3/src/test/types.rs new file mode 100644 index 0000000000..186300e677 --- /dev/null +++ b/precompiles/dapp-staking-v3/src/test/types.rs @@ -0,0 +1,154 @@ +// 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 . + +extern crate alloc; +use crate::{test::mock::*, *}; + +use assert_matches::assert_matches; + +#[test] +fn smart_contract_types_are_ok() { + // Verify Astar EVM smart contract type + { + let index: u8 = SmartContractTypes::Evm.into(); + assert_eq!(index, 0); + assert_eq!(Ok(SmartContractTypes::Evm), index.try_into()); + } + + // Verify Astar WASM smart contract type + { + let index: u8 = SmartContractTypes::Wasm.into(); + assert_eq!(index, 1); + assert_eq!(Ok(SmartContractTypes::Wasm), index.try_into()); + } + + // Negative case + { + let index: u8 = 2; + let maybe_smart_contract: Result = index.try_into(); + assert_matches!(maybe_smart_contract, Err(_)); + } +} + +#[test] +fn decode_smart_contract_is_ok() { + ExternalityBuilder::build().execute_with(|| { + // Astar EVM smart contract decoding + { + let address = H160::repeat_byte(0xCA); + let smart_contract_v2 = SmartContractV2 { + contract_type: SmartContractTypes::Evm, + address: address.as_bytes().into(), + }; + + assert_eq!( + Ok(::SmartContract::evm(address)), + DappStakingV3Precompile::::decode_smart_contract(smart_contract_v2) + ); + } + + // Astar WASM smart contract decoding + { + let address = [0x6E; 32]; + let smart_contract_v2 = SmartContractV2 { + contract_type: SmartContractTypes::Wasm, + address: address.into(), + }; + + assert_eq!( + Ok(::SmartContract::wasm(address.into())), + DappStakingV3Precompile::::decode_smart_contract(smart_contract_v2) + ); + } + }); +} + +#[test] +fn decode_smart_contract_fails_when_type_and_address_mismatch() { + ExternalityBuilder::build().execute_with(|| { + // H160 address for Wasm smart contract type + { + let address = H160::repeat_byte(0xCA); + let smart_contract_v2 = SmartContractV2 { + contract_type: SmartContractTypes::Wasm, + address: address.as_bytes().into(), + }; + + assert_matches!( + DappStakingV3Precompile::::decode_smart_contract(smart_contract_v2), + Err(_) + ); + } + + // Native address for EVM smart contract type + { + let address = [0x6E; 32]; + let smart_contract_v2 = SmartContractV2 { + contract_type: SmartContractTypes::Evm, + address: address.into(), + }; + + assert_matches!( + DappStakingV3Precompile::::decode_smart_contract(smart_contract_v2), + Err(_) + ); + } + }); +} + +#[test] +fn parse_input_address_is_ok() { + ExternalityBuilder::build().execute_with(|| { + // H160 address + { + let address_h160 = H160::repeat_byte(0xCA); + let address_native = AddressMapper::into_account_id(address_h160); + + assert_eq!( + DappStakingV3Precompile::::parse_input_address( + address_h160.as_bytes().into() + ), + Ok(address_native) + ); + } + + // Native address + { + let address_native = [0x6E; 32]; + + assert_eq!( + DappStakingV3Precompile::::parse_input_address(address_native.into()), + Ok(address_native.into()) + ); + } + }); +} + +#[test] +fn parse_input_address_fails_with_incorrect_address_length() { + ExternalityBuilder::build().execute_with(|| { + let addresses: Vec<&[u8]> = vec![&[0x6E; 19], &[0xA1; 21], &[0xC3; 31], &[0x99; 33]]; + + for address in addresses { + assert_matches!( + DappStakingV3Precompile::::parse_input_address(address.into()), + Err(_) + ); + } + }); +} diff --git a/precompiles/dapps-staking/src/mock.rs b/precompiles/dapps-staking/src/mock.rs index bcc3fac837..e85d563580 100644 --- a/precompiles/dapps-staking/src/mock.rs +++ b/precompiles/dapps-staking/src/mock.rs @@ -21,7 +21,7 @@ use super::*; use fp_evm::{IsPrecompileResult, Precompile}; use frame_support::{ construct_runtime, parameter_types, - traits::{ConstU64, Currency, OnFinalize, OnInitialize}, + traits::{ConstBool, ConstU64, Currency, OnFinalize, OnInitialize}, weights::{RuntimeDbWeight, Weight}, PalletId, }; @@ -309,6 +309,7 @@ impl pallet_dapps_staking::Config for TestRuntime { type UnbondingPeriod = UnbondingPeriod; type MaxEraStakeValues = MaxEraStakeValues; type UnregisteredDappRewardRetention = ConstU32<2>; + type ForcePalletDisabled = ConstBool; } pub struct ExternalityBuilder { diff --git a/primitives/src/dapp_staking.rs b/primitives/src/dapp_staking.rs new file mode 100644 index 0000000000..baf7ff34b5 --- /dev/null +++ b/primitives/src/dapp_staking.rs @@ -0,0 +1,138 @@ +// 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 super::{Balance, BlockNumber}; + +use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; + +use frame_support::RuntimeDebug; +use sp_core::H160; +use sp_std::hash::Hash; + +/// Configuration for cycles, periods, subperiods & eras. +/// +/// * `cycle` - Time unit similar to 'year' in the real world. Consists of one or more periods. At the beginning of each cycle, inflation is recalculated. +/// * `period` - Period consists of two distinct subperiods: `Voting` & `Build&Earn`. They are integral parts of dApp staking. +/// Length is expressed in standard eras or just _eras_. +/// * `era` - Era is the basic time unit in the dApp staking protocol. At the end of each era, reward pools for stakers & dApps are calculated. +/// Era length is expressed in blocks. +pub trait CycleConfiguration { + /// How many different periods are there in a cycle (a 'year'). + /// + /// This value has to be at least 1. + fn periods_per_cycle() -> u32; + + /// For how many standard era lengths does the voting subperiod last. + /// + /// This value has to be at least 1. + fn eras_per_voting_subperiod() -> u32; + + /// How many standard eras are there in the build&earn subperiod. + /// + /// This value has to be at least 1. + fn eras_per_build_and_earn_subperiod() -> u32; + + /// How many blocks are there per standard era. + /// + /// This value has to be at least 1. + fn blocks_per_era() -> BlockNumber; + + /// For how many standard era lengths does the period last. + fn eras_per_period() -> u32 { + Self::eras_per_voting_subperiod().saturating_add(Self::eras_per_build_and_earn_subperiod()) + } + + /// For how many standard era lengths does the cylce (a 'year') last. + fn eras_per_cycle() -> u32 { + Self::eras_per_period().saturating_mul(Self::periods_per_cycle()) + } + + /// How many blocks are there per cycle (a 'year'). + fn blocks_per_cycle() -> BlockNumber { + Self::blocks_per_era().saturating_mul(Self::eras_per_cycle()) + } + + /// For how many standard era lengths do all the build&earn subperiods in a cycle last. + fn build_and_earn_eras_per_cycle() -> u32 { + Self::eras_per_build_and_earn_subperiod().saturating_mul(Self::periods_per_cycle()) + } +} + +/// Interface for staking reward handler. +/// +/// Provides reward pool values for stakers - normal & bonus rewards, as well as dApp reward pool. +/// Also provides a safe function for paying out rewards. +pub trait StakingRewardHandler { + /// Returns the staker reward pool & dApp reward pool for an era. + /// + /// The total staker reward pool is dynamic and depends on the total value staked. + fn staker_and_dapp_reward_pools(total_value_staked: Balance) -> (Balance, Balance); + + /// Returns the bonus reward pool for a period. + fn bonus_reward_pool() -> Balance; + + /// Attempts to pay out the rewards to the beneficiary. + fn payout_reward(beneficiary: &AccountId, reward: Balance) -> Result<(), ()>; +} + +/// Trait defining the interface for dApp staking `smart contract types` handler. +/// +/// It can be used to create a representation of the specified smart contract instance type. +pub trait SmartContractHandle { + /// Create a new smart contract representation for the specified EVM address. + fn evm(address: H160) -> Self; + /// Create a new smart contract representation for the specified Wasm address. + fn wasm(address: AccountId) -> Self; +} + +/// Multi-VM pointer to smart contract instance. +#[derive( + PartialEq, + Eq, + Copy, + Clone, + Encode, + Decode, + RuntimeDebug, + MaxEncodedLen, + Hash, + scale_info::TypeInfo, +)] +pub enum SmartContract { + /// EVM smart contract instance. + Evm(H160), + /// Wasm smart contract instance. + Wasm(AccountId), +} + +// TODO: remove this once dApps staking v2 has been removed. +impl Default for SmartContract { + fn default() -> Self { + Self::evm([0x01; 20].into()) + } +} + +impl SmartContractHandle for SmartContract { + fn evm(address: H160) -> Self { + Self::Evm(address) + } + + fn wasm(address: AccountId) -> Self { + Self::Wasm(address) + } +} diff --git a/primitives/src/lib.rs b/primitives/src/lib.rs index 010feba290..a531b87334 100644 --- a/primitives/src/lib.rs +++ b/primitives/src/lib.rs @@ -35,6 +35,12 @@ pub mod evm; /// Precompiles pub mod precompiles; +/// dApp staking & inflation primitives. +pub mod dapp_staking; + +/// Useful primitives for testing. +pub mod testing; + /// Benchmark primitives #[cfg(feature = "runtime-benchmarks")] pub mod benchmarks; diff --git a/primitives/src/testing.rs b/primitives/src/testing.rs new file mode 100644 index 0000000000..aa800582f1 --- /dev/null +++ b/primitives/src/testing.rs @@ -0,0 +1,24 @@ +// 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 super::BlockNumber; + +use sp_runtime::{generic::Header as GenericHeader, traits::BlakeTwo256}; + +/// Test `Header` type aligned with Astar primitives. +pub type Header = GenericHeader; diff --git a/runtime/astar/src/lib.rs b/runtime/astar/src/lib.rs index 09e9a48184..c03b403732 100644 --- a/runtime/astar/src/lib.rs +++ b/runtime/astar/src/lib.rs @@ -336,6 +336,7 @@ impl pallet_dapps_staking::Config for Runtime { type MaxEraStakeValues = MaxEraStakeValues; // Not allowed on Astar yet type UnregisteredDappRewardRetention = ConstU32<{ u32::MAX }>; + type ForcePalletDisabled = ConstBool; } /// Multi-VM pointer to smart contract instance. diff --git a/runtime/local/Cargo.toml b/runtime/local/Cargo.toml index 76f7e62145..92740a2eae 100644 --- a/runtime/local/Cargo.toml +++ b/runtime/local/Cargo.toml @@ -48,6 +48,7 @@ pallet-treasury = { workspace = true } pallet-utility = { workspace = true } pallet-vesting = { workspace = true } sp-api = { workspace = true } +sp-arithmetic = { workspace = true } sp-block-builder = { workspace = true } sp-consensus-aura = { workspace = true } sp-core = { workspace = true } @@ -67,20 +68,24 @@ pallet-transaction-payment-rpc-runtime-api = { workspace = true } # Astar pallets astar-primitives = { workspace = true } pallet-block-rewards-hybrid = { workspace = true } -pallet-chain-extension-dapps-staking = { workspace = true } pallet-chain-extension-unified-accounts = { workspace = true } pallet-chain-extension-xvm = { workspace = true } +pallet-dapp-staking-migration = { workspace = true } +pallet-dapp-staking-v3 = { workspace = true } pallet-dapps-staking = { workspace = true } pallet-dynamic-evm-base-fee = { workspace = true } pallet-evm-precompile-assets-erc20 = { workspace = true } -pallet-evm-precompile-dapps-staking = { workspace = true } +pallet-evm-precompile-dapp-staking-v3 = { workspace = true } pallet-evm-precompile-sr25519 = { workspace = true } pallet-evm-precompile-substrate-ecdsa = { workspace = true } pallet-evm-precompile-unified-accounts = { workspace = true } pallet-evm-precompile-xvm = { workspace = true } +pallet-inflation = { workspace = true } pallet-unified-accounts = { workspace = true } pallet-xvm = { workspace = true } +dapp-staking-v3-runtime-api = { workspace = true } + precompile-utils = { workspace = true } # Moonbeam tracing @@ -118,10 +123,13 @@ std = [ "pallet-block-rewards-hybrid/std", "pallet-contracts/std", "pallet-contracts-primitives/std", - "pallet-chain-extension-dapps-staking/std", "pallet-chain-extension-xvm/std", "pallet-chain-extension-unified-accounts/std", "pallet-dapps-staking/std", + "pallet-dapp-staking-v3/std", + "pallet-dapp-staking-migration/std", + "dapp-staking-v3-runtime-api/std", + "pallet-inflation/std", "pallet-dynamic-evm-base-fee/std", "pallet-ethereum/std", "pallet-evm/std", @@ -132,7 +140,7 @@ std = [ "pallet-evm-precompile-ed25519/std", "pallet-evm-precompile-modexp/std", "pallet-evm-precompile-sha3fips/std", - "pallet-evm-precompile-dapps-staking/std", + "pallet-evm-precompile-dapp-staking-v3/std", "pallet-evm-precompile-sr25519/std", "pallet-evm-precompile-substrate-ecdsa/std", "pallet-evm-precompile-unified-accounts/std", @@ -155,6 +163,7 @@ std = [ "sp-offchain/std", "sp-runtime/std", "sp-session/std", + "sp-arithmetic/std", "sp-std/std", "sp-transaction-pool/std", "sp-version/std", @@ -192,7 +201,10 @@ runtime-benchmarks = [ "pallet-unified-accounts/runtime-benchmarks", "astar-primitives/runtime-benchmarks", "pallet-assets/runtime-benchmarks", + "pallet-dapp-staking-v3/runtime-benchmarks", + "pallet-inflation/runtime-benchmarks", "pallet-dynamic-evm-base-fee/runtime-benchmarks", + "pallet-dapp-staking-migration/runtime-benchmarks", ] try-runtime = [ "fp-self-contained/try-runtime", @@ -206,6 +218,8 @@ try-runtime = [ "pallet-block-rewards-hybrid/try-runtime", "pallet-contracts/try-runtime", "pallet-dapps-staking/try-runtime", + "pallet-dapp-staking-v3/try-runtime", + "pallet-inflation/try-runtime", "pallet-grandpa/try-runtime", "pallet-insecure-randomness-collective-flip/try-runtime", "pallet-sudo/try-runtime", @@ -226,6 +240,7 @@ try-runtime = [ "pallet-dynamic-evm-base-fee/try-runtime", "pallet-evm/try-runtime", "pallet-ethereum-checked/try-runtime", + "pallet-dapp-staking-migration/try-runtime", ] evm-tracing = [ "moonbeam-evm-tracer", diff --git a/runtime/local/src/chain_extensions.rs b/runtime/local/src/chain_extensions.rs index 516bc8040b..a368fa8d18 100644 --- a/runtime/local/src/chain_extensions.rs +++ b/runtime/local/src/chain_extensions.rs @@ -22,16 +22,11 @@ use super::{Runtime, UnifiedAccounts, Xvm}; pub use pallet_chain_extension_assets::AssetsExtension; use pallet_contracts::chain_extension::RegisteredChainExtension; -pub use pallet_chain_extension_dapps_staking::DappsStakingExtension; pub use pallet_chain_extension_unified_accounts::UnifiedAccountsExtension; pub use pallet_chain_extension_xvm::XvmExtension; // Following impls defines chain extension IDs. -impl RegisteredChainExtension for DappsStakingExtension { - const ID: u16 = 00; -} - impl RegisteredChainExtension for XvmExtension { const ID: u16 = 01; } diff --git a/runtime/local/src/lib.rs b/runtime/local/src/lib.rs index 6368ecf410..b1bec5ead9 100644 --- a/runtime/local/src/lib.rs +++ b/runtime/local/src/lib.rs @@ -24,7 +24,6 @@ #[cfg(feature = "std")] include!(concat!(env!("OUT_DIR"), "/wasm_binary.rs")); -use astar_primitives::evm::HashedDefaultMappings; use frame_support::{ construct_runtime, parameter_types, traits::{ @@ -49,6 +48,7 @@ use pallet_grandpa::{fg_primitives, AuthorityList as GrandpaAuthorityList}; use pallet_transaction_payment::{CurrencyAdapter, Multiplier, TargetedFeeAdjustment}; use parity_scale_codec::{Compact, Decode, Encode, MaxEncodedLen}; use sp_api::impl_runtime_apis; +use sp_arithmetic::fixed_point::FixedU64; use sp_core::{crypto::KeyTypeId, ConstBool, OpaqueMetadata, H160, H256, U256}; use sp_runtime::{ create_runtime_str, generic, impl_opaque_keys, @@ -61,11 +61,15 @@ use sp_runtime::{ }; use sp_std::prelude::*; -pub use astar_primitives::{ - evm::EvmRevertCodeHandler, AccountId, Address, AssetId, Balance, BlockNumber, Hash, Header, - Index, Signature, +use astar_primitives::{ + dapp_staking::{CycleConfiguration, SmartContract}, + evm::{EvmRevertCodeHandler, HashedDefaultMappings}, + Address, AssetId, Balance, BlockNumber, Hash, Header, Index, }; + +pub use astar_primitives::{AccountId, Signature}; pub use pallet_block_rewards_hybrid::RewardDistributionConfig; +pub use pallet_dapp_staking_v3::TierThreshold; pub use crate::precompiles::WhitelistedCalls; #[cfg(feature = "std")] @@ -75,6 +79,7 @@ use sp_version::RuntimeVersion; pub use frame_system::Call as SystemCall; pub use pallet_balances::Call as BalancesCall; pub use pallet_grandpa::AuthorityId as GrandpaId; +pub use pallet_inflation::InflationParameters; pub use pallet_timestamp::Call as TimestampCall; pub use sp_consensus_aura::sr25519::AuthorityId as AuraId; #[cfg(any(feature = "std", test))] @@ -294,9 +299,9 @@ impl pallet_balances::Config for Runtime { type AccountStore = System; type WeightInfo = weights::pallet_balances::SubstrateWeight; type HoldIdentifier = (); - type FreezeIdentifier = (); + type FreezeIdentifier = RuntimeFreezeReason; type MaxHolds = ConstU32<0>; - type MaxFreezes = ConstU32<0>; + type MaxFreezes = ConstU32<1>; } parameter_types! { @@ -477,31 +482,99 @@ impl pallet_dapps_staking::Config for Runtime { type MinimumRemainingAmount = MinimumRemainingAmount; type MaxEraStakeValues = MaxEraStakeValues; type UnregisteredDappRewardRetention = ConstU32<3>; + type ForcePalletDisabled = ConstBool; // This will be set to `true` when needed } -/// Multi-VM pointer to smart contract instance. -#[derive( - PartialEq, Eq, Copy, Clone, Encode, Decode, RuntimeDebug, MaxEncodedLen, scale_info::TypeInfo, -)] -pub enum SmartContract { - /// EVM smart contract instance. - Evm(sp_core::H160), - /// Wasm smart contract instance. - Wasm(AccountId), +pub struct DummyPriceProvider; +impl pallet_dapp_staking_v3::PriceProvider for DummyPriceProvider { + fn average_price() -> FixedU64 { + FixedU64::from_rational(1, 10) + } } -impl Default for SmartContract { - fn default() -> Self { - SmartContract::Evm(H160::repeat_byte(0x00)) +#[cfg(feature = "runtime-benchmarks")] +pub struct BenchmarkHelper(sp_std::marker::PhantomData<(SC, ACC)>); +#[cfg(feature = "runtime-benchmarks")] +impl pallet_dapp_staking_v3::BenchmarkHelper, AccountId> + for BenchmarkHelper, AccountId> +{ + fn get_smart_contract(id: u32) -> SmartContract { + SmartContract::Wasm(AccountId::from([id as u8; 32])) + } + + fn set_balance(account: &AccountId, amount: Balance) { + use frame_support::traits::fungible::Unbalanced as FunUnbalanced; + Balances::write_balance(account, amount) + .expect("Must succeed in test/benchmark environment."); } } -impl> From<[u8; 32]> for SmartContract { - fn from(input: [u8; 32]) -> Self { - SmartContract::Wasm(input.into()) +impl pallet_dapp_staking_v3::Config for Runtime { + type RuntimeEvent = RuntimeEvent; + type RuntimeFreezeReason = RuntimeFreezeReason; + type Currency = Balances; + type SmartContract = SmartContract; + type ManagerOrigin = frame_system::EnsureRoot; + type NativePriceProvider = DummyPriceProvider; + type StakingRewardHandler = Inflation; + type CycleConfiguration = InflationCycleConfig; + type EraRewardSpanLength = ConstU32<8>; + type RewardRetentionInPeriods = ConstU32<2>; + type MaxNumberOfContracts = ConstU32<100>; + type MaxUnlockingChunks = ConstU32<5>; + type MinimumLockedAmount = ConstU128; + type UnlockingPeriod = ConstU32<2>; + type MaxNumberOfStakedContracts = ConstU32<3>; + type MinimumStakeAmount = ConstU128; + type NumberOfTiers = ConstU32<4>; + type WeightInfo = pallet_dapp_staking_v3::weights::SubstrateWeight; + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = BenchmarkHelper, AccountId>; +} + +pub struct InflationPayoutPerBlock; +impl pallet_inflation::PayoutPerBlock for InflationPayoutPerBlock { + fn treasury(reward: NegativeImbalance) { + Balances::resolve_creating(&TreasuryPalletId::get().into_account_truncating(), reward); + } + + fn collators(_reward: NegativeImbalance) { + // no collators for local dev node } } +pub struct InflationCycleConfig; +impl CycleConfiguration for InflationCycleConfig { + fn periods_per_cycle() -> u32 { + 4 + } + + fn eras_per_voting_subperiod() -> u32 { + 2 + } + + fn eras_per_build_and_earn_subperiod() -> u32 { + 22 + } + + fn blocks_per_era() -> BlockNumber { + 30 + } +} + +impl pallet_inflation::Config for Runtime { + type Currency = Balances; + type PayoutPerBlock = InflationPayoutPerBlock; + type CycleConfiguration = InflationCycleConfig; + type RuntimeEvent = RuntimeEvent; + type WeightInfo = pallet_inflation::weights::SubstrateWeight; +} + +impl pallet_dapp_staking_migration::Config for Runtime { + type RuntimeEvent = RuntimeEvent; + type WeightInfo = pallet_dapp_staking_migration::weights::SubstrateWeight; +} + impl pallet_utility::Config for Runtime { type RuntimeEvent = RuntimeEvent; type RuntimeCall = RuntimeCall; @@ -850,7 +923,7 @@ impl pallet_contracts::Config for Runtime { type WeightPrice = pallet_transaction_payment::Pallet; type WeightInfo = pallet_contracts::weights::SubstrateWeight; type ChainExtension = ( - DappsStakingExtension, + // DappsStakingExtension, XvmExtension, AssetsExtension>, UnifiedAccountsExtension, @@ -1028,6 +1101,9 @@ construct_runtime!( Balances: pallet_balances, Vesting: pallet_vesting, DappsStaking: pallet_dapps_staking, + DappStaking: pallet_dapp_staking_v3, + DappStakingMigration: pallet_dapp_staking_migration, + Inflation: pallet_inflation, BlockReward: pallet_block_rewards_hybrid, TransactionPayment: pallet_transaction_payment, EVM: pallet_evm, @@ -1080,8 +1156,11 @@ pub type Executive = frame_executive::Executive< frame_system::ChainContext, Runtime, AllPalletsWithSystem, + Migrations, >; +pub type Migrations = (); + type EventRecord = frame_system::EventRecord< ::RuntimeEvent, ::Hash, @@ -1160,6 +1239,9 @@ mod benches { [pallet_dapps_staking, DappsStaking] [pallet_block_rewards_hybrid, BlockReward] [pallet_ethereum_checked, EthereumChecked] + [pallet_dapp_staking_v3, DappStaking] + [pallet_dapp_staking_migration, DappStakingMigration] + [pallet_inflation, Inflation] [pallet_dynamic_evm_base_fee, DynamicEvmBaseFee] ); } @@ -1648,6 +1730,20 @@ impl_runtime_apis! { } } + impl dapp_staking_v3_runtime_api::DappStakingApi for Runtime { + fn eras_per_voting_subperiod() -> pallet_dapp_staking_v3::EraNumber { + InflationCycleConfig::eras_per_voting_subperiod() + } + + fn eras_per_build_and_earn_subperiod() -> pallet_dapp_staking_v3::EraNumber { + InflationCycleConfig::eras_per_build_and_earn_subperiod() + } + + fn blocks_per_era() -> BlockNumber { + InflationCycleConfig::blocks_per_era() + } + } + #[cfg(feature = "runtime-benchmarks")] impl frame_benchmarking::Benchmark for Runtime { fn benchmark_metadata(extra: bool) -> ( diff --git a/runtime/local/src/precompiles.rs b/runtime/local/src/precompiles.rs index 151c7ca577..d207068f4b 100644 --- a/runtime/local/src/precompiles.rs +++ b/runtime/local/src/precompiles.rs @@ -24,7 +24,7 @@ use frame_support::{parameter_types, traits::Contains}; use pallet_evm_precompile_assets_erc20::Erc20AssetsPrecompileSet; use pallet_evm_precompile_blake2::Blake2F; use pallet_evm_precompile_bn128::{Bn128Add, Bn128Mul, Bn128Pairing}; -use pallet_evm_precompile_dapps_staking::DappsStakingWrapper; +use pallet_evm_precompile_dapp_staking_v3::DappStakingV3Precompile; use pallet_evm_precompile_dispatch::Dispatch; use pallet_evm_precompile_ed25519::Ed25519Verify; use pallet_evm_precompile_modexp::Modexp; @@ -92,7 +92,7 @@ pub type LocalPrecompilesSetAt = ( // Local specific precompiles: PrecompileAt< AddressU64<20481>, - DappsStakingWrapper, + DappStakingV3Precompile, (CallableByContract, CallableByPrecompile), >, PrecompileAt< diff --git a/runtime/shibuya/Cargo.toml b/runtime/shibuya/Cargo.toml index 49eb2d6c01..bd50815120 100644 --- a/runtime/shibuya/Cargo.toml +++ b/runtime/shibuya/Cargo.toml @@ -20,6 +20,7 @@ smallvec = { workspace = true } fp-rpc = { workspace = true } fp-self-contained = { workspace = true } sp-api = { workspace = true } +sp-arithmetic = { workspace = true } sp-block-builder = { workspace = true } sp-consensus-aura = { workspace = true } sp-core = { workspace = true } @@ -96,26 +97,29 @@ orml-xtokens = { workspace = true } # Astar pallets astar-primitives = { workspace = true } -pallet-block-rewards-hybrid = { workspace = true } -pallet-chain-extension-dapps-staking = { workspace = true } pallet-chain-extension-unified-accounts = { workspace = true } pallet-chain-extension-xvm = { workspace = true } pallet-collator-selection = { workspace = true } +pallet-dapp-staking-migration = { workspace = true } +pallet-dapp-staking-v3 = { workspace = true } pallet-dapps-staking = { workspace = true } pallet-dynamic-evm-base-fee = { workspace = true } pallet-ethereum-checked = { workspace = true } pallet-evm-precompile-assets-erc20 = { workspace = true } -pallet-evm-precompile-dapps-staking = { workspace = true } +pallet-evm-precompile-dapp-staking-v3 = { workspace = true } pallet-evm-precompile-sr25519 = { workspace = true } pallet-evm-precompile-substrate-ecdsa = { workspace = true } pallet-evm-precompile-unified-accounts = { workspace = true } pallet-evm-precompile-xcm = { workspace = true } pallet-evm-precompile-xvm = { workspace = true } +pallet-inflation = { workspace = true } pallet-unified-accounts = { workspace = true } pallet-xc-asset-config = { workspace = true } pallet-xcm = { workspace = true } pallet-xvm = { workspace = true } +dapp-staking-v3-runtime-api = { workspace = true } + precompile-utils = { workspace = true } # Moonbeam tracing @@ -154,6 +158,7 @@ std = [ "sp-block-builder/std", "sp-transaction-pool/std", "sp-inherents/std", + "sp-arithmetic/std", "frame-support/std", "frame-executive/std", "frame-system/std", @@ -162,10 +167,8 @@ std = [ "pallet-aura/std", "pallet-assets/std", "pallet-balances/std", - "pallet-block-rewards-hybrid/std", "pallet-contracts/std", "pallet-contracts-primitives/std", - "pallet-chain-extension-dapps-staking/std", "pallet-chain-extension-xvm/std", "pallet-chain-extension-unified-accounts/std", "pallet-dynamic-evm-base-fee/std", @@ -180,7 +183,7 @@ std = [ "pallet-evm-precompile-ed25519/std", "pallet-evm-precompile-modexp/std", "pallet-evm-precompile-sha3fips/std", - "pallet-evm-precompile-dapps-staking/std", + "pallet-evm-precompile-dapp-staking-v3/std", "pallet-evm-precompile-sr25519/std", "pallet-evm-precompile-substrate-ecdsa/std", "pallet-evm-precompile-assets-erc20/std", @@ -188,6 +191,10 @@ std = [ "pallet-evm-precompile-xvm/std", "pallet-evm-precompile-unified-accounts/std", "pallet-dapps-staking/std", + "pallet-dapp-staking-v3/std", + "pallet-dapp-staking-migration/std", + "dapp-staking-v3-runtime-api/std", + "pallet-inflation/std", "pallet-identity/std", "pallet-multisig/std", "pallet-insecure-randomness-collective-flip/std", @@ -244,7 +251,9 @@ runtime-benchmarks = [ "frame-system/runtime-benchmarks", "sp-runtime/runtime-benchmarks", "pallet-dapps-staking/runtime-benchmarks", - "pallet-block-rewards-hybrid/runtime-benchmarks", + "pallet-dapp-staking-v3/runtime-benchmarks", + "pallet-dapp-staking-migration/runtime-benchmarks", + "pallet-inflation/runtime-benchmarks", "pallet-balances/runtime-benchmarks", "pallet-timestamp/runtime-benchmarks", "pallet-collective/runtime-benchmarks", @@ -271,8 +280,10 @@ try-runtime = [ "frame-system/try-runtime", "pallet-aura/try-runtime", "pallet-balances/try-runtime", - "pallet-block-rewards-hybrid/try-runtime", "pallet-dapps-staking/try-runtime", + "pallet-dapp-staking-v3/try-runtime", + "pallet-dapp-staking-migration/try-runtime", + "pallet-inflation/try-runtime", "pallet-sudo/try-runtime", "pallet-timestamp/try-runtime", "pallet-transaction-payment/try-runtime", diff --git a/runtime/shibuya/src/chain_extensions.rs b/runtime/shibuya/src/chain_extensions.rs index 516bc8040b..a368fa8d18 100644 --- a/runtime/shibuya/src/chain_extensions.rs +++ b/runtime/shibuya/src/chain_extensions.rs @@ -22,16 +22,11 @@ use super::{Runtime, UnifiedAccounts, Xvm}; pub use pallet_chain_extension_assets::AssetsExtension; use pallet_contracts::chain_extension::RegisteredChainExtension; -pub use pallet_chain_extension_dapps_staking::DappsStakingExtension; pub use pallet_chain_extension_unified_accounts::UnifiedAccountsExtension; pub use pallet_chain_extension_xvm::XvmExtension; // Following impls defines chain extension IDs. -impl RegisteredChainExtension for DappsStakingExtension { - const ID: u16 = 00; -} - impl RegisteredChainExtension for XvmExtension { const ID: u16 = 01; } diff --git a/runtime/shibuya/src/lib.rs b/runtime/shibuya/src/lib.rs index 110d0f8d12..228718d421 100644 --- a/runtime/shibuya/src/lib.rs +++ b/runtime/shibuya/src/lib.rs @@ -22,7 +22,6 @@ // `construct_runtime!` does a lot of recursion and requires us to increase the limit to 256. #![recursion_limit = "256"] -use astar_primitives::evm::HashedDefaultMappings; use cumulus_pallet_parachain_system::AnyRelayNumber; use frame_support::{ construct_runtime, @@ -54,6 +53,7 @@ use pallet_transaction_payment::{ use parity_scale_codec::{Compact, Decode, Encode, MaxEncodedLen}; use polkadot_runtime_common::BlockHashCount; use sp_api::impl_runtime_apis; +use sp_arithmetic::fixed_point::FixedU64; use sp_core::{ConstBool, OpaqueMetadata, H160, H256, U256}; use sp_inherents::{CheckInherentsResult, InherentData}; use sp_runtime::{ @@ -67,12 +67,16 @@ use sp_runtime::{ }; use sp_std::prelude::*; -pub use astar_primitives::{ - ethereum_checked::CheckedEthereumTransact, evm::EvmRevertCodeHandler, - xcm::AssetLocationIdConverter, AccountId, Address, AssetId, Balance, BlockNumber, Hash, Header, - Index, Signature, +use astar_primitives::{ + dapp_staking::{CycleConfiguration, SmartContract}, + evm::{EvmRevertCodeHandler, HashedDefaultMappings}, + xcm::AssetLocationIdConverter, + Address, AssetId, BlockNumber, Hash, Header, Index, }; -pub use pallet_block_rewards_hybrid::RewardDistributionConfig; + +pub use astar_primitives::{AccountId, Balance, Signature}; +pub use pallet_dapp_staking_v3::TierThreshold; +pub use pallet_inflation::InflationParameters; pub use crate::precompiles::WhitelistedCalls; @@ -297,7 +301,7 @@ parameter_types! { impl pallet_timestamp::Config for Runtime { /// A timestamp: milliseconds since the unix epoch. type Moment = u64; - type OnTimestampSet = BlockReward; + type OnTimestampSet = (); type MinimumPeriod = MinimumPeriod; type WeightInfo = pallet_timestamp::weights::SubstrateWeight; } @@ -402,31 +406,109 @@ impl pallet_dapps_staking::Config for Runtime { type MinimumRemainingAmount = MinimumRemainingAmount; type MaxEraStakeValues = MaxEraStakeValues; type UnregisteredDappRewardRetention = ConstU32<10>; + // Needed so benchmark can use the pallets extrinsics + #[cfg(feature = "runtime-benchmarks")] + type ForcePalletDisabled = ConstBool; + #[cfg(not(feature = "runtime-benchmarks"))] + type ForcePalletDisabled = ConstBool; } -/// Multi-VM pointer to smart contract instance. -#[derive( - PartialEq, Eq, Copy, Clone, Encode, Decode, RuntimeDebug, MaxEncodedLen, scale_info::TypeInfo, -)] -pub enum SmartContract { - /// EVM smart contract instance. - Evm(sp_core::H160), - /// Wasm smart contract instance. - Wasm(AccountId), +// Placeholder until we introduce a pallet for this. +// Real solution will be an oracle. +pub struct DummyPriceProvider; +impl pallet_dapp_staking_v3::PriceProvider for DummyPriceProvider { + fn average_price() -> FixedU64 { + FixedU64::from_rational(1, 10) + } } -impl Default for SmartContract { - fn default() -> Self { - SmartContract::Evm(H160::repeat_byte(0x00)) +#[cfg(feature = "runtime-benchmarks")] +pub struct BenchmarkHelper(sp_std::marker::PhantomData<(SC, ACC)>); +#[cfg(feature = "runtime-benchmarks")] +impl pallet_dapp_staking_v3::BenchmarkHelper, AccountId> + for BenchmarkHelper, AccountId> +{ + fn get_smart_contract(id: u32) -> SmartContract { + let id_bytes = id.to_le_bytes(); + let mut account = [0u8; 32]; + account[..id_bytes.len()].copy_from_slice(&id_bytes); + + SmartContract::Wasm(AccountId::from(account)) + } + + fn set_balance(account: &AccountId, amount: Balance) { + use frame_support::traits::fungible::Unbalanced as FunUnbalanced; + Balances::write_balance(account, amount) + .expect("Must succeed in test/benchmark environment."); + } +} + +impl pallet_dapp_staking_v3::Config for Runtime { + type RuntimeEvent = RuntimeEvent; + type RuntimeFreezeReason = RuntimeFreezeReason; + type Currency = Balances; + type SmartContract = SmartContract; + type ManagerOrigin = frame_system::EnsureRoot; + type NativePriceProvider = DummyPriceProvider; + type StakingRewardHandler = Inflation; + type CycleConfiguration = InflationCycleConfig; + type EraRewardSpanLength = ConstU32<16>; + type RewardRetentionInPeriods = ConstU32<2>; // Low enough value so we can get some expired rewards during testing + type MaxNumberOfContracts = ConstU32<500>; + type MaxUnlockingChunks = ConstU32<8>; + type MinimumLockedAmount = MinimumStakingAmount; // Keep the same as the old pallet + type UnlockingPeriod = ConstU32<4>; // Keep it low so it's easier to test + type MaxNumberOfStakedContracts = ConstU32<8>; + type MinimumStakeAmount = MinimumStakingAmount; + type NumberOfTiers = ConstU32<4>; + type WeightInfo = weights::pallet_dapp_staking_v3::SubstrateWeight; + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = BenchmarkHelper, AccountId>; +} + +pub struct InflationPayoutPerBlock; +impl pallet_inflation::PayoutPerBlock for InflationPayoutPerBlock { + fn treasury(reward: NegativeImbalance) { + Balances::resolve_creating(&TreasuryPalletId::get().into_account_truncating(), reward); + } + + fn collators(reward: NegativeImbalance) { + ToStakingPot::on_unbalanced(reward); } } -impl> From<[u8; 32]> for SmartContract { - fn from(input: [u8; 32]) -> Self { - SmartContract::Wasm(input.into()) +pub struct InflationCycleConfig; +impl CycleConfiguration for InflationCycleConfig { + fn periods_per_cycle() -> u32 { + 2 + } + + fn eras_per_voting_subperiod() -> u32 { + 8 + } + + fn eras_per_build_and_earn_subperiod() -> u32 { + 20 + } + + fn blocks_per_era() -> BlockNumber { + 6 * HOURS } } +impl pallet_inflation::Config for Runtime { + type Currency = Balances; + type PayoutPerBlock = InflationPayoutPerBlock; + type CycleConfiguration = InflationCycleConfig; + type RuntimeEvent = RuntimeEvent; + type WeightInfo = pallet_inflation::weights::SubstrateWeight; +} + +impl pallet_dapp_staking_migration::Config for Runtime { + type RuntimeEvent = RuntimeEvent; + type WeightInfo = pallet_dapp_staking_migration::weights::SubstrateWeight; +} + impl pallet_utility::Config for Runtime { type RuntimeEvent = RuntimeEvent; type RuntimeCall = RuntimeCall; @@ -530,41 +612,6 @@ impl OnUnbalanced for ToStakingPot { } } -pub struct DappsStakingTvlProvider(); -impl Get for DappsStakingTvlProvider { - fn get() -> Balance { - DappsStaking::tvl() - } -} - -pub struct BeneficiaryPayout(); -impl pallet_block_rewards_hybrid::BeneficiaryPayout for BeneficiaryPayout { - fn treasury(reward: NegativeImbalance) { - Balances::resolve_creating(&TreasuryPalletId::get().into_account_truncating(), reward); - } - - fn collators(reward: NegativeImbalance) { - ToStakingPot::on_unbalanced(reward); - } - - fn dapps_staking(stakers: NegativeImbalance, dapps: NegativeImbalance) { - DappsStaking::rewards(stakers, dapps) - } -} - -parameter_types! { - pub const MaxBlockRewardAmount: Balance = 230_718 * MILLISBY; -} - -impl pallet_block_rewards_hybrid::Config for Runtime { - type Currency = Balances; - type DappsStakingTvlProvider = DappsStakingTvlProvider; - type BeneficiaryPayout = BeneficiaryPayout; - type MaxBlockRewardAmount = MaxBlockRewardAmount; - type RuntimeEvent = RuntimeEvent; - type WeightInfo = pallet_block_rewards_hybrid::weights::SubstrateWeight; -} - parameter_types! { pub const ExistentialDeposit: Balance = 1_000_000; pub const MaxLocks: u32 = 50; @@ -582,9 +629,9 @@ impl pallet_balances::Config for Runtime { type AccountStore = frame_system::Pallet; type WeightInfo = weights::pallet_balances::SubstrateWeight; type HoldIdentifier = (); - type FreezeIdentifier = (); + type FreezeIdentifier = RuntimeFreezeReason; type MaxHolds = ConstU32<0>; - type MaxFreezes = ConstU32<0>; + type MaxFreezes = ConstU32<1>; } parameter_types! { @@ -666,7 +713,6 @@ impl pallet_contracts::Config for Runtime { type WeightPrice = pallet_transaction_payment::Pallet; type WeightInfo = pallet_contracts::weights::SubstrateWeight; type ChainExtension = ( - DappsStakingExtension, XvmExtension, AssetsExtension>, UnifiedAccountsExtension, @@ -1080,7 +1126,7 @@ pub enum ProxyType { /// Only reject_announcement call from pallet proxy allowed for proxy account CancelProxy, /// All runtime calls from pallet DappStaking allowed for proxy account - DappsStaking, + DappStaking, /// Only claim_staker call from pallet DappStaking allowed for proxy account StakerRewardClaim, } @@ -1115,7 +1161,7 @@ impl InstanceFilter for ProxyType { | RuntimeCall::Vesting(pallet_vesting::Call::vest{..}) | RuntimeCall::Vesting(pallet_vesting::Call::vest_other{..}) // Specifically omitting Vesting `vested_transfer`, and `force_vested_transfer` - | RuntimeCall::DappsStaking(..) + | RuntimeCall::DappStaking(..) // Skip entire Assets pallet | RuntimeCall::CollatorSelection(..) | RuntimeCall::Session(..) @@ -1166,13 +1212,15 @@ impl InstanceFilter for ProxyType { ) } // All runtime calls from pallet DappStaking allowed for proxy account - ProxyType::DappsStaking => { - matches!(c, RuntimeCall::DappsStaking(..)) + ProxyType::DappStaking => { + matches!(c, RuntimeCall::DappStaking(..)) } ProxyType::StakerRewardClaim => { matches!( c, - RuntimeCall::DappsStaking(pallet_dapps_staking::Call::claim_staker { .. }) + RuntimeCall::DappStaking( + pallet_dapp_staking_v3::Call::claim_staker_rewards { .. } + ) ) } } @@ -1184,7 +1232,7 @@ impl InstanceFilter for ProxyType { (ProxyType::Any, _) => true, (_, ProxyType::Any) => false, (ProxyType::NonTransfer, _) => true, - (ProxyType::DappsStaking, ProxyType::StakerRewardClaim) => true, + (ProxyType::DappStaking, ProxyType::StakerRewardClaim) => true, _ => false, } } @@ -1252,8 +1300,8 @@ construct_runtime!( TransactionPayment: pallet_transaction_payment = 30, Balances: pallet_balances = 31, Vesting: pallet_vesting = 32, - DappsStaking: pallet_dapps_staking = 34, - BlockReward: pallet_block_rewards_hybrid = 35, + DappStaking: pallet_dapp_staking_v3 = 34, + Inflation: pallet_inflation = 35, Assets: pallet_assets = 36, Authorship: pallet_authorship = 40, @@ -1287,6 +1335,11 @@ construct_runtime!( Xvm: pallet_xvm = 90, Sudo: pallet_sudo = 99, + + // To be removed & cleaned up after migration has been finished + DappStakingMigration: pallet_dapp_staking_migration = 254, + // Legacy dApps staking v2, to be removed after migration has been finished + DappsStaking: pallet_dapps_staking = 255, } ); @@ -1324,10 +1377,118 @@ pub type Executive = frame_executive::Executive< Migrations, >; +parameter_types! { + pub const BlockRewardName: &'static str = "BlockReward"; +} /// All migrations that will run on the next runtime upgrade. /// /// Once done, migrations should be removed from the tuple. -pub type Migrations = (); +pub type Migrations = ( + pallet_inflation::PalletInflationInitConfig, + pallet_dapp_staking_v3::DAppStakingV3InitConfig, + frame_support::migrations::RemovePallet< + BlockRewardName, + ::DbWeight, + >, + // This will handle new pallet storage version setting & it will put the new pallet into maintenance mode. + // But it's most important for testing with try-runtime. + pallet_dapp_staking_migration::DappStakingMigrationHandler, +); + +/// Used to initialize inflation parameters for the runtime. +pub struct InitInflationParams; +impl Get for InitInflationParams { + fn get() -> pallet_inflation::InflationParameters { + pallet_inflation::InflationParameters { + // Recalculation is done every two weeks, hence the small %. + max_inflation_rate: Perquintill::from_percent(1), + treasury_part: Perquintill::from_percent(5), + collators_part: Perquintill::from_percent(3), + dapps_part: Perquintill::from_percent(20), + base_stakers_part: Perquintill::from_percent(25), + adjustable_stakers_part: Perquintill::from_percent(35), + bonus_part: Perquintill::from_percent(12), + ideal_staking_rate: Perquintill::from_percent(20), + } + } +} + +use frame_support::BoundedVec; +use pallet_dapp_staking_v3::{EraNumber, TierParameters, TiersConfiguration}; +type NumberOfTiers = ::NumberOfTiers; +/// Used to initialize dApp staking parameters for the runtime. +pub struct InitDappStakingv3Params; +impl + Get<( + EraNumber, + TierParameters, + TiersConfiguration, + )> for InitDappStakingv3Params +{ + fn get() -> ( + EraNumber, + TierParameters, + TiersConfiguration, + ) { + // 1. Prepare init values + + // Init era of dApp staking v3 should be the next era after dApp staking v2 + let init_era = pallet_dapps_staking::CurrentEra::::get().saturating_add(1); + + // Reward portions according to the Tokenomics 2.0 report + let reward_portion = BoundedVec::try_from(vec![ + Permill::from_percent(40), + Permill::from_percent(30), + Permill::from_percent(20), + Permill::from_percent(10), + ]) + .unwrap_or_default(); + + // Tier thresholds adjusted according to numbers observed on Shibuya + let tier_thresholds = BoundedVec::try_from(vec![ + TierThreshold::DynamicTvlAmount { + amount: SBY.saturating_mul(1_000_000), + minimum_amount: SBY.saturating_mul(150_000), + }, + TierThreshold::DynamicTvlAmount { + amount: SBY.saturating_mul(100_000), + minimum_amount: SBY.saturating_mul(60_000), + }, + TierThreshold::DynamicTvlAmount { + amount: SBY.saturating_mul(50_000), + minimum_amount: SBY.saturating_mul(15_000), + }, + TierThreshold::FixedTvlAmount { + amount: SBY.saturating_mul(10_000), + }, + ]) + .unwrap_or_default(); + + // 2. Tier params + let tier_params = + TierParameters::<::NumberOfTiers> { + reward_portion: reward_portion.clone(), + slot_distribution: BoundedVec::try_from(vec![ + Permill::from_percent(10), + Permill::from_percent(20), + Permill::from_percent(30), + Permill::from_percent(40), + ]) + .unwrap_or_default(), + tier_thresholds: tier_thresholds.clone(), + }; + + // 3. Init tier config + let init_tier_config = TiersConfiguration { + number_of_slots: 100, + slots_per_tier: BoundedVec::try_from(vec![10, 20, 30, 40]).unwrap_or_default(), + reward_portion, + tier_thresholds, + }; + + (init_era, tier_params, init_tier_config) + } +} type EventRecord = frame_system::EventRecord< ::RuntimeEvent, @@ -1405,7 +1566,9 @@ mod benches { [pallet_balances, Balances] [pallet_timestamp, Timestamp] [pallet_dapps_staking, DappsStaking] - [block_rewards_hybrid, BlockReward] + [pallet_dapp_staking_v3, DappStaking] + [pallet_inflation, Inflation] + [pallet_dapp_staking_migration, DappStakingMigration] [pallet_xc_asset_config, XcAssetConfig] [pallet_collator_selection, CollatorSelection] [pallet_xcm, PolkadotXcm] @@ -1870,6 +2033,20 @@ impl_runtime_apis! { } } + impl dapp_staking_v3_runtime_api::DappStakingApi for Runtime { + fn eras_per_voting_subperiod() -> pallet_dapp_staking_v3::EraNumber { + InflationCycleConfig::eras_per_voting_subperiod() + } + + fn eras_per_build_and_earn_subperiod() -> pallet_dapp_staking_v3::EraNumber { + InflationCycleConfig::eras_per_build_and_earn_subperiod() + } + + fn blocks_per_era() -> BlockNumber { + InflationCycleConfig::blocks_per_era() + } + } + #[cfg(feature = "runtime-benchmarks")] impl frame_benchmarking::Benchmark for Runtime { fn benchmark_metadata(extra: bool) -> ( diff --git a/runtime/shibuya/src/precompiles.rs b/runtime/shibuya/src/precompiles.rs index 93f3e4ec16..c9f34a9a9d 100644 --- a/runtime/shibuya/src/precompiles.rs +++ b/runtime/shibuya/src/precompiles.rs @@ -24,7 +24,7 @@ use frame_support::{parameter_types, traits::Contains}; use pallet_evm_precompile_assets_erc20::Erc20AssetsPrecompileSet; use pallet_evm_precompile_blake2::Blake2F; use pallet_evm_precompile_bn128::{Bn128Add, Bn128Mul, Bn128Pairing}; -use pallet_evm_precompile_dapps_staking::DappsStakingWrapper; +use pallet_evm_precompile_dapp_staking_v3::DappStakingV3Precompile; use pallet_evm_precompile_dispatch::Dispatch; use pallet_evm_precompile_ed25519::Ed25519Verify; use pallet_evm_precompile_modexp::Modexp; @@ -59,7 +59,7 @@ impl Contains for WhitelistedCalls { | RuntimeCall::Utility(pallet_utility::Call::batch_all { calls }) => { calls.iter().all(|call| WhitelistedCalls::contains(call)) } - RuntimeCall::DappsStaking(_) => true, + RuntimeCall::DappStaking(_) => true, RuntimeCall::Assets(pallet_assets::Call::transfer { .. }) => true, _ => false, } @@ -93,7 +93,7 @@ pub type ShibuyaPrecompilesSetAt = ( // Astar specific precompiles: PrecompileAt< AddressU64<20481>, - DappsStakingWrapper, + DappStakingV3Precompile, (CallableByContract, CallableByPrecompile), >, PrecompileAt< diff --git a/runtime/shibuya/src/weights/mod.rs b/runtime/shibuya/src/weights/mod.rs index f1df12d585..b49f1c1a29 100644 --- a/runtime/shibuya/src/weights/mod.rs +++ b/runtime/shibuya/src/weights/mod.rs @@ -18,4 +18,7 @@ pub mod pallet_assets; pub mod pallet_balances; +pub mod pallet_dapp_staking_migration; +pub mod pallet_dapp_staking_v3; +pub mod pallet_inflation; pub mod pallet_xcm; diff --git a/runtime/shibuya/src/weights/pallet_dapp_staking_migration.rs b/runtime/shibuya/src/weights/pallet_dapp_staking_migration.rs new file mode 100644 index 0000000000..cb43de8369 --- /dev/null +++ b/runtime/shibuya/src/weights/pallet_dapp_staking_migration.rs @@ -0,0 +1,133 @@ + +// 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 . + +//! Autogenerated weights for pallet_dapp_staking_migration +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 4.0.0-dev +//! DATE: 2023-12-20, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! WORST CASE MAP SIZE: `1000000` +//! HOSTNAME: `devserver-01`, CPU: `Intel(R) Xeon(R) E-2236 CPU @ 3.40GHz` +//! EXECUTION: Some(Wasm), WASM-EXECUTION: Compiled, CHAIN: Some("shibuya-dev"), DB CACHE: 1024 + +// Executed Command: +// ./target/release/astar-collator +// benchmark +// pallet +// --chain=shibuya-dev +// --steps=50 +// --repeat=20 +// --pallet=pallet_dapp_staking_migration +// --extrinsic=* +// --execution=wasm +// --wasm-execution=compiled +// --heap-pages=4096 +// --output=./benchmark-results/shibuya-dev/dapp_staking_migration_weights.rs +// --template=./scripts/templates/weight-template.hbs + + +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(unused_parens)] +#![allow(unused_imports)] + +use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; +use core::marker::PhantomData; +use pallet_dapp_staking_migration::WeightInfo; + +/// Weights for pallet_dapp_staking_migration using the Substrate node and recommended hardware. +pub struct SubstrateWeight(PhantomData); +impl WeightInfo for SubstrateWeight { + /// Storage: DappsStaking RegisteredDapps (r:2 w:1) + /// Proof: DappsStaking RegisteredDapps (max_values: None, max_size: Some(86), added: 2561, mode: MaxEncodedLen) + /// Storage: System Account (r:1 w:1) + /// Proof: System Account (max_values: None, max_size: Some(128), added: 2603, mode: MaxEncodedLen) + /// Storage: DappStaking IntegratedDApps (r:1 w:1) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, mode: MaxEncodedLen) + /// Storage: DappStaking CounterForIntegratedDApps (r:1 w:1) + /// Proof: DappStaking CounterForIntegratedDApps (max_values: Some(1), max_size: Some(4), added: 499, mode: MaxEncodedLen) + /// Storage: DappStaking NextDAppId (r:1 w:1) + /// Proof: DappStaking NextDAppId (max_values: Some(1), max_size: Some(2), added: 497, mode: MaxEncodedLen) + fn migrate_dapps_success() -> Weight { + // Proof Size summary in bytes: + // Measured: `558` + // Estimated: `6112` + // Minimum execution time: 46_218_000 picoseconds. + Weight::from_parts(47_610_000, 6112) + .saturating_add(T::DbWeight::get().reads(6_u64)) + .saturating_add(T::DbWeight::get().writes(5_u64)) + } + /// Storage: DappsStaking RegisteredDapps (r:1 w:0) + /// Proof: DappsStaking RegisteredDapps (max_values: None, max_size: Some(86), added: 2561, mode: MaxEncodedLen) + fn migrate_dapps_noop() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `3551` + // Minimum execution time: 3_385_000 picoseconds. + Weight::from_parts(3_552_000, 3551) + .saturating_add(T::DbWeight::get().reads(1_u64)) + } + /// Storage: DappsStaking Ledger (r:2 w:1) + /// Proof: DappsStaking Ledger (max_values: None, max_size: Some(266), added: 2741, mode: MaxEncodedLen) + /// Storage: Balances Locks (r:1 w:1) + /// Proof: Balances Locks (max_values: None, max_size: Some(1299), added: 3774, mode: MaxEncodedLen) + /// Storage: Balances Freezes (r:1 w:1) + /// Proof: Balances Freezes (max_values: None, max_size: Some(67), added: 2542, mode: MaxEncodedLen) + /// Storage: System Account (r:1 w:1) + /// Proof: System Account (max_values: None, max_size: Some(128), added: 2603, 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 CurrentEraInfo (r:1 w:1) + /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, mode: MaxEncodedLen) + fn migrate_ledger_success() -> Weight { + // Proof Size summary in bytes: + // Measured: `1748` + // Estimated: `6472` + // Minimum execution time: 69_553_000 picoseconds. + Weight::from_parts(70_319_000, 6472) + .saturating_add(T::DbWeight::get().reads(7_u64)) + .saturating_add(T::DbWeight::get().writes(6_u64)) + } + /// Storage: DappsStaking Ledger (r:1 w:0) + /// Proof: DappsStaking Ledger (max_values: None, max_size: Some(266), added: 2741, mode: MaxEncodedLen) + fn migrate_ledger_noop() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `3731` + // Minimum execution time: 2_918_000 picoseconds. + Weight::from_parts(3_022_000, 3731) + .saturating_add(T::DbWeight::get().reads(1_u64)) + } + /// Storage: DappsStaking Ledger (r:2 w:1) + /// Proof: DappsStaking Ledger (max_values: None, max_size: Some(266), added: 2741, mode: MaxEncodedLen) + fn cleanup_old_storage_success() -> Weight { + // Proof Size summary in bytes: + // Measured: `739` + // Estimated: `6472` + // Minimum execution time: 7_109_000 picoseconds. + Weight::from_parts(7_383_000, 6472) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + fn cleanup_old_storage_noop() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 2_095_000 picoseconds. + Weight::from_parts(2_213_000, 0) + } +} \ No newline at end of file diff --git a/runtime/shibuya/src/weights/pallet_dapp_staking_v3.rs b/runtime/shibuya/src/weights/pallet_dapp_staking_v3.rs new file mode 100644 index 0000000000..0bcfcee538 --- /dev/null +++ b/runtime/shibuya/src/weights/pallet_dapp_staking_v3.rs @@ -0,0 +1,432 @@ + +// 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 . + +//! Autogenerated weights for pallet_dapp_staking_v3 +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 4.0.0-dev +//! DATE: 2023-12-20, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! WORST CASE MAP SIZE: `1000000` +//! HOSTNAME: `devserver-01`, CPU: `Intel(R) Xeon(R) E-2236 CPU @ 3.40GHz` +//! EXECUTION: Some(Wasm), WASM-EXECUTION: Compiled, CHAIN: Some("shibuya-dev"), DB CACHE: 1024 + +// Executed Command: +// ./target/release/astar-collator +// benchmark +// pallet +// --chain=shibuya-dev +// --steps=50 +// --repeat=20 +// --pallet=pallet_dapp_staking_v3 +// --extrinsic=* +// --execution=wasm +// --wasm-execution=compiled +// --heap-pages=4096 +// --output=./benchmark-results/shibuya-dev/dapp_staking_v3_weights.rs +// --template=./scripts/templates/weight-template.hbs + +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(unused_parens)] +#![allow(unused_imports)] + +use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; +use core::marker::PhantomData; +use pallet_dapp_staking_v3::WeightInfo; + +/// Weights for pallet_dapp_staking_v3 using the Substrate node and recommended hardware. +pub struct SubstrateWeight(PhantomData); +impl WeightInfo for SubstrateWeight { + fn maintenance_mode() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 8_474_000 picoseconds. + Weight::from_parts(8_711_000, 0) + } + /// Storage: DappStaking IntegratedDApps (r:1 w:1) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, mode: MaxEncodedLen) + /// Storage: DappStaking CounterForIntegratedDApps (r:1 w:1) + /// Proof: DappStaking CounterForIntegratedDApps (max_values: Some(1), max_size: Some(4), added: 499, mode: MaxEncodedLen) + /// Storage: DappStaking NextDAppId (r:1 w:1) + /// Proof: DappStaking NextDAppId (max_values: Some(1), max_size: Some(2), added: 497, mode: MaxEncodedLen) + fn register() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `3091` + // Minimum execution time: 16_360_000 picoseconds. + Weight::from_parts(16_697_000, 3091) + .saturating_add(T::DbWeight::get().reads(3_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) + } + /// Storage: DappStaking IntegratedDApps (r:1 w:1) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, mode: MaxEncodedLen) + fn set_dapp_reward_beneficiary() -> Weight { + // Proof Size summary in bytes: + // Measured: `75` + // Estimated: `3091` + // Minimum execution time: 12_927_000 picoseconds. + Weight::from_parts(13_229_000, 3091) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: DappStaking IntegratedDApps (r:1 w:1) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, mode: MaxEncodedLen) + fn set_dapp_owner() -> Weight { + // Proof Size summary in bytes: + // Measured: `75` + // Estimated: `3091` + // Minimum execution time: 13_610_000 picoseconds. + Weight::from_parts(13_851_000, 3091) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: DappStaking IntegratedDApps (r:1 w:1) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, mode: MaxEncodedLen) + /// Storage: DappStaking ContractStake (r:0 w:1) + /// Proof: DappStaking ContractStake (max_values: Some(65535), max_size: Some(91), added: 2071, mode: MaxEncodedLen) + fn unregister() -> Weight { + // Proof Size summary in bytes: + // Measured: `75` + // Estimated: `3091` + // Minimum execution time: 16_704_000 picoseconds. + Weight::from_parts(16_952_000, 3091) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) + } + /// Storage: DappStaking Ledger (r:1 w:1) + /// Proof: DappStaking Ledger (max_values: None, max_size: Some(310), added: 2785, 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) + /// Storage: DappStaking CurrentEraInfo (r:1 w:1) + /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, mode: MaxEncodedLen) + fn lock() -> Weight { + // Proof Size summary in bytes: + // Measured: `12` + // Estimated: `4764` + // Minimum execution time: 31_680_000 picoseconds. + Weight::from_parts(32_075_000, 4764) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) + } + /// Storage: DappStaking Ledger (r:1 w:1) + /// Proof: DappStaking Ledger (max_values: None, max_size: Some(310), added: 2785, 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) + /// Storage: DappStaking CurrentEraInfo (r:1 w:1) + /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, mode: MaxEncodedLen) + fn unlock() -> Weight { + // Proof Size summary in bytes: + // Measured: `156` + // Estimated: `4764` + // Minimum execution time: 34_576_000 picoseconds. + Weight::from_parts(34_777_000, 4764) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) + } + /// Storage: DappStaking Ledger (r:1 w:1) + /// Proof: DappStaking Ledger (max_values: None, max_size: Some(310), added: 2785, 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) + /// Storage: DappStaking CurrentEraInfo (r:1 w:1) + /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, mode: MaxEncodedLen) + /// The range of component `x` is `[0, 8]`. + fn claim_unlocked(x: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `187` + // Estimated: `4764` + // Minimum execution time: 33_562_000 picoseconds. + Weight::from_parts(34_600_552, 4764) + // Standard Error: 5_079 + .saturating_add(Weight::from_parts(193_345, 0).saturating_mul(x.into())) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) + } + /// Storage: DappStaking Ledger (r:1 w:1) + /// Proof: DappStaking Ledger (max_values: None, max_size: Some(310), added: 2785, 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) + /// Storage: DappStaking CurrentEraInfo (r:1 w:1) + /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, mode: MaxEncodedLen) + fn relock_unlocking() -> Weight { + // Proof Size summary in bytes: + // Measured: `182` + // Estimated: `4764` + // Minimum execution time: 36_436_000 picoseconds. + Weight::from_parts(37_262_000, 4764) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) + } + /// Storage: DappStaking IntegratedDApps (r:1 w:0) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, 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:1 w:1) + /// Proof: DappStaking StakerInfo (max_values: None, max_size: Some(138), added: 2613, mode: MaxEncodedLen) + /// Storage: DappStaking ContractStake (r:1 w:1) + /// Proof: DappStaking ContractStake (max_values: Some(65535), max_size: Some(91), added: 2071, mode: MaxEncodedLen) + /// Storage: DappStaking CurrentEraInfo (r:1 w:1) + /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, 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 stake() -> Weight { + // Proof Size summary in bytes: + // Measured: `250` + // Estimated: `4764` + // Minimum execution time: 43_866_000 picoseconds. + Weight::from_parts(44_468_000, 4764) + .saturating_add(T::DbWeight::get().reads(7_u64)) + .saturating_add(T::DbWeight::get().writes(5_u64)) + } + /// Storage: DappStaking IntegratedDApps (r:1 w:0) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, 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:1 w:1) + /// Proof: DappStaking StakerInfo (max_values: None, max_size: Some(138), added: 2613, mode: MaxEncodedLen) + /// Storage: DappStaking ContractStake (r:1 w:1) + /// Proof: DappStaking ContractStake (max_values: Some(65535), max_size: Some(91), added: 2071, mode: MaxEncodedLen) + /// Storage: DappStaking CurrentEraInfo (r:1 w:1) + /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, 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 unstake() -> Weight { + // Proof Size summary in bytes: + // Measured: `427` + // Estimated: `4764` + // Minimum execution time: 47_368_000 picoseconds. + Weight::from_parts(48_049_000, 4764) + .saturating_add(T::DbWeight::get().reads(7_u64)) + .saturating_add(T::DbWeight::get().writes(5_u64)) + } + /// Storage: DappStaking Ledger (r:1 w:1) + /// Proof: DappStaking Ledger (max_values: None, max_size: Some(310), added: 2785, mode: MaxEncodedLen) + /// Storage: DappStaking EraRewards (r:1 w:0) + /// Proof: DappStaking EraRewards (max_values: None, max_size: Some(789), added: 3264, mode: MaxEncodedLen) + /// Storage: DappStaking PeriodEnd (r:1 w:0) + /// Proof: DappStaking PeriodEnd (max_values: None, max_size: Some(48), added: 2523, 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) + /// The range of component `x` is `[1, 16]`. + fn claim_staker_rewards_past_period(x: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `560` + // Estimated: `4764` + // Minimum execution time: 51_230_000 picoseconds. + Weight::from_parts(48_696_805, 4764) + // Standard Error: 6_139 + .saturating_add(Weight::from_parts(3_374_191, 0).saturating_mul(x.into())) + .saturating_add(T::DbWeight::get().reads(5_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) + } + /// Storage: DappStaking Ledger (r:1 w:1) + /// Proof: DappStaking Ledger (max_values: None, max_size: Some(310), added: 2785, mode: MaxEncodedLen) + /// Storage: DappStaking EraRewards (r:1 w:0) + /// Proof: DappStaking EraRewards (max_values: None, max_size: Some(789), added: 3264, 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) + /// The range of component `x` is `[1, 16]`. + fn claim_staker_rewards_ongoing_period(x: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `501` + // Estimated: `4764` + // Minimum execution time: 45_030_000 picoseconds. + Weight::from_parts(43_179_071, 4764) + // Standard Error: 5_547 + .saturating_add(Weight::from_parts(3_296_864, 0).saturating_mul(x.into())) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) + } + /// Storage: DappStaking StakerInfo (r:1 w:1) + /// Proof: DappStaking StakerInfo (max_values: None, max_size: Some(138), added: 2613, mode: MaxEncodedLen) + /// Storage: DappStaking PeriodEnd (r:1 w:0) + /// Proof: DappStaking PeriodEnd (max_values: None, max_size: Some(48), added: 2523, mode: MaxEncodedLen) + /// Storage: DappStaking Ledger (r:1 w:1) + /// Proof: DappStaking Ledger (max_values: None, max_size: Some(310), added: 2785, mode: MaxEncodedLen) + fn claim_bonus_reward() -> Weight { + // Proof Size summary in bytes: + // Measured: `267` + // Estimated: `3775` + // Minimum execution time: 42_248_000 picoseconds. + Weight::from_parts(42_687_000, 3775) + .saturating_add(T::DbWeight::get().reads(3_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) + } + /// Storage: DappStaking IntegratedDApps (r:1 w:0) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, mode: MaxEncodedLen) + /// Storage: DappStaking DAppTiers (r:1 w:1) + /// Proof: DappStaking DAppTiers (max_values: None, max_size: Some(2083), added: 4558, mode: MaxEncodedLen) + fn claim_dapp_reward() -> Weight { + // Proof Size summary in bytes: + // Measured: `3021` + // Estimated: `5548` + // Minimum execution time: 50_968_000 picoseconds. + Weight::from_parts(51_778_000, 5548) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: DappStaking IntegratedDApps (r:1 w:0) + /// Proof: DappStaking IntegratedDApps (max_values: Some(65535), max_size: Some(121), added: 2101, mode: MaxEncodedLen) + /// Storage: DappStaking StakerInfo (r:1 w:1) + /// Proof: DappStaking StakerInfo (max_values: None, max_size: Some(138), added: 2613, 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 CurrentEraInfo (r:1 w:1) + /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, 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 unstake_from_unregistered() -> Weight { + // Proof Size summary in bytes: + // Measured: `389` + // Estimated: `4764` + // Minimum execution time: 42_329_000 picoseconds. + Weight::from_parts(42_737_000, 4764) + .saturating_add(T::DbWeight::get().reads(6_u64)) + .saturating_add(T::DbWeight::get().writes(4_u64)) + } + /// Storage: DappStaking StakerInfo (r:9 w:8) + /// Proof: DappStaking StakerInfo (max_values: None, max_size: Some(138), added: 2613, mode: MaxEncodedLen) + /// Storage: DappStaking Ledger (r:1 w:1) + /// Proof: DappStaking Ledger (max_values: None, max_size: Some(310), added: 2785, 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) + /// The range of component `x` is `[1, 8]`. + fn cleanup_expired_entries(x: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `255 + x * (69 ±0)` + // Estimated: `4764 + x * (2613 ±0)` + // Minimum execution time: 42_222_000 picoseconds. + Weight::from_parts(38_945_386, 4764) + // Standard Error: 14_325 + .saturating_add(Weight::from_parts(5_044_310, 0).saturating_mul(x.into())) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(x.into()))) + .saturating_add(T::DbWeight::get().writes(2_u64)) + .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(x.into()))) + .saturating_add(Weight::from_parts(0, 2613).saturating_mul(x.into())) + } + fn force() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 9_971_000 picoseconds. + Weight::from_parts(10_190_000, 0) + } + /// 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) + /// Proof: DappStaking EraRewards (max_values: None, max_size: Some(789), added: 3264, mode: MaxEncodedLen) + fn on_initialize_voting_to_build_and_earn() -> Weight { + // Proof Size summary in bytes: + // Measured: `16` + // Estimated: `4254` + // Minimum execution time: 17_308_000 picoseconds. + Weight::from_parts(17_774_000, 4254) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(2_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 StaticTierParams (r:1 w:0) + /// Proof: DappStaking StaticTierParams (max_values: Some(1), max_size: Some(167), added: 662, mode: MaxEncodedLen) + /// Storage: DappStaking TierConfig (r:1 w:1) + /// Proof: DappStaking TierConfig (max_values: Some(1), max_size: Some(161), added: 656, mode: MaxEncodedLen) + /// Storage: DappStaking EraRewards (r:1 w:1) + /// Proof: DappStaking EraRewards (max_values: None, max_size: Some(789), added: 3264, mode: MaxEncodedLen) + /// Storage: DappStaking PeriodEnd (r:0 w:1) + /// Proof: DappStaking PeriodEnd (max_values: None, max_size: Some(48), added: 2523, mode: MaxEncodedLen) + /// Storage: DappStaking DAppTiers (r:0 w:1) + /// Proof: DappStaking DAppTiers (max_values: None, max_size: Some(2083), added: 4558, mode: MaxEncodedLen) + fn on_initialize_build_and_earn_to_voting() -> Weight { + // Proof Size summary in bytes: + // Measured: `550` + // Estimated: `4254` + // Minimum execution time: 39_768_000 picoseconds. + Weight::from_parts(40_422_000, 4254) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(5_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) + /// Proof: DappStaking EraRewards (max_values: None, max_size: Some(789), added: 3264, mode: MaxEncodedLen) + /// Storage: DappStaking DAppTiers (r:0 w:1) + /// Proof: DappStaking DAppTiers (max_values: None, max_size: Some(2083), added: 4558, mode: MaxEncodedLen) + fn on_initialize_build_and_earn_to_build_and_earn() -> Weight { + // Proof Size summary in bytes: + // Measured: `68` + // Estimated: `4254` + // Minimum execution time: 20_976_000 picoseconds. + Weight::from_parts(21_507_000, 4254) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) + } + /// Storage: DappStaking ContractStake (r:101 w:0) + /// Proof: DappStaking ContractStake (max_values: Some(65535), max_size: Some(91), added: 2071, mode: MaxEncodedLen) + /// Storage: DappStaking TierConfig (r:1 w:0) + /// Proof: DappStaking TierConfig (max_values: Some(1), max_size: Some(161), added: 656, mode: MaxEncodedLen) + /// The range of component `x` is `[0, 100]`. + fn dapp_tier_assignment(x: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `152 + x * (32 ±0)` + // Estimated: `3061 + x * (2071 ±0)` + // Minimum execution time: 7_374_000 picoseconds. + Weight::from_parts(10_826_637, 3061) + // Standard Error: 3_374 + .saturating_add(Weight::from_parts(2_291_643, 0).saturating_mul(x.into())) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(x.into()))) + .saturating_add(Weight::from_parts(0, 2071).saturating_mul(x.into())) + } + /// Storage: DappStaking HistoryCleanupMarker (r:1 w:1) + /// Proof: DappStaking HistoryCleanupMarker (max_values: Some(1), max_size: Some(8), added: 503, mode: MaxEncodedLen) + /// Storage: DappStaking PeriodEnd (r:1 w:1) + /// Proof: DappStaking PeriodEnd (max_values: None, max_size: Some(48), added: 2523, mode: MaxEncodedLen) + /// Storage: DappStaking EraRewards (r:1 w:1) + /// Proof: DappStaking EraRewards (max_values: None, max_size: Some(789), added: 3264, mode: MaxEncodedLen) + /// Storage: DappStaking DAppTiers (r:0 w:1) + /// Proof: DappStaking DAppTiers (max_values: None, max_size: Some(2083), added: 4558, mode: MaxEncodedLen) + fn on_idle_cleanup() -> Weight { + // Proof Size summary in bytes: + // Measured: `473` + // Estimated: `4254` + // Minimum execution time: 14_500_000 picoseconds. + Weight::from_parts(14_969_000, 4254) + .saturating_add(T::DbWeight::get().reads(3_u64)) + .saturating_add(T::DbWeight::get().writes(4_u64)) + } +} \ No newline at end of file diff --git a/runtime/shibuya/src/weights/pallet_inflation.rs b/runtime/shibuya/src/weights/pallet_inflation.rs new file mode 100644 index 0000000000..98616cb747 --- /dev/null +++ b/runtime/shibuya/src/weights/pallet_inflation.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 . + +//! Autogenerated weights for pallet_inflation +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 4.0.0-dev +//! DATE: 2023-12-20, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! WORST CASE MAP SIZE: `1000000` +//! HOSTNAME: `devserver-01`, CPU: `Intel(R) Xeon(R) E-2236 CPU @ 3.40GHz` +//! EXECUTION: Some(Wasm), WASM-EXECUTION: Compiled, CHAIN: Some("shibuya-dev"), DB CACHE: 1024 + +// Executed Command: +// ./target/release/astar-collator +// benchmark +// pallet +// --chain=shibuya-dev +// --steps=50 +// --repeat=20 +// --pallet=pallet_inflation +// --extrinsic=* +// --execution=wasm +// --wasm-execution=compiled +// --heap-pages=4096 +// --output=./benchmark-results/shibuya-dev/inflation_weights.rs +// --template=./scripts/templates/weight-template.hbs + +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(unused_parens)] +#![allow(unused_imports)] + +use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; +use core::marker::PhantomData; +use pallet_inflation::WeightInfo; + +/// Weights for pallet_inflation using the Substrate node and recommended hardware. +pub struct SubstrateWeight(PhantomData); +impl WeightInfo for SubstrateWeight { + /// Storage: Inflation InflationParams (r:0 w:1) + /// Proof: Inflation InflationParams (max_values: Some(1), max_size: Some(64), added: 559, mode: MaxEncodedLen) + fn force_set_inflation_params() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 9_036_000 picoseconds. + Weight::from_parts(9_186_000, 0) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + fn force_set_inflation_config() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `0` + // Minimum execution time: 9_073_000 picoseconds. + Weight::from_parts(9_411_000, 0) + } + /// Storage: Inflation InflationParams (r:1 w:0) + /// Proof: Inflation InflationParams (max_values: Some(1), max_size: Some(64), added: 559, mode: MaxEncodedLen) + fn force_inflation_recalculation() -> Weight { + // Proof Size summary in bytes: + // Measured: `58` + // Estimated: `1549` + // Minimum execution time: 14_839_000 picoseconds. + Weight::from_parts(15_198_000, 1549) + .saturating_add(T::DbWeight::get().reads(1_u64)) + } + /// Storage: System Account (r:2 w:1) + /// Proof: System Account (max_values: None, max_size: Some(128), added: 2603, mode: MaxEncodedLen) + /// Storage: Inflation InflationParams (r:1 w:0) + /// Proof: Inflation InflationParams (max_values: Some(1), max_size: Some(64), added: 559, mode: MaxEncodedLen) + fn hook_with_recalculation() -> Weight { + // Proof Size summary in bytes: + // Measured: `232` + // Estimated: `6196` + // Minimum execution time: 31_965_000 picoseconds. + Weight::from_parts(32_498_000, 6196) + .saturating_add(T::DbWeight::get().reads(3_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: System Account (r:2 w:1) + /// Proof: System Account (max_values: None, max_size: Some(128), added: 2603, mode: MaxEncodedLen) + fn hook_without_recalculation() -> Weight { + // Proof Size summary in bytes: + // Measured: `174` + // Estimated: `6196` + // Minimum execution time: 22_235_000 picoseconds. + Weight::from_parts(22_378_000, 6196) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } +} \ No newline at end of file diff --git a/runtime/shiden/src/lib.rs b/runtime/shiden/src/lib.rs index 3fc2d9f442..50fe7657a0 100644 --- a/runtime/shiden/src/lib.rs +++ b/runtime/shiden/src/lib.rs @@ -339,6 +339,7 @@ impl pallet_dapps_staking::Config for Runtime { type UnbondingPeriod = UnbondingPeriod; type MaxEraStakeValues = MaxEraStakeValues; type UnregisteredDappRewardRetention = ConstU32<7>; + type ForcePalletDisabled = ConstBool; } /// Multi-VM pointer to smart contract instance. diff --git a/tests/integration/Cargo.toml b/tests/integration/Cargo.toml index 2295c836a1..5bb0a19714 100644 --- a/tests/integration/Cargo.toml +++ b/tests/integration/Cargo.toml @@ -25,6 +25,7 @@ pallet-assets = { workspace = true } pallet-balances = { workspace = true } pallet-contracts = { workspace = true } pallet-contracts-primitives = { workspace = true } +pallet-dapp-staking-v3 = { workspace = true } pallet-dapps-staking = { workspace = true } pallet-proxy = { workspace = true } pallet-utility = { workspace = true } diff --git a/tests/integration/src/dispatch_precompile_filter_new.rs b/tests/integration/src/dispatch_precompile_filter_new.rs new file mode 100644 index 0000000000..b406b43d05 --- /dev/null +++ b/tests/integration/src/dispatch_precompile_filter_new.rs @@ -0,0 +1,207 @@ +// 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 . +#![cfg(test)] + +use crate::setup::*; +use astar_primitives::precompiles::DispatchFilterValidate; +use fp_evm::{ExitError, PrecompileFailure}; +use frame_support::{ + dispatch::{DispatchClass, DispatchInfo, GetDispatchInfo, Pays}, + traits::Contains, +}; +use pallet_evm_precompile_dispatch::DispatchValidateT; +use parity_scale_codec::Compact; + +/// Whitelisted Calls are defined in the runtime +#[test] +fn filter_accepts_batch_call_with_whitelisted_calls() { + ExtBuilder::default().build().execute_with(|| { + let inner_call = RuntimeCall::DappStaking(DappStakingCall::Call::claim_staker_rewards {}); + let call = RuntimeCall::Utility(UtilityCall::batch { + calls: vec![inner_call], + }); + assert!(WhitelistedCalls::contains(&call)); + }); +} + +#[test] +fn filter_rejects_non_whitelisted_batch_calls() { + ExtBuilder::default().build().execute_with(|| { + // CASE1 - only non whitelisted calls + let transfer_call = RuntimeCall::Balances(BalancesCall::transfer { + dest: MultiAddress::Id(CAT), + value: 100_000_000_000, + }); + let transfer = Box::new(transfer_call); + let call = Box::new(RuntimeCall::Utility(UtilityCall::batch { + calls: vec![*transfer.clone()], + })); + + // Utility call containing Balances Call + assert!(!WhitelistedCalls::contains(&call)); + + // CASE 2 - now whitelisted mixed with whitelisted calls + + let staking_call = RuntimeCall::DappStaking(DappStakingCall::Call::claim_staker_rewards {}); + let staking = Box::new(staking_call); + + let call = Box::new(RuntimeCall::Utility(UtilityCall::batch { + calls: vec![*transfer, *staking.clone()], + })); + + // Utility call containing Balances Call and Dappsstaking Call Fails filter + assert!(!WhitelistedCalls::contains(&call)); + }); +} + +#[test] +fn filter_accepts_whitelisted_calls() { + ExtBuilder::default().build().execute_with(|| { + // Dappstaking call works + let stake_call = RuntimeCall::DappStaking(DappStakingCall::Call::claim_staker_rewards {}); + assert!(WhitelistedCalls::contains(&stake_call)); + + // Pallet::Assets transfer call works + let transfer_call = RuntimeCall::Assets(pallet_assets::Call::transfer { + id: Compact(0), + target: MultiAddress::Address20(H160::repeat_byte(0x01).into()), + amount: 100, + }); + assert!(WhitelistedCalls::contains(&transfer_call)); + }); +} + +#[test] +fn filter_rejects_non_whitelisted_calls() { + ExtBuilder::default().build().execute_with(|| { + // Random call from non whitelisted pallet doesn't work + let transfer_call = RuntimeCall::Balances(BalancesCall::transfer { + dest: MultiAddress::Id(CAT), + value: 100_000_000_000, + }); + assert!(!WhitelistedCalls::contains(&transfer_call)); + + // Only `transfer` call from pallet assets work + // Other random call from Pallet Assets doesn't work + let thaw_asset_call = + RuntimeCall::Assets(pallet_assets::Call::thaw_asset { id: Compact(0) }); + assert!(!WhitelistedCalls::contains(&thaw_asset_call)); + }) +} + +#[test] +fn filter_accepts_whitelisted_batch_all_calls() { + ExtBuilder::default().build().execute_with(|| { + let inner_call1 = RuntimeCall::DappStaking(DappStakingCall::Call::claim_staker_rewards {}); + let inner_call2 = RuntimeCall::DappStaking(DappStakingCall::Call::claim_staker_rewards {}); + let transfer_call = RuntimeCall::Assets(pallet_assets::Call::transfer { + id: Compact(0), + target: MultiAddress::Address20(H160::repeat_byte(0x01).into()), + amount: 100, + }); + let call = RuntimeCall::Utility(UtilityCall::batch_all { + calls: vec![inner_call1, inner_call2, transfer_call], + }); + assert!(WhitelistedCalls::contains(&call)); + }); +} + +#[test] +fn test_correct_dispatch_info_works() { + ExtBuilder::default().build().execute_with(|| { + // Mock implementation + struct Filter; + struct AccountId; + enum RuntimeCall { + System, + DappStaking, + } + impl GetDispatchInfo for RuntimeCall { + fn get_dispatch_info(&self) -> DispatchInfo { + // Default is Pays::Yes and DispatchCall::Normal + DispatchInfo::default() + } + } + impl Contains for Filter { + fn contains(t: &RuntimeCall) -> bool { + match t { + RuntimeCall::DappStaking => true, + _ => false, + } + } + } + // Case 1: Whitelisted Call with correct Dispatch info + assert_eq!( + DispatchFilterValidate::::validate_before_dispatch( + &AccountId, + &RuntimeCall::DappStaking + ), + Option::None + ); + // Case 2: Non-Whitelisted Call with correct Dispatch Info + assert_eq!( + DispatchFilterValidate::::validate_before_dispatch( + &AccountId, + &RuntimeCall::System + ), + Option::Some(PrecompileFailure::Error { + exit_status: ExitError::Other("call filtered out".into()), + }) + ); + }); +} + +#[test] +fn test_incorrect_dispatch_info_fails() { + ExtBuilder::default().build().execute_with(|| { + // Mock implementation + struct Filter; + struct AccountId; + enum RuntimeCall { + DappStaking, + } + impl GetDispatchInfo for RuntimeCall { + fn get_dispatch_info(&self) -> DispatchInfo { + DispatchInfo { + weight: Weight::default(), + class: DispatchClass::Normal, + // Should have been Pays::Yes for call to pass + pays_fee: Pays::No, + } + } + } + impl Contains for Filter { + fn contains(t: &RuntimeCall) -> bool { + match t { + RuntimeCall::DappStaking => true, + } + } + } + + // WhiteListed Call fails because of incorrect DispatchInfo + assert_eq!( + DispatchFilterValidate::::validate_before_dispatch( + &AccountId, + &RuntimeCall::DappStaking + ), + Option::Some(PrecompileFailure::Error { + exit_status: ExitError::Other("invalid call".into()), + }) + ); + }) +} diff --git a/tests/integration/src/dispatch_precompile_filter.rs b/tests/integration/src/dispatch_precompile_filter_old.rs similarity index 95% rename from tests/integration/src/dispatch_precompile_filter.rs rename to tests/integration/src/dispatch_precompile_filter_old.rs index 7992b39c0a..95d04bcfba 100644 --- a/tests/integration/src/dispatch_precompile_filter.rs +++ b/tests/integration/src/dispatch_precompile_filter_old.rs @@ -32,7 +32,7 @@ use parity_scale_codec::Compact; fn filter_accepts_batch_call_with_whitelisted_calls() { ExtBuilder::default().build().execute_with(|| { let contract = SmartContract::Evm(H160::repeat_byte(0x01)); - let inner_call = RuntimeCall::DappsStaking(DappStakingCall::Call::claim_staker { + let inner_call = RuntimeCall::DappsStaking(DappsStakingCall::Call::claim_staker { contract_id: contract.clone(), }); let call = RuntimeCall::Utility(UtilityCall::batch { @@ -61,7 +61,7 @@ fn filter_rejects_non_whitelisted_batch_calls() { // CASE 2 - now whitelisted mixed with whitelisted calls let contract = SmartContract::Evm(H160::repeat_byte(0x01)); - let staking_call = RuntimeCall::DappsStaking(DappStakingCall::Call::claim_staker { + let staking_call = RuntimeCall::DappsStaking(DappsStakingCall::Call::claim_staker { contract_id: contract.clone(), }); let staking = Box::new(staking_call); @@ -80,7 +80,7 @@ fn filter_accepts_whitelisted_calls() { ExtBuilder::default().build().execute_with(|| { // Dappstaking call works let contract = SmartContract::Evm(H160::repeat_byte(0x01)); - let stake_call = RuntimeCall::DappsStaking(DappStakingCall::Call::claim_staker { + let stake_call = RuntimeCall::DappsStaking(DappsStakingCall::Call::claim_staker { contract_id: contract.clone(), }); assert!(WhitelistedCalls::contains(&stake_call)); @@ -117,10 +117,10 @@ fn filter_rejects_non_whitelisted_calls() { fn filter_accepts_whitelisted_batch_all_calls() { ExtBuilder::default().build().execute_with(|| { let contract = SmartContract::Evm(H160::repeat_byte(0x01)); - let inner_call1 = RuntimeCall::DappsStaking(DappStakingCall::Call::claim_staker { + let inner_call1 = RuntimeCall::DappsStaking(DappsStakingCall::Call::claim_staker { contract_id: contract.clone(), }); - let inner_call2 = RuntimeCall::DappsStaking(DappStakingCall::Call::claim_staker { + let inner_call2 = RuntimeCall::DappsStaking(DappsStakingCall::Call::claim_staker { contract_id: contract.clone(), }); let transfer_call = RuntimeCall::Assets(pallet_assets::Call::transfer { diff --git a/tests/integration/src/lib.rs b/tests/integration/src/lib.rs index 012ee39d07..43b2d9843b 100644 --- a/tests/integration/src/lib.rs +++ b/tests/integration/src/lib.rs @@ -23,8 +23,12 @@ #[cfg(any(feature = "shibuya", feature = "shiden", feature = "astar"))] mod setup; -#[cfg(any(feature = "shibuya", feature = "shiden", feature = "astar"))] -mod proxy; +#[cfg(any(feature = "shibuya"))] +mod proxy_new; + +// Remove this once dApp staking v3 is integrated into Shiden & Astar +#[cfg(any(feature = "shiden", feature = "astar"))] +mod proxy_old; #[cfg(any(feature = "shibuya", feature = "shiden", feature = "astar"))] mod assets; @@ -32,7 +36,12 @@ mod assets; #[cfg(feature = "shibuya")] mod xvm; -#[cfg(any(feature = "shibuya", feature = "shiden", feature = "astar"))] -mod dispatch_precompile_filter; +#[cfg(any(feature = "shibuya"))] +mod dispatch_precompile_filter_new; + +// Remove this once dApp staking v3 is integrated into Shiden & Astar +#[cfg(any(feature = "shiden", feature = "astar"))] +mod dispatch_precompile_filter_old; + #[cfg(feature = "shibuya")] mod unified_accounts; diff --git a/tests/integration/src/proxy_new.rs b/tests/integration/src/proxy_new.rs new file mode 100644 index 0000000000..c54bc30ca8 --- /dev/null +++ b/tests/integration/src/proxy_new.rs @@ -0,0 +1,222 @@ +// 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::setup::*; +use astar_primitives::dapp_staking::SmartContractHandle; +use pallet_dapp_staking_v3::ForcingType; + +#[test] +fn test_utility_call_pass_for_any() { + new_test_ext().execute_with(|| { + // Any proxy should be allowed to make balance transfer call + assert_ok!(Proxy::add_proxy( + RuntimeOrigin::signed(ALICE), + MultiAddress::Id(BOB), + ProxyType::Any, + 0 + )); + + // Preparing Utility call + let transfer_call = RuntimeCall::Balances(BalancesCall::transfer { + dest: MultiAddress::Id(CAT), + value: 100_000_000_000, + }); + let inner = Box::new(transfer_call); + let call = Box::new(RuntimeCall::Utility(UtilityCall::batch { + calls: vec![*inner], + })); + + // Utility call passed through filter + assert_ok!(Proxy::proxy( + RuntimeOrigin::signed(BOB), + MultiAddress::Id(ALICE), + None, + call.clone() + )); + expect_events(vec![ + UtilityEvent::BatchCompleted.into(), + ProxyEvent::ProxyExecuted { result: Ok(()) }.into(), + ]); + }); +} + +#[test] +fn test_utility_call_pass_for_balances() { + new_test_ext().execute_with(|| { + // Balances proxy should be allowed to make balance transfer call + assert_ok!(Proxy::add_proxy( + RuntimeOrigin::signed(ALICE), + MultiAddress::Id(BOB), + ProxyType::Balances, + 0 + )); + + // Preparing Utility call + let transfer_call = RuntimeCall::Balances(BalancesCall::transfer { + dest: MultiAddress::Id(CAT), + value: 100_000_000_000, + }); + let inner = Box::new(transfer_call); + let call = Box::new(RuntimeCall::Utility(UtilityCall::batch { + calls: vec![*inner], + })); + + // Utility call passed through filter + assert_ok!(Proxy::proxy( + RuntimeOrigin::signed(BOB), + MultiAddress::Id(ALICE), + None, + call.clone() + )); + expect_events(vec![ + UtilityEvent::BatchCompleted.into(), + ProxyEvent::ProxyExecuted { result: Ok(()) }.into(), + ]); + }); +} + +#[test] +fn test_utility_call_fail_non_transfer() { + new_test_ext().execute_with(|| { + // NonTransfer proxy shouldn't be allowed to make balance transfer call + assert_ok!(Proxy::add_proxy( + RuntimeOrigin::signed(ALICE), + MultiAddress::Id(BOB), + ProxyType::NonTransfer, + 0 + )); + + // Preparing Utility call + let transfer_call = RuntimeCall::Balances(BalancesCall::transfer { + dest: MultiAddress::Id(CAT), + value: 100_000_000_000, + }); + let inner = Box::new(transfer_call); + let call = Box::new(RuntimeCall::Utility(UtilityCall::batch { + calls: vec![*inner], + })); + + assert_ok!(Proxy::proxy( + RuntimeOrigin::signed(BOB), + MultiAddress::Id(ALICE), + None, + call.clone() + )); + + // Utility call filtered out + expect_events(vec![ + UtilityEvent::BatchInterrupted { + index: 0, + error: SystemError::CallFiltered.into(), + } + .into(), + ProxyEvent::ProxyExecuted { result: Ok(()) }.into(), + ]); + }); +} + +#[test] +fn test_utility_call_fail_for_dappstaking() { + new_test_ext().execute_with(|| { + // Dappstaking proxy shouldn't be allowed to make balance transfer call + assert_ok!(Proxy::add_proxy( + RuntimeOrigin::signed(ALICE), + MultiAddress::Id(BOB), + ProxyType::DappStaking, + 0 + )); + + // Preparing Utility call + let transfer_call = RuntimeCall::Balances(BalancesCall::transfer { + dest: MultiAddress::Id(CAT), + value: 100_000_000_000, + }); + let inner = Box::new(transfer_call); + let call = Box::new(RuntimeCall::Utility(UtilityCall::batch { + calls: vec![*inner], + })); + + assert_ok!(Proxy::proxy( + RuntimeOrigin::signed(BOB), + MultiAddress::Id(ALICE), + None, + call.clone() + )); + // Utility call filtered out + expect_events(vec![ + UtilityEvent::BatchInterrupted { + index: 0, + error: SystemError::CallFiltered.into(), + } + .into(), + ProxyEvent::ProxyExecuted { result: Ok(()) }.into(), + ]); + }); +} + +#[test] +fn test_staker_reward_claim_proxy_works() { + new_test_ext().execute_with(|| { + // Make CAT delegate for StakerRewardClaim proxy + assert_ok!(Proxy::add_proxy( + RuntimeOrigin::signed(BOB), + MultiAddress::Id(CAT), + ProxyType::StakerRewardClaim, + 0 + )); + + let contract = ::SmartContract::evm( + H160::repeat_byte(0x01), + ); + let staker_reward_claim_call = + RuntimeCall::DappStaking(DappStakingCall::Call::claim_staker_rewards {}); + let call = Box::new(staker_reward_claim_call); + + // contract must be registered + assert_ok!(DappStaking::register( + RuntimeOrigin::root(), + ALICE.clone(), + contract.clone() + )); + + // some amount must be locked&staked + let amount = 600 * UNIT; + assert_ok!(DappStaking::lock(RuntimeOrigin::signed(BOB), amount)); + assert_ok!(DappStaking::stake( + RuntimeOrigin::signed(BOB), + contract.clone(), + amount, + )); + + // Generate some rewards + assert_ok!(DappStaking::force(RuntimeOrigin::root(), ForcingType::Era)); + run_to_block(System::block_number() + 1); + assert_ok!(DappStaking::force(RuntimeOrigin::root(), ForcingType::Era)); + run_to_block(System::block_number() + 1); + + // CAT making proxy call on behalf of staker (BOB) + assert_ok!(Proxy::proxy( + RuntimeOrigin::signed(CAT), + MultiAddress::Id(BOB), + None, + call.clone() + )); + + expect_events(vec![ProxyEvent::ProxyExecuted { result: Ok(()) }.into()]); + }) +} diff --git a/tests/integration/src/proxy.rs b/tests/integration/src/proxy_old.rs similarity index 98% rename from tests/integration/src/proxy.rs rename to tests/integration/src/proxy_old.rs index 41fc247372..d6d9479a82 100644 --- a/tests/integration/src/proxy.rs +++ b/tests/integration/src/proxy_old.rs @@ -180,7 +180,7 @@ fn test_staker_reward_claim_proxy_works() { let contract = SmartContract::Evm(H160::repeat_byte(0x01)); let staker_reward_claim_call = - RuntimeCall::DappsStaking(DappStakingCall::Call::claim_staker { + RuntimeCall::DappsStaking(DappsStakingCall::Call::claim_staker { contract_id: contract.clone(), }); let call = Box::new(staker_reward_claim_call); diff --git a/tests/integration/src/setup.rs b/tests/integration/src/setup.rs index bc7dddeaca..ba266bfe3e 100644 --- a/tests/integration/src/setup.rs +++ b/tests/integration/src/setup.rs @@ -159,7 +159,8 @@ pub const INITIAL_AMOUNT: u128 = 100_000 * UNIT; pub type SystemError = frame_system::Error; pub use pallet_balances::Call as BalancesCall; -pub use pallet_dapps_staking as DappStakingCall; +pub use pallet_dapp_staking_v3 as DappStakingCall; +pub use pallet_dapps_staking as DappsStakingCall; pub use pallet_proxy::Event as ProxyEvent; pub use pallet_utility::{Call as UtilityCall, Event as UtilityEvent}; @@ -212,32 +213,37 @@ pub const BLOCK_TIME: u64 = 12_000; pub fn run_to_block(n: u32) { while System::block_number() < n { let block_number = System::block_number(); - Timestamp::set_timestamp(block_number as u64 * BLOCK_TIME); TransactionPayment::on_finalize(block_number); + #[cfg(any(feature = "shibuya"))] + DappStaking::on_finalize(block_number); + #[cfg(any(feature = "astar", feature = "shiden"))] DappsStaking::on_finalize(block_number); Authorship::on_finalize(block_number); Session::on_finalize(block_number); AuraExt::on_finalize(block_number); PolkadotXcm::on_finalize(block_number); Ethereum::on_finalize(block_number); - #[cfg(any(features = "astar"))] - BaseFee::on_finalize(block_number); - #[cfg(any(feature = "shibuya", feature = "shiden"))] DynamicEvmBaseFee::on_finalize(block_number); + #[cfg(any(feature = "shibuya"))] + Inflation::on_finalize(block_number); System::set_block_number(block_number + 1); + let block_number = System::block_number(); + #[cfg(any(feature = "shibuya"))] + Inflation::on_initialize(block_number); + Timestamp::set_timestamp(block_number as u64 * BLOCK_TIME); TransactionPayment::on_initialize(block_number); + #[cfg(any(feature = "shibuya"))] + DappStaking::on_initialize(block_number); + #[cfg(any(feature = "astar", feature = "shiden"))] DappsStaking::on_initialize(block_number); Authorship::on_initialize(block_number); Aura::on_initialize(block_number); AuraExt::on_initialize(block_number); Ethereum::on_initialize(block_number); - #[cfg(any(features = "astar"))] - BaseFee::on_initialize(block_number); - #[cfg(any(feature = "shibuya", feature = "shiden"))] DynamicEvmBaseFee::on_initialize(block_number); - #[cfg(any(feature = "shibuya", feature = "shiden", features = "astar"))] + #[cfg(any(feature = "shibuya", feature = "shiden"))] RandomnessCollectiveFlip::on_initialize(block_number); XcmpQueue::on_idle(block_number, Weight::MAX); diff --git a/tests/xcm-simulator/src/mocks/parachain.rs b/tests/xcm-simulator/src/mocks/parachain.rs index 31e9602b0a..bf67fff877 100644 --- a/tests/xcm-simulator/src/mocks/parachain.rs +++ b/tests/xcm-simulator/src/mocks/parachain.rs @@ -23,8 +23,8 @@ use frame_support::{ dispatch::DispatchClass, match_types, parameter_types, traits::{ - AsEnsureOriginWithArg, ConstU128, ConstU32, ConstU64, Contains, Currency, Everything, - Imbalance, InstanceFilter, Nothing, OnUnbalanced, + AsEnsureOriginWithArg, ConstBool, ConstU128, ConstU32, ConstU64, Contains, Currency, + Everything, Imbalance, InstanceFilter, Nothing, OnUnbalanced, }, weights::{ constants::{BlockExecutionWeight, ExtrinsicBaseWeight, WEIGHT_REF_TIME_PER_SECOND}, @@ -37,7 +37,7 @@ use frame_system::{ EnsureRoot, EnsureSigned, }; use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; -use sp_core::{ConstBool, H256}; +use sp_core::H256; use sp_runtime::{ testing::Header, traits::{AccountIdConversion, Convert, Get, IdentityLookup}, @@ -311,6 +311,7 @@ impl pallet_dapps_staking::Config for Runtime { type UnbondingPeriod = ConstU32<2>; type MaxEraStakeValues = ConstU32<4>; type UnregisteredDappRewardRetention = ConstU32<7>; + type ForcePalletDisabled = ConstBool; } /// The type used to represent the kinds of proxying allowed.