# Technical Interview: Centrifuge <> William [toc] ## High level description * Based on extension to paper about scalable block rewards under assumption of constant stake * Extension solved for changing stakes for a single currency * Implementation scales by having multiple currencies * Base idea: Pull based approach which enables accounting reward for one currency in O(1) instead of iterating over all stakers * By keeping track of stake changes, can derive rewards for epoch from current stake and sum of rates ## Liquidity Rewards requirements * Complexity: O(1) computation for rewarding a group (sacrifice space to have better time complexity) * Still `O(|Groups * MaxCurrenciesPerGroup|)` by iterating over Groups on epoch change * `StakeAccount --> Currency --> Group --> Rate` * Different reward rates for different groups * Stakes can be moved from one group to another * Min duration to claim reward = 1 epoch * deferred + liquidity rewards = 2 epochs * Min duration to unlock = 0 * block rewards: > "The money can not be unstaked" ## Code hierarchy and structure Copy pasted from spec: ```plantuml @startuml skinparam component { BackgroundColor<<utility>> PaleGoldenRod BackgroundColor<<with extrinsics>> SkyBlue } skinparam storage { BackgroundColor GoldenRod } skinparam rectangle { BackgroundColor Orange } storage "Common types/functions" as common rectangle "Rewards trait (interface)" as rewards_i [pallet-rewards] <<utility>> as rewards [pallet-deferred-rewards] <<utility>> as deferred_rewards [pallet-liquidity-rewards] <<with extrinsics>> as liquidity [pallet-block-rewards] <<with extrinsics>> as block rewards ..> rewards_i : implements deferred_rewards ..> rewards_i : implements rewards_i <-- liquidity : use rewards_i <-- block : use rewards -up-> common : use deferred_rewards -up-> common : use @enduml ``` ## Audit/review ### Runtime API * Reward calculaion should be exposed via runtime API (not as extrinsic!) * Best practice for claiming multiple currencies would be to handle via runtime API and batch call (see [my comment on PR #944](https://github.com/centrifuge/centrifuge-chain/pull/944#discussion_r999014384)): 1. Do runtime API call to get vec of staked currencies 2. Do batch call for claiming currencies ### Loopholes? * `pallet-deferred-rewards` substitution to get rid of storing last epoch's rpt seems incorrect, see the bottom of [S15 Appendix A](https://docs.google.com/presentation/d/1EwEcsXVc5HLyULQehz1L-0XMOf7KGuvQWAYVgKcS2Qk/edit#slide=id.g1613d09df7c_0_816): ```diff - rewarded_amount * rpt == rewarded_amount * rpt-1 - rewarded_amount * rate + rewarded_amount * rpt == rewarded_amount * rpt-1 + rewarded_amount * rate ``` $$ \begin{align} rpt_n & = \sum_{k=1}^{n}\frac{reward_k}{T_k} = rpt_{n-1} + rate \\ & \stackrel{\text{slide 15}}{\ne} rpt_{n-1} - rate \\ \Leftrightarrow rpt_{n-1} & = rpt_n - rate \\ \\ \Rightarrow rewarded\_amount * rpt_{n-1}& = rewarded\_amount * ( rpt_n + rate) \end{align} $$ * Also sign of `restitution[i]` in `claim` call by implication (see [Appendix A S16](https://docs.google.com/presentation/d/1EwEcsXVc5HLyULQehz1L-0XMOf7KGuvQWAYVgKcS2Qk/edit#slide=id.g1613d09df7c_0_861)): ```diff - Base::claim_reward(i) - rewarded_stake[i] * rate + restitution[i] + Base::claim_reward(i) - rewarded_stake[i] * rate - restitution[i] ``` ### Future * Maximum number of groups for `pallet-liquidity-rewards` and `pallet-block-rewards` critical until WeightsV2, theoretically could exceed PoV size by iterating over BoundedVec in inherent during epoch change (but practically not) * However, Collator size seems to be planned to stay 16 * Liquidity pools should also stay reasonably small ## How to incentivize longer staking periods? _Disclaimer: Impracticable, only makes sense in [V2](#V2). However, the majority of the concept of V1 still applies. Did not have time to combine V1 and V2._ _The rewards shall not be paid from the same pot as the epoch rewards. I recommend using the Treasury. However, minting additional rewards into another pot is also an option, but this increases inflation._ ### V1 * Add new field `last_claimed` to `StakeAccount` to account for the last epoch in which a reward was claimed ```rust struct StakeAccount { stake: Balance, reward_tally: SignedBalance, currency_version: u32, // new, init with 0 last_claimed: u32 } ``` * When claiming a reward, the difference of `current_epoch` and `last_claimed` determines an additional reward rate for longterm holding * On success (e.g. not zero), `reward * longterm_rate` will be **awarded from a different pot (e.g. Treasury)** to the account which receives rewards in `claim_reward` call * For every claim, bump `last_claimed` to current epoch * Add storage item `LongtermRewards`: ```rust struct LongtermReward { rate: Rate, threshold: u32, } type LongtermRewards = Map<CurrencyId, LongtermReward> ``` ```rust // Add to `Reward` trait and StakeAccount // Internally called by `claim_reward` // // Iff non-zero rate, `claim_reward` will mint additional // `reward * longterm_rate` tokens for AccountId from Treasury fn compute_longterm_reward_rate(currency_id: currency_id, num_epochs: u32) -> Rate { let longterm_reward = LongtermRewards::<T>::get(currency_id).ok_or(Error::<T>::CurrenyWithoutLongtermReward)?; if num_epochs > longterm_reward.threshold { longterm_reward.rate } else { Rate::zero() } } ``` ```rust fn claim_reward(account_id: AccountId, currency_id: CurrencyId) -> DispatchResult { // ... // Inside StakeAccount::<T>::mutate ... let longterm_reward: Self::Balance = compute_longterm_reward_rate(currency_id, staker.last_claimed) * reward; let treasury = T::LongtermPot::get().into_account_truncating(); if longterm_reward.is_zero() && T::Currency::free_balance(treasury) > longterm_reward { T::Currency::withdraw( treasury, longterm_reward, WithdrawReasons::TRANSFER, ExistenceRequirement::KeepAlive, )?; } staker.last_claimed = current_epoch; // ... Ok(()) } ``` * Expose set functions in trait `Rewards` which require admin origin in `pallet-liquidity-rewards` and `pallet-block-rewards` ```rust fn set_longterm_threshold(currency_id: CurrencyId, threshold: u32); fn set_longterm_rate(currency_id: CurrencyId, rate: Rate); ``` **Q: Period length and reward rate = global setting in `pallet-rewards` or associated to specific Currency/Group?** * If group: Potential loss/gain when moving Currency to new group * If currency: Inconsistent with reward rates set for groups * If global: Inflexible ### Downsides of Proposal: * Users are disincentivized to call `claim_rewards` before reaching longterm threshold to not miss out on longterm rewards * However: Longterm rewards could be regarded as opportunity cost of missing out of liquidating/efficiently using earlier received rewards ### V2 * Add a second threshold of type `Balance` to `LongtermReward` * Only reset `last_claimed` (now moved to a new DoubleStorageMap, see below) if any of the following happens: * User claimed a longterm reward or * User staked below the threshold in `withdraw_stake` or * User claimed with current stake below threshold * Store sum of claimed rewards which will be multiplied with the longterm rate upon claiming longterm rewards * This is reset, when the last epoch counter for the user is reset ```rust struct StakeAccount { stake: Balance, reward_tally: SignedBalance, currency_version: u32, // new, init with 0 longterm_reward_sum: Balance, } ``` ```rust struct LongtermReward { // Reward rate rate: Rate, // Minimum required number of epochs in which user must hold min_stake min_duration: u32, // Minimum required stake which needs to be hold over min_duration min_stake: Balance, } ``` Instead of polluting `StakeAccount` with `last_claimed` epoch, add a new `DoubleStorageMap` to store `last_claimed` of V1, e.g. the last epoch in which the longterm reward was claimed or the stake fell below the `min_stake` threshold. ```rust type StakeAccountsEpoch = DoubleMap<AccountId, CurrencyId, u32>; ``` #### Downsides * Have to educate and warn users about risks of staking below threshold * When `LongtermReward.min_stake`... * Either have to accept some accounts might be below threshold or * Have to migrate `StakeAccounts` (no-go!) ____ ## Appendix ### WIP: Prove deferred potential incorrect math for Substitution in Appendix (restitution) * Assume we are in epoch `n` and have a user who first claims claims rewards, then stakes amount X, then unstakes amount Y (!= X) in the same epoch such that `rewarded` in `unstake` is not zero * In the the next epoch `n+1`, stake is untouched and claim is called again * Same for the following epoch `n+2` * Let's compare the reward and tally calculations of Slide 15 with Slide 16 ### $$ \begin{align} rpt_n & = \sum_{k=1}^{n}\frac{reward_k}{T_k} = rpt_{n-1} + rate \\ & \stackrel{\text{slide 15}}{\ne} rpt_{n-1} - rate \\ \Leftrightarrow rpt_{n-1} & = rpt_n - rate \tag{1} \\ \Rightarrow rewarded\_amount * rpt_{n-1}& = rewarded\_amount * ( rpt_n + rate) \end{align} $$ ### V1: Using "old rate" from [S15 Appendix A](https://docs.google.com/presentation/d/1EwEcsXVc5HLyULQehz1L-0XMOf7KGuvQWAYVgKcS2Qk/edit#slide=id.g1613d09df7c_0_816) TODO: Why does this resolve to `-rewarded * rate`? $$ \begin{align} tally[i] & = stake[i] * rpt - rewarded\_stake[i] * rate + rewarded\_amount * rate \\ reward[i] & = stake[i] * rpt - tally[i] - rewarded\_stake[i] * rate \\ & = stake[i] * rpt - stake[i] * rpt + rewarded\_stake[i] * rate - rewarded\_amount * rate \\ & - rewarded\_stake[i] * rate \\ & = - rewarded * rate \tag{V1} \end{align} $$ ### V2: Using restitution from [S16 Appendix A](https://docs.google.com/presentation/d/1EwEcsXVc5HLyULQehz1L-0XMOf7KGuvQWAYVgKcS2Qk/edit#slide=id.g1613d09df7c_0_861) $$ \begin{align} tally[i] & = stake[i] * rpt \\ restitution[i] & = -rewarded\_stake[i] * rate + rewarded\_amount * rate \\ reward[i] & = stake[i] * rpt - tally[i] - rewarded\_stake[i] * rate + restitution[i] \\ & = stake[i] * rpt - stake[i] * rpt - rewarded\_stake[i] * rate \\ & - rewarded\_stake[i] * rate + rewarded\_amount * rate \\ & = -2 * rewared\_stake[i] + rewarded\_amount \\ & \stackrel{V1}{\neq} - rewarded * rate \\ \\ \Rightarrow reward[i] & \stackrel{!}{=} stake[i] * rpt - tally[i] - rewarded\_stake[i] * rate \color{red}{-} restitution[i] \end{align} $$