Skip to content

Commit

Permalink
bonding: Implement active stake checkpointing (#614)
Browse files Browse the repository at this point in the history
* package.json: Update @OpenZeppelin libraries

Will be required for new checkpoints code and later governor
implementation.

* bonding: Create SortedArrays library

Used for checkpointing logic in bonding state checkpoints

* bonding: Create BondingCheckpoints contract

Handles historic checkpointing ("snapshotting") and lookup
of the bonding state.

* bonding: Checkpoint bonding state on changes

* test/bonding: Test BondingManager and Checkpoints

 - unit tests
 - integration tests
 - gas-report
 - fork test for upgrade

* bonding: Migrate to custom error types

* bonding: Allow querying unbonded+uncheckpointed accounts

This will provide a cleaner experience using governance
tools, since users that don't have any stake won't get
errors when querying their voting power. For users that
do have stake, we will make sure to checkpoint them on
first deploy.

* bonding: Make sure we checkpoint up to once per op

* bonding: Make bonding checkpoints implement IVotes

* bonding: Read votes from the end of the round

Meaning the start of next round instead of the
current round. This is more compatible with the
way OZ expects the timepoints to work in the clock
and snapshots on the Governor framework.

* bonding: Make checkpoint reading revert-free

This changes the BondingVotes implementation to
stop having so many reverts on supposedly invalid states.

Instead, redefine the voting power (and checkpointed stake)
as being zero before the first round to be checkpointed.

This had other implications in the code like removing
changes in BondingManager to make the state complete
(always having an earnings pool on lastClaimRound).
Instead, the BondingVotes implementaiton is resilient
to that as well and just assumes reward() had never
been called.

This also fixed the redundant reverts between BondingVotes
and SortedArrays, as now there are almost no reverts.

* bonding: Address minor code review comments

* bonding: Move constructor to after modifiers

Just for consistency with other contracts.
Some docs as a bonus

* bonding: Implement ERC20 metadata on votes

This is to increase compatibility with some tools
out there like Tally, which require only these
functions from the ERC20 spec, not the full implementation.

So we can have these from the get-go to make things
easier if we want to make something with them.

* bonding: Address PR comments

* bonding: Address BondingVotes review comments

* test/bonding: Document IVotes layer tests
  • Loading branch information
victorges authored Aug 25, 2023
1 parent d0a9b87 commit 4d720f0
Show file tree
Hide file tree
Showing 27 changed files with 4,443 additions and 46 deletions.
131 changes: 93 additions & 38 deletions contracts/bonding/BondingManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import "../token/ILivepeerToken.sol";
import "../token/IMinter.sol";
import "../rounds/IRoundsManager.sol";
import "../snapshots/IMerkleSnapshot.sol";
import "./IBondingVotes.sol";

import "@openzeppelin/contracts/utils/math/SafeMath.sol";

Expand Down Expand Up @@ -123,6 +124,11 @@ contract BondingManager is ManagerProxyTarget, IBondingManager {
_;
}

modifier autoCheckpoint(address _account) {
_;
_checkpointBondingState(_account, delegators[_account], transcoders[_account]);
}

/**
* @notice BondingManager constructor. Only invokes constructor of base Manager contract with provided Controller address
* @dev This constructor will not initialize any state variables besides `controller`. The following setter functions
Expand Down Expand Up @@ -198,6 +204,15 @@ contract BondingManager is ManagerProxyTarget, IBondingManager {
rebondFromUnbondedWithHint(_to, _unbondingLockId, address(0), address(0));
}

/**
* @notice Checkpoints the bonding state for a given account.
* @dev This is to allow checkpointing an account that has an inconsistent checkpoint with its current state.
* @param _account The account to make the checkpoint for
*/
function checkpointBondingState(address _account) external {
_checkpointBondingState(_account, delegators[_account], transcoders[_account]);
}

/**
* @notice Withdraws tokens for an unbonding lock that has existed through an unbonding period
* @param _unbondingLockId ID of unbonding lock to withdraw with
Expand Down Expand Up @@ -347,7 +362,7 @@ contract BondingManager is ManagerProxyTarget, IBondingManager {
address _finder,
uint256 _slashAmount,
uint256 _finderFee
) external whenSystemNotPaused onlyVerifier {
) external whenSystemNotPaused onlyVerifier autoClaimEarnings(_transcoder) autoCheckpoint(_transcoder) {
Delegator storage del = delegators[_transcoder];

if (del.bondedAmount > 0) {
Expand Down Expand Up @@ -395,7 +410,12 @@ contract BondingManager is ManagerProxyTarget, IBondingManager {
* @notice Claim token pools shares for a delegator from its lastClaimRound through the end round
* @param _endRound The last round for which to claim token pools shares for a delegator
*/
function claimEarnings(uint256 _endRound) external whenSystemNotPaused currentRoundInitialized {
function claimEarnings(uint256 _endRound)
external
whenSystemNotPaused
currentRoundInitialized
autoCheckpoint(msg.sender)
{
// Silence unused param compiler warning
_endRound;

Expand All @@ -407,6 +427,8 @@ contract BondingManager is ManagerProxyTarget, IBondingManager {
*/
function setCurrentRoundTotalActiveStake() external onlyRoundsManager {
currentRoundTotalActiveStake = nextRoundTotalActiveStake;

bondingVotes().checkpointTotalActiveStake(currentRoundTotalActiveStake, roundsManager().currentRound());
}

/**
Expand Down Expand Up @@ -555,6 +577,9 @@ contract BondingManager is ManagerProxyTarget, IBondingManager {
}

emit Bond(_to, currentDelegate, _owner, _amount, del.bondedAmount);

// the `autoCheckpoint` modifier has been replaced with its internal function as a `Stack too deep` error work-around
_checkpointBondingState(_owner, del, transcoders[_owner]);
}

/**
Expand Down Expand Up @@ -681,7 +706,7 @@ contract BondingManager is ManagerProxyTarget, IBondingManager {
uint256 _amount,
address _newPosPrev,
address _newPosNext
) public whenSystemNotPaused currentRoundInitialized autoClaimEarnings(msg.sender) {
) public whenSystemNotPaused currentRoundInitialized autoClaimEarnings(msg.sender) autoCheckpoint(msg.sender) {
require(delegatorStatus(msg.sender) == DelegatorStatus.Bonded, "caller must be bonded");

Delegator storage del = delegators[msg.sender];
Expand Down Expand Up @@ -778,6 +803,7 @@ contract BondingManager is ManagerProxyTarget, IBondingManager {
public
whenSystemNotPaused
currentRoundInitialized
autoCheckpoint(msg.sender)
{
uint256 currentRound = roundsManager().currentRound();

Expand Down Expand Up @@ -1132,7 +1158,8 @@ contract BondingManager is ManagerProxyTarget, IBondingManager {
* @notice Return a delegator's cumulative stake and fees using the LIP-36 earnings claiming algorithm
* @param _transcoder Storage pointer to a transcoder struct for a delegator's delegate
* @param _startRound The round for the start cumulative factors
* @param _endRound The round for the end cumulative factors
* @param _endRound The round for the end cumulative factors. Normally this is the current round as historical
* lookup is only supported through BondingVotes
* @param _stake The delegator's initial stake before including earned rewards
* @param _fees The delegator's initial fees before including earned fees
* @return cStake , cFees where cStake is the delegator's cumulative stake including earned rewards and cFees is the delegator's cumulative fees including earned fees
Expand All @@ -1146,31 +1173,10 @@ contract BondingManager is ManagerProxyTarget, IBondingManager {
) internal view returns (uint256 cStake, uint256 cFees) {
// Fetch start cumulative factors
EarningsPool.Data memory startPool = cumulativeFactorsPool(_transcoder, _startRound);

// If the start cumulativeRewardFactor is 0 set the default value to PreciseMathUtils.percPoints(1, 1)
if (startPool.cumulativeRewardFactor == 0) {
startPool.cumulativeRewardFactor = PreciseMathUtils.percPoints(1, 1);
}

// Fetch end cumulative factors
EarningsPool.Data memory endPool = latestCumulativeFactorsPool(_transcoder, _endRound);

// If the end cumulativeRewardFactor is 0 set the default value to PreciseMathUtils.percPoints(1, 1)
if (endPool.cumulativeRewardFactor == 0) {
endPool.cumulativeRewardFactor = PreciseMathUtils.percPoints(1, 1);
}

cFees = _fees.add(
PreciseMathUtils.percOf(
_stake,
endPool.cumulativeFeeFactor.sub(startPool.cumulativeFeeFactor),
startPool.cumulativeRewardFactor
)
);

cStake = PreciseMathUtils.percOf(_stake, endPool.cumulativeRewardFactor, startPool.cumulativeRewardFactor);

return (cStake, cFees);
return EarningsPoolLIP36.delegatorCumulativeStakeAndFees(startPool, endPool, _stake, _fees);
}

/**
Expand Down Expand Up @@ -1219,18 +1225,33 @@ contract BondingManager is ManagerProxyTarget, IBondingManager {
uint256 _amount,
address _newPosPrev,
address _newPosNext
) internal autoCheckpoint(_delegate) {
return increaseTotalStakeUncheckpointed(_delegate, _amount, _newPosPrev, _newPosNext);
}

/**
* @dev Implementation of increaseTotalStake that does not checkpoint the caller, to be used by functions that
* guarantee the checkpointing themselves.
*/
function increaseTotalStakeUncheckpointed(
address _delegate,
uint256 _amount,
address _newPosPrev,
address _newPosNext
) internal {
Transcoder storage t = transcoders[_delegate];

uint256 currStake = transcoderTotalStake(_delegate);
uint256 newStake = currStake.add(_amount);

if (isRegisteredTranscoder(_delegate)) {
uint256 currStake = transcoderTotalStake(_delegate);
uint256 newStake = currStake.add(_amount);
uint256 currRound = roundsManager().currentRound();
uint256 nextRound = currRound.add(1);

// If the transcoder is already in the active set update its stake and return
if (transcoderPool.contains(_delegate)) {
transcoderPool.updateKey(_delegate, newStake, _newPosPrev, _newPosNext);
nextRoundTotalActiveStake = nextRoundTotalActiveStake.add(_amount);
Transcoder storage t = transcoders[_delegate];

// currStake (the transcoder's delegatedAmount field) will reflect the transcoder's stake from lastActiveStakeUpdateRound
// because it is updated every time lastActiveStakeUpdateRound is updated
Expand All @@ -1249,7 +1270,7 @@ contract BondingManager is ManagerProxyTarget, IBondingManager {
}

// Increase delegate's delegated amount
delegators[_delegate].delegatedAmount = delegators[_delegate].delegatedAmount.add(_amount);
delegators[_delegate].delegatedAmount = newStake;
}

/**
Expand All @@ -1262,16 +1283,18 @@ contract BondingManager is ManagerProxyTarget, IBondingManager {
uint256 _amount,
address _newPosPrev,
address _newPosNext
) internal {
) internal autoCheckpoint(_delegate) {
Transcoder storage t = transcoders[_delegate];

uint256 currStake = transcoderTotalStake(_delegate);
uint256 newStake = currStake.sub(_amount);

if (transcoderPool.contains(_delegate)) {
uint256 currStake = transcoderTotalStake(_delegate);
uint256 newStake = currStake.sub(_amount);
uint256 currRound = roundsManager().currentRound();
uint256 nextRound = currRound.add(1);

transcoderPool.updateKey(_delegate, newStake, _newPosPrev, _newPosNext);
nextRoundTotalActiveStake = nextRoundTotalActiveStake.sub(_amount);
Transcoder storage t = transcoders[_delegate];

// currStake (the transcoder's delegatedAmount field) will reflect the transcoder's stake from lastActiveStakeUpdateRound
// because it is updated every time lastActiveStakeUpdateRound is updated
Expand All @@ -1286,7 +1309,7 @@ contract BondingManager is ManagerProxyTarget, IBondingManager {
}

// Decrease old delegate's delegated amount
delegators[_delegate].delegatedAmount = delegators[_delegate].delegatedAmount.sub(_amount);
delegators[_delegate].delegatedAmount = newStake;
}

/**
Expand Down Expand Up @@ -1354,7 +1377,8 @@ contract BondingManager is ManagerProxyTarget, IBondingManager {

/**
* @dev Update a transcoder with rewards and update the transcoder pool with an optional list hint if needed.
* See SortedDoublyLL.sol for details on list hints
* See SortedDoublyLL.sol for details on list hints. This function updates the transcoder state but does not
* checkpoint it as it assumes the caller will ensure that.
* @param _transcoder Address of transcoder
* @param _rewards Amount of rewards
* @param _round Round that transcoder is updated
Expand Down Expand Up @@ -1390,11 +1414,14 @@ contract BondingManager is ManagerProxyTarget, IBondingManager {
// the earnings claiming algorithm and instead that amount is accounted for in the transcoder's cumulativeRewards field
earningsPool.updateCumulativeRewardFactor(prevEarningsPool, delegatorsRewards);
// Update transcoder's total stake with rewards
increaseTotalStake(_transcoder, _rewards, _newPosPrev, _newPosNext);
increaseTotalStakeUncheckpointed(_transcoder, _rewards, _newPosPrev, _newPosNext);
}

/**
* @dev Update a delegator with token pools shares from its lastClaimRound through a given round
*
* Notice that this function updates the delegator storage but does not checkpoint its state. Since it is internal
* it assumes the top-level caller will checkpoint it instead.
* @param _delegator Delegator address
* @param _endRound The last round for which to update a delegator's stake with earnings pool shares
* @param _lastClaimRound The round for which a delegator has last claimed earnings
Expand Down Expand Up @@ -1468,7 +1495,7 @@ contract BondingManager is ManagerProxyTarget, IBondingManager {
uint256 _unbondingLockId,
address _newPosPrev,
address _newPosNext
) internal {
) internal autoCheckpoint(_delegator) {
Delegator storage del = delegators[_delegator];
UnbondingLock storage lock = del.unbondingLocks[_unbondingLockId];

Expand All @@ -1486,6 +1513,30 @@ contract BondingManager is ManagerProxyTarget, IBondingManager {
emit Rebond(del.delegateAddress, _delegator, _unbondingLockId, amount);
}

/**
* @notice Checkpoints a delegator state after changes, to be used for historical voting power calculations in
* on-chain governor logic.
*/
function _checkpointBondingState(
address _owner,
Delegator storage _delegator,
Transcoder storage _transcoder
) internal {
// start round refers to the round where the checkpointed stake will be active. The actual `startRound` value
// in the delegators doesn't get updated on bond or claim earnings though, so we use currentRound() + 1
// which is the only guaranteed round where the currently stored stake will be active.
uint256 startRound = roundsManager().currentRound() + 1;
bondingVotes().checkpointBondingState(
_owner,
startRound,
_delegator.bondedAmount,
_delegator.delegateAddress,
_delegator.delegatedAmount,
_delegator.lastClaimRound,
_transcoder.lastRewardRound
);
}

/**
* @dev Return LivepeerToken interface
* @return Livepeer token contract registered with Controller
Expand Down Expand Up @@ -1518,6 +1569,10 @@ contract BondingManager is ManagerProxyTarget, IBondingManager {
return IRoundsManager(controller.getContract(keccak256("RoundsManager")));
}

function bondingVotes() internal view returns (IBondingVotes) {
return IBondingVotes(controller.getContract(keccak256("BondingVotes")));
}

function _onlyTicketBroker() internal view {
require(msg.sender == controller.getContract(keccak256("TicketBroker")), "caller must be TicketBroker");
}
Expand Down
Loading

0 comments on commit 4d720f0

Please sign in to comment.