Reward Streams is a powerful and flexible implementation of the billion-dollar algorithm, a popular method for proportional reward distribution in the Ethereum developer community. This project extends the algorithm's functionality to support both staking and staking-free (based on balance changes tracking) reward distribution, multiple reward tokens, and permissionless registration of reward distribution schemes (reward streams). This makes Reward Streams a versatile tool for incentivizing token staking and holding in a variety of use cases.
.
├── interfaces
│ ├── IBalanceTracker.sol
│ └── IRewardStreams.sol
├── BaseRewardStreams.sol
├── StakingRewardStreams.sol
└── TrackingRewardStreams.sol
The billion-dollar algorithm is a method for efficient incremental calculation of proportional reward distribution. It works by dividing the total reward pool proportionally among participants based on their stake. It does this incrementally, updating the distribution as new stakes are added or existing ones are removed. This allows the algorithm to handle large numbers of participants and high transaction volumes efficiently.
This algorithm was first described in Scalable Reward Distribution on the Ethereum Blockchain by Bogdan Batog, Lucian Boca, and Nick Johnson. It gained popularity in 2019, with the first liquidity mining program being launched by Synthetix for the sETH Uniswap pool. The initial smart contract developed by Anton Bukov has been forked and adapted repeatedly over the years.
Today, the algorithm is used by many projects in the Ethereum ecosystem. For example, Uniswap v3 uses it to track fees earned by individual positions, SushiSwap uses it in its MasterChef smart contract to incentivize liquidity providers, and 1inch has adapted it as part of their ERC20Plugins concept to allow farming without a need to stake tokens.
The billion-dollar algorithm has been a game-changer for reward distribution in the Ethereum ecosystem. However, existing implementations have limitations that can hinder their flexibility and utility. For instance, most implementations do not support reward distribution based on balance changes tracking and do not allow to join multiple farms that provide rewards for the same token.
Reward Streams was developed to address these limitations and provide a more flexible and powerful implementation of the billion-dollar algorithm. Here's what Reward Streams offers:
- A common base contract (
BaseRewardStreams
) that is reused by both staking and balance-tracking mechanisms of rewards distribution. - An easy-to-use mechanism for balance-tracking reward distribution, which requires only a subtle change to the ERC-20 token contract.
- A permissionless mechanism to create a reward stream, enabling anyone to incentivize staking/holding of any token with any reward.
- The ability for users to earn up to 5 different reward tokens simultaneously for staking/holding of a single rewarded token.
- Additive, fixed length epoch-based distribution of rewards where the reward rate may differ from epoch to epoch.
- A mechanism allowing to create a reward stream for up to 25 epochs in one go.
- Protection against reward tokens being lost in case nobody earns them.
Reward Streams operates in two modes of rewards distribution: staking and balance-tracking. Each mode has a separate contract implementation.
The balance-tracking TrackingRewardStreams
implementation inherits from the BaseRewardStreams
contract. It defines the IBalanceTracker.balanceTrackerHook
function, which is required to be called on every transfer of the rewarded token if a user opted in for the hook to be called.
The staking StakingRewardStreams
implementation also inherits from the BaseRewardStreams
contract. It defines two functions: stake
and unstake
, which are used to stake and unstake the rewarded token.
In both modes, each distributor contract defines an EPOCH_DURATION
constant, which is the duration of a single epoch. This duration cannot be less than 1 week and more than 10 weeks.
When registering a new reward stream for the rewarded
token, one must specify the startEpoch
number when the new stream will come into effect. To protect users from obvious mistakes, the distributor contract enforces a soft requirement that ensures the startEpoch
is not more than 5 epochs into the future. Moreover, one must specify the rewardAmounts
array, which instructs the contract how much reward
one wants to distribute in each epoch starting from startEpoch
. The rewardAmounts
array must have a length of at most 25 for one function call.
If rewarded epochs of multiple reward streams overlap, the amounts will be combined and the effective distribution will be the sum of the amounts in the overlapping epochs.
Let's consider a scenario where Alice and Bob want to incentivize ABC
token staking with DEF
reward.
Alice wants to distribute DEF
reward for 3 epochs starting from the next epoch. She specifies the rewardAmounts
array as follows:
rewardAmounts = [1e18, 1e18, 1e18]
. This means Alice wants to distribute 1 DEF
token per epoch for the next 3 epochs.
Bob, on the other hand, wants to incentivize staking of the same ABC
token with the same DEF
reward. He wants to distribute DEF
reward for 5 epochs starting one epoch later than Alice's reward stream. He specifies the rewardAmounts
array as follows:
rewardAmounts = [2e18, 2e18, 2e18, 2e18, 2e18]
. This means Bob wants to distribute 2 DEF
tokens per epoch for the next 5 epochs, starting one epoch after Alice.
Considering that the amounts for the overlapping epochs get added, the effective reward stream will start from the next epoch and will look as follows:
rewardAmounts = [1e18, 3e18, 3e18, 2e18, 2e18, 2e18]
This means that in the first epoch, 1 DEF
token will be distributed. In the second and third epoch, 3 DEF
tokens will be distributed each. In the fourth, fifth, and sixth epoch, 2 DEF
tokens will be distributed each.
For each account and rewarded token, each distributor contract maintains a set of enabled rewards. This feature is designed to prevent users from wasting gas on earning rewards they are not interested in.
Suppose a user is interested in earning the reward
token from the distribution which was permissionlessly set up for stakers/holders of the rewarded
token. The user needs to explicitly express their preference by calling enableReward
function with the reward
token address and the rewarded
token address.
The user can also call disableReward
function to stop earning the reward
token for the rewarded
token. This might be useful in situations where the user determines that the potential rewards do not justify the gas costs of participating in the distribution.
A user may select up to 5 reward tokens to earn for a single rewarded token. This allows users to diversify their potential rewards and increase their potential earnings.
Consider a scenario where the holding of token ABC
is currently being incentivized with two types of rewards: DEF
and GHI
. Alice, a holder of ABC
tokens, is evaluating which rewards to enable.
After examining the distributor parameters, Alice determines that accruing DEF
reward does not make sense for her due to her small balance of ABC
tokens versus the total balance currently earning DEF
. She estimates that enabling DEF
would not even compensate for the increased gas costs on ABC
transfers.
Given this, Alice decides to enable only the GHI
reward and keep the DEF
reward disabled. This decision allows Alice to optimize her potential earnings while minimizing her costs.
Multiple functions of the distributors contain an additional boolean parameter called forfeitRecentReward
/ignoreRecentReward
. It allows a user to optimize gas consumption in case it is not worth to iterate over multiple distribution epochs and updating contract storage. It also allows for "emergency exit" for operations like disabling reward and claiming, and DOS protection (i.e. in liquidation flows).
As previously explained, rewards distributions are epoch-based. Thanks to that, each epoch may have a different reward rate, but also it is possible for the reward streams to be registered permissionlessly in additive manner. However, the downside of this approach is the fact that whenever a user stakes or unstakes (or, for balance-tracking version of the distributor, transfers/mints/burns the rewarded token), the distributor contract needs to iterate over all the epochs since the last time given distribution, defined by rewarded
and reward
token, was updated. Moreover, a user may be earning multiple rewards for a single rewarded token, so the distributor contract needs to iterate over all the epochs since the last update for all the rewards the user is earning. If updates happen rarely (i.e. due to low staking/unstaking activity of the rewarded
token for a given reward
), the gas cost associated with iterating may be significant, affecting user's profitability. Hence, i.e. when disabling or claiming reward, if the user wants to skip the epochs iteration, they can call the relevant function with forfeitRecentReward
/ignoreRecentReward
set to true
. This will, depending on the operation, either grant the rewards earned since the last distribution update, which would normally be earned by the user, to the rest of the distribution participants or ignore them by skiping the iteration, lowering the gas cost for the user.
forfeitRecentReward
parameter may also come handy for the rewarded token contract which calls balanceTrackerHook
on the balance changes. In case of i.e. liquidation, where user may have incentive to perform DOS attack and increase gas cost of the token transfer by enabling multiple rewards for distributions of low activity, the rewarded token contract may call balanceTrackerHook
with forfeitRecentReward
set to true
to lower the gas cost of the transfer. Unfortunately, this may lead to the user losing part of their rewards.
Alice staked her ABC
and decided to enable both DEF
and GHI
rewards. Alice now wants to unstake her ABC
, but notices that despite her estimations GHI
tokens that she earned have very low value. It's been some time since the GHI
distribution was updated last time hence the gas cost associated with unstaking may be significant. Alice may decide to either call unstake
with forfeitRecentReward
set to true
, which means that both DEF
and GHI
rewards that she would earn since the last updates would get lost in favor of the rest of participants. Or she may first call disableReward(GHI)
with forfeitRecentReward
set to true
, which will skip epochs iteration for GHI
distribution, and then call unstake
with forfeitRecentReward
set to false
, keeping all the DEF
rewards.
Unlike other permissioned distributors based on the billion-dollar algorithm, Reward Streams distributors do not have an owner or admin meaning that none of the assets can be directly recovered from them. This property is required in order for the system to work in a permissionless manner, allowing anyone to transfer rewards token to a distributor and register a new reward stream. The drawback of this approach is that reward tokens may get lost if nobody earns them at the given moment (i.e. nobody stakes required assets or nobody enabled earning those rewards). In order to prevent reward tokens from being lost when nobody earns them at the moment, the rewards get virtually accrued by address(0) and, in exchange for updating given distribution data, are claimable by anyone with use of updateReward
function.
- Epoch duration may not be shorter than 1 week and longer than 10 weeks: This limitation is in place to ensure the stability and efficiency of the distribution system. The longer the epoch, the more gas efficient the distribution is.
- Registered reward stream can start at most 5 epochs ahead and can last for a maximum of 25 epochs: This limitation ensures that user inputs are reasonable and helps protect them from making obvious mistakes.
- A user may have at most 5 rewards enabled at a time for a given rewarded token: This limitation is in place to prevent users from enabling an excessive number of rewards, which could lead to increased gas costs and potential system instability.
- During its lifetime, a distributor may distribute at most
type(uint160).max / 2e19
units of a reward token per rewarded token: This limitation is in place not to allow accumulator overflow. - Not all rewarded-reward token pairs may be compatible with the distributor: This limitation may occur due to unfortunate rounding errors during internal calculations, which can result in registered rewards being irrevocably lost. To avoid this, one must ensure that the following condition, based on an empirical formula, holds true:
6e6 * block_time_sec * expected_apr_percent * 10**reward_token_decimals * price_of_rewarded_token / 10**rewarded_token_decimals / price_of_reward_token > 1
For example, for the SHIB-USDC rewarded-reward pair, the above condition will not be met, even with an unusually high assumed APR of 1000%:
block_time_sec = 12
expected_apr_percent = 1000
rewarded_token_decimals = 18
reward_token_decimals = 6
price_of_rewarded_token = 0.00002
price_of_reward_token = 1
6e6 * 12 * 1000 * 10**6 * 0.00002 / 10**18 / 1
is less than 1
.
- If nobody earns rewards at the moment (i.e. nobody staked/deposited yet), they're being virtually accrued by address(0) and may be claimed by anyone: This feature is designed to prevent reward tokens from being lost when nobody earns them at the moment. However, it also means that unclaimed rewards could potentially be claimed by anyone.
- If nobody earns rewards at the moment, despite being virtually accrued by address(0) and claimable by anyone, they might still get lost due to rounding: This limitation may occur due to unfortunate rounding errors during internal calculations, which can result in registered rewards being irrevocably lost. To ensure that the value lost due to rounding is not significant, one must ensure that 1 wei of the reward token multiplied by epoch duration has negligible value.
For example, if the epoch duration is 2 weeks (which corresponds to ~1.2e6 seconds) and the reward token is WBTC, in one rounding, up to ~1.2e6 WBTC may be lost. At the current BTC price, this value corresponds to ~$700, which is a significant value to lose for just one update of the reward stream. Hence, one must either avoid adding rewards that have a significant value of 1 wei or make sure that someone earns rewards at all times.
- Distributor contracts do not have an owner or admin meaning that none of the assets can be directly recovered from them: This feature is required for the system to work in a permissionless manner. However, it also means that if a mistake is made in the distribution of rewards, the assets cannot be directly recovered from the distributor contracts.
- Distributor contracts do not support rebasing and fee-on-transfer tokens: This limitation is in place due to internal accounting system limitations. Neither reward nor rewarded tokens may be rebasing or fee-on-transfer tokens.
- Precision loss may lead to the portion of rewards being lost to the distributor: Precision loss is inherent to calculations in Solidity due to its use of fixed-point arithmetic. In some configurations of the distributor and streams, depending on the accumulator update frequency, a dust portion of the rewards registered might get irrevocably lost to the distributor. However, the amount lost should be negligible as long as the condition from 5. is met.
- Permissionless nature of the distributor may lead to DOS for popular reward-rewarded token pairs: The distributor allows anyone to incentivize any token with any reward. A bad actor may grief the party willing to legitimately incentivize a given reward-rewarded token pair by registering a tiny reward stream long before the party decides to register the reward stream themselves. Such a reward stream, if not updated frequently, may lead to a situation where the legitimate party is forced to update it themselves since the time the bad actor set up their stream. This may be costly in terms of gas.
To install Reward Streams in a Foundry project:
forge install euler-xyz/reward-streams
The Reward Streams repository comes with a comprehensive set of tests written in Solidity, which can be executed using Foundry.
To install Foundry:
curl -L https://foundry.paradigm.xyz | bash
This will download foundryup. To start Foundry, run:
foundryup
To clone the repo:
git clone https://github.com/euler-xyz/reward-streams.git && cd reward-streams
To run the tests in a default
mode:
forge test
forge coverage
This project has been set up with a suite of tests that check for specific invariants for the reward streams, implemented by vnmrtz.eth. These tests are located in the test/invariants
directory. They are written in Solidity and are designed to be run with the echidna fuzzing tool.
Installation and usage of these tools is outside the scope of this README, but you can find more information in the respective repositories:
To run invariant tests with Echidna:
./test/scripts/echidna.sh
To run assert tests with Echidna:
./test/scripts/echidna-assert.sh
This software is experimental and is provided "as is" and "as available".
No warranties are provided and no liability will be accepted for any loss incurred through the use of this codebase.
Always include thorough tests when using the Reward Streams to ensure it interacts correctly with your code.
The code is currently in an experimental phase. Feedback or ideas for improving the Reward Streams are appreciated. Contributions are welcome from anyone interested in conducting security research, writing more tests including formal verification, improving readability and documentation, optimizing, simplifying, or developing integrations.
(c) 2024 Euler Labs Ltd.
Licensed under the GPL-2.0-or-later license.