From cc315d17b86d09647adf1fc05b005b5357cd4b98 Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Fri, 7 Jul 2023 17:51:59 -0300 Subject: [PATCH 01/29] package.json: Update @openzeppelin libraries Will be required for new checkpoints code and later governor implementation. --- deploy/deploy_contracts.ts | 3 ++- package.json | 3 ++- test/integration/GovernorUpdate.js | 4 +++- test/unit/Governor.js | 4 +++- yarn.lock | 13 +++++++++---- 5 files changed, 19 insertions(+), 8 deletions(-) diff --git a/deploy/deploy_contracts.ts b/deploy/deploy_contracts.ts index 6d365ba0..1a415fc6 100644 --- a/deploy/deploy_contracts.ts +++ b/deploy/deploy_contracts.ts @@ -178,6 +178,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 +186,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/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/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/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" From d42e92c477f5383bb29b96016d0c9d48f41778e3 Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Fri, 7 Jul 2023 17:58:12 -0300 Subject: [PATCH 02/29] bonding: Create SortedArrays library Used for checkpointing logic in bonding state checkpoints --- contracts/bonding/libraries/SortedArrays.sol | 78 +++++++++++++++++ contracts/test/TestSortedArrays.sol | 91 ++++++++++++++++++++ contracts/test/mocks/SortedArraysFixture.sol | 25 ++++++ test/unit/SortedArrays.js | 8 ++ 4 files changed, 202 insertions(+) create mode 100644 contracts/bonding/libraries/SortedArrays.sol create mode 100644 contracts/test/TestSortedArrays.sol create mode 100644 contracts/test/mocks/SortedArraysFixture.sol create mode 100644 test/unit/SortedArrays.js diff --git a/contracts/bonding/libraries/SortedArrays.sol b/contracts/bonding/libraries/SortedArrays.sol new file mode 100644 index 00000000..48b49ccd --- /dev/null +++ b/contracts/bonding/libraries/SortedArrays.sol @@ -0,0 +1,78 @@ +// 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[]; + + /** + * @notice Searches a sorted _array and returns the last element to be lower or equal to _val. + * + * @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 main differences from the OZ version (apart from the obvious lower vs upper bound) are: + * - It returns the array element directly instead of its index + * - If no such element exists (i.e. all values in the array are higher than _val) this function will fail instead + * of returning some default value. + * @param _array Array to search in + * @param _val Value to search for + * @return lower Lower bound value found in array + */ + function findLowerBound(uint256[] storage _array, uint256 _val) internal view returns (uint256) { + uint256 len = _array.length; + require(len > 0, "findLowerBound: empty array"); + + uint256 lastElm = _array[len - 1]; + if (lastElm <= _val) { + return lastElm; + } + + uint256 upperIdx = _array.findUpperBound(_val); + + // we already checked the last element above so the upper will always be inside the array + assert(upperIdx < len); + + uint256 upperElm = _array[upperIdx]; + // the exact value we were searching is in the array + if (upperElm == _val) { + return upperElm; + } + + // a 0 idx means that the first elem is already higher than the searched value (and not equal, checked above) + require(upperIdx > 0, "findLowerBound: all values in array are higher than searched value"); + + // the upperElm is the first element higher than the value we want, so return the previous element + return _array[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 + require(val >= last, "pushSorted: decreasing values"); + + // 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..d35f58eb --- /dev/null +++ b/contracts/test/TestSortedArrays.sol @@ -0,0 +1,91 @@ +// 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), 2, "found incorrect element"); + Assert.equal(fixture.findLowerBound(6), 4, "found incorrect element"); + Assert.equal(fixture.findLowerBound(10), 7, "found incorrect element"); + Assert.equal(fixture.findLowerBound(15), 11, "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), 3, "found incorrect element"); + Assert.equal(fixture.findLowerBound(5), 5, "found incorrect element"); + Assert.equal(fixture.findLowerBound(8), 8, "found incorrect element"); + Assert.equal(fixture.findLowerBound(13), 13, "found incorrect element"); + } + + function test_findLowerBound_revertsOnEmpty() public { + SortedArraysFixture(address(proxy)).callFindLowerBound(3); + bool ok = proxy.execute(address(fixture)); + Assert.isFalse(ok, "did not revert"); + } + + function test_findLowerBound_revertsOnNotFound() public { + fixture.pushSorted(8); + fixture.pushSorted(13); + + Assert.equal(fixture.findLowerBound(22), 13, "found incorrect element"); + + // looking for a value lower than min should revert + SortedArraysFixture(address(proxy)).callFindLowerBound(5); + bool ok = proxy.execute(address(fixture)); + Assert.isFalse(ok, "did not revert"); + } +} diff --git a/contracts/test/mocks/SortedArraysFixture.sol b/contracts/test/mocks/SortedArraysFixture.sol new file mode 100644 index 00000000..2f496a17 --- /dev/null +++ b/contracts/test/mocks/SortedArraysFixture.sol @@ -0,0 +1,25 @@ +// 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); + } + + // this function cannot be 'view' or the RevertProxy will fail weirdly + function callFindLowerBound(uint256 val) external { + 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/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 +) From 2abf20789e2f9a508dcecef0626ebb0e797c9583 Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Fri, 7 Jul 2023 18:04:18 -0300 Subject: [PATCH 03/29] bonding: Create BondingCheckpoints contract Handles historic checkpointing ("snapshotting") and lookup of the bonding state. --- contracts/bonding/BondingCheckpoints.sol | 357 ++++++++++++++++++++++ contracts/bonding/IBondingCheckpoints.sol | 32 ++ deploy/deploy_contracts.ts | 7 + 3 files changed, 396 insertions(+) create mode 100644 contracts/bonding/BondingCheckpoints.sol create mode 100644 contracts/bonding/IBondingCheckpoints.sol diff --git a/contracts/bonding/BondingCheckpoints.sol b/contracts/bonding/BondingCheckpoints.sol new file mode 100644 index 00000000..9611a0c4 --- /dev/null +++ b/contracts/bonding/BondingCheckpoints.sol @@ -0,0 +1,357 @@ +// 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/SortedArrays.sol"; + +import "../ManagerProxyTarget.sol"; +import "../IController.sol"; +import "../rounds/IRoundsManager.sol"; +import "./BondingManager.sol"; + +/** + * @title BondingCheckpoints + * @dev Checkpointing logic for BondingManager state for historical stake calculations. + */ +contract BondingCheckpoints is ManagerProxyTarget, IBondingCheckpoints { + using SortedArrays for uint256[]; + + constructor(address _controller) Manager(_controller) {} + + struct BondingCheckpoint { + /** + * @dev The amount of bonded tokens to another delegate as per 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 {BondingManager-delegatorCumulativeStakeAndFees}. + */ + uint256 lastClaimRound; + /** + * @dev The last round during which the transcoder called {BondingManager-reward}. This is needed to find a + * reward pool for any round when calculating historical rewards. + * + * Notice that this is the only field that comes from the Transcoder struct in BondingManager, not Delegator. + */ + uint256 lastRewardRound; + // TODO: add a storage gap? + } + + /** + * @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 Checkpoints by account (delegators and transcoders). + */ + mapping(address => BondingCheckpointsByRound) private bondingCheckpoints; + + /** + * @dev Rounds in which we have checkpoints for the total active stake. This and {totalActiveStakeCheckpoints} are + * handled in the same wat that {BondingCheckpointsByRound}, with rounds stored and queried on this array and + * checkpointed value stored and retrieved from the mapping. + */ + uint256[] totalStakeCheckpointRounds; + /** + * @dev See {totalStakeCheckpointRounds} above. + */ + mapping(uint256 => uint256) private totalActiveStakeCheckpoints; + + // IERC6372 interface implementation + + /** + * @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() public pure returns (string memory) { + return "mode=livepeer_round"; + } + + // 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 + ) public virtual onlyBondingManager { + require(_startRound <= clock() + 1, "can only checkpoint delegator up to the next round"); + require(_lastClaimRound < _startRound, "claim round must always be lower than start round"); + + BondingCheckpointsByRound storage checkpoints = bondingCheckpoints[_account]; + + checkpoints.data[_startRound] = BondingCheckpoint( + _bondedAmount, + _delegateAddress, + _delegatedAmount, + _lastClaimRound, + _lastRewardRound + ); + + // 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); + } + + /** + * @notice Returns whether an account already has any checkpoint. + * @dev This is used in BondingManager logic to initialize the checkpointing of existing accounts. It is meant to be + * called once we deploy the checkpointing logic for the first time, so we have a starting checkpoint from all + * accounts in the system. + */ + function hasCheckpoint(address _account) external virtual returns (bool) { + return bondingCheckpoints[_account].startRounds.length > 0; + } + + /** + * @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) public virtual onlyBondingManager { + require(_round <= clock(), "can only checkpoint total active stake in the current round"); + + totalActiveStakeCheckpoints[_round] = _totalStake; + + totalStakeCheckpointRounds.pushSorted(_round); + } + + // 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) { + require(_round <= clock(), "getTotalActiveStakeAt: future lookup"); + + uint256 activeStake = totalActiveStakeCheckpoints[_round]; + + if (activeStake == 0) { + uint256 lastInitialized = totalStakeCheckpointRounds.findLowerBound(_round); + + // Check that the round was in fact initialized so we don't return a 0 value accidentally. + require(lastInitialized == _round, "getTotalActiveStakeAt: round was not initialized"); + } + + return activeStake; + } + + /** + * @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 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) + { + require(_round <= clock(), "getBondingCheckpointAt: future lookup"); + + 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 startRound = checkpoints.startRounds.findLowerBound(_round); + 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 = getTranscoderEarningPoolForRound( + bond.delegateAddress, + bond.lastClaimRound + ); + require(startPool.cumulativeRewardFactor > 0, "missing earning pool from delegator's last claim round"); + + (uint256 rewardRound, EarningsPool.Data memory endPool) = getTranscoderLastRewardsEarningPool( + bond.delegateAddress, + _round + ); + + // Only allow reward factor to be zero if transcoder had never called reward() + require( + endPool.cumulativeRewardFactor > 0 || rewardRound == 0, + "missing transcoder earning pool on reported last reward 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, ) = bondingManager().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 getTranscoderLastRewardsEarningPool(address _transcoder, uint256 _round) + internal + view + returns (uint256 rewardRound, EarningsPool.Data memory pool) + { + BondingCheckpoint storage bond = getBondingCheckpointAt(_transcoder, _round); + rewardRound = bond.lastRewardRound; + pool = getTranscoderEarningPoolForRound(_transcoder, rewardRound); + } + + /** + * @dev Proxy for {BondingManager-getTranscoderEarningsPoolForRound} that returns an EarningsPool.Data struct. + */ + function getTranscoderEarningPoolForRound(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 Modified to ensure the sender is BondingManager + */ + modifier onlyBondingManager() { + _onlyBondingManager(); + _; + } + + /** + * @dev Return BondingManager interface + */ + function bondingManager() internal view returns (BondingManager) { + return BondingManager(controller.getContract(keccak256("BondingManager"))); + } + + /** + * @dev Return IRoundsManager interface + */ + function roundsManager() public view returns (IRoundsManager) { + return IRoundsManager(controller.getContract(keccak256("RoundsManager"))); + } + + /** + * @dev Ensure the sender is BondingManager + */ + function _onlyBondingManager() internal view { + require(msg.sender == address(bondingManager()), "caller must be BondingManager"); + } +} diff --git a/contracts/bonding/IBondingCheckpoints.sol b/contracts/bonding/IBondingCheckpoints.sol new file mode 100644 index 00000000..9ebd437f --- /dev/null +++ b/contracts/bonding/IBondingCheckpoints.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "@openzeppelin/contracts-upgradeable/interfaces/IERC6372Upgradeable.sol"; + +/** + * @title Interface for BondingCheckpoints + */ +interface IBondingCheckpoints is IERC6372Upgradeable { + // 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 getTotalActiveStakeAt(uint256 _round) external view returns (uint256); + + function getBondingStateAt(address _account, uint256 _round) + external + view + returns (uint256 amount, address delegateAddress); +} diff --git a/deploy/deploy_contracts.ts b/deploy/deploy_contracts.ts index 1a415fc6..58477f69 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: "BondingCheckpoints", + name: "BondingCheckpoints", + proxy: true, + args: [Controller.address] + }) + // rounds manager let roundsManager if (!isLiveNetwork(hre.network.name)) { From a9dde9db815877d95abc2b970a811f363a4fcb5b Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Fri, 7 Jul 2023 18:05:27 -0300 Subject: [PATCH 04/29] bonding: Checkpoint bonding state on changes --- contracts/bonding/BondingManager.sol | 133 +++++++++++++++++++++++---- 1 file changed, 114 insertions(+), 19 deletions(-) diff --git a/contracts/bonding/BondingManager.sol b/contracts/bonding/BondingManager.sol index 7b142265..cb8e8e33 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 "./IBondingCheckpoints.sol"; import "@openzeppelin/contracts/utils/math/SafeMath.sol"; @@ -348,6 +349,8 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { uint256 _slashAmount, uint256 _finderFee ) external whenSystemNotPaused onlyVerifier { + _autoClaimEarnings(_transcoder); + Delegator storage del = delegators[_transcoder]; if (del.bondedAmount > 0) { @@ -361,6 +364,8 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { // Decrease bonded stake del.bondedAmount = del.bondedAmount.sub(penalty); + checkpointBondingState(_transcoder, del, transcoders[_transcoder]); + // If still bonded decrease delegate's delegated amount if (delegatorStatus(_transcoder) == DelegatorStatus.Bonded) { delegators[del.delegateAddress].delegatedAmount = delegators[del.delegateAddress].delegatedAmount.sub( @@ -407,6 +412,11 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { */ function setCurrentRoundTotalActiveStake() external onlyRoundsManager { currentRoundTotalActiveStake = nextRoundTotalActiveStake; + + IBondingCheckpoints checkpoints = bondingCheckpoints(); + if (address(checkpoints) != address(0)) { + checkpoints.checkpointTotalActiveStake(currentRoundTotalActiveStake, roundsManager().currentRound()); + } } /** @@ -524,6 +534,9 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { if (currPool.cumulativeRewardFactor == 0) { currPool.cumulativeRewardFactor = cumulativeFactorsPool(newDelegate, newDelegate.lastRewardRound) .cumulativeRewardFactor; + if (currPool.cumulativeRewardFactor == 0) { + currPool.cumulativeRewardFactor = PreciseMathUtils.percPoints(1, 1); + } } if (currPool.cumulativeFeeFactor == 0) { currPool.cumulativeFeeFactor = cumulativeFactorsPool(newDelegate, newDelegate.lastFeeRound) @@ -538,6 +551,8 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { // Update bonded amount del.bondedAmount = currentBondedAmount.add(_amount); + checkpointBondingState(_owner, del, transcoders[_owner]); + increaseTotalStake(_to, delegationAmount, _currDelegateNewPosPrev, _currDelegateNewPosNext); if (_amount > 0) { @@ -548,6 +563,40 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { emit Bond(_to, currentDelegate, _owner, _amount, del.bondedAmount); } + /** + * @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; + bondingCheckpoints().checkpointBondingState( + _owner, + startRound, + _delegator.bondedAmount, + _delegator.delegateAddress, + _delegator.delegatedAmount, + _delegator.lastClaimRound, + _transcoder.lastRewardRound + ); + } + + /** + * @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. + * Implemented as a deploy utility to checkpoint the existing state when deploying the BondingCheckpoints contract. + * @param _account The account to initialize the bonding checkpoint for + */ + function checkpointBondingState(address _account) external { + checkpointBondingState(_account, delegators[_account], transcoders[_account]); + } + /** * @notice Delegates stake towards a specific address and updates the transcoder pool using optional list hints if needed * @dev If the caller is decreasing the stake of its old delegate in the transcoder pool, the caller can provide an optional hint @@ -700,6 +749,9 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { } } + // No problem that startRound may have been cleared above, checkpoints are always made for currentRound()+1 + checkpointBondingState(msg.sender, del, transcoders[msg.sender]); + // If msg.sender was resigned this statement will only decrease delegators[currentDelegate].delegatedAmount decreaseTotalStake(currentDelegate, _amount, _newPosPrev, _newPosNext); @@ -799,6 +851,8 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { // Set last round that transcoder called reward t.lastRewardRound = currentRound; + checkpointBondingState(msg.sender, delegators[msg.sender], t); + emit Reward(msg.sender, rewardTokens); } @@ -1120,7 +1174,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 BondingCheckpoints * @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 @@ -1134,29 +1189,49 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { ) internal view returns (uint256 cStake, uint256 cFees) { // Fetch start cumulative factors EarningsPool.Data memory startPool = cumulativeFactorsPool(_transcoder, _startRound); + // Fetch end cumulative factors + EarningsPool.Data memory endPool = latestCumulativeFactorsPool(_transcoder, _endRound); + return delegatorCumulativeStakeAndFees(startPool, endPool, _stake, _fees); + } + + /** + * @notice Calculates a delegator's cumulative stake and fees using the LIP-36 earnings claiming algorithm. + * @dev This is a mostly a memroy-only function to be called from other contracts. Created for historical stake + * calculations with BondingCheckpoints. + * @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 + ) public 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 (_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); + if (_endPool.cumulativeRewardFactor == 0) { + _endPool.cumulativeRewardFactor = PreciseMathUtils.percPoints(1, 1); } cFees = _fees.add( PreciseMathUtils.percOf( _stake, - endPool.cumulativeFeeFactor.sub(startPool.cumulativeFeeFactor), - startPool.cumulativeRewardFactor + _endPool.cumulativeFeeFactor.sub(_startPool.cumulativeFeeFactor), + _startPool.cumulativeRewardFactor ) ); - cStake = PreciseMathUtils.percOf(_stake, endPool.cumulativeRewardFactor, startPool.cumulativeRewardFactor); + cStake = PreciseMathUtils.percOf(_stake, _endPool.cumulativeRewardFactor, _startPool.cumulativeRewardFactor); return (cStake, cFees); } @@ -1208,9 +1283,12 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { 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); @@ -1218,7 +1296,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 @@ -1236,8 +1313,12 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { } } + Delegator storage del = delegators[_delegate]; + // Increase delegate's delegated amount - delegators[_delegate].delegatedAmount = delegators[_delegate].delegatedAmount.add(_amount); + del.delegatedAmount = newStake; + + checkpointBondingState(_delegate, del, t); } /** @@ -1251,15 +1332,17 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { address _newPosPrev, address _newPosNext ) internal { + 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 @@ -1273,8 +1356,12 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { t.earningsPoolPerRound[nextRound].setStake(newStake); } + Delegator storage del = delegators[_delegate]; + // Decrease old delegate's delegated amount - delegators[_delegate].delegatedAmount = delegators[_delegate].delegatedAmount.sub(_amount); + del.delegatedAmount = newStake; + + checkpointBondingState(_delegate, del, t); } /** @@ -1441,6 +1528,8 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { // Rewards are bonded by default del.bondedAmount = currentBondedAmount; del.fees = currentFees; + + checkpointBondingState(_delegator, del, transcoders[_delegator]); } /** @@ -1466,6 +1555,8 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { // Increase delegator's bonded amount del.bondedAmount = del.bondedAmount.add(amount); + checkpointBondingState(_delegator, del, transcoders[_delegator]); + // Delete lock delete del.unbondingLocks[_unbondingLockId]; @@ -1506,6 +1597,10 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { return IRoundsManager(controller.getContract(keccak256("RoundsManager"))); } + function bondingCheckpoints() internal view returns (IBondingCheckpoints) { + return IBondingCheckpoints(controller.getContract(keccak256("BondingCheckpoints"))); + } + function _onlyTicketBroker() internal view { require(msg.sender == controller.getContract(keccak256("TicketBroker")), "caller must be TicketBroker"); } From 078f348d7398cc943876ca41f4c48fada08e9291 Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Fri, 7 Jul 2023 18:06:22 -0300 Subject: [PATCH 05/29] test/bonding: Test BondingManager and Checkpoints - unit tests - integration tests - gas-report - fork test for upgrade --- contracts/bonding/BondingCheckpoints.sol | 27 +- contracts/bonding/BondingManager.sol | 52 +- contracts/bonding/IBondingCheckpoints.sol | 2 + .../bonding/libraries/EarningsPoolLIP36.sol | 39 + .../test/mocks/BondingCheckpointsMock.sol | 41 + contracts/test/mocks/BondingManagerMock.sol | 48 + contracts/test/mocks/GenericMock.sol | 5 + .../BondingCheckpointsStateInitialization.sol | 203 ++++ src/test/interfaces/ICheatCodes.sol | 2 + test/gas-report/checkpoints.js | 105 +++ test/integration/BondingCheckpoints.js | 887 ++++++++++++++++++ test/unit/BondingCheckpoints.js | 765 +++++++++++++++ test/unit/BondingManager.js | 566 +++++++++++ test/unit/helpers/Fixture.js | 7 + test/unit/helpers/expectCheckpoints.ts | 52 + 15 files changed, 2739 insertions(+), 62 deletions(-) create mode 100644 contracts/test/mocks/BondingCheckpointsMock.sol create mode 100644 src/test/BondingCheckpointsStateInitialization.sol create mode 100644 test/gas-report/checkpoints.js create mode 100644 test/integration/BondingCheckpoints.js create mode 100644 test/unit/BondingCheckpoints.js create mode 100644 test/unit/helpers/expectCheckpoints.ts diff --git a/contracts/bonding/BondingCheckpoints.sol b/contracts/bonding/BondingCheckpoints.sol index 9611a0c4..d0a5902f 100644 --- a/contracts/bonding/BondingCheckpoints.sol +++ b/contracts/bonding/BondingCheckpoints.sol @@ -5,6 +5,7 @@ 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"; @@ -37,7 +38,7 @@ contract BondingCheckpoints is ManagerProxyTarget, IBondingCheckpoints { uint256 delegatedAmount; /** * @dev The last round during which the delegator claimed its earnings. This pegs the value of bondedAmount for - * rewards calculation in {BondingManager-delegatorCumulativeStakeAndFees}. + * rewards calculation in {EarningsPoolLIP36-delegatorCumulativeStakeAndFees}. */ uint256 lastClaimRound; /** @@ -47,7 +48,6 @@ contract BondingCheckpoints is ManagerProxyTarget, IBondingCheckpoints { * Notice that this is the only field that comes from the Transcoder struct in BondingManager, not Delegator. */ uint256 lastRewardRound; - // TODO: add a storage gap? } /** @@ -122,13 +122,13 @@ contract BondingCheckpoints is ManagerProxyTarget, IBondingCheckpoints { BondingCheckpointsByRound storage checkpoints = bondingCheckpoints[_account]; - checkpoints.data[_startRound] = BondingCheckpoint( - _bondedAmount, - _delegateAddress, - _delegatedAmount, - _lastClaimRound, - _lastRewardRound - ); + checkpoints.data[_startRound] = BondingCheckpoint({ + bondedAmount: _bondedAmount, + delegateAddress: _delegateAddress, + delegatedAmount: _delegatedAmount, + lastClaimRound: _lastClaimRound, + lastRewardRound: _lastRewardRound + }); // now store the startRound itself in the startRounds array to allow us // to find it and lookup in the above mapping @@ -137,11 +137,10 @@ contract BondingCheckpoints is ManagerProxyTarget, IBondingCheckpoints { /** * @notice Returns whether an account already has any checkpoint. - * @dev This is used in BondingManager logic to initialize the checkpointing of existing accounts. It is meant to be - * called once we deploy the checkpointing logic for the first time, so we have a starting checkpoint from all - * accounts in the system. + * @dev This is meant to be called by a checkpoint initialization script once we deploy the checkpointing logic for + * the first time, so we can efficiently initialize the checkpoint state for all accounts in the system. */ - function hasCheckpoint(address _account) external virtual returns (bool) { + function hasCheckpoint(address _account) external view returns (bool) { return bondingCheckpoints[_account].startRounds.length > 0; } @@ -278,7 +277,7 @@ contract BondingCheckpoints is ManagerProxyTarget, IBondingCheckpoints { return bond.bondedAmount; } - (uint256 stakeWithRewards, ) = bondingManager().delegatorCumulativeStakeAndFees( + (uint256 stakeWithRewards, ) = EarningsPoolLIP36.delegatorCumulativeStakeAndFees( startPool, endPool, bond.bondedAmount, diff --git a/contracts/bonding/BondingManager.sol b/contracts/bonding/BondingManager.sol index cb8e8e33..5dd05937 100644 --- a/contracts/bonding/BondingManager.sol +++ b/contracts/bonding/BondingManager.sol @@ -364,8 +364,6 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { // Decrease bonded stake del.bondedAmount = del.bondedAmount.sub(penalty); - checkpointBondingState(_transcoder, del, transcoders[_transcoder]); - // If still bonded decrease delegate's delegated amount if (delegatorStatus(_transcoder) == DelegatorStatus.Bonded) { delegators[del.delegateAddress].delegatedAmount = delegators[del.delegateAddress].delegatedAmount.sub( @@ -373,6 +371,8 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { ); } + checkpointBondingState(_transcoder, del, transcoders[_transcoder]); + // Account for penalty uint256 burnAmount = penalty; @@ -413,10 +413,7 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { function setCurrentRoundTotalActiveStake() external onlyRoundsManager { currentRoundTotalActiveStake = nextRoundTotalActiveStake; - IBondingCheckpoints checkpoints = bondingCheckpoints(); - if (address(checkpoints) != address(0)) { - checkpoints.checkpointTotalActiveStake(currentRoundTotalActiveStake, roundsManager().currentRound()); - } + bondingCheckpoints().checkpointTotalActiveStake(currentRoundTotalActiveStake, roundsManager().currentRound()); } /** @@ -1192,48 +1189,7 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { // Fetch end cumulative factors EarningsPool.Data memory endPool = latestCumulativeFactorsPool(_transcoder, _endRound); - return delegatorCumulativeStakeAndFees(startPool, endPool, _stake, _fees); - } - - /** - * @notice Calculates a delegator's cumulative stake and fees using the LIP-36 earnings claiming algorithm. - * @dev This is a mostly a memroy-only function to be called from other contracts. Created for historical stake - * calculations with BondingCheckpoints. - * @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 - ) public 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); + return EarningsPoolLIP36.delegatorCumulativeStakeAndFees(startPool, endPool, _stake, _fees); } /** diff --git a/contracts/bonding/IBondingCheckpoints.sol b/contracts/bonding/IBondingCheckpoints.sol index 9ebd437f..ad3e8589 100644 --- a/contracts/bonding/IBondingCheckpoints.sol +++ b/contracts/bonding/IBondingCheckpoints.sol @@ -23,6 +23,8 @@ interface IBondingCheckpoints is IERC6372Upgradeable { // 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) 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/test/mocks/BondingCheckpointsMock.sol b/contracts/test/mocks/BondingCheckpointsMock.sol new file mode 100644 index 00000000..c95ce040 --- /dev/null +++ b/contracts/test/mocks/BondingCheckpointsMock.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "./GenericMock.sol"; + +contract BondingCheckpointsMock 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/BondingManagerMock.sol b/contracts/test/mocks/BondingManagerMock.sol index c0ed2669..47a20403 100644 --- a/contracts/test/mocks/BondingManagerMock.sol +++ b/contracts/test/mocks/BondingManagerMock.sol @@ -6,6 +6,16 @@ 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; + } + + mapping(address => mapping(uint256 => EarningsPoolMock)) private earningPools; + function updateTranscoderWithFees( address _transcoder, uint256 _fees, @@ -13,4 +23,42 @@ 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 = earningPools[_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 { + earningPools[_transcoder][_round] = EarningsPoolMock({ + totalStake: _totalStake, + transcoderRewardCut: _transcoderRewardCut, + transcoderFeeShare: _transcoderFeeShare, + cumulativeRewardFactor: _cumulativeRewardFactor, + cumulativeFeeFactor: _cumulativeFeeFactor + }); + } } 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/src/test/BondingCheckpointsStateInitialization.sol b/src/test/BondingCheckpointsStateInitialization.sol new file mode 100644 index 00000000..56f2b652 --- /dev/null +++ b/src/test/BondingCheckpointsStateInitialization.sol @@ -0,0 +1,203 @@ +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/BondingCheckpoints.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 BondingCheckpointsStateInitialization --fork-url https://arbitrum-mainnet.infura.io/v3/$INFURA_KEY -vvv --fork-block-number 110930219 +contract BondingCheckpointsStateInitialization is GovernorBaseTest { + 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_CHECKPOINTS_ID = keccak256("BondingCheckpoints"); + bytes32 public constant BONDING_CHECKPOINTS_TARGET_ID = keccak256("BondingCheckpointsTarget"); + + // Has a non-null delegate as of fork block + address public constant DELEGATOR = 0xed89FFb5F4a7460a2F9B894b494db4F5e431f842; + // Is a transcoder as of fork block + address public constant TRANSCODER = 0x5D98F8d269C94B746A5c3C2946634dCfc75E5E60; + // Initialized on test setup + address[] public _testAddresses; + + BondingManager public newBondingManagerTarget; + BondingCheckpoints public bondingCheckpointsTarget; + IBondingCheckpoints public bondingCheckpoints; + + function setUp() public { + address nonParticipant = CHEATS.addr(1); + _testAddresses = [DELEGATOR, TRANSCODER, nonParticipant]; + + newBondingManagerTarget = new BondingManager(address(CONTROLLER)); + bondingCheckpointsTarget = new BondingCheckpoints(address(CONTROLLER)); + + ManagerProxy bondingCheckpointsProxy = new ManagerProxy(address(CONTROLLER), BONDING_CHECKPOINTS_TARGET_ID); + bondingCheckpoints = IBondingCheckpoints(address(bondingCheckpointsProxy)); + + (, gitCommitHash) = CONTROLLER.getContractInfo(BONDING_MANAGER_TARGET_ID); + + stageAndExecuteOne( + address(CONTROLLER), + 0, + abi.encodeWithSelector( + CONTROLLER.setContractInfo.selector, + BONDING_CHECKPOINTS_TARGET_ID, + address(bondingCheckpointsTarget), + gitCommitHash + ) + ); + stageAndExecuteOne( + address(CONTROLLER), + 0, + abi.encodeWithSelector( + CONTROLLER.setContractInfo.selector, + BONDING_CHECKPOINTS_ID, + address(bondingCheckpoints), + 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_CHECKPOINTS_TARGET_ID); + assertEq(infoAddr, address(bondingCheckpointsTarget)); + assertEq(infoGitCommitHash, gitCommitHash); + + (infoAddr, infoGitCommitHash) = fetchContractInfo(BONDING_CHECKPOINTS_ID); + assertEq(infoAddr, address(bondingCheckpoints)); + assertEq(infoGitCommitHash, gitCommitHash); + } + + function testNoAddressHasCheckpoints() public { + assertEq(_testAddresses.length, 3); + + for (uint256 i = 0; i < _testAddresses.length; i++) { + assertTrue(!bondingCheckpoints.hasCheckpoint(_testAddresses[i])); + } + } + + function testDisallowsQueryingEmptyState() public { + uint256 currentRound = ROUNDS_MANAGER.currentRound(); + + for (uint256 i = 0; i < _testAddresses.length; i++) { + CHEATS.expectRevert("findLowerBound: empty array"); + bondingCheckpoints.getBondingStateAt(_testAddresses[i], currentRound); + } + } + + 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(bondingCheckpoints.hasCheckpoint(addr)); + + // Still doesn't allow lookup in the current round, that comes next. + CHEATS.expectRevert("findLowerBound: all values in array are higher than searched value"); + bondingCheckpoints.getBondingStateAt(addr, currentRound); + + CHEATS.expectRevert("getBondingCheckpointAt: future lookup"); + bondingCheckpoints.getBondingStateAt(addr, currentRound + 1); + } + } + + function testAllowsQueryingTranscoderStateOnNextRound() public { + uint256 currentRound = ROUNDS_MANAGER.currentRound(); + (, , , uint256 delegatedAmount, , , ) = BONDING_MANAGER.getDelegator(TRANSCODER); + + BONDING_MANAGER.checkpointBondingState(TRANSCODER); + + // Need to wait 1 round before we can query for the checkpointed state + uint256 nextRoundStartBlock = ROUNDS_MANAGER.currentRoundStartBlock() + ROUNDS_MANAGER.roundLength(); + CHEATS.roll(nextRoundStartBlock); + assertEq(ROUNDS_MANAGER.currentRound(), currentRound + 1); + + (uint256 checkedAmount, address checkedDelegate) = bondingCheckpoints.getBondingStateAt( + TRANSCODER, + currentRound + 1 + ); + assertEq(checkedAmount, delegatedAmount); + assertEq(checkedDelegate, TRANSCODER); + } + + function testAllowsQueryingDelegatorStateOnNextRound() public { + uint256 currentRound = ROUNDS_MANAGER.currentRound(); + (, , address delegateAddress, , , , ) = BONDING_MANAGER.getDelegator(DELEGATOR); + uint256 pendingStake = BONDING_MANAGER.pendingStake(DELEGATOR, currentRound + 1); + + BONDING_MANAGER.checkpointBondingState(DELEGATOR); + // the delegate also needs to be checkpointed in case of delegators + BONDING_MANAGER.checkpointBondingState(delegateAddress); + + // Need to wait 1 round before we can query for the checkpointed state + uint256 nextRoundStartBlock = ROUNDS_MANAGER.currentRoundStartBlock() + ROUNDS_MANAGER.roundLength(); + CHEATS.roll(nextRoundStartBlock); + assertEq(ROUNDS_MANAGER.currentRound(), currentRound + 1); + + (uint256 checkedAmount, address checkedDelegate) = bondingCheckpoints.getBondingStateAt( + DELEGATOR, + currentRound + 1 + ); + + assertEq(checkedAmount, pendingStake); + assertEq(checkedDelegate, delegateAddress); + } + + function testDoesNotHaveTotalActiveStakeImmediately() public { + uint256 currentRound = ROUNDS_MANAGER.currentRound(); + + CHEATS.expectRevert("findLowerBound: empty array"); + bondingCheckpoints.getTotalActiveStakeAt(currentRound); + } + + function testDoesNotHaveTotalActiveStakeIfRoundNotInitialized() public { + uint256 currentRound = ROUNDS_MANAGER.currentRound(); + + uint256 nextRoundStartBlock = ROUNDS_MANAGER.currentRoundStartBlock() + ROUNDS_MANAGER.roundLength(); + CHEATS.roll(nextRoundStartBlock); + assertEq(ROUNDS_MANAGER.currentRound(), currentRound + 1); + + CHEATS.expectRevert("findLowerBound: empty array"); + bondingCheckpoints.getTotalActiveStakeAt(currentRound); + } + + function testCheckpointsTotalActiveStakeOnInitializeRound() public { + uint256 currentRound = ROUNDS_MANAGER.currentRound(); + + uint256 nextRoundStartBlock = ROUNDS_MANAGER.currentRoundStartBlock() + ROUNDS_MANAGER.roundLength(); + CHEATS.roll(nextRoundStartBlock); + assertEq(ROUNDS_MANAGER.currentRound(), currentRound + 1); + ROUNDS_MANAGER.initializeRound(); + + uint256 totalBonded = BONDING_MANAGER.getTotalBonded(); + + uint256 totalAcctiveStakeChk = bondingCheckpoints.getTotalActiveStakeAt(currentRound + 1); + assertEq(totalAcctiveStakeChk, totalBonded); + } +} diff --git a/src/test/interfaces/ICheatCodes.sol b/src/test/interfaces/ICheatCodes.sol index d1343d01..4225f70f 100644 --- a/src/test/interfaces/ICheatCodes.sol +++ b/src/test/interfaces/ICheatCodes.sol @@ -23,4 +23,6 @@ interface ICheatCodes { bytes calldata, bytes calldata ) external; + + function addr(uint256) external returns (address); } diff --git a/test/gas-report/checkpoints.js b/test/gas-report/checkpoints.js new file mode 100644 index 00000000..3a9302af --- /dev/null +++ b/test/gas-report/checkpoints.js @@ -0,0 +1,105 @@ +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 roundsManager + let token + + let transcoder + let delegator + + const stake = 1000 + + 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() + + // Deploy a new BondingCheckpoints contract so we can simulate a fresh deploy on existing BondingManager state + const [, gitCommitHash] = await controller.getContractInfo( + contractId("BondingCheckpoints") + ) + const newBondingCheckpoints = await ethers + .getContractFactory("BondingCheckpoints") + .then(fac => fac.deploy(controller.address)) + await controller.setContractInfo( + contractId("BondingCheckpoints"), + newBondingCheckpoints.address, + gitCommitHash + ) + }) + + beforeEach(async () => { + snapshotId = await rpc.snapshot() + }) + + afterEach(async () => { + await rpc.revert(snapshotId) + }) + + it("checkpoint delegator", async () => { + await bondingManager.checkpointBondingState(delegator.address) + }) + + it("checkpoint transcoder", async () => { + await bondingManager.checkpointBondingState(transcoder.address) + }) + + it("checkpoint non-participant", async () => { + await bondingManager.checkpointBondingState(signers[99].address) + }) +}) diff --git a/test/integration/BondingCheckpoints.js b/test/integration/BondingCheckpoints.js new file mode 100644 index 00000000..723aa0d4 --- /dev/null +++ b/test/integration/BondingCheckpoints.js @@ -0,0 +1,887 @@ +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("BondingCheckpoints", () => { + let rpc + + let signers + let bondingCheckpoints + 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 + ) + + bondingCheckpoints = await ethers.getContractAt( + "BondingCheckpoints", + fixture.BondingCheckpoints.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 => + bondingCheckpoints + .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) + + stake = stake.add(pendingRewards0) // reward call + assert.equal(await stakeAt(currentRound + 1), stake) + + stake = stake.add(pendingRewards1) // reward call + assert.equal(await stakeAt(currentRound + 2), stake) + }) + + it("should return partial rewards for all transcoder stake", async () => { + const stakeAt = round => + bondingCheckpoints + .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 => + bondingCheckpoints + .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 bondingCheckpoints.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 bondingCheckpoints.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 bondingCheckpoints.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 bondingCheckpoints.getBondingStateAt( + transcoder.address, + r + ) + activeStakeSum = activeStakeSum.add(stake) + } + + const totalStake = + await bondingCheckpoints.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) => + bondingCheckpoints + .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 => + bondingCheckpoints + .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() + + 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 bondingCheckpoints.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 => + bondingCheckpoints + .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 only allow querying total active stake on initialized rounds", async () => { + const expectRevertAt = r => + expect(totalStakeAt(r)).to.be.revertedWith( + "round was not initialized" + ) + + await expectTotalStakeAt(currentRound - 1, 0) // transcoder bonds here + await expectTotalStakeAt(currentRound, lptAmount(1)) // delegator bonds here + + // initialize gap, bonding not reflected yet + await expectRevertAt(currentRound + 1) + await expectRevertAt(currentRound + 25) + await expectRevertAt(currentRound + 49) + + // only when a round is initialized it picks up the change + await expectTotalStakeAt(currentRound + 50, lptAmount(2)) // transcoder calls reward + + // same thing here, reward only gets picked up on next initialized round + await expectRevertAt(currentRound + 51) + await expectRevertAt(currentRound + 75) + await expectRevertAt(currentRound + 99) + + // first round to be initialized + await expectTotalStakeAt( + currentRound + 100, + lptAmount(2).add(mintableTokens[currentRound + 50]) + ) + }) + }) + + 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/unit/BondingCheckpoints.js b/test/unit/BondingCheckpoints.js new file mode 100644 index 00000000..6237b265 --- /dev/null +++ b/test/unit/BondingCheckpoints.js @@ -0,0 +1,765 @@ +import Fixture from "./helpers/Fixture" +import {functionSig} from "../../utils/helpers" +import {assert} from "chai" +import {ethers, web3} from "hardhat" +import chai from "chai" +import {solidity} from "ethereum-waffle" + +chai.use(solidity) +const {expect} = chai + +describe("BondingCheckpoints", () => { + let signers + let fixture + + let bondingCheckpoints + + const PERC_DIVISOR = 1000000 + + const setRound = async round => { + await fixture.roundsManager.setMockUint256( + functionSig("currentRound()"), + round + ) + } + + before(async () => { + signers = await ethers.getSigners() + fixture = new Fixture(web3) + await fixture.deploy() + + const bondingCheckpointsFac = await ethers.getContractFactory( + "BondingCheckpoints" + ) + + bondingCheckpoints = await fixture.deployAndRegister( + bondingCheckpointsFac, + "BondingCheckpoints", + fixture.controller.address + ) + }) + + beforeEach(async () => { + await fixture.setUp() + }) + + afterEach(async () => { + await fixture.tearDown() + }) + + describe("IERC6372Upgradeable", () => { + describe("clock", () => { + let currentRound + + beforeEach(async () => { + currentRound = 100 + + await setRound(currentRound) + }) + + it("should return the current round", async () => { + assert.equal(await bondingCheckpoints.clock(), currentRound) + + await setRound(currentRound + 7) + + assert.equal(await bondingCheckpoints.clock(), currentRound + 7) + }) + }) + + describe("CLOCK_MODE", () => { + it("should return mode=livepeer_round", async () => { + assert.equal( + await bondingCheckpoints.CLOCK_MODE(), + "mode=livepeer_round" + ) + }) + }) + }) + + const encodeCheckpointBondingState = ({ + account, + startRound, + bondedAmount, + delegateAddress, + delegatedAmount, + lastClaimRound, + lastRewardRound + }) => { + return bondingCheckpoints.interface.encodeFunctionData( + "checkpointBondingState", + [ + account, + startRound, + bondedAmount, + delegateAddress, + delegatedAmount, + lastClaimRound, + lastRewardRound + ] + ) + } + + const encodeCheckpointTotalActiveStake = (totalStake, round) => { + return bondingCheckpoints.interface.encodeFunctionData( + "checkpointTotalActiveStake", + [totalStake, round] + ) + } + + describe("checkpointTotalActiveStake", () => { + let currentRound + + beforeEach(async () => { + currentRound = 100 + + await setRound(currentRound) + }) + + it("should fail if BondingManager is not the caller", async () => { + const tx = bondingCheckpoints + .connect(signers[2]) + .checkpointTotalActiveStake(1337, currentRound) + await expect(tx).to.be.revertedWith("caller must be BondingManager") + }) + + it("should fail if checkpointing after current round", async () => { + const functionData = encodeCheckpointTotalActiveStake( + 1337, + currentRound + 1 + ) + + await expect( + fixture.bondingManager.execute( + bondingCheckpoints.address, + functionData + ) + ).to.be.revertedWith( + "can only checkpoint total active stake in the current round" + ) + }) + + it("should allow checkpointing in the current round", async () => { + const functionData = encodeCheckpointTotalActiveStake( + 1337, + currentRound + ) + + await fixture.bondingManager.execute( + bondingCheckpoints.address, + functionData + ) + + assert.equal( + await bondingCheckpoints.getTotalActiveStakeAt(currentRound), + 1337 + ) + }) + }) + + describe("getTotalActiveStakeAt", () => { + let currentRound + + beforeEach(async () => { + currentRound = 100 + + await setRound(currentRound) + }) + + it("should fail if round is in the future", async () => { + const tx = bondingCheckpoints.getTotalActiveStakeAt( + currentRound + 1 + ) + await expect(tx).to.be.revertedWith( + "getTotalActiveStakeAt: future lookup" + ) + }) + + it("should fail if round was not checkpointed", async () => { + const tx = bondingCheckpoints.getTotalActiveStakeAt(currentRound) + await expect(tx).to.be.revertedWith("findLowerBound: empty array") + }) + + it("should query checkpointed value in the current round", async () => { + const functionData = encodeCheckpointTotalActiveStake( + 1337, + currentRound + ) + await fixture.bondingManager.execute( + bondingCheckpoints.address, + functionData + ) + + assert.equal( + await bondingCheckpoints.getTotalActiveStakeAt(currentRound), + 1337 + ) + }) + + 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) { + const functionData = encodeCheckpointTotalActiveStake( + totalStake, + round + ) + await fixture.bondingManager.execute( + bondingCheckpoints.address, + functionData + ) + } + + // now check all past values that must be recorded + for (const [expectedStake, round] of roundStakes) { + assert.equal( + await bondingCheckpoints.getTotalActiveStakeAt(round), + 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 = bondingCheckpoints + .connect(signers[2]) + .checkpointBondingState( + transcoder.address, + currentRound + 1, + 1000, + transcoder.address, + 1000, + currentRound, + 0 + ) + await expect(tx).to.be.revertedWith("caller must be BondingManager") + }) + + it("should fail if checkpointing after 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( + bondingCheckpoints.address, + functionData + ) + ).to.be.revertedWith( + "can only checkpoint delegator up to the next round" + ) + }) + + it("should fail if lastClaimRound is not lower than start round", async () => { + const functionData = encodeCheckpointBondingState({ + account: transcoder.address, + startRound: currentRound, + bondedAmount: 1000, + delegateAddress: transcoder.address, + delegatedAmount: 1000, + lastClaimRound: currentRound, + lastRewardRound: 0 + }) + + await expect( + fixture.bondingManager.execute( + bondingCheckpoints.address, + functionData + ) + ).to.be.revertedWith( + "claim round must always be lower than start round" + ) + }) + + it("should allow checkpointing in 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 fixture.bondingManager.execute( + bondingCheckpoints.address, + functionData + ) + }) + + it("should checkpoint account state", async () => { + const functionData = encodeCheckpointBondingState({ + account: transcoder.address, + startRound: currentRound, + bondedAmount: 1000, + delegateAddress: transcoder.address, + delegatedAmount: 1000, + lastClaimRound: currentRound - 1, + lastRewardRound: 0 + }) + await fixture.bondingManager.execute( + bondingCheckpoints.address, + functionData + ) + + assert.deepEqual( + await bondingCheckpoints + .getBondingStateAt(transcoder.address, currentRound) + .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( + bondingCheckpoints.address, + functionData + ) + } + + await makeCheckpoint(1000) + + // simulating a bond where bonding manager checkpoints the current state and then the next + await makeCheckpoint(2000) + + await setRound(currentRound + 1) + + assert.deepEqual( + await bondingCheckpoints + .getBondingStateAt(transcoder.address, currentRound + 1) + .then(t => t.map(v => v.toString())), + ["2000", transcoder.address] + ) + }) + }) + + 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 bondingCheckpoints.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( + bondingCheckpoints.address, + functionData + ) + } + + for (let i = 0; i < 3; i++) { + const round = currentRound + i + await setRound(round) + + await makeCheckpoint(round + 1) + + assert.equal( + await bondingCheckpoints.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 in the future", async () => { + const tx = bondingCheckpoints.getBondingStateAt( + delegator.address, + currentRound + 1 + ) + await expect(tx).to.be.revertedWith( + "getBondingCheckpointAt: future lookup" + ) + }) + + describe("for transcoder", () => { + const makeCheckpoint = async (startRound, delegatedAmount) => { + 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( + bondingCheckpoints.address, + functionData + ) + } + + it("should disallow querying before the first checkpoint", async () => { + await makeCheckpoint(currentRound, 1000) + + const tx = bondingCheckpoints.getBondingStateAt( + transcoder.address, + currentRound - 2 + ) + await expect(tx).to.be.revertedWith( + "findLowerBound: all values in array are higher than searched value" + ) + }) + + it("should return the same round delegatedAmount and own address", async () => { + await makeCheckpoint(currentRound, 1000) + + assert.deepEqual( + await bondingCheckpoints + .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 bondingCheckpoints + .getBondingStateAt(transcoder.address, currentRound - 7) + .then(t => t.map(v => v.toString())), + ["1000", transcoder.address] + ) + + assert.deepEqual( + await bondingCheckpoints + .getBondingStateAt(transcoder.address, currentRound) + .then(t => t.map(v => v.toString())), + ["2000", transcoder.address] + ) + }) + }) + + describe("for delegator", () => { + let transcoder2 + + const checkpointTranscoder = async ({ + account, + startRound, + lastRewardRound + }) => { + const functionData = encodeCheckpointBondingState({ + account, + startRound, + bondedAmount: 0, // not used in these tests + delegateAddress: account, + delegatedAmount: 0, // not used in these tests + lastClaimRound: 0, // not used in these tests + lastRewardRound + }) + await fixture.bondingManager.execute( + bondingCheckpoints.address, + functionData + ) + } + + const setEarningPoolRewardFactor = async ( + address, + round, + factor + ) => { + await fixture.bondingManager.setMockTranscoderEarningsPoolForRound( + address, + round, + 0, + 0, + 0, + factor, + 0 + ) + } + + const checkpointDelegator = async ({ + startRound, + bondedAmount, + delegateAddress, + lastClaimRound + }) => { + 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( + bondingCheckpoints.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 disallow querying before the first checkpoint", async () => { + await checkpointDelegator({ + startRound: currentRound, + bondedAmount: 1000, + delegateAddress: transcoder.address, + lastClaimRound: currentRound - 1 + }) + + const tx = bondingCheckpoints.getBondingStateAt( + delegator.address, + currentRound - 2 + ) + await expect(tx).to.be.revertedWith( + "findLowerBound: all values in array are higher than searched value" + ) + }) + + it("should fail if there's no earning pool on the lastClaimRound", async () => { + await checkpointDelegator({ + startRound: currentRound, + bondedAmount: 1000, + delegateAddress: transcoder.address, + lastClaimRound: currentRound - 11 + }) + + const tx = bondingCheckpoints.getBondingStateAt( + delegator.address, + currentRound + ) + await expect(tx).to.be.revertedWith( + "missing earning pool from delegator's last claim round" + ) + }) + + it("should return the bonded amount if transcoder never called reward", async () => { + await checkpointDelegator({ + startRound: currentRound - 10, + bondedAmount: 1000, + delegateAddress: transcoder.address, + lastClaimRound: currentRound - 11 + }) + await setEarningPoolRewardFactor( + transcoder.address, + currentRound - 11, + PERC_DIVISOR + ) + + assert.deepEqual( + await bondingCheckpoints + .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 setEarningPoolRewardFactor( + transcoder.address, + currentRound - 11, + PERC_DIVISOR + ) + + await checkpointDelegator({ + startRound: currentRound - 5, + bondedAmount: 2000, + delegateAddress: transcoder2.address, + lastClaimRound: currentRound - 6 + }) + await setEarningPoolRewardFactor( + transcoder2.address, + currentRound - 6, + PERC_DIVISOR + ) + + assert.deepEqual( + await bondingCheckpoints + .getBondingStateAt(delegator.address, currentRound - 7) + .then(t => t.map(v => v.toString())), + ["1000", transcoder.address] + ) + + assert.deepEqual( + await bondingCheckpoints + .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, + 2 * PERC_DIVISOR + ) + + assert.deepEqual( + await bondingCheckpoints + .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 + }) + + const tx = bondingCheckpoints.getBondingStateAt( + delegator.address, + currentRound + ) + await expect(tx).to.be.revertedWith( + "missing transcoder earning pool on reported last reward round" + ) + }) + + 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 + ) + + await checkpointTranscoder({ + account: transcoder.address, + startRound: currentRound - 1, + lastRewardRound: currentRound - 2 + }) + await setEarningPoolRewardFactor( + transcoder.address, + currentRound - 2, + 3 * PERC_DIVISOR + ) + + assert.deepEqual( + await bondingCheckpoints + .getBondingStateAt(delegator.address, currentRound) + .then(t => t.map(v => v.toString())), + ["3000", transcoder.address] + ) + }) + }) + }) +}) diff --git a/test/unit/BondingManager.js b/test/unit/BondingManager.js index c3fdc8fd..662021a6 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,36 @@ describe("BondingManager", () => { ) }) }) + + it("should checkpoint the delegator and transcoder states", async () => { + const tx = await bondingManager + .connect(delegator) + .bond(1000, transcoder0.address) + + await expectCheckpoints( + fixture, + tx, + {account: delegator.address, bondedAmount: 0}, // there's always a checkpoint before the update + { + account: delegator.address, + startRound: currentRound + 1, + bondedAmount: 1000, + delegateAddress: transcoder0.address, + delegatedAmount: 0, + lastClaimRound: currentRound, + lastRewardRound: 0 + }, + { + account: transcoder0.address, + startRound: currentRound + 1, + bondedAmount: 1000, + delegateAddress: transcoder0.address, + delegatedAmount: 2000, + lastClaimRound: currentRound - 1, + lastRewardRound: 0 + } + ) + }) }) describe("bondForWithHint", () => { @@ -2262,6 +2293,54 @@ describe("BondingManager", () => { ) }) }) + + it("should checkpoint the delegator and transcoder states", async () => { + 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: delegator1.address, + bondedAmount: startBondedAmount + }, + { + account: delegator1.address, + startRound: currentRound + 1, + bondedAmount: startBondedAmount.add(1000), + delegateAddress: transcoder0.address, + delegatedAmount: 0, + lastClaimRound: currentRound, + lastRewardRound: 0 + }, + { + account: transcoder0.address, + startRound: currentRound + 1, + bondedAmount: selfBondedAmount, + delegateAddress: transcoder0.address, + delegatedAmount: startDelegatedAmount.add(1000), + lastClaimRound: currentRound - 1, + lastRewardRound: 0 + } + ) + }) }) describe("unbond", () => { @@ -2421,6 +2500,37 @@ describe("BondingManager", () => { assert.equal(startActiveStake.toString(), endActiveStake.toString()) }) + it("should checkpoint the delegator and transcoder states", async () => { + const tx = await bondingManager.connect(delegator).unbond(500) + + await expectCheckpoints( + fixture, + tx, + { + account: delegator.address, + bondedAmount: 1000 + }, + { + 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 + }, + { + account: transcoder.address, + startRound: currentRound + 2, + bondedAmount: 1000, + delegateAddress: transcoder.address, + delegatedAmount: 1500, + lastClaimRound: currentRound, + lastRewardRound: 0 + } + ) + }) + describe("partial unbonding", () => { it("should create an unbonding lock for a partial unbond", async () => { const unbondingLockID = ( @@ -2708,6 +2818,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 BondingCheckpoints is not registered", async () => { + await fixture.register("BondingCheckpoints", ZERO_ADDRESS) + + await expect( + bondingManager.checkpointBondingState(transcoder.address) + ).to.be.revertedWith("function call to a non-contract account") + }) + + it("should call BondingCheckpoints 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 BondingCheckpoints 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 BondingCheckpoints 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 @@ -2806,6 +3141,36 @@ describe("BondingManager", () => { assert.equal(lock[1], 0, "wrong lock withdrawRound should be 0") }) + it("should checkpoint the delegator and transcoder states", async () => { + const tx = await bondingManager + .connect(delegator) + .rebond(unbondingLockID) + + await expectCheckpoints( + fixture, + tx, + // no checkpoint of current state here since earnings are already claimed in round on the unbond call + { + account: delegator.address, + startRound: currentRound + 2, + bondedAmount: 1000, + delegateAddress: transcoder.address, + delegatedAmount: 0, + lastClaimRound: currentRound + 1, + lastRewardRound: 0 + }, + { + account: transcoder.address, + startRound: currentRound + 2, + bondedAmount: 1000, + delegateAddress: transcoder.address, + delegatedAmount: 2000, + lastClaimRound: currentRound, + 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) @@ -3050,6 +3415,38 @@ describe("BondingManager", () => { assert.equal(lock[1], 0, "wrong lock withdrawRound should be 0") }) + it("should checkpoint the delegator and transcoder states", async () => { + // 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: delegator.address, + startRound: currentRound + 2, + bondedAmount: 500, + delegateAddress: transcoder.address, + delegatedAmount: 0, + lastClaimRound: currentRound + 1, + lastRewardRound: 0 + }, + { + account: transcoder.address, + startRound: currentRound + 2, + bondedAmount: 1000, + delegateAddress: transcoder.address, + delegatedAmount: 1500, + lastClaimRound: currentRound, + lastRewardRound: 0 + } + ) + }) + describe("new delegate is a registered transcoder", () => { beforeEach(async () => { // Delegator unbonds rest of tokens transitioning to the Unbonded state @@ -3536,6 +3933,68 @@ describe("BondingManager", () => { expect(d1LastClaimRound).to.equal(currentRound + 3) expect(d2LastClaimRound).to.equal(currentRound + 3) }) + + it("should checkpoint both delegators and transcoders states", async () => { + const tx = await bondingManager + .connect(delegator1) + .transferBond( + delegator2.address, + 1800, + ZERO_ADDRESS, + ZERO_ADDRESS, + ZERO_ADDRESS, + ZERO_ADDRESS + ) + + await expectCheckpoints( + fixture, + tx, + { + account: delegator1.address, + bondedAmount: 2000 + }, + { + account: delegator1.address, + startRound: currentRound + 4, + bondedAmount: 200, + delegateAddress: transcoder0.address, + delegatedAmount: 0, + lastClaimRound: currentRound + 3, + lastRewardRound: 0 + }, + { + account: transcoder0.address, + startRound: currentRound + 4, + bondedAmount: 1000, + delegateAddress: transcoder0.address, + delegatedAmount: 1200, + lastClaimRound: currentRound - 1, + lastRewardRound: 0 + }, + { + account: delegator2.address, + bondedAmount: 2000 + }, + { + account: delegator2.address, + startRound: currentRound + 4, + bondedAmount: 3800, + delegateAddress: transcoder1.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 + } + ) + }) }) describe("receiver is bonded to zero address", () => { @@ -4038,6 +4497,30 @@ 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, + delegatedAmount: 2000, // the first checkpoint happens when we bump the delegatedAmount value + lastRewardRound: 0 + }, + { + account: transcoder.address, + startRound: currentRound + 2, + bondedAmount: 1000, + delegateAddress: transcoder.address, + delegatedAmount: 2000, + lastClaimRound: currentRound, + lastRewardRound: currentRound + 1 // then it's made again when the lastRewardRound is bumped + } + ) + }) + it("should update caller with rewards if lastActiveStakeUpdateRound < currentRound", async () => { await fixture.roundsManager.setMockUint256( functionSig("currentRound()"), @@ -4666,6 +5149,45 @@ describe("BondingManager", () => { ) }) + it("should checkpoint the transcoder state", async () => { + 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, + // current state is checkpointed first + { + account: transcoder.address, + bondedAmount: startBondedAmount, + delegatedAmount: startBondedAmount + }, + { + account: transcoder.address, + startRound: currentRound + 2, + bondedAmount: startBondedAmount / 2, + delegateAddress: transcoder.address, + delegatedAmount: startBondedAmount / 2, + lastClaimRound: currentRound + 1, + lastRewardRound: 0 + } + ) + }) + describe("transcoder is bonded", () => { it("updates delegated amount and next total stake tokens", async () => { const startNextTotalStake = @@ -5144,6 +5666,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()"), @@ -6281,10 +6820,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 @@ -6298,6 +6843,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 () => { @@ -6316,6 +6866,22 @@ 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.bondingCheckpoints, + "CheckpointTotalActiveStake" + ) + .withArgs(1000, currentRound) + }) }) describe("transcoderStatus", () => { diff --git a/test/unit/helpers/Fixture.js b/test/unit/helpers/Fixture.js index e806dfdf..6f4eaa9c 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 BondingCheckpointsMock = await ethers.getContractFactory( + "BondingCheckpointsMock" + ) 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.bondingCheckpoints = await this.deployAndRegister( + BondingCheckpointsMock, + "BondingCheckpoints" + ) 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..cd315336 --- /dev/null +++ b/test/unit/helpers/expectCheckpoints.ts @@ -0,0 +1,52 @@ +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 = + await fixture.bondingCheckpoints.filters.CheckpointBondingState() + const events = await fixture.bondingCheckpoints.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}` + ) + } + } +} From 22ee2dbf1cb2af7563e50cf7d7656270a2e7ff6b Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Fri, 14 Jul 2023 14:08:02 -0300 Subject: [PATCH 06/29] bonding: Migrate to custom error types --- contracts/bonding/BondingCheckpoints.sol | 60 +++++++++++++------ contracts/bonding/IBondingCheckpoints.sol | 12 +++- contracts/bonding/libraries/SortedArrays.sol | 11 +++- .../BondingCheckpointsStateInitialization.sol | 35 ++++++++--- test/integration/BondingCheckpoints.js | 2 +- test/unit/BondingCheckpoints.js | 51 ++++++++++++---- 6 files changed, 130 insertions(+), 41 deletions(-) diff --git a/contracts/bonding/BondingCheckpoints.sol b/contracts/bonding/BondingCheckpoints.sol index d0a5902f..e4358463 100644 --- a/contracts/bonding/BondingCheckpoints.sol +++ b/contracts/bonding/BondingCheckpoints.sol @@ -117,8 +117,11 @@ contract BondingCheckpoints is ManagerProxyTarget, IBondingCheckpoints { uint256 _lastClaimRound, uint256 _lastRewardRound ) public virtual onlyBondingManager { - require(_startRound <= clock() + 1, "can only checkpoint delegator up to the next round"); - require(_lastClaimRound < _startRound, "claim round must always be lower than start round"); + if (_startRound > clock() + 1) { + revert FutureCheckpoint(_startRound, clock() + 1); + } else if (_lastClaimRound >= _startRound) { + revert FutureLastClaimRound(_lastClaimRound, _startRound - 1); + } BondingCheckpointsByRound storage checkpoints = bondingCheckpoints[_account]; @@ -152,7 +155,9 @@ contract BondingCheckpoints is ManagerProxyTarget, IBondingCheckpoints { * @param _round The round for which the total active stake is valid. This is normally the current round. */ function checkpointTotalActiveStake(uint256 _totalStake, uint256 _round) public virtual onlyBondingManager { - require(_round <= clock(), "can only checkpoint total active stake in the current round"); + if (_round > clock()) { + revert FutureCheckpoint(_round, clock()); + } totalActiveStakeCheckpoints[_round] = _totalStake; @@ -166,15 +171,19 @@ contract BondingCheckpoints is ManagerProxyTarget, IBondingCheckpoints { * @param _round The round for which we want to get the total active stake. */ function getTotalActiveStakeAt(uint256 _round) public view virtual returns (uint256) { - require(_round <= clock(), "getTotalActiveStakeAt: future lookup"); + if (_round > clock()) { + revert FutureLookup(_round, clock()); + } uint256 activeStake = totalActiveStakeCheckpoints[_round]; if (activeStake == 0) { - uint256 lastInitialized = totalStakeCheckpointRounds.findLowerBound(_round); + uint256 lastInitialized = checkedFindLowerBound(totalStakeCheckpointRounds, _round); // Check that the round was in fact initialized so we don't return a 0 value accidentally. - require(lastInitialized == _round, "getTotalActiveStakeAt: round was not initialized"); + if (lastInitialized != _round) { + revert MissingRoundCheckpoint(_round); + } } return activeStake; @@ -226,7 +235,9 @@ contract BondingCheckpoints is ManagerProxyTarget, IBondingCheckpoints { view returns (BondingCheckpoint storage) { - require(_round <= clock(), "getBondingCheckpointAt: future lookup"); + if (_round > clock()) { + revert FutureLookup(_round, clock()); + } BondingCheckpointsByRound storage checkpoints = bondingCheckpoints[_account]; @@ -237,7 +248,7 @@ contract BondingCheckpoints is ManagerProxyTarget, IBondingCheckpoints { return bond; } - uint256 startRound = checkpoints.startRounds.findLowerBound(_round); + uint256 startRound = checkedFindLowerBound(checkpoints.startRounds, _round); return checkpoints.data[startRound]; } @@ -258,19 +269,12 @@ contract BondingCheckpoints is ManagerProxyTarget, IBondingCheckpoints { bond.delegateAddress, bond.lastClaimRound ); - require(startPool.cumulativeRewardFactor > 0, "missing earning pool from delegator's last claim round"); (uint256 rewardRound, EarningsPool.Data memory endPool) = getTranscoderLastRewardsEarningPool( bond.delegateAddress, _round ); - // Only allow reward factor to be zero if transcoder had never called reward() - require( - endPool.cumulativeRewardFactor > 0 || rewardRound == 0, - "missing transcoder earning pool on reported last reward 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. @@ -303,7 +307,11 @@ contract BondingCheckpoints is ManagerProxyTarget, IBondingCheckpoints { { BondingCheckpoint storage bond = getBondingCheckpointAt(_transcoder, _round); rewardRound = bond.lastRewardRound; - pool = getTranscoderEarningPoolForRound(_transcoder, rewardRound); + + // only fetch pool if there is a previous reward() call recorded + if (rewardRound > 0) { + pool = getTranscoderEarningPoolForRound(_transcoder, rewardRound); + } } /** @@ -321,6 +329,22 @@ contract BondingCheckpoints is ManagerProxyTarget, IBondingCheckpoints { pool.cumulativeRewardFactor, pool.cumulativeFeeFactor ) = bondingManager().getTranscoderEarningsPoolForRound(_transcoder, _round); + + if (pool.cumulativeRewardFactor == 0) { + revert MissingEarningsPool(_transcoder, _round); + } + } + + /** + * @dev Helper to return more helpful custom errors in case of bad queries. + */ + function checkedFindLowerBound(uint256[] storage array, uint256 value) internal view returns (uint256) { + if (array.length == 0) { + revert NoRecordedCheckpoints(); + } else if (array[0] > value) { + revert PastLookup(value, array[0]); + } + return array.findLowerBound(value); } // Manager/Controller helpers @@ -351,6 +375,8 @@ contract BondingCheckpoints is ManagerProxyTarget, IBondingCheckpoints { * @dev Ensure the sender is BondingManager */ function _onlyBondingManager() internal view { - require(msg.sender == address(bondingManager()), "caller must be BondingManager"); + if (msg.sender != address(bondingManager())) { + revert InvalidCaller(msg.sender, address(bondingManager())); + } } } diff --git a/contracts/bonding/IBondingCheckpoints.sol b/contracts/bonding/IBondingCheckpoints.sol index ad3e8589..0b0b8d88 100644 --- a/contracts/bonding/IBondingCheckpoints.sol +++ b/contracts/bonding/IBondingCheckpoints.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.9; +pragma solidity ^0.8.9; import "@openzeppelin/contracts-upgradeable/interfaces/IERC6372Upgradeable.sol"; @@ -9,6 +9,10 @@ import "@openzeppelin/contracts-upgradeable/interfaces/IERC6372Upgradeable.sol"; interface IBondingCheckpoints is IERC6372Upgradeable { // BondingManager hooks + error InvalidCaller(address caller, address required); + error FutureCheckpoint(uint256 checkpointRound, uint256 maxAllowed); + error FutureLastClaimRound(uint256 lastClaimRound, uint256 maxAllowed); + function checkpointBondingState( address _account, uint256 _startRound, @@ -23,6 +27,12 @@ interface IBondingCheckpoints is IERC6372Upgradeable { // Historical stake access functions + error FutureLookup(uint256 queryRound, uint256 currentRound); + error MissingRoundCheckpoint(uint256 round); + error NoRecordedCheckpoints(); + error PastLookup(uint256 queryRound, uint256 firstCheckpointRound); + error MissingEarningsPool(address transcoder, uint256 round); + function hasCheckpoint(address _account) external view returns (bool); function getTotalActiveStakeAt(uint256 _round) external view returns (uint256); diff --git a/contracts/bonding/libraries/SortedArrays.sol b/contracts/bonding/libraries/SortedArrays.sol index 48b49ccd..a3bc51f0 100644 --- a/contracts/bonding/libraries/SortedArrays.sol +++ b/contracts/bonding/libraries/SortedArrays.sol @@ -12,6 +12,9 @@ import "@openzeppelin/contracts/utils/Arrays.sol"; library SortedArrays { using Arrays for uint256[]; + error EmptyArray(); + error NoLowerBoundInArray(uint256 queryValue, uint256 minValue); + /** * @notice Searches a sorted _array and returns the last element to be lower or equal to _val. * @@ -28,7 +31,9 @@ library SortedArrays { */ function findLowerBound(uint256[] storage _array, uint256 _val) internal view returns (uint256) { uint256 len = _array.length; - require(len > 0, "findLowerBound: empty array"); + if (len == 0) { + revert EmptyArray(); + } uint256 lastElm = _array[len - 1]; if (lastElm <= _val) { @@ -47,7 +52,9 @@ library SortedArrays { } // a 0 idx means that the first elem is already higher than the searched value (and not equal, checked above) - require(upperIdx > 0, "findLowerBound: all values in array are higher than searched value"); + if (upperIdx == 0) { + revert NoLowerBoundInArray(_val, _array[0]); + } // the upperElm is the first element higher than the value we want, so return the previous element return _array[upperIdx - 1]; diff --git a/src/test/BondingCheckpointsStateInitialization.sol b/src/test/BondingCheckpointsStateInitialization.sol index 56f2b652..e3b3e570 100644 --- a/src/test/BondingCheckpointsStateInitialization.sol +++ b/src/test/BondingCheckpointsStateInitialization.sol @@ -104,7 +104,7 @@ contract BondingCheckpointsStateInitialization is GovernorBaseTest { uint256 currentRound = ROUNDS_MANAGER.currentRound(); for (uint256 i = 0; i < _testAddresses.length; i++) { - CHEATS.expectRevert("findLowerBound: empty array"); + CHEATS.expectRevert(IBondingCheckpoints.NoRecordedCheckpoints.selector); bondingCheckpoints.getBondingStateAt(_testAddresses[i], currentRound); } } @@ -119,10 +119,14 @@ contract BondingCheckpointsStateInitialization is GovernorBaseTest { assertTrue(bondingCheckpoints.hasCheckpoint(addr)); // Still doesn't allow lookup in the current round, that comes next. - CHEATS.expectRevert("findLowerBound: all values in array are higher than searched value"); + CHEATS.expectRevert( + abi.encodeWithSelector(IBondingCheckpoints.PastLookup.selector, currentRound, currentRound + 1) + ); bondingCheckpoints.getBondingStateAt(addr, currentRound); - CHEATS.expectRevert("getBondingCheckpointAt: future lookup"); + CHEATS.expectRevert( + abi.encodeWithSelector(IBondingCheckpoints.FutureLookup.selector, currentRound + 1, currentRound) + ); bondingCheckpoints.getBondingStateAt(addr, currentRound + 1); } } @@ -172,7 +176,7 @@ contract BondingCheckpointsStateInitialization is GovernorBaseTest { function testDoesNotHaveTotalActiveStakeImmediately() public { uint256 currentRound = ROUNDS_MANAGER.currentRound(); - CHEATS.expectRevert("findLowerBound: empty array"); + CHEATS.expectRevert(IBondingCheckpoints.NoRecordedCheckpoints.selector); bondingCheckpoints.getTotalActiveStakeAt(currentRound); } @@ -183,8 +187,25 @@ contract BondingCheckpointsStateInitialization is GovernorBaseTest { CHEATS.roll(nextRoundStartBlock); assertEq(ROUNDS_MANAGER.currentRound(), currentRound + 1); - CHEATS.expectRevert("findLowerBound: empty array"); - bondingCheckpoints.getTotalActiveStakeAt(currentRound); + CHEATS.expectRevert(IBondingCheckpoints.NoRecordedCheckpoints.selector); + bondingCheckpoints.getTotalActiveStakeAt(currentRound + 1); + } + + function testDoesNotUsePastCheckpointForTotalActiveStake() 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); + + CHEATS.expectRevert( + abi.encodeWithSelector(IBondingCheckpoints.MissingRoundCheckpoint.selector, currentRound + 2) + ); + bondingCheckpoints.getTotalActiveStakeAt(currentRound + 2); } function testCheckpointsTotalActiveStakeOnInitializeRound() public { @@ -192,8 +213,8 @@ contract BondingCheckpointsStateInitialization is GovernorBaseTest { uint256 nextRoundStartBlock = ROUNDS_MANAGER.currentRoundStartBlock() + ROUNDS_MANAGER.roundLength(); CHEATS.roll(nextRoundStartBlock); - assertEq(ROUNDS_MANAGER.currentRound(), currentRound + 1); ROUNDS_MANAGER.initializeRound(); + assertEq(ROUNDS_MANAGER.currentRound(), currentRound + 1); uint256 totalBonded = BONDING_MANAGER.getTotalBonded(); diff --git a/test/integration/BondingCheckpoints.js b/test/integration/BondingCheckpoints.js index 723aa0d4..2cc4545b 100644 --- a/test/integration/BondingCheckpoints.js +++ b/test/integration/BondingCheckpoints.js @@ -791,7 +791,7 @@ describe("BondingCheckpoints", () => { it("should only allow querying total active stake on initialized rounds", async () => { const expectRevertAt = r => expect(totalStakeAt(r)).to.be.revertedWith( - "round was not initialized" + `MissingRoundCheckpoint(${r})` ) await expectTotalStakeAt(currentRound - 1, 0) // transcoder bonds here diff --git a/test/unit/BondingCheckpoints.js b/test/unit/BondingCheckpoints.js index 6237b265..a2332a1e 100644 --- a/test/unit/BondingCheckpoints.js +++ b/test/unit/BondingCheckpoints.js @@ -106,6 +106,13 @@ describe("BondingCheckpoints", () => { ) } + const customErrorAbi = (sig, args) => { + const iface = new ethers.utils.Interface([`function ${sig}`]) + const funcDataHex = iface.encodeFunctionData(sig, args) + const abi = Buffer.from(funcDataHex, "hex") + return abi.toString() + } + describe("checkpointTotalActiveStake", () => { let currentRound @@ -119,7 +126,9 @@ describe("BondingCheckpoints", () => { const tx = bondingCheckpoints .connect(signers[2]) .checkpointTotalActiveStake(1337, currentRound) - await expect(tx).to.be.revertedWith("caller must be BondingManager") + await expect(tx).to.be.revertedWith( + `InvalidCaller("${signers[2].address}", "${fixture.bondingManager.address}")` + ) }) it("should fail if checkpointing after current round", async () => { @@ -134,7 +143,10 @@ describe("BondingCheckpoints", () => { functionData ) ).to.be.revertedWith( - "can only checkpoint total active stake in the current round" + customErrorAbi("FutureCheckpoint(uint256,uint256)", [ + currentRound + 1, + currentRound + ]) ) }) @@ -170,13 +182,13 @@ describe("BondingCheckpoints", () => { currentRound + 1 ) await expect(tx).to.be.revertedWith( - "getTotalActiveStakeAt: future lookup" + `FutureLookup(${currentRound + 1}, ${currentRound})` ) }) it("should fail if round was not checkpointed", async () => { const tx = bondingCheckpoints.getTotalActiveStakeAt(currentRound) - await expect(tx).to.be.revertedWith("findLowerBound: empty array") + await expect(tx).to.be.revertedWith("NoRecordedCheckpoints()") }) it("should query checkpointed value in the current round", async () => { @@ -238,7 +250,7 @@ describe("BondingCheckpoints", () => { it("should fail if BondingManager is not the caller", async () => { const tx = bondingCheckpoints - .connect(signers[2]) + .connect(signers[4]) .checkpointBondingState( transcoder.address, currentRound + 1, @@ -248,7 +260,9 @@ describe("BondingCheckpoints", () => { currentRound, 0 ) - await expect(tx).to.be.revertedWith("caller must be BondingManager") + await expect(tx).to.be.revertedWith( + `InvalidCaller("${signers[4].address}", "${fixture.bondingManager.address}")` + ) }) it("should fail if checkpointing after next round", async () => { @@ -268,7 +282,10 @@ describe("BondingCheckpoints", () => { functionData ) ).to.be.revertedWith( - "can only checkpoint delegator up to the next round" + customErrorAbi("FutureCheckpoint(uint256,uint256)", [ + currentRound + 2, + currentRound + 1 + ]) ) }) @@ -289,7 +306,10 @@ describe("BondingCheckpoints", () => { functionData ) ).to.be.revertedWith( - "claim round must always be lower than start round" + customErrorAbi("FutureLastClaimRound(uint256,uint256)", [ + currentRound, + currentRound - 1 + ]) ) }) @@ -435,7 +455,7 @@ describe("BondingCheckpoints", () => { currentRound + 1 ) await expect(tx).to.be.revertedWith( - "getBondingCheckpointAt: future lookup" + `FutureLookup(${currentRound + 1}, ${currentRound})` ) }) @@ -464,7 +484,7 @@ describe("BondingCheckpoints", () => { currentRound - 2 ) await expect(tx).to.be.revertedWith( - "findLowerBound: all values in array are higher than searched value" + `PastLookup(${currentRound - 2}, ${currentRound})` ) }) @@ -587,7 +607,7 @@ describe("BondingCheckpoints", () => { currentRound - 2 ) await expect(tx).to.be.revertedWith( - "findLowerBound: all values in array are higher than searched value" + `PastLookup(${currentRound - 2}, ${currentRound})` ) }) @@ -604,7 +624,9 @@ describe("BondingCheckpoints", () => { currentRound ) await expect(tx).to.be.revertedWith( - "missing earning pool from delegator's last claim round" + `MissingEarningsPool("${transcoder.address}", ${ + currentRound - 11 + })` ) }) @@ -719,13 +741,16 @@ describe("BondingCheckpoints", () => { startRound: currentRound - 1, lastRewardRound: currentRound - 2 }) + // no earning pool for currentRound - 2 const tx = bondingCheckpoints.getBondingStateAt( delegator.address, currentRound ) await expect(tx).to.be.revertedWith( - "missing transcoder earning pool on reported last reward round" + `MissingEarningsPool("${transcoder.address}", ${ + currentRound - 2 + })` ) }) From 2e4157ba5a6bd296b5fb118b49daeab2b2b94f9f Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Mon, 17 Jul 2023 00:38:34 -0300 Subject: [PATCH 07/29] bonding: Allow querying unbonded+uncheckpointed accounts This will provide a cleaner experience using governance tools, since users that don't have any stake won't get errors when querying their voting power. For users that do have stake, we will make sure to checkpoint them on first deploy. --- contracts/bonding/BondingCheckpoints.sol | 11 +++ contracts/test/mocks/BondingManagerMock.sol | 63 ++++++++++++++++- .../BondingCheckpointsStateInitialization.sol | 63 +++++++++++++++-- src/test/interfaces/ICheatCodes.sol | 2 + test/unit/BondingCheckpoints.js | 70 +++++++++++++++++++ 5 files changed, 201 insertions(+), 8 deletions(-) diff --git a/contracts/bonding/BondingCheckpoints.sol b/contracts/bonding/BondingCheckpoints.sol index e4358463..39100ba1 100644 --- a/contracts/bonding/BondingCheckpoints.sol +++ b/contracts/bonding/BondingCheckpoints.sol @@ -248,6 +248,17 @@ contract BondingCheckpoints is ManagerProxyTarget, IBondingCheckpoints { return bond; } + if (checkpoints.startRounds.length == 0) { + (uint256 bondedAmount, , , uint256 delegatedAmount, , uint256 lastClaimRound, ) = bondingManager() + .getDelegator(_account); + // we use lastClaimRound instead of startRound since the latter is cleared on a full unbond + if (lastClaimRound < _round && bondedAmount == 0 && delegatedAmount == 0) { + // If the account was not delegating to anyone at the queried round, we can just return the zero + // BondingCheckpoint value. This also handles the case of accounts that have never made a delegation. + return bond; + } + } + uint256 startRound = checkedFindLowerBound(checkpoints.startRounds, _round); return checkpoints.data[startRound]; } diff --git a/contracts/test/mocks/BondingManagerMock.sol b/contracts/test/mocks/BondingManagerMock.sol index 47a20403..a24b37da 100644 --- a/contracts/test/mocks/BondingManagerMock.sol +++ b/contracts/test/mocks/BondingManagerMock.sol @@ -14,7 +14,19 @@ contract BondingManagerMock is GenericMock { uint256 cumulativeFeeFactor; } - mapping(address => mapping(uint256 => EarningsPoolMock)) private earningPools; + 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, @@ -35,7 +47,7 @@ contract BondingManagerMock is GenericMock { uint256 cumulativeFeeFactor ) { - EarningsPoolMock storage pool = earningPools[_transcoder][_round]; + EarningsPoolMock storage pool = earningPoolMocks[_transcoder][_round]; totalStake = pool.totalStake; transcoderRewardCut = pool.transcoderRewardCut; @@ -53,7 +65,7 @@ contract BondingManagerMock is GenericMock { uint256 _cumulativeRewardFactor, uint256 _cumulativeFeeFactor ) external { - earningPools[_transcoder][_round] = EarningsPoolMock({ + earningPoolMocks[_transcoder][_round] = EarningsPoolMock({ totalStake: _totalStake, transcoderRewardCut: _transcoderRewardCut, transcoderFeeShare: _transcoderFeeShare, @@ -61,4 +73,49 @@ contract BondingManagerMock is GenericMock { 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/src/test/BondingCheckpointsStateInitialization.sol b/src/test/BondingCheckpointsStateInitialization.sol index e3b3e570..2eb6eea7 100644 --- a/src/test/BondingCheckpointsStateInitialization.sol +++ b/src/test/BondingCheckpointsStateInitialization.sol @@ -13,6 +13,7 @@ import "./interfaces/IL2Migrator.sol"; // forge test --match-contract BondingCheckpointsStateInitialization --fork-url https://arbitrum-mainnet.infura.io/v3/$INFURA_KEY -vvv --fork-block-number 110930219 contract BondingCheckpointsStateInitialization is GovernorBaseTest { + address public constant CURRENT_BONDING_MANAGER_TARGET = 0x3a941e1094B9E33efABB26a9047a8ABb4b257907; BondingManager public constant BONDING_MANAGER = BondingManager(0x35Bcf3c30594191d53231E4FF333E8A770453e40); RoundsManager public constant ROUNDS_MANAGER = RoundsManager(0xdd6f56DcC28D3F5f27084381fE8Df634985cc39f); @@ -25,6 +26,7 @@ contract BondingCheckpointsStateInitialization is GovernorBaseTest { // Is a transcoder as of fork block address public constant TRANSCODER = 0x5D98F8d269C94B746A5c3C2946634dCfc75E5E60; // Initialized on test setup + address nonParticipant; address[] public _testAddresses; BondingManager public newBondingManagerTarget; @@ -32,7 +34,7 @@ contract BondingCheckpointsStateInitialization is GovernorBaseTest { IBondingCheckpoints public bondingCheckpoints; function setUp() public { - address nonParticipant = CHEATS.addr(1); + nonParticipant = CHEATS.addr(1); _testAddresses = [DELEGATOR, TRANSCODER, nonParticipant]; newBondingManagerTarget = new BondingManager(address(CONTROLLER)); @@ -100,15 +102,66 @@ contract BondingCheckpointsStateInitialization is GovernorBaseTest { } } - function testDisallowsQueryingEmptyState() public { + function testDisallowsQueryingParticipantUncheckpointedAccount() public { uint256 currentRound = ROUNDS_MANAGER.currentRound(); - for (uint256 i = 0; i < _testAddresses.length; i++) { + address[2] memory testAddresses = [DELEGATOR, TRANSCODER]; + for (uint256 i = 0; i < testAddresses.length; i++) { CHEATS.expectRevert(IBondingCheckpoints.NoRecordedCheckpoints.selector); - bondingCheckpoints.getBondingStateAt(_testAddresses[i], currentRound); + bondingCheckpoints.getBondingStateAt(testAddresses[i], currentRound); } } + function testAllowsQueryingNonParticipantZeroedAccount() public { + uint256 currentRound = ROUNDS_MANAGER.currentRound(); + + (uint256 checkedAmount, address checkedDelegate) = bondingCheckpoints.getBondingStateAt( + nonParticipant, + currentRound + ); + assertEq(checkedAmount, 0); + assertEq(checkedDelegate, address(0)); + } + + function testAllowsQueryingFullyUnbondedAccountOnNextRound() public { + // Revert to old bonding manager in this test so it doesn't make any checkpoints + stageAndExecuteOne( + address(CONTROLLER), + 0, + abi.encodeWithSelector( + CONTROLLER.setContractInfo.selector, + BONDING_MANAGER_TARGET_ID, + CURRENT_BONDING_MANAGER_TARGET, + gitCommitHash + ) + ); + + uint256 currentRound = ROUNDS_MANAGER.currentRound(); + uint256 pendingStake = BONDING_MANAGER.pendingStake(DELEGATOR, currentRound); + + CHEATS.prank(DELEGATOR); + BONDING_MANAGER.unbond(pendingStake); + + (uint256 bondedAmount, , , uint256 delegatedAmount, , uint256 lastClaimRound, ) = BONDING_MANAGER.getDelegator( + DELEGATOR + ); + assertTrue(lastClaimRound == currentRound && bondedAmount == 0 && delegatedAmount == 0); + + CHEATS.expectRevert(IBondingCheckpoints.NoRecordedCheckpoints.selector); + bondingCheckpoints.getBondingStateAt(DELEGATOR, currentRound); + + uint256 nextRoundStartBlock = ROUNDS_MANAGER.currentRoundStartBlock() + ROUNDS_MANAGER.roundLength(); + CHEATS.roll(nextRoundStartBlock); + assertEq(ROUNDS_MANAGER.currentRound(), currentRound + 1); + + (uint256 checkedAmount, address checkedDelegate) = bondingCheckpoints.getBondingStateAt( + DELEGATOR, + currentRound + 1 + ); + assertEq(checkedAmount, 0); + assertEq(checkedDelegate, address(0)); + } + function testInitializesCheckpointState() public { uint256 currentRound = ROUNDS_MANAGER.currentRound(); @@ -153,7 +206,7 @@ contract BondingCheckpointsStateInitialization is GovernorBaseTest { function testAllowsQueryingDelegatorStateOnNextRound() public { uint256 currentRound = ROUNDS_MANAGER.currentRound(); (, , address delegateAddress, , , , ) = BONDING_MANAGER.getDelegator(DELEGATOR); - uint256 pendingStake = BONDING_MANAGER.pendingStake(DELEGATOR, currentRound + 1); + uint256 pendingStake = BONDING_MANAGER.pendingStake(DELEGATOR, currentRound); BONDING_MANAGER.checkpointBondingState(DELEGATOR); // the delegate also needs to be checkpointed in case of delegators diff --git a/src/test/interfaces/ICheatCodes.sol b/src/test/interfaces/ICheatCodes.sol index 4225f70f..4d5cec20 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/unit/BondingCheckpoints.js b/test/unit/BondingCheckpoints.js index a2332a1e..8f3dbc08 100644 --- a/test/unit/BondingCheckpoints.js +++ b/test/unit/BondingCheckpoints.js @@ -4,6 +4,7 @@ import {assert} from "chai" import {ethers, web3} from "hardhat" import chai from "chai" import {solidity} from "ethereum-waffle" +import {constants} from "ethers" chai.use(solidity) const {expect} = chai @@ -459,6 +460,75 @@ describe("BondingCheckpoints", () => { ) }) + 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.AddressZero, + delegatedAmount ?? 0, + 0, + lastClaimRound, + 0 + ) + + const expectRevert = async queryRound => { + const tx = bondingCheckpoints.getBondingStateAt( + delegator.address, + queryRound + ) + await expect(tx).to.be.revertedWith("NoRecordedCheckpoints()") + } + + it("should fail if the account has a zero bond but updated on or after queried round", async () => { + await setBondMock({lastClaimRound: currentRound - 10}) + await expectRevert(currentRound - 10) + + await setBondMock({lastClaimRound: currentRound - 9}) + await expectRevert(currentRound - 10) + + await setBondMock({lastClaimRound: currentRound - 5}) + await expectRevert(currentRound - 10) + }) + + it("should fail if the account has a non-zero bond", async () => { + await setBondMock({ + bondedAmount: 1, + lastClaimRound: currentRound - 1 + }) + await expectRevert(currentRound) + + await setBondMock({ + delegatedAmount: 1, + lastClaimRound: currentRound - 1 + }) + await expectRevert(currentRound) + }) + + it("should succeed for never bonded (non-participant) accounts", async () => { + expect( + await bondingCheckpoints + .getBondingStateAt(delegator.address, currentRound) + .then(t => t.map(v => v.toString())) + ).to.deep.equal(["0", constants.AddressZero]) + }) + + it("should succeed for fully unbonded delegators before query round", async () => { + await setBondMock({lastClaimRound: currentRound - 1}) + expect( + await bondingCheckpoints + .getBondingStateAt(delegator.address, currentRound) + .then(t => t.map(v => v.toString())) + ).to.deep.equal(["0", constants.AddressZero]) + }) + }) + describe("for transcoder", () => { const makeCheckpoint = async (startRound, delegatedAmount) => { const functionData = encodeCheckpointBondingState({ From 06b7100b6edce76e40d824102aef58fec147e826 Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Mon, 17 Jul 2023 00:43:52 -0300 Subject: [PATCH 08/29] treasury: Create treasury governance contracts --- .../treasury/BondingCheckpointsVotes.sol | 106 +++++++++ .../treasury/GovernorCountingOverridable.sol | 222 ++++++++++++++++++ contracts/treasury/LivepeerGovernor.sol | 185 +++++++++++++++ 3 files changed, 513 insertions(+) create mode 100644 contracts/treasury/BondingCheckpointsVotes.sol create mode 100644 contracts/treasury/GovernorCountingOverridable.sol create mode 100644 contracts/treasury/LivepeerGovernor.sol diff --git a/contracts/treasury/BondingCheckpointsVotes.sol b/contracts/treasury/BondingCheckpointsVotes.sol new file mode 100644 index 00000000..435ce21f --- /dev/null +++ b/contracts/treasury/BondingCheckpointsVotes.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "../Manager.sol"; +import "../bonding/IBondingCheckpoints.sol"; +import "./GovernorCountingOverridable.sol"; + +/** + * @title Interface for BondingCheckpoints + */ +contract BondingCheckpointsVotes is Manager, IVotes { + // Indicates that the called function is not supported in this contract and should be performed through the + // BondingManager instead. This is mostly used for delegation methods, which must be bonds instead. + error MustCallBondingManager(); + + constructor(address _controller) Manager(_controller) {} + + /** + * @notice Clock is set to match the current round, which is the checkpointing + * method implemented here. + */ + function clock() public view returns (uint48) { + return bondingCheckpoints().clock(); + } + + /** + * @notice Machine-readable description of the clock as specified in EIP-6372. + */ + // solhint-disable-next-line func-name-mixedcase + function CLOCK_MODE() public view returns (string memory) { + return bondingCheckpoints().CLOCK_MODE(); + } + + /** + * @notice Returns the current amount of votes that `account` has. + */ + function getVotes(address _account) external view returns (uint256) { + return getPastVotes(_account, clock()); + } + + /** + * @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. + */ + function getPastVotes(address _account, uint256 _round) public view returns (uint256) { + (uint256 amount, ) = bondingCheckpoints().getBondingStateAt(_account, _round); + return amount; + } + + /** + * @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. + */ + function getPastTotalSupply(uint256 _round) external view returns (uint256) { + return bondingCheckpoints().getTotalActiveStakeAt(_round); + } + + /** + * @notice Returns the delegate that _account has chosen. This means the delegated transcoder address in case of + * delegators, and the account own address for transcoders (self-delegated). + */ + function delegates(address _account) external view returns (address) { + return delegatedAt(_account, clock()); + } + + /** + * @notice Returns the delegate that _account had chosen in a specific round in the past. See `delegates()` above + * for more details. + * @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 {GovernorVotesBondingCheckpoints-_handleVoteOverrides}. + */ + function delegatedAt(address _account, uint256 _round) public view returns (address) { + (, address delegateAddress) = bondingCheckpoints().getBondingStateAt(_account, _round); + return delegateAddress; + } + + /** + * @notice Delegation through BondingCheckpoints is not supported. + */ + function delegate(address) external pure { + revert MustCallBondingManager(); + } + + /** + * @notice Delegation through BondingCheckpoints is not supported. + */ + function delegateBySig( + address, + uint256, + uint256, + uint8, + bytes32, + bytes32 + ) external pure { + revert MustCallBondingManager(); + } + + /** + * @dev Returns the BondingCheckpoints contract. + */ + function bondingCheckpoints() internal view returns (IBondingCheckpoints) { + return IBondingCheckpoints(controller.getContract(keccak256("BondingCheckpoints"))); + } +} diff --git a/contracts/treasury/GovernorCountingOverridable.sol b/contracts/treasury/GovernorCountingOverridable.sol new file mode 100644 index 00000000..79cb0896 --- /dev/null +++ b/contracts/treasury/GovernorCountingOverridable.sol @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/extensions/GovernorVotesQuorumFractionUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/interfaces/IERC5805Upgradeable.sol"; + +import "../bonding/libraries/EarningsPool.sol"; +import "../bonding/libraries/EarningsPoolLIP36.sol"; + +import "../Manager.sol"; +import "../IController.sol"; +import "../rounds/IRoundsManager.sol"; +import "../bonding/IBondingCheckpoints.sol"; + +interface IVotes is IERC5805Upgradeable { + function delegatedAt(address account, uint256 timepoint) external returns (address); +} + +/** + * @title GovernorCountingOverridable + * @notice Implements the Counting module from OpenZeppelin Governor with support for delegators overriding their + * delegated transcoder's vote. This module is used through inheritance by the Governor contract. + */ +abstract contract GovernorCountingOverridable is Initializable, GovernorUpgradeable { + using SafeMath for uint256; + + error InvalidVoteType(uint8 voteType); + error VoteAlreadyCast(); + + function __GovernorCountingOverridable_init() internal onlyInitializing { + __GovernorCountingOverridable_init_unchained(); + } + + function __GovernorCountingOverridable_init_unchained() internal onlyInitializing {} + + /** + * @dev Supported vote types. Matches Governor Bravo ordering. + */ + enum VoteType { + Against, + For, + Abstain + } + + /** + * @dev Tracks state of specicic voters in a single proposal. + */ + struct ProposalVoterState { + bool hasVoted; + VoteType support; + // This vote deductions state is only necessary to support the case where a delegator might vote before their + // transcoder. When that happens, we need to deduct the delegator(s) votes before tallying the transcoder vote. + uint256 deductions; + } + + /** + * @dev Tracks the tallying state for a proposal vote counting logic. + */ + struct ProposalTally { + uint256 againstVotes; + uint256 forVotes; + uint256 abstainVotes; + mapping(address => ProposalVoterState) voters; + } + + mapping(uint256 => ProposalTally) private _proposalTallies; + + /** + * @dev See {IGovernor-COUNTING_MODE}. + */ + // solhint-disable-next-line func-name-mixedcase + function COUNTING_MODE() public pure virtual override returns (string memory) { + return "support=bravo&quorum=for,abstain,against"; + } + + /** + * @dev See {IGovernor-hasVoted}. + */ + function hasVoted(uint256 _proposalId, address _account) public view virtual override returns (bool) { + return _proposalTallies[_proposalId].voters[_account].hasVoted; + } + + /** + * @dev Accessor to the internal vote counts. + */ + function proposalVotes(uint256 _proposalId) + public + view + virtual + returns ( + uint256 againstVotes, + uint256 forVotes, + uint256 abstainVotes + ) + { + ProposalTally storage tally = _proposalTallies[_proposalId]; + return (tally.againstVotes, tally.forVotes, tally.abstainVotes); + } + + /** + * @dev See {Governor-_quorumReached}. + */ + function _quorumReached(uint256 _proposalId) internal view virtual override returns (bool) { + (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes) = proposalVotes(_proposalId); + + uint256 totalVotes = againstVotes.add(forVotes).add(abstainVotes); + + return totalVotes >= quorum(proposalSnapshot(_proposalId)); + } + + /** + * @dev See {Governor-_voteSucceeded}. In this module, the forVotes must be at least QUOTA of the total votes. + */ + function _voteSucceeded(uint256 _proposalId) internal view virtual override returns (bool) { + (uint256 againstVotes, uint256 forVotes, ) = proposalVotes(_proposalId); + + // we ignore abstain votes for vote succeeded calculation + uint256 totalValidVotes = againstVotes.add(forVotes); + + return forVotes >= MathUtils.percOf(totalValidVotes, quota()); + } + + /** + * @dev See {Governor-_countVote}. In this module, the support follows the `VoteType` enum (from Governor Bravo). + */ + function _countVote( + uint256 _proposalId, + address _account, + uint8 _supportInt, + uint256 _weight, + bytes memory // params + ) internal virtual override { + if (_supportInt > uint8(VoteType.Abstain)) { + revert InvalidVoteType(_supportInt); + } + VoteType support = VoteType(_supportInt); + + ProposalTally storage tally = _proposalTallies[_proposalId]; + ProposalVoterState storage voter = tally.voters[_account]; + + if (voter.hasVoted) { + revert VoteAlreadyCast(); + } + voter.hasVoted = true; + voter.support = support; + + _weight = _handleVoteOverrides(_proposalId, tally, voter, _account, _weight); + + if (support == VoteType.Against) { + tally.againstVotes += _weight; + } else if (support == VoteType.For) { + tally.forVotes += _weight; + } else { + assert(support == VoteType.Abstain); + tally.abstainVotes += _weight; + } + } + + /** + * @notice Handles vote overrides that delegators can make to their + * corresponding delegated transcoder votes. Usually only the transcoders + * vote on proposals, but any delegator can change their part of the vote. + * This tracks past votes and deduction on separate mappings in order to + * calculate the effective voting power of each vote. + * @param _proposalId ID of the proposal being voted on + * @param _tally struct where the vote totals are tallied on + * @param _voter struct where the specific voter state is tracked + * @param _account current user making a vote + * @param _weight voting weight of the user making the vote + */ + function _handleVoteOverrides( + uint256 _proposalId, + ProposalTally storage _tally, + ProposalVoterState storage _voter, + address _account, + uint256 _weight + ) internal returns (uint256) { + uint256 timepoint = proposalSnapshot(_proposalId); + address delegate = votes().delegatedAt(_account, timepoint); + + bool isTranscoder = _account == delegate; + if (isTranscoder) { + // deduce weight from any previous delegators for this transcoder to + // make a vote + return _weight - _voter.deductions; + } + + // this is a delegator, so add a deduction to the delegated transcoder + ProposalVoterState storage delegateVoter = _tally.voters[delegate]; + delegateVoter.deductions += _weight; + + if (delegateVoter.hasVoted) { + // this is a delegator overriding its delegated transcoder vote, + // we need to update the current totals to move the weight of + // the delegator vote to the right outcome. + VoteType delegateSupport = delegateVoter.support; + + if (delegateSupport == VoteType.Against) { + _tally.againstVotes -= _weight; + } else if (delegateSupport == VoteType.For) { + _tally.forVotes -= _weight; + } else { + assert(delegateSupport == VoteType.Abstain); + _tally.abstainVotes -= _weight; + } + } + + return _weight; + } + + /** + * @dev Implement in inheriting contract to provide the voting power provider. + */ + function votes() public view virtual returns (IVotes); + + /** + * @dev Implement in inheriting contract to provide quota value to use to decide proposal success. + * @return quota value as a MathUtils percentage value (e.g. 6 decimal places). + */ + function quota() public view virtual returns (uint256); +} diff --git a/contracts/treasury/LivepeerGovernor.sol b/contracts/treasury/LivepeerGovernor.sol new file mode 100644 index 00000000..101ed5b1 --- /dev/null +++ b/contracts/treasury/LivepeerGovernor.sol @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/GovernorUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/extensions/GovernorVotesUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/extensions/GovernorVotesQuorumFractionUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/extensions/GovernorSettingsUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/extensions/GovernorTimelockControlUpgradeable.sol"; + +import "../bonding/libraries/EarningsPool.sol"; +import "../bonding/libraries/EarningsPoolLIP36.sol"; +import "../polling/PollCreator.sol"; + +import "../ManagerProxyTarget.sol"; +import "../IController.sol"; +import "../rounds/IRoundsManager.sol"; +import "./GovernorCountingOverridable.sol"; +import "./BondingCheckpointsVotes.sol"; + +/** + * @title LivepeerGovernor + * @notice Core contract for Livepeer governance, starting as the treasury governor. + * @dev If we ever add fields to this class or more extensions, make sure to add a storage gap to our custom + * GovernorCountingOverridable extension. + */ +contract LivepeerGovernor is + Initializable, + ManagerProxyTarget, + GovernorUpgradeable, + GovernorSettingsUpgradeable, + GovernorTimelockControlUpgradeable, + GovernorVotesUpgradeable, + GovernorVotesQuorumFractionUpgradeable, + GovernorCountingOverridable +{ + /** + * @notice TreasuryGovernor constructor. Only invokes constructor of base Manager contract with provided Controller. + * @dev This constructor will not initialize any state variables besides `controller`. The `initialize` function + * must be called through the proxy after construction to initialize the contract's state in the proxy contract. + * @param _controller Address of Controller that this contract will be registered with + */ + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(address _controller) Manager(_controller) { + _disableInitializers(); + } + + /** + * Initializes the LivepeerGovernor instance. This requires the following contracts to have already been deployed + * and registered on the controller: + * - "Treasury" (TimelockControllerUpgradeable) + * - "BondingCheckpointsVotes" + * - "PollCreator" + */ + function initialize() public initializer { + __Governor_init("LivepeerGovernor"); + __GovernorSettings_init( + 1, /* 1 round/day voting delay */ + 10, /* 10 rounds/days voting period */ + 100e18 /* 100 LPT min proposal threshold */ + ); + __GovernorTimelockControl_init(treasury()); + + // The GovernorVotes module will hold a fixed reference to the votes contract. If we ever change its address we + // need to call the {bumpVotesAddress} function to update it in here as well. + __GovernorVotes_init(votes()); + + // Initialize with the same value from the existing polling system. + uint256 initialQuorum = pollCreator().QUORUM(); + __GovernorVotesQuorumFraction_init(initialQuorum); + + __GovernorCountingOverridable_init(); + } + + /** + * @dev Overrides the quorum denominator from the GovernorVotesQuorumFractionUpgradeable extension. We use + * MathUtils.PERC_DIVISOR so that our quorum numerator is a valid MathUtils fraction. + */ + function quorumDenominator() public view virtual override returns (uint256) { + return MathUtils.PERC_DIVISOR; + } + + /** + * @dev See {GovernorCountingOverridable-votes}. + */ + function votes() public view override returns (IVotes) { + return bondingCheckpointVotes(); + } + + /** + * @dev See {GovernorCountingOverridable-quota}. We use the same QUOTA value from the protocol governance system for + * now, but can consider changing this in the future (e.g. to make it updateable through proposals without deploys). + */ + function quota() public view override returns (uint256) { + return pollCreator().QUOTA(); + } + + /** + * @dev This should be called if we ever change the address of the BondingCheckpointsVotes contract. It is a simple + * non upgradeable proxy to the BondingCheckpoints not to require any upgrades, but its address could still + * eventually change in the controller so we provide this function as a future-proof commodity. This function is + * callable by anyone because always fetch the current address from the controller, so it's not exploitable. + */ + function bumpVotesAddress() external { + token = votes(); + } + + /** + * @dev Returns the BondingCheckpointsVotes contract address from the controller. + */ + function bondingCheckpointVotes() internal view returns (BondingCheckpointsVotes) { + return BondingCheckpointsVotes(controller.getContract(keccak256("BondingCheckpointsVotes"))); + } + + /** + * @dev Returns the PollCreator contract address from the controller. + */ + function pollCreator() internal view returns (PollCreator) { + return PollCreator(controller.getContract(keccak256("PollCreator"))); + } + + /** + * @dev Returns the Treasury contract address from the controller. + */ + function treasury() internal view returns (TimelockControllerUpgradeable) { + return TimelockControllerUpgradeable(payable(controller.getContract(keccak256("Treasury")))); + } + + // The following functions are overrides required by Solidity. + + function proposalThreshold() + public + view + override(GovernorUpgradeable, GovernorSettingsUpgradeable) + returns (uint256) + { + return super.proposalThreshold(); + } + + function state(uint256 proposalId) + public + view + override(GovernorUpgradeable, GovernorTimelockControlUpgradeable) + returns (ProposalState) + { + return super.state(proposalId); + } + + function _execute( + uint256 proposalId, + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) internal override(GovernorUpgradeable, GovernorTimelockControlUpgradeable) { + super._execute(proposalId, targets, values, calldatas, descriptionHash); + } + + function _cancel( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) internal override(GovernorUpgradeable, GovernorTimelockControlUpgradeable) returns (uint256) { + return super._cancel(targets, values, calldatas, descriptionHash); + } + + function _executor() + internal + view + override(GovernorUpgradeable, GovernorTimelockControlUpgradeable) + returns (address) + { + return super._executor(); + } + + function supportsInterface(bytes4 interfaceId) + public + view + override(GovernorUpgradeable, GovernorTimelockControlUpgradeable) + returns (bool) + { + return super.supportsInterface(interfaceId); + } +} From 0aa6cd000665de9fc1e3ab2dd75bdaf665157583 Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Mon, 17 Jul 2023 16:48:35 -0300 Subject: [PATCH 09/29] test/treasury: Add unit test fro BondingCheckpointsVotes --- .../test/mocks/BondingCheckpointsMock.sol | 24 +++ test/unit/BondingCheckpointsVotes.js | 198 ++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 test/unit/BondingCheckpointsVotes.js diff --git a/contracts/test/mocks/BondingCheckpointsMock.sol b/contracts/test/mocks/BondingCheckpointsMock.sol index c95ce040..52a2dd26 100644 --- a/contracts/test/mocks/BondingCheckpointsMock.sol +++ b/contracts/test/mocks/BondingCheckpointsMock.sol @@ -38,4 +38,28 @@ contract BondingCheckpointsMock is GenericMock { function checkpointTotalActiveStake(uint256 _totalStake, uint256 _round) external { emit CheckpointTotalActiveStake(_totalStake, _round); } + + function CLOCK_MODE() external pure returns (string memory) { + return "mode=cuckoo&species=dasylophus_superciliosus"; + } + + /** + * @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) + external + pure + returns (uint256 amount, address delegateAddress) + { + uint160 intAddr = uint160(_account); + + amount = (intAddr & 0xffffffff) + _round; + delegateAddress = address((intAddr << 4) | uint160(_round)); + } + + function getTotalActiveStakeAt(uint256 _round) external pure returns (uint256) { + return 4 * _round; + } } diff --git a/test/unit/BondingCheckpointsVotes.js b/test/unit/BondingCheckpointsVotes.js new file mode 100644 index 00000000..2278edc3 --- /dev/null +++ b/test/unit/BondingCheckpointsVotes.js @@ -0,0 +1,198 @@ +import Fixture from "./helpers/Fixture" +import {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" + +chai.use(solidity) +const {expect} = chai + +describe("BondingCheckpointsVotes", () => { + let signers + let fixture + + let bondingCheckpointsVotes + + before(async () => { + signers = await ethers.getSigners() + + fixture = new Fixture(web3) + await fixture.deploy() + + const bondingCheckpointsVotesFac = await ethers.getContractFactory( + "BondingCheckpointsVotes" + ) + + bondingCheckpointsVotes = await fixture.deployAndRegister( + bondingCheckpointsVotesFac, + "BondingCheckpointsVotes", + fixture.controller.address + ) + }) + + beforeEach(async () => { + await fixture.setUp() + }) + + afterEach(async () => { + await fixture.tearDown() + }) + + describe("IERC6372Upgradeable", () => { + describe("clock", () => { + it("should proxy to BondingCheckpoints", async () => { + await fixture.bondingCheckpoints.setMockUint256( + functionSig("clock()"), + 12348 + ) + assert.equal(await bondingCheckpointsVotes.clock(), 12348) + }) + }) + + describe("CLOCK_MODE", () => { + it("should proxy to BondingCheckpoints", async () => { + assert.equal( + await bondingCheckpointsVotes.CLOCK_MODE(), + // BondingCheckpointsMock returns this + "mode=cuckoo&species=dasylophus_superciliosus" + ) + }) + }) + }) + + // Same implementation as the BondingCheckpointsMock + const mockGetBondingStateAt = (_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()) + ] + } + + describe("get(Past)?Votes", () => { + it("getPastVotes should proxy to BondingCheckpoints.getBondingStateAt", async () => { + const testOnce = async (account, round) => { + const [expected] = mockGetBondingStateAt(account.address, round) + + const votes = await bondingCheckpointsVotes.getPastVotes( + account.address, + round + ) + assert.equal(votes.toNumber(), expected) + } + + await testOnce(signers[0], 123) + await testOnce(signers[1], 256) + await testOnce(signers[2], 34784) + }) + + it("getVotes should query with the current round", async () => { + const testOnce = async (account, round) => { + await fixture.bondingCheckpoints.setMockUint256( + functionSig("clock()"), + round + ) + const [expected] = mockGetBondingStateAt(account.address, round) + + const votes = await bondingCheckpointsVotes.getVotes( + account.address + ) + assert.equal(votes.toNumber(), expected) + } + + await testOnce(signers[3], 321) + await testOnce(signers[4], 652) + await testOnce(signers[5], 48743) + }) + }) + + describe("delegate(s|dAt)", () => { + it("delegatedAt should proxy to BondingCheckpoints.getBondingStateAt", async () => { + const testOnce = async (account, round) => { + const [, expected] = mockGetBondingStateAt( + account.address, + round + ) + + const delegate = await bondingCheckpointsVotes.delegatedAt( + account.address, + round + ) + assert.equal(delegate, expected) + } + + await testOnce(signers[6], 123) + await testOnce(signers[7], 256) + await testOnce(signers[8], 34784) + }) + + it("delegates should query with the current round", async () => { + const testOnce = async (account, round) => { + await fixture.bondingCheckpoints.setMockUint256( + functionSig("clock()"), + round + ) + const [, expected] = mockGetBondingStateAt( + account.address, + round + ) + + assert.equal( + await bondingCheckpointsVotes.delegates(account.address), + expected + ) + } + + await testOnce(signers[9], 321) + await testOnce(signers[10], 652) + await testOnce(signers[11], 48743) + }) + }) + + describe("getPastTotalSupply", () => { + it("should proxy to BondingCheckpoints.getTotalActiveStakeAt", async () => { + const testOnce = async round => { + const expected = 4 * round // same as BondingCheckpointsMock impl + + const totalSupply = + await bondingCheckpointsVotes.getPastTotalSupply(round) + assert.equal(totalSupply.toNumber(), expected) + } + + await testOnce(213) + await testOnce(526) + await testOnce(784347) + }) + }) + + describe("delegation", () => { + it("should fail to call delegate", async () => { + await expect( + bondingCheckpointsVotes + .connect(signers[0]) + .delegate(signers[1].address) + ).to.be.revertedWith("MustCallBondingManager()") + }) + + it("should fail to call delegateBySig", async () => { + await expect( + bondingCheckpointsVotes.delegateBySig( + signers[1].address, + 420, + 1689794400, + 171, + ethers.utils.hexZeroPad("0xfacade", 32), + ethers.utils.hexZeroPad("0xdeadbeef", 32) + ) + ).to.be.revertedWith("MustCallBondingManager()") + }) + }) +}) From 9f007ba299a1901c02564e1a6b9ee45a933d6bd2 Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Tue, 18 Jul 2023 14:29:14 -0300 Subject: [PATCH 10/29] test/treasury: Test GovernorCountingOverridable --- .../GovernorCountingOverridableTestable.sol | 78 +++ contracts/test/mocks/VotesMock.sol | 112 +++++ test/helpers/math.js | 6 +- test/unit/GovernorCountingOverridable.js | 463 ++++++++++++++++++ test/unit/helpers/expectCheckpoints.ts | 3 +- 5 files changed, 657 insertions(+), 5 deletions(-) create mode 100644 contracts/test/mocks/GovernorCountingOverridableTestable.sol create mode 100644 contracts/test/mocks/VotesMock.sol create mode 100644 test/unit/GovernorCountingOverridable.js diff --git a/contracts/test/mocks/GovernorCountingOverridableTestable.sol b/contracts/test/mocks/GovernorCountingOverridableTestable.sol new file mode 100644 index 00000000..6de1d3fe --- /dev/null +++ b/contracts/test/mocks/GovernorCountingOverridableTestable.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/GovernorUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/extensions/GovernorVotesUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/extensions/GovernorVotesQuorumFractionUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/extensions/GovernorSettingsUpgradeable.sol"; + +import "../../treasury/GovernorCountingOverridable.sol"; + +/** + * @dev This is a concrete contract to test the GovernorCountingOverridable extension. It implements the minimum + * necessary to get a working Governor to test the extension. + */ +contract GovernorCountingOverridableTestable is + Initializable, + GovernorUpgradeable, + GovernorSettingsUpgradeable, + GovernorVotesUpgradeable, + GovernorCountingOverridable +{ + // use non-standard values for these to test if it's really used + uint256 constant QUOTA = 420000; // 42% + uint256 constant QUORUM = 370000; // 37% + + IVotes internal iVotes; // 🍎 + + function initialize(IVotes _votes) public initializer { + iVotes = _votes; + + __Governor_init("GovernorCountingOverridableConcrete"); + __GovernorSettings_init( + 0, /* no voting delay */ + 100, /* 100 blocks voting period */ + 0 /* no minimum proposal threshold */ + ); + + __GovernorVotes_init(iVotes); + __GovernorCountingOverridable_init(); + } + + function votes() public view override returns (IVotes) { + return iVotes; + } + + function quota() public pure override returns (uint256) { + return QUOTA; + } + + function quorum(uint256 timepoint) public view virtual override returns (uint256) { + uint256 totalSupply = iVotes.getPastTotalSupply(timepoint); + return MathUtils.percOf(totalSupply, QUORUM); + } + + /** + * @dev Expose internal _quorumReached function for testing. + */ + function quorumReached(uint256 proposalId) public view returns (bool) { + return super._quorumReached(proposalId); + } + + /** + * @dev Expose internal _voteSucceeded function for testing. + */ + function voteSucceeded(uint256 proposalId) public view returns (bool) { + return super._voteSucceeded(proposalId); + } + + function proposalThreshold() + public + view + override(GovernorUpgradeable, GovernorSettingsUpgradeable) + returns (uint256) + { + return super.proposalThreshold(); + } +} diff --git a/contracts/test/mocks/VotesMock.sol b/contracts/test/mocks/VotesMock.sol new file mode 100644 index 00000000..3f528fd6 --- /dev/null +++ b/contracts/test/mocks/VotesMock.sol @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20SnapshotUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20VotesUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; + +import { IVotes } from "../../treasury/GovernorCountingOverridable.sol"; +import "../../bonding/libraries/SortedArrays.sol"; + +/** + * @dev Minimum implementation of an IVotes interface to test the GovernorCountingOverridable extension. It inherits + * from the default ERC20VotesUpgradeable implementation but overrides the voting power functions to provide power to + * delegators as well (to be made overridable by the GovernorCountingOverridable extension). + */ +contract VotesMock is + Initializable, + ERC20Upgradeable, + ERC20BurnableUpgradeable, + OwnableUpgradeable, + ERC20VotesUpgradeable, + IVotes +{ + mapping(address => uint256[]) private _delegateChangingTimes; + mapping(address => mapping(uint256 => address)) private _delegatedAtTime; + + function initialize() public initializer { + __ERC20_init("VotesMock", "VTCK"); + __ERC20Burnable_init(); + __Ownable_init(); + __ERC20Votes_init(); + } + + function delegatedAt(address _account, uint256 _timepoint) external view returns (address) { + uint256[] storage rounds = _delegateChangingTimes[_account]; + if (rounds.length == 0 || _timepoint < rounds[0]) { + return address(0); + } + + uint256 prevRound = SortedArrays.findLowerBound(rounds, _timepoint); + return _delegatedAtTime[_account][prevRound]; + } + + function _delegate(address _delegator, address _to) internal override { + super._delegate(_delegator, _to); + + uint256 currTime = clock(); + + uint256[] storage rounds = _delegateChangingTimes[_delegator]; + SortedArrays.pushSorted(rounds, currTime); + _delegatedAtTime[_delegator][currTime] = _to; + } + + /** + * @dev Simulates the behavior of our actual voting power, where the delegator also has voting power which can + * override their transcoder's vote. This is not the case in the OpenZeppelin implementation. + */ + function getPastVotes(address account, uint256 blockNumber) + public + view + override(IVotesUpgradeable, ERC20VotesUpgradeable) + returns (uint256) + { + // Blatant simplification that only works in our tests where we never change participants balance during + // proposal voting period. We check and return delegators current state instead of tracking historical values. + if (delegates(account) != account) { + return balanceOf(account); + } + return super.getPastVotes(account, blockNumber); + } + + /** + * @dev Same as above. Still don't understand why the OZ implementation for these 2 is incompatible, with getPast* + * reverting if you query it with the current round. + */ + function getVotes(address account) + public + view + override(IVotesUpgradeable, ERC20VotesUpgradeable) + returns (uint256) + { + if (delegates(account) != account) { + return balanceOf(account); + } + return super.getVotes(account); + } + + function mint(address to, uint256 amount) public onlyOwner { + _mint(to, amount); + } + + // The following functions are overrides required by Solidity. + + function _afterTokenTransfer( + address from, + address to, + uint256 amount + ) internal override(ERC20Upgradeable, ERC20VotesUpgradeable) { + super._afterTokenTransfer(from, to, amount); + } + + function _mint(address to, uint256 amount) internal override(ERC20Upgradeable, ERC20VotesUpgradeable) { + super._mint(to, amount); + } + + function _burn(address account, uint256 amount) internal override(ERC20Upgradeable, ERC20VotesUpgradeable) { + super._burn(account, amount); + } +} diff --git a/test/helpers/math.js b/test/helpers/math.js index 8e67f337..39886a3f 100644 --- a/test/helpers/math.js +++ b/test/helpers/math.js @@ -8,7 +8,7 @@ const percPoints = (a, b) => { // Returns a * (b / c) scaled by PERC_DIVISOR // See percOf() in contracts/libraries/MathUtils.sol -const percOf = (a, b, c) => { +const percOf = (a, b, c = BigNumber.from(constants.PERC_DIVISOR)) => { return _percOf(a, b, c, BigNumber.from(constants.PERC_DIVISOR)) } @@ -16,7 +16,7 @@ const precise = { percPoints: (a, b) => { return _percPoints(a, b, constants.PERC_DIVISOR_PRECISE) }, - percOf: (a, b, c) => { + percOf: (a, b, c = constants.PERC_DIVISOR_PRECISE) => { return _percOf(a, b, c, constants.PERC_DIVISOR_PRECISE) } } @@ -25,7 +25,7 @@ const v2 = { percPoints: (a, b) => { return _percPoints(a, b, constants.PERC_DIVISOR_V2) }, - percOf: (a, b, c) => { + percOf: (a, b, c = constants.PERC_DIVISOR_V2) => { return _percOf(a, b, c, constants.PERC_DIVISOR_V2) } } diff --git a/test/unit/GovernorCountingOverridable.js b/test/unit/GovernorCountingOverridable.js new file mode 100644 index 00000000..a3e070c8 --- /dev/null +++ b/test/unit/GovernorCountingOverridable.js @@ -0,0 +1,463 @@ +import {assert} from "chai" +import {ethers, web3} from "hardhat" +const BigNumber = ethers.BigNumber +import chai from "chai" +import {solidity} from "ethereum-waffle" + +import Fixture from "./helpers/Fixture" +import math from "../helpers/math" + +chai.use(solidity) +const {expect} = chai + +const VoteType = { + Against: 0, + For: 1, + Abstain: 2 +} + +const ProposalState = { + Pending: 0, + Active: 1, + Canceled: 2, + Defeated: 3, + Succeeded: 4, + Queued: 5, + Expired: 6, + Executed: 7 +} + +describe("GovernorCountingOverridable", () => { + let signers + let fixture + + let votes + let governor + + let proposer + let proposalId + let voters + + const initVoter = async ({ + signer, + amount = ethers.utils.parseEther("1"), + delegateAddress = signer.address + }) => { + await votes.mint(signer.address, amount) + await votes.connect(signer).delegate(delegateAddress) + return signer + } + + const createProposal = async (proposer, description) => { + const tx = await governor + .connect(proposer) + .propose([proposer.address], [100], ["0x"], description) + + const filter = governor.filters.ProposalCreated() + const events = await governor.queryFilter( + filter, + tx.blockNumber, + tx.blockNumber + ) + const proposalId = events[0].args[0] + return proposalId + } + + before(async () => { + signers = await ethers.getSigners() + proposer = signers[0] + voters = signers.slice(1, 11) + + fixture = new Fixture(web3) + await fixture.deploy() + + // setup votes token + + const votesFac = await ethers.getContractFactory("VotesMock") + votes = await votesFac.deploy() + await votes.initialize() + + await initVoter({signer: proposer}) + for (const i = 1; i <= voters.length; i++) { + await initVoter({ + signer: voters[i - 1], + amount: ethers.utils.parseEther("1").mul(i) + }) + } + + // setup governor + + const governorFac = await ethers.getContractFactory( + "GovernorCountingOverridableTestable" + ) + governor = await governorFac.deploy() + await governor.initialize(votes.address) + + await signers[99].sendTransaction({ + to: governor.address, + value: ethers.utils.parseEther("100") + }) + + proposalId = await createProposal(proposer, "Steal all the money") + + // skip a block so voting can start + await fixture.rpc.wait() + }) + + beforeEach(async () => { + await fixture.setUp() + }) + + afterEach(async () => { + await fixture.tearDown() + }) + + describe("test fixture", () => { + const QUOTA = BigNumber.from(420000) // 42% + const QUORUM = BigNumber.from(370000) // 37% + const TOTAL_SUPPLY = ethers.utils.parseEther("56") // 1 (proposer) + (1 + 2 ... 9 + 10) (voters) + + let proposalSnapshot + + beforeEach(async () => { + proposalSnapshot = await governor.proposalSnapshot(proposalId) + }) + + it("quota should be 42%", async () => { + const quota = await governor.quota() + assert.equal(quota.toString(), QUOTA.toString()) + }) + + it("total supply should be 56 VTCK", async () => { + const totalSupply = await votes.getPastTotalSupply(proposalSnapshot) + assert.equal(totalSupply.toString(), TOTAL_SUPPLY.toString()) + }) + + it("quorum should be 37% of total supply", async () => { + const expectedQuorum = math.percOf(TOTAL_SUPPLY, QUORUM) + + const quorum = await governor.quorum(proposalSnapshot) + assert.equal(quorum.toString(), expectedQuorum.toString()) + }) + + it("it should use the block number as clock", async () => { + assert.equal( + await governor.clock(), + await ethers.provider.getBlockNumber() + ) + }) + }) + + describe("COUNTING_MODE", () => { + it("should include bravo support and all vote types on quorum count", async () => { + assert.equal( + await governor.COUNTING_MODE(), + "support=bravo&quorum=for,abstain,against" + ) + }) + }) + + describe("hasVoted", () => { + it("should return false for users that haven't voted", async () => { + for (let i = 0; i < 10; i++) { + assert.isFalse( + await governor.hasVoted(proposalId, signers[i].address) + ) + } + }) + + it("should return true after voting", async () => { + await governor.connect(voters[0]).castVote(proposalId, VoteType.For) + + assert.isTrue( + await governor.hasVoted(proposalId, voters[0].address) + ) + }) + }) + + describe("proposalVotes", () => { + it("should return the sum of all votes made of each type", async () => { + // against, for abstain, as per bravo ordering + const tally = [0, 0, 0] + + const checkTally = async () => { + const ether = ethers.utils.parseEther("1") + const expected = tally.map(c => ether.mul(c).toString()) + + const votes = await governor + .proposalVotes(proposalId) + .then(v => v.map(v => v.toString())) + + assert.deepEqual(votes, expected) + } + + for (let i = 1; i <= 10; i++) { + await checkTally() + + // Each voter has a voting power of {i} VTCK + const voteType = + i % 2 ? + VoteType.Against : // 25 Against (1 + 3 + 5 + 7 + 9) + i % 3 ? + VoteType.For : // 24 For (2 + 4 + 8 + 10) + VoteType.Abstain // 6 abstain (6) + + await governor + .connect(voters[i - 1]) + .castVote(proposalId, voteType) + tally[voteType] += i + } + + // sanity check + assert.deepEqual(tally, [25, 24, 6]) + await checkTally() + + await fixture.rpc.wait(100) + + assert.equal( + await governor.state(proposalId), + ProposalState.Succeeded // funds were stolen! + ) + }) + }) + + describe("_quorumReached", () => { + it("should return false if less than the quorum has voted", async () => { + // results in a 35.7% participation, just below the quorum of 37% + const voterIdxs = [1, 2, 3, 4, 10] + + assert.isFalse(await governor.quorumReached(proposalId)) + for (const i of voterIdxs) { + await governor + .connect(voters[i - 1]) + .castVote(proposalId, i % 3) // should count all vote types + + assert.isFalse(await governor.quorumReached(proposalId)) + } + }) + + it("should return true after quorum has voted", async () => { + // results in a 37.5% participation, above quorum of 37% + const voterIdxs = [1, 2, 3, 4, 5, 6] + + for (const i of voterIdxs) { + await governor + .connect(voters[i - 1]) + .castVote(proposalId, i % 3) // should count all vote types + } + assert.isTrue(await governor.quorumReached(proposalId)) + }) + }) + + describe("_voteSucceeded", () => { + it("should return false when less than the quota voted For", async () => { + // results in a 41.8% For votes, just below the quota of 42% + const forVotersIdxs = [1, 2, 3, 4, 5, 8] + + // starts as true as 0>=0, governor never uses it without checking quorum + assert.isTrue(await governor.voteSucceeded(proposalId)) + + const [forVotes, totalVotes] = [0, 0] + for (let i = 1; i <= 10; i++) { + const voteType = forVotersIdxs.includes(i) ? + VoteType.For : + VoteType.Against + + await governor + .connect(voters[i - 1]) + .castVote(proposalId, voteType) + + totalVotes += i + if (voteType === VoteType.For) { + forVotes += i + } + + assert.equal( + await governor.voteSucceeded(proposalId), + forVotes > Math.floor(0.42 * totalVotes) + ) + } + + // double check the expected end result + assert.isFalse(await governor.voteSucceeded(proposalId)) + + await fixture.rpc.wait(100) + + assert.equal( + await governor.state(proposalId), // calls _voteSucceeded internally + ProposalState.Defeated // not enough for votes + ) + }) + + it("should return true if For votes are higher than quota", async () => { + // results in 43.6% For votes, above the quota of 42% + const forVotersIdxs = [1, 2, 3, 4, 5, 9] + + for (let i = 1; i <= 10; i++) { + const voteType = forVotersIdxs.includes(i) ? + VoteType.For : + VoteType.Against + + await governor + .connect(voters[i - 1]) + .castVote(proposalId, voteType) + } + + assert.isTrue(await governor.voteSucceeded(proposalId)) + + await fixture.rpc.wait(100) + + assert.equal( + await governor.state(proposalId), // calls _voteSucceeded internally + ProposalState.Succeeded // money stolen :( + ) + }) + + it("should ignore abstain votes", async () => { + const multiVote = async (idxs, support) => { + for (const i of idxs) { + await governor + .connect(voters[i - 1]) + .castVote(proposalId, support) + } + } + + await multiVote([1, 2, 3], VoteType.For) + await multiVote([4, 5], VoteType.Against) + // 40% For votes at this point, if Against was counted as For it'd change + assert.isFalse(await governor.voteSucceeded(proposalId)) + + await multiVote([6], VoteType.Abstain) + // does't make it true (not counted as For) + assert.isFalse(await governor.voteSucceeded(proposalId)) + + // now tip the scales + await multiVote([7], VoteType.For) + assert.isTrue(await governor.voteSucceeded(proposalId)) + + await multiVote([8, 9, 10], VoteType.Abstain) + // doesn't make it false either (not counted as Against) + assert.isTrue(await governor.voteSucceeded(proposalId)) + }) + }) + + describe("_countVote", () => { + let delegators + let transcoders + + // override proposalId in these tests + let proposalId + + beforeEach(async () => { + // rename them here for simpler reasoning + transcoders = voters + delegators = signers.slice(11, 21) + + for (const i = 1; i <= delegators.length; i++) { + await initVoter({ + signer: delegators[i - 1], + amount: ethers.utils.parseEther("1").mul(i), + // with this the `transcoders` should have 2x their voting power + delegateAddress: transcoders[i - 1].address + }) + } + + // create another proposal so it grabs the new snapshot + proposalId = await createProposal( + proposer, + "Totally not steal all the money" + ) + await fixture.rpc.wait() + }) + + const expectVotes = async expected => { + expected = expected.map(e => + ethers.utils.parseEther("1").mul(e).toString() + ) + + const votes = await governor + .proposalVotes(proposalId) + .then(v => v.map(v => v.toString())) + assert.deepEqual(votes, expected) + } + + it("should fail on invalid vote type", async () => { + await expect( + governor.connect(transcoders[0]).castVote(proposalId, 7) + ).to.be.revertedWith("InvalidVoteType(7)") + }) + + it("should fail on duplicate votes", async () => { + await governor + .connect(transcoders[0]) + .castVote(proposalId, VoteType.For) + + await expect( + governor + .connect(transcoders[0]) + .castVote(proposalId, VoteType.For) + ).to.be.revertedWith("VoteAlreadyCast()") + }) + + describe("overrides", () => { + for (const transVote of Object.keys(VoteType)) { + describe(`transcoder votes ${transVote} first`, () => { + beforeEach(async () => { + await governor + .connect(transcoders[0]) + .castVote(proposalId, VoteType[transVote]) + }) + + it("should count transcoder votes with delegations", async () => { + const expected = [0, 0, 0] + expected[VoteType[transVote]] += 2 + + await expectVotes(expected) + }) + + for (const delVote of Object.keys(VoteType)) { + describe(`delegator votes ${delVote} after`, () => { + beforeEach(async () => { + await governor + .connect(delegators[0]) + .castVote(proposalId, VoteType[delVote]) + }) + + it("should count delegator votes and deduct transcoder", async () => { + const expected = [0, 0, 0] + expected[VoteType[transVote]] += 1 + expected[VoteType[delVote]] += 1 + + await expectVotes(expected) + }) + }) + } + }) + } + + describe("delegator votes first", () => { + beforeEach(async () => { + await governor + .connect(delegators[0]) + .castVote(proposalId, VoteType.Against) + }) + + it("should count delegator votes", async () => { + await expectVotes([1, 0, 0]) + }) + + describe("transcoder votes after", () => { + beforeEach(async () => { + await governor + .connect(transcoders[0]) + .castVote(proposalId, VoteType.Abstain) + }) + + it("should count transcoder votes without delegation", async () => { + await expectVotes([1, 0, 1]) + }) + }) + }) + }) + }) +}) diff --git a/test/unit/helpers/expectCheckpoints.ts b/test/unit/helpers/expectCheckpoints.ts index cd315336..5fd01aba 100644 --- a/test/unit/helpers/expectCheckpoints.ts +++ b/test/unit/helpers/expectCheckpoints.ts @@ -17,8 +17,7 @@ export default async function expectCheckpoints( tx: ethers.providers.TransactionReceipt, ...checkpoints: Checkpoint[] ) { - const filter = - await fixture.bondingCheckpoints.filters.CheckpointBondingState() + const filter = fixture.bondingCheckpoints.filters.CheckpointBondingState() const events = await fixture.bondingCheckpoints.queryFilter( filter, tx.blockNumber, From 2fd7abb92485fc8e0e46f3611326da22cb1d86e4 Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Thu, 20 Jul 2023 02:00:36 -0300 Subject: [PATCH 11/29] test/treasury: Test LivepeerGovernor --- contracts/treasury/Treasury.sol | 24 ++ deploy/deploy_livepeer_governor.ts | 104 ++++++++ test/helpers/governorEnums.js | 16 ++ test/helpers/setupIntegrationTest.ts | 10 +- test/integration/LivepeerGovernor.ts | 316 +++++++++++++++++++++++ test/unit/GovernorCountingOverridable.js | 18 +- utils/deployer.ts | 9 + 7 files changed, 477 insertions(+), 20 deletions(-) create mode 100644 contracts/treasury/Treasury.sol create mode 100644 deploy/deploy_livepeer_governor.ts create mode 100644 test/helpers/governorEnums.js create mode 100644 test/integration/LivepeerGovernor.ts diff --git a/contracts/treasury/Treasury.sol b/contracts/treasury/Treasury.sol new file mode 100644 index 00000000..7a5822c0 --- /dev/null +++ b/contracts/treasury/Treasury.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "@openzeppelin/contracts-upgradeable/governance/TimelockControllerUpgradeable.sol"; + +/** + * @title Treasury + * @notice Holder of the treasury and executor of proposals for the LivepeerGovernor. + * @dev This was only really needed because TimelockControllerUpgradeable does not expose a public initializer, so we + * need to inherit and expose the initialization function here. + * + * Even though this contract is upgradeable to fit with the rest of the contracts that expect upgradeable instances, it + * is not used with a proxy, so we don't need to disable initializers in the constructor. + */ +contract Treasury is Initializable, TimelockControllerUpgradeable { + function initialize( + uint256 minDelay, + address[] memory proposers, + address[] memory executors, + address admin + ) external initializer { + __TimelockController_init(minDelay, proposers, executors, admin); + } +} diff --git a/deploy/deploy_livepeer_governor.ts b/deploy/deploy_livepeer_governor.ts new file mode 100644 index 00000000..7f6dfe01 --- /dev/null +++ b/deploy/deploy_livepeer_governor.ts @@ -0,0 +1,104 @@ +import {constants} from "ethers" +import {ethers} from "hardhat" +import {HardhatRuntimeEnvironment} from "hardhat/types" +import {DeployFunction} from "hardhat-deploy/types" + +import ContractDeployer from "../utils/deployer" +import {LivepeerGovernor, Treasury} from "../typechain" +import getNetworkConfig from "./migrations.config" + +const PROD_NETWORKS = ["mainnet", "arbitrumMainnet"] + +const isProdNetwork = (name: string): boolean => { + return PROD_NETWORKS.indexOf(name) > -1 +} + +const func: DeployFunction = async function(hre: HardhatRuntimeEnvironment) { + const {deployments, getNamedAccounts} = hre // Get the deployments and getNamedAccounts which are provided by hardhat-deploy + const {deploy, get} = deployments // the deployments object itself contains the deploy function + + const {deployer} = await getNamedAccounts() // Fetch named accounts from hardhat.config.ts + + const config = getNetworkConfig(hre.network.name) + + const contractDeployer = new ContractDeployer(deploy, deployer, deployments) + const controller = await contractDeployer.fetchDeployedController() + + // PollCreator is deployed without being registered to Controller, so we do that here + const registeredPollCreator = await controller.getContract( + ethers.utils.solidityKeccak256(["string"], ["PollCreator"]) + ) + if (registeredPollCreator === constants.AddressZero) { + const pollCreator = await ethers.getContractAt( + "PollCreator", + isProdNetwork(hre.network.name) ? + config.livepeerGovernor.pollCreatorAddress : + await get("PollCreator").then(p => p.address) + ) + + await contractDeployer.register("PollCreator", pollCreator.address) + } + + await contractDeployer.deployAndRegister({ + contract: "BondingCheckpointsVotes", + name: "BondingCheckpointsVotes", + args: [controller.address] + }) + + // Onchain treasury governor (LivepeerGovernor) + const treasury = await contractDeployer.deployAndRegister({ + contract: "Treasury", + name: "Treasury", + args: [] + }) + const Treasury: Treasury = await ethers.getContractAt( + "Treasury", + treasury.address + ) + + await Treasury.initialize( + 0, // no min delay + [], // governor will be added as a proposer later + [constants.AddressZero], // let anyone execute proposals + deployer // temporary admin role for deployer + ).then(tx => tx.wait()) + console.log("I am deployer of worlds ", deployer) + + const livepeerGovernor = await contractDeployer.deployAndRegister({ + contract: "LivepeerGovernor", + name: "LivepeerGovernor", + args: [controller.address], + proxy: true + }) + const LivepeerGovernor: LivepeerGovernor = await ethers.getContractAt( + "LivepeerGovernor", + livepeerGovernor.address + ) + + await LivepeerGovernor.initialize().then(tx => tx.wait()) + + // Now grant proposer and executor roles to governor and renounce deployer admin role + const roles = { + proposer: await Treasury.PROPOSER_ROLE(), + canceller: await Treasury.CANCELLER_ROLE(), + executor: await Treasury.EXECUTOR_ROLE(), + admin: await Treasury.TIMELOCK_ADMIN_ROLE() + } + for (const role of [roles.proposer, roles.canceller, roles.executor]) { + await Treasury.grantRole(role, LivepeerGovernor.address).then(tx => + tx.wait() + ) + } + + if (isProdNetwork(hre.network.name)) { + // TODO: Make sure we really want this. Multi-sig would have root to everything + await Treasury.grantRole(roles.admin, config.governor.owner).then(tx => + tx.wait() + ) + } + await Treasury.renounceRole(roles.admin, deployer).then(tx => tx.wait()) +} + +func.dependencies = ["Contracts", "Poll"] +func.tags = ["LivepeerGovernor"] +export default func diff --git a/test/helpers/governorEnums.js b/test/helpers/governorEnums.js new file mode 100644 index 00000000..c59f3fbc --- /dev/null +++ b/test/helpers/governorEnums.js @@ -0,0 +1,16 @@ +export const VoteType = { + Against: 0, + For: 1, + Abstain: 2 +} + +export const ProposalState = { + Pending: 0, + Active: 1, + Canceled: 2, + Defeated: 3, + Succeeded: 4, + Queued: 5, + Expired: 6, + Executed: 7 +} diff --git a/test/helpers/setupIntegrationTest.ts b/test/helpers/setupIntegrationTest.ts index 4c3a2935..1514b0e0 100644 --- a/test/helpers/setupIntegrationTest.ts +++ b/test/helpers/setupIntegrationTest.ts @@ -3,8 +3,12 @@ import {GenericMock__factory} from "../../typechain" import {contractId} from "../../utils/helpers" const setupIntegrationTest = deployments.createFixture( - async ({deployments, getNamedAccounts, ethers}) => { - const fixture = await deployments.fixture(["Contracts"]) + async ( + {deployments, getNamedAccounts, ethers}, + opts?: { tags: string[] } + ) => { + const tags = opts?.tags ?? ["Contracts"] + const fixture = await deployments.fixture(tags) const {deployer} = await getNamedAccounts() const signer = await ethers.getSigner(deployer) @@ -33,4 +37,4 @@ const setupIntegrationTest = deployments.createFixture( } ) -module.exports = setupIntegrationTest +export default setupIntegrationTest diff --git a/test/integration/LivepeerGovernor.ts b/test/integration/LivepeerGovernor.ts new file mode 100644 index 00000000..2f13b552 --- /dev/null +++ b/test/integration/LivepeerGovernor.ts @@ -0,0 +1,316 @@ +import {ethers} from "hardhat" +import {SignerWithAddress as Signer} from "@nomiclabs/hardhat-ethers/dist/src/signers" +import {BigNumberish} from "ethers" +import chai, {assert, expect} from "chai" +import {solidity} from "ethereum-waffle" + +import {contractId} from "../../utils/helpers" +import setupIntegrationTest from "../helpers/setupIntegrationTest" +import { + AdjustableRoundsManager, + BondingCheckpointsVotes, + BondingManager, + Controller, + LivepeerGovernor, + LivepeerToken, + PollCreator, + Treasury +} from "../../typechain" +import {ProposalState, VoteType} from "../helpers/governorEnums" +import RPC from "../../utils/rpc" +import {constants} from "../../utils/constants" + +chai.use(solidity) + +describe("LivepeerGovernor", () => { + let rpc: RPC + let controller: Controller + + let roundsManager: AdjustableRoundsManager + let bondingManager: BondingManager + let bondingCheckpointsVotes: BondingCheckpointsVotes + let token: LivepeerToken + let pollCreator: PollCreator + + let treasury: Treasury + let governor: LivepeerGovernor + + let signers: Signer[] + let proposer: Signer // the only participant here + let roundLength: number + + before(async () => { + rpc = new RPC((global as any).web3) + signers = await ethers.getSigners() + proposer = signers[0] + + const fixture = await setupIntegrationTest({ + tags: ["LivepeerGovernor"] + }) + controller = await ethers.getContractAt( + "Controller", + fixture.Controller.address + ) + roundsManager = await ethers.getContractAt( + "AdjustableRoundsManager", + fixture.AdjustableRoundsManager.address + ) + roundLength = (await roundsManager.roundLength()).toNumber() + bondingManager = await ethers.getContractAt( + "BondingManager", + fixture.BondingManager.address + ) + bondingCheckpointsVotes = await ethers.getContractAt( + "BondingCheckpointsVotes", + fixture.BondingCheckpointsVotes.address + ) + token = await ethers.getContractAt( + "LivepeerToken", + fixture.LivepeerToken.address + ) + pollCreator = await ethers.getContractAt( + "PollCreator", + fixture.PollCreator.address + ) + + treasury = await ethers.getContractAt( + "Treasury", + fixture.Treasury.address + ) + governor = await ethers.getContractAt( + "LivepeerGovernor", + fixture.LivepeerGovernor.address + ) + + await controller.unpause() + + await bond(proposer, ethers.utils.parseEther("100"), proposer) + + // the bond checkpoints on the next round, and Governor.propose() + // checks the previous round, so we need to wait 2 rounds here + await waitRounds(2) + }) + + let snapshotId: string + + beforeEach(async () => { + snapshotId = await rpc.snapshot() + }) + + afterEach(async () => { + await rpc.revert(snapshotId) + }) + + it("ensure deployment success", async () => { + assert.equal(await governor.name(), "LivepeerGovernor") + }) + + async function bond( + delegator: Signer, + amount: BigNumberish, + transcoder: Signer + ) { + await token.transfer(delegator.address, amount) + await token.connect(delegator).approve(bondingManager.address, amount) + await bondingManager.connect(delegator).bond(amount, transcoder.address) + } + + async function waitRounds(rounds: number) { + for (let i = 0; i < rounds; i++) { + await roundsManager.mineBlocks(roundLength) + await roundsManager.initializeRound() + } + } + + async function governorExecute( + signer: Signer, + target: string, + functionData: string, + description: string + ) { + const tx = await governor + .connect(signer) + .propose([target], [0], [functionData], description) + const filter = governor.filters.ProposalCreated() + const events = await governor.queryFilter( + filter, + tx.blockNumber, + tx.blockNumber + ) + const proposalId = events[0].args[0] + + // let the voting begin + await waitRounds(2) + + await governor.connect(signer).castVote(proposalId, VoteType.For) + + await waitRounds(10) + + const descriptionHash = ethers.utils.solidityKeccak256( + ["string"], + [description] + ) + await governor + .connect(signer) + .queue([target], [0], [functionData], descriptionHash) + await governor + .connect(signer) + .execute([target], [0], [functionData], descriptionHash) + + assert.equal(await governor.state(proposalId), ProposalState.Executed) + } + + describe("treasury timelock", async () => { + it("should have 0 initial minDelay", async () => { + const minDelay = await treasury.getMinDelay() + assert.equal(minDelay.toNumber(), 0) + }) + + describe("should allow updating minDelay", () => { + const testDelay = 3 * 24 * 60 * 60 // 3 days + + beforeEach(async () => { + await governorExecute( + signers[0], + treasury.address, + treasury.interface.encodeFunctionData("updateDelay", [ + testDelay + ]), + "set treasury minDelay to 3 days" + ) + }) + + it("should return new value", async () => { + const minDelay = await treasury.getMinDelay() + assert.equal(minDelay.toNumber(), testDelay) + }) + + it("should effectively delay execution", async () => { + // default execute code will not wait for the 3 days after queueing + const tx = governorExecute( + signers[0], + token.address, + token.interface.encodeFunctionData("transfer", [ + signers[1].address, + ethers.utils.parseEther("500") + ]), + "sample transfer" + ) + + await expect(tx).to.be.revertedWith( + "TimelockController: operation is not ready" + ) + }) + }) + }) + + describe("settings", () => { + const testProperty = ( + name: string, + initialValue: BigNumberish, + setFunc?: string, + newValue?: BigNumberish + ) => { + describe(name, () => { + let getter: typeof governor["votingDelay"] + + before(async () => { + getter = governor[ + name as keyof LivepeerGovernor + ] as typeof getter + }) + + it(`should start as ${initialValue}`, async () => { + const value = await getter() + assert.equal(value.toString(), initialValue.toString()) + }) + + if (setFunc && newValue) { + it("should be updatable", async () => { + await governorExecute( + proposer, + governor.address, + governor.interface.encodeFunctionData( + setFunc as any, + [newValue] + ), + `set ${name} to ${newValue}` + ) + + const value = await getter() + assert.equal(value.toString(), newValue.toString()) + }) + } + }) + } + + testProperty("votingDelay", 1, "setVotingDelay", 5) + testProperty("votingPeriod", 10, "setVotingPeriod", 14) + testProperty( + "proposalThreshold", + ethers.utils.parseEther("100"), + "setProposalThreshold", + ethers.utils.parseEther("50") + ) + testProperty( + "quorumNumerator()", + 333300, // 33.33% + "updateQuorumNumerator", + 500000 // 50% + ) + testProperty("quorumDenominator", constants.PERC_DIVISOR) + }) + + describe("voting module", () => { + it("should use BondingCheckpointVotes as the token", async () => { + const tokenAddr = await governor.token() + assert.equal(tokenAddr, bondingCheckpointsVotes.address) + }) + + describe("bumpVotesAddress()", () => { + let newBondingCheckpointsVotes: BondingCheckpointsVotes + + before(async () => { + const factory = await ethers.getContractFactory( + "BondingCheckpoints" + ) + newBondingCheckpointsVotes = (await factory.deploy( + controller.address + )) as BondingCheckpointsVotes + + const id = contractId("BondingCheckpointsVotes") + const [, gitCommitHash] = await controller.getContractInfo(id) + await controller.setContractInfo( + id, + newBondingCheckpointsVotes.address, + gitCommitHash + ) + }) + + it("should not update the reference automatically", async () => { + assert.equal( + await governor.token(), + bondingCheckpointsVotes.address + ) + }) + + it("should update reference after calling bumpVotesAddress", async () => { + await governor.bumpVotesAddress() + assert.equal( + await governor.token(), + newBondingCheckpointsVotes.address + ) + }) + }) + + describe("quota()", () => { + it("should return the same value as PollCreator", async () => { + const expected = await pollCreator + .QUOTA() + .then(bn => bn.toString()) + const actual = await governor.quota().then(bn => bn.toString()) + assert.equal(actual, expected) + }) + }) + }) +}) diff --git a/test/unit/GovernorCountingOverridable.js b/test/unit/GovernorCountingOverridable.js index a3e070c8..1892b8cc 100644 --- a/test/unit/GovernorCountingOverridable.js +++ b/test/unit/GovernorCountingOverridable.js @@ -6,27 +6,11 @@ import {solidity} from "ethereum-waffle" import Fixture from "./helpers/Fixture" import math from "../helpers/math" +import {ProposalState, VoteType} from "../helpers/governorEnums" chai.use(solidity) const {expect} = chai -const VoteType = { - Against: 0, - For: 1, - Abstain: 2 -} - -const ProposalState = { - Pending: 0, - Active: 1, - Canceled: 2, - Defeated: 3, - Succeeded: 4, - Queued: 5, - Expired: 6, - Executed: 7 -} - describe("GovernorCountingOverridable", () => { let signers let fixture diff --git a/utils/deployer.ts b/utils/deployer.ts index fe65d7b6..f3866f57 100644 --- a/utils/deployer.ts +++ b/utils/deployer.ts @@ -69,6 +69,15 @@ export default class ContractDeployer { return this.controller } + async fetchDeployedController(): Promise { + const deployment = await this.deployments.get("Controller") + this.controller = (await ethers.getContractAt( + "Controller", + deployment.address + )) as Controller + return this.controller + } + async deployAndRegister(config: { contract: string name: string From 34cb4551325cf04117438b8d0e8749b8e4add3a0 Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Thu, 20 Jul 2023 16:57:53 -0300 Subject: [PATCH 12/29] test/treasury: A couple additional Governor tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 100% coverage 😎 --- .../test/mocks/GovenorInterfacesFixture.sol | 51 +++++++ test/integration/LivepeerGovernor.ts | 125 +++++++++++++++--- 2 files changed, 160 insertions(+), 16 deletions(-) create mode 100644 contracts/test/mocks/GovenorInterfacesFixture.sol diff --git a/contracts/test/mocks/GovenorInterfacesFixture.sol b/contracts/test/mocks/GovenorInterfacesFixture.sol new file mode 100644 index 00000000..6e42e9a8 --- /dev/null +++ b/contracts/test/mocks/GovenorInterfacesFixture.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "@openzeppelin/contracts-upgradeable/governance/IGovernorUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/extensions/IGovernorTimelockUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC1155/IERC1155ReceiverUpgradeable.sol"; + +/** + * @dev This is a helper contract to return the expected interface values that the LivepeerGovenor interface should + * support. This only exists in Solidity since generating these interfaces in JS is kinda of a pain. + */ +contract GovernorInterfacesFixture { + function TimelockUpgradeableInterface() external pure returns (bytes4) { + return type(IGovernorTimelockUpgradeable).interfaceId; + } + + /** + * @dev ID calculation logic copied from {GovernorUpgradeable-supportsInterface}. + */ + function GovernorInterfaces() external pure returns (bytes4[] memory) { + IGovernorUpgradeable governor; + // + bytes4 governorCancelId = governor.cancel.selector ^ governor.proposalProposer.selector; + + bytes4 governorParamsId = governor.castVoteWithReasonAndParams.selector ^ + governor.castVoteWithReasonAndParamsBySig.selector ^ + governor.getVotesWithParams.selector; + + // The original interface id in v4.3. + bytes4 governor43Id = type(IGovernorUpgradeable).interfaceId ^ + type(IERC6372Upgradeable).interfaceId ^ + governorCancelId ^ + governorParamsId; + + // An updated interface id in v4.6, with params added. + bytes4 governor46Id = type(IGovernorUpgradeable).interfaceId ^ + type(IERC6372Upgradeable).interfaceId ^ + governorCancelId; + + // For the updated interface id in v4.9, we use governorCancelId directly. + // + + // replace the interface checks with return the expected interface ids + bytes4[] memory ids = new bytes4[](4); + ids[0] = governor43Id; + ids[1] = governor46Id; + ids[2] = governorCancelId; + ids[3] = type(IERC1155ReceiverUpgradeable).interfaceId; + return ids; + } +} diff --git a/test/integration/LivepeerGovernor.ts b/test/integration/LivepeerGovernor.ts index 2f13b552..0e4546c8 100644 --- a/test/integration/LivepeerGovernor.ts +++ b/test/integration/LivepeerGovernor.ts @@ -11,6 +11,7 @@ import { BondingCheckpointsVotes, BondingManager, Controller, + GovernorInterfacesFixture, LivepeerGovernor, LivepeerToken, PollCreator, @@ -101,10 +102,6 @@ describe("LivepeerGovernor", () => { await rpc.revert(snapshotId) }) - it("ensure deployment success", async () => { - assert.equal(await governor.name(), "LivepeerGovernor") - }) - async function bond( delegator: Signer, amount: BigNumberish, @@ -122,15 +119,24 @@ describe("LivepeerGovernor", () => { } } - async function governorExecute( + async function createProposal( signer: Signer, target: string, functionData: string, description: string ) { + const execArgs: GovernorExecuteArgs = [ + [target], + [0], + [functionData], + description + ] const tx = await governor .connect(signer) - .propose([target], [0], [functionData], description) + .propose.apply(governor, execArgs) + // after the propose calls, description is replaced with hash + execArgs[3] = ethers.utils.solidityKeccak256(["string"], [description]) + const filter = governor.filters.ProposalCreated() const events = await governor.queryFilter( filter, @@ -139,6 +145,25 @@ describe("LivepeerGovernor", () => { ) const proposalId = events[0].args[0] + return [proposalId, execArgs] as const + } + + type GovernorExecuteArgs = [string[], BigNumberish[], string[], string] + + async function governorExecute( + signer: Signer, + target: string, + functionData: string, + description: string, + beforeExecute?: (args: GovernorExecuteArgs) => Promise + ) { + const [proposalId, execArgs] = await createProposal( + signer, + target, + functionData, + description + ) + // let the voting begin await waitRounds(2) @@ -146,26 +171,80 @@ describe("LivepeerGovernor", () => { await waitRounds(10) - const descriptionHash = ethers.utils.solidityKeccak256( - ["string"], - [description] - ) - await governor - .connect(signer) - .queue([target], [0], [functionData], descriptionHash) - await governor - .connect(signer) - .execute([target], [0], [functionData], descriptionHash) + await governor.connect(signer).queue.apply(governor, execArgs) + + if (beforeExecute) { + await beforeExecute(execArgs) + } + await governor.connect(signer).execute.apply(governor, execArgs) assert.equal(await governor.state(proposalId), ProposalState.Executed) } + it("ensure deployment success", async () => { + assert.equal(await governor.name(), "LivepeerGovernor") + }) + + describe("supportsInterface", () => { + const INVALID_ID = "0xffffffff" + + let interfacesProvider: GovernorInterfacesFixture + + before(async () => { + const factory = await ethers.getContractFactory( + "GovernorInterfacesFixture" + ) + interfacesProvider = + (await factory.deploy()) as GovernorInterfacesFixture + }) + + it("should support all Governor interfaces", async () => { + const interfaces = await interfacesProvider.GovernorInterfaces() + for (const iface of interfaces) { + assert.isTrue(await governor.supportsInterface(iface)) + } + }) + + it("should support TimelockController interface", async () => { + const iface = + await interfacesProvider.TimelockUpgradeableInterface() + assert.isTrue(await governor.supportsInterface(iface)) + }) + + it("should not support an invalid interface", async () => { + assert.isFalse(await governor.supportsInterface(INVALID_ID)) + }) + }) + describe("treasury timelock", async () => { it("should have 0 initial minDelay", async () => { const minDelay = await treasury.getMinDelay() assert.equal(minDelay.toNumber(), 0) }) + it("should be able to cancel proposal before voting starts", async () => { + // Cancellation is not really a timelock feature, but we only override _cancel() because we inherit from + // GovernorTimelockControlUpgradeable, so test that cancelling still works here. + + const [proposalId, cancelArgs] = await createProposal( + signers[0], + treasury.address, + token.interface.encodeFunctionData("transfer", [ + signers[1].address, + ethers.utils.parseEther("500") + ]), + "sample transfer" + ) + await governor + .connect(signers[0]) + .cancel.apply(governor, cancelArgs) + + assert.equal( + await governor.state(proposalId), + ProposalState.Canceled + ) + }) + describe("should allow updating minDelay", () => { const testDelay = 3 * 24 * 60 * 60 // 3 days @@ -201,6 +280,20 @@ describe("LivepeerGovernor", () => { "TimelockController: operation is not ready" ) }) + + it("should work after waiting for the delay", async () => { + await governorExecute( + signers[0], + treasury.address, + treasury.interface.encodeFunctionData("updateDelay", [0]), + "set treasury minDelay back to 0", + // wait for 3 day-long rounds before executing + () => rpc.wait(3, 24 * 60 * 60) + ) + + const minDelay = await treasury.getMinDelay() + assert.equal(minDelay.toNumber(), 0) + }) }) }) From e97047228c713bf771f873ad7cc3020927ff3f51 Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Thu, 20 Jul 2023 17:08:35 -0300 Subject: [PATCH 13/29] test/treasury: Rename Counting unit test mock "Harness" seems to make more sense, I could only think of that now. --- ...dableTestable.sol => GovernorCountingOverridableHarness.sol} | 2 +- test/unit/GovernorCountingOverridable.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename contracts/test/mocks/{GovernorCountingOverridableTestable.sol => GovernorCountingOverridableHarness.sol} (98%) diff --git a/contracts/test/mocks/GovernorCountingOverridableTestable.sol b/contracts/test/mocks/GovernorCountingOverridableHarness.sol similarity index 98% rename from contracts/test/mocks/GovernorCountingOverridableTestable.sol rename to contracts/test/mocks/GovernorCountingOverridableHarness.sol index 6de1d3fe..028c1773 100644 --- a/contracts/test/mocks/GovernorCountingOverridableTestable.sol +++ b/contracts/test/mocks/GovernorCountingOverridableHarness.sol @@ -13,7 +13,7 @@ import "../../treasury/GovernorCountingOverridable.sol"; * @dev This is a concrete contract to test the GovernorCountingOverridable extension. It implements the minimum * necessary to get a working Governor to test the extension. */ -contract GovernorCountingOverridableTestable is +contract GovernorCountingOverridableHarness is Initializable, GovernorUpgradeable, GovernorSettingsUpgradeable, diff --git a/test/unit/GovernorCountingOverridable.js b/test/unit/GovernorCountingOverridable.js index 1892b8cc..74e67b95 100644 --- a/test/unit/GovernorCountingOverridable.js +++ b/test/unit/GovernorCountingOverridable.js @@ -72,7 +72,7 @@ describe("GovernorCountingOverridable", () => { // setup governor const governorFac = await ethers.getContractFactory( - "GovernorCountingOverridableTestable" + "GovernorCountingOverridableHarness" ) governor = await governorFac.deploy() await governor.initialize(votes.address) From a6db562f747593dcfa5f59f35cd54732322f33e7 Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Wed, 26 Jul 2023 15:56:17 -0300 Subject: [PATCH 14/29] Apply suggestions from code review Co-authored-by: Chase Adams --- contracts/treasury/BondingCheckpointsVotes.sol | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/treasury/BondingCheckpointsVotes.sol b/contracts/treasury/BondingCheckpointsVotes.sol index 435ce21f..72d4f3f7 100644 --- a/contracts/treasury/BondingCheckpointsVotes.sol +++ b/contracts/treasury/BondingCheckpointsVotes.sol @@ -32,14 +32,14 @@ contract BondingCheckpointsVotes is Manager, IVotes { } /** - * @notice Returns the current amount of votes that `account` has. + * @notice Returns the current amount of votes that `_account` has. */ function getVotes(address _account) external view returns (uint256) { return getPastVotes(_account, clock()); } /** - * @notice Returns the amount of votes that `account` had at a specific moment in the past. If the `clock()` is + * @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. */ function getPastVotes(address _account, uint256 _round) public view returns (uint256) { @@ -59,7 +59,7 @@ contract BondingCheckpointsVotes is Manager, IVotes { /** * @notice Returns the delegate that _account has chosen. This means the delegated transcoder address in case of - * delegators, and the account own address for transcoders (self-delegated). + * delegators, and the account's own address for transcoders (self-delegated). */ function delegates(address _account) external view returns (address) { return delegatedAt(_account, clock()); From 0ad3a8fca7b121e82d67034cd92585967dc782a9 Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Thu, 27 Jul 2023 18:26:34 -0300 Subject: [PATCH 15/29] treasury: Fix storage layout situation --- .../mocks/LivepeerGovernorUpgradeMock.sol | 14 +++ .../treasury/GovernorCountingOverridable.sol | 7 ++ contracts/treasury/LivepeerGovernor.sol | 2 +- deploy/deploy_livepeer_governor.ts | 1 - test/integration/LivepeerGovernor.ts | 105 ++++++++++++++++++ 5 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 contracts/test/mocks/LivepeerGovernorUpgradeMock.sol diff --git a/contracts/test/mocks/LivepeerGovernorUpgradeMock.sol b/contracts/test/mocks/LivepeerGovernorUpgradeMock.sol new file mode 100644 index 00000000..61d844db --- /dev/null +++ b/contracts/test/mocks/LivepeerGovernorUpgradeMock.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "../../treasury/LivepeerGovernor.sol"; + +contract LivepeerGovernorUpgradeMock is LivepeerGovernor { + uint256 public customField; + + constructor(address _controller) LivepeerGovernor(_controller) {} + + function setCustomField(uint256 _customField) external { + customField = _customField; + } +} diff --git a/contracts/treasury/GovernorCountingOverridable.sol b/contracts/treasury/GovernorCountingOverridable.sol index 79cb0896..949bc0d8 100644 --- a/contracts/treasury/GovernorCountingOverridable.sol +++ b/contracts/treasury/GovernorCountingOverridable.sol @@ -219,4 +219,11 @@ abstract contract GovernorCountingOverridable is Initializable, GovernorUpgradea * @return quota value as a MathUtils percentage value (e.g. 6 decimal places). */ function quota() public view virtual returns (uint256); + + /** + * @dev This empty reserved space is put in place to allow future versions to add new + * variables without shifting down storage in the inheritance chain. + * See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps + */ + uint256[50] private __gap; } diff --git a/contracts/treasury/LivepeerGovernor.sol b/contracts/treasury/LivepeerGovernor.sol index 101ed5b1..964a3b45 100644 --- a/contracts/treasury/LivepeerGovernor.sol +++ b/contracts/treasury/LivepeerGovernor.sol @@ -25,8 +25,8 @@ import "./BondingCheckpointsVotes.sol"; * GovernorCountingOverridable extension. */ contract LivepeerGovernor is - Initializable, ManagerProxyTarget, + Initializable, GovernorUpgradeable, GovernorSettingsUpgradeable, GovernorTimelockControlUpgradeable, diff --git a/deploy/deploy_livepeer_governor.ts b/deploy/deploy_livepeer_governor.ts index 7f6dfe01..f491666e 100644 --- a/deploy/deploy_livepeer_governor.ts +++ b/deploy/deploy_livepeer_governor.ts @@ -62,7 +62,6 @@ const func: DeployFunction = async function(hre: HardhatRuntimeEnvironment) { [constants.AddressZero], // let anyone execute proposals deployer // temporary admin role for deployer ).then(tx => tx.wait()) - console.log("I am deployer of worlds ", deployer) const livepeerGovernor = await contractDeployer.deployAndRegister({ contract: "LivepeerGovernor", diff --git a/test/integration/LivepeerGovernor.ts b/test/integration/LivepeerGovernor.ts index 0e4546c8..82e0af9e 100644 --- a/test/integration/LivepeerGovernor.ts +++ b/test/integration/LivepeerGovernor.ts @@ -13,6 +13,7 @@ import { Controller, GovernorInterfacesFixture, LivepeerGovernor, + LivepeerGovernorUpgradeMock, LivepeerToken, PollCreator, Treasury @@ -35,6 +36,7 @@ describe("LivepeerGovernor", () => { let treasury: Treasury let governor: LivepeerGovernor + let governorTarget: LivepeerGovernor let signers: Signer[] let proposer: Signer // the only participant here @@ -82,6 +84,10 @@ describe("LivepeerGovernor", () => { "LivepeerGovernor", fixture.LivepeerGovernor.address ) + governorTarget = await ethers.getContractAt( + "LivepeerGovernor", + fixture.LivepeerGovernorTarget.address + ) await controller.unpause() @@ -185,6 +191,105 @@ describe("LivepeerGovernor", () => { assert.equal(await governor.name(), "LivepeerGovernor") }) + describe("upgradeability", () => { + describe("target", () => { + it("should implement ManagerProxyTarget", async () => { + // these would get handled by the proxy, so check the target state directly + assert.equal( + await governorTarget.controller(), + controller.address + ) + assert.equal( + await governorTarget.targetContractId(), + constants.NULL_BYTES + ) + }) + + it("should not be initializable", async () => { + // revert msg is misleading, but it's not initializable because initializers are disabled + await expect(governorTarget.initialize()).to.be.revertedWith( + "Initializable: contract is already initialized" + ) + + // to be more certain, we check if the `initialized` event was emitted with MaxInt8 + const filter = governorTarget.filters.Initialized() + const events = await governorTarget.queryFilter( + filter, + 0, + "latest" + ) + assert.equal(events.length, 1) + assert.equal(events[0].args[0], 255) // MaxUint8 (disabled) instead of 1 (initialized) + }) + }) + + describe("proxy", () => { + let newGovernorTarget: LivepeerGovernorUpgradeMock + + before(async () => { + const factory = await ethers.getContractFactory( + "LivepeerGovernorUpgradeMock" + ) + newGovernorTarget = (await factory.deploy( + controller.address + )) as LivepeerGovernorUpgradeMock + }) + + it("should have the right configuration", async () => { + assert.equal(await governor.controller(), controller.address) + assert.equal( + await governor.targetContractId(), + contractId("LivepeerGovernorTarget") + ) + }) + + it("should not be re-initializable", async () => { + await expect(governor.initialize()).to.be.revertedWith( + "Initializable: contract is already initialized" + ) + + // check if there has been a regular initialization in the past + const filter = governor.filters.Initialized() + const events = await governor.queryFilter(filter, 0, "latest") + assert.equal(events.length, 1) + assert.equal(events[0].args[0], 1) // 1 (initialized) instead of MaxUint8 (disabled) + }) + + it("should allow upgrades", async () => { + const [, gitCommitHash] = await controller.getContractInfo( + contractId("LivepeerGovernorTarget") + ) + await controller.setContractInfo( + contractId("LivepeerGovernorTarget"), + newGovernorTarget.address, + gitCommitHash + ) + + // should keep initialized state + await expect(governor.initialize()).to.be.revertedWith( + "Initializable: contract is already initialized" + ) + + // should have the new logic + const newGovernor = (await ethers.getContractAt( + "LivepeerGovernorUpgradeMock", // same proxy, just grab the new abi + governor.address + )) as LivepeerGovernorUpgradeMock + + assert.equal( + await newGovernor.customField().then(bn => bn.toNumber()), + 0 + ) + + await newGovernor.setCustomField(123) + assert.equal( + await newGovernor.customField().then(bn => bn.toNumber()), + 123 + ) + }) + }) + }) + describe("supportsInterface", () => { const INVALID_ID = "0xffffffff" From bb9ff45b4edcc0c25b897f08681e34b47107a185 Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Fri, 4 Aug 2023 12:59:34 -0300 Subject: [PATCH 16/29] treasury: Move governor initial params to configs --- contracts/treasury/BondingCheckpointsVotes.sol | 6 +++--- contracts/treasury/LivepeerGovernor.sol | 12 ++++++------ deploy/deploy_livepeer_governor.ts | 8 ++++++-- deploy/migrations.config.ts | 8 ++++++++ test/integration/LivepeerGovernor.ts | 8 +++++--- 5 files changed, 28 insertions(+), 14 deletions(-) diff --git a/contracts/treasury/BondingCheckpointsVotes.sol b/contracts/treasury/BondingCheckpointsVotes.sol index 72d4f3f7..0dc73906 100644 --- a/contracts/treasury/BondingCheckpointsVotes.sol +++ b/contracts/treasury/BondingCheckpointsVotes.sol @@ -11,7 +11,7 @@ import "./GovernorCountingOverridable.sol"; contract BondingCheckpointsVotes is Manager, IVotes { // Indicates that the called function is not supported in this contract and should be performed through the // BondingManager instead. This is mostly used for delegation methods, which must be bonds instead. - error MustCallBondingManager(); + error MustCallBondingManager(string bondingManagerFunction); constructor(address _controller) Manager(_controller) {} @@ -80,7 +80,7 @@ contract BondingCheckpointsVotes is Manager, IVotes { * @notice Delegation through BondingCheckpoints is not supported. */ function delegate(address) external pure { - revert MustCallBondingManager(); + revert MustCallBondingManager("bond"); } /** @@ -94,7 +94,7 @@ contract BondingCheckpointsVotes is Manager, IVotes { bytes32, bytes32 ) external pure { - revert MustCallBondingManager(); + revert MustCallBondingManager("bond"); } /** diff --git a/contracts/treasury/LivepeerGovernor.sol b/contracts/treasury/LivepeerGovernor.sol index 964a3b45..d5ea08f1 100644 --- a/contracts/treasury/LivepeerGovernor.sol +++ b/contracts/treasury/LivepeerGovernor.sol @@ -52,13 +52,13 @@ contract LivepeerGovernor is * - "BondingCheckpointsVotes" * - "PollCreator" */ - function initialize() public initializer { + function initialize( + uint256 initialVotingDelay, + uint256 initialVotingPeriod, + uint256 initialProposalThreshold + ) public initializer { __Governor_init("LivepeerGovernor"); - __GovernorSettings_init( - 1, /* 1 round/day voting delay */ - 10, /* 10 rounds/days voting period */ - 100e18 /* 100 LPT min proposal threshold */ - ); + __GovernorSettings_init(initialVotingDelay, initialVotingPeriod, initialProposalThreshold); __GovernorTimelockControl_init(treasury()); // The GovernorVotes module will hold a fixed reference to the votes contract. If we ever change its address we diff --git a/deploy/deploy_livepeer_governor.ts b/deploy/deploy_livepeer_governor.ts index f491666e..bac4868f 100644 --- a/deploy/deploy_livepeer_governor.ts +++ b/deploy/deploy_livepeer_governor.ts @@ -57,7 +57,7 @@ const func: DeployFunction = async function(hre: HardhatRuntimeEnvironment) { ) await Treasury.initialize( - 0, // no min delay + config.treasury.minDelay, [], // governor will be added as a proposer later [constants.AddressZero], // let anyone execute proposals deployer // temporary admin role for deployer @@ -74,7 +74,11 @@ const func: DeployFunction = async function(hre: HardhatRuntimeEnvironment) { livepeerGovernor.address ) - await LivepeerGovernor.initialize().then(tx => tx.wait()) + await LivepeerGovernor.initialize( + config.livepeerGovernor.initialVotingDelay, + config.livepeerGovernor.initialVotingPeriod, + config.livepeerGovernor.initialProposalThreshold + ).then(tx => tx.wait()) // Now grant proposer and executor roles to governor and renounce deployer admin role const roles = { diff --git a/deploy/migrations.config.ts b/deploy/migrations.config.ts index b2aba13c..43165c07 100644 --- a/deploy/migrations.config.ts +++ b/deploy/migrations.config.ts @@ -52,6 +52,14 @@ const defaultConfig = { inflation: 137, inflationChange: 3, targetBondingRate: 500000 + }, + treasury: { + minDelay: 0 // 0s initial proposal delay + }, + livepeerGovernor: { + initialVotingDelay: 1, // 1 round + initialVotingPeriod: 10, // 10 rounds + initialProposalThreshold: ethers.utils.parseEther("100") // 100 LPT } } diff --git a/test/integration/LivepeerGovernor.ts b/test/integration/LivepeerGovernor.ts index 82e0af9e..ee1beedd 100644 --- a/test/integration/LivepeerGovernor.ts +++ b/test/integration/LivepeerGovernor.ts @@ -207,7 +207,9 @@ describe("LivepeerGovernor", () => { it("should not be initializable", async () => { // revert msg is misleading, but it's not initializable because initializers are disabled - await expect(governorTarget.initialize()).to.be.revertedWith( + await expect( + governorTarget.initialize(0, 0, 0) + ).to.be.revertedWith( "Initializable: contract is already initialized" ) @@ -244,7 +246,7 @@ describe("LivepeerGovernor", () => { }) it("should not be re-initializable", async () => { - await expect(governor.initialize()).to.be.revertedWith( + await expect(governor.initialize(0, 0, 0)).to.be.revertedWith( "Initializable: contract is already initialized" ) @@ -266,7 +268,7 @@ describe("LivepeerGovernor", () => { ) // should keep initialized state - await expect(governor.initialize()).to.be.revertedWith( + await expect(governor.initialize(0, 0, 0)).to.be.revertedWith( "Initializable: contract is already initialized" ) From 0c41f78401a97ba4d92def55ba21f2c1820ab6b2 Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Sat, 12 Aug 2023 17:22:36 -0300 Subject: [PATCH 17/29] bonding: Make sure we checkpoint up to once per op --- contracts/bonding/BondingManager.sol | 74 ++++++------ test/unit/BondingManager.js | 166 +++++++++++---------------- 2 files changed, 105 insertions(+), 135 deletions(-) diff --git a/contracts/bonding/BondingManager.sol b/contracts/bonding/BondingManager.sol index 5dd05937..08f8221a 100644 --- a/contracts/bonding/BondingManager.sol +++ b/contracts/bonding/BondingManager.sol @@ -124,6 +124,11 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { _; } + modifier autoCheckpoint(address account) { + _; + checkpointBondingState(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 @@ -348,9 +353,7 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { address _finder, uint256 _slashAmount, uint256 _finderFee - ) external whenSystemNotPaused onlyVerifier { - _autoClaimEarnings(_transcoder); - + ) external whenSystemNotPaused onlyVerifier autoClaimEarnings(_transcoder) autoCheckpoint(_transcoder) { Delegator storage del = delegators[_transcoder]; if (del.bondedAmount > 0) { @@ -371,8 +374,6 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { ); } - checkpointBondingState(_transcoder, del, transcoders[_transcoder]); - // Account for penalty uint256 burnAmount = penalty; @@ -400,7 +401,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; @@ -548,8 +554,6 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { // Update bonded amount del.bondedAmount = currentBondedAmount.add(_amount); - checkpointBondingState(_owner, del, transcoders[_owner]); - increaseTotalStake(_to, delegationAmount, _currDelegateNewPosPrev, _currDelegateNewPosNext); if (_amount > 0) { @@ -558,6 +562,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[_to]); } /** @@ -590,7 +597,7 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { * Implemented as a deploy utility to checkpoint the existing state when deploying the BondingCheckpoints contract. * @param _account The account to initialize the bonding checkpoint for */ - function checkpointBondingState(address _account) external { + function checkpointBondingState(address _account) public { checkpointBondingState(_account, delegators[_account], transcoders[_account]); } @@ -715,7 +722,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]; @@ -746,9 +753,6 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { } } - // No problem that startRound may have been cleared above, checkpoints are always made for currentRound()+1 - checkpointBondingState(msg.sender, del, transcoders[msg.sender]); - // If msg.sender was resigned this statement will only decrease delegators[currentDelegate].delegatedAmount decreaseTotalStake(currentDelegate, _amount, _newPosPrev, _newPosNext); @@ -815,6 +819,7 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { public whenSystemNotPaused currentRoundInitialized + autoCheckpoint(msg.sender) { uint256 currentRound = roundsManager().currentRound(); @@ -848,8 +853,6 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { // Set last round that transcoder called reward t.lastRewardRound = currentRound; - checkpointBondingState(msg.sender, delegators[msg.sender], t); - emit Reward(msg.sender, rewardTokens); } @@ -1238,6 +1241,19 @@ 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]; @@ -1269,12 +1285,8 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { } } - Delegator storage del = delegators[_delegate]; - // Increase delegate's delegated amount - del.delegatedAmount = newStake; - - checkpointBondingState(_delegate, del, t); + delegators[_delegate].delegatedAmount = newStake; } /** @@ -1287,7 +1299,7 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { uint256 _amount, address _newPosPrev, address _newPosNext - ) internal { + ) internal autoCheckpoint(_delegate) { Transcoder storage t = transcoders[_delegate]; uint256 currStake = transcoderTotalStake(_delegate); @@ -1312,12 +1324,8 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { t.earningsPoolPerRound[nextRound].setStake(newStake); } - Delegator storage del = delegators[_delegate]; - // Decrease old delegate's delegated amount - del.delegatedAmount = newStake; - - checkpointBondingState(_delegate, del, t); + delegators[_delegate].delegatedAmount = newStake; } /** @@ -1385,7 +1393,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 @@ -1421,11 +1430,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 udpates 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 @@ -1484,8 +1496,6 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { // Rewards are bonded by default del.bondedAmount = currentBondedAmount; del.fees = currentFees; - - checkpointBondingState(_delegator, del, transcoders[_delegator]); } /** @@ -1501,7 +1511,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]; @@ -1511,8 +1521,6 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { // Increase delegator's bonded amount del.bondedAmount = del.bondedAmount.add(amount); - checkpointBondingState(_delegator, del, transcoders[_delegator]); - // Delete lock delete del.unbondingLocks[_unbondingLockId]; diff --git a/test/unit/BondingManager.js b/test/unit/BondingManager.js index 662021a6..73eade4b 100644 --- a/test/unit/BondingManager.js +++ b/test/unit/BondingManager.js @@ -1860,23 +1860,22 @@ describe("BondingManager", () => { await expectCheckpoints( fixture, tx, - {account: delegator.address, bondedAmount: 0}, // there's always a checkpoint before the update { - account: delegator.address, + account: transcoder0.address, startRound: currentRound + 1, bondedAmount: 1000, delegateAddress: transcoder0.address, - delegatedAmount: 0, - lastClaimRound: currentRound, + delegatedAmount: 2000, + lastClaimRound: currentRound - 1, lastRewardRound: 0 }, { - account: transcoder0.address, + account: delegator.address, startRound: currentRound + 1, bondedAmount: 1000, delegateAddress: transcoder0.address, - delegatedAmount: 2000, - lastClaimRound: currentRound - 1, + delegatedAmount: 0, + lastClaimRound: currentRound, lastRewardRound: 0 } ) @@ -2318,8 +2317,13 @@ describe("BondingManager", () => { fixture, tx, { - account: delegator1.address, - bondedAmount: startBondedAmount + account: transcoder0.address, + startRound: currentRound + 1, + bondedAmount: selfBondedAmount, + delegateAddress: transcoder0.address, + delegatedAmount: startDelegatedAmount.add(1000), + lastClaimRound: currentRound - 1, + lastRewardRound: 0 }, { account: delegator1.address, @@ -2329,15 +2333,6 @@ describe("BondingManager", () => { delegatedAmount: 0, lastClaimRound: currentRound, lastRewardRound: 0 - }, - { - account: transcoder0.address, - startRound: currentRound + 1, - bondedAmount: selfBondedAmount, - delegateAddress: transcoder0.address, - delegatedAmount: startDelegatedAmount.add(1000), - lastClaimRound: currentRound - 1, - lastRewardRound: 0 } ) }) @@ -2507,8 +2502,13 @@ describe("BondingManager", () => { fixture, tx, { - account: delegator.address, - bondedAmount: 1000 + account: transcoder.address, + startRound: currentRound + 2, + bondedAmount: 1000, + delegateAddress: transcoder.address, + delegatedAmount: 1500, + lastClaimRound: currentRound, + lastRewardRound: 0 }, { account: delegator.address, @@ -2518,15 +2518,6 @@ describe("BondingManager", () => { delegatedAmount: 1000, // delegator2 delegates to delegator lastClaimRound: currentRound + 1, // gets updated on unbond lastRewardRound: 0 - }, - { - account: transcoder.address, - startRound: currentRound + 2, - bondedAmount: 1000, - delegateAddress: transcoder.address, - delegatedAmount: 1500, - lastClaimRound: currentRound, - lastRewardRound: 0 } ) }) @@ -3149,23 +3140,22 @@ describe("BondingManager", () => { await expectCheckpoints( fixture, tx, - // no checkpoint of current state here since earnings are already claimed in round on the unbond call { - account: delegator.address, + account: transcoder.address, startRound: currentRound + 2, bondedAmount: 1000, delegateAddress: transcoder.address, - delegatedAmount: 0, - lastClaimRound: currentRound + 1, + delegatedAmount: 2000, + lastClaimRound: currentRound, lastRewardRound: 0 }, { - account: transcoder.address, + account: delegator.address, startRound: currentRound + 2, bondedAmount: 1000, delegateAddress: transcoder.address, - delegatedAmount: 2000, - lastClaimRound: currentRound, + delegatedAmount: 0, + lastClaimRound: currentRound + 1, lastRewardRound: 0 } ) @@ -3427,21 +3417,21 @@ describe("BondingManager", () => { fixture, tx, { - account: delegator.address, + account: transcoder.address, startRound: currentRound + 2, - bondedAmount: 500, + bondedAmount: 1000, delegateAddress: transcoder.address, - delegatedAmount: 0, - lastClaimRound: currentRound + 1, + delegatedAmount: 1500, + lastClaimRound: currentRound, lastRewardRound: 0 }, { - account: transcoder.address, + account: delegator.address, startRound: currentRound + 2, - bondedAmount: 1000, + bondedAmount: 500, delegateAddress: transcoder.address, - delegatedAmount: 1500, - lastClaimRound: currentRound, + delegatedAmount: 0, + lastClaimRound: currentRound + 1, lastRewardRound: 0 } ) @@ -3950,8 +3940,13 @@ describe("BondingManager", () => { fixture, tx, { - account: delegator1.address, - bondedAmount: 2000 + account: transcoder0.address, + startRound: currentRound + 4, + bondedAmount: 1000, + delegateAddress: transcoder0.address, + delegatedAmount: 1200, + lastClaimRound: currentRound - 1, + lastRewardRound: 0 }, { account: delegator1.address, @@ -3963,18 +3958,14 @@ describe("BondingManager", () => { lastRewardRound: 0 }, { - account: transcoder0.address, + account: transcoder1.address, startRound: currentRound + 4, - bondedAmount: 1000, - delegateAddress: transcoder0.address, - delegatedAmount: 1200, + bondedAmount: 2000, + delegateAddress: transcoder1.address, + delegatedAmount: 5800, lastClaimRound: currentRound - 1, lastRewardRound: 0 }, - { - account: delegator2.address, - bondedAmount: 2000 - }, { account: delegator2.address, startRound: currentRound + 4, @@ -3983,15 +3974,6 @@ describe("BondingManager", () => { 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 } ) }) @@ -4500,25 +4482,15 @@ 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, - delegatedAmount: 2000, // the first checkpoint happens when we bump the delegatedAmount value - lastRewardRound: 0 - }, - { - account: transcoder.address, - startRound: currentRound + 2, - bondedAmount: 1000, - delegateAddress: transcoder.address, - delegatedAmount: 2000, - lastClaimRound: currentRound, - lastRewardRound: currentRound + 1 // then it's made again when the lastRewardRound is bumped - } - ) + await expectCheckpoints(fixture, tx, { + account: transcoder.address, + startRound: currentRound + 2, + bondedAmount: 1000, + delegateAddress: transcoder.address, + delegatedAmount: 2000, + lastClaimRound: currentRound, + lastRewardRound: currentRound + 1 // then it's made again when the lastRewardRound is bumped + }) }) it("should update caller with rewards if lastActiveStakeUpdateRound < currentRound", async () => { @@ -5167,25 +5139,15 @@ describe("BondingManager", () => { ) ) - await expectCheckpoints( - fixture, - tx, - // current state is checkpointed first - { - account: transcoder.address, - bondedAmount: startBondedAmount, - delegatedAmount: startBondedAmount - }, - { - account: transcoder.address, - startRound: currentRound + 2, - bondedAmount: startBondedAmount / 2, - delegateAddress: transcoder.address, - delegatedAmount: startBondedAmount / 2, - lastClaimRound: currentRound + 1, - lastRewardRound: 0 - } - ) + await expectCheckpoints(fixture, tx, { + account: transcoder.address, + startRound: currentRound + 2, + bondedAmount: startBondedAmount / 2, + delegateAddress: transcoder.address, + delegatedAmount: startBondedAmount / 2, + lastClaimRound: currentRound + 1, + lastRewardRound: 0 + }) }) describe("transcoder is bonded", () => { @@ -6877,7 +6839,7 @@ describe("BondingManager", () => { await expect(tx) .to.emit( - fixture.bondingCheckpoints, + fixture.BondingCheckpoints, "CheckpointTotalActiveStake" ) .withArgs(1000, currentRound) From d518cd6058e87c7bc10c8ecfc05696e8c30655e8 Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Sun, 13 Aug 2023 19:51:14 -0300 Subject: [PATCH 18/29] bonding: Make bonding checkpoints implement IVotes --- contracts/bonding/BondingManager.sol | 14 +- ...ondingCheckpoints.sol => BondingVotes.sol} | 151 ++++- ...ndingCheckpoints.sol => IBondingVotes.sol} | 28 +- .../test/mocks/BondingVotesERC5805Harness.sol | 34 + ...eckpointsMock.sol => BondingVotesMock.sol} | 2 +- contracts/treasury/IVotes.sol | 14 + deploy/deploy_contracts.ts | 4 +- ...ol => BondingVotesStateInitialization.sol} | 135 ++-- test/gas-report/checkpoints.js | 12 +- ...{BondingCheckpoints.js => BondingVotes.js} | 49 +- test/unit/BondingManager.js | 15 +- ...{BondingCheckpoints.js => BondingVotes.js} | 617 ++++++++++++++---- test/unit/helpers/Fixture.js | 10 +- test/unit/helpers/expectCheckpoints.ts | 5 +- 14 files changed, 786 insertions(+), 304 deletions(-) rename contracts/bonding/{BondingCheckpoints.sol => BondingVotes.sol} (73%) rename contracts/bonding/{IBondingCheckpoints.sol => IBondingVotes.sol} (69%) create mode 100644 contracts/test/mocks/BondingVotesERC5805Harness.sol rename contracts/test/mocks/{BondingCheckpointsMock.sol => BondingVotesMock.sol} (95%) create mode 100644 contracts/treasury/IVotes.sol rename src/test/{BondingCheckpointsStateInitialization.sol => BondingVotesStateInitialization.sol} (65%) rename test/integration/{BondingCheckpoints.js => BondingVotes.js} (96%) rename test/unit/{BondingCheckpoints.js => BondingVotes.js} (59%) diff --git a/contracts/bonding/BondingManager.sol b/contracts/bonding/BondingManager.sol index 08f8221a..e8926998 100644 --- a/contracts/bonding/BondingManager.sol +++ b/contracts/bonding/BondingManager.sol @@ -12,7 +12,7 @@ import "../token/ILivepeerToken.sol"; import "../token/IMinter.sol"; import "../rounds/IRoundsManager.sol"; import "../snapshots/IMerkleSnapshot.sol"; -import "./IBondingCheckpoints.sol"; +import "./IBondingVotes.sol"; import "@openzeppelin/contracts/utils/math/SafeMath.sol"; @@ -419,7 +419,7 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { function setCurrentRoundTotalActiveStake() external onlyRoundsManager { currentRoundTotalActiveStake = nextRoundTotalActiveStake; - bondingCheckpoints().checkpointTotalActiveStake(currentRoundTotalActiveStake, roundsManager().currentRound()); + bondingVotes().checkpointTotalActiveStake(currentRoundTotalActiveStake, roundsManager().currentRound()); } /** @@ -580,7 +580,7 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { // 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; - bondingCheckpoints().checkpointBondingState( + bondingVotes().checkpointBondingState( _owner, startRound, _delegator.bondedAmount, @@ -594,7 +594,7 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { /** * @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. - * Implemented as a deploy utility to checkpoint the existing state when deploying the BondingCheckpoints contract. + * Implemented as a deploy utility to checkpoint the existing state when deploying the BondingVotes contract. * @param _account The account to initialize the bonding checkpoint for */ function checkpointBondingState(address _account) public { @@ -1175,7 +1175,7 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { * @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. Normally this is the current round as historical - * lookup is only supported through BondingCheckpoints + * 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 @@ -1561,8 +1561,8 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { return IRoundsManager(controller.getContract(keccak256("RoundsManager"))); } - function bondingCheckpoints() internal view returns (IBondingCheckpoints) { - return IBondingCheckpoints(controller.getContract(keccak256("BondingCheckpoints"))); + function bondingVotes() internal view returns (IBondingVotes) { + return IBondingVotes(controller.getContract(keccak256("BondingVotes"))); } function _onlyTicketBroker() internal view { diff --git a/contracts/bonding/BondingCheckpoints.sol b/contracts/bonding/BondingVotes.sol similarity index 73% rename from contracts/bonding/BondingCheckpoints.sol rename to contracts/bonding/BondingVotes.sol index 39100ba1..2d30b092 100644 --- a/contracts/bonding/BondingCheckpoints.sol +++ b/contracts/bonding/BondingVotes.sol @@ -14,10 +14,10 @@ import "../rounds/IRoundsManager.sol"; import "./BondingManager.sol"; /** - * @title BondingCheckpoints + * @title BondingVotes * @dev Checkpointing logic for BondingManager state for historical stake calculations. */ -contract BondingCheckpoints is ManagerProxyTarget, IBondingCheckpoints { +contract BondingVotes is ManagerProxyTarget, IBondingVotes { using SortedArrays for uint256[]; constructor(address _controller) Manager(_controller) {} @@ -61,22 +61,27 @@ contract BondingCheckpoints is ManagerProxyTarget, IBondingCheckpoints { } /** - * @dev Checkpoints by account (delegators and transcoders). + * @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}). */ - mapping(address => BondingCheckpointsByRound) private bondingCheckpoints; + struct TotalActiveStakeByRound { + uint256[] rounds; + mapping(uint256 => uint256) data; + } /** - * @dev Rounds in which we have checkpoints for the total active stake. This and {totalActiveStakeCheckpoints} are - * handled in the same wat that {BondingCheckpointsByRound}, with rounds stored and queried on this array and - * checkpointed value stored and retrieved from the mapping. + * @dev Checkpoints by account (delegators and transcoders). */ - uint256[] totalStakeCheckpointRounds; + mapping(address => BondingCheckpointsByRound) private bondingCheckpoints; /** - * @dev See {totalStakeCheckpointRounds} above. + * @dev Total active stake checkpoints. */ - mapping(uint256 => uint256) private totalActiveStakeCheckpoints; + TotalActiveStakeByRound private totalStakeCheckpoints; - // IERC6372 interface implementation + // IVotes interface implementation. + // These should not access any storage directly but proxy to the bonding state functions. /** * @notice Clock is set to match the current round, which is the checkpointing @@ -94,6 +99,72 @@ contract BondingCheckpoints is ManagerProxyTarget, IBondingCheckpoints { return "mode=livepeer_round"; } + /** + * @notice Returns the current amount of votes that `_account` has. + */ + function getVotes(address _account) external view returns (uint256) { + return getPastVotes(_account, clock()); + } + + /** + * @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. + */ + function getPastVotes(address _account, uint256 _round) public view returns (uint256) { + (uint256 amount, ) = getBondingStateAt(_account, _round); + return amount; + } + + /** + * @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. + */ + function getPastTotalSupply(uint256 _round) external view returns (uint256) { + return getTotalActiveStakeAt(_round); + } + + /** + * @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). + */ + function delegates(address _account) external view returns (address) { + return delegatedAt(_account, clock()); + } + + /** + * @notice Returns the delegate that _account had chosen in a specific round in the past. See `delegates()` above + * for more details. + * @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 {GovernorVotesBondingVotes-_handleVoteOverrides}. + */ + function delegatedAt(address _account, uint256 _round) public view returns (address) { + (, address delegateAddress) = getBondingStateAt(_account, _round); + 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 /** @@ -117,25 +188,60 @@ contract BondingCheckpoints is ManagerProxyTarget, IBondingCheckpoints { uint256 _lastClaimRound, uint256 _lastRewardRound ) public virtual onlyBondingManager { - if (_startRound > clock() + 1) { - revert FutureCheckpoint(_startRound, clock() + 1); + if (_startRound != clock() + 1) { + revert InvalidCheckpoint(_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]; - checkpoints.data[_startRound] = BondingCheckpoint({ + 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); + + onCheckpointChanged(_account, previous, bond); + } + + function onCheckpointChanged( + 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; + if (isTranscoder) { + emit DelegateVotesChanged(_account, previous.delegatedAmount, current.delegatedAmount); + } else if (wasTranscoder) { + // if the account stopped being a transcoder, we want to emit an event zeroing its "delegate votes" + emit DelegateVotesChanged(_account, previous.delegatedAmount, 0); + } + + // 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 DelegatorVotesChanged(_account, previous.bondedAmount, current.bondedAmount); + } } /** @@ -143,7 +249,7 @@ contract BondingCheckpoints is ManagerProxyTarget, IBondingCheckpoints { * @dev This is meant to be called by a checkpoint initialization script once we deploy the checkpointing logic for * the first time, so we can efficiently initialize the checkpoint state for all accounts in the system. */ - function hasCheckpoint(address _account) external view returns (bool) { + function hasCheckpoint(address _account) public view returns (bool) { return bondingCheckpoints[_account].startRounds.length > 0; } @@ -156,12 +262,11 @@ contract BondingCheckpoints is ManagerProxyTarget, IBondingCheckpoints { */ function checkpointTotalActiveStake(uint256 _totalStake, uint256 _round) public virtual onlyBondingManager { if (_round > clock()) { - revert FutureCheckpoint(_round, clock()); + revert InvalidCheckpoint(_round, clock()); } - totalActiveStakeCheckpoints[_round] = _totalStake; - - totalStakeCheckpointRounds.pushSorted(_round); + totalStakeCheckpoints.data[_round] = _totalStake; + totalStakeCheckpoints.rounds.pushSorted(_round); } // Historical stake access functions @@ -175,10 +280,10 @@ contract BondingCheckpoints is ManagerProxyTarget, IBondingCheckpoints { revert FutureLookup(_round, clock()); } - uint256 activeStake = totalActiveStakeCheckpoints[_round]; + uint256 activeStake = totalStakeCheckpoints.data[_round]; if (activeStake == 0) { - uint256 lastInitialized = checkedFindLowerBound(totalStakeCheckpointRounds, _round); + uint256 lastInitialized = checkedFindLowerBound(totalStakeCheckpoints.rounds, _round); // Check that the round was in fact initialized so we don't return a 0 value accidentally. if (lastInitialized != _round) { @@ -235,8 +340,8 @@ contract BondingCheckpoints is ManagerProxyTarget, IBondingCheckpoints { view returns (BondingCheckpoint storage) { - if (_round > clock()) { - revert FutureLookup(_round, clock()); + if (_round > clock() + 1) { + revert FutureLookup(_round, clock() + 1); } BondingCheckpointsByRound storage checkpoints = bondingCheckpoints[_account]; diff --git a/contracts/bonding/IBondingCheckpoints.sol b/contracts/bonding/IBondingVotes.sol similarity index 69% rename from contracts/bonding/IBondingCheckpoints.sol rename to contracts/bonding/IBondingVotes.sol index 0b0b8d88..984aa6ce 100644 --- a/contracts/bonding/IBondingCheckpoints.sol +++ b/contracts/bonding/IBondingVotes.sol @@ -3,16 +3,28 @@ pragma solidity ^0.8.9; import "@openzeppelin/contracts-upgradeable/interfaces/IERC6372Upgradeable.sol"; +import "../treasury/IVotes.sol"; + /** - * @title Interface for BondingCheckpoints + * @title Interface for BondingVotes */ -interface IBondingCheckpoints is IERC6372Upgradeable { - // BondingManager hooks - +interface IBondingVotes is IERC6372Upgradeable, IVotes { error InvalidCaller(address caller, address required); - error FutureCheckpoint(uint256 checkpointRound, uint256 maxAllowed); + error InvalidCheckpoint(uint256 checkpointRound, uint256 requiredRound); error FutureLastClaimRound(uint256 lastClaimRound, uint256 maxAllowed); + error FutureLookup(uint256 queryRound, uint256 maxAllowed); + error MissingRoundCheckpoint(uint256 round); + error NoRecordedCheckpoints(); + error PastLookup(uint256 queryRound, uint256 firstCheckpointRound); + 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); + + // BondingManager hooks + function checkpointBondingState( address _account, uint256 _startRound, @@ -27,12 +39,6 @@ interface IBondingCheckpoints is IERC6372Upgradeable { // Historical stake access functions - error FutureLookup(uint256 queryRound, uint256 currentRound); - error MissingRoundCheckpoint(uint256 round); - error NoRecordedCheckpoints(); - error PastLookup(uint256 queryRound, uint256 firstCheckpointRound); - error MissingEarningsPool(address transcoder, uint256 round); - function hasCheckpoint(address _account) external view returns (bool); function getTotalActiveStakeAt(uint256 _round) external view returns (uint256); 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/BondingCheckpointsMock.sol b/contracts/test/mocks/BondingVotesMock.sol similarity index 95% rename from contracts/test/mocks/BondingCheckpointsMock.sol rename to contracts/test/mocks/BondingVotesMock.sol index c95ce040..ffb090c7 100644 --- a/contracts/test/mocks/BondingCheckpointsMock.sol +++ b/contracts/test/mocks/BondingVotesMock.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.9; import "./GenericMock.sol"; -contract BondingCheckpointsMock is GenericMock { +contract BondingVotesMock is GenericMock { event CheckpointBondingState( address account, uint256 startRound, diff --git a/contracts/treasury/IVotes.sol b/contracts/treasury/IVotes.sol new file mode 100644 index 00000000..bfe4f41c --- /dev/null +++ b/contracts/treasury/IVotes.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "@openzeppelin/contracts-upgradeable/interfaces/IERC5805Upgradeable.sol"; + +interface IVotes is IERC5805Upgradeable { + /** + * @dev Emitted when bonding change results in changes to a delegator's number of votes. This complements the events + * from IERC5805 by also supporting voting power for the delegators themselves. + */ + event DelegatorVotesChanged(address indexed delegate, uint256 previousBalance, uint256 newBalance); + + function delegatedAt(address account, uint256 timepoint) external returns (address); +} diff --git a/deploy/deploy_contracts.ts b/deploy/deploy_contracts.ts index 58477f69..8212562c 100644 --- a/deploy/deploy_contracts.ts +++ b/deploy/deploy_contracts.ts @@ -144,8 +144,8 @@ const func: DeployFunction = async function(hre: HardhatRuntimeEnvironment) { }) await contractDeployer.deployAndRegister({ - contract: "BondingCheckpoints", - name: "BondingCheckpoints", + contract: "BondingVotes", + name: "BondingVotes", proxy: true, args: [Controller.address] }) diff --git a/src/test/BondingCheckpointsStateInitialization.sol b/src/test/BondingVotesStateInitialization.sol similarity index 65% rename from src/test/BondingCheckpointsStateInitialization.sol rename to src/test/BondingVotesStateInitialization.sol index 2eb6eea7..14ab125a 100644 --- a/src/test/BondingCheckpointsStateInitialization.sol +++ b/src/test/BondingVotesStateInitialization.sol @@ -4,44 +4,46 @@ import "ds-test/test.sol"; import "./base/GovernorBaseTest.sol"; import "contracts/ManagerProxy.sol"; import "contracts/bonding/BondingManager.sol"; -import "contracts/bonding/BondingCheckpoints.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 BondingCheckpointsStateInitialization --fork-url https://arbitrum-mainnet.infura.io/v3/$INFURA_KEY -vvv --fork-block-number 110930219 -contract BondingCheckpointsStateInitialization is GovernorBaseTest { +// 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_CHECKPOINTS_ID = keccak256("BondingCheckpoints"); - bytes32 public constant BONDING_CHECKPOINTS_TARGET_ID = keccak256("BondingCheckpointsTarget"); + 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; - // Is a transcoder as of fork block + // 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; - BondingCheckpoints public bondingCheckpointsTarget; - IBondingCheckpoints public bondingCheckpoints; + BondingVotes public bondingVotesTarget; + IBondingVotes public bondingVotes; function setUp() public { nonParticipant = CHEATS.addr(1); - _testAddresses = [DELEGATOR, TRANSCODER, nonParticipant]; + _testAddresses = [DELEGATOR_DELEGATE, DELEGATOR, TRANSCODER, nonParticipant]; newBondingManagerTarget = new BondingManager(address(CONTROLLER)); - bondingCheckpointsTarget = new BondingCheckpoints(address(CONTROLLER)); + bondingVotesTarget = new BondingVotes(address(CONTROLLER)); - ManagerProxy bondingCheckpointsProxy = new ManagerProxy(address(CONTROLLER), BONDING_CHECKPOINTS_TARGET_ID); - bondingCheckpoints = IBondingCheckpoints(address(bondingCheckpointsProxy)); + ManagerProxy bondingVotesProxy = new ManagerProxy(address(CONTROLLER), BONDING_VOTES_TARGET_ID); + bondingVotes = IBondingVotes(address(bondingVotesProxy)); (, gitCommitHash) = CONTROLLER.getContractInfo(BONDING_MANAGER_TARGET_ID); @@ -50,8 +52,8 @@ contract BondingCheckpointsStateInitialization is GovernorBaseTest { 0, abi.encodeWithSelector( CONTROLLER.setContractInfo.selector, - BONDING_CHECKPOINTS_TARGET_ID, - address(bondingCheckpointsTarget), + BONDING_VOTES_TARGET_ID, + address(bondingVotesTarget), gitCommitHash ) ); @@ -60,8 +62,8 @@ contract BondingCheckpointsStateInitialization is GovernorBaseTest { 0, abi.encodeWithSelector( CONTROLLER.setContractInfo.selector, - BONDING_CHECKPOINTS_ID, - address(bondingCheckpoints), + BONDING_VOTES_ID, + address(bondingVotes), gitCommitHash ) ); @@ -85,20 +87,20 @@ contract BondingCheckpointsStateInitialization is GovernorBaseTest { assertEq(infoAddr, address(newBondingManagerTarget)); assertEq(infoGitCommitHash, gitCommitHash); - (infoAddr, infoGitCommitHash) = fetchContractInfo(BONDING_CHECKPOINTS_TARGET_ID); - assertEq(infoAddr, address(bondingCheckpointsTarget)); + (infoAddr, infoGitCommitHash) = fetchContractInfo(BONDING_VOTES_TARGET_ID); + assertEq(infoAddr, address(bondingVotesTarget)); assertEq(infoGitCommitHash, gitCommitHash); - (infoAddr, infoGitCommitHash) = fetchContractInfo(BONDING_CHECKPOINTS_ID); - assertEq(infoAddr, address(bondingCheckpoints)); + (infoAddr, infoGitCommitHash) = fetchContractInfo(BONDING_VOTES_ID); + assertEq(infoAddr, address(bondingVotes)); assertEq(infoGitCommitHash, gitCommitHash); } function testNoAddressHasCheckpoints() public { - assertEq(_testAddresses.length, 3); + assertEq(_testAddresses.length, 4); for (uint256 i = 0; i < _testAddresses.length; i++) { - assertTrue(!bondingCheckpoints.hasCheckpoint(_testAddresses[i])); + assertTrue(!bondingVotes.hasCheckpoint(_testAddresses[i])); } } @@ -107,18 +109,15 @@ contract BondingCheckpointsStateInitialization is GovernorBaseTest { address[2] memory testAddresses = [DELEGATOR, TRANSCODER]; for (uint256 i = 0; i < testAddresses.length; i++) { - CHEATS.expectRevert(IBondingCheckpoints.NoRecordedCheckpoints.selector); - bondingCheckpoints.getBondingStateAt(testAddresses[i], currentRound); + CHEATS.expectRevert(IBondingVotes.NoRecordedCheckpoints.selector); + bondingVotes.getBondingStateAt(testAddresses[i], currentRound); } } function testAllowsQueryingNonParticipantZeroedAccount() public { uint256 currentRound = ROUNDS_MANAGER.currentRound(); - (uint256 checkedAmount, address checkedDelegate) = bondingCheckpoints.getBondingStateAt( - nonParticipant, - currentRound - ); + (uint256 checkedAmount, address checkedDelegate) = bondingVotes.getBondingStateAt(nonParticipant, currentRound); assertEq(checkedAmount, 0); assertEq(checkedDelegate, address(0)); } @@ -147,17 +146,10 @@ contract BondingCheckpointsStateInitialization is GovernorBaseTest { ); assertTrue(lastClaimRound == currentRound && bondedAmount == 0 && delegatedAmount == 0); - CHEATS.expectRevert(IBondingCheckpoints.NoRecordedCheckpoints.selector); - bondingCheckpoints.getBondingStateAt(DELEGATOR, currentRound); - - uint256 nextRoundStartBlock = ROUNDS_MANAGER.currentRoundStartBlock() + ROUNDS_MANAGER.roundLength(); - CHEATS.roll(nextRoundStartBlock); - assertEq(ROUNDS_MANAGER.currentRound(), currentRound + 1); + CHEATS.expectRevert(IBondingVotes.NoRecordedCheckpoints.selector); + bondingVotes.getBondingStateAt(DELEGATOR, currentRound); - (uint256 checkedAmount, address checkedDelegate) = bondingCheckpoints.getBondingStateAt( - DELEGATOR, - currentRound + 1 - ); + (uint256 checkedAmount, address checkedDelegate) = bondingVotes.getBondingStateAt(DELEGATOR, currentRound + 1); assertEq(checkedAmount, 0); assertEq(checkedDelegate, address(0)); } @@ -169,18 +161,29 @@ contract BondingCheckpointsStateInitialization is GovernorBaseTest { address addr = _testAddresses[i]; BONDING_MANAGER.checkpointBondingState(addr); - assertTrue(bondingCheckpoints.hasCheckpoint(addr)); + assertTrue(bondingVotes.hasCheckpoint(addr)); - // Still doesn't allow lookup in the current round, that comes next. + // Still doesn't allow lookup in the current round, checkpoint is made for the next CHEATS.expectRevert( - abi.encodeWithSelector(IBondingCheckpoints.PastLookup.selector, currentRound, currentRound + 1) + abi.encodeWithSelector(IBondingVotes.PastLookup.selector, currentRound, currentRound + 1) + ); + bondingVotes.getBondingStateAt(addr, currentRound); + + // Allows querying up to the next round. + // We don't check delegatedAmount for simplicity here, it is checked in he other tests. + (, address checkedDelegate) = bondingVotes.getBondingStateAt(addr, currentRound + 1); + assertEq( + checkedDelegate, + addr == DELEGATOR || addr == DELEGATOR_DELEGATE ? DELEGATOR_DELEGATE : addr == TRANSCODER + ? TRANSCODER + : address(0) ); - bondingCheckpoints.getBondingStateAt(addr, currentRound); + // Disallows querying further than the next round though CHEATS.expectRevert( - abi.encodeWithSelector(IBondingCheckpoints.FutureLookup.selector, currentRound + 1, currentRound) + abi.encodeWithSelector(IBondingVotes.FutureLookup.selector, currentRound + 2, currentRound + 1) ); - bondingCheckpoints.getBondingStateAt(addr, currentRound + 1); + bondingVotes.getBondingStateAt(addr, currentRound + 2); } } @@ -190,47 +193,33 @@ contract BondingCheckpointsStateInitialization is GovernorBaseTest { BONDING_MANAGER.checkpointBondingState(TRANSCODER); - // Need to wait 1 round before we can query for the checkpointed state - uint256 nextRoundStartBlock = ROUNDS_MANAGER.currentRoundStartBlock() + ROUNDS_MANAGER.roundLength(); - CHEATS.roll(nextRoundStartBlock); - assertEq(ROUNDS_MANAGER.currentRound(), currentRound + 1); - - (uint256 checkedAmount, address checkedDelegate) = bondingCheckpoints.getBondingStateAt( - TRANSCODER, - currentRound + 1 - ); + (uint256 checkedAmount, address checkedDelegate) = bondingVotes.getBondingStateAt(TRANSCODER, currentRound + 1); assertEq(checkedAmount, delegatedAmount); assertEq(checkedDelegate, TRANSCODER); } function testAllowsQueryingDelegatorStateOnNextRound() public { - uint256 currentRound = ROUNDS_MANAGER.currentRound(); (, , 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(delegateAddress); - - // Need to wait 1 round before we can query for the checkpointed state - uint256 nextRoundStartBlock = ROUNDS_MANAGER.currentRoundStartBlock() + ROUNDS_MANAGER.roundLength(); - CHEATS.roll(nextRoundStartBlock); - assertEq(ROUNDS_MANAGER.currentRound(), currentRound + 1); + BONDING_MANAGER.checkpointBondingState(DELEGATOR_DELEGATE); - (uint256 checkedAmount, address checkedDelegate) = bondingCheckpoints.getBondingStateAt( - DELEGATOR, - currentRound + 1 - ); + (uint256 checkedAmount, address checkedDelegate) = bondingVotes.getBondingStateAt(DELEGATOR, currentRound + 1); assertEq(checkedAmount, pendingStake); - assertEq(checkedDelegate, delegateAddress); + assertEq(checkedDelegate, DELEGATOR_DELEGATE); } function testDoesNotHaveTotalActiveStakeImmediately() public { uint256 currentRound = ROUNDS_MANAGER.currentRound(); - CHEATS.expectRevert(IBondingCheckpoints.NoRecordedCheckpoints.selector); - bondingCheckpoints.getTotalActiveStakeAt(currentRound); + CHEATS.expectRevert(IBondingVotes.NoRecordedCheckpoints.selector); + bondingVotes.getTotalActiveStakeAt(currentRound); } function testDoesNotHaveTotalActiveStakeIfRoundNotInitialized() public { @@ -240,8 +229,8 @@ contract BondingCheckpointsStateInitialization is GovernorBaseTest { CHEATS.roll(nextRoundStartBlock); assertEq(ROUNDS_MANAGER.currentRound(), currentRound + 1); - CHEATS.expectRevert(IBondingCheckpoints.NoRecordedCheckpoints.selector); - bondingCheckpoints.getTotalActiveStakeAt(currentRound + 1); + CHEATS.expectRevert(IBondingVotes.NoRecordedCheckpoints.selector); + bondingVotes.getTotalActiveStakeAt(currentRound + 1); } function testDoesNotUsePastCheckpointForTotalActiveStake() public { @@ -255,10 +244,8 @@ contract BondingCheckpointsStateInitialization is GovernorBaseTest { CHEATS.roll(nextRoundStartBlock); assertEq(ROUNDS_MANAGER.currentRound(), currentRound + 2); - CHEATS.expectRevert( - abi.encodeWithSelector(IBondingCheckpoints.MissingRoundCheckpoint.selector, currentRound + 2) - ); - bondingCheckpoints.getTotalActiveStakeAt(currentRound + 2); + CHEATS.expectRevert(abi.encodeWithSelector(IBondingVotes.MissingRoundCheckpoint.selector, currentRound + 2)); + bondingVotes.getTotalActiveStakeAt(currentRound + 2); } function testCheckpointsTotalActiveStakeOnInitializeRound() public { @@ -271,7 +258,7 @@ contract BondingCheckpointsStateInitialization is GovernorBaseTest { uint256 totalBonded = BONDING_MANAGER.getTotalBonded(); - uint256 totalAcctiveStakeChk = bondingCheckpoints.getTotalActiveStakeAt(currentRound + 1); + uint256 totalAcctiveStakeChk = bondingVotes.getTotalActiveStakeAt(currentRound + 1); assertEq(totalAcctiveStakeChk, totalBonded); } } diff --git a/test/gas-report/checkpoints.js b/test/gas-report/checkpoints.js index 3a9302af..17890a59 100644 --- a/test/gas-report/checkpoints.js +++ b/test/gas-report/checkpoints.js @@ -69,16 +69,16 @@ describe("checkpoint bonding state gas report", () => { await roundsManager.mineBlocks(roundLength.toNumber()) await roundsManager.initializeRound() - // Deploy a new BondingCheckpoints contract so we can simulate a fresh deploy on existing BondingManager state + // Deploy a new BondingVotes contract so we can simulate a fresh deploy on existing BondingManager state const [, gitCommitHash] = await controller.getContractInfo( - contractId("BondingCheckpoints") + contractId("BondingVotes") ) - const newBondingCheckpoints = await ethers - .getContractFactory("BondingCheckpoints") + const newBondingVotes = await ethers + .getContractFactory("BondingVotes") .then(fac => fac.deploy(controller.address)) await controller.setContractInfo( - contractId("BondingCheckpoints"), - newBondingCheckpoints.address, + contractId("BondingVotes"), + newBondingVotes.address, gitCommitHash ) }) diff --git a/test/integration/BondingCheckpoints.js b/test/integration/BondingVotes.js similarity index 96% rename from test/integration/BondingCheckpoints.js rename to test/integration/BondingVotes.js index 2cc4545b..83710600 100644 --- a/test/integration/BondingCheckpoints.js +++ b/test/integration/BondingVotes.js @@ -11,11 +11,11 @@ import math from "../helpers/math" chai.use(solidity) const {expect} = chai -describe("BondingCheckpoints", () => { +describe("BondingVotes", () => { let rpc let signers - let bondingCheckpoints + let bondingVotes let bondingManager let roundsManager let roundLength @@ -38,9 +38,9 @@ describe("BondingCheckpoints", () => { fixture.BondingManager.address ) - bondingCheckpoints = await ethers.getContractAt( - "BondingCheckpoints", - fixture.BondingCheckpoints.address + bondingVotes = await ethers.getContractAt( + "BondingVotes", + fixture.BondingVotes.address ) token = await ethers.getContractAt( @@ -156,7 +156,7 @@ describe("BondingCheckpoints", () => { ) const stakeAt = round => - bondingCheckpoints + bondingVotes .getBondingStateAt(delegator.address, round) .then(n => n[0].toString()) @@ -175,7 +175,7 @@ describe("BondingCheckpoints", () => { it("should return partial rewards for all transcoder stake", async () => { const stakeAt = round => - bondingCheckpoints + bondingVotes .getBondingStateAt(transcoder.address, round) .then(n => n[0].toString()) @@ -198,7 +198,7 @@ describe("BondingCheckpoints", () => { describe("getTotalActiveStakeAt", () => { const totalStakeAt = round => - bondingCheckpoints + bondingVotes .getTotalActiveStakeAt(round) .then(n => n.toString()) @@ -324,7 +324,7 @@ describe("BondingCheckpoints", () => { const delegator = delegators[delegators.length - 1].address const testHasStake = async (address, round) => { - const [stake] = await bondingCheckpoints.getBondingStateAt( + const [stake] = await bondingVotes.getBondingStateAt( address, round ) @@ -354,10 +354,7 @@ describe("BondingCheckpoints", () => { const expectedStake = pendingStakes[address] const [stakeCheckpoint] = - await bondingCheckpoints.getBondingStateAt( - address, - round - ) + await bondingVotes.getBondingStateAt(address, round) assert.equal( stakeCheckpoint.toString(), expectedStake, @@ -372,7 +369,7 @@ describe("BondingCheckpoints", () => { 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 bondingCheckpoints.getTotalActiveStakeAt(round) + await bondingVotes.getTotalActiveStakeAt(round) assert.equal( totalStakeCheckpoint.toString(), totalActiveStakeByRound[round], @@ -385,16 +382,16 @@ describe("BondingCheckpoints", () => { for (const r = currentRound - 2; r <= currentRound + 2; r++) { const activeStakeSum = BigNumber.from(0) for (const transcoder of activeTranscoders) { - const [stake] = - await bondingCheckpoints.getBondingStateAt( - transcoder.address, - r - ) + const [stake] = await bondingVotes.getBondingStateAt( + transcoder.address, + r + ) activeStakeSum = activeStakeSum.add(stake) } - const totalStake = - await bondingCheckpoints.getTotalActiveStakeAt(r) + const totalStake = await bondingVotes.getTotalActiveStakeAt( + r + ) assert.equal( totalStake.toString(), activeStakeSum.toString(), @@ -463,7 +460,7 @@ describe("BondingCheckpoints", () => { describe("getBondingStateAt", () => { const stakeAt = (account, round) => - bondingCheckpoints + bondingVotes .getBondingStateAt(account.address, round) .then(n => n[0].toString()) const expectStakeAt = async (account, round, expected) => { @@ -547,7 +544,7 @@ describe("BondingCheckpoints", () => { describe("getTotalActiveStakeAt", () => { const totalStakeAt = round => - bondingCheckpoints + bondingVotes .getTotalActiveStakeAt(round) .then(n => n.toString()) const expectTotalStakeAt = async (round, expected) => { @@ -611,7 +608,7 @@ describe("BondingCheckpoints", () => { }) const expectStakeAt = async (account, round, expected, delegate) => { - const stakeAndAddress = await bondingCheckpoints.getBondingStateAt( + const stakeAndAddress = await bondingVotes.getBondingStateAt( account.address, round ) @@ -630,9 +627,7 @@ describe("BondingCheckpoints", () => { } const totalStakeAt = round => - bondingCheckpoints - .getTotalActiveStakeAt(round) - .then(n => n.toString()) + bondingVotes.getTotalActiveStakeAt(round).then(n => n.toString()) const expectTotalStakeAt = async (round, expected) => { assert.equal( await totalStakeAt(round), diff --git a/test/unit/BondingManager.js b/test/unit/BondingManager.js index 73eade4b..fab57d46 100644 --- a/test/unit/BondingManager.js +++ b/test/unit/BondingManager.js @@ -2850,15 +2850,15 @@ describe("BondingManager", () => { .bond(1000, transcoder.address) }) - it("should fail if BondingCheckpoints is not registered", async () => { - await fixture.register("BondingCheckpoints", ZERO_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 BondingCheckpoints with non-participant zeroed state", async () => { + it("should call BondingVotes with non-participant zeroed state", async () => { const tx = await bondingManager.checkpointBondingState( nonParticipant.address ) @@ -2874,7 +2874,7 @@ describe("BondingManager", () => { }) }) - it("should call BondingCheckpoints with current transcoder state", async () => { + it("should call BondingVotes with current transcoder state", async () => { const tx = await bondingManager.checkpointBondingState( transcoder.address ) @@ -2890,7 +2890,7 @@ describe("BondingManager", () => { }) }) - it("should call BondingCheckpoints with current delegator state", async () => { + it("should call BondingVotes with current delegator state", async () => { const tx = await bondingManager.checkpointBondingState( delegator.address ) @@ -6838,10 +6838,7 @@ describe("BondingManager", () => { ) await expect(tx) - .to.emit( - fixture.BondingCheckpoints, - "CheckpointTotalActiveStake" - ) + .to.emit(fixture.bondingVotes, "CheckpointTotalActiveStake") .withArgs(1000, currentRound) }) }) diff --git a/test/unit/BondingCheckpoints.js b/test/unit/BondingVotes.js similarity index 59% rename from test/unit/BondingCheckpoints.js rename to test/unit/BondingVotes.js index 8f3dbc08..21f3fc68 100644 --- a/test/unit/BondingCheckpoints.js +++ b/test/unit/BondingVotes.js @@ -1,19 +1,20 @@ import Fixture from "./helpers/Fixture" -import {functionSig} from "../../utils/helpers" +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 {constants} from "ethers" +import {BigNumber, constants} from "ethers" chai.use(solidity) const {expect} = chai -describe("BondingCheckpoints", () => { +describe("BondingVotes", () => { let signers let fixture - let bondingCheckpoints + let bondingVotes + let roundsManager const PERC_DIVISOR = 1000000 @@ -24,18 +25,37 @@ describe("BondingCheckpoints", () => { ) } + 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() - const bondingCheckpointsFac = await ethers.getContractFactory( - "BondingCheckpoints" + roundsManager = await ethers.getContractAt( + "RoundsManager", + fixture.roundsManager.address ) - bondingCheckpoints = await fixture.deployAndRegister( - bondingCheckpointsFac, - "BondingCheckpoints", + const BondingVotesFac = await ethers.getContractFactory("BondingVotes") + + bondingVotes = await fixture.deployAndRegister( + BondingVotesFac, + "BondingVotes", fixture.controller.address ) }) @@ -48,35 +68,6 @@ describe("BondingCheckpoints", () => { await fixture.tearDown() }) - describe("IERC6372Upgradeable", () => { - describe("clock", () => { - let currentRound - - beforeEach(async () => { - currentRound = 100 - - await setRound(currentRound) - }) - - it("should return the current round", async () => { - assert.equal(await bondingCheckpoints.clock(), currentRound) - - await setRound(currentRound + 7) - - assert.equal(await bondingCheckpoints.clock(), currentRound + 7) - }) - }) - - describe("CLOCK_MODE", () => { - it("should return mode=livepeer_round", async () => { - assert.equal( - await bondingCheckpoints.CLOCK_MODE(), - "mode=livepeer_round" - ) - }) - }) - }) - const encodeCheckpointBondingState = ({ account, startRound, @@ -86,7 +77,7 @@ describe("BondingCheckpoints", () => { lastClaimRound, lastRewardRound }) => { - return bondingCheckpoints.interface.encodeFunctionData( + return bondingVotes.interface.encodeFunctionData( "checkpointBondingState", [ account, @@ -101,7 +92,7 @@ describe("BondingCheckpoints", () => { } const encodeCheckpointTotalActiveStake = (totalStake, round) => { - return bondingCheckpoints.interface.encodeFunctionData( + return bondingVotes.interface.encodeFunctionData( "checkpointTotalActiveStake", [totalStake, round] ) @@ -124,7 +115,7 @@ describe("BondingCheckpoints", () => { }) it("should fail if BondingManager is not the caller", async () => { - const tx = bondingCheckpoints + const tx = bondingVotes .connect(signers[2]) .checkpointTotalActiveStake(1337, currentRound) await expect(tx).to.be.revertedWith( @@ -140,7 +131,7 @@ describe("BondingCheckpoints", () => { await expect( fixture.bondingManager.execute( - bondingCheckpoints.address, + bondingVotes.address, functionData ) ).to.be.revertedWith( @@ -158,12 +149,12 @@ describe("BondingCheckpoints", () => { ) await fixture.bondingManager.execute( - bondingCheckpoints.address, + bondingVotes.address, functionData ) assert.equal( - await bondingCheckpoints.getTotalActiveStakeAt(currentRound), + await bondingVotes.getTotalActiveStakeAt(currentRound), 1337 ) }) @@ -179,16 +170,14 @@ describe("BondingCheckpoints", () => { }) it("should fail if round is in the future", async () => { - const tx = bondingCheckpoints.getTotalActiveStakeAt( - currentRound + 1 - ) + const tx = bondingVotes.getTotalActiveStakeAt(currentRound + 1) await expect(tx).to.be.revertedWith( `FutureLookup(${currentRound + 1}, ${currentRound})` ) }) it("should fail if round was not checkpointed", async () => { - const tx = bondingCheckpoints.getTotalActiveStakeAt(currentRound) + const tx = bondingVotes.getTotalActiveStakeAt(currentRound) await expect(tx).to.be.revertedWith("NoRecordedCheckpoints()") }) @@ -198,12 +187,12 @@ describe("BondingCheckpoints", () => { currentRound ) await fixture.bondingManager.execute( - bondingCheckpoints.address, + bondingVotes.address, functionData ) assert.equal( - await bondingCheckpoints.getTotalActiveStakeAt(currentRound), + await bondingVotes.getTotalActiveStakeAt(currentRound), 1337 ) }) @@ -223,7 +212,7 @@ describe("BondingCheckpoints", () => { round ) await fixture.bondingManager.execute( - bondingCheckpoints.address, + bondingVotes.address, functionData ) } @@ -231,7 +220,7 @@ describe("BondingCheckpoints", () => { // now check all past values that must be recorded for (const [expectedStake, round] of roundStakes) { assert.equal( - await bondingCheckpoints.getTotalActiveStakeAt(round), + await bondingVotes.getTotalActiveStakeAt(round), expectedStake ) } @@ -250,7 +239,7 @@ describe("BondingCheckpoints", () => { }) it("should fail if BondingManager is not the caller", async () => { - const tx = bondingCheckpoints + const tx = bondingVotes .connect(signers[4]) .checkpointBondingState( transcoder.address, @@ -279,7 +268,7 @@ describe("BondingCheckpoints", () => { await expect( fixture.bondingManager.execute( - bondingCheckpoints.address, + bondingVotes.address, functionData ) ).to.be.revertedWith( @@ -303,7 +292,7 @@ describe("BondingCheckpoints", () => { await expect( fixture.bondingManager.execute( - bondingCheckpoints.address, + bondingVotes.address, functionData ) ).to.be.revertedWith( @@ -317,7 +306,7 @@ describe("BondingCheckpoints", () => { it("should allow checkpointing in the next round", async () => { const functionData = encodeCheckpointBondingState({ account: transcoder.address, - startRound: currentRound, + startRound: currentRound + 1, bondedAmount: 1000, delegateAddress: transcoder.address, delegatedAmount: 1000, @@ -325,7 +314,7 @@ describe("BondingCheckpoints", () => { lastRewardRound: 0 }) await fixture.bondingManager.execute( - bondingCheckpoints.address, + bondingVotes.address, functionData ) }) @@ -333,7 +322,7 @@ describe("BondingCheckpoints", () => { it("should checkpoint account state", async () => { const functionData = encodeCheckpointBondingState({ account: transcoder.address, - startRound: currentRound, + startRound: currentRound + 1, bondedAmount: 1000, delegateAddress: transcoder.address, delegatedAmount: 1000, @@ -341,13 +330,13 @@ describe("BondingCheckpoints", () => { lastRewardRound: 0 }) await fixture.bondingManager.execute( - bondingCheckpoints.address, + bondingVotes.address, functionData ) assert.deepEqual( - await bondingCheckpoints - .getBondingStateAt(transcoder.address, currentRound) + await bondingVotes + .getBondingStateAt(transcoder.address, currentRound + 1) .then(t => t.map(v => v.toString())), ["1000", transcoder.address] ) @@ -365,7 +354,7 @@ describe("BondingCheckpoints", () => { lastRewardRound: 0 }) await fixture.bondingManager.execute( - bondingCheckpoints.address, + bondingVotes.address, functionData ) } @@ -375,15 +364,174 @@ describe("BondingCheckpoints", () => { // simulating a bond where bonding manager checkpoints the current state and then the next await makeCheckpoint(2000) - await setRound(currentRound + 1) - assert.deepEqual( - await bondingCheckpoints + 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.AddressZero, + transcoder.address + ) + await expect(tx) + .to.emit(bondingVotes, "DelegatorVotesChanged") + .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, "DelegatorVotesChanged") + .withArgs(delegator.address, 1000, 2000) + + // Changing only delegateAddress + tx = await makeCheckpoint( + delegator.address, + transcoder2.address, + 2000, + 0 + ) + + await expect(tx).not.to.emit( + bondingVotes, + "DelegatorVotesChanged" + ) + 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.AddressZero, + 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, "DelegatorVotesChanged") + .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, + "DelegatorVotesChanged" + ) + 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, + "DelegatorVotesChanged" + ) + }) + }) }) describe("hasCheckpoint", () => { @@ -400,7 +548,7 @@ describe("BondingCheckpoints", () => { it("should return false for accounts without checkpoints", async () => { for (let i = 0; i < 10; i++) { assert.equal( - await bondingCheckpoints.hasCheckpoint(signers[i].address), + await bondingVotes.hasCheckpoint(signers[i].address), false ) } @@ -418,7 +566,7 @@ describe("BondingCheckpoints", () => { lastRewardRound: 0 }) await fixture.bondingManager.execute( - bondingCheckpoints.address, + bondingVotes.address, functionData ) } @@ -430,7 +578,7 @@ describe("BondingCheckpoints", () => { await makeCheckpoint(round + 1) assert.equal( - await bondingCheckpoints.hasCheckpoint(transcoder.address), + await bondingVotes.hasCheckpoint(transcoder.address), true ) } @@ -450,13 +598,13 @@ describe("BondingCheckpoints", () => { await setRound(currentRound) }) - it("should fail if round is in the future", async () => { - const tx = bondingCheckpoints.getBondingStateAt( + it("should fail if round is after the next round", async () => { + const tx = bondingVotes.getBondingStateAt( delegator.address, - currentRound + 1 + currentRound + 2 ) await expect(tx).to.be.revertedWith( - `FutureLookup(${currentRound + 1}, ${currentRound})` + `FutureLookup(${currentRound + 2}, ${currentRound + 1})` ) }) @@ -479,7 +627,7 @@ describe("BondingCheckpoints", () => { ) const expectRevert = async queryRound => { - const tx = bondingCheckpoints.getBondingStateAt( + const tx = bondingVotes.getBondingStateAt( delegator.address, queryRound ) @@ -513,7 +661,7 @@ describe("BondingCheckpoints", () => { it("should succeed for never bonded (non-participant) accounts", async () => { expect( - await bondingCheckpoints + await bondingVotes .getBondingStateAt(delegator.address, currentRound) .then(t => t.map(v => v.toString())) ).to.deep.equal(["0", constants.AddressZero]) @@ -522,7 +670,7 @@ describe("BondingCheckpoints", () => { it("should succeed for fully unbonded delegators before query round", async () => { await setBondMock({lastClaimRound: currentRound - 1}) expect( - await bondingCheckpoints + await bondingVotes .getBondingStateAt(delegator.address, currentRound) .then(t => t.map(v => v.toString())) ).to.deep.equal(["0", constants.AddressZero]) @@ -530,26 +678,27 @@ describe("BondingCheckpoints", () => { }) describe("for transcoder", () => { - const makeCheckpoint = async (startRound, delegatedAmount) => { - 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 + 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 + ) }) - await fixture.bondingManager.execute( - bondingCheckpoints.address, - functionData - ) - } it("should disallow querying before the first checkpoint", async () => { await makeCheckpoint(currentRound, 1000) - const tx = bondingCheckpoints.getBondingStateAt( + const tx = bondingVotes.getBondingStateAt( transcoder.address, currentRound - 2 ) @@ -562,7 +711,7 @@ describe("BondingCheckpoints", () => { await makeCheckpoint(currentRound, 1000) assert.deepEqual( - await bondingCheckpoints + await bondingVotes .getBondingStateAt(transcoder.address, currentRound) .then(t => t.map(v => v.toString())), ["1000", transcoder.address] @@ -574,14 +723,14 @@ describe("BondingCheckpoints", () => { await makeCheckpoint(currentRound - 5, 2000) assert.deepEqual( - await bondingCheckpoints + await bondingVotes .getBondingStateAt(transcoder.address, currentRound - 7) .then(t => t.map(v => v.toString())), ["1000", transcoder.address] ) assert.deepEqual( - await bondingCheckpoints + await bondingVotes .getBondingStateAt(transcoder.address, currentRound) .then(t => t.map(v => v.toString())), ["2000", transcoder.address] @@ -592,25 +741,26 @@ describe("BondingCheckpoints", () => { describe("for delegator", () => { let transcoder2 - const checkpointTranscoder = async ({ + const checkpointTranscoder = ({ account, startRound, lastRewardRound - }) => { - const functionData = encodeCheckpointBondingState({ - account, - startRound, - bondedAmount: 0, // not used in these tests - delegateAddress: account, - delegatedAmount: 0, // not used in these tests - lastClaimRound: 0, // not used in these tests - 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 + ) }) - await fixture.bondingManager.execute( - bondingCheckpoints.address, - functionData - ) - } const setEarningPoolRewardFactor = async ( address, @@ -628,26 +778,27 @@ describe("BondingCheckpoints", () => { ) } - const checkpointDelegator = async ({ + const checkpointDelegator = ({ startRound, bondedAmount, delegateAddress, lastClaimRound - }) => { - const functionData = encodeCheckpointBondingState({ - account: delegator.address, - startRound, - bondedAmount, - delegateAddress, - delegatedAmount: 0, // not used for delegators - lastClaimRound, - lastRewardRound: 0 // not used for delegators + }) => + 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 + ) }) - await fixture.bondingManager.execute( - bondingCheckpoints.address, - functionData - ) - } beforeEach(async () => { transcoder2 = signers[2] @@ -666,30 +817,30 @@ describe("BondingCheckpoints", () => { it("should disallow querying before the first checkpoint", async () => { await checkpointDelegator({ - startRound: currentRound, + startRound: currentRound + 1, bondedAmount: 1000, delegateAddress: transcoder.address, - lastClaimRound: currentRound - 1 + lastClaimRound: currentRound }) - const tx = bondingCheckpoints.getBondingStateAt( + const tx = bondingVotes.getBondingStateAt( delegator.address, - currentRound - 2 + currentRound ) await expect(tx).to.be.revertedWith( - `PastLookup(${currentRound - 2}, ${currentRound})` + `PastLookup(${currentRound}, ${currentRound + 1})` ) }) it("should fail if there's no earning pool on the lastClaimRound", async () => { await checkpointDelegator({ - startRound: currentRound, + startRound: currentRound - 10, bondedAmount: 1000, delegateAddress: transcoder.address, lastClaimRound: currentRound - 11 }) - const tx = bondingCheckpoints.getBondingStateAt( + const tx = bondingVotes.getBondingStateAt( delegator.address, currentRound ) @@ -714,7 +865,7 @@ describe("BondingCheckpoints", () => { ) assert.deepEqual( - await bondingCheckpoints + await bondingVotes .getBondingStateAt(delegator.address, currentRound) .then(t => t.map(v => v.toString())), ["1000", transcoder.address] @@ -747,14 +898,14 @@ describe("BondingCheckpoints", () => { ) assert.deepEqual( - await bondingCheckpoints + await bondingVotes .getBondingStateAt(delegator.address, currentRound - 7) .then(t => t.map(v => v.toString())), ["1000", transcoder.address] ) assert.deepEqual( - await bondingCheckpoints + await bondingVotes .getBondingStateAt(delegator.address, currentRound) .then(t => t.map(v => v.toString())), ["2000", transcoder2.address] @@ -786,7 +937,7 @@ describe("BondingCheckpoints", () => { ) assert.deepEqual( - await bondingCheckpoints + await bondingVotes .getBondingStateAt(delegator.address, currentRound) .then(t => t.map(v => v.toString())), ["1000", transcoder.address] @@ -813,7 +964,7 @@ describe("BondingCheckpoints", () => { }) // no earning pool for currentRound - 2 - const tx = bondingCheckpoints.getBondingStateAt( + const tx = bondingVotes.getBondingStateAt( delegator.address, currentRound ) @@ -849,7 +1000,7 @@ describe("BondingCheckpoints", () => { ) assert.deepEqual( - await bondingCheckpoints + await bondingVotes .getBondingStateAt(delegator.address, currentRound) .then(t => t.map(v => v.toString())), ["3000", transcoder.address] @@ -857,4 +1008,198 @@ describe("BondingCheckpoints", () => { }) }) }) + + 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" + ) + }) + }) + }) + + describe("IERC5805", () => { + // redefine it here to avoid overriding top-level var + let bondingVotes + + before(async () => { + const HarnessFac = await ethers.getContractFactory( + "BondingVotesERC5805Harness" + ) + + bondingVotes = await fixture.deployAndRegister( + HarnessFac, + "BondingVotes", + fixture.controller.address + ) + }) + + it("ensure harness was deployed", async () => { + assert.equal( + await fixture.controller.getContract( + contractId("BondingVotes") + ), + ethers.utils.getAddress(bondingVotes.address) + ) + }) + + // Same implementation as the BondingVotesERC5805Mock + const mockGetBondingStateAt = (_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()) + ] + } + + describe("get(Past)?Votes", () => { + it("getPastVotes should proxy to getBondingStateAt", async () => { + const testOnce = async (account, round) => { + const [expected] = mockGetBondingStateAt( + account.address, + round + ) + + const votes = await bondingVotes.getPastVotes( + account.address, + round + ) + assert.equal(votes.toNumber(), expected) + } + + await testOnce(signers[0], 123) + await testOnce(signers[1], 256) + await testOnce(signers[2], 34784) + }) + + it("getVotes should query with the current round", async () => { + const testOnce = async (account, round) => { + await fixture.roundsManager.setMockUint256( + functionSig("currentRound()"), + round + ) + const [expected] = mockGetBondingStateAt( + account.address, + round + ) + + const votes = await bondingVotes.getVotes(account.address) + assert.equal(votes.toNumber(), expected) + } + + await testOnce(signers[3], 321) + await testOnce(signers[4], 652) + await testOnce(signers[5], 48743) + }) + }) + + describe("delegate(s|dAt)", () => { + it("delegatedAt should proxy to BondingVotes.getBondingStateAt", async () => { + const testOnce = async (account, round) => { + const [, expected] = mockGetBondingStateAt( + account.address, + round + ) + + 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], 34784) + }) + + it("delegates should query with the current round", async () => { + const testOnce = async (account, round) => { + await fixture.roundsManager.setMockUint256( + functionSig("currentRound()"), + round + ) + const [, expected] = mockGetBondingStateAt( + account.address, + round + ) + + assert.equal( + await bondingVotes.delegates(account.address), + expected + ) + } + + await testOnce(signers[9], 321) + await testOnce(signers[10], 652) + await testOnce(signers[11], 48743) + }) + }) + + describe("getPastTotalSupply", () => { + it("should proxy to getTotalActiveStakeAt", async () => { + const testOnce = async round => { + const expected = 4 * round // same as BondingVotesERC5805Harness impl + + const totalSupply = await bondingVotes.getPastTotalSupply( + round + ) + assert.equal(totalSupply.toNumber(), expected) + } + + await testOnce(213) + await testOnce(526) + await testOnce(784347) + }) + }) + + 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/helpers/Fixture.js b/test/unit/helpers/Fixture.js index 6f4eaa9c..c21e3fb0 100644 --- a/test/unit/helpers/Fixture.js +++ b/test/unit/helpers/Fixture.js @@ -23,8 +23,8 @@ export default class Fixture { const BondingManagerMock = await ethers.getContractFactory( "BondingManagerMock" ) - const BondingCheckpointsMock = await ethers.getContractFactory( - "BondingCheckpointsMock" + const BondingVotesMock = await ethers.getContractFactory( + "BondingVotesMock" ) this.token = await this.deployAndRegister(GenericMock, "LivepeerToken") @@ -33,9 +33,9 @@ export default class Fixture { BondingManagerMock, "BondingManager" ) - this.bondingCheckpoints = await this.deployAndRegister( - BondingCheckpointsMock, - "BondingCheckpoints" + this.bondingVotes = await this.deployAndRegister( + BondingVotesMock, + "BondingVotes" ) this.roundsManager = await this.deployAndRegister( GenericMock, diff --git a/test/unit/helpers/expectCheckpoints.ts b/test/unit/helpers/expectCheckpoints.ts index cd315336..b8dd61d8 100644 --- a/test/unit/helpers/expectCheckpoints.ts +++ b/test/unit/helpers/expectCheckpoints.ts @@ -17,9 +17,8 @@ export default async function expectCheckpoints( tx: ethers.providers.TransactionReceipt, ...checkpoints: Checkpoint[] ) { - const filter = - await fixture.bondingCheckpoints.filters.CheckpointBondingState() - const events = await fixture.bondingCheckpoints.queryFilter( + const filter = fixture.bondingVotes.filters.CheckpointBondingState() + const events = await fixture.bondingVotes.queryFilter( filter, tx.blockNumber, tx.blockNumber From ccc3ddf071a4c7b63fe22ca9531118b858a8b390 Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Tue, 15 Aug 2023 17:26:53 -0300 Subject: [PATCH 19/29] bonding: Read votes from the end of the round Meaning the start of next round instead of the current round. This is more compatible with the way OZ expects the timepoints to work in the clock and snapshots on the Governor framework. --- contracts/bonding/BondingVotes.sol | 48 +++++-- src/test/BondingVotesStateInitialization.sol | 26 +++- test/integration/BondingVotes.js | 66 ++++++--- test/unit/BondingVotes.js | 133 ++++++++++++++----- 4 files changed, 206 insertions(+), 67 deletions(-) diff --git a/contracts/bonding/BondingVotes.sol b/contracts/bonding/BondingVotes.sol index 2d30b092..241d5bf6 100644 --- a/contracts/bonding/BondingVotes.sol +++ b/contracts/bonding/BondingVotes.sol @@ -18,6 +18,7 @@ import "./BondingManager.sol"; * @dev Checkpointing logic for BondingManager state for historical stake calculations. */ contract BondingVotes is ManagerProxyTarget, IBondingVotes { + using Arrays for uint256[]; using SortedArrays for uint256[]; constructor(address _controller) Manager(_controller) {} @@ -109,9 +110,12 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { /** * @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) public view returns (uint256) { - (uint256 amount, ) = getBondingStateAt(_account, _round); + (uint256 amount, ) = getBondingStateAt(_account, _round + 1); return amount; } @@ -120,9 +124,12 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { * @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 returns (uint256) { - return getTotalActiveStakeAt(_round); + function getPastTotalSupply(uint256 _round) public view returns (uint256) { + return getTotalActiveStakeAt(_round + 1); } /** @@ -138,9 +145,12 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { * for more details. * @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 {GovernorVotesBondingVotes-_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) public view returns (address) { - (, address delegateAddress) = getBondingStateAt(_account, _round); + (, address delegateAddress) = getBondingStateAt(_account, _round + 1); return delegateAddress; } @@ -276,22 +286,32 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { * @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()) { - revert FutureLookup(_round, clock()); + if (_round > clock() + 1) { + revert FutureLookup(_round, clock() + 1); } - uint256 activeStake = totalStakeCheckpoints.data[_round]; + uint256 exactCheckpoint = totalStakeCheckpoints.data[_round]; + if (exactCheckpoint > 0) { + return exactCheckpoint; + } - if (activeStake == 0) { - uint256 lastInitialized = checkedFindLowerBound(totalStakeCheckpoints.rounds, _round); + uint256[] storage initializedRounds = totalStakeCheckpoints.rounds; + if (initializedRounds.length == 0) { + revert NoRecordedCheckpoints(); + } - // Check that the round was in fact initialized so we don't return a 0 value accidentally. - if (lastInitialized != _round) { - revert MissingRoundCheckpoint(_round); - } + uint256 upper = initializedRounds.findUpperBound(_round); + if (upper == 0) { + // we can't use the first checkpoint as an upper bound since we don't know any state before that + revert PastLookup(_round, initializedRounds[0]); + } else if (upper < initializedRounds.length) { + // use the checkpoint from the next round that has been initialized + uint256 nextInitedRound = initializedRounds[upper]; + return totalStakeCheckpoints.data[nextInitedRound]; } - return activeStake; + // the _round is after any initialized round, so grab its stake from nextRoundTotalActiveStake() + return bondingManager().nextRoundTotalActiveStake(); } /** diff --git a/src/test/BondingVotesStateInitialization.sol b/src/test/BondingVotesStateInitialization.sol index 14ab125a..8ea30fef 100644 --- a/src/test/BondingVotesStateInitialization.sol +++ b/src/test/BondingVotesStateInitialization.sol @@ -4,15 +4,15 @@ 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/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 { +// 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); @@ -233,7 +233,19 @@ contract bondingVotesStateInitialization is GovernorBaseTest { bondingVotes.getTotalActiveStakeAt(currentRound + 1); } - function testDoesNotUsePastCheckpointForTotalActiveStake() public { + 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); + + CHEATS.expectRevert(abi.encodeWithSelector(IBondingVotes.PastLookup.selector, currentRound, currentRound + 1)); + bondingVotes.getTotalActiveStakeAt(currentRound); + } + + function testUsesNextRoundTotalActiveStakeForCurrentRounds() public { uint256 currentRound = ROUNDS_MANAGER.currentRound(); uint256 nextRoundStartBlock = ROUNDS_MANAGER.currentRoundStartBlock() + ROUNDS_MANAGER.roundLength(); @@ -244,8 +256,10 @@ contract bondingVotesStateInitialization is GovernorBaseTest { CHEATS.roll(nextRoundStartBlock); assertEq(ROUNDS_MANAGER.currentRound(), currentRound + 2); - CHEATS.expectRevert(abi.encodeWithSelector(IBondingVotes.MissingRoundCheckpoint.selector, currentRound + 2)); - bondingVotes.getTotalActiveStakeAt(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 { diff --git a/test/integration/BondingVotes.js b/test/integration/BondingVotes.js index 83710600..fa8ab2c3 100644 --- a/test/integration/BondingVotes.js +++ b/test/integration/BondingVotes.js @@ -587,7 +587,7 @@ describe("BondingVotes", () => { delegator = signers[1] // Initialize the first round ever - await nextRound() + await nextRound(10) for (const account of [transcoder, delegator]) { await bondingManager.checkpointBondingState(account.address) @@ -783,33 +783,67 @@ describe("BondingVotes", () => { } }) - it("should only allow querying total active stake on initialized rounds", async () => { - const expectRevertAt = r => - expect(totalStakeAt(r)).to.be.revertedWith( - `MissingRoundCheckpoint(${r})` + it("should only allow querying total active stake after the first initialized round", async () => { + // first checkpointed round was R-2 + for (const i = 3; i <= 10; i++) { + const round = currentRound - i + await expect(totalStakeAt(round)).to.be.revertedWith( + `PastLookup(${round}, ${currentRound - 2})` ) + } + }) + 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, bonding not reflected yet - await expectRevertAt(currentRound + 1) - await expectRevertAt(currentRound + 25) - await expectRevertAt(currentRound + 49) + // 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)) - // only when a round is initialized it picks up the change - await expectTotalStakeAt(currentRound + 50, lptAmount(2)) // transcoder calls reward + // 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, reward only gets picked up on next initialized round - await expectRevertAt(currentRound + 51) - await expectRevertAt(currentRound + 75) - await expectRevertAt(currentRound + 99) + // 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) - // first round to be initialized + // 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)) + ) }) }) diff --git a/test/unit/BondingVotes.js b/test/unit/BondingVotes.js index 21f3fc68..40b0d942 100644 --- a/test/unit/BondingVotes.js +++ b/test/unit/BondingVotes.js @@ -169,18 +169,34 @@ describe("BondingVotes", () => { await setRound(currentRound) }) - it("should fail if round is in the future", async () => { - const tx = bondingVotes.getTotalActiveStakeAt(currentRound + 1) + 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 + 1}, ${currentRound})` + `FutureLookup(${currentRound + 2}, ${currentRound + 1})` ) }) - it("should fail if round was not checkpointed", async () => { + it("should fail if there are no checkpointed rounds", async () => { const tx = bondingVotes.getTotalActiveStakeAt(currentRound) await expect(tx).to.be.revertedWith("NoRecordedCheckpoints()") }) + it("should fail to query before the first checkpoint", async () => { + const functionData = encodeCheckpointTotalActiveStake( + 1337, + currentRound - 1 + ) + await fixture.bondingManager.execute( + bondingVotes.address, + functionData + ) + + const tx = bondingVotes.getTotalActiveStakeAt(currentRound - 2) + await expect(tx).to.be.revertedWith( + `PastLookup(${currentRound - 2}, ${currentRound - 1})` + ) + }) + it("should query checkpointed value in the current round", async () => { const functionData = encodeCheckpointTotalActiveStake( 1337, @@ -197,6 +213,28 @@ describe("BondingVotes", () => { ) }) + it("should query next rounds value from next round total active stake", async () => { + const functionData = encodeCheckpointTotalActiveStake( + 1337, + currentRound - 5 + ) + await fixture.bondingManager.execute( + bondingVotes.address, + functionData + ) + 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], @@ -225,6 +263,36 @@ describe("BondingVotes", () => { ) } }) + + 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) { + 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", () => { @@ -1054,6 +1122,24 @@ describe("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 + } + it("ensure harness was deployed", async () => { assert.equal( await fixture.controller.getContract( @@ -1063,27 +1149,12 @@ describe("BondingVotes", () => { ) }) - // Same implementation as the BondingVotesERC5805Mock - const mockGetBondingStateAt = (_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()) - ] - } - describe("get(Past)?Votes", () => { - it("getPastVotes should proxy to getBondingStateAt", async () => { + it("getPastVotes should proxy to getBondingStateAt from next round", async () => { const testOnce = async (account, round) => { - const [expected] = mockGetBondingStateAt( + const [expected] = mock.getBondingStateAt( account.address, - round + round + 1 ) const votes = await bondingVotes.getPastVotes( @@ -1104,9 +1175,9 @@ describe("BondingVotes", () => { functionSig("currentRound()"), round ) - const [expected] = mockGetBondingStateAt( + const [expected] = mock.getBondingStateAt( account.address, - round + round + 1 ) const votes = await bondingVotes.getVotes(account.address) @@ -1120,11 +1191,11 @@ describe("BondingVotes", () => { }) describe("delegate(s|dAt)", () => { - it("delegatedAt should proxy to BondingVotes.getBondingStateAt", async () => { + it("delegatedAt should proxy to BondingVotes.getBondingStateAt at next round", async () => { const testOnce = async (account, round) => { - const [, expected] = mockGetBondingStateAt( + const [, expected] = mock.getBondingStateAt( account.address, - round + round + 1 ) const delegate = await bondingVotes.delegatedAt( @@ -1145,9 +1216,9 @@ describe("BondingVotes", () => { functionSig("currentRound()"), round ) - const [, expected] = mockGetBondingStateAt( + const [, expected] = mock.getBondingStateAt( account.address, - round + round + 1 ) assert.equal( @@ -1163,9 +1234,9 @@ describe("BondingVotes", () => { }) describe("getPastTotalSupply", () => { - it("should proxy to getTotalActiveStakeAt", async () => { + it("should proxy to getTotalActiveStakeAt at next round", async () => { const testOnce = async round => { - const expected = 4 * round // same as BondingVotesERC5805Harness impl + const expected = mock.getTotalActiveStakeAt(round + 1) const totalSupply = await bondingVotes.getPastTotalSupply( round From d5062fe2db7d5037988c2f1a5989886d7ce087d3 Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Wed, 16 Aug 2023 23:30:19 -0300 Subject: [PATCH 20/29] bonding: Make checkpoint reading revert-free This changes the BondingVotes implementation to stop having so many reverts on supposedly invalid states. Instead, redefine the voting power (and checkpointed stake) as being zero before the first round to be checkpointed. This had other implications in the code like removing changes in BondingManager to make the state complete (always having an earnings pool on lastClaimRound). Instead, the BondingVotes implementaiton is resilient to that as well and just assumes reward() had never been called. This also fixed the redundant reverts between BondingVotes and SortedArrays, as now there are almost no reverts. --- contracts/bonding/BondingManager.sol | 3 - contracts/bonding/BondingVotes.sol | 65 +++--- contracts/bonding/IBondingVotes.sol | 3 - contracts/bonding/libraries/SortedArrays.sol | 34 ++-- contracts/test/TestSortedArrays.sol | 34 ++-- contracts/test/mocks/SortedArraysFixture.sol | 5 - test/integration/BondingVotes.js | 12 +- test/unit/BondingVotes.js | 202 +++++++++---------- 8 files changed, 149 insertions(+), 209 deletions(-) diff --git a/contracts/bonding/BondingManager.sol b/contracts/bonding/BondingManager.sol index e8926998..4ca75545 100644 --- a/contracts/bonding/BondingManager.sol +++ b/contracts/bonding/BondingManager.sol @@ -537,9 +537,6 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { if (currPool.cumulativeRewardFactor == 0) { currPool.cumulativeRewardFactor = cumulativeFactorsPool(newDelegate, newDelegate.lastRewardRound) .cumulativeRewardFactor; - if (currPool.cumulativeRewardFactor == 0) { - currPool.cumulativeRewardFactor = PreciseMathUtils.percPoints(1, 1); - } } if (currPool.cumulativeFeeFactor == 0) { currPool.cumulativeFeeFactor = cumulativeFactorsPool(newDelegate, newDelegate.lastFeeRound) diff --git a/contracts/bonding/BondingVotes.sol b/contracts/bonding/BondingVotes.sol index 241d5bf6..101b0b01 100644 --- a/contracts/bonding/BondingVotes.sol +++ b/contracts/bonding/BondingVotes.sol @@ -296,22 +296,19 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { } uint256[] storage initializedRounds = totalStakeCheckpoints.rounds; - if (initializedRounds.length == 0) { - revert NoRecordedCheckpoints(); - } - uint256 upper = initializedRounds.findUpperBound(_round); if (upper == 0) { - // we can't use the first checkpoint as an upper bound since we don't know any state before that - revert PastLookup(_round, initializedRounds[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 round that has been initialized + // 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(); } - - // the _round is after any initialized round, so grab its stake from nextRoundTotalActiveStake() - return bondingManager().nextRoundTotalActiveStake(); } /** @@ -373,18 +370,14 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { return bond; } - if (checkpoints.startRounds.length == 0) { - (uint256 bondedAmount, , , uint256 delegatedAmount, , uint256 lastClaimRound, ) = bondingManager() - .getDelegator(_account); - // we use lastClaimRound instead of startRound since the latter is cleared on a full unbond - if (lastClaimRound < _round && bondedAmount == 0 && delegatedAmount == 0) { - // If the account was not delegating to anyone at the queried round, we can just return the zero - // BondingCheckpoint value. This also handles the case of accounts that have never made a delegation. - 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 = checkedFindLowerBound(checkpoints.startRounds, _round); + uint256 startRound = checkpoints.startRounds[startRoundIdx]; return checkpoints.data[startRound]; } @@ -401,12 +394,12 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { view returns (uint256) { - EarningsPool.Data memory startPool = getTranscoderEarningPoolForRound( + EarningsPool.Data memory startPool = getTranscoderEarningsPoolForRound( bond.delegateAddress, bond.lastClaimRound ); - (uint256 rewardRound, EarningsPool.Data memory endPool) = getTranscoderLastRewardsEarningPool( + (uint256 rewardRound, EarningsPool.Data memory endPool) = getLastTranscoderRewardsEarningsPool( bond.delegateAddress, _round ); @@ -436,7 +429,7 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { * @return rewardRound Round in which the returned earning pool was calculated. * @return pool EarningsPool.Data struct with the last initialized earning pool. */ - function getTranscoderLastRewardsEarningPool(address _transcoder, uint256 _round) + function getLastTranscoderRewardsEarningsPool(address _transcoder, uint256 _round) internal view returns (uint256 rewardRound, EarningsPool.Data memory pool) @@ -444,16 +437,20 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { BondingCheckpoint storage bond = getBondingCheckpointAt(_transcoder, _round); rewardRound = bond.lastRewardRound; - // only fetch pool if there is a previous reward() call recorded if (rewardRound > 0) { - pool = getTranscoderEarningPoolForRound(_transcoder, rewardRound); + 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 getTranscoderEarningPoolForRound(address _transcoder, uint256 _round) + function getTranscoderEarningsPoolForRound(address _transcoder, uint256 _round) internal view returns (EarningsPool.Data memory pool) @@ -465,22 +462,6 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { pool.cumulativeRewardFactor, pool.cumulativeFeeFactor ) = bondingManager().getTranscoderEarningsPoolForRound(_transcoder, _round); - - if (pool.cumulativeRewardFactor == 0) { - revert MissingEarningsPool(_transcoder, _round); - } - } - - /** - * @dev Helper to return more helpful custom errors in case of bad queries. - */ - function checkedFindLowerBound(uint256[] storage array, uint256 value) internal view returns (uint256) { - if (array.length == 0) { - revert NoRecordedCheckpoints(); - } else if (array[0] > value) { - revert PastLookup(value, array[0]); - } - return array.findLowerBound(value); } // Manager/Controller helpers diff --git a/contracts/bonding/IBondingVotes.sol b/contracts/bonding/IBondingVotes.sol index 984aa6ce..0cee958c 100644 --- a/contracts/bonding/IBondingVotes.sol +++ b/contracts/bonding/IBondingVotes.sol @@ -14,9 +14,6 @@ interface IBondingVotes is IERC6372Upgradeable, IVotes { error FutureLastClaimRound(uint256 lastClaimRound, uint256 maxAllowed); error FutureLookup(uint256 queryRound, uint256 maxAllowed); - error MissingRoundCheckpoint(uint256 round); - error NoRecordedCheckpoints(); - error PastLookup(uint256 queryRound, uint256 firstCheckpointRound); error MissingEarningsPool(address transcoder, uint256 round); // Indicates that the called function is not supported in this contract and should be performed through the diff --git a/contracts/bonding/libraries/SortedArrays.sol b/contracts/bonding/libraries/SortedArrays.sol index a3bc51f0..d12391c4 100644 --- a/contracts/bonding/libraries/SortedArrays.sol +++ b/contracts/bonding/libraries/SortedArrays.sol @@ -12,32 +12,25 @@ import "@openzeppelin/contracts/utils/Arrays.sol"; library SortedArrays { using Arrays for uint256[]; - error EmptyArray(); - error NoLowerBoundInArray(uint256 queryValue, uint256 minValue); - /** - * @notice Searches a sorted _array and returns the last element to be lower or equal to _val. + * @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 main differences from the OZ version (apart from the obvious lower vs upper bound) are: - * - It returns the array element directly instead of its index - * - If no such element exists (i.e. all values in the array are higher than _val) this function will fail instead - * of returning some default value. + * 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 Lower bound value found in array + * @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) { - revert EmptyArray(); + return 0; } - uint256 lastElm = _array[len - 1]; - if (lastElm <= _val) { - return lastElm; + if (_array[len - 1] <= _val) { + return len - 1; } uint256 upperIdx = _array.findUpperBound(_val); @@ -45,19 +38,18 @@ library SortedArrays { // we already checked the last element above so the upper will always be inside the array assert(upperIdx < len); - uint256 upperElm = _array[upperIdx]; // the exact value we were searching is in the array - if (upperElm == _val) { - return upperElm; + 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) { - revert NoLowerBoundInArray(_val, _array[0]); + return len; } - // the upperElm is the first element higher than the value we want, so return the previous element - return _array[upperIdx - 1]; + // the element at upperIdx is the first element higher than the value we want, so return the previous element + return upperIdx - 1; } /** diff --git a/contracts/test/TestSortedArrays.sol b/contracts/test/TestSortedArrays.sol index d35f58eb..d497bfa2 100644 --- a/contracts/test/TestSortedArrays.sol +++ b/contracts/test/TestSortedArrays.sol @@ -53,10 +53,10 @@ contract TestSortedArrays { fixture.pushSorted(7); fixture.pushSorted(11); - Assert.equal(fixture.findLowerBound(3), 2, "found incorrect element"); - Assert.equal(fixture.findLowerBound(6), 4, "found incorrect element"); - Assert.equal(fixture.findLowerBound(10), 7, "found incorrect element"); - Assert.equal(fixture.findLowerBound(15), 11, "found incorrect element"); + 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 { @@ -65,27 +65,23 @@ contract TestSortedArrays { fixture.pushSorted(8); fixture.pushSorted(13); - Assert.equal(fixture.findLowerBound(3), 3, "found incorrect element"); - Assert.equal(fixture.findLowerBound(5), 5, "found incorrect element"); - Assert.equal(fixture.findLowerBound(8), 8, "found incorrect element"); - Assert.equal(fixture.findLowerBound(13), 13, "found incorrect element"); + 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_revertsOnEmpty() public { - SortedArraysFixture(address(proxy)).callFindLowerBound(3); - bool ok = proxy.execute(address(fixture)); - Assert.isFalse(ok, "did not revert"); + 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_revertsOnNotFound() public { + function test_findLowerBound_returnsLengthOnNotFound() public { fixture.pushSorted(8); fixture.pushSorted(13); - Assert.equal(fixture.findLowerBound(22), 13, "found incorrect element"); - - // looking for a value lower than min should revert - SortedArraysFixture(address(proxy)).callFindLowerBound(5); - bool ok = proxy.execute(address(fixture)); - Assert.isFalse(ok, "did not revert"); + 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/SortedArraysFixture.sol b/contracts/test/mocks/SortedArraysFixture.sol index 2f496a17..8c319651 100644 --- a/contracts/test/mocks/SortedArraysFixture.sol +++ b/contracts/test/mocks/SortedArraysFixture.sol @@ -10,11 +10,6 @@ contract SortedArraysFixture { return SortedArrays.findLowerBound(array, val); } - // this function cannot be 'view' or the RevertProxy will fail weirdly - function callFindLowerBound(uint256 val) external { - SortedArrays.findLowerBound(array, val); - } - function pushSorted(uint256 val) external { SortedArrays.pushSorted(array, val); } diff --git a/test/integration/BondingVotes.js b/test/integration/BondingVotes.js index fa8ab2c3..02282d32 100644 --- a/test/integration/BondingVotes.js +++ b/test/integration/BondingVotes.js @@ -164,13 +164,13 @@ describe("BondingVotes", () => { assert.equal(await stakeAt(currentRound - 1), 0) let stake = lptAmount(1) // bonded on previous round - assert.equal(await stakeAt(currentRound), stake) + assert.equal(await stakeAt(currentRound), stake.toString()) stake = stake.add(pendingRewards0) // reward call - assert.equal(await stakeAt(currentRound + 1), stake) + assert.equal(await stakeAt(currentRound + 1), stake.toString()) stake = stake.add(pendingRewards1) // reward call - assert.equal(await stakeAt(currentRound + 2), stake) + assert.equal(await stakeAt(currentRound + 2), stake.toString()) }) it("should return partial rewards for all transcoder stake", async () => { @@ -783,13 +783,11 @@ describe("BondingVotes", () => { } }) - it("should only allow querying total active stake after the first initialized round", async () => { + 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 expect(totalStakeAt(round)).to.be.revertedWith( - `PastLookup(${round}, ${currentRound - 2})` - ) + await expectTotalStakeAt(round, 0) } }) diff --git a/test/unit/BondingVotes.js b/test/unit/BondingVotes.js index 40b0d942..616fd181 100644 --- a/test/unit/BondingVotes.js +++ b/test/unit/BondingVotes.js @@ -4,7 +4,8 @@ import {assert} from "chai" import {ethers, web3} from "hardhat" import chai from "chai" import {solidity} from "ethereum-waffle" -import {BigNumber, constants} from "ethers" +import {BigNumber} from "ethers" +import {constants} from "../../utils/constants" chai.use(solidity) const {expect} = chai @@ -16,7 +17,7 @@ describe("BondingVotes", () => { let bondingVotes let roundsManager - const PERC_DIVISOR = 1000000 + const PERC_DIVISOR = constants.PERC_DIVISOR_PRECISE const setRound = async round => { await fixture.roundsManager.setMockUint256( @@ -176,24 +177,30 @@ describe("BondingVotes", () => { ) }) - it("should fail if there are no checkpointed rounds", async () => { - const tx = bondingVotes.getTotalActiveStakeAt(currentRound) - await expect(tx).to.be.revertedWith("NoRecordedCheckpoints()") + it("should return zero if there are no checkpointed rounds", async () => { + assert.equal( + await bondingVotes.getTotalActiveStakeAt(currentRound), + 0 + ) }) - it("should fail to query before the first checkpoint", async () => { + it("should return zero before the first checkpoint", async () => { const functionData = encodeCheckpointTotalActiveStake( 1337, - currentRound - 1 + currentRound ) await fixture.bondingManager.execute( bondingVotes.address, functionData ) - const tx = bondingVotes.getTotalActiveStakeAt(currentRound - 2) - await expect(tx).to.be.revertedWith( - `PastLookup(${currentRound - 2}, ${currentRound - 1})` + assert.equal( + await bondingVotes.getTotalActiveStakeAt(currentRound - 1), + 0 + ) + assert.equal( + await bondingVotes.getTotalActiveStakeAt(currentRound - 2), + 0 ) }) @@ -493,7 +500,7 @@ describe("BondingVotes", () => { .to.emit(bondingVotes, "DelegateChanged") .withArgs( delegator.address, - constants.AddressZero, + constants.NULL_ADDRESS, transcoder.address ) await expect(tx) @@ -547,7 +554,7 @@ describe("BondingVotes", () => { .to.emit(bondingVotes, "DelegateChanged") .withArgs( transcoder.address, - constants.AddressZero, + constants.NULL_ADDRESS, transcoder.address ) await expect(tx) @@ -687,61 +694,46 @@ describe("BondingVotes", () => { delegator.address, bondedAmount ?? 0, 0, - delegateAddress ?? constants.AddressZero, + delegateAddress ?? constants.NULL_ADDRESS, delegatedAmount ?? 0, 0, lastClaimRound, 0 ) - const expectRevert = async queryRound => { - const tx = bondingVotes.getBondingStateAt( - delegator.address, - queryRound - ) - await expect(tx).to.be.revertedWith("NoRecordedCheckpoints()") - } - - it("should fail if the account has a zero bond but updated on or after queried round", async () => { - await setBondMock({lastClaimRound: currentRound - 10}) - await expectRevert(currentRound - 10) - - await setBondMock({lastClaimRound: currentRound - 9}) - await expectRevert(currentRound - 10) - - await setBondMock({lastClaimRound: currentRound - 5}) - await expectRevert(currentRound - 10) - }) - - it("should fail if the account has a non-zero bond", async () => { - await setBondMock({ - bondedAmount: 1, - lastClaimRound: currentRound - 1 - }) - await expectRevert(currentRound) - - await setBondMock({ - delegatedAmount: 1, - lastClaimRound: currentRound - 1 - }) - await expectRevert(currentRound) - }) - - it("should succeed for never bonded (non-participant) accounts", async () => { + const expectZeroCheckpoint = async queryRound => { expect( await bondingVotes - .getBondingStateAt(delegator.address, currentRound) + .getBondingStateAt(delegator.address, queryRound) .then(t => t.map(v => v.toString())) - ).to.deep.equal(["0", constants.AddressZero]) - }) + ).to.deep.equal(["0", constants.NULL_ADDRESS]) + } - it("should succeed for fully unbonded delegators before query round", async () => { - await setBondMock({lastClaimRound: currentRound - 1}) - expect( - await bondingVotes - .getBondingStateAt(delegator.address, currentRound) - .then(t => t.map(v => v.toString())) - ).to.deep.equal(["0", constants.AddressZero]) + 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) + } }) }) @@ -763,15 +755,14 @@ describe("BondingVotes", () => { ) }) - it("should disallow querying before the first checkpoint", async () => { + it("should return zero before the first checkpoint", async () => { await makeCheckpoint(currentRound, 1000) - const tx = bondingVotes.getBondingStateAt( - transcoder.address, - currentRound - 2 - ) - await expect(tx).to.be.revertedWith( - `PastLookup(${currentRound - 2}, ${currentRound})` + assert.deepEqual( + await bondingVotes + .getBondingStateAt(transcoder.address, currentRound - 2) + .then(t => t.map(v => v.toString())), + ["0", constants.NULL_ADDRESS] ) }) @@ -883,7 +874,7 @@ describe("BondingVotes", () => { }) }) - it("should disallow querying before the first checkpoint", async () => { + it("should return zero before the first checkpoint", async () => { await checkpointDelegator({ startRound: currentRound + 1, bondedAmount: 1000, @@ -891,46 +882,21 @@ describe("BondingVotes", () => { lastClaimRound: currentRound }) - const tx = bondingVotes.getBondingStateAt( - delegator.address, - currentRound - ) - await expect(tx).to.be.revertedWith( - `PastLookup(${currentRound}, ${currentRound + 1})` - ) - }) - - it("should fail if there's no earning pool on the lastClaimRound", async () => { - await checkpointDelegator({ - startRound: currentRound - 10, - bondedAmount: 1000, - delegateAddress: transcoder.address, - lastClaimRound: currentRound - 11 - }) - - const tx = bondingVotes.getBondingStateAt( - delegator.address, - currentRound - ) - await expect(tx).to.be.revertedWith( - `MissingEarningsPool("${transcoder.address}", ${ - currentRound - 11 - })` + 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 transcoder never called reward", async () => { + 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 }) - await setEarningPoolRewardFactor( - transcoder.address, - currentRound - 11, - PERC_DIVISOR - ) assert.deepEqual( await bondingVotes @@ -947,11 +913,6 @@ describe("BondingVotes", () => { delegateAddress: transcoder.address, lastClaimRound: currentRound - 11 }) - await setEarningPoolRewardFactor( - transcoder.address, - currentRound - 11, - PERC_DIVISOR - ) await checkpointDelegator({ startRound: currentRound - 5, @@ -959,11 +920,6 @@ describe("BondingVotes", () => { delegateAddress: transcoder2.address, lastClaimRound: currentRound - 6 }) - await setEarningPoolRewardFactor( - transcoder2.address, - currentRound - 6, - PERC_DIVISOR - ) assert.deepEqual( await bondingVotes @@ -1001,7 +957,7 @@ describe("BondingVotes", () => { await setEarningPoolRewardFactor( transcoder.address, currentRound - 1, - 2 * PERC_DIVISOR + PERC_DIVISOR.mul(2) ) assert.deepEqual( @@ -1053,7 +1009,7 @@ describe("BondingVotes", () => { await setEarningPoolRewardFactor( transcoder.address, currentRound - 10, - PERC_DIVISOR + PERC_DIVISOR.mul(2) ) await checkpointTranscoder({ @@ -1064,7 +1020,7 @@ describe("BondingVotes", () => { await setEarningPoolRewardFactor( transcoder.address, currentRound - 2, - 3 * PERC_DIVISOR + PERC_DIVISOR.mul(6) ) assert.deepEqual( @@ -1074,6 +1030,34 @@ describe("BondingVotes", () => { ["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] + ) + }) }) }) From 292f9542610d6f270966570135915a12ce6fd531 Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Wed, 16 Aug 2023 16:55:42 -0300 Subject: [PATCH 21/29] bonding: Address minor code review comments --- contracts/bonding/BondingVotes.sol | 30 ++++++----- contracts/bonding/IBondingVotes.sol | 3 +- contracts/bonding/libraries/SortedArrays.sol | 6 ++- test/gas-report/checkpoints.js | 54 ++++++++++++++++---- test/unit/BondingVotes.js | 43 +++++++++++++--- 5 files changed, 103 insertions(+), 33 deletions(-) diff --git a/contracts/bonding/BondingVotes.sol b/contracts/bonding/BondingVotes.sol index 101b0b01..58d0bd4c 100644 --- a/contracts/bonding/BondingVotes.sol +++ b/contracts/bonding/BondingVotes.sol @@ -25,7 +25,7 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { struct BondingCheckpoint { /** - * @dev The amount of bonded tokens to another delegate as per the lastClaimRound. + * @dev The amount of bonded tokens to another delegate as of the lastClaimRound. */ uint256 bondedAmount; /** @@ -43,8 +43,10 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { */ uint256 lastClaimRound; /** - * @dev The last round during which the transcoder called {BondingManager-reward}. This is needed to find a - * reward pool for any round when calculating historical rewards. + * @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. */ @@ -81,6 +83,14 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { */ TotalActiveStakeByRound private totalStakeCheckpoints; + /** + * @dev Modifier to ensure the sender is BondingManager + */ + modifier onlyBondingManager() { + _onlyBondingManager(); + _; + } + // IVotes interface implementation. // These should not access any storage directly but proxy to the bonding state functions. @@ -199,7 +209,7 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { uint256 _lastRewardRound ) public virtual onlyBondingManager { if (_startRound != clock() + 1) { - revert InvalidCheckpoint(_startRound, clock() + 1); + revert InvalidStartRound(_startRound, clock() + 1); } else if (_lastClaimRound >= _startRound) { revert FutureLastClaimRound(_lastClaimRound, _startRound - 1); } @@ -272,7 +282,7 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { */ function checkpointTotalActiveStake(uint256 _totalStake, uint256 _round) public virtual onlyBondingManager { if (_round > clock()) { - revert InvalidCheckpoint(_round, clock()); + revert FutureTotalStakeCheckpoint(_round, clock()); } totalStakeCheckpoints.data[_round] = _totalStake; @@ -466,14 +476,6 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { // Manager/Controller helpers - /** - * @dev Modified to ensure the sender is BondingManager - */ - modifier onlyBondingManager() { - _onlyBondingManager(); - _; - } - /** * @dev Return BondingManager interface */ @@ -484,7 +486,7 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { /** * @dev Return IRoundsManager interface */ - function roundsManager() public view returns (IRoundsManager) { + function roundsManager() internal view returns (IRoundsManager) { return IRoundsManager(controller.getContract(keccak256("RoundsManager"))); } diff --git a/contracts/bonding/IBondingVotes.sol b/contracts/bonding/IBondingVotes.sol index 0cee958c..f684d75f 100644 --- a/contracts/bonding/IBondingVotes.sol +++ b/contracts/bonding/IBondingVotes.sol @@ -10,8 +10,9 @@ import "../treasury/IVotes.sol"; */ interface IBondingVotes is IERC6372Upgradeable, IVotes { error InvalidCaller(address caller, address required); - error InvalidCheckpoint(uint256 checkpointRound, uint256 requiredRound); + error InvalidStartRound(uint256 checkpointRound, uint256 requiredRound); error FutureLastClaimRound(uint256 lastClaimRound, uint256 maxAllowed); + error FutureTotalStakeCheckpoint(uint256 checkpointRound, uint256 maxAllowedRound); error FutureLookup(uint256 queryRound, uint256 maxAllowed); error MissingEarningsPool(address transcoder, uint256 round); diff --git a/contracts/bonding/libraries/SortedArrays.sol b/contracts/bonding/libraries/SortedArrays.sol index d12391c4..4e425f5a 100644 --- a/contracts/bonding/libraries/SortedArrays.sol +++ b/contracts/bonding/libraries/SortedArrays.sol @@ -12,6 +12,8 @@ import "@openzeppelin/contracts/utils/Arrays.sol"; 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. @@ -66,7 +68,9 @@ library SortedArrays { uint256 last = array[array.length - 1]; // values must be pushed in order - require(val >= last, "pushSorted: decreasing values"); + if (val < last) { + revert DecreasingValues(val, last); + } // don't push duplicate values if (val != last) { diff --git a/test/gas-report/checkpoints.js b/test/gas-report/checkpoints.js index 17890a59..f73ba31e 100644 --- a/test/gas-report/checkpoints.js +++ b/test/gas-report/checkpoints.js @@ -14,6 +14,7 @@ describe("checkpoint bonding state gas report", () => { let controller let bondingManager + let bondingVotes let roundsManager let token @@ -21,6 +22,7 @@ describe("checkpoint bonding state gas report", () => { let delegator const stake = 1000 + let currentRound let signers @@ -69,16 +71,20 @@ describe("checkpoint bonding state gas report", () => { 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") ) - const newBondingVotes = await ethers + bondingVotes = await ethers .getContractFactory("BondingVotes") .then(fac => fac.deploy(controller.address)) await controller.setContractInfo( contractId("BondingVotes"), - newBondingVotes.address, + bondingVotes.address, gitCommitHash ) }) @@ -91,15 +97,45 @@ describe("checkpoint bonding state gas report", () => { await rpc.revert(snapshotId) }) - it("checkpoint delegator", async () => { - await bondingManager.checkpointBondingState(delegator.address) - }) + describe("checkpointBondingState", () => { + it("delegator", async () => { + await bondingManager.checkpointBondingState(delegator.address) + }) + + it("transcoder", async () => { + await bondingManager.checkpointBondingState(transcoder.address) + }) - it("checkpoint transcoder", async () => { - await bondingManager.checkpointBondingState(transcoder.address) + it("non-participant", async () => { + await bondingManager.checkpointBondingState(signers[99].address) + }) }) - it("checkpoint 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/unit/BondingVotes.js b/test/unit/BondingVotes.js index 616fd181..1304bca6 100644 --- a/test/unit/BondingVotes.js +++ b/test/unit/BondingVotes.js @@ -102,7 +102,10 @@ describe("BondingVotes", () => { const customErrorAbi = (sig, args) => { const iface = new ethers.utils.Interface([`function ${sig}`]) const funcDataHex = iface.encodeFunctionData(sig, args) - const abi = Buffer.from(funcDataHex, "hex") + 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() } @@ -136,7 +139,7 @@ describe("BondingVotes", () => { functionData ) ).to.be.revertedWith( - customErrorAbi("FutureCheckpoint(uint256,uint256)", [ + customErrorAbi("FutureTotalStakeCheckpoint(uint256,uint256)", [ currentRound + 1, currentRound ]) @@ -330,7 +333,7 @@ describe("BondingVotes", () => { ) }) - it("should fail if checkpointing after next round", async () => { + it("should fail if checkpointing after the next round", async () => { const functionData = encodeCheckpointBondingState({ account: transcoder.address, startRound: currentRound + 2, @@ -347,21 +350,21 @@ describe("BondingVotes", () => { functionData ) ).to.be.revertedWith( - customErrorAbi("FutureCheckpoint(uint256,uint256)", [ + customErrorAbi("InvalidStartRound(uint256,uint256)", [ currentRound + 2, currentRound + 1 ]) ) }) - it("should fail if lastClaimRound is not lower than start round", async () => { + 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, + lastClaimRound: currentRound - 1, lastRewardRound: 0 }) @@ -371,9 +374,33 @@ describe("BondingVotes", () => { functionData ) ).to.be.revertedWith( - customErrorAbi("FutureLastClaimRound(uint256,uint256)", [ + customErrorAbi("InvalidStartRound(uint256,uint256)", [ currentRound, - currentRound - 1 + 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 ]) ) }) From e9d33395971c059452c77b78764faaafbb7b86ec Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Thu, 17 Aug 2023 17:36:23 -0300 Subject: [PATCH 22/29] treasury: Migrate to the new BondingVotes contract --- contracts/test/mocks/VotesMock.sol | 24 +-- .../treasury/BondingCheckpointsVotes.sol | 106 ---------- .../treasury/GovernorCountingOverridable.sol | 6 +- contracts/treasury/LivepeerGovernor.sol | 24 +-- deploy/deploy_livepeer_governor.ts | 6 - test/integration/LivepeerGovernor.ts | 43 ++-- test/unit/BondingCheckpointsVotes.js | 198 ------------------ 7 files changed, 34 insertions(+), 373 deletions(-) delete mode 100644 contracts/treasury/BondingCheckpointsVotes.sol delete mode 100644 test/unit/BondingCheckpointsVotes.js diff --git a/contracts/test/mocks/VotesMock.sol b/contracts/test/mocks/VotesMock.sol index 3f528fd6..737f2297 100644 --- a/contracts/test/mocks/VotesMock.sol +++ b/contracts/test/mocks/VotesMock.sol @@ -24,9 +24,6 @@ contract VotesMock is ERC20VotesUpgradeable, IVotes { - mapping(address => uint256[]) private _delegateChangingTimes; - mapping(address => mapping(uint256 => address)) private _delegatedAtTime; - function initialize() public initializer { __ERC20_init("VotesMock", "VTCK"); __ERC20Burnable_init(); @@ -35,23 +32,10 @@ contract VotesMock is } function delegatedAt(address _account, uint256 _timepoint) external view returns (address) { - uint256[] storage rounds = _delegateChangingTimes[_account]; - if (rounds.length == 0 || _timepoint < rounds[0]) { - return address(0); - } - - uint256 prevRound = SortedArrays.findLowerBound(rounds, _timepoint); - return _delegatedAtTime[_account][prevRound]; - } - - function _delegate(address _delegator, address _to) internal override { - super._delegate(_delegator, _to); - - uint256 currTime = clock(); - - uint256[] storage rounds = _delegateChangingTimes[_delegator]; - SortedArrays.pushSorted(rounds, currTime); - _delegatedAtTime[_delegator][currTime] = _to; + _timepoint; // unused + // Blatant simplification that only works in our tests where we never change participants balance during + // proposal voting period. We check and return delegators current state instead of tracking historical values. + return delegates(_account); } /** diff --git a/contracts/treasury/BondingCheckpointsVotes.sol b/contracts/treasury/BondingCheckpointsVotes.sol deleted file mode 100644 index 0dc73906..00000000 --- a/contracts/treasury/BondingCheckpointsVotes.sol +++ /dev/null @@ -1,106 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.9; - -import "../Manager.sol"; -import "../bonding/IBondingCheckpoints.sol"; -import "./GovernorCountingOverridable.sol"; - -/** - * @title Interface for BondingCheckpoints - */ -contract BondingCheckpointsVotes is Manager, IVotes { - // Indicates that the called function is not supported in this contract and should be performed through the - // BondingManager instead. This is mostly used for delegation methods, which must be bonds instead. - error MustCallBondingManager(string bondingManagerFunction); - - constructor(address _controller) Manager(_controller) {} - - /** - * @notice Clock is set to match the current round, which is the checkpointing - * method implemented here. - */ - function clock() public view returns (uint48) { - return bondingCheckpoints().clock(); - } - - /** - * @notice Machine-readable description of the clock as specified in EIP-6372. - */ - // solhint-disable-next-line func-name-mixedcase - function CLOCK_MODE() public view returns (string memory) { - return bondingCheckpoints().CLOCK_MODE(); - } - - /** - * @notice Returns the current amount of votes that `_account` has. - */ - function getVotes(address _account) external view returns (uint256) { - return getPastVotes(_account, clock()); - } - - /** - * @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. - */ - function getPastVotes(address _account, uint256 _round) public view returns (uint256) { - (uint256 amount, ) = bondingCheckpoints().getBondingStateAt(_account, _round); - return amount; - } - - /** - * @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. - */ - function getPastTotalSupply(uint256 _round) external view returns (uint256) { - return bondingCheckpoints().getTotalActiveStakeAt(_round); - } - - /** - * @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). - */ - function delegates(address _account) external view returns (address) { - return delegatedAt(_account, clock()); - } - - /** - * @notice Returns the delegate that _account had chosen in a specific round in the past. See `delegates()` above - * for more details. - * @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 {GovernorVotesBondingCheckpoints-_handleVoteOverrides}. - */ - function delegatedAt(address _account, uint256 _round) public view returns (address) { - (, address delegateAddress) = bondingCheckpoints().getBondingStateAt(_account, _round); - return delegateAddress; - } - - /** - * @notice Delegation through BondingCheckpoints is not supported. - */ - function delegate(address) external pure { - revert MustCallBondingManager("bond"); - } - - /** - * @notice Delegation through BondingCheckpoints is not supported. - */ - function delegateBySig( - address, - uint256, - uint256, - uint8, - bytes32, - bytes32 - ) external pure { - revert MustCallBondingManager("bond"); - } - - /** - * @dev Returns the BondingCheckpoints contract. - */ - function bondingCheckpoints() internal view returns (IBondingCheckpoints) { - return IBondingCheckpoints(controller.getContract(keccak256("BondingCheckpoints"))); - } -} diff --git a/contracts/treasury/GovernorCountingOverridable.sol b/contracts/treasury/GovernorCountingOverridable.sol index 949bc0d8..6d9439c2 100644 --- a/contracts/treasury/GovernorCountingOverridable.sol +++ b/contracts/treasury/GovernorCountingOverridable.sol @@ -11,11 +11,7 @@ import "../bonding/libraries/EarningsPoolLIP36.sol"; import "../Manager.sol"; import "../IController.sol"; import "../rounds/IRoundsManager.sol"; -import "../bonding/IBondingCheckpoints.sol"; - -interface IVotes is IERC5805Upgradeable { - function delegatedAt(address account, uint256 timepoint) external returns (address); -} +import "./IVotes.sol"; /** * @title GovernorCountingOverridable diff --git a/contracts/treasury/LivepeerGovernor.sol b/contracts/treasury/LivepeerGovernor.sol index d5ea08f1..0587072a 100644 --- a/contracts/treasury/LivepeerGovernor.sol +++ b/contracts/treasury/LivepeerGovernor.sol @@ -16,7 +16,6 @@ import "../ManagerProxyTarget.sol"; import "../IController.sol"; import "../rounds/IRoundsManager.sol"; import "./GovernorCountingOverridable.sol"; -import "./BondingCheckpointsVotes.sol"; /** * @title LivepeerGovernor @@ -48,8 +47,8 @@ contract LivepeerGovernor is /** * Initializes the LivepeerGovernor instance. This requires the following contracts to have already been deployed * and registered on the controller: - * - "Treasury" (TimelockControllerUpgradeable) - * - "BondingCheckpointsVotes" + * - "Treasury" + * - "BondingVotes" * - "PollCreator" */ function initialize( @@ -62,7 +61,7 @@ contract LivepeerGovernor is __GovernorTimelockControl_init(treasury()); // The GovernorVotes module will hold a fixed reference to the votes contract. If we ever change its address we - // need to call the {bumpVotesAddress} function to update it in here as well. + // need to call the {bumpGovernorVotesTokenAddress} function to update it in here as well. __GovernorVotes_init(votes()); // Initialize with the same value from the existing polling system. @@ -84,7 +83,7 @@ contract LivepeerGovernor is * @dev See {GovernorCountingOverridable-votes}. */ function votes() public view override returns (IVotes) { - return bondingCheckpointVotes(); + return bondingVotes(); } /** @@ -96,20 +95,19 @@ contract LivepeerGovernor is } /** - * @dev This should be called if we ever change the address of the BondingCheckpointsVotes contract. It is a simple - * non upgradeable proxy to the BondingCheckpoints not to require any upgrades, but its address could still - * eventually change in the controller so we provide this function as a future-proof commodity. This function is - * callable by anyone because always fetch the current address from the controller, so it's not exploitable. + * @dev This should be called if we ever change the address of the BondingVotes contract. Not a normal flow, but its + * address could still eventually change in the controller so we provide this function as a future-proof commodity. + * This is callable by anyone because it always fetches the current address from the controller, so not exploitable. */ - function bumpVotesAddress() external { + function bumpGovernorVotesTokenAddress() external { token = votes(); } /** - * @dev Returns the BondingCheckpointsVotes contract address from the controller. + * @dev Returns the BondingVotes contract address from the controller. */ - function bondingCheckpointVotes() internal view returns (BondingCheckpointsVotes) { - return BondingCheckpointsVotes(controller.getContract(keccak256("BondingCheckpointsVotes"))); + function bondingVotes() internal view returns (IVotes) { + return IVotes(controller.getContract(keccak256("BondingVotes"))); } /** diff --git a/deploy/deploy_livepeer_governor.ts b/deploy/deploy_livepeer_governor.ts index bac4868f..1d58d36b 100644 --- a/deploy/deploy_livepeer_governor.ts +++ b/deploy/deploy_livepeer_governor.ts @@ -39,12 +39,6 @@ const func: DeployFunction = async function(hre: HardhatRuntimeEnvironment) { await contractDeployer.register("PollCreator", pollCreator.address) } - await contractDeployer.deployAndRegister({ - contract: "BondingCheckpointsVotes", - name: "BondingCheckpointsVotes", - args: [controller.address] - }) - // Onchain treasury governor (LivepeerGovernor) const treasury = await contractDeployer.deployAndRegister({ contract: "Treasury", diff --git a/test/integration/LivepeerGovernor.ts b/test/integration/LivepeerGovernor.ts index ee1beedd..847c75ef 100644 --- a/test/integration/LivepeerGovernor.ts +++ b/test/integration/LivepeerGovernor.ts @@ -8,8 +8,8 @@ import {contractId} from "../../utils/helpers" import setupIntegrationTest from "../helpers/setupIntegrationTest" import { AdjustableRoundsManager, - BondingCheckpointsVotes, BondingManager, + BondingVotes, Controller, GovernorInterfacesFixture, LivepeerGovernor, @@ -30,7 +30,7 @@ describe("LivepeerGovernor", () => { let roundsManager: AdjustableRoundsManager let bondingManager: BondingManager - let bondingCheckpointsVotes: BondingCheckpointsVotes + let bondingVotes: BondingVotes let token: LivepeerToken let pollCreator: PollCreator @@ -63,9 +63,9 @@ describe("LivepeerGovernor", () => { "BondingManager", fixture.BondingManager.address ) - bondingCheckpointsVotes = await ethers.getContractAt( - "BondingCheckpointsVotes", - fixture.BondingCheckpointsVotes.address + bondingVotes = await ethers.getContractAt( + "BondingVotes", + fixture.BondingVotes.address ) token = await ethers.getContractAt( "LivepeerToken", @@ -464,42 +464,35 @@ describe("LivepeerGovernor", () => { describe("voting module", () => { it("should use BondingCheckpointVotes as the token", async () => { const tokenAddr = await governor.token() - assert.equal(tokenAddr, bondingCheckpointsVotes.address) + assert.equal(tokenAddr, bondingVotes.address) }) - describe("bumpVotesAddress()", () => { - let newBondingCheckpointsVotes: BondingCheckpointsVotes + describe("bumpGovernorVotesTokenAddress()", () => { + let newBondingVotes: BondingVotes before(async () => { - const factory = await ethers.getContractFactory( - "BondingCheckpoints" - ) - newBondingCheckpointsVotes = (await factory.deploy( + const factory = await ethers.getContractFactory("BondingVotes") + newBondingVotes = (await factory.deploy( controller.address - )) as BondingCheckpointsVotes + )) as BondingVotes - const id = contractId("BondingCheckpointsVotes") + // Replace the proxy directly + const id = contractId("BondingVotes") const [, gitCommitHash] = await controller.getContractInfo(id) await controller.setContractInfo( id, - newBondingCheckpointsVotes.address, + newBondingVotes.address, gitCommitHash ) }) it("should not update the reference automatically", async () => { - assert.equal( - await governor.token(), - bondingCheckpointsVotes.address - ) + assert.equal(await governor.token(), bondingVotes.address) }) - it("should update reference after calling bumpVotesAddress", async () => { - await governor.bumpVotesAddress() - assert.equal( - await governor.token(), - newBondingCheckpointsVotes.address - ) + it("should update reference after calling bumpGovernorVotesTokenAddress", async () => { + await governor.bumpGovernorVotesTokenAddress() + assert.equal(await governor.token(), newBondingVotes.address) }) }) diff --git a/test/unit/BondingCheckpointsVotes.js b/test/unit/BondingCheckpointsVotes.js deleted file mode 100644 index 2278edc3..00000000 --- a/test/unit/BondingCheckpointsVotes.js +++ /dev/null @@ -1,198 +0,0 @@ -import Fixture from "./helpers/Fixture" -import {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" - -chai.use(solidity) -const {expect} = chai - -describe("BondingCheckpointsVotes", () => { - let signers - let fixture - - let bondingCheckpointsVotes - - before(async () => { - signers = await ethers.getSigners() - - fixture = new Fixture(web3) - await fixture.deploy() - - const bondingCheckpointsVotesFac = await ethers.getContractFactory( - "BondingCheckpointsVotes" - ) - - bondingCheckpointsVotes = await fixture.deployAndRegister( - bondingCheckpointsVotesFac, - "BondingCheckpointsVotes", - fixture.controller.address - ) - }) - - beforeEach(async () => { - await fixture.setUp() - }) - - afterEach(async () => { - await fixture.tearDown() - }) - - describe("IERC6372Upgradeable", () => { - describe("clock", () => { - it("should proxy to BondingCheckpoints", async () => { - await fixture.bondingCheckpoints.setMockUint256( - functionSig("clock()"), - 12348 - ) - assert.equal(await bondingCheckpointsVotes.clock(), 12348) - }) - }) - - describe("CLOCK_MODE", () => { - it("should proxy to BondingCheckpoints", async () => { - assert.equal( - await bondingCheckpointsVotes.CLOCK_MODE(), - // BondingCheckpointsMock returns this - "mode=cuckoo&species=dasylophus_superciliosus" - ) - }) - }) - }) - - // Same implementation as the BondingCheckpointsMock - const mockGetBondingStateAt = (_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()) - ] - } - - describe("get(Past)?Votes", () => { - it("getPastVotes should proxy to BondingCheckpoints.getBondingStateAt", async () => { - const testOnce = async (account, round) => { - const [expected] = mockGetBondingStateAt(account.address, round) - - const votes = await bondingCheckpointsVotes.getPastVotes( - account.address, - round - ) - assert.equal(votes.toNumber(), expected) - } - - await testOnce(signers[0], 123) - await testOnce(signers[1], 256) - await testOnce(signers[2], 34784) - }) - - it("getVotes should query with the current round", async () => { - const testOnce = async (account, round) => { - await fixture.bondingCheckpoints.setMockUint256( - functionSig("clock()"), - round - ) - const [expected] = mockGetBondingStateAt(account.address, round) - - const votes = await bondingCheckpointsVotes.getVotes( - account.address - ) - assert.equal(votes.toNumber(), expected) - } - - await testOnce(signers[3], 321) - await testOnce(signers[4], 652) - await testOnce(signers[5], 48743) - }) - }) - - describe("delegate(s|dAt)", () => { - it("delegatedAt should proxy to BondingCheckpoints.getBondingStateAt", async () => { - const testOnce = async (account, round) => { - const [, expected] = mockGetBondingStateAt( - account.address, - round - ) - - const delegate = await bondingCheckpointsVotes.delegatedAt( - account.address, - round - ) - assert.equal(delegate, expected) - } - - await testOnce(signers[6], 123) - await testOnce(signers[7], 256) - await testOnce(signers[8], 34784) - }) - - it("delegates should query with the current round", async () => { - const testOnce = async (account, round) => { - await fixture.bondingCheckpoints.setMockUint256( - functionSig("clock()"), - round - ) - const [, expected] = mockGetBondingStateAt( - account.address, - round - ) - - assert.equal( - await bondingCheckpointsVotes.delegates(account.address), - expected - ) - } - - await testOnce(signers[9], 321) - await testOnce(signers[10], 652) - await testOnce(signers[11], 48743) - }) - }) - - describe("getPastTotalSupply", () => { - it("should proxy to BondingCheckpoints.getTotalActiveStakeAt", async () => { - const testOnce = async round => { - const expected = 4 * round // same as BondingCheckpointsMock impl - - const totalSupply = - await bondingCheckpointsVotes.getPastTotalSupply(round) - assert.equal(totalSupply.toNumber(), expected) - } - - await testOnce(213) - await testOnce(526) - await testOnce(784347) - }) - }) - - describe("delegation", () => { - it("should fail to call delegate", async () => { - await expect( - bondingCheckpointsVotes - .connect(signers[0]) - .delegate(signers[1].address) - ).to.be.revertedWith("MustCallBondingManager()") - }) - - it("should fail to call delegateBySig", async () => { - await expect( - bondingCheckpointsVotes.delegateBySig( - signers[1].address, - 420, - 1689794400, - 171, - ethers.utils.hexZeroPad("0xfacade", 32), - ethers.utils.hexZeroPad("0xdeadbeef", 32) - ) - ).to.be.revertedWith("MustCallBondingManager()") - }) - }) -}) From f0fb8ee4424132d63fabb8272efb5ee29134b4dc Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Thu, 17 Aug 2023 20:27:36 -0300 Subject: [PATCH 23/29] treasury: Address PR comments --- .../GovernorCountingOverridableHarness.sol | 6 +-- .../treasury/GovernorCountingOverridable.sol | 40 +++++++++---------- contracts/treasury/LivepeerGovernor.sol | 24 ++--------- deploy/deploy_livepeer_governor.ts | 22 ++-------- deploy/migrations.config.ts | 6 ++- test/integration/LivepeerGovernor.ts | 10 +++-- 6 files changed, 40 insertions(+), 68 deletions(-) diff --git a/contracts/test/mocks/GovernorCountingOverridableHarness.sol b/contracts/test/mocks/GovernorCountingOverridableHarness.sol index 028c1773..15628268 100644 --- a/contracts/test/mocks/GovernorCountingOverridableHarness.sol +++ b/contracts/test/mocks/GovernorCountingOverridableHarness.sol @@ -37,17 +37,13 @@ contract GovernorCountingOverridableHarness is ); __GovernorVotes_init(iVotes); - __GovernorCountingOverridable_init(); + __GovernorCountingOverridable_init(QUOTA); } function votes() public view override returns (IVotes) { return iVotes; } - function quota() public pure override returns (uint256) { - return QUOTA; - } - function quorum(uint256 timepoint) public view virtual override returns (uint256) { uint256 totalSupply = iVotes.getPastTotalSupply(timepoint); return MathUtils.percOf(totalSupply, QUORUM); diff --git a/contracts/treasury/GovernorCountingOverridable.sol b/contracts/treasury/GovernorCountingOverridable.sol index 6d9439c2..fa3385cc 100644 --- a/contracts/treasury/GovernorCountingOverridable.sol +++ b/contracts/treasury/GovernorCountingOverridable.sol @@ -2,7 +2,7 @@ pragma solidity 0.8.9; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import "@openzeppelin/contracts-upgradeable/governance/extensions/GovernorVotesQuorumFractionUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/GovernorUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/interfaces/IERC5805Upgradeable.sol"; import "../bonding/libraries/EarningsPool.sol"; @@ -19,17 +19,9 @@ import "./IVotes.sol"; * delegated transcoder's vote. This module is used through inheritance by the Governor contract. */ abstract contract GovernorCountingOverridable is Initializable, GovernorUpgradeable { - using SafeMath for uint256; - error InvalidVoteType(uint8 voteType); error VoteAlreadyCast(); - function __GovernorCountingOverridable_init() internal onlyInitializing { - __GovernorCountingOverridable_init_unchained(); - } - - function __GovernorCountingOverridable_init_unchained() internal onlyInitializing {} - /** * @dev Supported vote types. Matches Governor Bravo ordering. */ @@ -60,8 +52,23 @@ abstract contract GovernorCountingOverridable is Initializable, GovernorUpgradea mapping(address => ProposalVoterState) voters; } + /** + * @notice The required percentage of "for" votes in relation to the total opinionated votes (for and abstain) for + * a proposal to succeed. Represented as a MathUtils percentage value (e.g. 6 decimal places). + */ + uint256 public quota; + + // Maps proposal IDs to their corresponding vote tallies. mapping(uint256 => ProposalTally) private _proposalTallies; + function __GovernorCountingOverridable_init(uint256 _quota) internal onlyInitializing { + __GovernorCountingOverridable_init_unchained(_quota); + } + + function __GovernorCountingOverridable_init_unchained(uint256 _quota) internal onlyInitializing { + quota = _quota; + } + /** * @dev See {IGovernor-COUNTING_MODE}. */ @@ -100,7 +107,7 @@ abstract contract GovernorCountingOverridable is Initializable, GovernorUpgradea function _quorumReached(uint256 _proposalId) internal view virtual override returns (bool) { (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes) = proposalVotes(_proposalId); - uint256 totalVotes = againstVotes.add(forVotes).add(abstainVotes); + uint256 totalVotes = againstVotes + forVotes + abstainVotes; return totalVotes >= quorum(proposalSnapshot(_proposalId)); } @@ -112,9 +119,9 @@ abstract contract GovernorCountingOverridable is Initializable, GovernorUpgradea (uint256 againstVotes, uint256 forVotes, ) = proposalVotes(_proposalId); // we ignore abstain votes for vote succeeded calculation - uint256 totalValidVotes = againstVotes.add(forVotes); + uint256 opinionatedVotes = againstVotes + forVotes; - return forVotes >= MathUtils.percOf(totalValidVotes, quota()); + return forVotes >= MathUtils.percOf(opinionatedVotes, quota); } /** @@ -148,7 +155,6 @@ abstract contract GovernorCountingOverridable is Initializable, GovernorUpgradea } else if (support == VoteType.For) { tally.forVotes += _weight; } else { - assert(support == VoteType.Abstain); tally.abstainVotes += _weight; } } @@ -210,16 +216,10 @@ abstract contract GovernorCountingOverridable is Initializable, GovernorUpgradea */ function votes() public view virtual returns (IVotes); - /** - * @dev Implement in inheriting contract to provide quota value to use to decide proposal success. - * @return quota value as a MathUtils percentage value (e.g. 6 decimal places). - */ - function quota() public view virtual returns (uint256); - /** * @dev This empty reserved space is put in place to allow future versions to add new * variables without shifting down storage in the inheritance chain. * See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps */ - uint256[50] private __gap; + uint256[48] private __gap; } diff --git a/contracts/treasury/LivepeerGovernor.sol b/contracts/treasury/LivepeerGovernor.sol index 0587072a..6a787a41 100644 --- a/contracts/treasury/LivepeerGovernor.sol +++ b/contracts/treasury/LivepeerGovernor.sol @@ -10,7 +10,6 @@ import "@openzeppelin/contracts-upgradeable/governance/extensions/GovernorTimelo import "../bonding/libraries/EarningsPool.sol"; import "../bonding/libraries/EarningsPoolLIP36.sol"; -import "../polling/PollCreator.sol"; import "../ManagerProxyTarget.sol"; import "../IController.sol"; @@ -54,7 +53,9 @@ contract LivepeerGovernor is function initialize( uint256 initialVotingDelay, uint256 initialVotingPeriod, - uint256 initialProposalThreshold + uint256 initialProposalThreshold, + uint256 initialQuorum, + uint256 quota ) public initializer { __Governor_init("LivepeerGovernor"); __GovernorSettings_init(initialVotingDelay, initialVotingPeriod, initialProposalThreshold); @@ -64,11 +65,9 @@ contract LivepeerGovernor is // need to call the {bumpGovernorVotesTokenAddress} function to update it in here as well. __GovernorVotes_init(votes()); - // Initialize with the same value from the existing polling system. - uint256 initialQuorum = pollCreator().QUORUM(); __GovernorVotesQuorumFraction_init(initialQuorum); - __GovernorCountingOverridable_init(); + __GovernorCountingOverridable_init(quota); } /** @@ -86,14 +85,6 @@ contract LivepeerGovernor is return bondingVotes(); } - /** - * @dev See {GovernorCountingOverridable-quota}. We use the same QUOTA value from the protocol governance system for - * now, but can consider changing this in the future (e.g. to make it updateable through proposals without deploys). - */ - function quota() public view override returns (uint256) { - return pollCreator().QUOTA(); - } - /** * @dev This should be called if we ever change the address of the BondingVotes contract. Not a normal flow, but its * address could still eventually change in the controller so we provide this function as a future-proof commodity. @@ -110,13 +101,6 @@ contract LivepeerGovernor is return IVotes(controller.getContract(keccak256("BondingVotes"))); } - /** - * @dev Returns the PollCreator contract address from the controller. - */ - function pollCreator() internal view returns (PollCreator) { - return PollCreator(controller.getContract(keccak256("PollCreator"))); - } - /** * @dev Returns the Treasury contract address from the controller. */ diff --git a/deploy/deploy_livepeer_governor.ts b/deploy/deploy_livepeer_governor.ts index 1d58d36b..9e849ed0 100644 --- a/deploy/deploy_livepeer_governor.ts +++ b/deploy/deploy_livepeer_governor.ts @@ -15,7 +15,7 @@ const isProdNetwork = (name: string): boolean => { const func: DeployFunction = async function(hre: HardhatRuntimeEnvironment) { const {deployments, getNamedAccounts} = hre // Get the deployments and getNamedAccounts which are provided by hardhat-deploy - const {deploy, get} = deployments // the deployments object itself contains the deploy function + const {deploy} = deployments // the deployments object itself contains the deploy function const {deployer} = await getNamedAccounts() // Fetch named accounts from hardhat.config.ts @@ -24,22 +24,6 @@ const func: DeployFunction = async function(hre: HardhatRuntimeEnvironment) { const contractDeployer = new ContractDeployer(deploy, deployer, deployments) const controller = await contractDeployer.fetchDeployedController() - // PollCreator is deployed without being registered to Controller, so we do that here - const registeredPollCreator = await controller.getContract( - ethers.utils.solidityKeccak256(["string"], ["PollCreator"]) - ) - if (registeredPollCreator === constants.AddressZero) { - const pollCreator = await ethers.getContractAt( - "PollCreator", - isProdNetwork(hre.network.name) ? - config.livepeerGovernor.pollCreatorAddress : - await get("PollCreator").then(p => p.address) - ) - - await contractDeployer.register("PollCreator", pollCreator.address) - } - - // Onchain treasury governor (LivepeerGovernor) const treasury = await contractDeployer.deployAndRegister({ contract: "Treasury", name: "Treasury", @@ -71,7 +55,9 @@ const func: DeployFunction = async function(hre: HardhatRuntimeEnvironment) { await LivepeerGovernor.initialize( config.livepeerGovernor.initialVotingDelay, config.livepeerGovernor.initialVotingPeriod, - config.livepeerGovernor.initialProposalThreshold + config.livepeerGovernor.initialProposalThreshold, + config.livepeerGovernor.initialQuorum, + config.livepeerGovernor.quota ).then(tx => tx.wait()) // Now grant proposer and executor roles to governor and renounce deployer admin role diff --git a/deploy/migrations.config.ts b/deploy/migrations.config.ts index 43165c07..e9ecf23b 100644 --- a/deploy/migrations.config.ts +++ b/deploy/migrations.config.ts @@ -54,12 +54,14 @@ const defaultConfig = { targetBondingRate: 500000 }, treasury: { - minDelay: 0 // 0s initial proposal delay + minDelay: 0 // 0s initial proposal execution delay }, livepeerGovernor: { initialVotingDelay: 1, // 1 round initialVotingPeriod: 10, // 10 rounds - initialProposalThreshold: ethers.utils.parseEther("100") // 100 LPT + initialProposalThreshold: ethers.utils.parseEther("100"), // 100 LPT + initialQuorum: 333300, // 33% + quota: 500000 // 50% } } diff --git a/test/integration/LivepeerGovernor.ts b/test/integration/LivepeerGovernor.ts index 847c75ef..7a6c7d80 100644 --- a/test/integration/LivepeerGovernor.ts +++ b/test/integration/LivepeerGovernor.ts @@ -208,7 +208,7 @@ describe("LivepeerGovernor", () => { it("should not be initializable", async () => { // revert msg is misleading, but it's not initializable because initializers are disabled await expect( - governorTarget.initialize(0, 0, 0) + governorTarget.initialize(0, 0, 0, 0, 0) ).to.be.revertedWith( "Initializable: contract is already initialized" ) @@ -246,7 +246,9 @@ describe("LivepeerGovernor", () => { }) it("should not be re-initializable", async () => { - await expect(governor.initialize(0, 0, 0)).to.be.revertedWith( + await expect( + governor.initialize(0, 0, 0, 0, 0) + ).to.be.revertedWith( "Initializable: contract is already initialized" ) @@ -268,7 +270,9 @@ describe("LivepeerGovernor", () => { ) // should keep initialized state - await expect(governor.initialize(0, 0, 0)).to.be.revertedWith( + await expect( + governor.initialize(0, 0, 0, 0, 0) + ).to.be.revertedWith( "Initializable: contract is already initialized" ) From 9dbb0aeb4752fccad0d5f5370f38690661c8be90 Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Thu, 17 Aug 2023 20:31:39 -0300 Subject: [PATCH 24/29] bonding: Move constructor to after modifiers Just for consistency with other contracts. Some docs as a bonus --- contracts/bonding/BondingVotes.sol | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/contracts/bonding/BondingVotes.sol b/contracts/bonding/BondingVotes.sol index 58d0bd4c..0599cc8d 100644 --- a/contracts/bonding/BondingVotes.sol +++ b/contracts/bonding/BondingVotes.sol @@ -21,8 +21,6 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { using Arrays for uint256[]; using SortedArrays for uint256[]; - constructor(address _controller) Manager(_controller) {} - struct BondingCheckpoint { /** * @dev The amount of bonded tokens to another delegate as of the lastClaimRound. @@ -91,6 +89,12 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { _; } + /** + * @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. From b59a53804c2568139080ea41ac0e55c70ae37d8d Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Fri, 18 Aug 2023 14:32:09 -0300 Subject: [PATCH 25/29] test/mocks: Remove mock functions that moved to other mock --- contracts/test/mocks/BondingVotesMock.sol | 24 ----------------------- 1 file changed, 24 deletions(-) diff --git a/contracts/test/mocks/BondingVotesMock.sol b/contracts/test/mocks/BondingVotesMock.sol index 22b2684b..ffb090c7 100644 --- a/contracts/test/mocks/BondingVotesMock.sol +++ b/contracts/test/mocks/BondingVotesMock.sol @@ -38,28 +38,4 @@ contract BondingVotesMock is GenericMock { function checkpointTotalActiveStake(uint256 _totalStake, uint256 _round) external { emit CheckpointTotalActiveStake(_totalStake, _round); } - - function CLOCK_MODE() external pure returns (string memory) { - return "mode=cuckoo&species=dasylophus_superciliosus"; - } - - /** - * @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) - external - pure - returns (uint256 amount, address delegateAddress) - { - uint160 intAddr = uint160(_account); - - amount = (intAddr & 0xffffffff) + _round; - delegateAddress = address((intAddr << 4) | uint160(_round)); - } - - function getTotalActiveStakeAt(uint256 _round) external pure returns (uint256) { - return 4 * _round; - } } From 1692ec11e1cf29d3a26f29092b9e9fe12890b51d Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Mon, 21 Aug 2023 16:57:58 -0300 Subject: [PATCH 26/29] bonding: Implement ERC20 metadata on votes This is to increase compatibility with some tools out there like Tally, which require only these functions from the ERC20 spec, not the full implementation. So we can have these from the get-go to make things easier if we want to make something with them. --- contracts/bonding/BondingVotes.sol | 31 +++++++++++++++++++++++++- contracts/treasury/IVotes.sol | 8 +++++++ test/unit/BondingVotes.js | 35 ++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/contracts/bonding/BondingVotes.sol b/contracts/bonding/BondingVotes.sol index 0599cc8d..0e645450 100644 --- a/contracts/bonding/BondingVotes.sol +++ b/contracts/bonding/BondingVotes.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.9; import "@openzeppelin/contracts/utils/Arrays.sol"; import "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import "./libraries/EarningsPool.sol"; import "./libraries/EarningsPoolLIP36.sol"; @@ -98,6 +99,27 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { // 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 Stake"; + } + + /** + * @notice Returns the symbol of the token underlying the voting power. + */ + function symbol() external view returns (string memory) { + return livepeerToken().symbol(); + } + + /** + * @notice Returns the decimals places of the token underlying the voting. + */ + function decimals() external view returns (uint8) { + return livepeerToken().decimals(); + } + /** * @notice Clock is set to match the current round, which is the checkpointing * method implemented here. @@ -158,7 +180,7 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { * @notice Returns the delegate that _account had chosen in a specific round in the past. See `delegates()` above * for more details. * @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 {GovernorVotesBondingVotes-_handleVoteOverrides}. + * delegators to override their transcoders votes. See {GaovernorCountingOverridable-_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. @@ -494,6 +516,13 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { return IRoundsManager(controller.getContract(keccak256("RoundsManager"))); } + /** + * @dev Return LivepeerToken interface + */ + function livepeerToken() internal view returns (IERC20Metadata) { + return IERC20Metadata(controller.getContract(keccak256("LivepeerToken"))); + } + /** * @dev Ensure the sender is BondingManager */ diff --git a/contracts/treasury/IVotes.sol b/contracts/treasury/IVotes.sol index bfe4f41c..f251e265 100644 --- a/contracts/treasury/IVotes.sol +++ b/contracts/treasury/IVotes.sol @@ -11,4 +11,12 @@ interface IVotes is IERC5805Upgradeable { event DelegatorVotesChanged(address indexed delegate, uint256 previousBalance, uint256 newBalance); 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/test/unit/BondingVotes.js b/test/unit/BondingVotes.js index 1304bca6..b7ab1725 100644 --- a/test/unit/BondingVotes.js +++ b/test/unit/BondingVotes.js @@ -1088,6 +1088,41 @@ describe("BondingVotes", () => { }) }) + describe("IERC20 Metadata", () => { + describe("name", () => { + it("should return 'Livepeer Stake'", async () => { + assert.equal(await bondingVotes.name(), "Livepeer Stake") + }) + }) + + describe("symbol", () => { + beforeEach(async () => { + // easier to replace the token than mock a string on the GenericMock + const erc20Fac = await ethers.getContractFactory("ERC20") + await fixture.deployAndRegister( + erc20Fac, + "LivepeerToken", + "Mock Livepeer Token", + "LIVEPI" + ) + }) + + it("should proxy to LivepeerToken", async () => { + assert.equal(await bondingVotes.symbol(), "LIVEPI") + }) + }) + + describe("decimals", () => { + it("should proxy to LivepeerToken", async () => { + await fixture.token.setMockUint256( + functionSig("decimals()"), + 19 + ) + assert.equal(await bondingVotes.decimals(), 19) + }) + }) + }) + describe("IERC6372", () => { describe("clock", () => { let currentRound From fecf5a2e0e0cf76472ef1c23d543bc6f9512fb9f Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Tue, 22 Aug 2023 19:58:44 -0300 Subject: [PATCH 27/29] bonding: Address PR comments --- contracts/bonding/BondingManager.sol | 59 ++++++++++++++-------------- test/unit/BondingManager.js | 37 +++++++++++++---- 2 files changed, 58 insertions(+), 38 deletions(-) diff --git a/contracts/bonding/BondingManager.sol b/contracts/bonding/BondingManager.sol index 4ca75545..c3a64b76 100644 --- a/contracts/bonding/BondingManager.sol +++ b/contracts/bonding/BondingManager.sol @@ -124,9 +124,9 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { _; } - modifier autoCheckpoint(address account) { + modifier autoCheckpoint(address _account) { _; - checkpointBondingState(account); + checkpointBondingState(_account); } /** @@ -561,38 +561,13 @@ 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[_to]); - } - - /** - * @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 - ); + checkpointBondingState(_owner, del, transcoders[_owner]); } /** * @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. - * Implemented as a deploy utility to checkpoint the existing state when deploying the BondingVotes contract. - * @param _account The account to initialize the bonding checkpoint for + * @param _account The account to make the checkpoint for */ function checkpointBondingState(address _account) public { checkpointBondingState(_account, delegators[_account], transcoders[_account]); @@ -1433,7 +1408,7 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { /** * @dev Update a delegator with token pools shares from its lastClaimRound through a given round * - * Notice that this function udpates the delegator storage but does not checkpoint its state. Since it is internal + * 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 @@ -1526,6 +1501,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 diff --git a/test/unit/BondingManager.js b/test/unit/BondingManager.js index fab57d46..c4a85e7c 100644 --- a/test/unit/BondingManager.js +++ b/test/unit/BondingManager.js @@ -1853,6 +1853,9 @@ 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) @@ -1867,7 +1870,7 @@ describe("BondingManager", () => { delegateAddress: transcoder0.address, delegatedAmount: 2000, lastClaimRound: currentRound - 1, - lastRewardRound: 0 + lastRewardRound: 100 }, { account: delegator.address, @@ -2294,6 +2297,9 @@ 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 { @@ -2323,7 +2329,7 @@ describe("BondingManager", () => { delegateAddress: transcoder0.address, delegatedAmount: startDelegatedAmount.add(1000), lastClaimRound: currentRound - 1, - lastRewardRound: 0 + lastRewardRound: 100 }, { account: delegator1.address, @@ -2496,6 +2502,9 @@ describe("BondingManager", () => { }) 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( @@ -2508,7 +2517,7 @@ describe("BondingManager", () => { delegateAddress: transcoder.address, delegatedAmount: 1500, lastClaimRound: currentRound, - lastRewardRound: 0 + lastRewardRound: currentRound + 1 }, { account: delegator.address, @@ -3133,6 +3142,9 @@ describe("BondingManager", () => { }) 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) @@ -3147,7 +3159,7 @@ describe("BondingManager", () => { delegateAddress: transcoder.address, delegatedAmount: 2000, lastClaimRound: currentRound, - lastRewardRound: 0 + lastRewardRound: currentRound + 1 }, { account: delegator.address, @@ -3406,6 +3418,9 @@ describe("BondingManager", () => { }) 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) @@ -3423,7 +3438,7 @@ describe("BondingManager", () => { delegateAddress: transcoder.address, delegatedAmount: 1500, lastClaimRound: currentRound, - lastRewardRound: 0 + lastRewardRound: currentRound + 1 }, { account: delegator.address, @@ -3925,6 +3940,9 @@ describe("BondingManager", () => { }) 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( @@ -3946,7 +3964,7 @@ describe("BondingManager", () => { delegateAddress: transcoder0.address, delegatedAmount: 1200, lastClaimRound: currentRound - 1, - lastRewardRound: 0 + lastRewardRound: currentRound + 3 }, { account: delegator1.address, @@ -4489,7 +4507,7 @@ describe("BondingManager", () => { delegateAddress: transcoder.address, delegatedAmount: 2000, lastClaimRound: currentRound, - lastRewardRound: currentRound + 1 // then it's made again when the lastRewardRound is bumped + lastRewardRound: currentRound + 1 }) }) @@ -5122,6 +5140,9 @@ 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() @@ -5146,7 +5167,7 @@ describe("BondingManager", () => { delegateAddress: transcoder.address, delegatedAmount: startBondedAmount / 2, lastClaimRound: currentRound + 1, - lastRewardRound: 0 + lastRewardRound: currentRound + 1 }) }) From 42464742f53e3eac2c23f16ac1178b8810c22e8d Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Wed, 23 Aug 2023 17:03:17 -0300 Subject: [PATCH 28/29] bonding: Address BondingVotes review comments --- contracts/bonding/BondingManager.sol | 24 +-- contracts/bonding/BondingVotes.sol | 158 ++++++++------ contracts/bonding/IBondingManager.sol | 13 ++ contracts/bonding/IBondingVotes.sol | 13 +- contracts/treasury/IVotes.sol | 6 +- src/test/BondingVotesStateInitialization.sol | 88 +++----- test/unit/BondingVotes.js | 209 +++++++++++-------- 7 files changed, 272 insertions(+), 239 deletions(-) diff --git a/contracts/bonding/BondingManager.sol b/contracts/bonding/BondingManager.sol index c3a64b76..597e748e 100644 --- a/contracts/bonding/BondingManager.sol +++ b/contracts/bonding/BondingManager.sol @@ -126,7 +126,7 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { modifier autoCheckpoint(address _account) { _; - checkpointBondingState(_account); + _checkpointBondingState(_account, delegators[_account], transcoders[_account]); } /** @@ -204,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 @@ -561,16 +570,7 @@ 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]); - } - - /** - * @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) public { - checkpointBondingState(_account, delegators[_account], transcoders[_account]); + _checkpointBondingState(_owner, del, transcoders[_owner]); } /** @@ -1505,7 +1505,7 @@ contract BondingManager is ManagerProxyTarget, IBondingManager { * @notice Checkpoints a delegator state after changes, to be used for historical voting power calculations in * on-chain governor logic. */ - function checkpointBondingState( + function _checkpointBondingState( address _owner, Delegator storage _delegator, Transcoder storage _transcoder diff --git a/contracts/bonding/BondingVotes.sol b/contracts/bonding/BondingVotes.sol index 0e645450..2408c66e 100644 --- a/contracts/bonding/BondingVotes.sol +++ b/contracts/bonding/BondingVotes.sol @@ -3,16 +3,15 @@ pragma solidity 0.8.9; import "@openzeppelin/contracts/utils/Arrays.sol"; import "@openzeppelin/contracts/utils/math/SafeCast.sol"; -import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import "./libraries/EarningsPool.sol"; import "./libraries/EarningsPoolLIP36.sol"; import "./libraries/SortedArrays.sol"; import "../ManagerProxyTarget.sol"; -import "../IController.sol"; +import "./IBondingVotes.sol"; +import "./IBondingManager.sol"; import "../rounds/IRoundsManager.sol"; -import "./BondingManager.sol"; /** * @title BondingVotes @@ -90,6 +89,17 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { _; } + /** + * @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 @@ -103,21 +113,21 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { * @notice Returns the name of the virtual token implemented by this. */ function name() external pure returns (string memory) { - return "Livepeer Stake"; + return "Livepeer Voting Power"; } /** * @notice Returns the symbol of the token underlying the voting power. */ - function symbol() external view returns (string memory) { - return livepeerToken().symbol(); + function symbol() external pure returns (string memory) { + return "vLPT"; } /** * @notice Returns the decimals places of the token underlying the voting. */ - function decimals() external view returns (uint8) { - return livepeerToken().decimals(); + function decimals() external pure returns (uint8) { + return 18; } /** @@ -132,15 +142,19 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { * @notice Machine-readable description of the clock as specified in EIP-6372. */ // solhint-disable-next-line func-name-mixedcase - function CLOCK_MODE() public pure returns (string memory) { + 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) { - return getPastVotes(_account, clock()); + (uint256 amount, ) = getBondingStateAt(_account, clock() + 1); + return amount; } /** @@ -150,11 +164,24 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { * 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) public view returns (uint256) { + 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. @@ -164,28 +191,31 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { * 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) public view returns (uint256) { + 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) { - return delegatedAt(_account, clock()); + (, address delegateAddress) = getBondingStateAt(_account, clock() + 1); + return delegateAddress; } /** - * @notice Returns the delegate that _account had chosen in a specific round in the past. See `delegates()` above - * for more details. + * @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 {GaovernorCountingOverridable-_handleVoteOverrides}. + * 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) public view returns (address) { + function delegatedAt(address _account, uint256 _round) external view onlyPastRounds(_round) returns (address) { (, address delegateAddress) = getBondingStateAt(_account, _round + 1); return delegateAddress; } @@ -233,7 +263,7 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { uint256 _delegatedAmount, uint256 _lastClaimRound, uint256 _lastRewardRound - ) public virtual onlyBondingManager { + ) external virtual onlyBondingManager { if (_startRound != clock() + 1) { revert InvalidStartRound(_startRound, clock() + 1); } else if (_lastClaimRound >= _startRound) { @@ -260,43 +290,7 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { // to find it and lookup in the above mapping checkpoints.startRounds.pushSorted(_startRound); - onCheckpointChanged(_account, previous, bond); - } - - function onCheckpointChanged( - 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; - if (isTranscoder) { - emit DelegateVotesChanged(_account, previous.delegatedAmount, current.delegatedAmount); - } else if (wasTranscoder) { - // if the account stopped being a transcoder, we want to emit an event zeroing its "delegate votes" - emit DelegateVotesChanged(_account, previous.delegatedAmount, 0); - } - - // 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 DelegatorVotesChanged(_account, previous.bondedAmount, current.bondedAmount); - } - } - - /** - * @notice Returns whether an account already has any checkpoint. - * @dev This is meant to be called by a checkpoint initialization script once we deploy the checkpointing logic for - * the first time, so we can efficiently initialize the checkpoint state for all accounts in the system. - */ - function hasCheckpoint(address _account) public view returns (bool) { - return bondingCheckpoints[_account].startRounds.length > 0; + onBondingCheckpointChanged(_account, previous, bond); } /** @@ -306,15 +300,22 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { * @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) public virtual onlyBondingManager { - if (_round > clock()) { - revert FutureTotalStakeCheckpoint(_round, clock()); + 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 /** @@ -380,6 +381,36 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { } } + /** + * @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 @@ -505,8 +536,8 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { /** * @dev Return BondingManager interface */ - function bondingManager() internal view returns (BondingManager) { - return BondingManager(controller.getContract(keccak256("BondingManager"))); + function bondingManager() internal view returns (IBondingManager) { + return IBondingManager(controller.getContract(keccak256("BondingManager"))); } /** @@ -516,13 +547,6 @@ contract BondingVotes is ManagerProxyTarget, IBondingVotes { return IRoundsManager(controller.getContract(keccak256("RoundsManager"))); } - /** - * @dev Return LivepeerToken interface - */ - function livepeerToken() internal view returns (IERC20Metadata) { - return IERC20Metadata(controller.getContract(keccak256("LivepeerToken"))); - } - /** * @dev Ensure the sender is 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 index f684d75f..1fc4f358 100644 --- a/contracts/bonding/IBondingVotes.sol +++ b/contracts/bonding/IBondingVotes.sol @@ -1,18 +1,16 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.9; -import "@openzeppelin/contracts-upgradeable/interfaces/IERC6372Upgradeable.sol"; - import "../treasury/IVotes.sol"; /** * @title Interface for BondingVotes */ -interface IBondingVotes is IERC6372Upgradeable, IVotes { +interface IBondingVotes is IVotes { error InvalidCaller(address caller, address required); error InvalidStartRound(uint256 checkpointRound, uint256 requiredRound); error FutureLastClaimRound(uint256 lastClaimRound, uint256 maxAllowed); - error FutureTotalStakeCheckpoint(uint256 checkpointRound, uint256 maxAllowedRound); + error InvalidTotalStakeCheckpointRound(uint256 checkpointRound, uint256 requiredRound); error FutureLookup(uint256 queryRound, uint256 maxAllowed); error MissingEarningsPool(address transcoder, uint256 round); @@ -21,6 +19,13 @@ interface IBondingVotes is IERC6372Upgradeable, IVotes { // 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( diff --git a/contracts/treasury/IVotes.sol b/contracts/treasury/IVotes.sol index f251e265..56ec6baf 100644 --- a/contracts/treasury/IVotes.sol +++ b/contracts/treasury/IVotes.sol @@ -4,11 +4,7 @@ pragma solidity 0.8.9; import "@openzeppelin/contracts-upgradeable/interfaces/IERC5805Upgradeable.sol"; interface IVotes is IERC5805Upgradeable { - /** - * @dev Emitted when bonding change results in changes to a delegator's number of votes. This complements the events - * from IERC5805 by also supporting voting power for the delegators themselves. - */ - event DelegatorVotesChanged(address indexed delegate, uint256 previousBalance, uint256 newBalance); + function totalSupply() external view returns (uint256); function delegatedAt(address account, uint256 timepoint) external returns (address); diff --git a/src/test/BondingVotesStateInitialization.sol b/src/test/BondingVotesStateInitialization.sol index 8ea30fef..c90b26b8 100644 --- a/src/test/BondingVotesStateInitialization.sol +++ b/src/test/BondingVotesStateInitialization.sol @@ -104,56 +104,19 @@ contract BondingVotesStateInitialization is GovernorBaseTest { } } - function testDisallowsQueryingParticipantUncheckpointedAccount() public { + function testReturnsZeroBalanceForUncheckpointedAccount() public { uint256 currentRound = ROUNDS_MANAGER.currentRound(); - address[2] memory testAddresses = [DELEGATOR, TRANSCODER]; - for (uint256 i = 0; i < testAddresses.length; i++) { - CHEATS.expectRevert(IBondingVotes.NoRecordedCheckpoints.selector); - bondingVotes.getBondingStateAt(testAddresses[i], 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 testAllowsQueryingNonParticipantZeroedAccount() public { - uint256 currentRound = ROUNDS_MANAGER.currentRound(); - - (uint256 checkedAmount, address checkedDelegate) = bondingVotes.getBondingStateAt(nonParticipant, currentRound); - assertEq(checkedAmount, 0); - assertEq(checkedDelegate, address(0)); - } - - function testAllowsQueryingFullyUnbondedAccountOnNextRound() public { - // Revert to old bonding manager in this test so it doesn't make any checkpoints - stageAndExecuteOne( - address(CONTROLLER), - 0, - abi.encodeWithSelector( - CONTROLLER.setContractInfo.selector, - BONDING_MANAGER_TARGET_ID, - CURRENT_BONDING_MANAGER_TARGET, - gitCommitHash - ) - ); - - uint256 currentRound = ROUNDS_MANAGER.currentRound(); - uint256 pendingStake = BONDING_MANAGER.pendingStake(DELEGATOR, currentRound); - - CHEATS.prank(DELEGATOR); - BONDING_MANAGER.unbond(pendingStake); - - (uint256 bondedAmount, , , uint256 delegatedAmount, , uint256 lastClaimRound, ) = BONDING_MANAGER.getDelegator( - DELEGATOR - ); - assertTrue(lastClaimRound == currentRound && bondedAmount == 0 && delegatedAmount == 0); - - CHEATS.expectRevert(IBondingVotes.NoRecordedCheckpoints.selector); - bondingVotes.getBondingStateAt(DELEGATOR, currentRound); - - (uint256 checkedAmount, address checkedDelegate) = bondingVotes.getBondingStateAt(DELEGATOR, currentRound + 1); - assertEq(checkedAmount, 0); - assertEq(checkedDelegate, address(0)); - } - function testInitializesCheckpointState() public { uint256 currentRound = ROUNDS_MANAGER.currentRound(); @@ -163,15 +126,13 @@ contract BondingVotesStateInitialization is GovernorBaseTest { BONDING_MANAGER.checkpointBondingState(addr); assertTrue(bondingVotes.hasCheckpoint(addr)); - // Still doesn't allow lookup in the current round, checkpoint is made for the next - CHEATS.expectRevert( - abi.encodeWithSelector(IBondingVotes.PastLookup.selector, currentRound, currentRound + 1) - ); - bondingVotes.getBondingStateAt(addr, currentRound); + // 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. - // We don't check delegatedAmount for simplicity here, it is checked in he other tests. - (, address checkedDelegate) = bondingVotes.getBondingStateAt(addr, currentRound + 1); + (, checkedDelegate) = bondingVotes.getBondingStateAt(addr, currentRound + 1); assertEq( checkedDelegate, addr == DELEGATOR || addr == DELEGATOR_DELEGATE ? DELEGATOR_DELEGATE : addr == TRANSCODER @@ -218,19 +179,27 @@ contract BondingVotesStateInitialization is GovernorBaseTest { function testDoesNotHaveTotalActiveStakeImmediately() public { uint256 currentRound = ROUNDS_MANAGER.currentRound(); - CHEATS.expectRevert(IBondingVotes.NoRecordedCheckpoints.selector); - bondingVotes.getTotalActiveStakeAt(currentRound); + assertEq(bondingVotes.getTotalActiveStakeAt(currentRound), 0); } - function testDoesNotHaveTotalActiveStakeIfRoundNotInitialized() public { + 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); - assertEq(ROUNDS_MANAGER.currentRound(), currentRound + 1); + ROUNDS_MANAGER.initializeRound(); - CHEATS.expectRevert(IBondingVotes.NoRecordedCheckpoints.selector); - bondingVotes.getTotalActiveStakeAt(currentRound + 1); + 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 { @@ -241,8 +210,7 @@ contract BondingVotesStateInitialization is GovernorBaseTest { ROUNDS_MANAGER.initializeRound(); assertEq(ROUNDS_MANAGER.currentRound(), currentRound + 1); - CHEATS.expectRevert(abi.encodeWithSelector(IBondingVotes.PastLookup.selector, currentRound, currentRound + 1)); - bondingVotes.getTotalActiveStakeAt(currentRound); + assertEq(bondingVotes.getTotalActiveStakeAt(currentRound), 0); } function testUsesNextRoundTotalActiveStakeForCurrentRounds() public { diff --git a/test/unit/BondingVotes.js b/test/unit/BondingVotes.js index b7ab1725..1f8adc35 100644 --- a/test/unit/BondingVotes.js +++ b/test/unit/BondingVotes.js @@ -127,23 +127,31 @@ describe("BondingVotes", () => { ) }) - it("should fail if checkpointing after current round", async () => { - const functionData = encodeCheckpointTotalActiveStake( - 1337, - currentRound + 1 - ) + it("should fail if not checkpointing in the current round", async () => { + const rounds = [ + currentRound - 1, + currentRound + 1, + currentRound + 2 + ] - await expect( - fixture.bondingManager.execute( - bondingVotes.address, - functionData + for (const round of rounds) { + const functionData = encodeCheckpointTotalActiveStake( + 1337, + round ) - ).to.be.revertedWith( - customErrorAbi("FutureTotalStakeCheckpoint(uint256,uint256)", [ - currentRound + 1, - currentRound - ]) - ) + + 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 () => { @@ -223,7 +231,11 @@ describe("BondingVotes", () => { ) }) - it("should query next rounds value from next round total active stake", async () => { + it("should return nextRoundTotalActiveStake if querying after last checkpoint", async () => { + await fixture.roundsManager.setMockUint256( + functionSig("currentRound()"), + currentRound - 5 + ) const functionData = encodeCheckpointTotalActiveStake( 1337, currentRound - 5 @@ -232,6 +244,11 @@ describe("BondingVotes", () => { bondingVotes.address, functionData ) + + await fixture.roundsManager.setMockUint256( + functionSig("currentRound()"), + currentRound + ) await fixture.bondingManager.setMockUint256( functionSig("nextRoundTotalActiveStake()"), 1674 @@ -255,6 +272,10 @@ describe("BondingVotes", () => { ] for (const [totalStake, round] of roundStakes) { + await fixture.roundsManager.setMockUint256( + functionSig("currentRound()"), + round + ) const functionData = encodeCheckpointTotalActiveStake( totalStake, round @@ -284,6 +305,10 @@ describe("BondingVotes", () => { ] for (const [totalStake, round] of roundStakes) { + await fixture.roundsManager.setMockUint256( + functionSig("currentRound()"), + round + ) const functionData = encodeCheckpointTotalActiveStake( totalStake, round @@ -531,7 +556,7 @@ describe("BondingVotes", () => { transcoder.address ) await expect(tx) - .to.emit(bondingVotes, "DelegatorVotesChanged") + .to.emit(bondingVotes, "DelegatorBondedAmountChanged") .withArgs(delegator.address, 0, 1000) // Changing only bondedAmount @@ -544,7 +569,7 @@ describe("BondingVotes", () => { await expect(tx).not.to.emit(bondingVotes, "DelegateChanged") await expect(tx) - .to.emit(bondingVotes, "DelegatorVotesChanged") + .to.emit(bondingVotes, "DelegatorBondedAmountChanged") .withArgs(delegator.address, 1000, 2000) // Changing only delegateAddress @@ -557,7 +582,7 @@ describe("BondingVotes", () => { await expect(tx).not.to.emit( bondingVotes, - "DelegatorVotesChanged" + "DelegatorBondedAmountChanged" ) await expect(tx) .to.emit(bondingVotes, "DelegateChanged") @@ -589,7 +614,7 @@ describe("BondingVotes", () => { .withArgs(transcoder.address, 0, 50000) // Still emits a delegator event await expect(tx) - .to.emit(bondingVotes, "DelegatorVotesChanged") + .to.emit(bondingVotes, "DelegatorBondedAmountChanged") .withArgs(transcoder.address, 0, 20000) // Changing only delegatedAmount @@ -603,7 +628,7 @@ describe("BondingVotes", () => { await expect(tx).not.to.emit(bondingVotes, "DelegateChanged") await expect(tx).not.to.emit( bondingVotes, - "DelegatorVotesChanged" + "DelegatorBondedAmountChanged" ) await expect(tx) .to.emit(bondingVotes, "DelegateVotesChanged") @@ -630,7 +655,7 @@ describe("BondingVotes", () => { // Voting power as a delegator stayed the same await expect(tx).not.to.emit( bondingVotes, - "DelegatorVotesChanged" + "DelegatorBondedAmountChanged" ) }) }) @@ -1090,35 +1115,20 @@ describe("BondingVotes", () => { describe("IERC20 Metadata", () => { describe("name", () => { - it("should return 'Livepeer Stake'", async () => { - assert.equal(await bondingVotes.name(), "Livepeer Stake") + it("should return 'Livepeer Voting Power'", async () => { + assert.equal(await bondingVotes.name(), "Livepeer Voting Power") }) }) describe("symbol", () => { - beforeEach(async () => { - // easier to replace the token than mock a string on the GenericMock - const erc20Fac = await ethers.getContractFactory("ERC20") - await fixture.deployAndRegister( - erc20Fac, - "LivepeerToken", - "Mock Livepeer Token", - "LIVEPI" - ) - }) - - it("should proxy to LivepeerToken", async () => { - assert.equal(await bondingVotes.symbol(), "LIVEPI") + it("should return 'vLPT'", async () => { + assert.equal(await bondingVotes.symbol(), "vLPT") }) }) describe("decimals", () => { - it("should proxy to LivepeerToken", async () => { - await fixture.token.setMockUint256( - functionSig("decimals()"), - 19 - ) - assert.equal(await bondingVotes.decimals(), 19) + it("should return 18", async () => { + assert.equal(await bondingVotes.decimals(), 18) }) }) }) @@ -1152,10 +1162,12 @@ describe("BondingVotes", () => { }) }) - describe("IERC5805", () => { + describe("IVotes", () => { // redefine it here to avoid overriding top-level var let bondingVotes + const currentRound = 1000 + before(async () => { const HarnessFac = await ethers.getContractFactory( "BondingVotesERC5805Harness" @@ -1168,6 +1180,13 @@ describe("BondingVotes", () => { ) }) + beforeEach(async () => { + await fixture.roundsManager.setMockUint256( + functionSig("currentRound()"), + currentRound + ) + }) + // Same implementation as the BondingVotesERC5805Mock const mock = { getBondingStateAt: (_account, _round) => { @@ -1195,8 +1214,28 @@ describe("BondingVotes", () => { ) }) - describe("get(Past)?Votes", () => { - it("getPastVotes should proxy to getBondingStateAt from next round", async () => { + 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, @@ -1210,34 +1249,28 @@ describe("BondingVotes", () => { assert.equal(votes.toNumber(), expected) } - await testOnce(signers[0], 123) - await testOnce(signers[1], 256) - await testOnce(signers[2], 34784) + await testOnce(signers[1], 123) + await testOnce(signers[2], 256) + await testOnce(signers[3], 784) + await testOnce(signers[4], currentRound - 1) }) + }) - it("getVotes should query with the current round", async () => { - const testOnce = async (account, round) => { - await fixture.roundsManager.setMockUint256( - functionSig("currentRound()"), - round - ) - const [expected] = mock.getBondingStateAt( - account.address, - round + 1 - ) - - const votes = await bondingVotes.getVotes(account.address) - assert.equal(votes.toNumber(), expected) - } - - await testOnce(signers[3], 321) - await testOnce(signers[4], 652) - await testOnce(signers[5], 48743) + 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("delegate(s|dAt)", () => { - it("delegatedAt should proxy to BondingVotes.getBondingStateAt at next round", async () => { + describe("delegatedAt", () => { + it("should proxy to getBondingStateAt with next round", async () => { const testOnce = async (account, round) => { const [, expected] = mock.getBondingStateAt( account.address, @@ -1253,34 +1286,27 @@ describe("BondingVotes", () => { await testOnce(signers[6], 123) await testOnce(signers[7], 256) - await testOnce(signers[8], 34784) + await testOnce(signers[8], 784) + await testOnce(signers[9], 784) }) + }) - it("delegates should query with the current round", async () => { - const testOnce = async (account, round) => { - await fixture.roundsManager.setMockUint256( - functionSig("currentRound()"), - round - ) - const [, expected] = mock.getBondingStateAt( - account.address, - round + 1 - ) - - assert.equal( - await bondingVotes.delegates(account.address), - expected - ) - } + describe("totalSupply", () => { + it("should proxy to getTotalActiveStakeAt at next round", async () => { + const expected = mock.getTotalActiveStakeAt(currentRound + 1) - await testOnce(signers[9], 321) - await testOnce(signers[10], 652) - await testOnce(signers[11], 48743) + const totalSupply = await bondingVotes.totalSupply() + assert.equal(totalSupply.toNumber(), expected) }) }) describe("getPastTotalSupply", () => { - it("should proxy to getTotalActiveStakeAt at next round", async () => { + 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) @@ -1292,7 +1318,8 @@ describe("BondingVotes", () => { await testOnce(213) await testOnce(526) - await testOnce(784347) + await testOnce(784) + await testOnce(currentRound - 1) }) }) From 8c57a4d7620abd01512b06f9df08ae19685f8c6b Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Wed, 23 Aug 2023 17:32:18 -0300 Subject: [PATCH 29/29] treasury: Merge BondingCheckpoints and nits --- contracts/test/mocks/VotesMock.sol | 16 ++++++++++++++++ .../treasury/GovernorCountingOverridable.sol | 6 +++--- contracts/treasury/LivepeerGovernor.sol | 5 +++-- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/contracts/test/mocks/VotesMock.sol b/contracts/test/mocks/VotesMock.sol index 737f2297..c6530cb3 100644 --- a/contracts/test/mocks/VotesMock.sol +++ b/contracts/test/mocks/VotesMock.sol @@ -93,4 +93,20 @@ contract VotesMock is function _burn(address account, uint256 amount) internal override(ERC20Upgradeable, ERC20VotesUpgradeable) { super._burn(account, amount); } + + function name() public view override(IVotes, ERC20Upgradeable) returns (string memory) { + return super.name(); + } + + function symbol() public view override(IVotes, ERC20Upgradeable) returns (string memory) { + return super.symbol(); + } + + function decimals() public view override(IVotes, ERC20Upgradeable) returns (uint8) { + return super.decimals(); + } + + function totalSupply() public view override(IVotes, ERC20Upgradeable) returns (uint256) { + return super.totalSupply(); + } } diff --git a/contracts/treasury/GovernorCountingOverridable.sol b/contracts/treasury/GovernorCountingOverridable.sol index fa3385cc..ef7cb05d 100644 --- a/contracts/treasury/GovernorCountingOverridable.sol +++ b/contracts/treasury/GovernorCountingOverridable.sol @@ -52,15 +52,15 @@ abstract contract GovernorCountingOverridable is Initializable, GovernorUpgradea mapping(address => ProposalVoterState) voters; } + // Maps proposal IDs to their corresponding vote tallies. + mapping(uint256 => ProposalTally) private _proposalTallies; + /** * @notice The required percentage of "for" votes in relation to the total opinionated votes (for and abstain) for * a proposal to succeed. Represented as a MathUtils percentage value (e.g. 6 decimal places). */ uint256 public quota; - // Maps proposal IDs to their corresponding vote tallies. - mapping(uint256 => ProposalTally) private _proposalTallies; - function __GovernorCountingOverridable_init(uint256 _quota) internal onlyInitializing { __GovernorCountingOverridable_init_unchained(_quota); } diff --git a/contracts/treasury/LivepeerGovernor.sol b/contracts/treasury/LivepeerGovernor.sol index 6a787a41..3f72db2b 100644 --- a/contracts/treasury/LivepeerGovernor.sol +++ b/contracts/treasury/LivepeerGovernor.sol @@ -15,6 +15,7 @@ import "../ManagerProxyTarget.sol"; import "../IController.sol"; import "../rounds/IRoundsManager.sol"; import "./GovernorCountingOverridable.sol"; +import "./Treasury.sol"; /** * @title LivepeerGovernor @@ -104,8 +105,8 @@ contract LivepeerGovernor is /** * @dev Returns the Treasury contract address from the controller. */ - function treasury() internal view returns (TimelockControllerUpgradeable) { - return TimelockControllerUpgradeable(payable(controller.getContract(keccak256("Treasury")))); + function treasury() internal view returns (Treasury) { + return Treasury(payable(controller.getContract(keccak256("Treasury")))); } // The following functions are overrides required by Solidity.