diff --git a/code/Cargo.lock b/code/Cargo.lock index 80c6bb1f29f..d91164a7230 100644 --- a/code/Cargo.lock +++ b/code/Cargo.lock @@ -1811,6 +1811,7 @@ dependencies = [ "polkadot-primitives", "polkadot-service", "primitives", + "reward-rpc", "sc-basic-authorship", "sc-chain-spec", "sc-cli", @@ -3790,6 +3791,29 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" +[[package]] +name = "farming" +version = "1.0.0" +dependencies = [ + "frame-benchmarking", + "frame-support", + "frame-system", + "log 0.4.17", + "orml-tokens", + "orml-traits", + "pallet-balances", + "pallet-timestamp", + "parity-scale-codec", + "reward", + "scale-info", + "serde", + "sp-arithmetic 6.0.0", + "sp-core 7.0.0", + "sp-io 7.0.0", + "sp-runtime 7.0.0", + "sp-std 5.0.0", +] + [[package]] name = "fastrand" version = "1.9.0" @@ -10002,6 +10026,7 @@ dependencies = [ "cumulus-primitives-core", "cumulus-primitives-timestamp", "cumulus-primitives-utility", + "farming", "frame-benchmarking", "frame-executive", "frame-support", @@ -10058,6 +10083,8 @@ dependencies = [ "parity-scale-codec", "polkadot-parachain", "primitives", + "reward", + "reward-rpc-runtime-api", "scale-info", "smallvec 1.10.0", "sp-api", @@ -12279,6 +12306,48 @@ dependencies = [ "quick-error 1.2.3", ] +[[package]] +name = "reward" +version = "1.0.0" +dependencies = [ + "frame-benchmarking", + "frame-support", + "frame-system", + "log 0.4.17", + "pallet-timestamp", + "parity-scale-codec", + "rand 0.8.5", + "scale-info", + "serde", + "sp-arithmetic 6.0.0", + "sp-core 7.0.0", + "sp-io 7.0.0", + "sp-runtime 7.0.0", + "sp-std 5.0.0", +] + +[[package]] +name = "reward-rpc" +version = "0.3.0" +dependencies = [ + "jsonrpsee 0.16.2", + "parity-scale-codec", + "reward-rpc-runtime-api", + "sp-api", + "sp-blockchain", + "sp-runtime 7.0.0", +] + +[[package]] +name = "reward-rpc-runtime-api" +version = "0.3.0" +dependencies = [ + "frame-support", + "parity-scale-codec", + "serde", + "sp-api", +] + [[package]] name = "rfc6979" version = "0.3.1" diff --git a/code/parachain/frame/farming/Cargo.toml b/code/parachain/frame/farming/Cargo.toml new file mode 100644 index 00000000000..4ed127ae57e --- /dev/null +++ b/code/parachain/frame/farming/Cargo.toml @@ -0,0 +1,60 @@ +[package] +authors = ["Composable Developers"] +description = "Provides reward mechanism for LP tokens" +edition = "2021" +homepage = "https://composable.finance" +name = "farming" +version = "1.0.0" + + +[dependencies] +log = { version = "0.4.14", default-features = false } +serde = { version = "1.0.137", default-features = false, features = ["derive"], optional = true } +codec = { default-features = false, features = [ "derive", "max-encoded-len"], package = "parity-scale-codec", version = "3.0.0" } +scale-info = { version = "2.1.1", default-features = false, features = [ "derive" ] } + +# Orml dependencies +orml-tokens = { workspace = true, default-features = false } +orml-traits = { workspace = true, default-features = false } + +reward = { path = "../reward", default-features = false } +# Substrate dependencies +sp-arithmetic = { default-features = false, workspace = true } +sp-core = { default-features = false, workspace = true } +sp-io = { default-features = false, workspace = true } +sp-runtime = { default-features = false, workspace = true } +sp-std = { default-features = false, workspace = true } + +frame-support = { default-features = false, workspace = true } +frame-system = { default-features = false, workspace = true } +frame-benchmarking = { default-features = false, workspace = true, optional = true } + + +[dev-dependencies] +pallet-timestamp = { workspace = true } +pallet-balances = { workspace = true, default-features = false } +# frame-benchmarking = { default-features = false, workspace = true } + +[features] +default = ["std"] +std = [ + "log/std", + "serde", + "codec/std", + + "sp-arithmetic/std", + "sp-core/std", + "sp-io/std", + "sp-runtime/std", + "sp-std/std", + + "frame-support/std", + "frame-system/std", + "frame-benchmarking/std", +] + +runtime-benchmarks = [ + "frame-benchmarking", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", +] diff --git a/code/parachain/frame/farming/src/benchmarking.rs b/code/parachain/frame/farming/src/benchmarking.rs new file mode 100644 index 00000000000..768ce8104ae --- /dev/null +++ b/code/parachain/frame/farming/src/benchmarking.rs @@ -0,0 +1,127 @@ +use super::*; +use frame_benchmarking::{account, benchmarks, impl_benchmark_test_suite}; +use frame_support::{assert_ok, traits::Hooks}; +use frame_system::RawOrigin; +use sp_std::vec; + +type CurrencyId = u128; + +const PICA: CurrencyId = 1; +const KSM: CurrencyId = 2; +const DOT: CurrencyId = 0; +const REWARD: CurrencyId = 1000; +const CURRENCY_1: CurrencyId = 1001; +const CURRENCY_2: CurrencyId = 1002; +const CURRENCY_3: CurrencyId = 1003; + +// Pallets +use crate::Pallet as Farming; +use frame_system::Pallet as System; + +fn default_reward_schedule(reward_currency_id: CurrencyId) -> RewardScheduleOf { + let reward_schedule = RewardSchedule { period_count: 100u32, per_period: 1000u32.into() }; + let total_amount = reward_schedule.total().unwrap(); + + assert_ok!(T::MultiCurrency::deposit( + reward_currency_id.into(), + &T::TreasuryAccountId::get(), + total_amount, + )); + + reward_schedule +} + +fn create_reward_schedule(pool_currency_id: CurrencyId, reward_currency_id: CurrencyId) { + let reward_schedule = default_reward_schedule::(reward_currency_id); + + assert_ok!(Farming::::update_reward_schedule( + RawOrigin::Root.into(), + pool_currency_id.into(), + reward_currency_id.into(), + reward_schedule.period_count, + reward_schedule.total().unwrap(), + )); +} + +fn create_default_reward_schedule() -> (CurrencyId, CurrencyId) { + let pool_currency_id = REWARD; + let reward_currency_id = PICA; + create_reward_schedule::(pool_currency_id, reward_currency_id); + (pool_currency_id, reward_currency_id) +} + +fn deposit_lp_tokens( + pool_currency_id: CurrencyId, + account_id: &T::AccountId, + amount: BalanceOf, +) { + assert_ok!(T::MultiCurrency::deposit(pool_currency_id.into(), account_id, amount)); + assert_ok!(Farming::::deposit( + RawOrigin::Signed(account_id.clone()).into(), + pool_currency_id.into(), + )); +} + +pub fn get_benchmarking_currency_ids() -> Vec<(CurrencyId, CurrencyId)> { + vec![(DOT, PICA), (KSM, CURRENCY_1), (DOT, CURRENCY_2), (KSM, CURRENCY_3)] +} + +benchmarks! { + on_initialize { + let c in 0 .. get_benchmarking_currency_ids().len() as u32; + let currency_ids = get_benchmarking_currency_ids(); + let block_number = T::RewardPeriod::get(); + + for i in 0 .. c { + let (pool_currency_id, reward_currency_id) = currency_ids[i as usize]; + create_reward_schedule::(pool_currency_id, reward_currency_id); + } + + Farming::::on_initialize(1u32.into()); + System::::set_block_number(block_number); + }: { + Farming::::on_initialize(System::::block_number()); + } + + update_reward_schedule { + let pool_currency_id = REWARD; + let reward_currency_id = PICA; + let reward_schedule = default_reward_schedule::(reward_currency_id); + + }: _(RawOrigin::Root, pool_currency_id.into(), reward_currency_id.into(), reward_schedule.period_count, reward_schedule.total().unwrap()) + + remove_reward_schedule { + let (pool_currency_id, reward_currency_id) = create_default_reward_schedule::(); + + }: _(RawOrigin::Root, pool_currency_id.into(), reward_currency_id.into()) + + deposit { + let origin: T::AccountId = account("Origin", 0, 0); + let (pool_currency_id, _) = create_default_reward_schedule::(); + assert_ok!(T::MultiCurrency::deposit( + pool_currency_id.into(), + &origin, + 100u32.into(), + )); + + }: _(RawOrigin::Signed(origin), pool_currency_id.into()) + + withdraw { + let origin: T::AccountId = account("Origin", 0, 0); + let (pool_currency_id, _) = create_default_reward_schedule::(); + let amount = 100u32.into(); + deposit_lp_tokens::(pool_currency_id.into(), &origin, amount); + + }: _(RawOrigin::Signed(origin), pool_currency_id.into(), amount) + + claim { + let origin: T::AccountId = account("Origin", 0, 0); + let (pool_currency_id, reward_currency_id) = create_default_reward_schedule::(); + let amount = 100u32.into(); + deposit_lp_tokens::(pool_currency_id.into(), &origin, amount); + assert_ok!(T::RewardPools::distribute_reward(&pool_currency_id.into(), reward_currency_id.into(), amount)); + + }: _(RawOrigin::Signed(origin), pool_currency_id.into(), reward_currency_id.into()) +} + +impl_benchmark_test_suite!(Farming, crate::mock::ExtBuilder::build(), crate::mock::Test); diff --git a/code/parachain/frame/farming/src/default_weights.rs b/code/parachain/frame/farming/src/default_weights.rs new file mode 100644 index 00000000000..eb39dd54de2 --- /dev/null +++ b/code/parachain/frame/farming/src/default_weights.rs @@ -0,0 +1,166 @@ +//! Autogenerated weights for farming +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 4.0.0-dev +//! DATE: 2023-01-17, STEPS: `100`, REPEAT: 10, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! EXECUTION: Some(Wasm), WASM-EXECUTION: Compiled, CHAIN: Some("dev"), DB CACHE: 1024 + +// Executed Command: +// target/release/interbtc-standalone +// benchmark +// pallet +// --chain +// dev +// --execution=wasm +// --wasm-execution=compiled +// --pallet +// farming +// --extrinsic +// * +// --steps +// 100 +// --repeat +// 10 +// --output +// crates/farming/src/default_weights.rs +// --template +// .deploy/weight-template.hbs + +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(unused_parens)] +#![allow(unused_imports)] + +use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; +use sp_std::marker::PhantomData; + +/// Weight functions needed for farming. +pub trait WeightInfo { + fn on_initialize(c: u32, ) -> Weight; + fn update_reward_schedule() -> Weight; + fn remove_reward_schedule() -> Weight; + fn deposit() -> Weight; + fn withdraw() -> Weight; + fn claim() -> Weight; +} + +/// Weights for farming using the Substrate node and recommended hardware. +pub struct SubstrateWeight(PhantomData); +impl WeightInfo for SubstrateWeight { + // Storage: Farming RewardSchedules (r:1 w:0) + // Storage: FarmingRewards TotalStake (r:1 w:0) + fn on_initialize(c: u32, ) -> Weight { + Weight::from_ref_time(18_073_005u64) + // Standard Error: 183_362 + .saturating_add(Weight::from_ref_time(18_555_611u64).saturating_mul(c as u64)) + .saturating_add(T::DbWeight::get().reads(1u64)) + .saturating_add(T::DbWeight::get().reads((2u64).saturating_mul(c as u64))) + } + // Storage: Tokens Accounts (r:2 w:2) + // Storage: System Account (r:2 w:1) + // Storage: Farming RewardSchedules (r:1 w:1) + fn update_reward_schedule() -> Weight { + Weight::from_ref_time(105_531_000u64) + .saturating_add(T::DbWeight::get().reads(5u64)) + .saturating_add(T::DbWeight::get().writes(4u64)) + } + // Storage: Tokens Accounts (r:2 w:2) + // Storage: System Account (r:1 w:0) + // Storage: Farming RewardSchedules (r:0 w:1) + fn remove_reward_schedule() -> Weight { + Weight::from_ref_time(83_988_000u64) + .saturating_add(T::DbWeight::get().reads(3u64)) + .saturating_add(T::DbWeight::get().writes(3u64)) + } + // Storage: Farming RewardSchedules (r:2 w:0) + // Storage: Tokens Accounts (r:1 w:1) + // Storage: FarmingRewards Stake (r:1 w:1) + // Storage: FarmingRewards TotalStake (r:1 w:1) + // Storage: FarmingRewards RewardTally (r:2 w:2) + // Storage: FarmingRewards RewardPerToken (r:2 w:0) + fn deposit() -> Weight { + Weight::from_ref_time(108_507_000u64) + .saturating_add(T::DbWeight::get().reads(9u64)) + .saturating_add(T::DbWeight::get().writes(5u64)) + } + // Storage: Tokens Accounts (r:1 w:1) + // Storage: FarmingRewards Stake (r:1 w:1) + // Storage: FarmingRewards TotalStake (r:1 w:1) + // Storage: FarmingRewards RewardTally (r:2 w:2) + // Storage: FarmingRewards RewardPerToken (r:2 w:0) + fn withdraw() -> Weight { + Weight::from_ref_time(96_703_000u64) + .saturating_add(T::DbWeight::get().reads(7u64)) + .saturating_add(T::DbWeight::get().writes(5u64)) + } + // Storage: FarmingRewards Stake (r:1 w:0) + // Storage: FarmingRewards RewardPerToken (r:1 w:0) + // Storage: FarmingRewards RewardTally (r:1 w:1) + // Storage: FarmingRewards TotalRewards (r:1 w:1) + // Storage: Tokens Accounts (r:2 w:2) + // Storage: System Account (r:2 w:1) + fn claim() -> Weight { + Weight::from_ref_time(136_142_000u64) + .saturating_add(T::DbWeight::get().reads(8u64)) + .saturating_add(T::DbWeight::get().writes(5u64)) + } +} + +// For backwards compatibility and tests +impl WeightInfo for () { + // Storage: Farming RewardSchedules (r:1 w:0) + // Storage: FarmingRewards TotalStake (r:1 w:0) + fn on_initialize(c: u32, ) -> Weight { + Weight::from_ref_time(18_073_005u64) + // Standard Error: 183_362 + .saturating_add(Weight::from_ref_time(18_555_611u64).saturating_mul(c as u64)) + .saturating_add(RocksDbWeight::get().reads(1u64)) + .saturating_add(RocksDbWeight::get().reads((2u64).saturating_mul(c as u64))) + } + // Storage: Tokens Accounts (r:2 w:2) + // Storage: System Account (r:2 w:1) + // Storage: Farming RewardSchedules (r:1 w:1) + fn update_reward_schedule() -> Weight { + Weight::from_ref_time(105_531_000u64) + .saturating_add(RocksDbWeight::get().reads(5u64)) + .saturating_add(RocksDbWeight::get().writes(4u64)) + } + // Storage: Tokens Accounts (r:2 w:2) + // Storage: System Account (r:1 w:0) + // Storage: Farming RewardSchedules (r:0 w:1) + fn remove_reward_schedule() -> Weight { + Weight::from_ref_time(83_988_000u64) + .saturating_add(RocksDbWeight::get().reads(3u64)) + .saturating_add(RocksDbWeight::get().writes(3u64)) + } + // Storage: Farming RewardSchedules (r:2 w:0) + // Storage: Tokens Accounts (r:1 w:1) + // Storage: FarmingRewards Stake (r:1 w:1) + // Storage: FarmingRewards TotalStake (r:1 w:1) + // Storage: FarmingRewards RewardTally (r:2 w:2) + // Storage: FarmingRewards RewardPerToken (r:2 w:0) + fn deposit() -> Weight { + Weight::from_ref_time(108_507_000u64) + .saturating_add(RocksDbWeight::get().reads(9u64)) + .saturating_add(RocksDbWeight::get().writes(5u64)) + } + // Storage: Tokens Accounts (r:1 w:1) + // Storage: FarmingRewards Stake (r:1 w:1) + // Storage: FarmingRewards TotalStake (r:1 w:1) + // Storage: FarmingRewards RewardTally (r:2 w:2) + // Storage: FarmingRewards RewardPerToken (r:2 w:0) + fn withdraw() -> Weight { + Weight::from_ref_time(96_703_000u64) + .saturating_add(RocksDbWeight::get().reads(7u64)) + .saturating_add(RocksDbWeight::get().writes(5u64)) + } + // Storage: FarmingRewards Stake (r:1 w:0) + // Storage: FarmingRewards RewardPerToken (r:1 w:0) + // Storage: FarmingRewards RewardTally (r:1 w:1) + // Storage: FarmingRewards TotalRewards (r:1 w:1) + // Storage: Tokens Accounts (r:2 w:2) + // Storage: System Account (r:2 w:1) + fn claim() -> Weight { + Weight::from_ref_time(136_142_000u64) + .saturating_add(RocksDbWeight::get().reads(8u64)) + .saturating_add(RocksDbWeight::get().writes(5u64)) + } +} diff --git a/code/parachain/frame/farming/src/lib.rs b/code/parachain/frame/farming/src/lib.rs new file mode 100644 index 00000000000..b12d1fac854 --- /dev/null +++ b/code/parachain/frame/farming/src/lib.rs @@ -0,0 +1,408 @@ +//! # Farming Module +//! Root can create reward schedules paying incentives periodically to users +//! staking certain tokens. +//! +//! A reward schedule consists of two items: +//! 1. The number of periods set globally as a configuration for all pools. +//! This number is ultimately measured in blocks; e.g., if a period is +//! defined as 10 blocks, then a period count of 10 means 100 blocks. +//! 2. The amount of reward tokens paid in that period. +//! +//! Users are only paid a share of the rewards in the period if they have +//! staked tokens for a reward schedule that distributed more than 0 tokens. +//! +//! The following design decisions have been made: +//! - The reward schedule is configured as a matrix such that a staked token (e.g., an AMM LP token) +//! and an incentive token (e.g., PICA or DOT) represent one reward schedule. This enables adding +//! multiple reward currencies per staked token. +//! - Rewards can be increased but not decreased unless the schedule is explicitly removed. +//! - The rewards period cannot change without a migration. +//! - Only constant rewards per period are paid. To implement more complex reward schemes, the +//! farming pallet relies on the scheduler pallet. This allows a creator to configure different +//! constant payouts by scheduling `update_reward_schedule` in the future. + +// #![deny(warnings)] +#![cfg_attr(test, feature(proc_macro_hygiene))] +#![cfg_attr(not(feature = "std"), no_std)] + +#[cfg(feature = "runtime-benchmarks")] +mod benchmarking; + +mod default_weights; +pub use default_weights::WeightInfo; + +#[cfg(test)] +mod mock; + +#[cfg(test)] +mod tests; + +use codec::{Decode, Encode, FullCodec, MaxEncodedLen}; +use core::fmt::Debug; +use frame_support::{ + dispatch::DispatchResult, traits::Get, transactional, weights::Weight, PalletId, RuntimeDebug, +}; +use orml_traits::{MultiCurrency, MultiReservableCurrency}; +use reward::RewardsApi; +use scale_info::TypeInfo; +use sp_runtime::{ + traits::{AccountIdConversion, AtLeast32Bit, CheckedDiv, Saturating, Zero}, + ArithmeticError, DispatchError, +}; +use sp_std::vec::Vec; + +pub use pallet::*; + +#[derive(Clone, Default, Encode, Decode, PartialEq, Eq, RuntimeDebug, TypeInfo, MaxEncodedLen)] +pub struct RewardSchedule { + /// Number of periods remaining + pub period_count: u32, + /// Amount of tokens to release + #[codec(compact)] + pub per_period: Balance, +} + +impl RewardSchedule { + /// Returns total amount to distribute, `None` if calculation overflows + pub fn total(&self) -> Option { + self.per_period.checked_mul(&self.period_count.into()) + } + + /// Take the next reward and decrement the period count + pub fn take(&mut self) -> Option { + if self.period_count.gt(&0) { + self.period_count.saturating_dec(); + Some(self.per_period) + } else { + None + } + } +} + +#[frame_support::pallet] +pub mod pallet { + use super::*; + use frame_support::pallet_prelude::*; + use frame_system::{ensure_root, ensure_signed, pallet_prelude::*}; + + pub(crate) type AccountIdOf = ::AccountId; + + pub(crate) type AssetIdOf = ::AssetId; + + pub(crate) type BalanceOf = + <::MultiCurrency as MultiCurrency>>::Balance; + + pub(crate) type RewardScheduleOf = RewardSchedule>; + + /// ## Configuration + /// The pallet's configuration trait. + #[pallet::config] + pub trait Config: frame_system::Config { + /// The overarching event type. + type RuntimeEvent: From> + IsType<::RuntimeEvent>; + + /// The farming pallet id, used for deriving pool accounts. + #[pallet::constant] + type FarmingPalletId: Get; + + /// The treasury account id for funding pools. + #[pallet::constant] + type TreasuryAccountId: Get; + + /// The period to accrue rewards. + #[pallet::constant] + type RewardPeriod: Get; + + /// Reward pools to track stake. + type RewardPools: RewardsApi< + AssetIdOf, // pool id is the lp token + AccountIdOf, + BalanceOf, + CurrencyId = AssetIdOf, + >; + + type AssetId: FullCodec + + MaxEncodedLen + + Eq + + PartialEq + + Copy + + Clone + + MaybeSerializeDeserialize + + Debug + + Default + + TypeInfo + + From + + Into + + Ord; + + /// Currency handler to transfer tokens. + type MultiCurrency: MultiReservableCurrency, CurrencyId = Self::AssetId>; + + /// Weight information for the extrinsics. + type WeightInfo: WeightInfo; + } + + // The pallet's events + #[pallet::event] + #[pallet::generate_deposit(pub(crate) fn deposit_event)] + pub enum Event { + RewardScheduleUpdated { + pool_currency_id: AssetIdOf, + reward_currency_id: AssetIdOf, + period_count: u32, + per_period: BalanceOf, + }, + RewardDistributed { + pool_currency_id: AssetIdOf, + reward_currency_id: AssetIdOf, + amount: BalanceOf, + }, + RewardClaimed { + account_id: AccountIdOf, + pool_currency_id: AssetIdOf, + reward_currency_id: AssetIdOf, + amount: BalanceOf, + }, + } + + #[pallet::error] + pub enum Error { + InsufficientStake, + } + + #[pallet::hooks] + impl Hooks for Pallet { + fn on_initialize(now: T::BlockNumber) -> Weight { + if now % T::RewardPeriod::get() == Zero::zero() { + let mut count: u32 = 0; + // collect first to avoid modifying in-place + let schedules = RewardSchedules::::iter().collect::>(); + for (pool_currency_id, reward_currency_id, mut reward_schedule) in + schedules.into_iter() + { + if let Some(amount) = reward_schedule.take() { + if Self::try_distribute_reward(pool_currency_id, reward_currency_id, amount) + .is_ok() + { + // only update the schedule if we could distribute the reward + RewardSchedules::::insert( + pool_currency_id, + reward_currency_id, + reward_schedule, + ); + count.saturating_inc(); + Self::deposit_event(Event::RewardDistributed { + pool_currency_id, + reward_currency_id, + amount, + }); + } + } else { + // period count is zero + RewardSchedules::::remove(pool_currency_id, reward_currency_id); + } + } + T::WeightInfo::on_initialize(count) + } else { + Weight::zero() + } + } + } + + #[pallet::storage] + #[pallet::getter(fn reward_schedules)] + #[allow(clippy::disallowed_types)] + pub type RewardSchedules = StorageDoubleMap< + _, + Blake2_128Concat, + AssetIdOf, // lp token + Blake2_128Concat, + AssetIdOf, // reward currency + RewardScheduleOf, + ValueQuery, + >; + + #[pallet::pallet] + pub struct Pallet(_); + + // The pallet's dispatchable functions. + #[pallet::call] + impl Pallet { + /// Create or overwrite the reward schedule, if a reward schedule + /// already exists for the rewards currency the duration is added + /// to the existing duration and the rewards per period are modified + /// s.t. that the total (old remaining + new) rewards are distributed + /// over the new total duration + #[pallet::call_index(0)] + #[pallet::weight(T::WeightInfo::update_reward_schedule())] + #[transactional] + pub fn update_reward_schedule( + origin: OriginFor, + pool_currency_id: AssetIdOf, + reward_currency_id: AssetIdOf, + period_count: u32, + #[pallet::compact] amount: BalanceOf, + ) -> DispatchResult { + ensure_root(origin)?; + // fund the pool account from treasury + let treasury_account_id = T::TreasuryAccountId::get(); + let pool_account_id = Self::pool_account_id(&pool_currency_id); + T::MultiCurrency::transfer( + reward_currency_id, + &treasury_account_id, + &pool_account_id, + amount, + )?; + + RewardSchedules::::try_mutate( + pool_currency_id, + reward_currency_id, + |reward_schedule| { + let total_period_count = reward_schedule + .period_count + .checked_add(period_count) + .ok_or_else(|| { + log::error!("Overflow error: Failed to calculate total_period_count for pool_currency_id : {:?}, reward_currency_id : {:?}, old period_count : {}, extend period_count : {}", + pool_currency_id, reward_currency_id, reward_schedule.period_count, period_count + ); + ArithmeticError::Overflow + })?; + + let total_free = + T::MultiCurrency::free_balance(reward_currency_id, &pool_account_id); + let total_per_period = + total_free.checked_div(&total_period_count.into()).unwrap_or_default(); + + reward_schedule.period_count = total_period_count; + reward_schedule.per_period = total_per_period; + + Self::deposit_event(Event::RewardScheduleUpdated { + pool_currency_id, + reward_currency_id, + period_count: total_period_count, + per_period: total_per_period, + }); + Ok(()) + }, + ) + } + + /// Explicitly remove a reward schedule and transfer any remaining + /// balance to the treasury + #[pallet::call_index(1)] + #[pallet::weight(T::WeightInfo::remove_reward_schedule())] + #[transactional] + pub fn remove_reward_schedule( + origin: OriginFor, + pool_currency_id: AssetIdOf, + reward_currency_id: AssetIdOf, + ) -> DispatchResultWithPostInfo { + ensure_root(origin)?; + // transfer unspent rewards to treasury + let treasury_account_id = T::TreasuryAccountId::get(); + let pool_account_id = Self::pool_account_id(&pool_currency_id); + T::MultiCurrency::transfer( + reward_currency_id, + &pool_account_id, + &treasury_account_id, + T::MultiCurrency::free_balance(reward_currency_id, &pool_account_id), + )?; + + RewardSchedules::::remove(pool_currency_id, reward_currency_id); + Self::deposit_event(Event::RewardScheduleUpdated { + pool_currency_id, + reward_currency_id, + period_count: Zero::zero(), + per_period: Zero::zero(), + }); + + Ok(().into()) + } + + /// Stake the pool tokens in the reward pool + #[pallet::call_index(2)] + #[pallet::weight(T::WeightInfo::deposit())] + #[transactional] + pub fn deposit(origin: OriginFor, pool_currency_id: AssetIdOf) -> DispatchResult { + let who = ensure_signed(origin)?; + // reserve lp tokens to prevent spending + let amount = T::MultiCurrency::free_balance(pool_currency_id, &who); + T::MultiCurrency::reserve(pool_currency_id, &who, amount)?; + + // deposit lp tokens as stake + T::RewardPools::deposit_stake(&pool_currency_id, &who, amount) + } + + /// Unstake the pool tokens from the reward pool + #[pallet::call_index(3)] + #[pallet::weight(T::WeightInfo::withdraw())] + #[transactional] + pub fn withdraw( + origin: OriginFor, + pool_currency_id: AssetIdOf, + amount: BalanceOf, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + // unreserve lp tokens to allow spending + let remaining = T::MultiCurrency::unreserve(pool_currency_id, &who, amount); + ensure!(remaining.is_zero(), Error::::InsufficientStake); + + // withdraw lp tokens from stake + T::RewardPools::withdraw_stake(&pool_currency_id, &who, amount) + } + + /// Withdraw any accrued rewards from the reward pool + #[pallet::call_index(4)] + #[pallet::weight(T::WeightInfo::claim())] + #[transactional] + pub fn claim( + origin: OriginFor, + pool_currency_id: AssetIdOf, + reward_currency_id: AssetIdOf, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + let pool_account_id = Self::pool_account_id(&pool_currency_id); + + // get reward from staking pool + let reward = + T::RewardPools::withdraw_reward(&pool_currency_id, &who, reward_currency_id)?; + // transfer from pool to user + T::MultiCurrency::transfer(reward_currency_id, &pool_account_id, &who, reward)?; + + Self::deposit_event(Event::RewardClaimed { + account_id: who, + pool_currency_id, + reward_currency_id, + amount: reward, + }); + + Ok(()) + } + } +} + +// "Internal" functions, callable by code. +impl Pallet { + pub fn pool_account_id(pool_currency_id: &AssetIdOf) -> T::AccountId { + T::FarmingPalletId::get().into_sub_account_truncating(pool_currency_id) + } + + pub fn total_rewards( + pool_currency_id: &AssetIdOf, + reward_currency_id: &AssetIdOf, + ) -> BalanceOf { + let pool_currency_id = pool_currency_id; + RewardSchedules::::get(pool_currency_id, reward_currency_id) + .total() + .unwrap_or_default() + } + + #[transactional] + fn try_distribute_reward( + pool_currency_id: AssetIdOf, + reward_currency_id: AssetIdOf, + amount: BalanceOf, + ) -> Result<(), DispatchError> { + T::RewardPools::distribute_reward(&pool_currency_id, reward_currency_id, amount) + } +} diff --git a/code/parachain/frame/farming/src/mock.rs b/code/parachain/frame/farming/src/mock.rs new file mode 100644 index 00000000000..98ebfc59522 --- /dev/null +++ b/code/parachain/frame/farming/src/mock.rs @@ -0,0 +1,144 @@ +use crate::{self as farming, Config, Error}; +use frame_support::{ + parameter_types, + traits::{ConstU32, Everything}, + PalletId, +}; +use orml_traits::parameter_type_with_key; +use sp_arithmetic::FixedI128; +use sp_core::H256; +use sp_runtime::{ + generic::Header as GenericHeader, + traits::{AccountIdConversion, BlakeTwo256, IdentityLookup}, +}; + +type Header = GenericHeader; + +type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic; +type Block = frame_system::mocking::MockBlock; + +// Configure a mock runtime to test the pallet. +frame_support::construct_runtime!( + pub enum Test where + Block = Block, + NodeBlock = Block, + UncheckedExtrinsic = UncheckedExtrinsic, + { + System: frame_system::{Pallet, Call, Storage, Config, Event}, + Tokens: orml_tokens::{Pallet, Storage, /*Config,*/ Event}, + Rewards: reward::{Pallet, Call, Storage, Event}, + Farming: farming::{Pallet, Call, Storage, Event}, + } +); + +pub type CurrencyId = u128; + +pub type AccountId = u64; +pub type Balance = u128; +pub type BlockNumber = u128; +pub type Index = u64; +pub type SignedFixedPoint = FixedI128; + +parameter_types! { + pub const BlockHashCount: u64 = 250; + pub const SS58Prefix: u8 = 42; +} + +impl frame_system::Config for Test { + type BaseCallFilter = Everything; + type BlockWeights = (); + type BlockLength = (); + type DbWeight = (); + type RuntimeOrigin = RuntimeOrigin; + type RuntimeCall = RuntimeCall; + type Index = Index; + 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 Version = (); + type PalletInfo = PalletInfo; + type AccountData = pallet_balances::AccountData; + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = SS58Prefix; + type OnSetCode = (); + type MaxConsumers = frame_support::traits::ConstU32<16>; +} + +parameter_types! { + pub const MaxLocks: u32 = 50; +} + +parameter_type_with_key! { + pub ExistentialDeposits: |_currency_id: CurrencyId| -> Balance { + 0 + }; +} + +impl orml_tokens::Config for Test { + type RuntimeEvent = RuntimeEvent; + type Balance = Balance; + type Amount = i128; + type CurrencyId = CurrencyId; + type WeightInfo = (); + type ExistentialDeposits = ExistentialDeposits; + type CurrencyHooks = (); + type MaxLocks = MaxLocks; + type DustRemovalWhitelist = Everything; + type MaxReserves = ConstU32<0>; // we don't use named reserves + type ReserveIdentifier = (); // we don't use named reserves +} + +impl reward::Config for Test { + type RuntimeEvent = RuntimeEvent; + type SignedFixedPoint = SignedFixedPoint; + type PoolId = CurrencyId; + type StakeId = AccountId; + type CurrencyId = CurrencyId; +} + +parameter_types! { + pub const FarmingPalletId: PalletId = PalletId(*b"farmings"); + pub TreasuryAccountId: AccountId = PalletId(*b"treasury").into_account_truncating(); + pub const RewardPeriod: BlockNumber = 10; +} + +impl Config for Test { + type RuntimeEvent = RuntimeEvent; + type FarmingPalletId = FarmingPalletId; + type TreasuryAccountId = TreasuryAccountId; + type RewardPeriod = RewardPeriod; + type RewardPools = Rewards; + type AssetId = CurrencyId; + type MultiCurrency = Tokens; + type WeightInfo = (); +} + +pub type TestEvent = RuntimeEvent; +pub type TestError = Error; + +pub struct ExtBuilder; + +impl ExtBuilder { + pub fn build() -> sp_io::TestExternalities { + let storage = frame_system::GenesisConfig::default().build_storage::().unwrap(); + + storage.into() + } +} + +pub fn run_test(test: T) +where + T: FnOnce(), +{ + ExtBuilder::build().execute_with(|| { + System::set_block_number(1); + test(); + }); +} diff --git a/code/parachain/frame/farming/src/tests.rs b/code/parachain/frame/farming/src/tests.rs new file mode 100644 index 00000000000..10cf67830ca --- /dev/null +++ b/code/parachain/frame/farming/src/tests.rs @@ -0,0 +1,233 @@ +use super::*; +use crate::mock::*; +use frame_support::{assert_err, assert_ok, traits::Hooks}; +use orml_traits::MultiCurrency; + +type Event = crate::Event; + +macro_rules! assert_emitted { + ($event:expr) => { + let test_event = TestEvent::Farming($event); + assert!(System::events().iter().any(|a| a.event == test_event)); + }; +} + +// use primitives::CurrencyId; +use crate::mock::CurrencyId; + +const POOL_CURRENCY_ID: CurrencyId = 1000; +const REWARD_CURRENCY_ID: CurrencyId = 1; + +#[test] +fn should_create_and_remove_reward_schedule() { + run_test(|| { + let reward_schedule = RewardSchedule { period_count: 100, per_period: 1000 }; + let total_amount = reward_schedule.total().unwrap(); + + assert_ok!(Tokens::set_balance( + RuntimeOrigin::root(), + TreasuryAccountId::get(), + REWARD_CURRENCY_ID, + total_amount, + 0 + )); + + // creating a reward pool should transfer from treasury + assert_ok!(Farming::update_reward_schedule( + RuntimeOrigin::root(), + POOL_CURRENCY_ID, + REWARD_CURRENCY_ID, + reward_schedule.period_count, + reward_schedule.total().unwrap(), + )); + + // check pool balance + assert_eq!( + Tokens::total_balance(REWARD_CURRENCY_ID, &Farming::pool_account_id(&POOL_CURRENCY_ID)), + total_amount + ); + + // deleting a reward pool should transfer back to treasury + assert_ok!(Farming::remove_reward_schedule( + RuntimeOrigin::root(), + POOL_CURRENCY_ID, + REWARD_CURRENCY_ID, + )); + + // check treasury balance + assert_eq!( + Tokens::total_balance(REWARD_CURRENCY_ID, &TreasuryAccountId::get()), + total_amount + ); + + assert_emitted!(Event::RewardScheduleUpdated { + pool_currency_id: POOL_CURRENCY_ID, + reward_currency_id: REWARD_CURRENCY_ID, + period_count: 0, + per_period: 0, + }); + }) +} + +#[test] +fn should_overwrite_existing_schedule() { + run_test(|| { + let reward_schedule_1 = RewardSchedule { period_count: 200, per_period: 20 }; + let reward_schedule_2 = RewardSchedule { period_count: 100, per_period: 10 }; + let total_amount = reward_schedule_1.total().unwrap() + reward_schedule_2.total().unwrap(); + let total_period_count = reward_schedule_1.period_count + reward_schedule_2.period_count; + let total_reward_per_period = total_amount / total_period_count as u128; + + assert_ok!(Tokens::set_balance( + RuntimeOrigin::root(), + TreasuryAccountId::get(), + REWARD_CURRENCY_ID, + total_amount, + 0 + )); + + // create first reward schedule + assert_ok!(Farming::update_reward_schedule( + RuntimeOrigin::root(), + POOL_CURRENCY_ID, + REWARD_CURRENCY_ID, + reward_schedule_1.period_count, + reward_schedule_1.total().unwrap(), + )); + + // check pool balance + assert_eq!( + Tokens::total_balance(REWARD_CURRENCY_ID, &Farming::pool_account_id(&POOL_CURRENCY_ID)), + reward_schedule_1.total().unwrap(), + ); + + // overwrite second reward schedule + assert_ok!(Farming::update_reward_schedule( + RuntimeOrigin::root(), + POOL_CURRENCY_ID, + REWARD_CURRENCY_ID, + reward_schedule_2.period_count, + reward_schedule_2.total().unwrap(), + )); + + // check pool balance now includes both + assert_eq!( + Tokens::total_balance(REWARD_CURRENCY_ID, &Farming::pool_account_id(&POOL_CURRENCY_ID)), + total_amount, + ); + + assert_emitted!(Event::RewardScheduleUpdated { + pool_currency_id: POOL_CURRENCY_ID, + reward_currency_id: REWARD_CURRENCY_ID, + period_count: total_period_count, + per_period: total_reward_per_period, + }); + }) +} + +fn mint_and_deposit(account_id: AccountId, amount: Balance) { + assert_ok!(Tokens::set_balance(RuntimeOrigin::root(), account_id, POOL_CURRENCY_ID, amount, 0)); + + assert_ok!(Farming::deposit(RuntimeOrigin::signed(account_id), POOL_CURRENCY_ID,)); +} + +#[test] +fn should_deposit_and_withdraw_stake() { + run_test(|| { + let pool_tokens = 1000; + let account_id = 0; + + let reward_schedule = RewardSchedule { period_count: 100, per_period: 1000 }; + let total_amount = reward_schedule.total().unwrap(); + + assert_ok!(Tokens::set_balance( + RuntimeOrigin::root(), + TreasuryAccountId::get(), + REWARD_CURRENCY_ID, + total_amount, + 0 + )); + + assert_ok!(Farming::update_reward_schedule( + RuntimeOrigin::root(), + POOL_CURRENCY_ID, + REWARD_CURRENCY_ID, + reward_schedule.period_count, + reward_schedule.total().unwrap(), + )); + + // mint and deposit stake + mint_and_deposit(account_id, pool_tokens); + + // can't withdraw more stake than reserved + let withdraw_amount = pool_tokens * 2; + assert_err!( + Farming::withdraw(RuntimeOrigin::signed(account_id), POOL_CURRENCY_ID, withdraw_amount), + TestError::InsufficientStake + ); + + // only withdraw half of deposit + let withdraw_amount = pool_tokens / 2; + assert_ok!(Farming::withdraw( + RuntimeOrigin::signed(account_id), + POOL_CURRENCY_ID, + withdraw_amount + )); + assert_eq!(Tokens::free_balance(POOL_CURRENCY_ID, &account_id), withdraw_amount); + }) +} + +#[test] +fn should_deposit_stake_and_claim_reward() { + run_test(|| { + let pool_tokens = 1000; + let account_id = 0; + + // setup basic reward schedule + let reward_schedule = RewardSchedule { period_count: 100, per_period: 1000 }; + let total_amount = reward_schedule.total().unwrap(); + + assert_ok!(Tokens::set_balance( + RuntimeOrigin::root(), + TreasuryAccountId::get(), + REWARD_CURRENCY_ID, + total_amount, + 0 + )); + + assert_ok!(Farming::update_reward_schedule( + RuntimeOrigin::root(), + POOL_CURRENCY_ID, + REWARD_CURRENCY_ID, + reward_schedule.period_count, + reward_schedule.total().unwrap(), + )); + + // mint and deposit stake + mint_and_deposit(account_id, pool_tokens); + + // check that we distribute per period + Farming::on_initialize(10); + assert_emitted!(Event::RewardDistributed { + pool_currency_id: POOL_CURRENCY_ID, + reward_currency_id: REWARD_CURRENCY_ID, + amount: reward_schedule.per_period, + }); + assert_eq!( + RewardSchedules::::get(POOL_CURRENCY_ID, REWARD_CURRENCY_ID).period_count, + reward_schedule.period_count - 1 + ); + + // withdraw reward + assert_ok!(Farming::claim( + RuntimeOrigin::signed(account_id), + POOL_CURRENCY_ID, + REWARD_CURRENCY_ID, + )); + // only one account with stake so they get all rewards + assert_eq!( + Tokens::free_balance(REWARD_CURRENCY_ID, &account_id), + reward_schedule.per_period + ); + }) +} diff --git a/code/parachain/frame/reward/Cargo.toml b/code/parachain/frame/reward/Cargo.toml new file mode 100644 index 00000000000..fc554536239 --- /dev/null +++ b/code/parachain/frame/reward/Cargo.toml @@ -0,0 +1,55 @@ +[package] +authors = ["Composable Developers"] +description = "Provides reward mechanism for LP tokens" +edition = "2021" +homepage = "https://composable.finance" +name = "reward" +version = "1.0.0" + + +[dependencies] +log = { version = "0.4.14", default-features = false } +serde = { version = "1.0.137", default-features = false, features = ["derive"], optional = true } +codec = { default-features = false, features = [ "derive", "max-encoded-len"], package = "parity-scale-codec", version = "3.0.0" } +scale-info = { version = "2.1.1", default-features = false, features = [ "derive" ] } + +# Substrate dependencies +sp-arithmetic = { default-features = false, workspace = true } +sp-core = { default-features = false, workspace = true } +sp-io = { default-features = false, workspace = true } +sp-runtime = { default-features = false, workspace = true } +sp-std = { default-features = false, workspace = true } + +frame-support = { default-features = false, workspace = true } +frame-system = { default-features = false, workspace = true } +frame-benchmarking = { default-features = false, workspace = true, optional = true } + + +[dev-dependencies] +pallet-timestamp = { workspace = true } +rand = "0.8.3" +frame-benchmarking = { default-features = false, workspace = true } + +[features] +default = ["std"] +std = [ + "log/std", + "serde", + "codec/std", + + "sp-arithmetic/std", + "sp-core/std", + "sp-io/std", + "sp-runtime/std", + "sp-std/std", + + "frame-support/std", + "frame-system/std", + "frame-benchmarking/std", +] + +runtime-benchmarks = [ + "frame-benchmarking", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", +] \ No newline at end of file diff --git a/code/parachain/frame/reward/rpc/Cargo.toml b/code/parachain/frame/reward/rpc/Cargo.toml new file mode 100644 index 00000000000..f4dba87699e --- /dev/null +++ b/code/parachain/frame/reward/rpc/Cargo.toml @@ -0,0 +1,13 @@ +[package] +authors = ["Composable Developers"] +edition = "2021" +name = "reward-rpc" +version = '0.3.0' + +[dependencies] +codec = { package = "parity-scale-codec", version = "3.0.0" } +jsonrpsee = { version = "0.16.2", features = ["server", "macros"] } +sp-runtime = { workspace = true } +sp-api = { workspace = true } +sp-blockchain = { workspace = true } +reward-rpc-runtime-api = { path = "runtime-api" } diff --git a/code/parachain/frame/reward/rpc/runtime-api/Cargo.toml b/code/parachain/frame/reward/rpc/runtime-api/Cargo.toml new file mode 100644 index 00000000000..39931b789fb --- /dev/null +++ b/code/parachain/frame/reward/rpc/runtime-api/Cargo.toml @@ -0,0 +1,27 @@ +[package] +authors = ["Composable Developers"] +edition = "2021" +name = "reward-rpc-runtime-api" +version = '0.3.0' + +[dependencies] +codec = { default-features = false, features = [ + "derive", "max-encoded-len" +], package = "parity-scale-codec", version = "3.0.0" } +sp-api = { default-features = false, workspace = true } +frame-support = { default-features = false, workspace = true } +serde = { version = '1.0.136', optional = true } + +# [dependencies.oracle-rpc-runtime-api] +# default-features = false +# path = '../../../oracle/rpc/runtime-api' + +[features] +default = ["std"] +std = [ + "codec/std", + "frame-support/std", + "sp-api/std", + "serde" + # "oracle-rpc-runtime-api/std", +] diff --git a/code/parachain/frame/reward/rpc/runtime-api/src/lib.rs b/code/parachain/frame/reward/rpc/runtime-api/src/lib.rs new file mode 100644 index 00000000000..57679f118ee --- /dev/null +++ b/code/parachain/frame/reward/rpc/runtime-api/src/lib.rs @@ -0,0 +1,55 @@ +//! Runtime API definition for the Reward Module. + +#![cfg_attr(not(feature = "std"), no_std)] + +use codec::Codec; +use frame_support::dispatch::DispatchError; + +use codec::{Decode, Encode}; +#[cfg(feature = "std")] +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +#[derive(Eq, PartialEq, Encode, Decode, Default)] +#[cfg_attr(feature = "std", derive(Debug, Serialize, Deserialize))] +#[cfg_attr(feature = "std", serde(rename_all = "camelCase"))] +/// a wrapper around a balance, used in RPC to workaround a bug where using u128 +/// in runtime-apis fails. See +pub struct BalanceWrapper { + #[cfg_attr(feature = "std", serde(bound(serialize = "T: std::fmt::Display")))] + #[cfg_attr(feature = "std", serde(serialize_with = "serialize_as_string"))] + #[cfg_attr(feature = "std", serde(bound(deserialize = "T: std::str::FromStr")))] + #[cfg_attr(feature = "std", serde(deserialize_with = "deserialize_from_string"))] + pub amount: T, +} + +#[cfg(feature = "std")] +fn serialize_as_string( + t: &T, + serializer: S, +) -> Result { + serializer.serialize_str(&t.to_string()) +} + +#[cfg(feature = "std")] +fn deserialize_from_string<'de, D: Deserializer<'de>, T: std::str::FromStr>( + deserializer: D, +) -> Result { + let s = String::deserialize(deserializer)?; + s.parse::().map_err(|_| serde::de::Error::custom("Parse from string failed")) +} + +sp_api::decl_runtime_apis! { + pub trait RewardApi where + AccountId: Codec, + CurrencyId: Codec, + Balance: Codec, + BlockNumber: Codec, + UnsignedFixedPoint: Codec, + { + /// Calculate the number of farming rewards accrued + fn compute_farming_reward(account_id: AccountId, pool_currency_id: CurrencyId, reward_currency_id: CurrencyId) -> Result, DispatchError>; + + /// Estimate farming rewards for remaining incentives + fn estimate_farming_reward(account_id: AccountId, pool_currency_id: CurrencyId, reward_currency_id: CurrencyId) -> Result, DispatchError>; + } +} diff --git a/code/parachain/frame/reward/rpc/src/lib.rs b/code/parachain/frame/reward/rpc/src/lib.rs new file mode 100644 index 00000000000..aa2150544a8 --- /dev/null +++ b/code/parachain/frame/reward/rpc/src/lib.rs @@ -0,0 +1,131 @@ +//! RPC interface for the Reward Module. + +use codec::Codec; +use jsonrpsee::{ + core::{async_trait, Error as JsonRpseeError, RpcResult}, + proc_macros::rpc, + types::error::{CallError, ErrorCode, ErrorObject}, +}; +use reward_rpc_runtime_api::BalanceWrapper; +use sp_api::ProvideRuntimeApi; +use sp_blockchain::HeaderBackend; +use sp_runtime::{ + generic::BlockId, + traits::{Block as BlockT, MaybeDisplay, MaybeFromStr}, + DispatchError, +}; +use std::sync::Arc; + +pub use reward_rpc_runtime_api::RewardApi as RewardRuntimeApi; + +#[rpc(client, server)] +pub trait RewardApi +where + Balance: Codec + MaybeDisplay + MaybeFromStr, + AccountId: Codec, + CurrencyId: Codec, + BlockNumber: Codec, + UnsignedFixedPoint: Codec, +{ + #[method(name = "reward_computeFarmingReward")] + fn compute_farming_reward( + &self, + account_id: AccountId, + pool_currency_id: CurrencyId, + reward_currency_id: CurrencyId, + at: Option, + ) -> RpcResult>; + + #[method(name = "reward_estimateFarmingReward")] + fn estimate_farming_reward( + &self, + account_id: AccountId, + pool_currency_id: CurrencyId, + reward_currency_id: CurrencyId, + at: Option, + ) -> RpcResult>; +} + +fn internal_err(message: T) -> JsonRpseeError { + JsonRpseeError::Call(CallError::Custom(ErrorObject::owned( + ErrorCode::InternalError.code(), + message.to_string(), + None::<()>, + ))) +} + +/// A struct that implements the [`RewardApi`]. +pub struct Reward { + client: Arc, + _marker: std::marker::PhantomData, +} + +impl Reward { + /// Create new `Reward` with the given reference to the client. + pub fn new(client: Arc) -> Self { + Reward { client, _marker: Default::default() } + } +} + +fn handle_response( + result: Result, E>, + msg: String, +) -> RpcResult { + result + .map_err(|err| internal_err(format!("Runtime error: {:?}: {:?}", msg, err)))? + .map_err(|err| internal_err(format!("Execution error: {:?}: {:?}", msg, err))) +} + +#[async_trait] +impl + RewardApiServer< + ::Hash, + AccountId, + CurrencyId, + Balance, + BlockNumber, + UnsignedFixedPoint, + > for Reward +where + Block: BlockT, + C: Send + Sync + 'static + ProvideRuntimeApi + HeaderBackend, + C::Api: + RewardRuntimeApi, + AccountId: Codec, + CurrencyId: Codec, + Balance: Codec + MaybeDisplay + MaybeFromStr, + BlockNumber: Codec, + UnsignedFixedPoint: Codec, +{ + fn compute_farming_reward( + &self, + account_id: AccountId, + pool_currency_id: CurrencyId, + reward_currency_id: CurrencyId, + at: Option<::Hash>, + ) -> RpcResult> { + let api = self.client.runtime_api(); + let at = BlockId::hash(at.unwrap_or_else(|| self.client.info().best_hash)); + + handle_response( + api.compute_farming_reward(&at, account_id, pool_currency_id, reward_currency_id), + "Unable to compute the current reward".into(), + ) + } + + fn estimate_farming_reward( + &self, + account_id: AccountId, + pool_currency_id: CurrencyId, + reward_currency_id: CurrencyId, + at: Option<::Hash>, + ) -> RpcResult> { + let api = self.client.runtime_api(); + let at = BlockId::hash(at.unwrap_or_else(|| self.client.info().best_hash)); + + handle_response( + api.estimate_farming_reward(&at, account_id, pool_currency_id, reward_currency_id), + "Unable to estimate the current reward".into(), + ) + } +} diff --git a/code/parachain/frame/reward/src/lib.rs b/code/parachain/frame/reward/src/lib.rs new file mode 100644 index 00000000000..10a94fce505 --- /dev/null +++ b/code/parachain/frame/reward/src/lib.rs @@ -0,0 +1,575 @@ +//! # Reward Module +//! Based on the [Scalable Reward Distribution](https://solmaz.io/2019/02/24/scalable-reward-changing/) algorithm. + +// #![deny(warnings)] +#![cfg_attr(test, feature(proc_macro_hygiene))] +#![cfg_attr(not(feature = "std"), no_std)] + +#[cfg(test)] +mod mock; + +#[cfg(test)] +mod tests; + +use codec::{Decode, Encode, EncodeLike}; +use frame_support::{ + dispatch::{DispatchError, DispatchResult}, + ensure, +}; + +use scale_info::TypeInfo; +use sp_arithmetic::FixedPointNumber; +use sp_runtime::{ + traits::{ + CheckedAdd, CheckedDiv, CheckedMul, CheckedSub, MaybeSerializeDeserialize, Saturating, Zero, + }, + ArithmeticError, FixedI128, FixedU128, +}; +use sp_std::{cmp::PartialOrd, collections::btree_set::BTreeSet, convert::TryInto, fmt::Debug}; + +pub(crate) type SignedFixedPoint = >::SignedFixedPoint; + +pub use pallet::*; + +pub trait BalanceToFixedPoint { + fn to_fixed(self) -> Option; +} + +impl BalanceToFixedPoint for u128 { + fn to_fixed(self) -> Option { + FixedI128::checked_from_integer( + TryInto::<::Inner>::try_into(self).ok()?, + ) + } +} + +pub trait TruncateFixedPointToInt: FixedPointNumber { + /// take a fixed point number and turns it into the truncated inner representation, + /// e.g. FixedU128(1.23) -> 1u128 + fn truncate_to_inner(&self) -> Option<::Inner>; +} + +impl TruncateFixedPointToInt for FixedI128 { + fn truncate_to_inner(&self) -> Option { + self.into_inner().checked_div(FixedI128::accuracy()) + } +} + +impl TruncateFixedPointToInt for FixedU128 { + fn truncate_to_inner(&self) -> Option<::Inner> { + self.into_inner().checked_div(FixedU128::accuracy()) + } +} +#[frame_support::pallet] +pub mod pallet { + use super::*; + use frame_support::pallet_prelude::*; + + /// ## Configuration + /// The pallet's configuration trait. + #[pallet::config] + pub trait Config: frame_system::Config { + /// The overarching event type. + type RuntimeEvent: From> + + IsType<::RuntimeEvent>; + + /// Signed fixed point type. + type SignedFixedPoint: FixedPointNumber + + TruncateFixedPointToInt + + Encode + + EncodeLike + + Decode + + TypeInfo; + + /// The pool identifier type. + type PoolId: Parameter + Member + MaybeSerializeDeserialize + Debug + MaxEncodedLen; + + /// The stake identifier type. + type StakeId: Parameter + Member + MaybeSerializeDeserialize + Debug + MaxEncodedLen; + + /// The currency ID type. + type CurrencyId: Parameter + Member + Copy + MaybeSerializeDeserialize + Ord + MaxEncodedLen; + } + + // The pallet's events + #[pallet::event] + #[pallet::generate_deposit(pub(crate) fn deposit_event)] + pub enum Event, I: 'static = ()> { + DepositStake { + pool_id: T::PoolId, + stake_id: T::StakeId, + amount: T::SignedFixedPoint, + }, + DistributeReward { + currency_id: T::CurrencyId, + amount: T::SignedFixedPoint, + }, + WithdrawStake { + pool_id: T::PoolId, + stake_id: T::StakeId, + amount: T::SignedFixedPoint, + }, + WithdrawReward { + pool_id: T::PoolId, + stake_id: T::StakeId, + currency_id: T::CurrencyId, + amount: T::SignedFixedPoint, + }, + } + + #[pallet::error] + pub enum Error { + /// Unable to convert value. + TryIntoIntError, + /// Balance not sufficient to withdraw stake. + InsufficientFunds, + /// Cannot distribute rewards without stake. + ZeroTotalStake, + } + + #[pallet::hooks] + impl, I: 'static> Hooks for Pallet {} + + /// The total stake deposited to this reward pool. + #[pallet::storage] + #[pallet::getter(fn total_stake)] + #[allow(clippy::disallowed_types)] + pub type TotalStake, I: 'static = ()> = + StorageMap<_, Blake2_128Concat, T::PoolId, SignedFixedPoint, ValueQuery>; + + /// The total unclaimed rewards distributed to this reward pool. + /// NOTE: this is currently only used for integration tests. + #[pallet::storage] + #[pallet::getter(fn total_rewards)] + #[allow(clippy::disallowed_types)] + pub type TotalRewards, I: 'static = ()> = + StorageMap<_, Blake2_128Concat, T::CurrencyId, SignedFixedPoint, ValueQuery>; + + /// Used to compute the rewards for a participant's stake. + #[pallet::storage] + #[pallet::getter(fn reward_per_token)] + #[allow(clippy::disallowed_types)] + pub type RewardPerToken, I: 'static = ()> = StorageDoubleMap< + _, + Blake2_128Concat, + T::CurrencyId, + Blake2_128Concat, + T::PoolId, + SignedFixedPoint, + ValueQuery, + >; + + /// The stake of a participant in this reward pool. + #[pallet::storage] + #[allow(clippy::disallowed_types)] + pub type Stake, I: 'static = ()> = StorageMap< + _, + Blake2_128Concat, + (T::PoolId, T::StakeId), + SignedFixedPoint, + ValueQuery, + >; + + /// Accounts for previous changes in stake size. + #[pallet::storage] + #[allow(clippy::disallowed_types)] + pub type RewardTally, I: 'static = ()> = StorageDoubleMap< + _, + Blake2_128Concat, + T::CurrencyId, + Blake2_128Concat, + (T::PoolId, T::StakeId), + SignedFixedPoint, + ValueQuery, + >; + + /// Track the currencies used for rewards. + #[pallet::storage] + #[allow(clippy::disallowed_types)] + pub type RewardCurrencies, I: 'static = ()> = + StorageMap<_, Blake2_128Concat, T::PoolId, BTreeSet, ValueQuery>; + + #[pallet::pallet] + #[pallet::without_storage_info] + pub struct Pallet(_); + + // The pallet's dispatchable functions. + #[pallet::call] + impl, I: 'static> Pallet {} +} + +#[macro_export] +macro_rules! checked_add_mut { + ($storage:ty, $amount:expr) => { + <$storage>::mutate(|value| { + *value = value.checked_add($amount).ok_or(ArithmeticError::Overflow)?; + Ok::<_, DispatchError>(()) + })?; + }; + ($storage:ty, $currency:expr, $amount:expr) => { + <$storage>::mutate($currency, |value| { + *value = value.checked_add($amount).ok_or(ArithmeticError::Overflow)?; + Ok::<_, DispatchError>(()) + })?; + }; + ($storage:ty, $currency:expr, $account:expr, $amount:expr) => { + <$storage>::mutate($currency, $account, |value| { + *value = value.checked_add($amount).ok_or(ArithmeticError::Overflow)?; + Ok::<_, DispatchError>(()) + })?; + }; +} + +macro_rules! checked_sub_mut { + ($storage:ty, $amount:expr) => { + <$storage>::mutate(|value| { + *value = value.checked_sub($amount).ok_or(ArithmeticError::Underflow)?; + Ok::<_, DispatchError>(()) + })?; + }; + ($storage:ty, $currency:expr, $amount:expr) => { + <$storage>::mutate($currency, |value| { + *value = value.checked_sub($amount).ok_or(ArithmeticError::Underflow)?; + Ok::<_, DispatchError>(()) + })?; + }; + ($storage:ty, $currency:expr, $account:expr, $amount:expr) => { + <$storage>::mutate($currency, $account, |value| { + *value = value.checked_sub($amount).ok_or(ArithmeticError::Underflow)?; + Ok::<_, DispatchError>(()) + })?; + }; +} + +// "Internal" functions, callable by code. +impl, I: 'static> Pallet { + pub fn stake(pool_id: &T::PoolId, stake_id: &T::StakeId) -> SignedFixedPoint { + Stake::::get((pool_id, stake_id)) + } + + pub fn get_total_rewards( + currency_id: T::CurrencyId, + ) -> Result<::Inner, DispatchError> { + Ok(Self::total_rewards(currency_id) + .truncate_to_inner() + .ok_or(Error::::TryIntoIntError)?) + } + + pub fn deposit_stake( + pool_id: &T::PoolId, + stake_id: &T::StakeId, + amount: SignedFixedPoint, + ) -> Result<(), DispatchError> { + checked_add_mut!(Stake, (pool_id, stake_id), &amount); + checked_add_mut!(TotalStake, pool_id, &amount); + + for currency_id in RewardCurrencies::::get(pool_id) { + >::mutate(currency_id, (pool_id, stake_id), |reward_tally| { + let reward_per_token = Self::reward_per_token(currency_id, pool_id); + let reward_per_token_mul_amount = + reward_per_token.checked_mul(&amount).ok_or(ArithmeticError::Overflow)?; + *reward_tally = reward_tally + .checked_add(&reward_per_token_mul_amount) + .ok_or(ArithmeticError::Overflow)?; + Ok::<_, DispatchError>(()) + })?; + } + + Self::deposit_event(Event::::DepositStake { + pool_id: pool_id.clone(), + stake_id: stake_id.clone(), + amount, + }); + + Ok(()) + } + + pub fn distribute_reward( + pool_id: &T::PoolId, + currency_id: T::CurrencyId, + reward: SignedFixedPoint, + ) -> DispatchResult { + if reward.is_zero() { + return Ok(()) + } + let total_stake = Self::total_stake(pool_id); + ensure!(!total_stake.is_zero(), Error::::ZeroTotalStake); + + // track currency for future deposits / withdrawals + RewardCurrencies::::mutate(pool_id, |reward_currencies| { + reward_currencies.insert(currency_id); + }); + + let reward_div_total_stake = + reward.checked_div(&total_stake).ok_or(ArithmeticError::Underflow)?; + checked_add_mut!(RewardPerToken, currency_id, pool_id, &reward_div_total_stake); + checked_add_mut!(TotalRewards, currency_id, &reward); + + Self::deposit_event(Event::::DistributeReward { currency_id, amount: reward }); + Ok(()) + } + + pub fn compute_reward( + pool_id: &T::PoolId, + stake_id: &T::StakeId, + currency_id: T::CurrencyId, + ) -> Result< as FixedPointNumber>::Inner, DispatchError> { + let stake = Self::stake(pool_id, stake_id); + let reward_per_token = Self::reward_per_token(currency_id, pool_id); + + let stake_mul_reward_per_token = + stake.checked_mul(&reward_per_token).ok_or(ArithmeticError::Overflow)?; + let reward_tally = >::get(currency_id, (pool_id, stake_id)); + + let reward = stake_mul_reward_per_token + .checked_sub(&reward_tally) + .ok_or(ArithmeticError::Underflow)? + .truncate_to_inner() + .ok_or(Error::::TryIntoIntError)?; + Ok(reward) + } + + pub fn withdraw_stake( + pool_id: &T::PoolId, + stake_id: &T::StakeId, + amount: SignedFixedPoint, + ) -> Result<(), DispatchError> { + if amount > Self::stake(pool_id, stake_id) { + return Err(Error::::InsufficientFunds.into()) + } + + checked_sub_mut!(Stake, (pool_id, stake_id), &amount); + checked_sub_mut!(TotalStake, pool_id, &amount); + + for currency_id in RewardCurrencies::::get(pool_id) { + >::mutate(currency_id, (pool_id, stake_id), |reward_tally| { + let reward_per_token = Self::reward_per_token(currency_id, pool_id); + let reward_per_token_mul_amount = + reward_per_token.checked_mul(&amount).ok_or(ArithmeticError::Overflow)?; + + *reward_tally = reward_tally + .checked_sub(&reward_per_token_mul_amount) + .ok_or(ArithmeticError::Underflow)?; + Ok::<_, DispatchError>(()) + })?; + } + + Self::deposit_event(Event::::WithdrawStake { + pool_id: pool_id.clone(), + stake_id: stake_id.clone(), + amount, + }); + Ok(()) + } + + pub fn withdraw_reward( + pool_id: &T::PoolId, + stake_id: &T::StakeId, + currency_id: T::CurrencyId, + ) -> Result< as FixedPointNumber>::Inner, DispatchError> { + let reward = Self::compute_reward(pool_id, stake_id, currency_id)?; + let reward_as_fixed = SignedFixedPoint::::checked_from_integer(reward) + .ok_or(Error::::TryIntoIntError)?; + checked_sub_mut!(TotalRewards, currency_id, &reward_as_fixed); + + let stake = Self::stake(pool_id, stake_id); + let reward_per_token = Self::reward_per_token(currency_id, pool_id); + >::insert( + currency_id, + (pool_id, stake_id), + stake.checked_mul(&reward_per_token).ok_or(ArithmeticError::Overflow)?, + ); + + Self::deposit_event(Event::::WithdrawReward { + currency_id, + pool_id: pool_id.clone(), + stake_id: stake_id.clone(), + amount: reward_as_fixed, + }); + Ok(reward) + } +} + +pub trait RewardsApi +where + Balance: Saturating + PartialOrd, +{ + type CurrencyId; + + /// Distribute the `amount` to all participants OR error if zero total stake. + fn distribute_reward( + pool_id: &PoolId, + currency_id: Self::CurrencyId, + amount: Balance, + ) -> DispatchResult; + + /// Compute the expected reward for the `stake_id`. + fn compute_reward( + pool_id: &PoolId, + stake_id: &StakeId, + currency_id: Self::CurrencyId, + ) -> Result; + + /// Withdraw all rewards from the `stake_id`. + fn withdraw_reward( + pool_id: &PoolId, + stake_id: &StakeId, + currency_id: Self::CurrencyId, + ) -> Result; + + /// Deposit stake for an account. + fn deposit_stake(pool_id: &PoolId, stake_id: &StakeId, amount: Balance) -> DispatchResult; + + /// Withdraw stake for an account. + fn withdraw_stake(pool_id: &PoolId, stake_id: &StakeId, amount: Balance) -> DispatchResult; + + /// Withdraw all stake for an account. + fn withdraw_all_stake(pool_id: &PoolId, stake_id: &StakeId) -> DispatchResult { + Self::withdraw_stake(pool_id, stake_id, Self::get_stake(pool_id, stake_id)?) + } + + /// Return the stake associated with the `pool_id`. + fn get_total_stake(pool_id: &PoolId) -> Result; + + /// Return the stake associated with the `stake_id`. + fn get_stake(pool_id: &PoolId, stake_id: &StakeId) -> Result; + + /// Set the stake to `amount` for `stake_id` regardless of its current stake. + fn set_stake(pool_id: &PoolId, stake_id: &StakeId, amount: Balance) -> DispatchResult { + let current_stake = Self::get_stake(pool_id, stake_id)?; + if current_stake < amount { + let additional_stake = amount.saturating_sub(current_stake); + Self::deposit_stake(pool_id, stake_id, additional_stake) + } else if current_stake > amount { + let surplus_stake = current_stake.saturating_sub(amount); + Self::withdraw_stake(pool_id, stake_id, surplus_stake) + } else { + Ok(()) + } + } +} + +impl RewardsApi for Pallet +where + T: Config, + I: 'static, + Balance: BalanceToFixedPoint> + Saturating + PartialOrd, + ::Inner: TryInto, +{ + type CurrencyId = T::CurrencyId; + + fn distribute_reward( + pool_id: &T::PoolId, + currency_id: T::CurrencyId, + amount: Balance, + ) -> DispatchResult { + Pallet::::distribute_reward( + pool_id, + currency_id, + amount.to_fixed().ok_or(Error::::TryIntoIntError)?, + ) + } + + fn compute_reward( + pool_id: &T::PoolId, + stake_id: &T::StakeId, + currency_id: T::CurrencyId, + ) -> Result { + Pallet::::compute_reward(pool_id, stake_id, currency_id)? + .try_into() + .map_err(|_| Error::::TryIntoIntError.into()) + } + + fn withdraw_reward( + pool_id: &T::PoolId, + stake_id: &T::StakeId, + currency_id: T::CurrencyId, + ) -> Result { + Pallet::::withdraw_reward(pool_id, stake_id, currency_id)? + .try_into() + .map_err(|_| Error::::TryIntoIntError.into()) + } + + fn get_total_stake(pool_id: &T::PoolId) -> Result { + Pallet::::total_stake(pool_id) + .truncate_to_inner() + .ok_or(Error::::TryIntoIntError)? + .try_into() + .map_err(|_| Error::::TryIntoIntError.into()) + } + + fn get_stake(pool_id: &T::PoolId, stake_id: &T::StakeId) -> Result { + Pallet::::stake(pool_id, stake_id) + .truncate_to_inner() + .ok_or(Error::::TryIntoIntError)? + .try_into() + .map_err(|_| Error::::TryIntoIntError.into()) + } + + fn deposit_stake( + pool_id: &T::PoolId, + stake_id: &T::StakeId, + amount: Balance, + ) -> DispatchResult { + Pallet::::deposit_stake( + pool_id, + stake_id, + amount.to_fixed().ok_or(Error::::TryIntoIntError)?, + ) + } + + fn withdraw_stake( + pool_id: &T::PoolId, + stake_id: &T::StakeId, + amount: Balance, + ) -> DispatchResult { + Pallet::::withdraw_stake( + pool_id, + stake_id, + amount.to_fixed().ok_or(Error::::TryIntoIntError)?, + ) + } +} + +impl RewardsApi for () +where + Balance: Saturating + PartialOrd + Default, +{ + type CurrencyId = (); + + fn distribute_reward(_: &PoolId, _: Self::CurrencyId, _: Balance) -> DispatchResult { + Ok(()) + } + + fn compute_reward( + _: &PoolId, + _: &StakeId, + _: Self::CurrencyId, + ) -> Result { + Ok(Default::default()) + } + + fn withdraw_reward( + _: &PoolId, + _: &StakeId, + _: Self::CurrencyId, + ) -> Result { + Ok(Default::default()) + } + + fn get_total_stake(_: &PoolId) -> Result { + Ok(Default::default()) + } + + fn get_stake(_: &PoolId, _: &StakeId) -> Result { + Ok(Default::default()) + } + + fn deposit_stake(_: &PoolId, _: &StakeId, _: Balance) -> DispatchResult { + Ok(()) + } + + fn withdraw_stake(_: &PoolId, _: &StakeId, _: Balance) -> DispatchResult { + Ok(()) + } +} diff --git a/code/parachain/frame/reward/src/mock.rs b/code/parachain/frame/reward/src/mock.rs new file mode 100644 index 00000000000..447cd0e54c7 --- /dev/null +++ b/code/parachain/frame/reward/src/mock.rs @@ -0,0 +1,95 @@ +use crate as reward; +use crate::{Config, Error}; +use frame_support::{parameter_types, traits::Everything}; +use sp_arithmetic::FixedI128; +use sp_core::H256; +use sp_runtime::{ + testing::Header, + traits::{BlakeTwo256, IdentityLookup}, +}; + +type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic; +type Block = frame_system::mocking::MockBlock; + +// Configure a mock runtime to test the pallet. +frame_support::construct_runtime!( + pub enum Test where + Block = Block, + NodeBlock = Block, + UncheckedExtrinsic = UncheckedExtrinsic, + { + System: frame_system::{Pallet, Call, Storage, Config, Event}, + Reward: reward::{Pallet, Call, Storage, Event}, + } +); + +pub type CurrencyId = u128; +pub type AccountId = u64; +pub type BlockNumber = u64; +pub type Index = u64; +pub type SignedFixedPoint = FixedI128; + +parameter_types! { + pub const BlockHashCount: u64 = 250; + pub const SS58Prefix: u8 = 42; +} + +impl frame_system::Config for Test { + type BaseCallFilter = Everything; + type BlockWeights = (); + type BlockLength = (); + type DbWeight = (); + type RuntimeOrigin = RuntimeOrigin; + type RuntimeCall = RuntimeCall; + type Index = Index; + 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 Version = (); + type PalletInfo = PalletInfo; + type AccountData = (); + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = SS58Prefix; + type OnSetCode = (); + type MaxConsumers = frame_support::traits::ConstU32<16>; +} + +impl Config for Test { + type RuntimeEvent = RuntimeEvent; + type SignedFixedPoint = SignedFixedPoint; + type PoolId = (); + type StakeId = AccountId; + type CurrencyId = CurrencyId; +} + +pub type TestError = Error; + +pub const ALICE: AccountId = 1; +pub const BOB: AccountId = 2; + +pub struct ExtBuilder; + +impl ExtBuilder { + pub fn build() -> sp_io::TestExternalities { + let storage = frame_system::GenesisConfig::default().build_storage::().unwrap(); + + storage.into() + } +} + +pub fn run_test(test: T) +where + T: FnOnce(), +{ + ExtBuilder::build().execute_with(|| { + System::set_block_number(1); + test(); + }); +} diff --git a/code/parachain/frame/reward/src/tests.rs b/code/parachain/frame/reward/src/tests.rs new file mode 100644 index 00000000000..68ab3e98b14 --- /dev/null +++ b/code/parachain/frame/reward/src/tests.rs @@ -0,0 +1,173 @@ +/// Tests for Reward +use crate::mock::*; +use frame_support::{assert_err, assert_ok}; +use rand::Rng; + +// type Event = crate::Event; + +use crate::mock::CurrencyId; + +const PICA: CurrencyId = 1; +const LP: CurrencyId = 10000; +const KSM: CurrencyId = 4; +const USDT: CurrencyId = 140; + +macro_rules! fixed { + ($amount:expr) => { + sp_arithmetic::FixedI128::from($amount) + }; +} + +#[test] +#[cfg_attr(rustfmt, rustfmt_skip)] +fn reproduce_live_state() { + // This function is most useful for debugging. Keeping this test here for convenience + // and to function as an additional regression test + run_test(|| { + let f = |x: i128| SignedFixedPoint::from_inner(x); + let currency = PICA; + + // state for a3eFe9M2HbAgrQrShEDH2CEvXACtzLhSf4JGkwuT9SQ1EV4ti at block 0xb47ed0e773e25c81da2cc606495ab6f716c3c2024f9beb361605860912fee652 + crate::RewardPerToken::::insert(currency, (), f(1_699_249_738_518_636_122_154_288_694)); + crate::RewardTally::::insert(currency, ((), ALICE), f(164_605_943_476_265_834_062_592_062_507_811_208)); + crate::Stake::::insert(((), ALICE), f(97_679_889_000_000_000_000_000_000)); + crate::TotalRewards::::insert(currency, f(8_763_982_459_262_268_000_000_000_000_000_000)); + crate::TotalStake::::insert((), f(2_253_803_217_000_000_000_000_000_000)); + + assert_ok!(Reward::compute_reward(&(), &ALICE, currency), 1376582365513566); + }) +} + +#[test] +fn should_distribute_rewards_equally() { + run_test(|| { + assert_ok!(Reward::deposit_stake(&(), &ALICE, fixed!(50))); + assert_ok!(Reward::deposit_stake(&(), &BOB, fixed!(50))); + assert_ok!(Reward::distribute_reward(&(), LP, fixed!(100))); + assert_ok!(Reward::compute_reward(&(), &ALICE, LP), 50); + assert_ok!(Reward::compute_reward(&(), &BOB, LP), 50); + }) +} + +#[test] +fn should_distribute_uneven_rewards_equally() { + run_test(|| { + assert_ok!(Reward::deposit_stake(&(), &ALICE, fixed!(50))); + assert_ok!(Reward::deposit_stake(&(), &BOB, fixed!(50))); + assert_ok!(Reward::distribute_reward(&(), LP, fixed!(451))); + assert_ok!(Reward::compute_reward(&(), &ALICE, LP), 225); + assert_ok!(Reward::compute_reward(&(), &BOB, LP), 225); + }) +} + +#[test] +fn should_not_update_previous_rewards() { + run_test(|| { + assert_ok!(Reward::deposit_stake(&(), &ALICE, fixed!(40))); + assert_ok!(Reward::distribute_reward(&(), LP, fixed!(1000))); + assert_ok!(Reward::compute_reward(&(), &ALICE, LP), 1000); + + assert_ok!(Reward::deposit_stake(&(), &BOB, fixed!(20))); + assert_ok!(Reward::compute_reward(&(), &ALICE, LP), 1000); + assert_ok!(Reward::compute_reward(&(), &BOB, LP), 0); + }) +} + +#[test] +fn should_withdraw_reward() { + run_test(|| { + assert_ok!(Reward::deposit_stake(&(), &ALICE, fixed!(45))); + assert_ok!(Reward::deposit_stake(&(), &BOB, fixed!(55))); + assert_ok!(Reward::distribute_reward(&(), LP, fixed!(2344))); + assert_ok!(Reward::compute_reward(&(), &BOB, LP), 1289); + assert_ok!(Reward::withdraw_reward(&(), &ALICE, LP), 1054); + assert_ok!(Reward::compute_reward(&(), &BOB, LP), 1289); + }) +} + +#[test] +fn should_withdraw_stake() { + run_test(|| { + assert_ok!(Reward::deposit_stake(&(), &ALICE, fixed!(1312))); + assert_ok!(Reward::distribute_reward(&(), LP, fixed!(4242))); + // rounding in `CheckedDiv` loses some precision + assert_ok!(Reward::compute_reward(&(), &ALICE, LP), 4241); + assert_ok!(Reward::withdraw_stake(&(), &ALICE, fixed!(1312))); + assert_ok!(Reward::compute_reward(&(), &ALICE, LP), 4241); + }) +} + +#[test] +fn should_not_withdraw_stake_if_balance_insufficient() { + run_test(|| { + assert_ok!(Reward::deposit_stake(&(), &ALICE, fixed!(100))); + assert_ok!(Reward::distribute_reward(&(), LP, fixed!(2000))); + assert_ok!(Reward::compute_reward(&(), &ALICE, LP), 2000); + assert_err!(Reward::withdraw_stake(&(), &ALICE, fixed!(200)), TestError::InsufficientFunds); + }) +} + +#[test] +fn should_deposit_stake() { + run_test(|| { + assert_ok!(Reward::deposit_stake(&(), &ALICE, fixed!(25))); + assert_ok!(Reward::deposit_stake(&(), &ALICE, fixed!(25))); + assert_eq!(Reward::stake(&(), &ALICE), fixed!(50)); + assert_ok!(Reward::deposit_stake(&(), &BOB, fixed!(50))); + assert_ok!(Reward::distribute_reward(&(), LP, fixed!(1000))); + assert_ok!(Reward::compute_reward(&(), &ALICE, LP), 500); + }) +} + +#[test] +fn should_not_distribute_rewards_without_stake() { + run_test(|| { + assert_err!(Reward::distribute_reward(&(), LP, fixed!(1000)), TestError::ZeroTotalStake); + assert_eq!(Reward::total_rewards(LP), fixed!(0)); + }) +} + +#[test] +fn should_distribute_with_many_rewards() { + // test that reward tally doesn't overflow + run_test(|| { + let mut rng = rand::thread_rng(); + assert_ok!(Reward::deposit_stake(&(), &ALICE, fixed!(9230404))); + assert_ok!(Reward::deposit_stake(&(), &BOB, fixed!(234234444))); + for _ in 0..30 { + // NOTE: this will overflow compute_reward with > u32 + assert_ok!(Reward::distribute_reward(&(), LP, fixed!(rng.gen::() as i128))); + } + let alice_reward = Reward::compute_reward(&(), &ALICE, LP).unwrap(); + assert_ok!(Reward::withdraw_reward(&(), &ALICE, LP), alice_reward); + let bob_reward = Reward::compute_reward(&(), &BOB, LP).unwrap(); + assert_ok!(Reward::withdraw_reward(&(), &BOB, LP), bob_reward); + }) +} + +macro_rules! assert_approx_eq { + ($left:expr, $right:expr, $delta:expr) => { + assert!(if $left > $right { $left - $right } else { $right - $left } <= $delta) + }; +} + +#[test] +fn should_distribute_with_different_rewards() { + run_test(|| { + assert_ok!(Reward::deposit_stake(&(), &ALICE, fixed!(100))); + assert_ok!(Reward::distribute_reward(&(), LP, fixed!(1000))); + assert_ok!(Reward::deposit_stake(&(), &ALICE, fixed!(100))); + assert_ok!(Reward::distribute_reward(&(), PICA, fixed!(1000))); + assert_ok!(Reward::deposit_stake(&(), &ALICE, fixed!(100))); + assert_ok!(Reward::distribute_reward(&(), KSM, fixed!(1000))); + assert_ok!(Reward::deposit_stake(&(), &ALICE, fixed!(100))); + assert_ok!(Reward::distribute_reward(&(), USDT, fixed!(1000))); + + assert_ok!(Reward::withdraw_stake(&(), &ALICE, fixed!(300))); + + assert_approx_eq!(Reward::compute_reward(&(), &ALICE, LP).unwrap(), 1000, 1); + assert_approx_eq!(Reward::compute_reward(&(), &ALICE, PICA).unwrap(), 1000, 1); + assert_approx_eq!(Reward::compute_reward(&(), &ALICE, KSM).unwrap(), 1000, 1); + assert_approx_eq!(Reward::compute_reward(&(), &ALICE, USDT).unwrap(), 1000, 1); + }) +} diff --git a/code/parachain/node/Cargo.toml b/code/parachain/node/Cargo.toml index 530f3a73625..c5d8ce137e2 100644 --- a/code/parachain/node/Cargo.toml +++ b/code/parachain/node/Cargo.toml @@ -39,6 +39,8 @@ staking-rewards-runtime-api = { path = "../frame/staking-rewards/runtime-api" } pallet-transaction-payment-rpc = { path = "../frame/transaction-payment/rpc" } pallet-transaction-payment-rpc-runtime-api = { path = "../frame/transaction-payment/rpc/runtime-api" } +reward-rpc = { path = "../frame/reward/rpc" } + ibc-rpc = { workspace = true } pallet-ibc = { workspace = true } diff --git a/code/parachain/node/src/rpc.rs b/code/parachain/node/src/rpc.rs index b8335046909..3e1b7ed2668 100644 --- a/code/parachain/node/src/rpc.rs +++ b/code/parachain/node/src/rpc.rs @@ -21,8 +21,8 @@ use crate::{ client::{FullBackend, FullClient}, runtime::{ assets::ExtendWithAssetsApi, cosmwasm::ExtendWithCosmwasmApi, - crowdloan_rewards::ExtendWithCrowdloanRewardsApi, ibc::ExtendWithIbcApi, - lending::ExtendWithLendingApi, pablo::ExtendWithPabloApi, + crowdloan_rewards::ExtendWithCrowdloanRewardsApi, farming::ExtendWithFarmingApi, + ibc::ExtendWithIbcApi, lending::ExtendWithLendingApi, pablo::ExtendWithPabloApi, staking_rewards::ExtendWithStakingRewardsApi, BaseHostRuntimeApis, }, }; @@ -67,6 +67,7 @@ where + ExtendWithAssetsApi + ExtendWithCrowdloanRewardsApi + ExtendWithPabloApi + + ExtendWithFarmingApi + ExtendWithLendingApi + ExtendWithCosmwasmApi + ExtendWithIbcApi, @@ -95,6 +96,11 @@ where deps.clone(), )?; + as ProvideRuntimeApi>::Api::extend_with_farming_api( + &mut io, + deps.clone(), + )?; + as ProvideRuntimeApi>::Api::extend_with_lending_api( &mut io, deps.clone(), diff --git a/code/parachain/node/src/runtime.rs b/code/parachain/node/src/runtime.rs index d25739a346e..9929fefaf2d 100644 --- a/code/parachain/node/src/runtime.rs +++ b/code/parachain/node/src/runtime.rs @@ -5,6 +5,7 @@ use cumulus_primitives_core::CollectCollationInfo; use ibc_rpc::{IbcApiServer, IbcRpcHandler}; use pablo_rpc::{Pablo, PabloApiServer}; use pallet_transaction_payment_rpc::TransactionPaymentRuntimeApi; +use reward_rpc::{Reward, RewardApiServer}; use sp_api::{ApiExt, Metadata, StateBackend}; use sp_block_builder::BlockBuilder; use sp_consensus_aura::{sr25519, AuraApi}; @@ -195,6 +196,20 @@ define_trait! { } } + mod farming { + pub trait ExtendWithFarmingApi { + fn extend_with_farming_api(io, deps); + } + + impl for composable_runtime {} + + impl for picasso_runtime { + fn (io, deps) { + io.merge(Reward::new(deps.client).into_rpc()) + } + } + } + mod lending { pub trait ExtendWithLendingApi { fn extend_with_lending_api(io, deps); diff --git a/code/parachain/node/src/service.rs b/code/parachain/node/src/service.rs index 9e537312303..45f1a9b66b8 100644 --- a/code/parachain/node/src/service.rs +++ b/code/parachain/node/src/service.rs @@ -5,8 +5,8 @@ use crate::{ rpc, runtime::{ assets::ExtendWithAssetsApi, cosmwasm::ExtendWithCosmwasmApi, - crowdloan_rewards::ExtendWithCrowdloanRewardsApi, ibc::ExtendWithIbcApi, - lending::ExtendWithLendingApi, pablo::ExtendWithPabloApi, + crowdloan_rewards::ExtendWithCrowdloanRewardsApi, farming::ExtendWithFarmingApi, + ibc::ExtendWithIbcApi, lending::ExtendWithLendingApi, pablo::ExtendWithPabloApi, staking_rewards::ExtendWithStakingRewardsApi, BaseHostRuntimeApis, }, }; @@ -268,6 +268,7 @@ where + ExtendWithAssetsApi + ExtendWithCrowdloanRewardsApi + ExtendWithPabloApi + + ExtendWithFarmingApi + ExtendWithLendingApi + ExtendWithCosmwasmApi + ExtendWithIbcApi, diff --git a/code/parachain/runtime/picasso/Cargo.toml b/code/parachain/runtime/picasso/Cargo.toml index 4e29756414f..865460dc5e3 100644 --- a/code/parachain/runtime/picasso/Cargo.toml +++ b/code/parachain/runtime/picasso/Cargo.toml @@ -84,6 +84,10 @@ transaction-payment-rpc-runtime-api = { package = "pallet-transaction-payment-rp assets-runtime-api = { path = "../../frame/assets/runtime-api", default-features = false } crowdloan-rewards-runtime-api = { path = "../../frame/crowdloan-rewards/runtime-api", default-features = false } pablo-runtime-api = { path = "../../frame/pablo/runtime-api", default-features = false } +reward = { path = "../../frame/reward", default-features = false } +farming = { path = "../../frame/farming", default-features = false } + +reward-rpc-runtime-api = { path = "../../frame/reward/rpc/runtime-api", default-features = false } codec = { package = "parity-scale-codec", version = "3.0.0", default-features = false, features = [ "derive", @@ -129,4 +133,4 @@ fastnet = [] default = ["std"] local-integration-tests = [] runtime-benchmarks = ["balances/runtime-benchmarks", "frame-benchmarking", "frame-support/runtime-benchmarks", "frame-system-benchmarking/runtime-benchmarks", "frame-system/runtime-benchmarks", "balances/runtime-benchmarks", "timestamp/runtime-benchmarks", "collective/runtime-benchmarks", "collator-selection/runtime-benchmarks", "session-benchmarking/runtime-benchmarks", "pallet-xcm/runtime-benchmarks", "sp-runtime/runtime-benchmarks", "xcm-builder/runtime-benchmarks", "indices/runtime-benchmarks", "identity/runtime-benchmarks", "multisig/runtime-benchmarks", "membership/runtime-benchmarks", "treasury/runtime-benchmarks", "scheduler/runtime-benchmarks", "collective/runtime-benchmarks", "democracy/runtime-benchmarks", "utility/runtime-benchmarks", "crowdloan-rewards/runtime-benchmarks", "currency-factory/runtime-benchmarks", "assets/runtime-benchmarks", "assets-registry/runtime-benchmarks", "vesting/runtime-benchmarks", "bonded-finance/runtime-benchmarks", "common/runtime-benchmarks", "asset-tx-payment/runtime-benchmarks", "proxy/runtime-benchmarks", "pablo/runtime-benchmarks", "oracle/runtime-benchmarks", "pallet-ibc/runtime-benchmarks"] -std = ["codec/std", "sp-api/std", "sp-std/std", "sp-core/std", "sp-runtime/std", "sp-version/std", "sp-offchain/std", "sp-session/std", "sp-block-builder/std", "sp-transaction-pool/std", "sp-inherents/std", "frame-support/std", "executive/std", "frame-system/std", "utility/std", "authorship/std", "balances/std", "randomness-collective-flip/std", "timestamp/std", "session/std", "sudo/std", "indices/std", "identity/std", "multisig/std", "call-filter/std", "orml-tokens/std", "orml-traits/std", "treasury/std", "democracy/std", "scheduler/std", "common/std", "primitives/std", "collective/std", "transaction-payment/std", "parachain-info/std", "cumulus-pallet-aura-ext/std", "cumulus-pallet-parachain-system/std", "cumulus-pallet-xcmp-queue/std", "cumulus-pallet-xcm/std", "cumulus-primitives-core/std", "cumulus-primitives-timestamp/std", "cumulus-primitives-utility/std", "collator-selection/std", "xcm/std", "xcm-builder/std", "xcm-executor/std", "aura/std", "sp-consensus-aura/std", "scale-info/std", "orml-xtokens/std", "orml-xcm-support/std", "orml-unknown-tokens/std", "composable-traits/std", "composable-support/std", "governance-registry/std", "currency-factory/std", "assets/std", "assets-transactor-router/std", "assets-registry/std", "vesting/std", "bonded-finance/std", "crowdloan-rewards/std", "preimage/std", "membership/std", "system-rpc-runtime-api/std", "transaction-payment-rpc-runtime-api/std", "assets-runtime-api/std", "crowdloan-rewards-runtime-api/std", "asset-tx-payment/std", "proxy/std", "pablo/std", "oracle/std", "pablo-runtime-api/std", "ibc/std", "pallet-ibc/std", "ibc-primitives/std", "ibc-runtime-api/std"] +std = ["codec/std", "sp-api/std", "sp-std/std", "sp-core/std", "sp-runtime/std", "sp-version/std", "sp-offchain/std", "sp-session/std", "sp-block-builder/std", "sp-transaction-pool/std", "sp-inherents/std", "frame-support/std", "executive/std", "frame-system/std", "utility/std", "authorship/std", "balances/std", "randomness-collective-flip/std", "timestamp/std", "session/std", "sudo/std", "indices/std", "identity/std", "multisig/std", "call-filter/std", "orml-tokens/std", "orml-traits/std", "treasury/std", "democracy/std", "scheduler/std", "common/std", "primitives/std", "collective/std", "transaction-payment/std", "parachain-info/std", "cumulus-pallet-aura-ext/std", "cumulus-pallet-parachain-system/std", "cumulus-pallet-xcmp-queue/std", "cumulus-pallet-xcm/std", "cumulus-primitives-core/std", "cumulus-primitives-timestamp/std", "cumulus-primitives-utility/std", "collator-selection/std", "xcm/std", "xcm-builder/std", "xcm-executor/std", "aura/std", "sp-consensus-aura/std", "scale-info/std", "orml-xtokens/std", "orml-xcm-support/std", "orml-unknown-tokens/std", "composable-traits/std", "composable-support/std", "governance-registry/std", "currency-factory/std", "assets/std", "assets-transactor-router/std", "assets-registry/std", "vesting/std", "bonded-finance/std", "crowdloan-rewards/std", "preimage/std", "membership/std", "system-rpc-runtime-api/std", "transaction-payment-rpc-runtime-api/std", "assets-runtime-api/std", "crowdloan-rewards-runtime-api/std", "asset-tx-payment/std", "proxy/std", "pablo/std", "oracle/std", "pablo-runtime-api/std", "ibc/std", "pallet-ibc/std", "ibc-primitives/std", "ibc-runtime-api/std", "reward/std", "farming/std", "reward-rpc-runtime-api/std"] diff --git a/code/parachain/runtime/picasso/src/lib.rs b/code/parachain/runtime/picasso/src/lib.rs index 6fc67ae6590..444dceeb13b 100644 --- a/code/parachain/runtime/picasso/src/lib.rs +++ b/code/parachain/runtime/picasso/src/lib.rs @@ -13,7 +13,7 @@ #![warn(clippy::unseparated_literal_suffix, clippy::disallowed_types)] #![cfg_attr(not(feature = "std"), no_std)] // `construct_runtime!` does a lot of recursion and requires us to increase the limit to 256. -#![recursion_limit = "256"] +#![recursion_limit = "512"] #![allow(incomplete_features)] // see other usage - #![feature(adt_const_params)] @@ -35,6 +35,7 @@ mod weights; pub mod xcmp; pub use common::xcmp::{MaxInstructions, UnitWeightCost}; pub use fees::{AssetsPaymentHeader, FinalPriceConverter}; +use frame_support::dispatch::DispatchError; use version::{Version, VERSION}; pub use xcmp::XcmConfig; @@ -68,7 +69,7 @@ use sp_runtime::{ AccountIdConversion, AccountIdLookup, BlakeTwo256, Block as BlockT, ConvertInto, Zero, }, transaction_validity::{TransactionSource, TransactionValidity}, - ApplyExtrinsicResult, Either, + ApplyExtrinsicResult, Either, FixedI128, }; use sp_std::{collections::btree_map::BTreeMap, vec::Vec}; @@ -282,6 +283,33 @@ impl assets::Config for Runtime { type CurrencyValidator = ValidateCurrencyId; } +type FarmingRewardsInstance = reward::Instance1; + +impl reward::Config for Runtime { + type RuntimeEvent = RuntimeEvent; + type SignedFixedPoint = FixedI128; + type PoolId = CurrencyId; + type StakeId = AccountId; + type CurrencyId = CurrencyId; +} + +parameter_types! { + pub const RewardPeriod: BlockNumber = 5; //1 minute + pub const FarmingPalletId: PalletId = PalletId(*b"mod/farm"); + pub FarmingAccount: AccountId = FarmingPalletId::get().into_account_truncating(); +} + +impl farming::Config for Runtime { + type RuntimeEvent = RuntimeEvent; + type AssetId = CurrencyId; + type FarmingPalletId = FarmingPalletId; + type TreasuryAccountId = FarmingAccount; + type RewardPeriod = RewardPeriod; + type RewardPools = FarmingRewards; + type MultiCurrency = AssetsTransactorRouter; + type WeightInfo = (); +} + parameter_types! { pub const StakeLock: BlockNumber = 50; pub const StalePrice: BlockNumber = 5; @@ -788,6 +816,8 @@ construct_runtime!( Pablo: pablo = 60, Oracle: oracle = 61, AssetsTransactorRouter: assets_transactor_router = 62, + FarmingRewards: reward:: = 63, + Farming: farming = 64, CallFilter: call_filter = 100, @@ -1115,6 +1145,32 @@ impl_runtime_apis! { } } + impl reward_rpc_runtime_api::RewardApi< + Block, + AccountId, + CurrencyId, + Balance, + BlockNumber, + sp_runtime::FixedU128 + > for Runtime { + fn compute_farming_reward(account_id: AccountId, pool_currency_id: CurrencyId, reward_currency_id: CurrencyId) -> Result, DispatchError> { + let amount = >::compute_reward(&pool_currency_id, &account_id, reward_currency_id)?; + let balance = reward_rpc_runtime_api::BalanceWrapper:: { amount }; + Ok(balance) + } + fn estimate_farming_reward( + account_id: AccountId, + pool_currency_id: CurrencyId, + reward_currency_id: CurrencyId, + ) -> Result, DispatchError> { + >::withdraw_reward(&pool_currency_id, &account_id, reward_currency_id)?; + >::distribute_reward(&pool_currency_id, reward_currency_id, Farming::total_rewards(&pool_currency_id, &reward_currency_id))?; + let amount = >::compute_reward(&pool_currency_id, &account_id, reward_currency_id)?; + let balance = reward_rpc_runtime_api::BalanceWrapper:: { amount }; + Ok(balance) + } + } + #[cfg(feature = "runtime-benchmarks")] impl frame_benchmarking::Benchmark for Runtime {