diff --git a/substrate/frame/asset-rewards/src/lib.rs b/substrate/frame/asset-rewards/src/lib.rs index 35e1eaa58f3ace1f6aed9dcb02e6111e020bc5de..50ce231bc41204362b11fd262a59a042dcb862a8 100644 --- a/substrate/frame/asset-rewards/src/lib.rs +++ b/substrate/frame/asset-rewards/src/lib.rs @@ -23,18 +23,23 @@ //! //! Governance can create a new incentive program for a fungible asset by creating a new pool. //! -//! When creating the pool, governance specifies a 'staking asset', 'reward asset', and 'reward rate -//! per block'. +//! When creating the pool, governance specifies a 'staking asset', 'reward asset', 'reward rate +//! per block', and an 'expiry block'. //! -//! Once the pool is created, holders of the 'staking asset' can stake them in this pallet (creating -//! a new Freeze). Once staked, the staker begins accumulating the right to claim the 'reward asset' -//! each block, proportional to their share of the total staked tokens in the pool. +//! Once the pool is created, holders of the 'staking asset' can stake them in this pallet, which +//! puts a Freeze on the asset. +//! +//! Once staked, the staker begins accumulating the right to claim the 'reward asset' each block, +//! proportional to their share of the total staked tokens in the pool. //! //! Reward assets pending distribution are held in an account derived from the pallet ID and a //! unique pool ID. //! //! Care should be taken to keep pool accounts adequately funded with the reward asset. //! +//! The pool administator can adjust the reward rate per block, the expiry block, and the admin +//! after the pool is created. +//! //! ## Permissioning //! //! Currently, pool creation and management is permissioned and restricted to a configured Origin. @@ -50,14 +55,23 @@ //! pallet Call method, which while slightly more verbose, makes it much easier to understand the //! code and reason about where side-effects occur in the pallet. //! -//! ## Implementation Notes +//! ## Rewards Algorithm //! -//! The implementation is based on the [AccumulatedRewardsPerShare](https://dev.to/heymarkkop/understanding-sushiswaps-masterchef-staking-rewards-1m6f) algorithm. +//! The rewards algorithm is based on the Synthetix [StakingRewards.sol](https://github.com/Synthetixio/synthetix/blob/develop/contracts/StakingRewards.sol) +//! smart contract. //! -//! Rewards are calculated JIT (just-in-time), when a staker claims their rewards. +//! Rewards are calculated JIT (just-in-time), and all operations are O(1) making the approach +//! scalable to many pools and stakers. //! -//! All operations are O(1), allowing the approach to scale to an arbitrary amount of pools and -//! stakers. +//! The approach is widly used across the Ethereum ecosystem, there is also quite battle tested. +//! +//! ### Resources +//! +//! - [This YouTube video series](https://www.youtube.com/watch?v=6ZO5aYg1GI8), which walks through +//! the math of the algorithm. +//! - [This dev.to article](https://dev.to/heymarkkop/understanding-sushiswaps-masterchef-staking-rewards-1m6f), +//! which explains the algorithm of the SushiSwap MasterChef staking. While not identical to the +//! Synthetix approach, they are very similar. #![deny(missing_docs)] #![cfg_attr(not(feature = "std"), no_std)] @@ -572,6 +586,26 @@ pub mod pallet { )?; Ok(()) } + + /// Permissioned method to withdraw reward tokens from a pool. + pub fn withdraw_reward_tokens( + origin: OriginFor<T>, + pool_id: PoolId, + amount: T::Balance, + ) -> DispatchResult { + let caller = ensure_signed(origin)?; + let pool_info = Pools::<T>::get(pool_id).ok_or(Error::<T>::NonExistentPool)?; + ensure!(pool_info.admin == caller, BadOrigin); + T::Assets::transfer( + pool_info.reward_asset_id, + &caller, + &Self::pool_account_id(&pool_id)?, + amount, + Preservation::Preserve, + )?; + + Ok(()) + } } impl<T: Config> Pallet<T> { diff --git a/substrate/frame/asset-rewards/src/tests.rs b/substrate/frame/asset-rewards/src/tests.rs index 8de62643b59cbb331acc7fb404b6c76c17ef1600..7988409620220581b8f52eafecad8dfa1278d6ea 100644 --- a/substrate/frame/asset-rewards/src/tests.rs +++ b/substrate/frame/asset-rewards/src/tests.rs @@ -921,6 +921,127 @@ mod deposit_reward_tokens { } } +mod withdraw_reward_tokens { + use super::*; + + #[test] + fn success() { + new_test_ext().execute_with(|| { + let admin = 1; + let pool_id = 0; + let reward_asset_id = NativeOrWithId::<u32>::Native; + let initial_deposit = 10; + let withdraw_amount = 5; + create_default_pool(); + let pool_account_id = StakingRewards::pool_account_id(&pool_id).unwrap(); + + let admin_balance_before = + <<MockRuntime as Config>::Assets>::balance(reward_asset_id.clone(), &admin); + let pool_balance_before = <<MockRuntime as Config>::Assets>::balance( + reward_asset_id.clone(), + &pool_account_id, + ); + + // Deposit initial reward tokens + assert_ok!(StakingRewards::deposit_reward_tokens( + RuntimeOrigin::signed(admin), + pool_id, + initial_deposit + )); + + // Withdraw some tokens + assert_ok!(StakingRewards::withdraw_reward_tokens( + RuntimeOrigin::signed(admin), + pool_id, + withdraw_amount + )); + + let admin_balance_after = + <<MockRuntime as Config>::Assets>::balance(reward_asset_id.clone(), &admin); + let pool_balance_after = + <<MockRuntime as Config>::Assets>::balance(reward_asset_id, &pool_account_id); + + assert_eq!( + <<MockRuntime as Config>::Assets>::balance( + reward_asset_id.clone(), + &pool_account_id + ), + initial_deposit - withdraw_amount + ); + assert_eq!( + <<MockRuntime as Config>::Assets>::balance(reward_asset_id, &admin), + withdraw_amount + ); + }); + } + + // #[test] + // fn fails_for_non_existent_pool() { + // new_test_ext().execute_with(|| { + // let admin = 1; + // let non_existent_pool_id = 999; + // let withdraw_amount = 5000; + // + // assert_err!( + // StakingRewards::withdraw_reward_tokens( + // RuntimeOrigin::signed(admin), + // non_existent_pool_id, + // withdraw_amount + // ), + // Error::<MockRuntime>::NonExistentPool + // ); + // }); + // } + // + // #[test] + // fn fails_for_non_admin() { + // new_test_ext().execute_with(|| { + // let non_admin = 2; + // let pool_id = 0; + // let withdraw_amount = 5000; + // create_default_pool(); + // + // assert_err!( + // StakingRewards::withdraw_reward_tokens( + // RuntimeOrigin::signed(non_admin), + // pool_id, + // withdraw_amount + // ), + // BadOrigin + // ); + // }); + // } + // + // #[test] + // fn fails_for_insufficient_pool_balance() { + // new_test_ext().execute_with(|| { + // let admin = 1; + // let pool_id = 0; + // let reward_asset_id = NativeOrWithId::<u32>::Native; + // let initial_deposit = 10000; + // let withdraw_amount = 15000; + // create_default_pool(); + // + // // Deposit initial reward tokens + // let pool_account = StakingRewards::pool_account_id(&pool_id).unwrap(); + // <<MockRuntime as Config>::Assets>::set_balance( + // reward_asset_id, + // &pool_account, + // initial_deposit, + // ); + // + // assert_err!( + // StakingRewards::withdraw_reward_tokens( + // RuntimeOrigin::signed(admin), + // pool_id, + // withdraw_amount + // ), + // assets::Error::<MockRuntime>::BalanceLow + // ); + // }); + // } +} + /// This integration test /// 1. Considers 2 stakers each staking and unstaking at different intervals, asserts their /// claimable rewards are adjusted as expected, and that harvesting works.