Closed yondonfu closed 1 year ago
Let's say an O does not call reward during an eligible rewardRound. What would happen to the LPT that had been previously minted during the initializeRound txn, that would have been allocated to this O? It just sits in the minter lost forever?
Let's say an O does not call reward during an eligible rewardRound. What would happen to the LPT that had been previously minted during the initializeRound txn, that would have been allocated to this O? It just sits in the minter lost forever?
Yes.
The current approach in the LIP is the easiest way to accumulate rewards during the reward period based on the per round inflation rate that I can think of at the moment. But, I also realized that the current approach results in the total LPT supply increasing each round without also increasing the total active staked LPT. So, if no additional LPT is staked by delegators, then the participation rate would be decreasing in each round of a reward period.
Will have to think a bit more about whether there is a reasonable alternative to avoid these outcomes.
Without opening a can of worms into "what any unclaimed LPT should be used for", it might make sense simply to add a mechanism to this LIP to appropriately account for permanently unclaimed LPT. Then in a future LIP, the community could access that unclaimed LPT for a specific purpose (like dev fund, or burn, etc).
Maybe the way to account for it is to enable a transaction that simply updates an accounting variable with the unclaimed LPT amount for a previous round. Or moves the LPT into another upgradeable contract and debits it from the Minter.
After looking through the spec and the draft implementation I'm not entirely sure still how stake updates are handled within a single rewardPeriod
.
An orchestrator's stake could still increase within a given rewardPeriod
and currently we do not account that rewards before that update should not be calculated according to the new updated stake because we only do the calculation at the end.
For example, let's assume 1000 LPT is minted each round
current reward period is round N up until round (and not including) N + 7
Up until (and including) round N + 2, 3000 LPT is minted and the orchestrator's network ownership is 10%
For these rounds the orchestrator is owed3000 LPT * 0.10 = 300 LPT
in rewards
In round N+2 the orchestrator receives a stake update, which will become effective in round N+3, that updates its network ownership to 20%.
This sets the orchestrator's lastActiveStakeUpdateRound = N+3
and transcoder.EarningsPoolForRound[N+3].totalStake = 20% of all stake
4000 LPT * 0.20 = 800 LPT
for this round => The total rewards for the orchestrator for the rewardPeriod
is 1100 LPT
But the current state of the implementation only considers the end stake after the update, so it will use a network ownership factor of 20% even for the rounds it was only 10% resulting in a discrepancy of the rewards calculated.
// rewardTokens = (minted tokens for the reward period * active transcoder stake) / total active stake
rewardTokens = MathUtils.percOf(roundsManager().mintedInRewardPeriod(), earningsPool.totalStake, currentRoundTotalActiveStake);
Or 7000 LPT * 0.20 = 1400 LPT
Or am I missing something here?
Perhaps it could be a good idea to describe the potential user outcomes in the test scenario's in plain english, some examples:
If a user calls reward()
for a round for which roundsManager.currentRoundIsRewardRound() == false
, the call should revert.
If a user fails to call reward() for the rewardPeriod (1)
in the nextRewardRound.sub(1)
for which roundsManager.currentRoundIsRewardRound() == true
but succesfully calls reward in the rewardRound
for rewardPeriod (2)
, it should only receive rewards for rewardPeriod (2)
If a user successfully calls reward()
for the rewardPeriod
it should receive insert formula here = X LPT for the rewardPeriod
So, if no additional LPT is staked by delegators, then the participation rate would be decreasing in each round of a reward period.
One solution I think is to assume that all LPT minted during the rewardPeriod
is stake that will participate (IIUC this will keep the participation rate as is) , then we can keep track how much LPT is unclaimed for during the actual reward Round and upon the next round initialisation do the actual accounting ?
What exactly should happen to the "leftover" LPT other than burning the leftover upon the next round init is , in my opinion, out of scope for this LIP. Burning would most closely resemble the current contract mechanics.
burning the leftover upon the next round init
Yah, I agree that the scope of what could happen is huge and probably shouldn't be discussed within this LIP. Good idea to handle the accounting (or burning) in the next initRound txn, which would mean that there isn't a growing pool of unaccounted for LPT.
A bit late, but I mentioned during the last LIP call that I would share my design for LIP-56 publicly so here it is.
This design also removes the inflation logic out of the Minter
into a separate InflationManager
contract, perhaps for clarity it might be better to move this into a separate LIP as a package deal.
Edit: Looks like github markdown doesn't parse some of the equations correctly, so here's the hackmd file for reference: https://hackmd.io/GxpinLeJSeOo6xX3bCJJkQ
This proposal introduces a reward period which can reduce the frequency of reward calls (reward distribution transactions) and the overall costs incurred by orchestrators that are responsible for calling reward.
Gas prices on Ethereum have been fairly high due to increased demand for blockspace. In recent months the transaction cost of calling reward daily in order to receive inflationary LPT rewards often exceeds or is only marginally lower than the value received for many Orchestrators on the network.
To reduce the cost burden we could change the reward algorithm to require less frequent reward calls in tradeoff for a slightly higher single transaction cost.
E.g. Let's say a reward call under current conditions (per round basis) costs 100$. Introducing a reward period would increase the cost to 150$ for the call, but the frequency of calls reduces by 7x.
This still results in a cost saving of several magnitudes, in this example a 4.6x reduction:
100$ * 7 / 150$ = 4.6
Rewards are minted for each rewardPeriod
rather than each round. Transcoders can call reward to claim their earnings for a rewardPeriod N
during the entirety of rewardPeriod N + 1
. The new frequency at which reward
needs to be called will be equal to rewardPeriodlength
. If a transcoder joins or leaves the active transcoder set during a rewardPeriod
it will still be eliglbe to claim rewards for the rounds it was actually active for during the rewardPeriod
.
The InflationManager
is a new contract responsible for inflation management, logic which is now part of the Minter
contract.
This separation of concerns allows for a more flexible upgrade path for inflation related parameters and logic in the future. Currently the Minter
acts as both a "Bank" for user funds and inflation manager. Any upgrade to the Minter
contract requires a migration of these user funds, thus ideally the Minter
contract should change as infrequent as possible.
Parameter | Type | Description |
---|---|---|
inflation | uint256 |
Per round inflation rate |
targetBondingRate | uint256 |
Target bonding rate as a percentage of total bonded tokens / total token supply |
inflationChange | uint256 |
Change in inflation rate per round until the target bonding rate is achieved |
rewardTokensForRound | mapping(uint256=>uint256) |
Mapping holding the current total mintable reward tokens for a round |
mintedInRewardPeriod | uint256 |
Track how many tokens are already minted in the current eligible reward period |
tokensForRewardPeriod | uint256 |
Track how many tokens are available for the eligible reward period (sum of rewardTokensForRound for a reward period) |
Minter
)function inflation() external view returns (uint256);
Get the current inflation rate. This is a percentage scaled by factor 1 000 000 000
.
Minter
)function targetBondingRate() external view returns (uint256);
Get the current target bonding rate. This is a percentage scaled by factor 1 000 000 000
.
i.e. 50% = 0.5 * 1 000 000 000 = 500 000 000
Minter
)function inflationChange() external view returns (uint256);
Get the current inflation change. This is a percentage scaled by factor 1 000 000 000
.
Minter
) function setTargetBondingRate(uint256 _targetBondingRate) external onlyControllerOwner;
Set the current target bonding rate. This is a percentage scaled by factor 1 000 000 000
.
Only callable by the owner
of Controller
.
Minter
)function setInflationChange(uint256 _inflationChange) external onlyControllerOwner;
Set the current inflation change. This is a percentage scaled by factor 1 000 000 000
.
Only callable by the owner
of Controller
.
function setRewardTokensForRound(uint256 _round, uint245 _amount) external onlyRoundsManager
Sets the amount of reward tokens that are mintable for a particular round in the rewardTokensForRound
mapping, this will likely always be currentRound
when this function called upon round initialization.
Only callable by the RoundsManager
.
The amount of tokens that is mintable for _round
is calculated as follows:
$r = {totalSupply}\times inflation$
uint256 rewardTokens = MathUtils.percOf(
livepeerToken().totalSupply(),
inflation
);
function rewardTokensForRound(uint256 _round) external view returns(uint256);
Gets the amount of reward tokens that are mintable for a given round.
function mintedInRewardPeriod() public view returns (uint256);
Returns the current amount total minted tokens for the current eligiblerewardPeriod
(defined as the last ended rewardPeriod
).
Called by BondingManager
to see if the amount of total rewards for the rewardPeriod
so far doesn't exceed the minimum set forth during round initialization.
uint256 currentMintedtokens = currentMintedTokens.add(rewardAmount);
require(
currentMintedTokens <= rewardTokensForPeriod,
"minted tokens cannot exceed mintable tokens"
);
mintedInRewardPeriod
and tokensForRewardPeriod
will be reset when a new reward period starts.
setMintedInRewardPeriod(uint256 _amount)
Sets the tokens minted so far in the current eligible reward period. Resets when a new reward period starts.
Can only be called by the BondingManager
and RoundsManager
.
If _amount
is 0, reset mintedInRewardPeriod
and tokensForRewardPeriod
, as this indicates a new reward period starts.
The minter will have a simplified API and is no longer concerned with inflation calculations. It will solely be responsible for minting, holding and sending ETH and LPT.
Migrates the current Minter
to a new deployment. Internally transfers all balances to the new contract.
Only callable by the owner
of Controller
function migrateToNewMinter(IMinter _newMinter) external onlyControllerOwner whenSystemPaused;
function trustedTransferTokens(address _to, uint256 _amount) external onlyBondingManager whenSystemNotPaused;
Transfers _amount
Livepeer Token from the Minter
to the recipient _to
.
Only callable by the BondingManager
when the system is not paused.
function trustedTransferTokens(address _to, uint256 _amount) external onlyBondingManager whenSystemNotPaused;
Burns _amount
Livepeer Token.
Only callable by the BondingManager
when the system is not paused.
function trustedWithdrawETH(address payable _to, uint256 _amount) external onlyBondingManagerOrJobsManager whenSystemNotPaused;
Transfers _amount
ETH from the Minter
to the recipient _to
.
Only callable by the BondingManager
or TicketBroker
when the system is not paused.
function depositETH() external payable onlyMinterOrJobsManager returns (bool)
function createReward(uint256 _rewardTokens) external onlyBondingManager whenSystemNotPaused
Mints _rewardTokens
where _rewardTokens
is the total amount of mintable tokens for a transcoder for a given rewardPeriod
, a rewardPeriod
can consist of multiple rounds.
Calculating the number of _rewardTokens
is now a responsability of the BondingManager
instead.
Following state variables are added/changed for the BondingManager
Parameter | Type | Description |
---|---|---|
currentRoundTotalActiveStake | uint256 |
DEPRECATED |
nextRoundTotalActiveStake | uint256 |
DEPRECATED |
activeStakeForRound | mapping(uint256=>uint256) |
Mapping keeping track of the total amount of active stake for a round |
Deprecated in favor of activeStakeForRound()
and setActiveStakeForRound()
Deprecated in favor of activeStakeForRound()
and setActiveStakeForRound()
Retrieves the active stake for a round. This can be currentRound
or a round in the past.
replaces BondingManager.currentRoundActiveStake
and BondingManager.nextRoundTotalAtiveStake
function activeStakeForRound(uint256 _round) public returns (uint256)
Sets or updates the active stake for a round. In as good as every case this will be for currentRound + 1
.
If activeStakeForRound(currentRound + 1) == 0
, use the activeStakeForRound(currentRound)
as a starting value to add to or substract from.
function setActiveStakeForRound(uint256 _round, uint256 _amount) external onlyRoundsManager
rewardPeriod
Reward period eligble for reward:
{
roundsManager().previousRewardPeriodStart()
..
roundsManager().currentRewardPeriodStart() - 1
}
require(
transcoder.lastRewardRound < roundsmanager().previousRewardPeriodStart(),
"Already called reward in current eligible reward period"
);
rewardPeriod
, revert if this is not the case.
uint256 previousRewardPeriodStart = roundsmanager().previousRewardPeriodStart()
require(
transcoder.deactivationRound >= previousRewardPeriodStart &&
transcoder.activationRound < previousRewardPeriodStart.add(roundsManager().rewardPeriodLength()),
"transcoder inactive during reward period"
);`
deactivationRound
will be 0
deactivationRound
will be set to currentRound + 1
deactivationRound
will be 2^256 - 1
Get the start and end rounds for the caller; this will be the range defined in step (1), unless the trancoder activated/deactivated within the current eligible rewardPeriod
uint256 startRound = roundsManager().previousRewardPeriodStart();
if (transcoder.activationRound >= startRound) {
startRound = transcoder.activationRound;
}
uint256 endRound = roundsManager().currentRewardPeriodStart() - 1;
if (transcoder.deactivationRound <= endRound) {
endRound = transcoder.deactivationRound - 1;
}
Calculate the amount of rewards to be minted for {startRound..endRound}
as defined in step (3)
$rewardTokens=\sum_{i=start}^{end} ri = r{start} + r{start+1}... + r{end-1} + r_{end}$
$r_i = {rewardTokensForRound_i}\times \frac{transcoderStakeForRound_i}{activeStakeForRound_i}$
if transcoder.lastActiveStakeUpdateRound <= startRound
then transcoder.earningsPoolForRound[i].totalStake
will be transcoder.earningsPoolForRound[transcoder.lastActiveStakeUpdateRound].totalStake
instead
If transcoder received a stake update during the rewardPeriod
we must loop through the earningsPools and retroactively calculate the rewardTokens
owed for each round based on the network ownership.
rewardTokens
and update the minted tokens for the current eligible reward period uint256 currentMintedtokens = inflationManager().mintedInRewardPeriod() + rewardAmount;
require(
currentMintedTokens <= inflationManager().tokensForRewardPeriod(),
"minted tokens cannot exceed mintable tokens"
);
minter().createReward(rewardTokens);
inflationManager().setMintedInRewardPeriod(currentMintedTokens);
updateTranscoderWithRewards
updateTranscoderWithRewards(
msg.sender,
rewardTokens,
currentRound,
_newPosPrev,
_newPosNext
);
This call will update the transcoder's lastActiveStakeUpdateRound
to the start round of the next rewardPeriod
.
Following state variables are added to the RoundsManager
Parameter | Type | Description |
---|---|---|
rewardPeriodLength | uint256 |
The number of rounds in a reward period, the initial value is 7 |
previousRewardPeriodStart | uint256 |
The start round of the previous reward period |
currentRewardPeriodStart | uint256 |
The start round of the current reward period |
function setRewardPeriodlength(uint256 _rewardPeriodLength) external onlyControllerOwner
Set the number of rounds in a reward period. If setRewardPeriodLength is called in the middle of a reward period, the length of the current reward period will be extended or shortened.
_rewardPeriodLength
has to be greater or equal than 1
round.
Only callable by the owner
of Controller
.
The function signature will remain the same but some of the logic external contract calls that are part of this function will change
function initializeRound() external whenSystemNotPaused {
uint256 currRound = currentRound();
// if the current reward period ends, update the state
if (currRound >= currentRewardPeriodStart + rewardPeriodLength) {
previousRewardPeriodStart = currentRewardPeriodStart;
currentRewardPeriodStart = currRound
// reset the amount of minted tokens for the eliglbe reward period
// as we now enter a new eliglble reward period
inflationManager.setMintedInRewardPeriod(0);
}
...
// Set total active stake for the round
bondingManager().setActiveStakeForRound(
currRound,
bondingManager().activeStakeForRound(currRound - 1);
);
// Set mintable rewards for the round
inflationManager().setRewardTokensForRound(currRound)
...
}
In go-livepeer
the rewardService
needs to be updated to only call reward at then end of a current rewardPeriod
.
An initial value of 7 is selected for rewardPeriodLength because it roughtly corresponds to a week which seems to be a a unit of time that is easy to reason about while also providing a sizeable reduction in the effective per round transaction cost for calling reward.
While this specification is slightly more stateful than the current implemented solution, it will still result in a significant gas cost reduction.
Transcoders that were active during the current rewardPeriod
but are not active during the last round of said period (due to being moved outside of the active transcoder set) should still be eligible to claim their rewards.
Rewards should be calculated accurately for all potential stake updates a transcoder received during a rewardPeriod
. This requires recursively calculating the network ownership and relative owed rewards for each round during the rewardPeriod
. A transcoder should not be able to game reward calculation by increasing its stake for the last round of a reward period.
Transcoders are given the window rewardPeriodLength
to claim their rewards from the previous rewardPeriod
. This offers greater flexibility for transcoders. Transcoders who call reward should be wary that this affects rewards calculation for the subsequent period however, as rewards only count towards active stake once reward()
has been called.
The minter is now stateless and only responsible for holding and transferring funds. The current structure would require less frequent upgrades for this contract, which puts user funds at 'ease'.
@kyriediculous thank you for putting this together - with gas prices seemingly growing again, and LPT market value falling, this is becoming more important.
For me, it would be good to see at least something written up for the "Motivation" for this design, to be able to understand whether we are aligned on this.
What are the next steps for this?
Added a motivation and made some corrections to the spec.
Also still have to come up with a proper algorithm for step 4 in the new reward()
logic.
This LIP has just been assigned the Last Call status which kicks of a 10 day Last Call period for any remaining feedback prior to the LIP being assigned the Proposed status.
Summary of a recent discussion with @kyriediculous:
It will be difficult to do the accounting required to distribute rewards to delegators on a round by round basis without also increasing gas costs substantially and the easier thing to do might be to only distribute rewards to delegators that are delegated to an orchestrator for the entirety of a reward period. This results in a less dynamic delegation market because it would mean delegators would change delegations only between reward periods instead of between rounds if they want to earn rewards. This is not desirable in the long term, but could be worth considering as a sacrifice in exchange for lower reward call costs in the short term given that delegation activity isn't expected to be very dynamic until delegation can be implemented on a L2/more scalable layer (which will be a big focus - it will just take longer to achieve which is why this proposal is being considered for the short term). Given the above:
The hope is to get the updated design out soon (here, in Discord, etc.) for feedback during the Last Call period.
Reward calls from the past 90 days
The above chart plots the # of reward calls in each round and the average USD tx cost for a reward call in each round.
Observations:
# of active orchestrators in the past 90 days
The above chart plots the # of active orchestrators in each round.
Observations:
Staking actions from the past 90 days
The above chart plots the # of staking actions [1] and the average USD tx cost for a staking action in each round.
Observations:
[1] Only transactions that emit Bond events are considered. Rebonding and withdrawals were not considered.
Thoughts:
Third iteration of the design : https://hackmd.io/GxpinLeJSeOo6xX3bCJJkQ?view
This proposal aims to seperate the current Minter
contract into two separate contracts.
InflationManager
, responsible for handling all inflation related logicMinter
acts as the owner of the LivepeerToken
and is the only entity allowed to mint tokens. It also holds minted LPT and ETH from fees awaiting to be distributed. Currently whenever inflation related logic has to change the entire Minter
contract needs to be replaced, this involves migrating user funds to the new contract making it a very sensitive operation.
Seperation of concerns between funds management and inflation management allows for upgrades to the inflation logic and parameters without having to migrate user funds to a new contract.
The Minter
contract itself would have to change very infrequently, on top of already being immutable, resulting in increased safety of user funds during protocol upgrades.
The InflationManager
is a new contract responsible for inflation management, logic which is now part of the Minter
contract.
Parameter | Type | Description |
---|---|---|
inflation | uint256 |
Per round inflation rate |
inflationChange | uint256 |
Change in inflation rate per round until the target bonding rate is achieved |
targetBondingRate | uint256 |
Target bonding rate as a percentage of total bonded tokens / total token supply |
currentMintableTokens | uint256 |
Total mintable reward tokens for the current reward period |
currentMintedTokens | uint256 |
Track how many tokens are already minted in the current round |
nextMintableTokens | uint256 |
Total mintable reward tokens for the next reward period |
function inflation() external view returns (uint256);
Get the current inflation rate. This is a percentage scaled by factor 1 000 000 000
.
function inflationChange() external view returns (uint256);
Get the current inflation change. This is a percentage scaled by factor 1 000 000 000
.
function setInflationChange(uint256 _inflationChange) external onlyControllerOwner;
Set the current inflation change. This is a percentage scaled by factor 1 000 000 000
.
Only callable by the owner
of Controller
.
function targetBondingRate() external view returns (uint256);
Get the current target bonding rate. This is a percentage scaled by factor 1 000 000 000
.
i.e. 50% = 0.5 * 1 000 000 000 = 500 000 000
function setTargetBondingRate(uint256 _targetBondingRate) external onlyControllerOwner;
Set the current target bonding rate. This is a percentage scaled by factor 1 000 000 000
.
Only callable by the owner
of Controller
.
function currentMintableTokens() uint256 public
Returns the amount of total mintable tokens for the current active reward period.
function setRewardTokensForRound(uint256 _round, uint245 _amount) external onlyRoundsManager
Sets the amount of reward tokens that are mintable for the current reward period.
Only callable by the RoundsManager
.
The amount of tokens that is mintable for a round is calculated as follows:
$r = {totalSupply}\times inflation$
uint256 rewardTokens = MathUtils.percOf(
livepeerToken().totalSupply(),
inflation
);
function currentMintedTokens() public view returns (uint256);
Returns the current total amount of tokens already minted in the current round.
Called by BondingManager
to see if the amount of total rewards for the round doesn't exceed the minimum set forth during round initialization.
uint256 currentMintedtokens = currentMintedTokens.add(rewardAmount);
require(
currentMintedTokens <= rewardTokensForPeriod,
"minted tokens cannot exceed mintable tokens"
);
currentMintableTokens
and currentMintedTokens
will be reset when a new round starts.
setCurrentMintedTokens(uint256 _amount) internal
Sets the tokens minted so far in the current eligible reward period. Resets when a new reward period starts.
Can only be called by the BondingManager
and RoundsManager
.
If _amount
is 0, reset mintedInRewardPeriod
and tokensForRewardPeriod
, as this indicates a new reward period starts.
function nextMintableTokens() uint256 public
Returns the amount of totable mintable tokens for the next reward period. This value is updated each round with the amount of inflationary LPT until all rounds for the reward period are accounted for.
function setRewardTokensForRound(uint256 _round, uint245 _amount) external onlyRoundsManager
Sets the amount of reward tokens that are mintable for the next reward period.
Only callable by the RoundsManager
.
The amount of tokens that is mintable for a round is calculated as follows:
$r = {totalSupply}\times inflation$
uint256 rewardTokens = MathUtils.percOf(
livepeerToken().totalSupply(),
inflation
);
The Minter
will hold inflationairy LPT that has been minted by reward
calls and will be the only entity that can mint
Livepeer Tokens.
The Minter
will also hold any ETH from winning ticket redemptions until withdrawal.
Migrates the current Minter
to a new deployment. Internally transfers all balances to the new contract.
Only callable by the owner
of Controller
function migrateToNewMinter(IMinter _newMinter) external onlyControllerOwner whenSystemPaused;
function trustedTransferTokens(address _to, uint256 _amount) external onlyBondingManager whenSystemNotPaused;
Transfers _amount
Livepeer Token from the Minter
to the recipient _to
.
Only callable by the BondingManager
when the system is not paused.
function trustedTransferTokens(address _to, uint256 _amount) external onlyBondingManager whenSystemNotPaused;
Burns _amount
Livepeer Token.
Only callable by the BondingManager
when the system is not paused.
function trustedWithdrawETH(address payable _to, uint256 _amount) external onlyBondingManagerOrJobsManager whenSystemNotPaused;
Transfers _amount
ETH from the Minter
to the recipient _to
.
Only callable by the BondingManager
or TicketBroker
when the system is not paused.
function depositETH() external payable onlyMinterOrJobsManager returns (bool)
function createReward(uint256 _rewardTokens) external onlyBondingManager whenSystemNotPaused
Mints _rewardTokens
where _rewardTokens
is the total amount of mintable tokens for a transcoder for a given rewardPeriod
, a rewardPeriod
can consist of multiple rounds.
Calculating the number of _rewardTokens
is now a responsability of the BondingManager
instead.
This proposal introduces a reward period which can reduce the frequency of reward calls (reward distribution transactions) and the overall costs incurred by orchestrators that are responsible for calling reward.
Gas prices on Ethereum have been fairly high due to increased demand for blockspace. In recent months the transaction cost of calling reward daily in order to receive inflationary LPT rewards often exceeds or is only marginally lower than the value received for many Orchestrators on the network.
To reduce the cost burden we could change the reward algorithm to require less frequent reward calls in tradeoff for a slightly higher single transaction cost.
E.g. Let's say a reward call under current conditions (per round basis) costs $100. Introducing a reward period would increase the cost to $150 for the call, but the frequency of calls reduces by 7x.
This still results in a cost saving of several magnitudes, in this example a 4.6x reduction:
$100 * 7 / $150 = 4.6
Rewards are minted for each rewardPeriod
rather than each round. Transcoders can call reward to claim their earnings for a rewardPeriod N
during the entirety of rewardPeriod N + 1
.
The new frequency at which reward
needs to be called will be equal to rewardPeriodlength
. A delegator and its delegates needs to be staked for the entirety of a rewardPeriod
to be eligible for its rewards.
In more detail the rules are as follows:
In order to achieve this, a few core principles are introduced in this specification:
totalStake
for a delegate
is locked in at the start of a reward period. Any calculation is based off of this locked value. endRound
of a reward periodtotalStake
for transcoders on their earningsPoolsFollowing state variables are added to the global state.
Parameter | Type | Description |
---|---|---|
currentRewardPeriodTotalActiveStake | private uint256 |
Total bonded stake for the current reward period |
nextRewardPeriodTotalActiveStake | private uint256 |
Total bonded stake for the next reward period |
NOTE: Consider making variables private if they are not required for any 3rd party API
Following state is added to the Delegator type |
Parameter | Type | Description |
---|---|---|---|
nextClaimRewardsStart | uint256 |
Start round for calculating cumulative rewards for the next claim earnings call | |
nextClaimRewardsPenalty | uint256 |
Amount of stake to discount during the next claim earnings call if reward has been called for the start earningsPool for the delegate |
Following state is added to the EarningsPool type |
Parameter | Type | Description |
---|---|---|---|
penaltyFromUnstake | uint256 |
Penalty for this reward period from unstakes that occured during the period, used to calculate amount of tokens to be minted | |
rewardCalled | boolean |
Indicates whether reward was called for the reward period, used to check whether the stake penalty for a delegate staked to the transcoder should be applied in case it did a staking action during the reward period |
function currentRewardPeriodTotalActiveStake() public returns (uint256)
Returns the total active stake to be used in the rewards calculation for the current eligible reward period. Will be set to nextRewardPeriodTotalActiveStake
by the RoundsManager
when a new reward period starts.
function nextRewardPeriodTotalActiveStake() public returns (uint256)
Returns the total active stake to be used in the rewards calculation for the next reward period.
function setCurrentRewardPeriodTotalActiveStake() external onlyRoundsManager
Sets the currentRewardPeriodTotalActiveStake
equal to nextRewardPeriodTotalActiveStake
.
Only callable by the RoundsManager
If a delegator was previously unstaked and staked during a reward period it will only be eligible for rewards starting from the next reward period.
Claims all pending earnings
Discount all of the stake for the delegator for the current reward period if reward were to be called, the discount is calculated as:
endCRF = currCRF + currCRF ( rewardsForRewardPeriod / totalStake)
diffRewards = stakeAmount * endCRF / startCRF
Store this value in delegate.nextClaimRewardsPenalty
.
It will be dormant unless the delegator
will call reward for the reward period.
Its nextRewardStartRound
will still be RoundsManager.currentRewardPeriodEnd
Increase the totalStake
on the earningsPool
for the delegate
for RoundsManager.nextRewardPeriodEnd
totalStake
from the earningsPool
for RoundsManager.currentRewardPeriodEnd
and add the amount to it, then store it. If the delegator next claims earnings its stake will be calculated as:
currentRound < delegate.nextRewardStartRound
- No-op / revert delegator.lastRewardRound < delegate.nextRewardStartRound
- No-op delegate.totalStake = delegate.totalStake * (endCRF / startCRF) - nextClaimDiscount
If a delegator switches delegate's during a reward period it will forgo it's rewards for the current reward period.
Claims all pending earnings
Discount all of the stake for the delegator for the current reward period if reward were to be called, the discount is calculated as:
endCRF = currCRF + currCRF ( rewardsForRewardPeriod / totalStake)
diffRewards = stakeAmount * endCRF / startCRF
Store this value in delegate.nextClaimRewardsPenalty
.
It will be dormant unless the delegator
will call reward for the reward period.
Its nextRewardStartRound
will still be RoundsManager.currentRewardPeriodEnd
Increase the totalStake
on the earningsPool
for the delegate
for RoundsManager.nextRewardPeriodEnd
totalStake
from the earningsPool
for RoundsManager.currentRewardPeriodEnd
and add the amount to it, then store it. Decrease the totalStake
on the earningsPool
for the old delegate
in the reverse manner
If the delegator next claims earnings its stake will be calculated as:
currentRound < delegate.nextRewardStartRound
- No-op / revert delegator.lastRewardRound < delegate.nextRewardStartRound
- No-op delegate.totalStake = delegate.totalStake * (endCRF / startCRF) - nextClaimDiscount
If a delegator adds stake during a reward period, only its initial stake will count for that reward period.
Claims all pending earnings
Discount the difference in rewards given the change in stake for the delegator for the current reward period if reward were to be called, the discount is calculated as:
endCRF = currCRF + currCRF ( rewards / totalStake)
stakeDiff = newStake - oldStake
diffRewards = stakeDiff * endCRF / startCRF
Store this value in delegate.nextClaimRewardsPenalty
.
It will be dormant unless the delegator
will call reward for the reward period.
Its nextRewardStartRound
will still be RoundsManager.currentRewardPeriodEnd
Increase the totalStake
on the earningsPool
for the delegate
for RoundsManager.nextRewardPeriodEnd
totalStake
from the earningsPool
for RoundsManager.currentRewardPeriodEnd
and add the amount to it, then store it. If the delegator next claims earnings its stake will be calculated as:
currentRound < delegate.nextRewardStartRound
- No-op / revert delegator.lastRewardRound < delegate.nextRewardStartRound
- No-op delegate.totalStake = delegate.totalStake * (endCRF / startCRF) - nextClaimDiscount
If a delegator completely unstakes during a reward period it will forgo all of its earned rewards during the reward period.
Claims pending earnings
Store this value in delegate.nextClaimRewardsPenalty
.
It will be dormant unless the delegator
will call reward for the reward period.
Also add this value to earningsPool.totalPenaltyFromUnstake
Its nextRewardStartRound
will still be RoundsManager.currentRewardPeriodEnd
Decrease the totalStake
on the earningsPool
for the delegate
for RoundsManager.nextRewardPeriodEnd
totalStake
from the earningsPool
for RoundsManager.currentRewardPeriodEnd
and substract the amount, then store it.If a delegate is self-bonded also set the totalStake
on the earningsPool
for RoundsManager.currentRewardPeriodEnd
to 0
If the delegator next claims earnings its stake will be calculated as:
currentRound < delegate.nextRewardStartRound
- No-op / revert delegator.lastRewardRound < delegate.nextRewardStartRound
- No-op delegate.totalStake = delegate.totalStake * (endCRF / startCRF) - nextClaimDiscount
If a delegator unstakes a portion of its stake during a reward period, its lowest amount will count for that reward period.
Claims all pending earnings
Discount the difference in rewards given the change in stake for the delegator for the current reward period if reward were to be called, the discount is calculated as:
endCRF = currCRF + currCRF ( rewards / totalStake)
stakeDiff = |newStake - oldStake|
diffRewards = stakeDiff * endCRF / startCRF
Store this value in delegate.nextClaimRewardsPenalty
.
It will be dormant unless the delegator
will call reward for the reward period.
Also add this value to earningsPool.totalPenaltyFromUnstake
Its nextRewardStartRound
will still be `RoundsManager.currentRewardPeriodEnd
Decrease the totalStake
on the earningsPool
for the delegate
for RoundsManager.nextRewardPeriodEnd
totalStake
from the earningsPool
for RoundsManager.currentRewardPeriodEnd
and substract the amount, then store it. If the delegator next claims earnings its stake will be calculated as:
currentRound < delegate.nextRewardStartRound
- No-op / revert delegator.lastRewardRound < delegate.nextRewardStartRound
- No-op delegate.totalStake = delegate.totalStake * (endCRF / startCRF) - nextClaimDiscount
If the nextClaimStartRound < currentRound
for a delegate, remove the stake discounts from the mapping to undo those state changes as we can technically count this as the delegate being staked for the full duration of the reward period.
Check that caller didn't already claim rewards for the eligible rewardPeriod
Reward period eligble for reward:
{
previousRewardPeriodStart
..
previousRewardPeriodEnd
}
require(
transcoder.lastRewardRound < roundsmanager().previousRewardPeriodEnd(),
"Already called reward for the current reward period"
);
Check if caller is/was an active transcoder during the eligible rewardPeriod
, revert if this is not the case. This will be implicitly implied by the totalStake
on the earningsPool
for RoundsManager.previousRewardPeriodEnd()
is 0.
require(
transcoder.earningsPoolForRound[RoundsManager().previousRewardPeriodEnd()] > 0,
"transcoder inactive during reward period"
);`
Calculate the amount of rewards to be minted for {startRound..endRound}
$r = {currentMintableTokens}\times \frac{totalStake_{delegate}}{currentRewardPeriodTotalActiveStake} - totalPenaltyFromUnstake$
where totalPenaltyFromUnstake
is the total amount of rewards that need to be discounted from the reward calculation due to unstaking activity happening for the delegate
.
Mint rewardTokens
and update the minted tokens for the current eligible reward period
uint256 currentMintedtokens = inflationManager().mintedInRewardPeriod() + rewardAmount;
require(
currentMintedTokens <= inflationManager().tokensForRewardPeriod(),
"minted tokens cannot exceed mintable tokens"
);
minter().createReward(rewardTokens);
inflationManager().setMintedInRewardPeriod(currentMintedTokens);
call updateTranscoderWithRewards
updateTranscoderWithRewards(
msg.sender,
rewardTokens,
currentRound,
_newPosPrev,
_newPosNext
);
Set rewardCalled
on the earningsPool
for the end round for the reward period to ensure any penalties for delegate's are live.
Check whether startEarningsPool.rewardCalled == true
If true
substract delegate.nextClaimRewardsPenalty
from the calculated cumulative stake.
Set delegate stake to calculated value
Set delegate.nextClaimRewardsPenalty
to 0
Following state variables are added to the RoundsManager
Parameter | Type | Description |
---|---|---|
rewardPeriodLength | uint256 |
The number of rounds in a reward period, the initial value is 7 |
previousRewardPeriodEnd | uint256 |
The end round of the previous reward period |
currentRewardPeriodEnd | uint256 |
The end round of the current reward period |
nextRewardPeriodEnd | uint256 |
The end round of the next reward period |
function setRewardPeriodlength(uint256 _rewardPeriodLength) external onlyControllerOwner
Set the number of rounds in a reward period. The changes goes into effect starting from the reward period after the next one.
_rewardPeriodLength
has to be greater or equal than 1
round.
Only callable by the owner
of Controller
.
The function signature will remain the same but some of the logic external contract calls that are part of this function will change
If the current reward period ends
previousRewardPeriodEnd
to currentRewardPeriodEnd
currentRewardPeriodEnd
to nextRewardPeriodEnd
nextRewardPeriodEnd
to currentRound + rewardPeriodLength
InflationManager.currentMintableTokens
to InflationManager.nextMintableTokens
nextMintableTokens
on the InflationManager
by calling InflationManager.setNextMintableTokens(0)
BondingManager.currentRewardPeriodTotalStake
to BondingManager.nextRewardPeriodTotalStake
BondingManager.nextRewardPeriodTotalStake
to the current BondingManager.totalActiveStake
calculate amount of rewardTokens
for this round, add it to InflationManager.nextMintableTokens()
and set the result using InflationManager.setNextMintableTokens(totalRewardTokens)
In go-livepeer
the rewardService
needs to be updated to not call reward every round, but every reward period instead.
An initial value of 7 is selected for rewardPeriodLength because it roughtly corresponds to a week which seems to be a a unit of time that is easy to reason about while also providing a sizeable reduction in the effective per round transaction cost for calling reward.
While this specification is slightly more stateful than the current implemented solution, it will still result in a significant gas cost reduction.
Transcoders that were active during the current rewardPeriod
but are not active during the last round of said period (due to being moved outside of the active transcoder set) should still be eligible to claim their rewards.
Rewards should be calculated accurately for all potential stake updates a transcoder received during a rewardPeriod
. This requires recursively calculating the network ownership and relative owed rewards for each round during the rewardPeriod
. A transcoder should not be able to game reward calculation by increasing its stake for the last round of a reward period.
Transcoders are given the window rewardPeriodLength
to claim their rewards from the previous rewardPeriod
. This offers greater flexibility for transcoders. Transcoders who call reward should be wary that this affects rewards calculation for the subsequent period however, as rewards only count towards active stake once reward()
has been called.
The minter is now stateless and only responsible for holding and transferring funds. The current structure would require less frequent upgrades for this contract, which puts user funds at 'ease'.
Focusing on the LIP-56 design below and putting aside the InflationManager design for the moment.
I'll try to summarize the design in my own words at a high level:
I think the ability to call reward for a previous reward period anytime during the current reward period is a nice property. However, I'm a bit concerned about the complexity of the accounting implementation and edge cases that might arise.
I'm curious if the following may achieve most (would skip a few things though) of what we want in a simpler manner:
delegationCooldownPeriod
that could be the same length as the reward period. Delegators would be able to continue to add/remove stake freely for the next round as they can now, but they would only be able to change their delegation once every delegationCooldownPeriod
. In contrast with the current proposed design, delegators would be able to earn rewards from a transcoder without being delegated to the transcoder for a full reward period, but they would also need to stay delegated to the transcoder for a full reward period afterwords. I think this would sidestep most of the accounting updates in the current proposed design including the discounter mechanism.I think your high level understanding of the design is on point and I think the way you framed some of the details could definitely help other people understand it as well.
I agree that the current way of accounting introduces some additional complexities, the main one being distinguishing "stake updates" from "reward eligible stake updates". However I think there are some beneficial tradeoffs to make here as well.
It discourages reward call front-running by staking actions to earn a larger share of the rewards, if it's too easy it will happen as it provides a low-risk financial opportunity. The only risk is the price movement and time value of money during the unstaking period.
I'd also like to point out that the current design enforces being active during a reward period more implicitly than just the explicit check seeing that the transcoder was active during the last round of the previous reward period. If the transcoder hasn't been active its totalStake for that rewardPeriod as established during the previous reward period will be 0. Straying away from this is actually quite a big difference.
A longer period to call reward means users can cost-average more and provides more flexibility generally. There's also a non-severe vulnerability with a short time frame to call reward: miners can collude to artificially exclude reward calls under certain gas prices strongarming orchestrators to pay up.
I don't think a delegationCooldownPeriod would reduce complexity of accounting overall as it brings its own set of design challenges and I don't consider it a good tradeoff to make because it suffers from similar front-running vulnerabilities, only the cost of attack will be slightly higher. Analogous to decreasing reward call frequency it sounds simple at a high level but is likely way more involved than we currently assume.
Or do you think I'm being too stubborn in terms of adversarial thinking here ?
Hello all,
where is your thinking about the problematic of reward period ? cause for orchestrator like me, with good performances but very few LPT (and no one stacked from delegator at this point) it's the only hope we have to claim our inflation reward, and, may be, have some delegators stacking on our node.
Thanks,
Cianha
This is the discussion thread for LIP-56: Reward Period which proposes introducing a reward to reduce the frequency of reward calls and the overall costs incurred by orchestrators that are responsible for calling reward. The draft was recently merged. All feedback is welcome! All reviewers should refer to the LIP text for the latest version of the proposal.