diff --git a/contracts/bonding/BondingManager.sol b/contracts/bonding/BondingManager.sol index 5bc4ec33..68571263 100644 --- a/contracts/bonding/BondingManager.sol +++ b/contracts/bonding/BondingManager.sol @@ -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"; @@ -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 @@ -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 @@ -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) { @@ -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; @@ -407,6 +427,8 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { */ function setCurrentRoundTotalActiveStake() external onlyRoundsManager { currentRoundTotalActiveStake = nextRoundTotalActiveStake; + + bondingVotes().checkpointTotalActiveStake(currentRoundTotalActiveStake, roundsManager().currentRound()); } /** @@ -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]); } /** @@ -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]; @@ -778,6 +803,7 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { public whenSystemNotPaused currentRoundInitialized + autoCheckpoint(msg.sender) { uint256 currentRound = roundsManager().currentRound(); @@ -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 @@ -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); } /** @@ -1219,10 +1225,26 @@ 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); @@ -1230,7 +1252,6 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { 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 @@ -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; } /** @@ -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 @@ -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; } /** @@ -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 @@ -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 @@ -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]; @@ -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 @@ -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"); } diff --git a/contracts/bonding/BondingVotes.sol b/contracts/bonding/BondingVotes.sol new file mode 100644 index 00000000..2408c66e --- /dev/null +++ b/contracts/bonding/BondingVotes.sol @@ -0,0 +1,558 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "@openzeppelin/contracts/utils/Arrays.sol"; +import "@openzeppelin/contracts/utils/math/SafeCast.sol"; + +import "./libraries/EarningsPool.sol"; +import "./libraries/EarningsPoolLIP36.sol"; +import "./libraries/SortedArrays.sol"; + +import "../ManagerProxyTarget.sol"; +import "./IBondingVotes.sol"; +import "./IBondingManager.sol"; +import "../rounds/IRoundsManager.sol"; + +/** + * @title BondingVotes + * @dev Checkpointing logic for BondingManager state for historical stake calculations. + */ +contract BondingVotes is ManagerProxyTarget, IBondingVotes { + using Arrays for uint256[]; + using SortedArrays for uint256[]; + + struct BondingCheckpoint { + /** + * @dev The amount of bonded tokens to another delegate as of the lastClaimRound. + */ + uint256 bondedAmount; + /** + * @dev The address of the delegate the account is bonded to. In case of transcoders this is their own address. + */ + address delegateAddress; + /** + * @dev The amount of tokens delegated from delegators to this account. This is only set for transcoders, which + * have to self-delegate first and then have tokens bonded from other delegators. + */ + uint256 delegatedAmount; + /** + * @dev The last round during which the delegator claimed its earnings. This pegs the value of bondedAmount for + * rewards calculation in {EarningsPoolLIP36-delegatorCumulativeStakeAndFees}. + */ + uint256 lastClaimRound; + /** + * @dev The last round during which the checkpointed account called {BondingManager-reward}. This is needed to + * when calculating pending rewards for a delegator to this transcoder, to find the last earning pool available + * for a given round. In that case we start from the delegator checkpoint and then fetch its delegate address + * checkpoint as well to find the last earning pool. + * + * Notice that this is the only field that comes from the Transcoder struct in BondingManager, not Delegator. + */ + uint256 lastRewardRound; + } + + /** + * @dev Stores a list of checkpoints for an account, queryable and mapped by start round. To access the checkpoint + * for a given round, find the checkpoint with the highest start round that is lower or equal to the queried round + * ({SortedArrays-findLowerBound}) and then fetch the specific checkpoint on the data mapping. + */ + struct BondingCheckpointsByRound { + uint256[] startRounds; + mapping(uint256 => BondingCheckpoint) data; + } + + /** + * @dev Stores a list of checkpoints for the total active stake, queryable and mapped by round. Notce that + * differently from bonding checkpoints, it's only accessible on the specific round. To access the checkpoint for a + * given round, look for the checkpoint in the {data}} and if it's zero ensure the round was actually checkpointed on + * the {rounds} array ({SortedArrays-findLowerBound}). + */ + struct TotalActiveStakeByRound { + uint256[] rounds; + mapping(uint256 => uint256) data; + } + + /** + * @dev Checkpoints by account (delegators and transcoders). + */ + mapping(address => BondingCheckpointsByRound) private bondingCheckpoints; + /** + * @dev Total active stake checkpoints. + */ + TotalActiveStakeByRound private totalStakeCheckpoints; + + /** + * @dev Modifier to ensure the sender is BondingManager + */ + modifier onlyBondingManager() { + _onlyBondingManager(); + _; + } + + /** + * @dev Ensures that the provided round is in the past. + */ + modifier onlyPastRounds(uint256 _round) { + uint256 currentRound = clock(); + if (_round >= currentRound) { + revert FutureLookup(_round, currentRound == 0 ? 0 : currentRound - 1); + } + _; + } + + /** + * @notice BondingVotes constructor. Only invokes constructor of base Manager contract with provided Controller address + * @param _controller Address of Controller that this contract will be registered with + */ + constructor(address _controller) Manager(_controller) {} + + // IVotes interface implementation. + // These should not access any storage directly but proxy to the bonding state functions. + + /** + * @notice Returns the name of the virtual token implemented by this. + */ + function name() external pure returns (string memory) { + return "Livepeer Voting Power"; + } + + /** + * @notice Returns the symbol of the token underlying the voting power. + */ + function symbol() external pure returns (string memory) { + return "vLPT"; + } + + /** + * @notice Returns the decimals places of the token underlying the voting. + */ + function decimals() external pure returns (uint8) { + return 18; + } + + /** + * @notice Clock is set to match the current round, which is the checkpointing + * method implemented here. + */ + function clock() public view returns (uint48) { + return SafeCast.toUint48(roundsManager().currentRound()); + } + + /** + * @notice Machine-readable description of the clock as specified in EIP-6372. + */ + // solhint-disable-next-line func-name-mixedcase + function CLOCK_MODE() external pure returns (string memory) { + return "mode=livepeer_round"; + } + + /** + * @notice Returns the current amount of votes that `_account` has. + * @dev Keep in mind that since this function should return the votes at the end of the current round, we need to + * fetch the bonding state at the next round instead. That because the bonding state reflects the active stake in + * the current round, which is the snapshotted stake from the end of the previous round. + */ + function getVotes(address _account) external view returns (uint256) { + (uint256 amount, ) = getBondingStateAt(_account, clock() + 1); + return amount; + } + + /** + * @notice Returns the amount of votes that `_account` had at a specific moment in the past. If the `clock()` is + * configured to use block numbers, this will return the value at the end of the corresponding block. + * @dev Keep in mind that since this function should return the votes at the end of the _round (or timepoint in OZ + * terms), we need to fetch the bonding state at the next round instead. That because the bonding state reflects the + * active stake in the current round, which is the snapshotted stake from the end of the previous round. + */ + function getPastVotes(address _account, uint256 _round) external view onlyPastRounds(_round) returns (uint256) { + (uint256 amount, ) = getBondingStateAt(_account, _round + 1); + return amount; + } + + /** + * @notice Returns the current total supply of votes available. + * @dev This value is the sum of all *active* stake, which is not necessarily the sum of all voting power. + * Bonded stake that is not part of the top 100 active transcoder set is still given voting power, but is not + * considered here. + * @dev Keep in mind that since this function should return the votes at the end of the current round, we need to + * fetch the total active stake at the next round instead. That because the active stake in the current round is the + * snapshotted stake from the end of the previous round. + */ + function totalSupply() external view returns (uint256) { + return getTotalActiveStakeAt(clock() + 1); + } + + /** + * @notice Returns the total supply of votes available at a specific round in the past. + * @dev This value is the sum of all *active* stake, which is not necessarily the sum of all voting power. + * Bonded stake that is not part of the top 100 active transcoder set is still given voting power, but is not + * considered here. + * @dev Keep in mind that since this function should return the votes at the end of the _round (or timepoint in OZ + * terms), we need to fetch the total active stake at the next round instead. That because the active stake in the + * current round is the snapshotted stake from the end of the previous round. + */ + function getPastTotalSupply(uint256 _round) external view onlyPastRounds(_round) returns (uint256) { + return getTotalActiveStakeAt(_round + 1); + } + + /** + * @notice Returns the delegate that _account has chosen. This means the delegated transcoder address in case of + * delegators, and the account's own address for transcoders (self-delegated). + * @dev Keep in mind that since this function should return the delegate at the end of the current round, we need to + * fetch the bonding state at the next round instead. That because the bonding state reflects the active stake in + * the current round, or the snapshotted stake from the end of the previous round. + */ + function delegates(address _account) external view returns (address) { + (, address delegateAddress) = getBondingStateAt(_account, clock() + 1); + return delegateAddress; + } + + /** + * @notice Returns the delegate that _account had chosen in a specific round in the past. + * @dev This is an addition to the IERC5805 interface to support our custom vote counting logic that allows + * delegators to override their transcoders votes. See {GovernorCountingOverridable-_handleVoteOverrides}. + * @dev Keep in mind that since this function should return the delegate at the end of the _round (or timepoint in + * OZ terms), we need to fetch the bonding state at the next round instead. That because the bonding state reflects + * the active stake in the current round, which is the snapshotted stake from the end of the previous round. + */ + function delegatedAt(address _account, uint256 _round) external view onlyPastRounds(_round) returns (address) { + (, address delegateAddress) = getBondingStateAt(_account, _round + 1); + return delegateAddress; + } + + /** + * @notice Delegation through BondingVotes is not supported. + */ + function delegate(address) external pure { + revert MustCallBondingManager("bond"); + } + + /** + * @notice Delegation through BondingVotes is not supported. + */ + function delegateBySig( + address, + uint256, + uint256, + uint8, + bytes32, + bytes32 + ) external pure { + revert MustCallBondingManager("bondFor"); + } + + // BondingManager checkpointing hooks + + /** + * @notice Called by the BondingManager when the bonding state of an account changes. + * @dev Since we checkpoint "delegator" and "transcoder" states, this is called both for the delegator and for the + * transcoder when any change is made to the bonds, including when rewards are calculated or claimed. + * @param _account The account whose bonding state changed + * @param _startRound The round from which the bonding state will be active. This is normally the next round. + * @param _bondedAmount From {BondingManager-Delegator-bondedAmount} + * @param _delegateAddress From {BondingManager-Delegator-delegateAddress} + * @param _delegatedAmount From {BondingManager-Transcoder-delegatedAmount} + * @param _lastClaimRound From {BondingManager-Delegator-lastClaimRound} + * @param _lastRewardRound From {BondingManager-Transcoder-lastRewardRound} + */ + function checkpointBondingState( + address _account, + uint256 _startRound, + uint256 _bondedAmount, + address _delegateAddress, + uint256 _delegatedAmount, + uint256 _lastClaimRound, + uint256 _lastRewardRound + ) external virtual onlyBondingManager { + if (_startRound != clock() + 1) { + revert InvalidStartRound(_startRound, clock() + 1); + } else if (_lastClaimRound >= _startRound) { + revert FutureLastClaimRound(_lastClaimRound, _startRound - 1); + } + + BondingCheckpoint memory previous; + if (hasCheckpoint(_account)) { + previous = getBondingCheckpointAt(_account, _startRound); + } + + BondingCheckpointsByRound storage checkpoints = bondingCheckpoints[_account]; + + BondingCheckpoint memory bond = BondingCheckpoint({ + bondedAmount: _bondedAmount, + delegateAddress: _delegateAddress, + delegatedAmount: _delegatedAmount, + lastClaimRound: _lastClaimRound, + lastRewardRound: _lastRewardRound + }); + checkpoints.data[_startRound] = bond; + + // now store the startRound itself in the startRounds array to allow us + // to find it and lookup in the above mapping + checkpoints.startRounds.pushSorted(_startRound); + + onBondingCheckpointChanged(_account, previous, bond); + } + + /** + * @notice Called by the BondingManager when the total active stake changes. + * @dev This is called only from the {BondingManager-setCurrentRoundTotalActiveStake} function to set the total + * active stake in the current round. + * @param _totalStake From {BondingManager-currentRoundTotalActiveStake} + * @param _round The round for which the total active stake is valid. This is normally the current round. + */ + function checkpointTotalActiveStake(uint256 _totalStake, uint256 _round) external virtual onlyBondingManager { + if (_round != clock()) { + revert InvalidTotalStakeCheckpointRound(_round, clock()); + } + + totalStakeCheckpoints.data[_round] = _totalStake; + totalStakeCheckpoints.rounds.pushSorted(_round); + } + + /** + * @notice Returns whether an account already has any checkpoint. + */ + function hasCheckpoint(address _account) public view returns (bool) { + return bondingCheckpoints[_account].startRounds.length > 0; + } + + // Historical stake access functions + + /** + * @dev Gets the checkpointed total active stake at a given round. + * @param _round The round for which we want to get the total active stake. + */ + function getTotalActiveStakeAt(uint256 _round) public view virtual returns (uint256) { + if (_round > clock() + 1) { + revert FutureLookup(_round, clock() + 1); + } + + uint256 exactCheckpoint = totalStakeCheckpoints.data[_round]; + if (exactCheckpoint > 0) { + return exactCheckpoint; + } + + uint256[] storage initializedRounds = totalStakeCheckpoints.rounds; + uint256 upper = initializedRounds.findUpperBound(_round); + if (upper == 0) { + // Return a zero voting power supply for any round before the first checkpoint. This also happens if there + // are no checkpoints at all. + return 0; + } else if (upper < initializedRounds.length) { + // Use the checkpoint from the next initialized round, which got the next total active stake checkpointed. + uint256 nextInitedRound = initializedRounds[upper]; + return totalStakeCheckpoints.data[nextInitedRound]; + } else { + // Here the _round is after any initialized round, so grab its stake from nextRoundTotalActiveStake() + return bondingManager().nextRoundTotalActiveStake(); + } + } + + /** + * @notice Gets the bonding state of an account at a given round. + * @dev In the case of delegators it is the amount they are delegating to a transcoder, while for transcoders this + * includes all the stake that has been delegated to them (including self-delegated). + * @param _account The account whose bonding state we want to get. + * @param _round The round for which we want to get the bonding state. Normally a proposal's vote start round. + * @return amount The active stake of the account at the given round including any accrued rewards. In case of + * transcoders this also includes all the amount delegated towards them by other delegators. + * @return delegateAddress The address the account delegated to. Will be equal to _account in case of transcoders. + */ + function getBondingStateAt(address _account, uint256 _round) + public + view + virtual + returns (uint256 amount, address delegateAddress) + { + BondingCheckpoint storage bond = getBondingCheckpointAt(_account, _round); + + delegateAddress = bond.delegateAddress; + bool isTranscoder = delegateAddress == _account; + + if (bond.bondedAmount == 0) { + amount = 0; + } else if (isTranscoder) { + // Address is a registered transcoder so we use its delegated amount. This includes self and delegated stake + // as well as any accrued rewards, even unclaimed ones + amount = bond.delegatedAmount; + } else { + // Address is NOT a registered transcoder so we calculate its cumulative stake for the voting power + amount = delegatorCumulativeStakeAt(bond, _round); + } + } + + /** + * @dev Reacts to changes in the bonding checkpoints of an account by emitting the corresponding events. + */ + function onBondingCheckpointChanged( + address _account, + BondingCheckpoint memory previous, + BondingCheckpoint memory current + ) internal { + address previousDelegate = previous.delegateAddress; + address newDelegate = current.delegateAddress; + if (previousDelegate != newDelegate) { + emit DelegateChanged(_account, previousDelegate, newDelegate); + } + + bool isTranscoder = newDelegate == _account; + bool wasTranscoder = previousDelegate == _account; + // we want to register zero "delegate votes" when the account is/was not a transcoder + uint256 previousDelegateVotes = wasTranscoder ? previous.delegatedAmount : 0; + uint256 currentDelegateVotes = isTranscoder ? current.delegatedAmount : 0; + if (previousDelegateVotes != currentDelegateVotes) { + emit DelegateVotesChanged(_account, previousDelegateVotes, currentDelegateVotes); + } + + // Always send delegator events since transcoders are delegators themselves. The way our rewards work, the + // delegator voting power calculated from events will only reflect their claimed stake without pending rewards. + if (previous.bondedAmount != current.bondedAmount) { + emit DelegatorBondedAmountChanged(_account, previous.bondedAmount, current.bondedAmount); + } + } + + /** + * @dev Gets the checkpointed bonding state of an account at a round. This works by looking for the last checkpoint + * at or before the given round and using the checkpoint of that round. If there hasn't been checkpoints since then + * it means that the state hasn't changed. + * @param _account The account whose bonding state we want to get. + * @param _round The round for which we want to get the bonding state. + * @return The {BondingCheckpoint} pointer to the checkpoints storage. + */ + function getBondingCheckpointAt(address _account, uint256 _round) + internal + view + returns (BondingCheckpoint storage) + { + if (_round > clock() + 1) { + revert FutureLookup(_round, clock() + 1); + } + + BondingCheckpointsByRound storage checkpoints = bondingCheckpoints[_account]; + + // Most of the time we will be calling this for a transcoder which checkpoints on every round through reward(). + // On those cases we will have a checkpoint for exactly the round we want, so optimize for that. + BondingCheckpoint storage bond = checkpoints.data[_round]; + if (bond.bondedAmount > 0) { + return bond; + } + + uint256 startRoundIdx = checkpoints.startRounds.findLowerBound(_round); + if (startRoundIdx == checkpoints.startRounds.length) { + // No checkpoint at or before _round, so return the zero BondingCheckpoint value. This also happens if there + // are no checkpoints for _account. The voting power will be zero until the first checkpoint is made. + return bond; + } + + uint256 startRound = checkpoints.startRounds[startRoundIdx]; + return checkpoints.data[startRound]; + } + + /** + * @dev Gets the cumulative stake of a delegator at any given round. Differently from the bonding manager + * implementation, we can calculate the stake at any round through the use of the checkpointed state. It works by + * re-using the bonding manager logic while changing only the way that we find the earning pool for the end round. + * @param bond The {BondingCheckpoint} of the delegator at the given round. + * @param _round The round for which we want to get the cumulative stake. + * @return The cumulative stake of the delegator at the given round. + */ + function delegatorCumulativeStakeAt(BondingCheckpoint storage bond, uint256 _round) + internal + view + returns (uint256) + { + EarningsPool.Data memory startPool = getTranscoderEarningsPoolForRound( + bond.delegateAddress, + bond.lastClaimRound + ); + + (uint256 rewardRound, EarningsPool.Data memory endPool) = getLastTranscoderRewardsEarningsPool( + bond.delegateAddress, + _round + ); + + if (rewardRound < bond.lastClaimRound) { + // If the transcoder hasn't called reward() since the last time the delegator claimed earnings, there wil be + // no rewards to add to the delegator's stake so we just return the originally bonded amount. + return bond.bondedAmount; + } + + (uint256 stakeWithRewards, ) = EarningsPoolLIP36.delegatorCumulativeStakeAndFees( + startPool, + endPool, + bond.bondedAmount, + 0 + ); + return stakeWithRewards; + } + + /** + * @notice Returns the last initialized earning pool for a transcoder at a given round. + * @dev Transcoders are just delegators with a self-delegation, so we find their last checkpoint before or at the + * provided _round and use its lastRewardRound value to grab the calculated earning pool. The only case where this + * returns a zero earning pool is if the transcoder had never called reward() before _round. + * @param _transcoder Address of the transcoder to look for + * @param _round Past round at which we want the valid earning pool from + * @return rewardRound Round in which the returned earning pool was calculated. + * @return pool EarningsPool.Data struct with the last initialized earning pool. + */ + function getLastTranscoderRewardsEarningsPool(address _transcoder, uint256 _round) + internal + view + returns (uint256 rewardRound, EarningsPool.Data memory pool) + { + BondingCheckpoint storage bond = getBondingCheckpointAt(_transcoder, _round); + rewardRound = bond.lastRewardRound; + + if (rewardRound > 0) { + pool = getTranscoderEarningsPoolForRound(_transcoder, rewardRound); + + if (pool.cumulativeRewardFactor == 0) { + // Invalid state: a lastRewardRound is registered but there's no recorded earnings pool. + revert MissingEarningsPool(_transcoder, rewardRound); + } + } + } + + /** + * @dev Proxy for {BondingManager-getTranscoderEarningsPoolForRound} that returns an EarningsPool.Data struct. + */ + function getTranscoderEarningsPoolForRound(address _transcoder, uint256 _round) + internal + view + returns (EarningsPool.Data memory pool) + { + ( + pool.totalStake, + pool.transcoderRewardCut, + pool.transcoderFeeShare, + pool.cumulativeRewardFactor, + pool.cumulativeFeeFactor + ) = bondingManager().getTranscoderEarningsPoolForRound(_transcoder, _round); + } + + // Manager/Controller helpers + + /** + * @dev Return BondingManager interface + */ + function bondingManager() internal view returns (IBondingManager) { + return IBondingManager(controller.getContract(keccak256("BondingManager"))); + } + + /** + * @dev Return IRoundsManager interface + */ + function roundsManager() internal view returns (IRoundsManager) { + return IRoundsManager(controller.getContract(keccak256("RoundsManager"))); + } + + /** + * @dev Ensure the sender is BondingManager + */ + function _onlyBondingManager() internal view { + if (msg.sender != address(bondingManager())) { + revert InvalidCaller(msg.sender, address(bondingManager())); + } + } +} diff --git a/contracts/bonding/IBondingManager.sol b/contracts/bonding/IBondingManager.sol index 3981e236..f981d7a7 100644 --- a/contracts/bonding/IBondingManager.sol +++ b/contracts/bonding/IBondingManager.sol @@ -78,4 +78,17 @@ interface IBondingManager { function isActiveTranscoder(address _transcoder) external view returns (bool); function getTotalBonded() external view returns (uint256); + + function nextRoundTotalActiveStake() external view returns (uint256); + + function getTranscoderEarningsPoolForRound(address _transcoder, uint256 _round) + external + view + returns ( + uint256 totalStake, + uint256 transcoderRewardCut, + uint256 transcoderFeeShare, + uint256 cumulativeRewardFactor, + uint256 cumulativeFeeFactor + ); } diff --git a/contracts/bonding/IBondingVotes.sol b/contracts/bonding/IBondingVotes.sol new file mode 100644 index 00000000..1fc4f358 --- /dev/null +++ b/contracts/bonding/IBondingVotes.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +import "../treasury/IVotes.sol"; + +/** + * @title Interface for BondingVotes + */ +interface IBondingVotes is IVotes { + error InvalidCaller(address caller, address required); + error InvalidStartRound(uint256 checkpointRound, uint256 requiredRound); + error FutureLastClaimRound(uint256 lastClaimRound, uint256 maxAllowed); + error InvalidTotalStakeCheckpointRound(uint256 checkpointRound, uint256 requiredRound); + + error FutureLookup(uint256 queryRound, uint256 maxAllowed); + error MissingEarningsPool(address transcoder, uint256 round); + + // Indicates that the called function is not supported in this contract and should be performed through the + // BondingManager instead. This is mostly used for IVotes delegation methods which must be bonds instead. + error MustCallBondingManager(string bondingManagerFunction); + + /** + * @dev Emitted when a checkpoint results in changes to a delegator's `bondedAmount`. This complements the events + * from IERC5805 by also supporting voting power for the delegators themselves, though requiring knowledge about our + * specific reward-claiming protocol to calculate voting power based on this value. + */ + event DelegatorBondedAmountChanged(address indexed delegate, uint256 previousBondedAmount, uint256 newBondedAmount); + + // BondingManager hooks + + function checkpointBondingState( + address _account, + uint256 _startRound, + uint256 _bondedAmount, + address _delegateAddress, + uint256 _delegatedAmount, + uint256 _lastClaimRound, + uint256 _lastRewardRound + ) external; + + function checkpointTotalActiveStake(uint256 _totalStake, uint256 _round) external; + + // Historical stake access functions + + function hasCheckpoint(address _account) external view returns (bool); + + function getTotalActiveStakeAt(uint256 _round) external view returns (uint256); + + function getBondingStateAt(address _account, uint256 _round) + external + view + returns (uint256 amount, address delegateAddress); +} diff --git a/contracts/bonding/libraries/EarningsPoolLIP36.sol b/contracts/bonding/libraries/EarningsPoolLIP36.sol index 0489d905..0505ecce 100644 --- a/contracts/bonding/libraries/EarningsPoolLIP36.sol +++ b/contracts/bonding/libraries/EarningsPoolLIP36.sol @@ -57,4 +57,43 @@ library EarningsPoolLIP36 { PreciseMathUtils.percOf(prevCumulativeRewardFactor, _rewards, earningsPool.totalStake) ); } + + /** + * @notice Calculates a delegator's cumulative stake and fees using the LIP-36 earnings claiming algorithm. + * @param _startPool The earning pool from the start round for the start cumulative factors. Normally this is the + * earning pool from the {Delegator-lastclaimRound}+1 round, as the round where `bondedAmount` was measured. + * @param _endPool The earning pool from the end round for the end cumulative factors + * @param _stake The delegator initial stake before including earned rewards. Normally the {Delegator-bondedAmount} + * @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 + */ + function delegatorCumulativeStakeAndFees( + EarningsPool.Data memory _startPool, + EarningsPool.Data memory _endPool, + uint256 _stake, + uint256 _fees + ) internal pure returns (uint256 cStake, uint256 cFees) { + // 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); + } + + // 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); + } } diff --git a/contracts/bonding/libraries/SortedArrays.sol b/contracts/bonding/libraries/SortedArrays.sol new file mode 100644 index 00000000..4e425f5a --- /dev/null +++ b/contracts/bonding/libraries/SortedArrays.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "../../libraries/MathUtils.sol"; + +import "@openzeppelin/contracts/utils/Arrays.sol"; + +/** + * @title SortedArrays + * @dev Handles maintaining and looking up on sorted uint256 arrays. + */ +library SortedArrays { + using Arrays for uint256[]; + + error DecreasingValues(uint256 newValue, uint256 lastValue); + + /** + * @notice Searches a sorted _array and returns the last element to be lower or equal to _val. If there is no such + * element (all elements in array are higher than the searched element), the array length is returned. + * + * @dev This basically converts OpenZeppelin's {Arrays-findUpperBound} into findLowerBound, meaning it also uses a + * binary search in the worst case after trying some shortcuts. Worst case time complexity is O(log n). The only + * change being that the returned index points to the element lower or equal to _val, instead of higher or equal. + * @param _array Array to search in + * @param _val Value to search for + * @return lower Index of the lower bound found in array + */ + function findLowerBound(uint256[] storage _array, uint256 _val) internal view returns (uint256) { + uint256 len = _array.length; + if (len == 0) { + return 0; + } + + if (_array[len - 1] <= _val) { + return len - 1; + } + + uint256 upperIdx = _array.findUpperBound(_val); + + // we already checked the last element above so the upper will always be inside the array + assert(upperIdx < len); + + // the exact value we were searching is in the array + if (_array[upperIdx] == _val) { + return upperIdx; + } + + // a 0 idx means that the first elem is already higher than the searched value (and not equal, checked above) + if (upperIdx == 0) { + return len; + } + + // the element at upperIdx is the first element higher than the value we want, so return the previous element + return upperIdx - 1; + } + + /** + * @notice Pushes a value into an already sorted array. + * @dev Values must be pushed in increasing order as to avoid shifting values in the array. This function only + * guarantees that the pushed value will not create duplicates nor make the array out of order. + * @param array Array to push the value into + * @param val Value to push into array. Must be greater than or equal to the highest (last) element. + */ + function pushSorted(uint256[] storage array, uint256 val) internal { + if (array.length == 0) { + array.push(val); + } else { + uint256 last = array[array.length - 1]; + + // values must be pushed in order + if (val < last) { + revert DecreasingValues(val, last); + } + + // don't push duplicate values + if (val != last) { + array.push(val); + } + } + } +} diff --git a/contracts/test/TestSortedArrays.sol b/contracts/test/TestSortedArrays.sol new file mode 100644 index 00000000..d497bfa2 --- /dev/null +++ b/contracts/test/TestSortedArrays.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "./helpers/truffle/Assert.sol"; +import "./helpers/RevertProxy.sol"; +import "./mocks/SortedArraysFixture.sol"; + +contract TestSortedArrays { + RevertProxy proxy; + SortedArraysFixture fixture; + + function beforeAll() public { + proxy = new RevertProxy(); + } + + function beforeEach() public { + fixture = new SortedArraysFixture(); + } + + function test_pushSorted_addsElements() public { + fixture.pushSorted(3); + Assert.equal(fixture.length(), 1, "incorrect array length"); + Assert.equal(fixture.array(0), 3, "incorrect value in array"); + + fixture.pushSorted(4); + Assert.equal(fixture.length(), 2, "incorrect array length"); + Assert.equal(fixture.array(0), 3, "incorrect value in array"); + Assert.equal(fixture.array(1), 4, "incorrect value in array"); + } + + function test_pushSorted_avoidsDuplicates() public { + fixture.pushSorted(4); + Assert.equal(fixture.length(), 1, "incorrect array length"); + Assert.equal(fixture.array(0), 4, "incorrect value in array"); + + fixture.pushSorted(4); + Assert.equal(fixture.length(), 1, "incorrect array length"); + } + + function test_pushSorted_revertsOnDecreasing() public { + fixture.pushSorted(4); + Assert.equal(fixture.length(), 1, "incorrect array length"); + Assert.equal(fixture.array(0), 4, "incorrect value in array"); + + SortedArraysFixture(address(proxy)).pushSorted(3); + bool ok = proxy.execute(address(fixture)); + Assert.isFalse(ok, "did not revert"); + } + + function test_findLowerBound_lowerThanElement() public { + fixture.pushSorted(2); + fixture.pushSorted(4); + fixture.pushSorted(7); + fixture.pushSorted(11); + + Assert.equal(fixture.findLowerBound(3), 0, "found incorrect element"); + Assert.equal(fixture.findLowerBound(6), 1, "found incorrect element"); + Assert.equal(fixture.findLowerBound(10), 2, "found incorrect element"); + Assert.equal(fixture.findLowerBound(15), 3, "found incorrect element"); + } + + function test_findLowerBound_exactElement() public { + fixture.pushSorted(3); + fixture.pushSorted(5); + fixture.pushSorted(8); + fixture.pushSorted(13); + + Assert.equal(fixture.findLowerBound(3), 0, "found incorrect element"); + Assert.equal(fixture.findLowerBound(5), 1, "found incorrect element"); + Assert.equal(fixture.findLowerBound(8), 2, "found incorrect element"); + Assert.equal(fixture.findLowerBound(13), 3, "found incorrect element"); + } + + function test_findLowerBound_returnsLengthOnEmpty() public { + Assert.equal(fixture.length(), 0, "incorrect array length"); + Assert.equal(fixture.findLowerBound(3), 0, "incorrect return on empty array"); + } + + function test_findLowerBound_returnsLengthOnNotFound() public { + fixture.pushSorted(8); + fixture.pushSorted(13); + + Assert.equal(fixture.findLowerBound(22), 1, "found incorrect element"); + // looking for a value lower than min should return the array length + Assert.equal(fixture.findLowerBound(5), 2, "incorrect return on not found"); + } +} diff --git a/contracts/test/mocks/BondingManagerMock.sol b/contracts/test/mocks/BondingManagerMock.sol index c0ed2669..a24b37da 100644 --- a/contracts/test/mocks/BondingManagerMock.sol +++ b/contracts/test/mocks/BondingManagerMock.sol @@ -6,6 +6,28 @@ import "./GenericMock.sol"; contract BondingManagerMock is GenericMock { event UpdateTranscoderWithFees(address transcoder, uint256 fees, uint256 round); + struct EarningsPoolMock { + uint256 totalStake; + uint256 transcoderRewardCut; + uint256 transcoderFeeShare; + uint256 cumulativeRewardFactor; + uint256 cumulativeFeeFactor; + } + + struct DelegatorMock { + uint256 bondedAmount; + uint256 fees; + address delegateAddress; + uint256 delegatedAmount; + uint256 startRound; + uint256 lastClaimRound; + uint256 nextUnbondingLockId; + } + + mapping(address => mapping(uint256 => EarningsPoolMock)) private earningPoolMocks; + + mapping(address => DelegatorMock) private delegatorMocks; + function updateTranscoderWithFees( address _transcoder, uint256 _fees, @@ -13,4 +35,87 @@ contract BondingManagerMock is GenericMock { ) external { emit UpdateTranscoderWithFees(_transcoder, _fees, _round); } + + function getTranscoderEarningsPoolForRound(address _transcoder, uint256 _round) + public + view + returns ( + uint256 totalStake, + uint256 transcoderRewardCut, + uint256 transcoderFeeShare, + uint256 cumulativeRewardFactor, + uint256 cumulativeFeeFactor + ) + { + EarningsPoolMock storage pool = earningPoolMocks[_transcoder][_round]; + + totalStake = pool.totalStake; + transcoderRewardCut = pool.transcoderRewardCut; + transcoderFeeShare = pool.transcoderFeeShare; + cumulativeRewardFactor = pool.cumulativeRewardFactor; + cumulativeFeeFactor = pool.cumulativeFeeFactor; + } + + function setMockTranscoderEarningsPoolForRound( + address _transcoder, + uint256 _round, + uint256 _totalStake, + uint256 _transcoderRewardCut, + uint256 _transcoderFeeShare, + uint256 _cumulativeRewardFactor, + uint256 _cumulativeFeeFactor + ) external { + earningPoolMocks[_transcoder][_round] = EarningsPoolMock({ + totalStake: _totalStake, + transcoderRewardCut: _transcoderRewardCut, + transcoderFeeShare: _transcoderFeeShare, + cumulativeRewardFactor: _cumulativeRewardFactor, + cumulativeFeeFactor: _cumulativeFeeFactor + }); + } + + function setMockDelegator( + address _delegator, + uint256 _bondedAmount, + uint256 _fees, + address _delegateAddress, + uint256 _delegatedAmount, + uint256 _startRound, + uint256 _lastClaimRound, + uint256 _nextUnbondingLockId + ) external { + delegatorMocks[_delegator] = DelegatorMock({ + bondedAmount: _bondedAmount, + fees: _fees, + delegateAddress: _delegateAddress, + delegatedAmount: _delegatedAmount, + startRound: _startRound, + lastClaimRound: _lastClaimRound, + nextUnbondingLockId: _nextUnbondingLockId + }); + } + + function getDelegator(address _delegator) + public + view + returns ( + uint256 bondedAmount, + uint256 fees, + address delegateAddress, + uint256 delegatedAmount, + uint256 startRound, + uint256 lastClaimRound, + uint256 nextUnbondingLockId + ) + { + DelegatorMock storage del = delegatorMocks[_delegator]; + + bondedAmount = del.bondedAmount; + fees = del.fees; + delegateAddress = del.delegateAddress; + delegatedAmount = del.delegatedAmount; + startRound = del.startRound; + lastClaimRound = del.lastClaimRound; + nextUnbondingLockId = del.nextUnbondingLockId; + } } diff --git a/contracts/test/mocks/BondingVotesERC5805Harness.sol b/contracts/test/mocks/BondingVotesERC5805Harness.sol new file mode 100644 index 00000000..82367ec8 --- /dev/null +++ b/contracts/test/mocks/BondingVotesERC5805Harness.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "../../bonding/BondingVotes.sol"; +import "./GenericMock.sol"; + +/** + * @dev This is a tets utility for unit tests on the ERC5805 functions of the BondingVotes contract. It overrides the + * functions that should be used to derive the values returned by the ERC5805 functions and checks against those. + */ +contract BondingVotesERC5805Harness is BondingVotes { + constructor(address _controller) BondingVotes(_controller) {} + + /** + * @dev Mocked version that returns transformed version of the input for testing. + * @return amount lowest 4 bytes of address + _round + * @return delegateAddress (_account << 4) | _round. + */ + function getBondingStateAt(address _account, uint256 _round) + public + pure + override + returns (uint256 amount, address delegateAddress) + { + uint160 intAddr = uint160(_account); + + amount = (intAddr & 0xffffffff) + _round; + delegateAddress = address((intAddr << 4) | uint160(_round)); + } + + function getTotalActiveStakeAt(uint256 _round) public pure override returns (uint256) { + return 4 * _round; + } +} diff --git a/contracts/test/mocks/BondingVotesMock.sol b/contracts/test/mocks/BondingVotesMock.sol new file mode 100644 index 00000000..ffb090c7 --- /dev/null +++ b/contracts/test/mocks/BondingVotesMock.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "./GenericMock.sol"; + +contract BondingVotesMock is GenericMock { + event CheckpointBondingState( + address account, + uint256 startRound, + uint256 bondedAmount, + address delegateAddress, + uint256 delegatedAmount, + uint256 lastClaimRound, + uint256 lastRewardRound + ); + event CheckpointTotalActiveStake(uint256 totalStake, uint256 round); + + function checkpointBondingState( + address _account, + uint256 _startRound, + uint256 _bondedAmount, + address _delegateAddress, + uint256 _delegatedAmount, + uint256 _lastClaimRound, + uint256 _lastRewardRound + ) external { + emit CheckpointBondingState( + _account, + _startRound, + _bondedAmount, + _delegateAddress, + _delegatedAmount, + _lastClaimRound, + _lastRewardRound + ); + } + + function checkpointTotalActiveStake(uint256 _totalStake, uint256 _round) external { + emit CheckpointTotalActiveStake(_totalStake, _round); + } +} diff --git a/contracts/test/mocks/GenericMock.sol b/contracts/test/mocks/GenericMock.sol index 682ba155..db986f5a 100644 --- a/contracts/test/mocks/GenericMock.sol +++ b/contracts/test/mocks/GenericMock.sol @@ -59,6 +59,11 @@ contract GenericMock { } } + /** + * @dev Empty receive function as dummy impl to supress compiler warnings. + */ + receive() external payable {} + /** * @dev Call a function on a target address using provided calldata for a function * @param _target Target contract to call with data diff --git a/contracts/test/mocks/SortedArraysFixture.sol b/contracts/test/mocks/SortedArraysFixture.sol new file mode 100644 index 00000000..8c319651 --- /dev/null +++ b/contracts/test/mocks/SortedArraysFixture.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "../../bonding/libraries/SortedArrays.sol"; + +contract SortedArraysFixture { + uint256[] public array; + + function findLowerBound(uint256 val) external view returns (uint256) { + return SortedArrays.findLowerBound(array, val); + } + + function pushSorted(uint256 val) external { + SortedArrays.pushSorted(array, val); + } + + function length() external view returns (uint256) { + return array.length; + } +} diff --git a/contracts/treasury/IVotes.sol b/contracts/treasury/IVotes.sol new file mode 100644 index 00000000..56ec6baf --- /dev/null +++ b/contracts/treasury/IVotes.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "@openzeppelin/contracts-upgradeable/interfaces/IERC5805Upgradeable.sol"; + +interface IVotes is IERC5805Upgradeable { + function totalSupply() external view returns (uint256); + + function delegatedAt(address account, uint256 timepoint) external returns (address); + + // ERC-20 metadata functions that improve compatibility with tools like Tally + + function name() external view returns (string memory); + + function symbol() external view returns (string memory); + + function decimals() external view returns (uint8); +} diff --git a/deploy/deploy_contracts.ts b/deploy/deploy_contracts.ts index 6d365ba0..8212562c 100644 --- a/deploy/deploy_contracts.ts +++ b/deploy/deploy_contracts.ts @@ -143,6 +143,13 @@ const func: DeployFunction = async function(hre: HardhatRuntimeEnvironment) { args: [Controller.address] }) + await contractDeployer.deployAndRegister({ + contract: "BondingVotes", + name: "BondingVotes", + proxy: true, + args: [Controller.address] + }) + // rounds manager let roundsManager if (!isLiveNetwork(hre.network.name)) { @@ -178,6 +185,7 @@ const func: DeployFunction = async function(hre: HardhatRuntimeEnvironment) { // governor const governor = await deploy("Governor", { + contract: "contracts/governance/Governor.sol:Governor", from: deployer, args: [], log: true @@ -185,7 +193,7 @@ const func: DeployFunction = async function(hre: HardhatRuntimeEnvironment) { // Transfer ownership of Governor to governance multisig const Governor: Governor = (await ethers.getContractAt( - "Governor", + "contracts/governance/Governor.sol:Governor", governor.address )) as Governor diff --git a/package.json b/package.json index 89291f02..4d0ecf51 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,8 @@ "@nomiclabs/hardhat-etherscan": "^2.1.4", "@nomiclabs/hardhat-waffle": "^2.0.1", "@nomiclabs/hardhat-web3": "^2.0.0", - "@openzeppelin/contracts": "^4.4.2", + "@openzeppelin/contracts": "^4.9.2", + "@openzeppelin/contracts-upgradeable": "^4.9.2", "@typechain/ethers-v5": "^7.0.1", "@typechain/hardhat": "^2.1.2", "@types/chai": "^4.2.21", diff --git a/src/test/BondingVotesStateInitialization.sol b/src/test/BondingVotesStateInitialization.sol new file mode 100644 index 00000000..c90b26b8 --- /dev/null +++ b/src/test/BondingVotesStateInitialization.sol @@ -0,0 +1,246 @@ +pragma solidity ^0.8.9; + +import "ds-test/test.sol"; +import "./base/GovernorBaseTest.sol"; +import "contracts/ManagerProxy.sol"; +import "contracts/bonding/BondingManager.sol"; +import "contracts/bonding/BondingVotes.sol"; +import "contracts/rounds/RoundsManager.sol"; +import "contracts/token/LivepeerToken.sol"; +import "contracts/snapshots/MerkleSnapshot.sol"; +import "./interfaces/ICheatCodes.sol"; +import "./interfaces/IL2Migrator.sol"; + +// forge test --match-contract BondingVotesStateInitialization --fork-url https://arbitrum-mainnet.infura.io/v3/$INFURA_KEY -vvv --fork-block-number 110930219 +contract BondingVotesStateInitialization is GovernorBaseTest { + address public constant CURRENT_BONDING_MANAGER_TARGET = 0x3a941e1094B9E33efABB26a9047a8ABb4b257907; + BondingManager public constant BONDING_MANAGER = BondingManager(0x35Bcf3c30594191d53231E4FF333E8A770453e40); + RoundsManager public constant ROUNDS_MANAGER = RoundsManager(0xdd6f56DcC28D3F5f27084381fE8Df634985cc39f); + + bytes32 public constant BONDING_MANAGER_TARGET_ID = keccak256("BondingManagerTarget"); + bytes32 public constant BONDING_VOTES_ID = keccak256("BondingVotes"); + bytes32 public constant BONDING_VOTES_TARGET_ID = keccak256("BondingVotesTarget"); + + // Has a non-null delegate as of fork block + address public constant DELEGATOR = 0xed89FFb5F4a7460a2F9B894b494db4F5e431f842; + // Delegate (transcoder) of the above delegator + address public constant DELEGATOR_DELEGATE = 0xBD677e96a755207D348578727AA57A512C2022Bd; + // Another independent transcoder as of fork block + address public constant TRANSCODER = 0x5D98F8d269C94B746A5c3C2946634dCfc75E5E60; + // Initialized on test setup + address nonParticipant; + address[] public _testAddresses; + + BondingManager public newBondingManagerTarget; + BondingVotes public bondingVotesTarget; + IBondingVotes public bondingVotes; + + function setUp() public { + nonParticipant = CHEATS.addr(1); + _testAddresses = [DELEGATOR_DELEGATE, DELEGATOR, TRANSCODER, nonParticipant]; + + newBondingManagerTarget = new BondingManager(address(CONTROLLER)); + bondingVotesTarget = new BondingVotes(address(CONTROLLER)); + + ManagerProxy bondingVotesProxy = new ManagerProxy(address(CONTROLLER), BONDING_VOTES_TARGET_ID); + bondingVotes = IBondingVotes(address(bondingVotesProxy)); + + (, gitCommitHash) = CONTROLLER.getContractInfo(BONDING_MANAGER_TARGET_ID); + + stageAndExecuteOne( + address(CONTROLLER), + 0, + abi.encodeWithSelector( + CONTROLLER.setContractInfo.selector, + BONDING_VOTES_TARGET_ID, + address(bondingVotesTarget), + gitCommitHash + ) + ); + stageAndExecuteOne( + address(CONTROLLER), + 0, + abi.encodeWithSelector( + CONTROLLER.setContractInfo.selector, + BONDING_VOTES_ID, + address(bondingVotes), + gitCommitHash + ) + ); + + // BondingManager deployed last since it depends on checkpoints to be there + stageAndExecuteOne( + address(CONTROLLER), + 0, + abi.encodeWithSelector( + CONTROLLER.setContractInfo.selector, + BONDING_MANAGER_TARGET_ID, + address(newBondingManagerTarget), + gitCommitHash + ) + ); + } + + function testDeploy() public { + // Check that new contracts are registered + (address infoAddr, bytes20 infoGitCommitHash) = fetchContractInfo(BONDING_MANAGER_TARGET_ID); + assertEq(infoAddr, address(newBondingManagerTarget)); + assertEq(infoGitCommitHash, gitCommitHash); + + (infoAddr, infoGitCommitHash) = fetchContractInfo(BONDING_VOTES_TARGET_ID); + assertEq(infoAddr, address(bondingVotesTarget)); + assertEq(infoGitCommitHash, gitCommitHash); + + (infoAddr, infoGitCommitHash) = fetchContractInfo(BONDING_VOTES_ID); + assertEq(infoAddr, address(bondingVotes)); + assertEq(infoGitCommitHash, gitCommitHash); + } + + function testNoAddressHasCheckpoints() public { + assertEq(_testAddresses.length, 4); + + for (uint256 i = 0; i < _testAddresses.length; i++) { + assertTrue(!bondingVotes.hasCheckpoint(_testAddresses[i])); + } + } + + function testReturnsZeroBalanceForUncheckpointedAccount() public { + uint256 currentRound = ROUNDS_MANAGER.currentRound(); + + for (uint256 i = 0; i < _testAddresses.length; i++) { + (uint256 checkedAmount, address checkedDelegate) = bondingVotes.getBondingStateAt( + _testAddresses[i], + currentRound + ); + assertEq(checkedAmount, 0); + assertEq(checkedDelegate, address(0)); + } + } + + function testInitializesCheckpointState() public { + uint256 currentRound = ROUNDS_MANAGER.currentRound(); + + for (uint256 i = 0; i < _testAddresses.length; i++) { + address addr = _testAddresses[i]; + + BONDING_MANAGER.checkpointBondingState(addr); + assertTrue(bondingVotes.hasCheckpoint(addr)); + + // Still returns zero checkpoint in the current round, checkpoint is made for the next. + // We don't check delegatedAmount for simplicity here, it is checked in the other tests. + (, address checkedDelegate) = bondingVotes.getBondingStateAt(addr, currentRound); + assertEq(checkedDelegate, address(0)); + + // Allows querying up to the next round. + (, checkedDelegate) = bondingVotes.getBondingStateAt(addr, currentRound + 1); + assertEq( + checkedDelegate, + addr == DELEGATOR || addr == DELEGATOR_DELEGATE ? DELEGATOR_DELEGATE : addr == TRANSCODER + ? TRANSCODER + : address(0) + ); + + // Disallows querying further than the next round though + CHEATS.expectRevert( + abi.encodeWithSelector(IBondingVotes.FutureLookup.selector, currentRound + 2, currentRound + 1) + ); + bondingVotes.getBondingStateAt(addr, currentRound + 2); + } + } + + function testAllowsQueryingTranscoderStateOnNextRound() public { + uint256 currentRound = ROUNDS_MANAGER.currentRound(); + (, , , uint256 delegatedAmount, , , ) = BONDING_MANAGER.getDelegator(TRANSCODER); + + BONDING_MANAGER.checkpointBondingState(TRANSCODER); + + (uint256 checkedAmount, address checkedDelegate) = bondingVotes.getBondingStateAt(TRANSCODER, currentRound + 1); + assertEq(checkedAmount, delegatedAmount); + assertEq(checkedDelegate, TRANSCODER); + } + + function testAllowsQueryingDelegatorStateOnNextRound() public { + (, , address delegateAddress, , , , ) = BONDING_MANAGER.getDelegator(DELEGATOR); + assertEq(delegateAddress, DELEGATOR_DELEGATE); + + uint256 currentRound = ROUNDS_MANAGER.currentRound(); + uint256 pendingStake = BONDING_MANAGER.pendingStake(DELEGATOR, currentRound); + + BONDING_MANAGER.checkpointBondingState(DELEGATOR); + // the delegate also needs to be checkpointed in case of delegators + BONDING_MANAGER.checkpointBondingState(DELEGATOR_DELEGATE); + + (uint256 checkedAmount, address checkedDelegate) = bondingVotes.getBondingStateAt(DELEGATOR, currentRound + 1); + + assertEq(checkedAmount, pendingStake); + assertEq(checkedDelegate, DELEGATOR_DELEGATE); + } + + function testDoesNotHaveTotalActiveStakeImmediately() public { + uint256 currentRound = ROUNDS_MANAGER.currentRound(); + + assertEq(bondingVotes.getTotalActiveStakeAt(currentRound), 0); + } + + function testReturnsZeroTotalActiveStakeIfNoCheckpointsMade() public { + uint256 currentRound = ROUNDS_MANAGER.currentRound(); + assertEq(bondingVotes.getTotalActiveStakeAt(currentRound), 0); + } + + function testReturnsNextRoundTotalActiveStakeIfAfterLastCheckpoint() public { + uint256 currentRound = ROUNDS_MANAGER.currentRound(); + + uint256 nextRoundStartBlock = ROUNDS_MANAGER.currentRoundStartBlock() + ROUNDS_MANAGER.roundLength(); + CHEATS.roll(nextRoundStartBlock); + ROUNDS_MANAGER.initializeRound(); + + CHEATS.roll(nextRoundStartBlock + 2 * ROUNDS_MANAGER.roundLength()); + assertEq(ROUNDS_MANAGER.currentRound(), currentRound + 3); + + uint256 expected = BONDING_MANAGER.nextRoundTotalActiveStake(); + assertEq(bondingVotes.getTotalActiveStakeAt(currentRound + 2), expected); + assertEq(bondingVotes.getTotalActiveStakeAt(currentRound + 3), expected); + } + + function testDoesNotUseFutureCheckpointForTotalActiveStake() public { + uint256 currentRound = ROUNDS_MANAGER.currentRound(); + + uint256 nextRoundStartBlock = ROUNDS_MANAGER.currentRoundStartBlock() + ROUNDS_MANAGER.roundLength(); + CHEATS.roll(nextRoundStartBlock); + ROUNDS_MANAGER.initializeRound(); + assertEq(ROUNDS_MANAGER.currentRound(), currentRound + 1); + + assertEq(bondingVotes.getTotalActiveStakeAt(currentRound), 0); + } + + function testUsesNextRoundTotalActiveStakeForCurrentRounds() public { + uint256 currentRound = ROUNDS_MANAGER.currentRound(); + + uint256 nextRoundStartBlock = ROUNDS_MANAGER.currentRoundStartBlock() + ROUNDS_MANAGER.roundLength(); + CHEATS.roll(nextRoundStartBlock); + ROUNDS_MANAGER.initializeRound(); + + nextRoundStartBlock = ROUNDS_MANAGER.currentRoundStartBlock() + ROUNDS_MANAGER.roundLength(); + CHEATS.roll(nextRoundStartBlock); + assertEq(ROUNDS_MANAGER.currentRound(), currentRound + 2); + + uint256 expected = BONDING_MANAGER.nextRoundTotalActiveStake(); + assertEq(bondingVotes.getTotalActiveStakeAt(currentRound + 2), expected); + // should work up to the next round as well + assertEq(bondingVotes.getTotalActiveStakeAt(currentRound + 3), expected); + } + + function testCheckpointsTotalActiveStakeOnInitializeRound() public { + uint256 currentRound = ROUNDS_MANAGER.currentRound(); + + uint256 nextRoundStartBlock = ROUNDS_MANAGER.currentRoundStartBlock() + ROUNDS_MANAGER.roundLength(); + CHEATS.roll(nextRoundStartBlock); + ROUNDS_MANAGER.initializeRound(); + assertEq(ROUNDS_MANAGER.currentRound(), currentRound + 1); + + uint256 totalBonded = BONDING_MANAGER.getTotalBonded(); + + uint256 totalAcctiveStakeChk = bondingVotes.getTotalActiveStakeAt(currentRound + 1); + assertEq(totalAcctiveStakeChk, totalBonded); + } +} diff --git a/src/test/interfaces/ICheatCodes.sol b/src/test/interfaces/ICheatCodes.sol index 3e1c5860..6e2ed9ff 100644 --- a/src/test/interfaces/ICheatCodes.sol +++ b/src/test/interfaces/ICheatCodes.sol @@ -9,6 +9,8 @@ interface ICheatCodes { function stopPrank() external; + function expectRevert(bytes4 message) external; + function expectRevert(bytes calldata) external; function expectEmit( diff --git a/test/gas-report/checkpoints.js b/test/gas-report/checkpoints.js new file mode 100644 index 00000000..f73ba31e --- /dev/null +++ b/test/gas-report/checkpoints.js @@ -0,0 +1,141 @@ +import RPC from "../../utils/rpc" +import {contractId} from "../../utils/helpers" + +import {ethers} from "hardhat" +import setupIntegrationTest from "../helpers/setupIntegrationTest" + +import chai from "chai" +import {solidity} from "ethereum-waffle" +chai.use(solidity) + +describe("checkpoint bonding state gas report", () => { + let rpc + let snapshotId + + let controller + let bondingManager + let bondingVotes + let roundsManager + let token + + let transcoder + let delegator + + const stake = 1000 + let currentRound + + let signers + + before(async () => { + rpc = new RPC(web3) + signers = await ethers.getSigners() + transcoder = signers[0] + delegator = signers[1] + + const fixture = await setupIntegrationTest() + controller = await ethers.getContractAt( + "Controller", + fixture.Controller.address + ) + + bondingManager = await ethers.getContractAt( + "BondingManager", + fixture.BondingManager.address + ) + + roundsManager = await ethers.getContractAt( + "AdjustableRoundsManager", + fixture.AdjustableRoundsManager.address + ) + + token = await ethers.getContractAt( + "LivepeerToken", + fixture.LivepeerToken.address + ) + + roundLength = await roundsManager.roundLength() + + await controller.unpause() + + // Register transcoder and delegator + await token.transfer(transcoder.address, stake) + await token.connect(transcoder).approve(bondingManager.address, stake) + await bondingManager.connect(transcoder).bond(stake, transcoder.address) + + await token.transfer(delegator.address, stake) + await token.connect(delegator).approve(bondingManager.address, stake) + await bondingManager.connect(delegator).bond(stake, transcoder.address) + + // Fast forward to start of new round to lock in active set + const roundLength = await roundsManager.roundLength() + await roundsManager.mineBlocks(roundLength.toNumber()) + await roundsManager.initializeRound() + + currentRound = await roundsManager + .currentRound() + .then(bn => bn.toNumber()) + + // Deploy a new BondingVotes contract so we can simulate a fresh deploy on existing BondingManager state + const [, gitCommitHash] = await controller.getContractInfo( + contractId("BondingVotes") + ) + bondingVotes = await ethers + .getContractFactory("BondingVotes") + .then(fac => fac.deploy(controller.address)) + await controller.setContractInfo( + contractId("BondingVotes"), + bondingVotes.address, + gitCommitHash + ) + }) + + beforeEach(async () => { + snapshotId = await rpc.snapshot() + }) + + afterEach(async () => { + await rpc.revert(snapshotId) + }) + + describe("checkpointBondingState", () => { + it("delegator", async () => { + await bondingManager.checkpointBondingState(delegator.address) + }) + + it("transcoder", async () => { + await bondingManager.checkpointBondingState(transcoder.address) + }) + + it("non-participant", async () => { + await bondingManager.checkpointBondingState(signers[99].address) + }) + }) + + describe("getBondingStateAt", () => { + beforeEach(async () => { + await bondingManager.checkpointBondingState(transcoder.address) + await bondingManager.checkpointBondingState(delegator.address) + await bondingManager.checkpointBondingState(signers[99].address) + }) + + const gasGetBondingStateAt = async (address, round) => { + const tx = await bondingVotes.populateTransaction.getBondingStateAt( + address, + round + ) + await signers[0].sendTransaction(tx) + } + + it("delegator", async () => { + await gasGetBondingStateAt(delegator.address, currentRound + 1) + }) + + it("transcoder", async () => { + await gasGetBondingStateAt(transcoder.address, currentRound + 1) + }) + + it("non-participant", async () => { + await gasGetBondingStateAt(signers[99].address, currentRound + 1) + }) + }) +}) diff --git a/test/integration/BondingVotes.js b/test/integration/BondingVotes.js new file mode 100644 index 00000000..02282d32 --- /dev/null +++ b/test/integration/BondingVotes.js @@ -0,0 +1,914 @@ +import RPC from "../../utils/rpc" +import setupIntegrationTest from "../helpers/setupIntegrationTest" + +import chai, {assert} from "chai" +import {ethers} from "hardhat" +import {solidity} from "ethereum-waffle" +import {BigNumber, constants} from "ethers" + +import math from "../helpers/math" + +chai.use(solidity) +const {expect} = chai + +describe("BondingVotes", () => { + let rpc + + let signers + let bondingVotes + let bondingManager + let roundsManager + let roundLength + let token + let minter + + const PERC_DIVISOR = 1000000 + const PERC_MULTIPLIER = PERC_DIVISOR / 100 + + const lptAmount = amount => ethers.utils.parseEther("1").mul(amount) + + before(async () => { + rpc = new RPC(web3) + + signers = await ethers.getSigners() + const fixture = await setupIntegrationTest() + + bondingManager = await ethers.getContractAt( + "BondingManager", + fixture.BondingManager.address + ) + + bondingVotes = await ethers.getContractAt( + "BondingVotes", + fixture.BondingVotes.address + ) + + token = await ethers.getContractAt( + "LivepeerToken", + fixture.LivepeerToken.address + ) + + minter = await ethers.getContractAt("Minter", fixture.Minter.address) + // simplify inflation calculations by making it fixed + await minter.setInflationChange(0) + mintableTokens = {} + + roundsManager = await ethers.getContractAt( + "AdjustableRoundsManager", + fixture.AdjustableRoundsManager.address + ) + roundLength = (await roundsManager.roundLength()).toNumber() + + const controller = await ethers.getContractAt( + "Controller", + fixture.Controller.address + ) + await controller.unpause() + }) + + // We re-define the before function for the sub-tests so it automatically + // reverts any changes made on their set-up. + const mochaBefore = before + before = async setupFn => { + let snapshotId + + mochaBefore(async () => { + snapshotId = await rpc.snapshot() + + await setupFn() + }) + + after(async () => { + await rpc.revert(snapshotId) + }) + } + + let mintableTokens + + const nextRound = async (rounds = 1) => { + await roundsManager.mineBlocks(rounds * roundLength) + await roundsManager.initializeRound() + const currRound = (await roundsManager.currentRound()).toNumber() + mintableTokens[currRound] = await minter.currentMintableTokens() + return currRound + } + + const bond = async (delegator, amount, transcoder) => { + await token.transfer(delegator.address, amount) + await token.connect(delegator).approve(bondingManager.address, amount) + await bondingManager.connect(delegator).bond(amount, transcoder.address) + } + + describe("single active transcoder", () => { + let transcoder + let delegator + let currentRound + + before(async () => { + transcoder = signers[0] + delegator = signers[1] + + // Initialize the first round ever + await nextRound() + + for (const account of [transcoder, delegator]) { + await bondingManager.checkpointBondingState(account.address) + } + + // Round R-2 + await nextRound() + + await bond(transcoder, lptAmount(1), transcoder) + await bondingManager + .connect(transcoder) + .transcoder(50 * PERC_MULTIPLIER, 25 * PERC_MULTIPLIER) + + // Round R-1 + await nextRound() + + await bond(delegator, lptAmount(1), transcoder) + + // Round R + currentRound = await nextRound() + + await bondingManager.connect(transcoder).reward() + + // Round R+1 + await nextRound() + + await bondingManager.connect(transcoder).reward() + + // Round R+2 + await nextRound() + }) + + describe("getBondingStateAt", () => { + it("should return partial rewards for any rounds since bonding", async () => { + const pendingRewards0 = math.precise.percOf( + mintableTokens[currentRound].div(2), // 50% cut rate + lptAmount(1), // delegator stake + lptAmount(2) // transcoder stake + ) + const pendingRewards1 = math.precise.percOf( + mintableTokens[currentRound + 1].div(2), + lptAmount(1).add(pendingRewards0), + lptAmount(2).add(mintableTokens[1]) + ) + + const stakeAt = round => + bondingVotes + .getBondingStateAt(delegator.address, round) + .then(n => n[0].toString()) + + assert.equal(await stakeAt(2), 0) + assert.equal(await stakeAt(currentRound - 1), 0) + + let stake = lptAmount(1) // bonded on previous round + assert.equal(await stakeAt(currentRound), stake.toString()) + + stake = stake.add(pendingRewards0) // reward call + assert.equal(await stakeAt(currentRound + 1), stake.toString()) + + stake = stake.add(pendingRewards1) // reward call + assert.equal(await stakeAt(currentRound + 2), stake.toString()) + }) + + it("should return partial rewards for all transcoder stake", async () => { + const stakeAt = round => + bondingVotes + .getBondingStateAt(transcoder.address, round) + .then(n => n[0].toString()) + + assert.equal(await stakeAt(2), 0) + assert.equal(await stakeAt(currentRound - 2), 0) + + let stake = lptAmount(1) // transcoder bonded on previous round + assert.equal(await stakeAt(currentRound - 1), stake) + + stake = stake.add(lptAmount(1)) // delegator bonded on previous round + assert.equal(await stakeAt(currentRound), stake) + + stake = lptAmount(2).add(mintableTokens[currentRound]) // reward call + assert.equal(await stakeAt(currentRound + 1), stake) + + stake = stake.add(mintableTokens[currentRound + 1]) // reward call + assert.equal(await stakeAt(currentRound + 2), stake) + }) + }) + + describe("getTotalActiveStakeAt", () => { + const totalStakeAt = round => + bondingVotes + .getTotalActiveStakeAt(round) + .then(n => n.toString()) + + it("should return total stake at any point in time", async () => { + assert.equal(await totalStakeAt(2), 0) + assert.equal(await totalStakeAt(currentRound - 2), 0) + + let stake = lptAmount(1) // transcoder bonded on previous round + assert.equal(await totalStakeAt(currentRound - 1), stake) + + stake = stake.add(lptAmount(1)) // delegator bonded on previous round + assert.equal(await totalStakeAt(currentRound), stake) + + stake = lptAmount(2).add(mintableTokens[currentRound]) // reward call + assert.equal(await totalStakeAt(currentRound + 1), stake) + + stake = stake.add(mintableTokens[currentRound + 1]) // reward call + assert.equal(await totalStakeAt(currentRound + 2), stake) + }) + }) + }) + + describe("inactive transcoders with stake", () => { + let transcoders = [] + let activeTranscoders = [] + let delegators = [] + let currentRound + + const pendingStakesByRound = {} + const totalActiveStakeByRound = {} + + const nextRoundAndSnapshot = async () => { + const round = await nextRound() + + pendingStakesByRound[round] = {} + for (const account of transcoders) { + pendingStakesByRound[round][account.address] = ( + await bondingManager.transcoderTotalStake(account.address) + ).toString() + } + for (const account of delegators) { + pendingStakesByRound[round][account.address] = ( + await bondingManager.pendingStake(account.address, 0) + ).toString() + } + + totalActiveStakeByRound[round] = ( + await bondingManager.getTotalBonded() + ).toString() + + return round + } + + before(async () => { + // migrations.config.ts defines default net numActiveTranscoders as 10 + activeTranscoders = signers.slice(0, 10) + transcoders = signers.slice(0, 11) + delegators = signers.slice(12, 23) + + // Initialize the first round ever + await nextRound() + + for (const account of [...transcoders, ...delegators]) { + await bondingManager.checkpointBondingState(account.address) + } + + // Round R-2 + await nextRoundAndSnapshot() + + // make every transcoder with the same self-delegated stake + for (const transcoder of transcoders) { + await bond(transcoder, lptAmount(10), transcoder) + await bondingManager + .connect(transcoder) + .transcoder(50 * PERC_MULTIPLIER, 25 * PERC_MULTIPLIER) + } + + // Round R-1 + await nextRoundAndSnapshot() + + for (const i = 0; i < delegators.length; i++) { + // Distribute stake regressively so the last T is always inactive + const amount = lptAmount(11 - i) + await bond(delegators[i], amount, transcoders[i]) + } + + // Round R + currentRound = await nextRoundAndSnapshot() + + for (const transcoder of activeTranscoders) { + await bondingManager.connect(transcoder).reward() + } + + // Round R+1 + await nextRoundAndSnapshot() + + for (const transcoder of activeTranscoders) { + await bondingManager.connect(transcoder).reward() + } + + // Round R+2 + await nextRoundAndSnapshot() + }) + + it("active transcoder count should match BondingManager config", async () => { + const maxSize = await bondingManager.getTranscoderPoolMaxSize() + assert.equal(maxSize.toString(), activeTranscoders.length) + }) + + it("should have all active transcoders but the last one", async () => { + const isActive = a => bondingManager.isActiveTranscoder(a) + for (const transcoder of activeTranscoders) { + assert.isTrue(await isActive(transcoder.address)) + } + + const inactiveTranscoder = transcoders[transcoders.length - 1] + assert.isFalse(await isActive(inactiveTranscoder.address)) + }) + + describe("getBondingStateAt", () => { + it("should provide voting power even for inactive transcoders and their delegators", async () => { + const transcoder = transcoders[transcoders.length - 1].address + const delegator = delegators[delegators.length - 1].address + + const testHasStake = async (address, round) => { + const [stake] = await bondingVotes.getBondingStateAt( + address, + round + ) + assert.isAbove( + stake, + 0, + `expected non-zero stake checkpoint at round ${round} for account ${address}` + ) + } + + // transcoders self-bond at R-2 so start from the next round + for (const r = currentRound - 1; r < currentRound + 2; r++) { + await testHasStake(transcoder, r) + + // delegators only bond at R-1 + if (r >= currentRound) { + await testHasStake(delegator, r) + } + } + }) + + it("should return exactly the account pendingStake in the corresponding round", async () => { + for (const round of Object.keys(pendingStakesByRound)) { + const pendingStakes = pendingStakesByRound[round] + + for (const address of Object.keys(pendingStakes)) { + const expectedStake = pendingStakes[address] + + const [stakeCheckpoint] = + await bondingVotes.getBondingStateAt(address, round) + assert.equal( + stakeCheckpoint.toString(), + expectedStake, + `stake mismatch at round ${round} for account ${address}` + ) + } + } + }) + }) + + describe("getTotalActiveStakeAt", () => { + it("should return total supply from only the active stake at any point in time", async () => { + for (const round of Object.keys(totalActiveStakeByRound)) { + const totalStakeCheckpoint = + await bondingVotes.getTotalActiveStakeAt(round) + assert.equal( + totalStakeCheckpoint.toString(), + totalActiveStakeByRound[round], + `total supply mismatch at round ${round}` + ) + } + }) + + it("should actually match the sum of all active transcoders stake", async () => { + for (const r = currentRound - 2; r <= currentRound + 2; r++) { + const activeStakeSum = BigNumber.from(0) + for (const transcoder of activeTranscoders) { + const [stake] = await bondingVotes.getBondingStateAt( + transcoder.address, + r + ) + activeStakeSum = activeStakeSum.add(stake) + } + + const totalStake = await bondingVotes.getTotalActiveStakeAt( + r + ) + assert.equal( + totalStake.toString(), + activeStakeSum.toString(), + `total supply mismatch at round ${r}` + ) + } + }) + }) + }) + + describe("intermittent reward-calling transcoder", () => { + let transcoder + let delegatorEarly + let delegator + let currentRound + + before(async () => { + transcoder = signers[0] + delegator = signers[1] + delegatorEarly = signers[2] + + // Initialize the first round ever + await nextRound() + + for (const account of [transcoder, delegator, delegatorEarly]) { + await bondingManager.checkpointBondingState(account.address) + } + + // Round R-202 + await nextRound() + + await bond(transcoder, lptAmount(1), transcoder) + await bondingManager + .connect(transcoder) + .transcoder(50 * PERC_MULTIPLIER, 25 * PERC_MULTIPLIER) + + await bond(delegatorEarly, lptAmount(1), transcoder) + + // Round R-201 + await nextRound() + + await bondingManager.connect(transcoder).reward() + + // Round R-200 + await nextRound() + + await bond(delegator, lptAmount(1), transcoder) + + // Now hibernate for 200 rounds... + for (const i = 0; i < 200; i++) { + // Round R-200 until R that gets set to currentRound + currentRound = await nextRound() + } + + // Round R+1 + await nextRound() + + await bondingManager.connect(transcoder).reward() + + // Round R+2 + await nextRound() + + // Round R+3 + await nextRound() + }) + + describe("getBondingStateAt", () => { + const stakeAt = (account, round) => + bondingVotes + .getBondingStateAt(account.address, round) + .then(n => n[0].toString()) + const expectStakeAt = async (account, round, expected) => { + assert.equal( + await stakeAt(account, round), + expected.toString(), + `stake mismatch at round ${round}` + ) + } + + it("consistent stake for delegator that had never observed a reward on the call gap", async () => { + await expectStakeAt(delegator, currentRound - 200, 0) // bond made on this round + + let stake = lptAmount(1) + await expectStakeAt(delegator, currentRound - 199, stake) // transcoder is gone from here until currRound+1 + await expectStakeAt(delegator, currentRound - 99, stake) + await expectStakeAt(delegator, currentRound, stake) + await expectStakeAt(delegator, currentRound + 1, stake) // reward is called again here + + const transcoderStake = lptAmount(3).add( + mintableTokens[currentRound - 201] + ) + const pendingRewards0 = math.precise.percOf( + mintableTokens[currentRound + 1].div(2), // 50% cut rate + stake, + transcoderStake + ) + stake = stake.add(pendingRewards0) + await expectStakeAt(delegator, currentRound + 2, stake) + await expectStakeAt(delegator, currentRound + 3, stake) + }) + + it("consistent stake for delegator that had unclaimed rewards", async () => { + await expectStakeAt(delegatorEarly, currentRound - 202, 0) // bond is made here + + let stake = lptAmount(1) + await expectStakeAt(delegatorEarly, currentRound - 201, stake) // reward is called first time + + const pendingRewards0 = math.precise.percOf( + mintableTokens[currentRound - 201].div(2), // 50% cut rate + lptAmount(1), // delegator stake + lptAmount(2) // transcoder stake + ) + stake = stake.add(pendingRewards0) + await expectStakeAt(delegatorEarly, currentRound - 200, stake) // transcoder is gone from here until currRound+1 + await expectStakeAt(delegatorEarly, currentRound - 199, stake) + await expectStakeAt(delegatorEarly, currentRound - 99, stake) + await expectStakeAt(delegatorEarly, currentRound, stake) + await expectStakeAt(delegatorEarly, currentRound + 1, stake) // reward called again + + const pendingRewards1 = math.precise.percOf( + mintableTokens[currentRound + 1].div(2), // 50% cut rate + stake, + lptAmount(3).add(mintableTokens[currentRound - 201]) // transcoder stake (another delegator added 1 LPT) + ) + stake = stake.add(pendingRewards1) + await expectStakeAt(delegatorEarly, currentRound + 2, stake) + await expectStakeAt(delegatorEarly, currentRound + 3, stake) + }) + + it("for the intermittent transcoder itself", async () => { + await expectStakeAt(transcoder, currentRound - 202, 0) // both transcoder and delegator bond 1000 + + let stake = lptAmount(2) + await expectStakeAt(transcoder, currentRound - 201, stake) // reward is called first time + + stake = stake.add(mintableTokens[currentRound - 201]) + await expectStakeAt(transcoder, currentRound - 200, stake) // late delegator bonds 1 LPT more + + stake = stake.add(lptAmount(1)) + await expectStakeAt(transcoder, currentRound - 199, stake) + await expectStakeAt(transcoder, currentRound - 99, stake) + await expectStakeAt(transcoder, currentRound, stake) + await expectStakeAt(transcoder, currentRound + 1, stake) // reward called again + + stake = stake.add(mintableTokens[currentRound + 1]) + await expectStakeAt(transcoder, currentRound + 2, stake) + await expectStakeAt(transcoder, currentRound + 3, stake) + }) + }) + + describe("getTotalActiveStakeAt", () => { + const totalStakeAt = round => + bondingVotes + .getTotalActiveStakeAt(round) + .then(n => n.toString()) + const expectTotalStakeAt = async (round, expected) => { + assert.equal( + await totalStakeAt(round), + expected.toString(), + `total stake mismatch at round ${round}` + ) + } + + it("maintains all history", async () => { + await expectTotalStakeAt(currentRound - 202, 0) // both transcoder and delegator bond 1000 + + let total = lptAmount(2) + await expectTotalStakeAt(currentRound - 201, total) // reward is called first time + + total = total.add(mintableTokens[currentRound - 201]) + await expectTotalStakeAt(currentRound - 200, total) // late delegator bonds more 1000 + + total = total.add(lptAmount(1)) + await expectTotalStakeAt(currentRound - 199, total) + await expectTotalStakeAt(currentRound - 99, total) + await expectTotalStakeAt(currentRound, total) + await expectTotalStakeAt(currentRound + 1, total) // reward called again + + total = total.add(mintableTokens[currentRound + 1]) + await expectTotalStakeAt(currentRound + 2, total) + await expectTotalStakeAt(currentRound + 3, total) + }) + }) + }) + + describe("corner cases", () => { + let transcoder + let delegator + let currentRound + + before(async () => { + transcoder = signers[0] + delegator = signers[1] + + // Initialize the first round ever + await nextRound(10) + + for (const account of [transcoder, delegator]) { + await bondingManager.checkpointBondingState(account.address) + } + + // Round R-1 + await nextRound() + + await bond(transcoder, lptAmount(1), transcoder) + await bondingManager + .connect(transcoder) + .transcoder(50 * PERC_MULTIPLIER, 25 * PERC_MULTIPLIER) + + // Round R + currentRound = await nextRound() + + // Stop setup now and let sub-tests do their thing + }) + + const expectStakeAt = async (account, round, expected, delegate) => { + const stakeAndAddress = await bondingVotes.getBondingStateAt( + account.address, + round + ) + assert.equal( + stakeAndAddress[0].toString(), + expected.toString(), + `stake mismatch at round ${round}` + ) + if (delegate) { + assert.equal( + stakeAndAddress[1].toString(), + delegate, + `delegate mismatch at round ${round}` + ) + } + } + + const totalStakeAt = round => + bondingVotes.getTotalActiveStakeAt(round).then(n => n.toString()) + const expectTotalStakeAt = async (round, expected) => { + assert.equal( + await totalStakeAt(round), + expected, + `total stake mismatch at round ${round}` + ) + } + + describe("delegator with no stake", () => { + before(async () => { + // Round R + await bond(delegator, lptAmount(1), transcoder) + + // Round R+1 + await nextRound() + + await bondingManager.connect(delegator).unbond(lptAmount(1)) + + // Round R+2 + await nextRound() + }) + + it("should not have stake before any bonding", async () => { + const expectNoStakeAt = r => + expectStakeAt(delegator, r, 0, constants.AddressZero) + + await expectNoStakeAt(currentRound - 1) + await expectNoStakeAt(currentRound) + }) + + it("should not have stake after unbonding", async () => { + const testCases = [ + [delegator, currentRound, 0, constants.AddressZero], + [ + delegator, + currentRound + 1, + lptAmount(1), + transcoder.address + ], + [delegator, currentRound + 2, 0, constants.AddressZero] + ] + for (const [acc, r, expStake, expDel] of testCases) { + await expectStakeAt(acc, r, expStake, expDel) + } + }) + }) + + describe("self-delegated-only active transcoder", () => { + before(async () => { + // call reward in a couple of rounds + for (const r = currentRound; r <= currentRound + 10; r++) { + await bondingManager.connect(transcoder).reward() + + // Rounds R - R+10 + await nextRound() + } + }) + + it("should have consistent checkpoints for reward accruing stake", async () => { + await expectStakeAt( + transcoder, + currentRound - 1, // bond was made at this round so stake should be 0 + 0, + constants.AddressZero + ) + + let expectedStake = lptAmount(1) + for (const r = currentRound; r <= currentRound + 10; r++) { + await expectStakeAt( + transcoder, + r, + expectedStake, + transcoder.address + ) + expectedStake = expectedStake.add(mintableTokens[r]) + } + }) + }) + + describe("rounds without initialization", () => { + before(async () => { + // Round R + await bond(delegator, lptAmount(1), transcoder) + + // then let's do a 50 round init gap + + // Round R+50 + const round = await nextRound(50) + assert.equal(round, currentRound + 50) + + await bondingManager.connect(transcoder).reward() + + // then let's do another 50 round call gap + + // Round R+100 + await nextRound(50) + + // Round R+101 + await nextRound() + }) + + it("should have checkpoints during gap for transcoder", async () => { + const rewards = mintableTokens[currentRound + 50] + const testCases = [ + [currentRound, lptAmount(1)], + [currentRound + 1, lptAmount(2)], + [currentRound + 2, lptAmount(2)], + [currentRound + 50, lptAmount(2)], + [currentRound + 51, lptAmount(2).add(rewards)], + [currentRound + 75, lptAmount(2).add(rewards)], + [currentRound + 100, lptAmount(2).add(rewards)], + [currentRound + 101, lptAmount(2).add(rewards)] + ] + for (const [round, stake] of testCases) { + await expectStakeAt( + transcoder, + round, + stake, + transcoder.address + ) + } + }) + + it("should have checkpoints during gap for delegator", async () => { + await expectStakeAt( + delegator, + currentRound, // bonding was made here so stake is still 0 + 0, + constants.AddressZero + ) + + const rewards = math.precise.percOf( + mintableTokens[currentRound + 50].div(2), // 50% reward cut + lptAmount(1), // delegator stake + lptAmount(2) // transcoder stake + ) + const testCases = [ + [currentRound + 1, lptAmount(1)], + [currentRound + 2, lptAmount(1)], + [currentRound + 50, lptAmount(1)], + [currentRound + 51, lptAmount(1).add(rewards)], + [currentRound + 75, lptAmount(1).add(rewards)], + [currentRound + 100, lptAmount(1).add(rewards)], + [currentRound + 101, lptAmount(1).add(rewards)] + ] + for (const [round, stake] of testCases) { + await expectStakeAt( + delegator, + round, + stake, + transcoder.address + ) + } + }) + + it("should return zero total active stake before the first initialized round", async () => { + // first checkpointed round was R-2 + for (const i = 3; i <= 10; i++) { + const round = currentRound - i + await expectTotalStakeAt(round, 0) + } + }) + + it("should return the next checkpointed round stake on uninitialized rounds", async () => { + await expectTotalStakeAt(currentRound - 1, 0) // transcoder bonds here + await expectTotalStakeAt(currentRound, lptAmount(1)) // delegator bonds here + + // initialize gap, return the state at the end of the gap + await expectTotalStakeAt(currentRound + 1, lptAmount(2)) + await expectTotalStakeAt(currentRound + 25, lptAmount(2)) + await expectTotalStakeAt(currentRound + 49, lptAmount(2)) + + // this is initialized round + await expectTotalStakeAt(currentRound + 50, lptAmount(2)) // transcoder also calls reward here + const totalStake = lptAmount(2).add( + mintableTokens[currentRound + 50] + ) + + // same thing here, the stake from currentRound + 100 will be returned + await expectTotalStakeAt(currentRound + 51, totalStake) + await expectTotalStakeAt(currentRound + 75, totalStake) + await expectTotalStakeAt(currentRound + 99, totalStake) + + // last round to be initialized + await expectTotalStakeAt( + currentRound + 100, + lptAmount(2).add(mintableTokens[currentRound + 50]) + ) + + // next rounds to be initialized, including current + await expectTotalStakeAt(currentRound + 100, totalStake) + await expectTotalStakeAt(currentRound + 101, totalStake) + }) + + it("should return the nextRountTotalActiveStake for rounds after the last initialized", async () => { + // sanity check + expect(await roundsManager.currentRound()).to.equal( + currentRound + 101 + ) + + const totalStake = lptAmount(2).add( + mintableTokens[currentRound + 50] + ) + await expectTotalStakeAt(currentRound + 101, totalStake) + // this is already the next round, which has the same active stake as the current + await expectTotalStakeAt(currentRound + 102, totalStake) + + // now add some stake to the system and the next round should return the updated value, which is + // consistent to what gets returned by the checkpointed bonding state on the next round as well. + await bond(delegator, lptAmount(1), transcoder) + await expectTotalStakeAt( + currentRound + 102, + totalStake.add(lptAmount(1)) + ) + }) + }) + + describe("delegator changing delegate address", () => { + let transcoder2 + + const halfLPT = lptAmount(1).div(2) + + before(async () => { + transcoder2 = signers[3] + + // Round R + await bond(transcoder2, lptAmount(1), transcoder2) + await bondingManager + .connect(transcoder2) + .transcoder(50 * PERC_MULTIPLIER, 25 * PERC_MULTIPLIER) + + await bond(delegator, halfLPT, transcoder) + + // Round R+1 + await nextRound() + + // only transcoder 2 calls reward so delegator migrates on next round + await bondingManager.connect(transcoder2).reward() + + // Round R+2 + await nextRound() + + await bond(delegator, halfLPT, transcoder2) + + await bondingManager.connect(transcoder2).reward() + + // Round R+3 + await nextRound() + + await bondingManager.connect(transcoder2).reward() + + // Round R+4 + await nextRound() + + await bondingManager.connect(transcoder2).reward() + + // Round R+5 + await nextRound() + }) + + it("should have valid bonded amount and delegate checkpoints", async () => { + const testCases = [ + [currentRound, 0, constants.AddressZero], + [currentRound + 1, halfLPT, transcoder.address], + [currentRound + 2, halfLPT, transcoder.address], + [currentRound + 3, lptAmount(1), transcoder2.address], + [ + currentRound + 4, + "1122610020423585937", // 1 LPT + rewards + transcoder2.address + ], + [ + currentRound + 5, + "1239149758727968097", // 1 LPT + 2 * rewards + transcoder2.address + ] + ] + for (const [r, expStake, expDel] of testCases) { + await expectStakeAt(delegator, r, expStake, expDel) + } + }) + }) + }) +}) diff --git a/test/integration/GovernorUpdate.js b/test/integration/GovernorUpdate.js index 345ef216..88c893c3 100644 --- a/test/integration/GovernorUpdate.js +++ b/test/integration/GovernorUpdate.js @@ -27,7 +27,9 @@ describe("Governor update", () => { fixture.BondingManager.address ) minter = await ethers.getContractAt("Minter", fixture.Minter.address) - const governorFac = await ethers.getContractFactory("Governor") + const governorFac = await ethers.getContractFactory( + "contracts/governance/Governor.sol:Governor" + ) governor = await governorFac.deploy() await controller.unpause() diff --git a/test/unit/BondingManager.js b/test/unit/BondingManager.js index 00c9604d..a449c74c 100644 --- a/test/unit/BondingManager.js +++ b/test/unit/BondingManager.js @@ -4,6 +4,7 @@ import { functionSig, functionEncodedABI } from "../../utils/helpers" +import expectCheckpoints from "./helpers/expectCheckpoints" import {constants} from "../../utils/constants" import math from "../helpers/math" import {assert} from "chai" @@ -1850,6 +1851,38 @@ describe("BondingManager", () => { ) }) }) + + it("should checkpoint the delegator and transcoder states", async () => { + // make sure trancoder has a non-null `lastRewardRound` + await bondingManager.connect(transcoder0).reward() + + const tx = await bondingManager + .connect(delegator) + .bond(1000, transcoder0.address) + + await expectCheckpoints( + fixture, + tx, + { + account: transcoder0.address, + startRound: currentRound + 1, + bondedAmount: 1000, + delegateAddress: transcoder0.address, + delegatedAmount: 2000, + lastClaimRound: currentRound - 1, + lastRewardRound: 100 + }, + { + account: delegator.address, + startRound: currentRound + 1, + bondedAmount: 1000, + delegateAddress: transcoder0.address, + delegatedAmount: 0, + lastClaimRound: currentRound, + lastRewardRound: 0 + } + ) + }) }) describe("bondForWithHint", () => { @@ -2292,6 +2325,53 @@ describe("BondingManager", () => { ) }) }) + + it("should checkpoint the delegator and transcoder states", async () => { + // make sure trancoder has a non-null `lastRewardRound` + await bondingManager.connect(transcoder0).reward() + + const {bondedAmount: startBondedAmount} = + await bondingManager.getDelegator(delegator1.address) + const { + bondedAmount: selfBondedAmount, + delegatedAmount: startDelegatedAmount + } = await bondingManager.getDelegator(transcoder0.address) + + const tx = await bondingManager + .connect(thirdParty) + .bondForWithHint( + 1000, + delegator1.address, + transcoder0.address, + ethers.constants.AddressZero, + ethers.constants.AddressZero, + ethers.constants.AddressZero, + ethers.constants.AddressZero + ) + + await expectCheckpoints( + fixture, + tx, + { + account: transcoder0.address, + startRound: currentRound + 1, + bondedAmount: selfBondedAmount, + delegateAddress: transcoder0.address, + delegatedAmount: startDelegatedAmount.add(1000), + lastClaimRound: currentRound - 1, + lastRewardRound: 100 + }, + { + account: delegator1.address, + startRound: currentRound + 1, + bondedAmount: startBondedAmount.add(1000), + delegateAddress: transcoder0.address, + delegatedAmount: 0, + lastClaimRound: currentRound, + lastRewardRound: 0 + } + ) + }) }) describe("unbond", () => { @@ -2451,6 +2531,36 @@ describe("BondingManager", () => { assert.equal(startActiveStake.toString(), endActiveStake.toString()) }) + it("should checkpoint the delegator and transcoder states", async () => { + // make sure trancoder has a non-null `lastRewardRound` + await bondingManager.connect(transcoder).reward() + + const tx = await bondingManager.connect(delegator).unbond(500) + + await expectCheckpoints( + fixture, + tx, + { + account: transcoder.address, + startRound: currentRound + 2, + bondedAmount: 1000, + delegateAddress: transcoder.address, + delegatedAmount: 1500, + lastClaimRound: currentRound, + lastRewardRound: currentRound + 1 + }, + { + account: delegator.address, + startRound: currentRound + 2, + bondedAmount: 500, + delegateAddress: transcoder.address, + delegatedAmount: 1000, // delegator2 delegates to delegator + lastClaimRound: currentRound + 1, // gets updated on unbond + lastRewardRound: 0 + } + ) + }) + describe("partial unbonding", () => { it("should create an unbonding lock for a partial unbond", async () => { const unbondingLockID = ( @@ -2738,6 +2848,231 @@ describe("BondingManager", () => { }) }) + describe("checkpointBondingState", () => { + // Keep in mind that we only test the external checkpointBondingState function here and ignore all the internal + // checkpointing calls made automatically by BondingManager. Those internal calls are verified in the specific + // tests for each of the other functions where the automated checkpointing is expected to happen. + + let transcoder + let delegator + let nonParticipant + let currentRound + + beforeEach(async () => { + transcoder = signers[0] + delegator = signers[1] + nonParticipant = signers[99] + currentRound = 100 + + await fixture.roundsManager.setMockBool( + functionSig("currentRoundInitialized()"), + true + ) + await fixture.roundsManager.setMockBool( + functionSig("currentRoundLocked()"), + false + ) + await fixture.roundsManager.setMockUint256( + functionSig("currentRound()"), + currentRound + ) + + await bondingManager + .connect(transcoder) + .bond(1000, transcoder.address) + await bondingManager + .connect(transcoder) + .transcoder(50 * PERC_MULTIPLIER, 25 * PERC_MULTIPLIER) + + await bondingManager + .connect(delegator) + .bond(1000, transcoder.address) + }) + + it("should fail if BondingVotes is not registered", async () => { + await fixture.register("BondingVotes", ZERO_ADDRESS) + + await expect( + bondingManager.checkpointBondingState(transcoder.address) + ).to.be.revertedWith("function call to a non-contract account") + }) + + it("should call BondingVotes with non-participant zeroed state", async () => { + const tx = await bondingManager.checkpointBondingState( + nonParticipant.address + ) + + await expectCheckpoints(fixture, tx, { + account: nonParticipant.address, + startRound: currentRound + 1, + bondedAmount: 0, + delegateAddress: ZERO_ADDRESS, + delegatedAmount: 0, + lastClaimRound: 0, + lastRewardRound: 0 + }) + }) + + it("should call BondingVotes with current transcoder state", async () => { + const tx = await bondingManager.checkpointBondingState( + transcoder.address + ) + + await expectCheckpoints(fixture, tx, { + account: transcoder.address, + startRound: currentRound + 1, + bondedAmount: 1000, + delegateAddress: transcoder.address, + delegatedAmount: 2000, // total amount delegated towards transcoder + lastClaimRound: currentRound, + lastRewardRound: 0 // reward was never called + }) + }) + + it("should call BondingVotes with current delegator state", async () => { + const tx = await bondingManager.checkpointBondingState( + delegator.address + ) + + await expectCheckpoints(fixture, tx, { + account: delegator.address, + startRound: currentRound + 1, + bondedAmount: 1000, + delegateAddress: transcoder.address, + delegatedAmount: 0, // no one delegates to the delegator :'( + lastClaimRound: currentRound, + lastRewardRound: 0 // reward was never called + }) + }) + + describe("after reward call", () => { + beforeEach(async () => { + await fixture.roundsManager.setMockUint256( + functionSig("currentRound()"), + currentRound + 1 + ) + + await fixture.minter.setMockUint256( + functionSig("createReward(uint256,uint256)"), + 1000 + ) + await bondingManager.connect(transcoder).reward() + }) + + it("transcoder reward fields should be updated", async () => { + const tx = await bondingManager.checkpointBondingState( + transcoder.address + ) + + await expectCheckpoints(fixture, tx, { + account: transcoder.address, + startRound: currentRound + 2, + bondedAmount: 1000, // only updated by claimEarnings call + delegateAddress: transcoder.address, + delegatedAmount: 3000, // 1000 self bonded + 1000 delegated + 1000 rewards + lastClaimRound: currentRound, // not updated by reward call + lastRewardRound: currentRound + 1 + }) + }) + + it("delegator state shouldn't change", async () => { + const tx = await bondingManager.checkpointBondingState( + delegator.address + ) + + await expectCheckpoints(fixture, tx, { + account: delegator.address, + startRound: currentRound + 2, + bondedAmount: 1000, + delegateAddress: transcoder.address, + delegatedAmount: 0, + lastClaimRound: currentRound, + lastRewardRound: 0 // still zero, only gets set for transcoders + }) + }) + + describe("after transcoder calls claimEarnings", () => { + beforeEach(async () => { + await bondingManager + .connect(transcoder) + .claimEarnings(currentRound + 1) + }) + + it("transcoder bonded amount should be updated", async () => { + const tx = await bondingManager.checkpointBondingState( + transcoder.address + ) + + await expectCheckpoints(fixture, tx, { + account: transcoder.address, + startRound: currentRound + 2, + bondedAmount: 1750, // 1000 + 1000 * (50% reward cut + 50% proportional stake of the rest) + delegateAddress: transcoder.address, + delegatedAmount: 3000, + lastClaimRound: currentRound + 1, + lastRewardRound: currentRound + 1 + }) + }) + + it("delegator state shouldn't change", async () => { + const tx = await bondingManager.checkpointBondingState( + delegator.address + ) + + await expectCheckpoints(fixture, tx, { + account: delegator.address, + startRound: currentRound + 2, + bondedAmount: 1000, + delegateAddress: transcoder.address, + delegatedAmount: 0, + lastClaimRound: currentRound, + lastRewardRound: 0 + }) + }) + }) + + describe("after delegator calls claimEarnings", () => { + beforeEach(async () => { + await bondingManager + .connect(delegator) + .claimEarnings(currentRound + 1) + }) + + it("transcoder state shouldn't change", async () => { + const tx = await bondingManager.checkpointBondingState( + transcoder.address + ) + + await expectCheckpoints(fixture, tx, { + account: transcoder.address, + startRound: currentRound + 2, + bondedAmount: 1000, + delegateAddress: transcoder.address, + delegatedAmount: 3000, + lastClaimRound: currentRound, + lastRewardRound: currentRound + 1 + }) + }) + + it("delegator bonded amount should be updated", async () => { + const tx = await bondingManager.checkpointBondingState( + delegator.address + ) + + await expectCheckpoints(fixture, tx, { + account: delegator.address, + startRound: currentRound + 2, + bondedAmount: 1250, // 1000 + 1000 * (1 - 50% reward cut) * (50% proportional stake) + delegateAddress: transcoder.address, + delegatedAmount: 0, + lastClaimRound: currentRound + 1, + lastRewardRound: 0 + }) + }) + }) + }) + }) + describe("rebond", () => { let transcoder let transcoder1 @@ -2836,6 +3171,38 @@ describe("BondingManager", () => { assert.equal(lock[1], 0, "wrong lock withdrawRound should be 0") }) + it("should checkpoint the delegator and transcoder states", async () => { + // make sure trancoder has a non-null `lastRewardRound` + await bondingManager.connect(transcoder).reward() + + const tx = await bondingManager + .connect(delegator) + .rebond(unbondingLockID) + + await expectCheckpoints( + fixture, + tx, + { + account: transcoder.address, + startRound: currentRound + 2, + bondedAmount: 1000, + delegateAddress: transcoder.address, + delegatedAmount: 2000, + lastClaimRound: currentRound, + lastRewardRound: currentRound + 1 + }, + { + account: delegator.address, + startRound: currentRound + 2, + bondedAmount: 1000, + delegateAddress: transcoder.address, + delegatedAmount: 0, + lastClaimRound: currentRound + 1, + lastRewardRound: 0 + } + ) + }) + describe("current delegate is a registered transcoder", () => { it("should increase transcoder's delegated stake in pool", async () => { await bondingManager.connect(delegator).rebond(unbondingLockID) @@ -3080,6 +3447,41 @@ describe("BondingManager", () => { assert.equal(lock[1], 0, "wrong lock withdrawRound should be 0") }) + it("should checkpoint the delegator and transcoder states", async () => { + // make sure trancoder has a non-null `lastRewardRound` + await bondingManager.connect(transcoder).reward() + + // Delegator unbonds rest of tokens transitioning to the Unbonded state + await bondingManager.connect(delegator).unbond(500) + + const tx = await bondingManager + .connect(delegator) + .rebondFromUnbonded(transcoder.address, unbondingLockID) + + await expectCheckpoints( + fixture, + tx, + { + account: transcoder.address, + startRound: currentRound + 2, + bondedAmount: 1000, + delegateAddress: transcoder.address, + delegatedAmount: 1500, + lastClaimRound: currentRound, + lastRewardRound: currentRound + 1 + }, + { + account: delegator.address, + startRound: currentRound + 2, + bondedAmount: 500, + delegateAddress: transcoder.address, + delegatedAmount: 0, + lastClaimRound: currentRound + 1, + lastRewardRound: 0 + } + ) + }) + describe("new delegate is a registered transcoder", () => { beforeEach(async () => { // Delegator unbonds rest of tokens transitioning to the Unbonded state @@ -3588,6 +3990,63 @@ describe("BondingManager", () => { expect(d1LastClaimRound).to.equal(currentRound + 3) expect(d2LastClaimRound).to.equal(currentRound + 3) }) + + it("should checkpoint both delegators and transcoders states", async () => { + // make sure trancoder has a non-null `lastRewardRound` + await bondingManager.connect(transcoder0).reward() + + const tx = await bondingManager + .connect(delegator1) + .transferBond( + delegator2.address, + 1800, + ZERO_ADDRESS, + ZERO_ADDRESS, + ZERO_ADDRESS, + ZERO_ADDRESS + ) + + await expectCheckpoints( + fixture, + tx, + { + account: transcoder0.address, + startRound: currentRound + 4, + bondedAmount: 1000, + delegateAddress: transcoder0.address, + delegatedAmount: 1200, + lastClaimRound: currentRound - 1, + lastRewardRound: currentRound + 3 + }, + { + account: delegator1.address, + startRound: currentRound + 4, + bondedAmount: 200, + delegateAddress: transcoder0.address, + delegatedAmount: 0, + lastClaimRound: currentRound + 3, + lastRewardRound: 0 + }, + { + account: transcoder1.address, + startRound: currentRound + 4, + bondedAmount: 2000, + delegateAddress: transcoder1.address, + delegatedAmount: 5800, + lastClaimRound: currentRound - 1, + lastRewardRound: 0 + }, + { + account: delegator2.address, + startRound: currentRound + 4, + bondedAmount: 3800, + delegateAddress: transcoder1.address, + delegatedAmount: 0, + lastClaimRound: currentRound + 3, + lastRewardRound: 0 + } + ) + }) }) describe("receiver is bonded to zero address", () => { @@ -4090,6 +4549,20 @@ describe("BondingManager", () => { ) }) + it("should checkpoint the caller state", async () => { + const tx = await bondingManager.connect(transcoder).reward() + + await expectCheckpoints(fixture, tx, { + account: transcoder.address, + startRound: currentRound + 2, + bondedAmount: 1000, + delegateAddress: transcoder.address, + delegatedAmount: 2000, + lastClaimRound: currentRound, + lastRewardRound: currentRound + 1 + }) + }) + it("should update caller with rewards if lastActiveStakeUpdateRound < currentRound", async () => { await fixture.roundsManager.setMockUint256( functionSig("currentRound()"), @@ -4718,6 +5191,38 @@ describe("BondingManager", () => { ) }) + it("should checkpoint the transcoder state", async () => { + // make sure trancoder has a non-null `lastRewardRound` + await bondingManager.connect(transcoder).reward() + + const startBondedAmount = ( + await bondingManager.getDelegator(transcoder.address) + )[0].toNumber() + const tx = await fixture.verifier.execute( + bondingManager.address, + functionEncodedABI( + "slashTranscoder(address,address,uint256,uint256)", + ["address", "uint256", "uint256", "uint256"], + [ + transcoder.address, + constants.NULL_ADDRESS, + PERC_DIVISOR / 2, + 0 + ] + ) + ) + + await expectCheckpoints(fixture, tx, { + account: transcoder.address, + startRound: currentRound + 2, + bondedAmount: startBondedAmount / 2, + delegateAddress: transcoder.address, + delegatedAmount: startBondedAmount / 2, + lastClaimRound: currentRound + 1, + lastRewardRound: currentRound + 1 + }) + }) + describe("transcoder is bonded", () => { it("updates delegated amount and next total stake tokens", async () => { const startNextTotalStake = @@ -5196,6 +5701,23 @@ describe("BondingManager", () => { ) }) + it("should checkpoint the caller state", async () => { + const tx = await bondingManager + .connect(delegator1) + .claimEarnings(currentRound + 1) + + await expectCheckpoints(fixture, tx, { + account: delegator1.address, + startRound: currentRound + 2, + bondedAmount: + 3000 + Math.floor((delegatorRewards * 3000) / 10000), + delegateAddress: transcoder.address, + delegatedAmount: 0, + lastClaimRound: currentRound + 1, + lastRewardRound: 0 + }) + }) + it("updates transcoders cumulativeRewardFactor for _endRound EarningsPool if reward is not called for _endRound yet", async () => { await fixture.roundsManager.setMockUint256( functionSig("currentRound()"), @@ -6333,10 +6855,16 @@ describe("BondingManager", () => { describe("setCurrentRoundTotalActiveStake", () => { let transcoder + let currentRound beforeEach(async () => { transcoder = signers[0] + currentRound = 100 + await fixture.roundsManager.setMockUint256( + functionSig("currentRound()"), + currentRound - 1 + ) await fixture.roundsManager.setMockBool( functionSig("currentRoundInitialized()"), true @@ -6350,6 +6878,11 @@ describe("BondingManager", () => { .connect(transcoder) .bond(1000, transcoder.address) await bondingManager.connect(transcoder).transcoder(5, 10) + + await fixture.roundsManager.setMockUint256( + functionSig("currentRound()"), + currentRound + ) }) it("fails if caller is not RoundsManager", async () => { @@ -6368,6 +6901,19 @@ describe("BondingManager", () => { 1000 ) }) + + it("should checkpoint the total active stake in the current round", async () => { + assert.equal(await bondingManager.currentRoundTotalActiveStake(), 0) + + const tx = await fixture.roundsManager.execute( + bondingManager.address, + functionSig("setCurrentRoundTotalActiveStake()") + ) + + await expect(tx) + .to.emit(fixture.bondingVotes, "CheckpointTotalActiveStake") + .withArgs(1000, currentRound) + }) }) describe("transcoderStatus", () => { diff --git a/test/unit/BondingVotes.js b/test/unit/BondingVotes.js new file mode 100644 index 00000000..66c08464 --- /dev/null +++ b/test/unit/BondingVotes.js @@ -0,0 +1,1355 @@ +import Fixture from "./helpers/Fixture" +import {contractId, functionSig} from "../../utils/helpers" +import {assert} from "chai" +import {ethers, web3} from "hardhat" +import chai from "chai" +import {solidity} from "ethereum-waffle" +import {BigNumber} from "ethers" +import {constants} from "../../utils/constants" + +chai.use(solidity) +const {expect} = chai + +describe("BondingVotes", () => { + let signers + let fixture + + let bondingVotes + let roundsManager + + const PERC_DIVISOR = constants.PERC_DIVISOR_PRECISE + + const setRound = async round => { + await fixture.roundsManager.setMockUint256( + functionSig("currentRound()"), + round + ) + } + + const inRound = async (round, fn) => { + const previous = await roundsManager.currentRound() + try { + await fixture.roundsManager.setMockUint256( + functionSig("currentRound()"), + round + ) + await fn() + } finally { + await fixture.roundsManager.setMockUint256( + functionSig("currentRound()"), + previous + ) + } + } + + before(async () => { + signers = await ethers.getSigners() + fixture = new Fixture(web3) + await fixture.deploy() + + roundsManager = await ethers.getContractAt( + "RoundsManager", + fixture.roundsManager.address + ) + + const BondingVotesFac = await ethers.getContractFactory("BondingVotes") + + bondingVotes = await fixture.deployAndRegister( + BondingVotesFac, + "BondingVotes", + fixture.controller.address + ) + }) + + beforeEach(async () => { + await fixture.setUp() + }) + + afterEach(async () => { + await fixture.tearDown() + }) + + const encodeCheckpointBondingState = ({ + account, + startRound, + bondedAmount, + delegateAddress, + delegatedAmount, + lastClaimRound, + lastRewardRound + }) => { + return bondingVotes.interface.encodeFunctionData( + "checkpointBondingState", + [ + account, + startRound, + bondedAmount, + delegateAddress, + delegatedAmount, + lastClaimRound, + lastRewardRound + ] + ) + } + + const encodeCheckpointTotalActiveStake = (totalStake, round) => { + return bondingVotes.interface.encodeFunctionData( + "checkpointTotalActiveStake", + [totalStake, round] + ) + } + + const customErrorAbi = (sig, args) => { + const iface = new ethers.utils.Interface([`function ${sig}`]) + const funcDataHex = iface.encodeFunctionData(sig, args) + expect(funcDataHex.substring(0, 2)).to.equal("0x") + + const abi = Buffer.from(funcDataHex.substring(2), "hex") + expect(abi.length).to.be.greaterThan(4) + return abi.toString() + } + + describe("checkpointTotalActiveStake", () => { + let currentRound + + beforeEach(async () => { + currentRound = 100 + + await setRound(currentRound) + }) + + it("should fail if BondingManager is not the caller", async () => { + const tx = bondingVotes + .connect(signers[2]) + .checkpointTotalActiveStake(1337, currentRound) + await expect(tx).to.be.revertedWith( + `InvalidCaller("${signers[2].address}", "${fixture.bondingManager.address}")` + ) + }) + + it("should fail if not checkpointing in the current round", async () => { + const rounds = [ + currentRound - 1, + currentRound + 1, + currentRound + 2 + ] + + for (const round of rounds) { + const functionData = encodeCheckpointTotalActiveStake( + 1337, + round + ) + + await expect( + fixture.bondingManager.execute( + bondingVotes.address, + functionData + ) + ).to.be.revertedWith( + customErrorAbi( + "InvalidTotalStakeCheckpointRound(uint256,uint256)", + [round, currentRound] + ) + ) + } + }) + + it("should allow checkpointing in the current round", async () => { + const functionData = encodeCheckpointTotalActiveStake( + 1337, + currentRound + ) + + await fixture.bondingManager.execute( + bondingVotes.address, + functionData + ) + + assert.equal( + await bondingVotes.getTotalActiveStakeAt(currentRound), + 1337 + ) + }) + }) + + describe("getTotalActiveStakeAt", () => { + let currentRound + + beforeEach(async () => { + currentRound = 100 + + await setRound(currentRound) + }) + + it("should fail if round is after the next round", async () => { + const tx = bondingVotes.getTotalActiveStakeAt(currentRound + 2) + await expect(tx).to.be.revertedWith( + `FutureLookup(${currentRound + 2}, ${currentRound + 1})` + ) + }) + + it("should return zero if there are no checkpointed rounds", async () => { + assert.equal( + await bondingVotes.getTotalActiveStakeAt(currentRound), + 0 + ) + }) + + it("should return zero before the first checkpoint", async () => { + const functionData = encodeCheckpointTotalActiveStake( + 1337, + currentRound + ) + await fixture.bondingManager.execute( + bondingVotes.address, + functionData + ) + + assert.equal( + await bondingVotes.getTotalActiveStakeAt(currentRound - 1), + 0 + ) + assert.equal( + await bondingVotes.getTotalActiveStakeAt(currentRound - 2), + 0 + ) + }) + + it("should query checkpointed value in the current round", async () => { + const functionData = encodeCheckpointTotalActiveStake( + 1337, + currentRound + ) + await fixture.bondingManager.execute( + bondingVotes.address, + functionData + ) + + assert.equal( + await bondingVotes.getTotalActiveStakeAt(currentRound), + 1337 + ) + }) + + it("should return nextRoundTotalActiveStake if querying after last checkpoint", async () => { + await fixture.roundsManager.setMockUint256( + functionSig("currentRound()"), + currentRound - 5 + ) + const functionData = encodeCheckpointTotalActiveStake( + 1337, + currentRound - 5 + ) + await fixture.bondingManager.execute( + bondingVotes.address, + functionData + ) + + await fixture.roundsManager.setMockUint256( + functionSig("currentRound()"), + currentRound + ) + await fixture.bondingManager.setMockUint256( + functionSig("nextRoundTotalActiveStake()"), + 1674 + ) + + const totalStakeAt = r => + bondingVotes.getTotalActiveStakeAt(r).then(bn => bn.toString()) + + assert.equal(await totalStakeAt(currentRound - 3), "1674") + assert.equal(await totalStakeAt(currentRound), "1674") + assert.equal(await totalStakeAt(currentRound + 1), "1674") + }) + + it("should allow querying the past checkpointed values", async () => { + const roundStakes = [ + [500, currentRound - 5], + [1000, currentRound - 4], + [1500, currentRound - 3], + [2000, currentRound - 2], + [2500, currentRound - 1] + ] + + for (const [totalStake, round] of roundStakes) { + await fixture.roundsManager.setMockUint256( + functionSig("currentRound()"), + round + ) + const functionData = encodeCheckpointTotalActiveStake( + totalStake, + round + ) + await fixture.bondingManager.execute( + bondingVotes.address, + functionData + ) + } + + // now check all past values that must be recorded + for (const [expectedStake, round] of roundStakes) { + assert.equal( + await bondingVotes.getTotalActiveStakeAt(round), + expectedStake + ) + } + }) + + it("should use the next checkpointed round values for intermediate queries", async () => { + const roundStakes = [ + [500, currentRound - 50], + [1000, currentRound - 40], + [1500, currentRound - 30], + [2000, currentRound - 20], + [2500, currentRound - 10] + ] + + for (const [totalStake, round] of roundStakes) { + await fixture.roundsManager.setMockUint256( + functionSig("currentRound()"), + round + ) + const functionData = encodeCheckpointTotalActiveStake( + totalStake, + round + ) + await fixture.bondingManager.execute( + bondingVotes.address, + functionData + ) + } + + // now query 1 round in between each of the checkpoints + for (const idx = 1; idx < roundStakes.length; idx++) { + const [expectedStake, round] = roundStakes[idx] + assert.equal( + await bondingVotes.getTotalActiveStakeAt(round - 1 - idx), + expectedStake + ) + } + }) + }) + + describe("checkpointBondingState", () => { + let transcoder + let currentRound + + beforeEach(async () => { + transcoder = signers[0] + currentRound = 100 + + await setRound(currentRound) + }) + + it("should fail if BondingManager is not the caller", async () => { + const tx = bondingVotes + .connect(signers[4]) + .checkpointBondingState( + transcoder.address, + currentRound + 1, + 1000, + transcoder.address, + 1000, + currentRound, + 0 + ) + await expect(tx).to.be.revertedWith( + `InvalidCaller("${signers[4].address}", "${fixture.bondingManager.address}")` + ) + }) + + it("should fail if checkpointing after the next round", async () => { + const functionData = encodeCheckpointBondingState({ + account: transcoder.address, + startRound: currentRound + 2, + bondedAmount: 1000, + delegateAddress: transcoder.address, + delegatedAmount: 1000, + lastClaimRound: currentRound + 1, + lastRewardRound: 0 + }) + + await expect( + fixture.bondingManager.execute( + bondingVotes.address, + functionData + ) + ).to.be.revertedWith( + customErrorAbi("InvalidStartRound(uint256,uint256)", [ + currentRound + 2, + currentRound + 1 + ]) + ) + }) + + it("should fail if checkpointing before the next round", async () => { + const functionData = encodeCheckpointBondingState({ + account: transcoder.address, + startRound: currentRound, + bondedAmount: 1000, + delegateAddress: transcoder.address, + delegatedAmount: 1000, + lastClaimRound: currentRound - 1, + lastRewardRound: 0 + }) + + await expect( + fixture.bondingManager.execute( + bondingVotes.address, + functionData + ) + ).to.be.revertedWith( + customErrorAbi("InvalidStartRound(uint256,uint256)", [ + currentRound, + currentRound + 1 + ]) + ) + }) + + it("should fail if lastClaimRound is not lower than start round", async () => { + const functionData = encodeCheckpointBondingState({ + account: transcoder.address, + startRound: currentRound + 1, + bondedAmount: 1000, + delegateAddress: transcoder.address, + delegatedAmount: 1000, + lastClaimRound: currentRound + 1, + lastRewardRound: 0 + }) + + await expect( + fixture.bondingManager.execute( + bondingVotes.address, + functionData + ) + ).to.be.revertedWith( + customErrorAbi("FutureLastClaimRound(uint256,uint256)", [ + currentRound + 1, + currentRound + ]) + ) + }) + + it("should allow checkpointing in the next round", async () => { + const functionData = encodeCheckpointBondingState({ + account: transcoder.address, + startRound: currentRound + 1, + bondedAmount: 1000, + delegateAddress: transcoder.address, + delegatedAmount: 1000, + lastClaimRound: currentRound - 1, + lastRewardRound: 0 + }) + await fixture.bondingManager.execute( + bondingVotes.address, + functionData + ) + }) + + it("should checkpoint account state", async () => { + const functionData = encodeCheckpointBondingState({ + account: transcoder.address, + startRound: currentRound + 1, + bondedAmount: 1000, + delegateAddress: transcoder.address, + delegatedAmount: 1000, + lastClaimRound: currentRound - 1, + lastRewardRound: 0 + }) + await fixture.bondingManager.execute( + bondingVotes.address, + functionData + ) + + assert.deepEqual( + await bondingVotes + .getBondingStateAt(transcoder.address, currentRound + 1) + .then(t => t.map(v => v.toString())), + ["1000", transcoder.address] + ) + }) + + it("should be callable multiple times for the same round", async () => { + const makeCheckpoint = async amount => { + const functionData = encodeCheckpointBondingState({ + account: transcoder.address, + startRound: currentRound + 1, + bondedAmount: amount, + delegateAddress: transcoder.address, + delegatedAmount: amount, + lastClaimRound: currentRound, + lastRewardRound: 0 + }) + await fixture.bondingManager.execute( + bondingVotes.address, + functionData + ) + } + + await makeCheckpoint(1000) + + // simulating a bond where bonding manager checkpoints the current state and then the next + await makeCheckpoint(2000) + + assert.deepEqual( + await bondingVotes + .getBondingStateAt(transcoder.address, currentRound + 1) + .then(t => t.map(v => v.toString())), + ["2000", transcoder.address] + ) + }) + + describe("events", () => { + let transcoder2 + let delegator + let currentRound + + beforeEach(async () => { + transcoder2 = signers[1] + delegator = signers[2] + currentRound = 100 + + await setRound(currentRound) + }) + + const makeCheckpoint = async ( + account, + delegateAddress, + bondedAmount, + delegatedAmount + ) => { + const functionData = encodeCheckpointBondingState({ + account, + startRound: currentRound + 1, + bondedAmount, + delegateAddress, + delegatedAmount, + lastClaimRound: currentRound, + lastRewardRound: 0 + }) + return await fixture.bondingManager.execute( + bondingVotes.address, + functionData + ) + } + + it("should send events for delegator", async () => { + // Changing both bondedAmount and delegateAddress + let tx = await makeCheckpoint( + delegator.address, + transcoder.address, + 1000, + 0 + ) + + // This will be sent on the transcoder state checkpoint instead + await expect(tx).not.to.emit( + bondingVotes, + "DelegateVotesChanged" + ) + + await expect(tx) + .to.emit(bondingVotes, "DelegateChanged") + .withArgs( + delegator.address, + constants.NULL_ADDRESS, + transcoder.address + ) + await expect(tx) + .to.emit(bondingVotes, "DelegatorBondedAmountChanged") + .withArgs(delegator.address, 0, 1000) + + // Changing only bondedAmount + tx = await makeCheckpoint( + delegator.address, + transcoder.address, + 2000, + 0 + ) + + await expect(tx).not.to.emit(bondingVotes, "DelegateChanged") + await expect(tx) + .to.emit(bondingVotes, "DelegatorBondedAmountChanged") + .withArgs(delegator.address, 1000, 2000) + + // Changing only delegateAddress + tx = await makeCheckpoint( + delegator.address, + transcoder2.address, + 2000, + 0 + ) + + await expect(tx).not.to.emit( + bondingVotes, + "DelegatorBondedAmountChanged" + ) + await expect(tx) + .to.emit(bondingVotes, "DelegateChanged") + .withArgs( + delegator.address, + transcoder.address, + transcoder2.address + ) + }) + + it("should send events for transcoder", async () => { + // Changing both bondedAmount and delegateAddress + let tx = await makeCheckpoint( + transcoder.address, + transcoder.address, + 20000, + 50000 + ) + + await expect(tx) + .to.emit(bondingVotes, "DelegateChanged") + .withArgs( + transcoder.address, + constants.NULL_ADDRESS, + transcoder.address + ) + await expect(tx) + .to.emit(bondingVotes, "DelegateVotesChanged") + .withArgs(transcoder.address, 0, 50000) + // Still emits a delegator event + await expect(tx) + .to.emit(bondingVotes, "DelegatorBondedAmountChanged") + .withArgs(transcoder.address, 0, 20000) + + // Changing only delegatedAmount + tx = await makeCheckpoint( + transcoder.address, + transcoder.address, + 20000, + 70000 + ) + + await expect(tx).not.to.emit(bondingVotes, "DelegateChanged") + await expect(tx).not.to.emit( + bondingVotes, + "DelegatorBondedAmountChanged" + ) + await expect(tx) + .to.emit(bondingVotes, "DelegateVotesChanged") + .withArgs(transcoder.address, 50000, 70000) + + // Changing delegateAddress, becoming a delegator itself + tx = await makeCheckpoint( + transcoder.address, + transcoder2.address, + 20000, + 50000 + ) + + await expect(tx) + .to.emit(bondingVotes, "DelegateChanged") + .withArgs( + transcoder.address, + transcoder.address, + transcoder2.address + ) + await expect(tx) + .to.emit(bondingVotes, "DelegateVotesChanged") + .withArgs(transcoder.address, 70000, 0) + // Voting power as a delegator stayed the same + await expect(tx).not.to.emit( + bondingVotes, + "DelegatorBondedAmountChanged" + ) + }) + }) + }) + + describe("hasCheckpoint", () => { + let transcoder + let currentRound + + beforeEach(async () => { + transcoder = signers[0] + currentRound = 100 + + await setRound(currentRound) + }) + + it("should return false for accounts without checkpoints", async () => { + for (let i = 0; i < 10; i++) { + assert.equal( + await bondingVotes.hasCheckpoint(signers[i].address), + false + ) + } + }) + + it("should return true after one or more checkpoints are made", async () => { + const makeCheckpoint = async startRound => { + const functionData = encodeCheckpointBondingState({ + account: transcoder.address, + startRound, + bondedAmount: 1000, + delegateAddress: transcoder.address, + delegatedAmount: 1000, + lastClaimRound: startRound - 1, + lastRewardRound: 0 + }) + await fixture.bondingManager.execute( + bondingVotes.address, + functionData + ) + } + + for (let i = 0; i < 3; i++) { + const round = currentRound + i + await setRound(round) + + await makeCheckpoint(round + 1) + + assert.equal( + await bondingVotes.hasCheckpoint(transcoder.address), + true + ) + } + }) + }) + + describe("getBondingStateAt", () => { + let transcoder + let delegator + let currentRound + + beforeEach(async () => { + transcoder = signers[0] + delegator = signers[1] + currentRound = 100 + + await setRound(currentRound) + }) + + it("should fail if round is after the next round", async () => { + const tx = bondingVotes.getBondingStateAt( + delegator.address, + currentRound + 2 + ) + await expect(tx).to.be.revertedWith( + `FutureLookup(${currentRound + 2}, ${currentRound + 1})` + ) + }) + + describe("on missing checkpoints", () => { + const setBondMock = async ({ + bondedAmount, + delegateAddress, + delegatedAmount, + lastClaimRound // only required field + }) => + await fixture.bondingManager.setMockDelegator( + delegator.address, + bondedAmount ?? 0, + 0, + delegateAddress ?? constants.NULL_ADDRESS, + delegatedAmount ?? 0, + 0, + lastClaimRound, + 0 + ) + + const expectZeroCheckpoint = async queryRound => { + expect( + await bondingVotes + .getBondingStateAt(delegator.address, queryRound) + .then(t => t.map(v => v.toString())) + ).to.deep.equal(["0", constants.NULL_ADDRESS]) + } + + it("should return zero regardless of the account current bonding state", async () => { + const mockBondStates = [ + {lastClaimRound: currentRound - 10}, + {lastClaimRound: currentRound - 9}, + {lastClaimRound: currentRound - 5}, + {lastClaimRound: currentRound - 1}, + { + bondedAmount: 1, + lastClaimRound: currentRound - 1 + }, + { + delegateAddress: delegator.address, + delegatedAmount: 1, + lastClaimRound: currentRound - 1 + } + ] + + // should work even without any state (zero) + await expectZeroCheckpoint(currentRound) + + for (const mockBond of mockBondStates) { + await setBondMock(mockBond) + await expectZeroCheckpoint(currentRound - 10) + await expectZeroCheckpoint(currentRound) + } + }) + }) + + describe("for transcoder", () => { + const makeCheckpoint = (startRound, delegatedAmount) => + inRound(startRound - 1, async () => { + const functionData = encodeCheckpointBondingState({ + account: transcoder.address, + startRound, + bondedAmount: 1, // doesn't matter, shouldn't be used + delegateAddress: transcoder.address, + delegatedAmount, + lastClaimRound: startRound - 1, + lastRewardRound: 0 + }) + await fixture.bondingManager.execute( + bondingVotes.address, + functionData + ) + }) + + it("should return zero before the first checkpoint", async () => { + await makeCheckpoint(currentRound, 1000) + + assert.deepEqual( + await bondingVotes + .getBondingStateAt(transcoder.address, currentRound - 2) + .then(t => t.map(v => v.toString())), + ["0", constants.NULL_ADDRESS] + ) + }) + + it("should return the same round delegatedAmount and own address", async () => { + await makeCheckpoint(currentRound, 1000) + + assert.deepEqual( + await bondingVotes + .getBondingStateAt(transcoder.address, currentRound) + .then(t => t.map(v => v.toString())), + ["1000", transcoder.address] + ) + }) + + it("should return the last checkpoint before the queried round", async () => { + await makeCheckpoint(currentRound - 10, 1000) + await makeCheckpoint(currentRound - 5, 2000) + + assert.deepEqual( + await bondingVotes + .getBondingStateAt(transcoder.address, currentRound - 7) + .then(t => t.map(v => v.toString())), + ["1000", transcoder.address] + ) + + assert.deepEqual( + await bondingVotes + .getBondingStateAt(transcoder.address, currentRound) + .then(t => t.map(v => v.toString())), + ["2000", transcoder.address] + ) + }) + }) + + describe("for delegator", () => { + let transcoder2 + + const checkpointTranscoder = ({ + account, + startRound, + lastRewardRound + }) => + inRound(startRound - 1, async () => { + const functionData = encodeCheckpointBondingState({ + account, + startRound, + bondedAmount: 0, // not used in these tests + delegateAddress: account, + delegatedAmount: 0, // not used in these tests + lastClaimRound: startRound - 1, + lastRewardRound + }) + await fixture.bondingManager.execute( + bondingVotes.address, + functionData + ) + }) + + const setEarningPoolRewardFactor = async ( + address, + round, + factor + ) => { + await fixture.bondingManager.setMockTranscoderEarningsPoolForRound( + address, + round, + 0, + 0, + 0, + factor, + 0 + ) + } + + const checkpointDelegator = ({ + startRound, + bondedAmount, + delegateAddress, + lastClaimRound + }) => + inRound(startRound - 1, async () => { + const functionData = encodeCheckpointBondingState({ + account: delegator.address, + startRound, + bondedAmount, + delegateAddress, + delegatedAmount: 0, // not used for delegators + lastClaimRound, + lastRewardRound: 0 // not used for delegators + }) + await fixture.bondingManager.execute( + bondingVotes.address, + functionData + ) + }) + + beforeEach(async () => { + transcoder2 = signers[2] + + await checkpointTranscoder({ + account: transcoder.address, + startRound: currentRound - 50, + lastRewardRound: 0 + }) + await checkpointTranscoder({ + account: transcoder2.address, + startRound: currentRound - 50, + lastRewardRound: 0 + }) + }) + + it("should return zero before the first checkpoint", async () => { + await checkpointDelegator({ + startRound: currentRound + 1, + bondedAmount: 1000, + delegateAddress: transcoder.address, + lastClaimRound: currentRound + }) + + assert.deepEqual( + await bondingVotes + .getBondingStateAt(delegator.address, currentRound) + .then(t => t.map(v => v.toString())), + ["0", constants.NULL_ADDRESS] + ) + }) + + it("should return the bonded amount if there's no earning pool on the lastClaimRound", async () => { + await checkpointDelegator({ + startRound: currentRound - 10, + bondedAmount: 1000, + delegateAddress: transcoder.address, + lastClaimRound: currentRound - 11 + }) + + assert.deepEqual( + await bondingVotes + .getBondingStateAt(delegator.address, currentRound) + .then(t => t.map(v => v.toString())), + ["1000", transcoder.address] + ) + }) + + it("should return the last checkpoint before the queried round", async () => { + await checkpointDelegator({ + startRound: currentRound - 10, + bondedAmount: 1000, + delegateAddress: transcoder.address, + lastClaimRound: currentRound - 11 + }) + + await checkpointDelegator({ + startRound: currentRound - 5, + bondedAmount: 2000, + delegateAddress: transcoder2.address, + lastClaimRound: currentRound - 6 + }) + + assert.deepEqual( + await bondingVotes + .getBondingStateAt(delegator.address, currentRound - 7) + .then(t => t.map(v => v.toString())), + ["1000", transcoder.address] + ) + + assert.deepEqual( + await bondingVotes + .getBondingStateAt(delegator.address, currentRound) + .then(t => t.map(v => v.toString())), + ["2000", transcoder2.address] + ) + }) + + it("should return the same bonded amount if transcoder last called reward before claim round", async () => { + await checkpointTranscoder({ + account: transcoder.address, + startRound: currentRound, + lastRewardRound: currentRound - 10 + }) + await setEarningPoolRewardFactor( + transcoder.address, + currentRound - 10, + PERC_DIVISOR + ) + + await checkpointDelegator({ + startRound: currentRound, + bondedAmount: 1000, + delegateAddress: transcoder.address, + lastClaimRound: currentRound - 1 + }) + await setEarningPoolRewardFactor( + transcoder.address, + currentRound - 1, + PERC_DIVISOR.mul(2) + ) + + assert.deepEqual( + await bondingVotes + .getBondingStateAt(delegator.address, currentRound) + .then(t => t.map(v => v.toString())), + ["1000", transcoder.address] + ) + }) + + it("should fail if there's no earning pool on the lastRewardRound", async () => { + await checkpointDelegator({ + startRound: currentRound - 9, + bondedAmount: 1000, + delegateAddress: transcoder.address, + lastClaimRound: currentRound - 10 + }) + await setEarningPoolRewardFactor( + transcoder.address, + currentRound - 10, + PERC_DIVISOR + ) + + await checkpointTranscoder({ + account: transcoder.address, + startRound: currentRound - 1, + lastRewardRound: currentRound - 2 + }) + // no earning pool for currentRound - 2 + + const tx = bondingVotes.getBondingStateAt( + delegator.address, + currentRound + ) + await expect(tx).to.be.revertedWith( + `MissingEarningsPool("${transcoder.address}", ${ + currentRound - 2 + })` + ) + }) + + it("should return the bonded amount with accrued pending rewards since lastClaimRound", async () => { + await checkpointDelegator({ + startRound: currentRound - 9, + bondedAmount: 1000, + delegateAddress: transcoder.address, + lastClaimRound: currentRound - 10 + }) + await setEarningPoolRewardFactor( + transcoder.address, + currentRound - 10, + PERC_DIVISOR.mul(2) + ) + + await checkpointTranscoder({ + account: transcoder.address, + startRound: currentRound - 1, + lastRewardRound: currentRound - 2 + }) + await setEarningPoolRewardFactor( + transcoder.address, + currentRound - 2, + PERC_DIVISOR.mul(6) + ) + + assert.deepEqual( + await bondingVotes + .getBondingStateAt(delegator.address, currentRound) + .then(t => t.map(v => v.toString())), + ["3000", transcoder.address] + ) + }) + + it("should return the accrued rewards even if there had been no reward calls on claim round", async () => { + await checkpointDelegator({ + startRound: currentRound - 9, + bondedAmount: 1000, + delegateAddress: transcoder.address, + lastClaimRound: currentRound - 10 + }) + // no earning pool saved here, which we expect the code to just assume 1 + + await checkpointTranscoder({ + account: transcoder.address, + startRound: currentRound - 1, + lastRewardRound: currentRound - 2 + }) + await setEarningPoolRewardFactor( + transcoder.address, + currentRound - 2, + PERC_DIVISOR.mul(5) + ) + + assert.deepEqual( + await bondingVotes + .getBondingStateAt(delegator.address, currentRound) + .then(t => t.map(v => v.toString())), + ["5000", transcoder.address] + ) + }) + }) + }) + + describe("IERC20 Metadata", () => { + describe("name", () => { + it("should return 'Livepeer Voting Power'", async () => { + assert.equal(await bondingVotes.name(), "Livepeer Voting Power") + }) + }) + + describe("symbol", () => { + it("should return 'vLPT'", async () => { + assert.equal(await bondingVotes.symbol(), "vLPT") + }) + }) + + describe("decimals", () => { + it("should return 18", async () => { + assert.equal(await bondingVotes.decimals(), 18) + }) + }) + }) + + describe("IERC6372", () => { + describe("clock", () => { + let currentRound + + beforeEach(async () => { + currentRound = 100 + + await setRound(currentRound) + }) + + it("should return the current round", async () => { + assert.equal(await bondingVotes.clock(), currentRound) + + await setRound(currentRound + 7) + + assert.equal(await bondingVotes.clock(), currentRound + 7) + }) + }) + + describe("CLOCK_MODE", () => { + it("should return mode=livepeer_round", async () => { + assert.equal( + await bondingVotes.CLOCK_MODE(), + "mode=livepeer_round" + ) + }) + }) + }) + + /** + * These tests mock the internal checkpointing logic from `BondingVotes` and tests only the methods from the + * `IVotes` interface that are expected to proxy to that internal implementation. It deploys a new BondingVotes + * contract (BondingVotesERC5805Harness) which has a mocked implementation of those internal functions, with a + * corresponding implementation here in the `mock` object. + */ + describe("IVotes", () => { + const currentRound = 1000 + + // redefine it here to avoid overriding top-level var + let bondingVotes + + // Same implementation as the BondingVotesERC5805Mock + const mock = { + getBondingStateAt: (_account, _round) => { + const intAddr = BigNumber.from(_account) + + // lowest 4 bytes of address + _round + const amount = intAddr.mask(32).add(_round) + // (_account << 4) | _round + const delegateAddress = intAddr.shl(4).mask(160).or(_round) + + return [ + amount.toNumber(), + ethers.utils.getAddress(delegateAddress.toHexString()) + ] + }, + getTotalActiveStakeAt: _round => 4 * _round + } + + before(async () => { + const HarnessFac = await ethers.getContractFactory( + "BondingVotesERC5805Harness" + ) + + bondingVotes = await fixture.deployAndRegister( + HarnessFac, + "BondingVotes", + fixture.controller.address + ) + }) + + beforeEach(async () => { + await fixture.roundsManager.setMockUint256( + functionSig("currentRound()"), + currentRound + ) + }) + + it("ensure harness was deployed", async () => { + assert.equal( + await fixture.controller.getContract( + contractId("BondingVotes") + ), + ethers.utils.getAddress(bondingVotes.address) + ) + }) + + describe("getVotes", () => { + it("should proxy to getBondingStateAt with the next round", async () => { + const [expected] = mock.getBondingStateAt( + signers[0].address, + currentRound + 1 + ) + + const votes = await bondingVotes.getVotes(signers[0].address) + assert.equal(votes.toNumber(), expected) + }) + }) + + describe("getPastVotes", () => { + it("should fail if not querying in the past", async () => { + const tx = bondingVotes.getPastVotes( + signers[0].address, + currentRound + ) + await expect(tx).to.be.revertedWith("FutureLookup(1000, 999)") + }) + + it("should proxy to getBondingStateAt with next round", async () => { + const testOnce = async (account, round) => { + const [expected] = mock.getBondingStateAt( + account.address, + round + 1 + ) + + const votes = await bondingVotes.getPastVotes( + account.address, + round + ) + assert.equal(votes.toNumber(), expected) + } + + await testOnce(signers[1], 123) + await testOnce(signers[2], 256) + await testOnce(signers[3], 784) + await testOnce(signers[4], currentRound - 1) + }) + }) + + describe("delegates", () => { + it("should proxy to getBondingStateAt with the next round", async () => { + const [, expected] = mock.getBondingStateAt( + signers[5].address, + currentRound + 1 + ) + assert.equal( + await bondingVotes.delegates(signers[5].address), + expected + ) + }) + }) + + describe("delegatedAt", () => { + it("should proxy to getBondingStateAt with next round", async () => { + const testOnce = async (account, round) => { + const [, expected] = mock.getBondingStateAt( + account.address, + round + 1 + ) + + const delegate = await bondingVotes.delegatedAt( + account.address, + round + ) + assert.equal(delegate, expected) + } + + await testOnce(signers[6], 123) + await testOnce(signers[7], 256) + await testOnce(signers[8], 784) + await testOnce(signers[9], 784) + }) + }) + + describe("totalSupply", () => { + it("should proxy to getTotalActiveStakeAt at next round", async () => { + const expected = mock.getTotalActiveStakeAt(currentRound + 1) + + const totalSupply = await bondingVotes.totalSupply() + assert.equal(totalSupply.toNumber(), expected) + }) + }) + + describe("getPastTotalSupply", () => { + it("should fail if not querying in the past", async () => { + const tx = bondingVotes.getPastTotalSupply(currentRound) + await expect(tx).to.be.revertedWith("FutureLookup(1000, 999)") + }) + + it("should proxy to getTotalActiveStakeAt with next round", async () => { + const testOnce = async round => { + const expected = mock.getTotalActiveStakeAt(round + 1) + + const totalSupply = await bondingVotes.getPastTotalSupply( + round + ) + assert.equal(totalSupply.toNumber(), expected) + } + + await testOnce(213) + await testOnce(526) + await testOnce(784) + await testOnce(currentRound - 1) + }) + }) + + describe("delegation", () => { + it("should fail to call delegate", async () => { + await expect( + bondingVotes + .connect(signers[0]) + .delegate(signers[1].address) + ).to.be.revertedWith("MustCallBondingManager(\"bond\")") + }) + + it("should fail to call delegateBySig", async () => { + await expect( + bondingVotes.delegateBySig( + signers[1].address, + 420, + 1689794400, + 171, + ethers.utils.hexZeroPad("0xfacade", 32), + ethers.utils.hexZeroPad("0xdeadbeef", 32) + ) + ).to.be.revertedWith("MustCallBondingManager(\"bondFor\")") + }) + }) + }) +}) diff --git a/test/unit/Governor.js b/test/unit/Governor.js index e04ff8f1..615e1c63 100644 --- a/test/unit/Governor.js +++ b/test/unit/Governor.js @@ -18,7 +18,9 @@ describe("Governor", () => { signers = await ethers.getSigners() fixture = new Fixture(web3) await fixture.deploy() - const govFac = await ethers.getContractFactory("Governor") + const govFac = await ethers.getContractFactory( + "contracts/governance/Governor.sol:Governor" + ) governor = await govFac.deploy() const setUintFac = await ethers.getContractFactory("SetUint256") setUint256 = await setUintFac.deploy() diff --git a/test/unit/SortedArrays.js b/test/unit/SortedArrays.js new file mode 100644 index 00000000..46d2c70a --- /dev/null +++ b/test/unit/SortedArrays.js @@ -0,0 +1,8 @@ +import runSolidityTest from "./helpers/runSolidityTest" + +runSolidityTest( + "TestSortedArrays", + ["AssertUint", "AssertBool"], + undefined, + true +) diff --git a/test/unit/helpers/Fixture.js b/test/unit/helpers/Fixture.js index e806dfdf..c21e3fb0 100644 --- a/test/unit/helpers/Fixture.js +++ b/test/unit/helpers/Fixture.js @@ -23,6 +23,9 @@ export default class Fixture { const BondingManagerMock = await ethers.getContractFactory( "BondingManagerMock" ) + const BondingVotesMock = await ethers.getContractFactory( + "BondingVotesMock" + ) this.token = await this.deployAndRegister(GenericMock, "LivepeerToken") this.minter = await this.deployAndRegister(MinterMock, "Minter") @@ -30,6 +33,10 @@ export default class Fixture { BondingManagerMock, "BondingManager" ) + this.bondingVotes = await this.deployAndRegister( + BondingVotesMock, + "BondingVotes" + ) this.roundsManager = await this.deployAndRegister( GenericMock, "RoundsManager" diff --git a/test/unit/helpers/expectCheckpoints.ts b/test/unit/helpers/expectCheckpoints.ts new file mode 100644 index 00000000..b8dd61d8 --- /dev/null +++ b/test/unit/helpers/expectCheckpoints.ts @@ -0,0 +1,51 @@ +import {assert} from "chai" +import {ethers} from "ethers" +import Fixture from "./Fixture" + +type Checkpoint = { + account: string + startRound: number + bondedAmount: number + delegateAddress: string + delegatedAmount: number + lastClaimRound: number + lastRewardRound: number +} + +export default async function expectCheckpoints( + fixture: Fixture, + tx: ethers.providers.TransactionReceipt, + ...checkpoints: Checkpoint[] +) { + const filter = fixture.bondingVotes.filters.CheckpointBondingState() + const events = await fixture.bondingVotes.queryFilter( + filter, + tx.blockNumber, + tx.blockNumber + ) + + assert.equal(events.length, checkpoints.length, "Checkpoint count") + + for (let i = 0; i < checkpoints.length; i++) { + const expected = checkpoints[i] + const {args} = events[i] + const actual: Checkpoint = { + account: args[0].toString(), + startRound: args[1].toNumber(), + bondedAmount: args[2].toNumber(), + delegateAddress: args[3].toString(), + delegatedAmount: args[4].toNumber(), + lastClaimRound: args[5].toNumber(), + lastRewardRound: args[6].toNumber() + } + + for (const keyStr of Object.keys(expected)) { + const key = keyStr as keyof Checkpoint // ts workaround + assert.equal( + actual[key], + expected[key], + `Checkpoint #${i + 1} ${key}` + ) + } + } +} diff --git a/yarn.lock b/yarn.lock index 4f487dda..f6501a16 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1013,10 +1013,15 @@ dependencies: "@types/bignumber.js" "^5.0.0" -"@openzeppelin/contracts@^4.4.2": - version "4.4.2" - resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-4.4.2.tgz#4e889c9c66e736f7de189a53f8ba5b8d789425c2" - integrity sha512-NyJV7sJgoGYqbtNUWgzzOGW4T6rR19FmX1IJgXGdapGPWsuMelGJn9h03nos0iqfforCbCB0iYIR0MtIuIFLLw== +"@openzeppelin/contracts-upgradeable@^4.9.2": + version "4.9.2" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts-upgradeable/-/contracts-upgradeable-4.9.2.tgz#a817c75688f8daede420052fbcb34e52482e769e" + integrity sha512-siviV3PZV/fHfPaoIC51rf1Jb6iElkYWnNYZ0leO23/ukXuvOyoC/ahy8jqiV7g+++9Nuo3n/rk5ajSN/+d/Sg== + +"@openzeppelin/contracts@^4.9.2": + version "4.9.2" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-4.9.2.tgz#1cb2d5e4d3360141a17dbc45094a8cad6aac16c1" + integrity sha512-mO+y6JaqXjWeMh9glYVzVu8HYPGknAAnWyxTRhGeckOruyXQMNnlcW6w/Dx9ftLeIQk6N+ZJFuVmTwF7lEIFrg== "@resolver-engine/core@^0.3.3": version "0.3.3"