diff --git a/.gitignore b/.gitignore index 9d91b783..e1161944 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,8 @@ docs/ # Dotenv file .env + +# Fuzzing +crytic-export/ +echidna/ +medusa/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index ee9c92b9..abee69e7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,6 +7,6 @@ [submodule "lib/v4-core"] path = lib/v4-core url = https://github.com/Uniswap/v4-core -[submodule "lib/solmate"] - path = lib/solmate - url = git@github.com:Transmissions11/solmate +[submodule "lib/chimera"] + path = lib/chimera + url = https://github.com/Recon-Fuzz/chimera diff --git a/Invariants.MD b/Invariants.MD new file mode 100644 index 00000000..c97ae35c --- /dev/null +++ b/Invariants.MD @@ -0,0 +1,10 @@ +Snapshot Solvency + + uint256 claim = _votesForInitiativeSnapshot.votes * boldAccrued / _votesSnapshot.votes; +For each initiative this is what the value is +If the initiative is "Claimable" this is what it receives +The call never reverts +The sum of claims is less than the boldAccrued + +Veto consistency + diff --git a/README.md b/README.md index 8faecc2f..c7ed93b6 100644 --- a/README.md +++ b/README.md @@ -44,48 +44,42 @@ In order to unstake and withdraw LQTY, a User must first deallocate a sufficient Initiative can be added permissionlessly, requiring the payment of a 100 BOLD fee, and in the following epoch become active for voting. During each snapshot, Initiatives which received as sufficient number of Votes that their incentive payout equals at least 500 BOLD, will be eligible to Claim ("minimum qualifying threshold"). Initiatives failing to meet the minimum qualifying threshold will not qualify to claim for that epoch. -Initiatives failing to meet the minimum qualifying threshold for a claim during four consecutive epochs may be deregistered permissionlessly, requiring -reregistration to become eligible for voting again. +Initiatives failing to meet the minimum qualifying threshold for a claim during four consecutive epochs may be deregistered permissionlessly, requiring reregistration to become eligible for voting again. -Claims for Initiatives which have met the minimum qualifying threshold, can be claimed permissionlessly, but must be claimed by the end of the epoch -in which they are awarded. Failure to do so will result in the unclaimed portion being reused in the following epoch. +Claims for Initiatives which have met the minimum qualifying threshold, can be claimed permissionlessly, but must be claimed by the end of the epoch in which they are awarded. Failure to do so will result in the unclaimed portion being reused in the following epoch. -As Initiatives are assigned to arbitrary addresses, they can be used for any purpose, including EOAs, Multisigs, or smart contracts designed -for targetted purposes. Smart contracts should be designed in a way that they can support BOLD and include any additional logic about -how BOLD is to be used. +As Initiatives are assigned to arbitrary addresses, they can be used for any purpose, including EOAs, Multisigs, or smart contracts designed for targetted purposes. Smart contracts should be designed in a way that they can support BOLD and include any additional logic about how BOLD is to be used. + +### Malicious Initiatives + +It's important to note that initiatives could be malicious, and the system does it's best effort to prevent any DOS to happen, however, a malicious initiative could drain all rewards if voted on. ## Voting -Users with LQTY staked in Governance.sol, can allocate LQTY in the same epoch in which they were deposited. But the -effective voting power at that point would be insignificant. +Users with LQTY staked in Governance.sol, can allocate LQTY in the same epoch in which they were deposited. But the effective voting power at that point would be insignificant. Votes can take two forms, a vote for an Initiative or a veto vote. Initiatives which have received vetoes which are both: three times greater than the minimum qualifying threshold, and greater than the number of votes for will not be eligible for claims by being excluded from the vote count and maybe deregistered as an Initiative. Users may split their votes for and veto votes across any number of initiatives. But cannot vote for and veto vote the same Initiative. -Each epoch is split into two parts, a six day period where both votes for and veto votes take place, and a final 24 hour period where votes -can only be made as veto votes. This is designed to give a period where any detrimental distributions can be mitigated should there be -sufficient will to do so by voters, but is not envisaged to be a regular occurance. +Each epoch is split into two parts, a six day period where both votes for and veto votes take place, and a final 24 hour period where votes can only be made as veto votes. This is designed to give a period where any detrimental distributions can be mitigated should there be sufficient will to do so by voters, but is not envisaged to be a regular occurance. ## Snapshots Snapshots of results from the voting activity of an epoch takes place on an initiative by initiative basis in a permissionless manner. -User interactions or direct calls following the closure of an epoch trigger the snapshot logic which makes a Claim available to a -qualifying Initiative. +User interactions or direct calls following the closure of an epoch trigger the snapshot logic which makes a Claim available to a qualifying Initiative. ## Bribing LQTY depositors can also receive bribes in the form of ERC20s in exchange for voting for a specified initiative. This is done externally to the Governance.sol logic and should be implemented at the initiative level. -BaseInitiative.sol is a reference implementation which allows for bribes to be set and paid in BOLD + another token, -all claims for bribes are made by directly interacting with the implemented BaseInitiative contract. +BaseInitiative.sol is a reference implementation which allows for bribes to be set and paid in BOLD + another token, all claims for bribes are made by directly interacting with the implemented BaseInitiative contract. ## Example Initiatives To facilitate the development of liquidity for BOLD and other relevant tokens after the launch of Liquity v2, initial example initiatives will be added. -They will be available from the first epoch in which claims are available (epoch 1), added in the construtor. Following epoch 1, these examples have -no further special status and can be removed by LQTY voters +They will be available from the first epoch in which claims are available (epoch 1), added in the construtor. Following epoch 1, these examples have no further special status and can be removed by LQTY voters ### Curve v2 @@ -95,3 +89,40 @@ Claiming and depositing to gauges must be done manually after each epoch in whic ### Uniswap v4 Simple hook for Uniswap v4 which implements a donate to a preconfigured pool. Allowing for adjustments to liquidity positions to make Claims which are smoothed over a vesting epoch. + +## Known Issues + +### Vetoed Initiatives and Initiatives that receive votes that are below the treshold cause a loss of emissions to the voted initiatives + +Because the system counts: valid_votes / total_votes +By definition, initiatives that increase the total_votes without receiving any rewards are stealing the rewards from other initiatives + +The rewards will be re-queued in the next epoch + +see: `test_voteVsVeto` as well as the miro and comments + +### User Votes, Initiative Votes and Global State Votes can desynchronize + +See `test_property_sum_of_lqty_global_user_matches_0` + +## Testing + +To run foundry, just +``` +forge test +``` + + +Please note the `TrophiesToFoundry`, which are repros of broken invariants, left failing on purpose + +### Invariant Testing + +We had a few issues with Medusa due to the use of `vm.warp`, we recommend using Echidna + +Run echidna with: + +``` +echidna . --contract CryticTester --config echidna.yaml +``` + +You can also run Echidna on Recon by simply pasting the URL of the Repo / Branch diff --git a/ToFix.MD b/ToFix.MD new file mode 100644 index 00000000..36cba9e8 --- /dev/null +++ b/ToFix.MD @@ -0,0 +1,31 @@ +- Add properties check to ensure that the math is sound <- HUGE, let's add it now + +A vote is: User TS * Votes +So an allocation should use that +We need to remove the data from the valid allocation +And not from a random one + +I think the best test is to simply store the contribution done +And see whether removing it is idempotent + +We would need a ton of work to make it even better + + +Specifically, if a user removes their votes, we need to see that reflect correctly +Because that's key + +- From there, try fixing with a reset on deposit and withdraw + +- Add a test that checks every: initiative, user allocation, ensure they are zero after a deposit and a withdrawal +- Add a test that checks every: X, ensure they use the correct TS + +- From there, reason around the deeper rounding errors + + + +Optimizations +Put the data in the storage +Remove all castings that are not safe +Invariant test it + +-- \ No newline at end of file diff --git a/echidna.yaml b/echidna.yaml new file mode 100644 index 00000000..21c707d2 --- /dev/null +++ b/echidna.yaml @@ -0,0 +1,10 @@ +testMode: "property" +prefix: "optimize_" +coverage: true +corpusDir: "echidna" +balanceAddr: 0x1043561a8829300000 +balanceContract: 0x1043561a8829300000 +filterFunctions: [] +cryticArgs: ["--foundry-compile-all"] + +shrinkLimit: 100000 diff --git a/lib/chimera b/lib/chimera new file mode 160000 index 00000000..d5cf52bc --- /dev/null +++ b/lib/chimera @@ -0,0 +1 @@ +Subproject commit d5cf52bc5bbf75f988f8aada23fd12d0bcf7798a diff --git a/lib/solmate b/lib/solmate deleted file mode 160000 index 97bdb200..00000000 --- a/lib/solmate +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 97bdb2003b70382996a79a406813f76417b1cf90 diff --git a/medusa.json b/medusa.json new file mode 100644 index 00000000..ea7baa00 --- /dev/null +++ b/medusa.json @@ -0,0 +1,88 @@ +{ + "fuzzing": { + "workers": 10, + "workerResetLimit": 50, + "timeout": 0, + "testLimit": 0, + "callSequenceLength": 100, + "corpusDirectory": "medusa", + "coverageEnabled": true, + "deploymentOrder": [ + "CryticTester" + ], + "targetContracts": [ + "CryticTester" + ], + "targetContractsBalances": [ + "0x27b46536c66c8e3000000" + ], + "constructorArgs": {}, + "deployerAddress": "0x30000", + "senderAddresses": [ + "0x10000", + "0x20000", + "0x30000" + ], + "blockNumberDelayMax": 60480, + "blockTimestampDelayMax": 604800, + "blockGasLimit": 125000000, + "transactionGasLimit": 12500000, + "testing": { + "stopOnFailedTest": false, + "stopOnFailedContractMatching": false, + "stopOnNoTests": true, + "testAllContracts": false, + "traceAll": false, + "assertionTesting": { + "enabled": true, + "testViewMethods": true, + "panicCodeConfig": { + "failOnCompilerInsertedPanic": false, + "failOnAssertion": true, + "failOnArithmeticUnderflow": false, + "failOnDivideByZero": false, + "failOnEnumTypeConversionOutOfBounds": false, + "failOnIncorrectStorageAccess": false, + "failOnPopEmptyArray": false, + "failOnOutOfBoundsArrayAccess": false, + "failOnAllocateTooMuchMemory": false, + "failOnCallUninitializedVariable": false + } + }, + "propertyTesting": { + "enabled": true, + "testPrefixes": [ + "crytic_" + ] + }, + "optimizationTesting": { + "enabled": false, + "testPrefixes": [ + "optimize_" + ] + } + }, + "chainConfig": { + "codeSizeCheckDisabled": true, + "cheatCodes": { + "cheatCodesEnabled": true, + "enableFFI": false + } + } + }, + "compilation": { + "platform": "crytic-compile", + "platformConfig": { + "target": ".", + "solcVersion": "", + "exportDirectory": "", + "args": [ + "--foundry-compile-all" + ] + } + }, + "logging": { + "level": "info", + "logDirectory": "" + } + } \ No newline at end of file diff --git a/remappings.txt b/remappings.txt index 41b68720..f2db83cf 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1 +1,4 @@ v4-core/=lib/v4-core/ +forge-std/=lib/forge-std/src/ +@chimera/=lib/chimera/src/ +openzeppelin/=lib/openzeppelin-contracts/ diff --git a/script/DeploySepolia.s.sol b/script/DeploySepolia.s.sol index 9b9443df..1a6f003a 100644 --- a/script/DeploySepolia.s.sol +++ b/script/DeploySepolia.s.sol @@ -92,10 +92,12 @@ contract DeploySepoliaScript is Script, Deployers { votingThresholdFactor: VOTING_THRESHOLD_FACTOR, minClaim: MIN_CLAIM, minAccrual: MIN_ACCRUAL, - epochStart: uint32(block.timestamp), + epochStart: uint32(block.timestamp - VESTING_EPOCH_START), + /// @audit Ensures that `initialInitiatives` can be voted on epochDuration: EPOCH_DURATION, epochVotingCutoff: EPOCH_VOTING_CUTOFF }), + deployer, initialInitiatives ); assert(governance == uniV4Donations.governance()); diff --git a/src/BribeInitiative.sol b/src/BribeInitiative.sol index dd313aec..e71f26ff 100644 --- a/src/BribeInitiative.sol +++ b/src/BribeInitiative.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -import {IERC20} from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; -import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "openzeppelin/contracts/interfaces/IERC20.sol"; +import {SafeERC20} from "openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IGovernance} from "./interfaces/IGovernance.sol"; import {IInitiative} from "./interfaces/IInitiative.sol"; @@ -10,6 +10,8 @@ import {IBribeInitiative} from "./interfaces/IBribeInitiative.sol"; import {DoubleLinkedList} from "./utils/DoubleLinkedList.sol"; +import {EncodingDecodingLib} from "src/utils/EncodingDecodingLib.sol"; + contract BribeInitiative is IInitiative, IBribeInitiative { using SafeERC20 for IERC20; using DoubleLinkedList for DoubleLinkedList.List; @@ -43,22 +45,19 @@ contract BribeInitiative is IInitiative, IBribeInitiative { } /// @inheritdoc IBribeInitiative - function totalLQTYAllocatedByEpoch(uint16 _epoch) external view returns (uint88) { - return totalLQTYAllocationByEpoch.getValue(_epoch); + function totalLQTYAllocatedByEpoch(uint16 _epoch) external view returns (uint88, uint120) { + return _loadTotalLQTYAllocation(_epoch); } /// @inheritdoc IBribeInitiative - function lqtyAllocatedByUserAtEpoch(address _user, uint16 _epoch) external view returns (uint88) { - return lqtyAllocationByUserAtEpoch[_user].getValue(_epoch); + function lqtyAllocatedByUserAtEpoch(address _user, uint16 _epoch) external view returns (uint88, uint120) { + return _loadLQTYAllocation(_user, _epoch); } /// @inheritdoc IBribeInitiative function depositBribe(uint128 _boldAmount, uint128 _bribeTokenAmount, uint16 _epoch) external { - bold.safeTransferFrom(msg.sender, address(this), _boldAmount); - bribeToken.safeTransferFrom(msg.sender, address(this), _bribeTokenAmount); - uint16 epoch = governance.epoch(); - require(_epoch > epoch, "BribeInitiative: only-future-epochs"); + require(_epoch >= epoch, "BribeInitiative: now-or-future-epochs"); Bribe memory bribe = bribeByEpoch[_epoch]; bribe.boldAmount += _boldAmount; @@ -66,15 +65,20 @@ contract BribeInitiative is IInitiative, IBribeInitiative { bribeByEpoch[_epoch] = bribe; emit DepositBribe(msg.sender, _boldAmount, _bribeTokenAmount, _epoch); + + bold.safeTransferFrom(msg.sender, address(this), _boldAmount); + bribeToken.safeTransferFrom(msg.sender, address(this), _bribeTokenAmount); } + uint256 constant TIMESTAMP_PRECISION = 1e26; + function _claimBribe( address _user, uint16 _epoch, uint16 _prevLQTYAllocationEpoch, uint16 _prevTotalLQTYAllocationEpoch ) internal returns (uint256 boldAmount, uint256 bribeTokenAmount) { - require(_epoch != governance.epoch(), "BribeInitiative: cannot-claim-for-current-epoch"); + require(_epoch < governance.epoch(), "BribeInitiative: cannot-claim-for-current-epoch"); require(!claimedBribeAtEpoch[_user][_epoch], "BribeInitiative: already-claimed"); Bribe memory bribe = bribeByEpoch[_epoch]; @@ -96,9 +100,27 @@ contract BribeInitiative is IInitiative, IBribeInitiative { "BribeInitiative: invalid-prev-total-lqty-allocation-epoch" ); - boldAmount = uint256(bribe.boldAmount) * uint256(lqtyAllocation.value) / uint256(totalLQTYAllocation.value); - bribeTokenAmount = - uint256(bribe.bribeTokenAmount) * uint256(lqtyAllocation.value) / uint256(totalLQTYAllocation.value); + (uint88 totalLQTY, uint120 totalAverageTimestamp) = _decodeLQTYAllocation(totalLQTYAllocation.value); + + // NOTE: SCALING!!! | The timestamp will work until type(uint32).max | After which the math will eventually overflow + uint120 scaledEpochEnd = ( + uint120(governance.EPOCH_START()) + uint120(_epoch) * uint120(governance.EPOCH_DURATION()) + ) * uint120(TIMESTAMP_PRECISION); + + /// @audit User Invariant + assert(totalAverageTimestamp <= scaledEpochEnd); + + uint240 totalVotes = governance.lqtyToVotes(totalLQTY, scaledEpochEnd, totalAverageTimestamp); + if (totalVotes != 0) { + (uint88 lqty, uint120 averageTimestamp) = _decodeLQTYAllocation(lqtyAllocation.value); + + /// @audit Governance Invariant + assert(averageTimestamp <= scaledEpochEnd); + + uint240 votes = governance.lqtyToVotes(lqty, scaledEpochEnd, averageTimestamp); + boldAmount = uint256(bribe.boldAmount) * uint256(votes) / uint256(totalVotes); + bribeTokenAmount = uint256(bribe.bribeTokenAmount) * uint256(votes) / uint256(totalVotes); + } claimedBribeAtEpoch[_user][_epoch] = true; @@ -119,8 +141,24 @@ contract BribeInitiative is IInitiative, IBribeInitiative { bribeTokenAmount += bribeTokenAmount_; } - if (boldAmount != 0) bold.safeTransfer(msg.sender, boldAmount); - if (bribeTokenAmount != 0) bribeToken.safeTransfer(msg.sender, bribeTokenAmount); + // NOTE: Due to rounding errors in the `averageTimestamp` bribes may slightly overpay compared to what they have allocated + // We cap to the available amount for this reason + // The error should be below 10 LQTY per annum, in the worst case + if (boldAmount != 0) { + uint256 max = bold.balanceOf(address(this)); + if (boldAmount > max) { + boldAmount = max; + } + bold.safeTransfer(msg.sender, boldAmount); + } + + if (bribeTokenAmount != 0) { + uint256 max = bribeToken.balanceOf(address(this)); + if (bribeTokenAmount > max) { + bribeTokenAmount = max; + } + bribeToken.safeTransfer(msg.sender, bribeTokenAmount); + } } /// @inheritdoc IInitiative @@ -129,93 +167,92 @@ contract BribeInitiative is IInitiative, IBribeInitiative { /// @inheritdoc IInitiative function onUnregisterInitiative(uint16) external virtual override onlyGovernance {} - function _setTotalLQTYAllocationByEpoch(uint16 _epoch, uint88 _value, bool _insert) private { + function _setTotalLQTYAllocationByEpoch(uint16 _epoch, uint88 _lqty, uint120 _averageTimestamp, bool _insert) + private + { + uint224 value = _encodeLQTYAllocation(_lqty, _averageTimestamp); if (_insert) { - totalLQTYAllocationByEpoch.insert(_epoch, _value, 0); + totalLQTYAllocationByEpoch.insert(_epoch, value, 0); } else { - totalLQTYAllocationByEpoch.items[_epoch].value = _value; + totalLQTYAllocationByEpoch.items[_epoch].value = value; } - emit ModifyTotalLQTYAllocation(_epoch, _value); + emit ModifyTotalLQTYAllocation(_epoch, _lqty, _averageTimestamp); } - function _setLQTYAllocationByUserAtEpoch(address _user, uint16 _epoch, uint88 _value, bool _insert) private { + function _setLQTYAllocationByUserAtEpoch( + address _user, + uint16 _epoch, + uint88 _lqty, + uint120 _averageTimestamp, + bool _insert + ) private { + uint224 value = _encodeLQTYAllocation(_lqty, _averageTimestamp); if (_insert) { - lqtyAllocationByUserAtEpoch[_user].insert(_epoch, _value, 0); + lqtyAllocationByUserAtEpoch[_user].insert(_epoch, value, 0); } else { - lqtyAllocationByUserAtEpoch[_user].items[_epoch].value = _value; + lqtyAllocationByUserAtEpoch[_user].items[_epoch].value = value; } - emit ModifyLQTYAllocation(_user, _epoch, _value); + emit ModifyLQTYAllocation(_user, _epoch, _lqty, _averageTimestamp); } - /// @inheritdoc IInitiative - function onAfterAllocateLQTY(uint16 _currentEpoch, address _user, uint88 _voteLQTY, uint88 _vetoLQTY) - external - virtual - onlyGovernance - { + function _encodeLQTYAllocation(uint88 _lqty, uint120 _averageTimestamp) private pure returns (uint224) { + return EncodingDecodingLib.encodeLQTYAllocation(_lqty, _averageTimestamp); + } + + function _decodeLQTYAllocation(uint224 _value) private pure returns (uint88, uint120) { + return EncodingDecodingLib.decodeLQTYAllocation(_value); + } + + function _loadTotalLQTYAllocation(uint16 _epoch) private view returns (uint88, uint120) { + require(_epoch <= governance.epoch(), "No future Lookup"); + return _decodeLQTYAllocation(totalLQTYAllocationByEpoch.items[_epoch].value); + } + + function _loadLQTYAllocation(address _user, uint16 _epoch) private view returns (uint88, uint120) { + require(_epoch <= governance.epoch(), "No future Lookup"); + return _decodeLQTYAllocation(lqtyAllocationByUserAtEpoch[_user].items[_epoch].value); + } + + /// @inheritdoc IBribeInitiative + function getMostRecentUserEpoch(address _user) external view returns (uint16) { uint16 mostRecentUserEpoch = lqtyAllocationByUserAtEpoch[_user].getHead(); + return mostRecentUserEpoch; + } + + /// @inheritdoc IBribeInitiative + function getMostRecentTotalEpoch() external view returns (uint16) { + uint16 mostRecentTotalEpoch = totalLQTYAllocationByEpoch.getHead(); + + return mostRecentTotalEpoch; + } + + function onAfterAllocateLQTY( + uint16 _currentEpoch, + address _user, + IGovernance.UserState calldata _userState, + IGovernance.Allocation calldata _allocation, + IGovernance.InitiativeState calldata _initiativeState + ) external virtual onlyGovernance { if (_currentEpoch == 0) return; - // if this is the first user allocation in the epoch, then insert a new item into the user allocation DLL - if (mostRecentUserEpoch != _currentEpoch) { - uint88 prevVoteLQTY = lqtyAllocationByUserAtEpoch[_user].items[mostRecentUserEpoch].value; - uint88 newVoteLQTY = (_vetoLQTY == 0) ? _voteLQTY : 0; - uint16 mostRecentTotalEpoch = totalLQTYAllocationByEpoch.getHead(); - // if this is the first allocation in the epoch, then insert a new item into the total allocation DLL - if (mostRecentTotalEpoch != _currentEpoch) { - uint88 prevTotalLQTYAllocation = totalLQTYAllocationByEpoch.items[mostRecentTotalEpoch].value; - if (_vetoLQTY == 0) { - // no veto to no veto - _setTotalLQTYAllocationByEpoch( - _currentEpoch, prevTotalLQTYAllocation + newVoteLQTY - prevVoteLQTY, true - ); - } else { - if (prevVoteLQTY != 0) { - // if the prev user allocation was counted in, then remove the prev user allocation from the - // total allocation (no veto to veto) - _setTotalLQTYAllocationByEpoch(_currentEpoch, prevTotalLQTYAllocation - prevVoteLQTY, true); - } else { - // veto to veto - _setTotalLQTYAllocationByEpoch(_currentEpoch, prevTotalLQTYAllocation, true); - } - } - } else { - if (_vetoLQTY == 0) { - // no veto to no veto - _setTotalLQTYAllocationByEpoch( - _currentEpoch, - totalLQTYAllocationByEpoch.items[_currentEpoch].value + newVoteLQTY - prevVoteLQTY, - false - ); - } else if (prevVoteLQTY != 0) { - // no veto to veto - _setTotalLQTYAllocationByEpoch( - _currentEpoch, totalLQTYAllocationByEpoch.items[_currentEpoch].value - prevVoteLQTY, false - ); - } - } - // insert a new item into the user allocation DLL - _setLQTYAllocationByUserAtEpoch(_user, _currentEpoch, newVoteLQTY, true); - } else { - uint88 prevVoteLQTY = lqtyAllocationByUserAtEpoch[_user].getItem(_currentEpoch).value; - if (_vetoLQTY == 0) { - // update the allocation for the current epoch by adding the new allocation and subtracting - // the previous one (no veto to no veto) - _setTotalLQTYAllocationByEpoch( - _currentEpoch, - totalLQTYAllocationByEpoch.items[_currentEpoch].value + _voteLQTY - prevVoteLQTY, - false - ); - _setLQTYAllocationByUserAtEpoch(_user, _currentEpoch, _voteLQTY, false); - } else { - // if the user vetoed the initiative, subtract the allocation from the DLLs (no veto to veto) - _setTotalLQTYAllocationByEpoch( - _currentEpoch, totalLQTYAllocationByEpoch.items[_currentEpoch].value - prevVoteLQTY, false - ); - _setLQTYAllocationByUserAtEpoch(_user, _currentEpoch, 0, false); - } - } + uint16 mostRecentUserEpoch = lqtyAllocationByUserAtEpoch[_user].getHead(); + uint16 mostRecentTotalEpoch = totalLQTYAllocationByEpoch.getHead(); + + _setTotalLQTYAllocationByEpoch( + _currentEpoch, + _initiativeState.voteLQTY, + _initiativeState.averageStakingTimestampVoteLQTY, + mostRecentTotalEpoch != _currentEpoch // Insert if current > recent + ); + + _setLQTYAllocationByUserAtEpoch( + _user, + _currentEpoch, + _allocation.voteLQTY, + _userState.averageStakingTimestamp, + mostRecentUserEpoch != _currentEpoch // Insert if user current > recent + ); } /// @inheritdoc IInitiative diff --git a/src/CurveV2GaugeRewards.sol b/src/CurveV2GaugeRewards.sol index 9c6ae51d..365e432f 100644 --- a/src/CurveV2GaugeRewards.sol +++ b/src/CurveV2GaugeRewards.sol @@ -18,14 +18,34 @@ contract CurveV2GaugeRewards is BribeInitiative { duration = _duration; } - function depositIntoGauge() external returns (uint256) { - uint256 amount = governance.claimForInitiative(address(this)); + uint256 public remainder; - bold.approve(address(gauge), amount); - gauge.deposit_reward_token(address(bold), amount, duration); + /// @notice Governance transfers Bold, and we deposit it into the gauge + /// @dev Doing this allows anyone to trigger the claim + function onClaimForInitiative(uint16, uint256 _bold) external override onlyGovernance { + _depositIntoGauge(_bold); + } + + // TODO: If this is capped, we may need to donate here, so cap it here as well + function _depositIntoGauge(uint256 amount) internal { + uint256 total = amount + remainder; + + // For small donations queue them into the contract + if (total < duration * 1000) { + remainder += amount; + return; + } + + remainder = 0; + + uint256 available = bold.balanceOf(address(this)); + if (available < total) { + total = available; // Cap due to rounding error causing a bit more bold being given away + } - emit DepositIntoGauge(amount); + bold.approve(address(gauge), total); + gauge.deposit_reward_token(address(bold), total, duration); - return amount; + emit DepositIntoGauge(total); } } diff --git a/src/ForwardBribe.sol b/src/ForwardBribe.sol index b7e4d1b8..d1cf4cca 100644 --- a/src/ForwardBribe.sol +++ b/src/ForwardBribe.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -import {IERC20} from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; -import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "openzeppelin/contracts/interfaces/IERC20.sol"; +import {SafeERC20} from "openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {BribeInitiative} from "./BribeInitiative.sol"; @@ -23,7 +23,7 @@ contract ForwardBribe is BribeInitiative { uint boldAmount = bold.balanceOf(address(this)); uint bribeTokenAmount = bribeToken.balanceOf(address(this)); - if (boldAmount != 0) bold.safeTransfer(receiver, boldAmount); - if (bribeTokenAmount != 0) bribeToken.safeTransfer(receiver, bribeTokenAmount); + if (boldAmount != 0) bold.transfer(receiver, boldAmount); + if (bribeTokenAmount != 0) bribeToken.transfer(receiver, bribeTokenAmount); } } diff --git a/src/Governance.sol b/src/Governance.sol index 2f361b3a..d9c711c5 100644 --- a/src/Governance.sol +++ b/src/Governance.sol @@ -1,9 +1,9 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -import {IERC20} from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; -import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; -import {ReentrancyGuard} from "openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol"; +import {IERC20} from "openzeppelin/contracts/interfaces/IERC20.sol"; +import {SafeERC20} from "openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ReentrancyGuard} from "openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {IGovernance} from "./interfaces/IGovernance.sol"; import {IInitiative} from "./interfaces/IInitiative.sol"; @@ -13,13 +13,20 @@ import {UserProxy} from "./UserProxy.sol"; import {UserProxyFactory} from "./UserProxyFactory.sol"; import {add, max} from "./utils/Math.sol"; +import {_requireNoDuplicates, _requireNoNegatives} from "./utils/UniqueArray.sol"; import {Multicall} from "./utils/Multicall.sol"; import {WAD, PermitParams} from "./utils/Types.sol"; +import {safeCallWithMinGas} from "./utils/SafeCallMinGas.sol"; +import {Ownable} from "./utils/Ownable.sol"; /// @title Governance: Modular Initiative based Governance -contract Governance is Multicall, UserProxyFactory, ReentrancyGuard, IGovernance { +contract Governance is Multicall, UserProxyFactory, ReentrancyGuard, Ownable, IGovernance { using SafeERC20 for IERC20; + uint256 constant MIN_GAS_TO_HOOK = 350_000; + + /// Replace this to ensure hooks have sufficient gas + /// @inheritdoc IGovernance ILQTYStaking public immutable stakingV1; /// @inheritdoc IGovernance @@ -68,24 +75,41 @@ contract Governance is Multicall, UserProxyFactory, ReentrancyGuard, IGovernance /// @inheritdoc IGovernance mapping(address => uint16) public override registeredInitiatives; + uint16 constant UNREGISTERED_INITIATIVE = type(uint16).max; + + // 100 Million LQTY will be necessary to make the rounding error cause 1 second of loss per operation + uint120 public constant TIMESTAMP_PRECISION = 1e26; + constructor( address _lqty, address _lusd, address _stakingV1, address _bold, Configuration memory _config, + address _owner, address[] memory _initiatives - ) UserProxyFactory(_lqty, _lusd, _stakingV1) { + ) UserProxyFactory(_lqty, _lusd, _stakingV1) Ownable(_owner) { stakingV1 = ILQTYStaking(_stakingV1); lqty = IERC20(_lqty); bold = IERC20(_bold); require(_config.minClaim <= _config.minAccrual, "Gov: min-claim-gt-min-accrual"); REGISTRATION_FEE = _config.registrationFee; + + // Registration threshold must be below 100% of votes + require(_config.registrationThresholdFactor < WAD, "Gov: registration-config"); REGISTRATION_THRESHOLD_FACTOR = _config.registrationThresholdFactor; + + // Unregistration must be X times above the `votingThreshold` + require(_config.unregistrationThresholdFactor > WAD, "Gov: unregistration-config"); UNREGISTRATION_THRESHOLD_FACTOR = _config.unregistrationThresholdFactor; + REGISTRATION_WARM_UP_PERIOD = _config.registrationWarmUpPeriod; UNREGISTRATION_AFTER_EPOCHS = _config.unregistrationAfterEpochs; + + // Voting threshold must be below 100% of votes + require(_config.votingThresholdFactor < WAD, "Gov: voting-config"); VOTING_THRESHOLD_FACTOR = _config.votingThresholdFactor; + MIN_CLAIM = _config.minClaim; MIN_ACCRUAL = _config.minAccrual; EPOCH_START = _config.epochStart; @@ -93,54 +117,81 @@ contract Governance is Multicall, UserProxyFactory, ReentrancyGuard, IGovernance EPOCH_DURATION = _config.epochDuration; require(_config.epochVotingCutoff < _config.epochDuration, "Gov: epoch-voting-cutoff-gt-epoch-duration"); EPOCH_VOTING_CUTOFF = _config.epochVotingCutoff; + + if (_initiatives.length > 0) { + registerInitialInitiatives(_initiatives); + } + } + + function registerInitialInitiatives(address[] memory _initiatives) public onlyOwner { + uint16 currentEpoch = epoch(); + for (uint256 i = 0; i < _initiatives.length; i++) { initiativeStates[_initiatives[i]] = InitiativeState(0, 0, 0, 0, 0); - registeredInitiatives[_initiatives[i]] = 1; + registeredInitiatives[_initiatives[i]] = currentEpoch; + + emit RegisterInitiative(_initiatives[i], msg.sender, currentEpoch); } + + _renounceOwnership(); } - function _averageAge(uint32 _currentTimestamp, uint32 _averageTimestamp) internal pure returns (uint32) { + function _averageAge(uint120 _currentTimestamp, uint120 _averageTimestamp) internal pure returns (uint120) { if (_averageTimestamp == 0 || _currentTimestamp < _averageTimestamp) return 0; return _currentTimestamp - _averageTimestamp; } function _calculateAverageTimestamp( - uint32 _prevOuterAverageTimestamp, - uint32 _newInnerAverageTimestamp, + uint120 _prevOuterAverageTimestamp, + uint120 _newInnerAverageTimestamp, uint88 _prevLQTYBalance, uint88 _newLQTYBalance - ) internal view returns (uint32) { + ) internal view returns (uint120) { if (_newLQTYBalance == 0) return 0; - uint32 prevOuterAverageAge = _averageAge(uint32(block.timestamp), _prevOuterAverageTimestamp); - uint32 newInnerAverageAge = _averageAge(uint32(block.timestamp), _newInnerAverageTimestamp); + // NOTE: Truncation + // NOTE: u32 -> u120 + // While we upscale the Timestamp, the system will stop working at type(uint32).max + // Because the rest of the type is used for precision + uint120 currentTime = uint120(uint32(block.timestamp)) * uint120(TIMESTAMP_PRECISION); + + uint120 prevOuterAverageAge = _averageAge(currentTime, _prevOuterAverageTimestamp); + uint120 newInnerAverageAge = _averageAge(currentTime, _newInnerAverageTimestamp); - uint88 newOuterAverageAge; + // 120 for timestamps = 2^32 * 1e18 | 2^32 * 1e26 + // 208 for voting power = 2^120 * 2^88 + // NOTE: 208 / X can go past u120! + // Therefore we keep `newOuterAverageAge` as u208 + uint208 newOuterAverageAge; if (_prevLQTYBalance <= _newLQTYBalance) { uint88 deltaLQTY = _newLQTYBalance - _prevLQTYBalance; - uint240 prevVotes = uint240(_prevLQTYBalance) * uint240(prevOuterAverageAge); - uint240 newVotes = uint240(deltaLQTY) * uint240(newInnerAverageAge); - uint240 votes = prevVotes + newVotes; - newOuterAverageAge = (_newLQTYBalance == 0) ? 0 : uint32(votes / uint240(_newLQTYBalance)); + uint208 prevVotes = uint208(_prevLQTYBalance) * uint208(prevOuterAverageAge); + uint208 newVotes = uint208(deltaLQTY) * uint208(newInnerAverageAge); + uint208 votes = prevVotes + newVotes; + newOuterAverageAge = votes / _newLQTYBalance; } else { uint88 deltaLQTY = _prevLQTYBalance - _newLQTYBalance; - uint240 prevVotes = uint240(_prevLQTYBalance) * uint240(prevOuterAverageAge); - uint240 newVotes = uint240(deltaLQTY) * uint240(newInnerAverageAge); - uint240 votes = (prevVotes >= newVotes) ? prevVotes - newVotes : 0; - newOuterAverageAge = (_newLQTYBalance == 0) ? 0 : uint32(votes / uint240(_newLQTYBalance)); + uint208 prevVotes = uint208(_prevLQTYBalance) * uint208(prevOuterAverageAge); + uint208 newVotes = uint208(deltaLQTY) * uint208(newInnerAverageAge); + uint208 votes = (prevVotes >= newVotes) ? prevVotes - newVotes : 0; + newOuterAverageAge = votes / _newLQTYBalance; } - if (newOuterAverageAge > block.timestamp) return 0; - return uint32(block.timestamp - newOuterAverageAge); + if (newOuterAverageAge > currentTime) return 0; + return uint120(currentTime - newOuterAverageAge); } /*////////////////////////////////////////////////////////////// STAKING //////////////////////////////////////////////////////////////*/ - function _deposit(uint88 _lqtyAmount) private returns (UserProxy) { + function _updateUserTimestamp(uint88 _lqtyAmount) private returns (UserProxy) { require(_lqtyAmount > 0, "Governance: zero-lqty-amount"); + // Assert that we have resetted here + UserState memory userState = userStates[msg.sender]; + require(userState.allocatedLQTY == 0, "Governance: must-be-zero-allocation"); + address userProxyAddress = deriveUserProxyAddress(msg.sender); if (userProxyAddress.code.length == 0) { @@ -152,9 +203,13 @@ contract Governance is Multicall, UserProxyFactory, ReentrancyGuard, IGovernance uint88 lqtyStaked = uint88(stakingV1.stakes(userProxyAddress)); // update the average staked timestamp for LQTY staked by the user - UserState memory userState = userStates[msg.sender]; + + // NOTE: Upscale user TS by `TIMESTAMP_PRECISION` userState.averageStakingTimestamp = _calculateAverageTimestamp( - userState.averageStakingTimestamp, uint32(block.timestamp), lqtyStaked, lqtyStaked + _lqtyAmount + userState.averageStakingTimestamp, + uint120(block.timestamp) * uint120(TIMESTAMP_PRECISION), + lqtyStaked, + lqtyStaked + _lqtyAmount ); userStates[msg.sender] = userState; @@ -165,29 +220,28 @@ contract Governance is Multicall, UserProxyFactory, ReentrancyGuard, IGovernance /// @inheritdoc IGovernance function depositLQTY(uint88 _lqtyAmount) external nonReentrant { - UserProxy userProxy = _deposit(_lqtyAmount); + UserProxy userProxy = _updateUserTimestamp(_lqtyAmount); userProxy.stake(_lqtyAmount, msg.sender); } /// @inheritdoc IGovernance function depositLQTYViaPermit(uint88 _lqtyAmount, PermitParams calldata _permitParams) external nonReentrant { - UserProxy userProxy = _deposit(_lqtyAmount); + UserProxy userProxy = _updateUserTimestamp(_lqtyAmount); userProxy.stakeViaPermit(_lqtyAmount, msg.sender, _permitParams); } /// @inheritdoc IGovernance function withdrawLQTY(uint88 _lqtyAmount) external nonReentrant { + // check that user has reset before changing lqty balance + UserState storage userState = userStates[msg.sender]; + require(userState.allocatedLQTY == 0, "Governance: must-allocate-zero"); + UserProxy userProxy = UserProxy(payable(deriveUserProxyAddress(msg.sender))); require(address(userProxy).code.length != 0, "Governance: user-proxy-not-deployed"); uint88 lqtyStaked = uint88(stakingV1.stakes(address(userProxy))); - UserState storage userState = userStates[msg.sender]; - - // check if user has enough unallocated lqty - require(_lqtyAmount <= lqtyStaked - userState.allocatedLQTY, "Governance: insufficient-unallocated-lqty"); - - (uint256 accruedLUSD, uint256 accruedETH) = userProxy.unstake(_lqtyAmount, msg.sender, msg.sender); + (uint256 accruedLUSD, uint256 accruedETH) = userProxy.unstake(_lqtyAmount, msg.sender); emit WithdrawLQTY(msg.sender, _lqtyAmount, accruedLUSD, accruedETH); } @@ -196,7 +250,7 @@ contract Governance is Multicall, UserProxyFactory, ReentrancyGuard, IGovernance function claimFromStakingV1(address _rewardRecipient) external returns (uint256 accruedLUSD, uint256 accruedETH) { address payable userProxyAddress = payable(deriveUserProxyAddress(msg.sender)); require(userProxyAddress.code.length != 0, "Governance: user-proxy-not-deployed"); - return UserProxy(userProxyAddress).unstake(0, _rewardRecipient, _rewardRecipient); + return UserProxy(userProxyAddress).unstake(0, _rewardRecipient); } /*////////////////////////////////////////////////////////////// @@ -205,7 +259,9 @@ contract Governance is Multicall, UserProxyFactory, ReentrancyGuard, IGovernance /// @inheritdoc IGovernance function epoch() public view returns (uint16) { - if (block.timestamp < EPOCH_START) return 0; + if (block.timestamp < EPOCH_START) { + return 0; + } return uint16(((block.timestamp - EPOCH_START) / EPOCH_DURATION) + 1); } @@ -223,39 +279,81 @@ contract Governance is Multicall, UserProxyFactory, ReentrancyGuard, IGovernance } /// @inheritdoc IGovernance - function lqtyToVotes(uint88 _lqtyAmount, uint256 _currentTimestamp, uint32 _averageTimestamp) + function lqtyToVotes(uint88 _lqtyAmount, uint120 _currentTimestamp, uint120 _averageTimestamp) public pure - returns (uint240) + returns (uint208) { - return uint240(_lqtyAmount) * _averageAge(uint32(_currentTimestamp), _averageTimestamp); + return uint208(_lqtyAmount) * uint208(_averageAge(_currentTimestamp, _averageTimestamp)); } + /*////////////////////////////////////////////////////////////// + SNAPSHOTS + //////////////////////////////////////////////////////////////*/ + /// @inheritdoc IGovernance - function calculateVotingThreshold() public view returns (uint256) { + function getLatestVotingThreshold() public view returns (uint256) { uint256 snapshotVotes = votesSnapshot.votes; - if (snapshotVotes == 0) return 0; + /// @audit technically can be out of synch + + return calculateVotingThreshold(snapshotVotes); + } + + /// @dev Returns the most up to date voting threshold + /// In contrast to `getLatestVotingThreshold` this function updates the snapshot + /// This ensures that the value returned is always the latest + function calculateVotingThreshold() public returns (uint256) { + (VoteSnapshot memory snapshot,) = _snapshotVotes(); + + return calculateVotingThreshold(snapshot.votes); + } + + /// @dev Utility function to compute the threshold votes without recomputing the snapshot + /// Note that `boldAccrued` is a cached value, this function works correctly only when called after an accrual + function calculateVotingThreshold(uint256 _votes) public view returns (uint256) { + if (_votes == 0) return 0; uint256 minVotes; // to reach MIN_CLAIM: snapshotVotes * MIN_CLAIM / boldAccrued - uint256 payoutPerVote = boldAccrued * WAD / snapshotVotes; + uint256 payoutPerVote = boldAccrued * WAD / _votes; if (payoutPerVote != 0) { minVotes = MIN_CLAIM * WAD / payoutPerVote; } - return max(snapshotVotes * VOTING_THRESHOLD_FACTOR / WAD, minVotes); + return max(_votes * VOTING_THRESHOLD_FACTOR / WAD, minVotes); } // Snapshots votes for the previous epoch and accrues funds for the current epoch function _snapshotVotes() internal returns (VoteSnapshot memory snapshot, GlobalState memory state) { + bool shouldUpdate; + (snapshot, state, shouldUpdate) = getTotalVotesAndState(); + + if (shouldUpdate) { + votesSnapshot = snapshot; + uint256 boldBalance = bold.balanceOf(address(this)); + boldAccrued = (boldBalance < MIN_ACCRUAL) ? 0 : boldBalance; + emit SnapshotVotes(snapshot.votes, snapshot.forEpoch); + } + } + + /// @notice Return the most up to date global snapshot and state as well as a flag to notify whether the state can be updated + /// This is a convenience function to always retrieve the most up to date state values + function getTotalVotesAndState() + public + view + returns (VoteSnapshot memory snapshot, GlobalState memory state, bool shouldUpdate) + { uint16 currentEpoch = epoch(); snapshot = votesSnapshot; state = globalState; + if (snapshot.forEpoch < currentEpoch - 1) { - snapshot.votes = lqtyToVotes(state.countedVoteLQTY, epochStart(), state.countedVoteLQTYAverageTimestamp); + shouldUpdate = true; + + snapshot.votes = lqtyToVotes( + state.countedVoteLQTY, + uint120(epochStart()) * uint120(TIMESTAMP_PRECISION), + state.countedVoteLQTYAverageTimestamp + ); snapshot.forEpoch = currentEpoch - 1; - votesSnapshot = snapshot; - uint256 boldBalance = bold.balanceOf(address(this)); - boldAccrued = (boldBalance < MIN_ACCRUAL) ? 0 : boldBalance; - emit SnapshotVotes(snapshot.votes, snapshot.forEpoch); } } @@ -265,26 +363,44 @@ contract Governance is Multicall, UserProxyFactory, ReentrancyGuard, IGovernance internal returns (InitiativeVoteSnapshot memory initiativeSnapshot, InitiativeState memory initiativeState) { + bool shouldUpdate; + (initiativeSnapshot, initiativeState, shouldUpdate) = getInitiativeSnapshotAndState(_initiative); + + if (shouldUpdate) { + votesForInitiativeSnapshot[_initiative] = initiativeSnapshot; + emit SnapshotVotesForInitiative(_initiative, initiativeSnapshot.votes, initiativeSnapshot.forEpoch); + } + } + + /// @dev Given an initiative address, return it's most up to date snapshot and state as well as a flag to notify whether the state can be updated + /// This is a convenience function to always retrieve the most up to date state values + function getInitiativeSnapshotAndState(address _initiative) + public + view + returns ( + InitiativeVoteSnapshot memory initiativeSnapshot, + InitiativeState memory initiativeState, + bool shouldUpdate + ) + { + // Get the storage data uint16 currentEpoch = epoch(); initiativeSnapshot = votesForInitiativeSnapshot[_initiative]; initiativeState = initiativeStates[_initiative]; + if (initiativeSnapshot.forEpoch < currentEpoch - 1) { - uint256 votingThreshold = calculateVotingThreshold(); - uint32 start = epochStart(); - uint240 votes = + shouldUpdate = true; + + uint120 start = uint120(epochStart()) * uint120(TIMESTAMP_PRECISION); + uint208 votes = lqtyToVotes(initiativeState.voteLQTY, start, initiativeState.averageStakingTimestampVoteLQTY); - uint240 vetos = + uint208 vetos = lqtyToVotes(initiativeState.vetoLQTY, start, initiativeState.averageStakingTimestampVetoLQTY); - // if the votes didn't meet the voting threshold then no votes qualify - if (votes >= votingThreshold && votes >= vetos) { - initiativeSnapshot.votes = uint224(votes); - initiativeSnapshot.lastCountedEpoch = currentEpoch - 1; - } else { - initiativeSnapshot.votes = 0; - } + // NOTE: Upscaling to u224 is safe + initiativeSnapshot.votes = votes; + initiativeSnapshot.vetos = vetos; + initiativeSnapshot.forEpoch = currentEpoch - 1; - votesForInitiativeSnapshot[_initiative] = initiativeSnapshot; - emit SnapshotVotesForInitiative(_initiative, initiativeSnapshot.votes, initiativeSnapshot.forEpoch); } } @@ -298,12 +414,125 @@ contract Governance is Multicall, UserProxyFactory, ReentrancyGuard, IGovernance (initiativeVoteSnapshot,) = _snapshotVotesForInitiative(_initiative); } + /*////////////////////////////////////////////////////////////// + FSM + //////////////////////////////////////////////////////////////*/ + + enum InitiativeStatus { + NONEXISTENT, + /// This Initiative Doesn't exist | This is never returned + WARM_UP, + /// This epoch was just registered + SKIP, + /// This epoch will result in no rewards and no unregistering + CLAIMABLE, + /// This epoch will result in claiming rewards + CLAIMED, + /// The rewards for this epoch have been claimed + UNREGISTERABLE, + /// Can be unregistered + DISABLED // It was already Unregistered + + } + + /// @notice Given an inititive address, updates all snapshots and return the initiative state + /// See the view version of `getInitiativeState` for the underlying logic on Initatives FSM + function getInitiativeState(address _initiative) + public + returns (InitiativeStatus status, uint16 lastEpochClaim, uint256 claimableAmount) + { + (VoteSnapshot memory votesSnapshot_,) = _snapshotVotes(); + (InitiativeVoteSnapshot memory votesForInitiativeSnapshot_, InitiativeState memory initiativeState) = + _snapshotVotesForInitiative(_initiative); + + return getInitiativeState(_initiative, votesSnapshot_, votesForInitiativeSnapshot_, initiativeState); + } + + /// @dev Given an initiative address and its snapshot, determines the current state for an initiative + function getInitiativeState( + address _initiative, + VoteSnapshot memory _votesSnapshot, + InitiativeVoteSnapshot memory _votesForInitiativeSnapshot, + InitiativeState memory _initiativeState + ) public view returns (InitiativeStatus status, uint16 lastEpochClaim, uint256 claimableAmount) { + // == Non existent Condition == // + if (registeredInitiatives[_initiative] == 0) { + return (InitiativeStatus.NONEXISTENT, 0, 0); + /// By definition it has zero rewards + } + + // == Just Registered Condition == // + if (registeredInitiatives[_initiative] == epoch()) { + return (InitiativeStatus.WARM_UP, 0, 0); + /// Was registered this week, cannot have rewards + } + + // Fetch last epoch at which we claimed + lastEpochClaim = initiativeStates[_initiative].lastEpochClaim; + + // == Disabled Condition == // + if (registeredInitiatives[_initiative] == UNREGISTERED_INITIATIVE) { + return (InitiativeStatus.DISABLED, lastEpochClaim, 0); + /// By definition it has zero rewards + } + + // == Already Claimed Condition == // + if (lastEpochClaim >= epoch() - 1) { + // early return, we have already claimed + return (InitiativeStatus.CLAIMED, lastEpochClaim, claimableAmount); + } + + // NOTE: Pass the snapshot value so we get accurate result + uint256 votingTheshold = calculateVotingThreshold(_votesSnapshot.votes); + + // If it's voted and can get rewards + // Votes > calculateVotingThreshold + // == Rewards Conditions (votes can be zero, logic is the same) == // + + // By definition if _votesForInitiativeSnapshot.votes > 0 then _votesSnapshot.votes > 0 + + uint256 upscaledInitiativeVotes = uint256(_votesForInitiativeSnapshot.votes); + uint256 upscaledInitiativeVetos = uint256(_votesForInitiativeSnapshot.vetos); + uint256 upscaledTotalVotes = uint256(_votesSnapshot.votes); + + if (upscaledInitiativeVotes > votingTheshold && !(upscaledInitiativeVetos >= upscaledInitiativeVotes)) { + /// @audit 2^208 means we only have 2^48 left + /// Therefore we need to scale the value down by 4 orders of magnitude to make it fit + assert(upscaledInitiativeVotes * 1e14 / (VOTING_THRESHOLD_FACTOR / 1e4) > upscaledTotalVotes); + + // 34 times when using 0.03e18 -> 33.3 + 1-> 33 + 1 = 34 + uint256 CUSTOM_PRECISION = WAD / VOTING_THRESHOLD_FACTOR + 1; + + /// @audit Because of the updated timestamp, we can run into overflows if we multiply by `boldAccrued` + /// We use `CUSTOM_PRECISION` for this reason, a smaller multiplicative value + /// The change SHOULD be safe because we already check for `threshold` before getting into these lines + /// As an alternative, this line could be replaced by https://github.com/Uniswap/v3-core/blob/main/contracts/libraries/FullMath.sol + uint256 claim = + upscaledInitiativeVotes * CUSTOM_PRECISION / upscaledTotalVotes * boldAccrued / CUSTOM_PRECISION; + return (InitiativeStatus.CLAIMABLE, lastEpochClaim, claim); + } + + // == Unregister Condition == // + // e.g. if `UNREGISTRATION_AFTER_EPOCHS` is 4, the 4th epoch flip that would result in SKIP, will result in the initiative being `UNREGISTERABLE` + if ( + (_initiativeState.lastEpochClaim + UNREGISTRATION_AFTER_EPOCHS < epoch() - 1) + || upscaledInitiativeVetos > upscaledInitiativeVotes + && upscaledInitiativeVetos > votingTheshold * UNREGISTRATION_THRESHOLD_FACTOR / WAD + ) { + return (InitiativeStatus.UNREGISTERABLE, lastEpochClaim, 0); + } + + // == Not meeting threshold Condition == // + return (InitiativeStatus.SKIP, lastEpochClaim, 0); + } + /// @inheritdoc IGovernance function registerInitiative(address _initiative) external nonReentrant { bold.safeTransferFrom(msg.sender, address(this), REGISTRATION_FEE); require(_initiative != address(0), "Governance: zero-address"); - require(registeredInitiatives[_initiative] == 0, "Governance: initiative-already-registered"); + (InitiativeStatus status,,) = getInitiativeState(_initiative); + require(status == InitiativeStatus.NONEXISTENT, "Governance: initiative-already-registered"); address userProxyAddress = deriveUserProxyAddress(msg.sender); (VoteSnapshot memory snapshot,) = _snapshotVotes(); @@ -311,9 +540,14 @@ contract Governance is Multicall, UserProxyFactory, ReentrancyGuard, IGovernance // an initiative can be registered if the registrant has more voting power (LQTY * age) // than the registration threshold derived from the previous epoch's total global votes + + uint256 upscaledSnapshotVotes = uint256(snapshot.votes); require( - lqtyToVotes(uint88(stakingV1.stakes(userProxyAddress)), block.timestamp, userState.averageStakingTimestamp) - >= snapshot.votes * REGISTRATION_THRESHOLD_FACTOR / WAD, + lqtyToVotes( + uint88(stakingV1.stakes(userProxyAddress)), + uint120(epochStart()) * uint120(TIMESTAMP_PRECISION), + userState.averageStakingTimestamp + ) >= upscaledSnapshotVotes * REGISTRATION_THRESHOLD_FACTOR / WAD, "Governance: insufficient-lqty" ); @@ -321,94 +555,185 @@ contract Governance is Multicall, UserProxyFactory, ReentrancyGuard, IGovernance registeredInitiatives[_initiative] = currentEpoch; + /// @audit This ensures that the initiatives has UNREGISTRATION_AFTER_EPOCHS even after the first epoch + initiativeStates[_initiative].lastEpochClaim = epoch() - 1; + emit RegisterInitiative(_initiative, msg.sender, currentEpoch); - try IInitiative(_initiative).onRegisterInitiative(currentEpoch) {} catch {} + // Replaces try / catch | Enforces sufficient gas is passed + safeCallWithMinGas( + _initiative, MIN_GAS_TO_HOOK, 0, abi.encodeCall(IInitiative.onRegisterInitiative, (currentEpoch)) + ); } - /// @inheritdoc IGovernance - function unregisterInitiative(address _initiative) external nonReentrant { - uint16 registrationEpoch = registeredInitiatives[_initiative]; - require(registrationEpoch != 0, "Governance: initiative-not-registered"); - uint16 currentEpoch = epoch(); - require(registrationEpoch + REGISTRATION_WARM_UP_PERIOD < currentEpoch, "Governance: initiative-in-warm-up"); - - (, GlobalState memory state) = _snapshotVotes(); - (InitiativeVoteSnapshot memory votesForInitiativeSnapshot_, InitiativeState memory initiativeState) = - _snapshotVotesForInitiative(_initiative); - - uint256 vetosForInitiative = - lqtyToVotes(initiativeState.vetoLQTY, block.timestamp, initiativeState.averageStakingTimestampVetoLQTY); - - // an initiative can be unregistered if it has no votes and has been inactive for 'UNREGISTRATION_AFTER_EPOCHS' - // epochs or if it has received more vetos than votes and the vetos are more than - // 'UNREGISTRATION_THRESHOLD_FACTOR' times the voting threshold - require( - (votesForInitiativeSnapshot_.lastCountedEpoch + UNREGISTRATION_AFTER_EPOCHS < currentEpoch) - || ( - vetosForInitiative > votesForInitiativeSnapshot_.votes - && vetosForInitiative > calculateVotingThreshold() * UNREGISTRATION_THRESHOLD_FACTOR / WAD - ), - "Governance: cannot-unregister-initiative" - ); + struct ResetInitiativeData { + address initiative; + int88 LQTYVotes; + int88 LQTYVetos; + } - // recalculate the average staking timestamp for all counted voting LQTY if the initiative was counted in - if (initiativeState.counted == 1) { - state.countedVoteLQTYAverageTimestamp = _calculateAverageTimestamp( - state.countedVoteLQTYAverageTimestamp, - initiativeState.averageStakingTimestampVoteLQTY, - state.countedVoteLQTY, - state.countedVoteLQTY - initiativeState.voteLQTY - ); - state.countedVoteLQTY -= initiativeState.voteLQTY; - globalState = state; + /// @dev Resets an initiative and return the previous votes + /// NOTE: Technically we don't need vetos + /// NOTE: Technically we want to populate the `ResetInitiativeData` only when `secondsWithinEpoch() > EPOCH_VOTING_CUTOFF` + function _resetInitiatives(address[] calldata _initiativesToReset) + internal + returns (ResetInitiativeData[] memory) + { + ResetInitiativeData[] memory cachedData = new ResetInitiativeData[](_initiativesToReset.length); + + int88[] memory deltaLQTYVotes = new int88[](_initiativesToReset.length); + int88[] memory deltaLQTYVetos = new int88[](_initiativesToReset.length); + + // Prepare reset data + for (uint256 i; i < _initiativesToReset.length; i++) { + Allocation memory alloc = lqtyAllocatedByUserToInitiative[msg.sender][_initiativesToReset[i]]; + + // Must be below, else we cannot reset" + // Makes cast safe + /// @audit Check INVARIANT: property_ensure_user_alloc_cannot_dos + assert(alloc.voteLQTY <= uint88(type(int88).max)); + assert(alloc.vetoLQTY <= uint88(type(int88).max)); + + // Cache, used to enforce limits later + cachedData[i] = ResetInitiativeData({ + initiative: _initiativesToReset[i], + LQTYVotes: int88(alloc.voteLQTY), + LQTYVetos: int88(alloc.vetoLQTY) + }); + + // -0 is still 0, so its fine to flip both + deltaLQTYVotes[i] = -int88(cachedData[i].LQTYVotes); + deltaLQTYVetos[i] = -int88(cachedData[i].LQTYVetos); } - delete initiativeStates[_initiative]; - delete registeredInitiatives[_initiative]; + // RESET HERE || All initiatives will receive most updated data and 0 votes / vetos + _allocateLQTY(_initiativesToReset, deltaLQTYVotes, deltaLQTYVetos); - emit UnregisterInitiative(_initiative, currentEpoch); + return cachedData; + } - try IInitiative(_initiative).onUnregisterInitiative(currentEpoch) {} catch {} + /// @notice Reset the allocations for the initiatives being passed, must pass all initiatives else it will revert + /// NOTE: If you reset at the last day of the epoch, you won't be able to vote again + /// Use `allocateLQTY` to reset and vote + function resetAllocations(address[] calldata _initiativesToReset, bool checkAll) external nonReentrant { + _requireNoDuplicates(_initiativesToReset); + _resetInitiatives(_initiativesToReset); + + // NOTE: In most cases, the check will pass + // But if you allocate too many initiatives, we may run OOG + // As such the check is optional here + // All other calls to the system enforce this + // So it's recommended that your last call to `resetAllocations` passes the check + if (checkAll) { + require(userStates[msg.sender].allocatedLQTY == 0, "Governance: must be a reset"); + } } /// @inheritdoc IGovernance function allocateLQTY( + address[] calldata _initiativesToReset, address[] calldata _initiatives, - int176[] calldata _deltaLQTYVotes, - int176[] calldata _deltaLQTYVetos + int88[] calldata _absoluteLQTYVotes, + int88[] calldata _absoluteLQTYVetos ) external nonReentrant { + require(_initiatives.length == _absoluteLQTYVotes.length, "Length"); + require(_absoluteLQTYVetos.length == _absoluteLQTYVotes.length, "Length"); + + // To ensure the change is safe, enforce uniqueness + _requireNoDuplicates(_initiatives); + _requireNoDuplicates(_initiativesToReset); + + // Explicit >= 0 checks for all values since we reset values below + _requireNoNegatives(_absoluteLQTYVotes); + _requireNoNegatives(_absoluteLQTYVetos); + + // You MUST always reset + ResetInitiativeData[] memory cachedData = _resetInitiatives(_initiativesToReset); + + /// Invariant, 0 allocated = 0 votes + UserState memory userState = userStates[msg.sender]; + require(userState.allocatedLQTY == 0, "must be a reset"); + + // After cutoff you can only re-apply the same vote + // Or vote less + // Or abstain + // You can always add a veto, hence we only validate the addition of Votes + // And ignore the addition of vetos + // Validate the data here to ensure that the voting is capped at the amount in the other case + if (secondsWithinEpoch() > EPOCH_VOTING_CUTOFF) { + // Cap the max votes to the previous cache value + // This means that no new votes can happen here + + // Removing and VETOING is always accepted + for (uint256 x; x < _initiatives.length; x++) { + // If we find it, we ensure it cannot be an increase + bool found; + for (uint256 y; y < cachedData.length; y++) { + if (cachedData[y].initiative == _initiatives[x]) { + found = true; + require(_absoluteLQTYVotes[x] <= cachedData[y].LQTYVotes, "Cannot increase"); + break; + } + } + + // Else we assert that the change is a veto, because by definition the initiatives will have received zero votes past this line + if (!found) { + require(_absoluteLQTYVotes[x] == 0, "Must be zero for new initiatives"); + } + } + } + + // Vote here, all values are now absolute changes + _allocateLQTY(_initiatives, _absoluteLQTYVotes, _absoluteLQTYVetos); + } + + /// @dev For each given initiative applies relative changes to the allocation + /// NOTE: Given the current usage the function either: Resets the value to 0, or sets the value to a new value + /// Review the flows as the function could be used in many ways, but it ends up being used in just those 2 ways + function _allocateLQTY( + address[] memory _initiatives, + int88[] memory _deltaLQTYVotes, + int88[] memory _deltaLQTYVetos + ) internal { require( _initiatives.length == _deltaLQTYVotes.length && _initiatives.length == _deltaLQTYVetos.length, "Governance: array-length-mismatch" ); - (, GlobalState memory state) = _snapshotVotes(); - - uint256 votingThreshold = calculateVotingThreshold(); + (VoteSnapshot memory votesSnapshot_, GlobalState memory state) = _snapshotVotes(); uint16 currentEpoch = epoch(); - UserState memory userState = userStates[msg.sender]; for (uint256 i = 0; i < _initiatives.length; i++) { address initiative = _initiatives[i]; - int176 deltaLQTYVotes = _deltaLQTYVotes[i]; - int176 deltaLQTYVetos = _deltaLQTYVetos[i]; - - // only allow vetoing post the voting cutoff - require( - deltaLQTYVotes <= 0 || deltaLQTYVotes >= 0 && secondsWithinEpoch() <= EPOCH_VOTING_CUTOFF, - "Governance: epoch-voting-cutoff" - ); + int88 deltaLQTYVotes = _deltaLQTYVotes[i]; + int88 deltaLQTYVetos = _deltaLQTYVetos[i]; + + /// === Check FSM === /// + // Can vote positively in SKIP, CLAIMABLE, CLAIMED and UNREGISTERABLE states + // Force to remove votes if disabled + // Can remove votes and vetos in every stage + (InitiativeVoteSnapshot memory votesForInitiativeSnapshot_, InitiativeState memory initiativeState) = + _snapshotVotesForInitiative(initiative); + + (InitiativeStatus status,,) = + getInitiativeState(initiative, votesSnapshot_, votesForInitiativeSnapshot_, initiativeState); + + if (deltaLQTYVotes > 0 || deltaLQTYVetos > 0) { + /// @audit You cannot vote on `unregisterable` but a vote may have been there + require( + status == InitiativeStatus.SKIP || status == InitiativeStatus.CLAIMABLE + || status == InitiativeStatus.CLAIMED, + "Governance: active-vote-fsm" + ); + } - // only allow allocations to initiatives that are active - // an initiative becomes active in the epoch after it is registered - { - uint16 registeredAtEpoch = registeredInitiatives[initiative]; - require(currentEpoch > registeredAtEpoch && registeredAtEpoch != 0, "Governance: initiative-not-active"); + if (status == InitiativeStatus.DISABLED) { + require(deltaLQTYVotes <= 0 && deltaLQTYVetos <= 0, "Must be a withdrawal"); } - (, InitiativeState memory initiativeState) = _snapshotVotesForInitiative(initiative); + /// === UPDATE ACCOUNTING === /// + // == INITIATIVE STATE == // // deep copy of the initiative's state before the allocation InitiativeState memory prevInitiativeState = InitiativeState( @@ -416,19 +741,21 @@ contract Governance is Multicall, UserProxyFactory, ReentrancyGuard, IGovernance initiativeState.vetoLQTY, initiativeState.averageStakingTimestampVoteLQTY, initiativeState.averageStakingTimestampVetoLQTY, - initiativeState.counted + initiativeState.lastEpochClaim ); // update the average staking timestamp for the initiative based on the user's average staking timestamp initiativeState.averageStakingTimestampVoteLQTY = _calculateAverageTimestamp( initiativeState.averageStakingTimestampVoteLQTY, userState.averageStakingTimestamp, + /// @audit This is wrong unless we enforce a reset on deposit and withdrawal initiativeState.voteLQTY, add(initiativeState.voteLQTY, deltaLQTYVotes) ); initiativeState.averageStakingTimestampVetoLQTY = _calculateAverageTimestamp( initiativeState.averageStakingTimestampVetoLQTY, userState.averageStakingTimestamp, + /// @audit This is wrong unless we enforce a reset on deposit and withdrawal initiativeState.vetoLQTY, add(initiativeState.vetoLQTY, deltaLQTYVetos) ); @@ -437,34 +764,56 @@ contract Governance is Multicall, UserProxyFactory, ReentrancyGuard, IGovernance initiativeState.voteLQTY = add(initiativeState.voteLQTY, deltaLQTYVotes); initiativeState.vetoLQTY = add(initiativeState.vetoLQTY, deltaLQTYVetos); - // determine if the initiative's allocated voting LQTY should be included in the vote count - uint240 votesForInitiative = - lqtyToVotes(initiativeState.voteLQTY, block.timestamp, initiativeState.averageStakingTimestampVoteLQTY); - initiativeState.counted = (votesForInitiative >= votingThreshold) ? 1 : 0; - // update the initiative's state initiativeStates[initiative] = initiativeState; + // == GLOBAL STATE == // + + // TODO: Veto reducing total votes logic change + // TODO: Accounting invariants + // TODO: Let's say I want to cap the votes vs weights + // Then by definition, I add the effective LQTY + // And the effective TS + // I remove the previous one + // and add the next one + // Veto > Vote + // Reduce down by Vote (cap min) + // If Vote > Veto + // Increase by Veto - Veto (reduced max) + // update the average staking timestamp for all counted voting LQTY - if (prevInitiativeState.counted == 1) { + /// Discount previous only if the initiative was not unregistered + + /// @audit We update the state only for non-disabled initiaitives + /// Disabled initiaitves have had their totals subtracted already + /// Math is also non associative so we cannot easily compare values + if (status != InitiativeStatus.DISABLED) { + /// @audit Trophy: `test_property_sum_of_lqty_global_user_matches_0` + /// Removing votes from state desynchs the state until all users remove their votes from the initiative + /// The invariant that holds is: the one that removes the initiatives that have been unregistered state.countedVoteLQTYAverageTimestamp = _calculateAverageTimestamp( state.countedVoteLQTYAverageTimestamp, - initiativeState.averageStakingTimestampVoteLQTY, + prevInitiativeState.averageStakingTimestampVoteLQTY, + /// @audit We don't have a test that fails when this line is changed state.countedVoteLQTY, state.countedVoteLQTY - prevInitiativeState.voteLQTY ); + assert(state.countedVoteLQTY >= prevInitiativeState.voteLQTY); + /// @audit INVARIANT: Never overflows state.countedVoteLQTY -= prevInitiativeState.voteLQTY; - } - if (initiativeState.counted == 1) { + state.countedVoteLQTYAverageTimestamp = _calculateAverageTimestamp( state.countedVoteLQTYAverageTimestamp, initiativeState.averageStakingTimestampVoteLQTY, state.countedVoteLQTY, state.countedVoteLQTY + initiativeState.voteLQTY ); + state.countedVoteLQTY += initiativeState.voteLQTY; } + // == USER ALLOCATION == // + // allocate the voting and vetoing LQTY to the initiative Allocation memory allocation = lqtyAllocatedByUserToInitiative[msg.sender][initiative]; allocation.voteLQTY = add(allocation.voteLQTY, deltaLQTYVotes); @@ -473,44 +822,123 @@ contract Governance is Multicall, UserProxyFactory, ReentrancyGuard, IGovernance require(!(allocation.voteLQTY != 0 && allocation.vetoLQTY != 0), "Governance: vote-and-veto"); lqtyAllocatedByUserToInitiative[msg.sender][initiative] = allocation; + // == USER STATE == // + userState.allocatedLQTY = add(userState.allocatedLQTY, deltaLQTYVotes + deltaLQTYVetos); emit AllocateLQTY(msg.sender, initiative, deltaLQTYVotes, deltaLQTYVetos, currentEpoch); - try IInitiative(initiative).onAfterAllocateLQTY( - currentEpoch, msg.sender, allocation.voteLQTY, allocation.vetoLQTY - ) {} catch {} + // Replaces try / catch | Enforces sufficient gas is passed + safeCallWithMinGas( + initiative, + MIN_GAS_TO_HOOK, + 0, + abi.encodeCall( + IInitiative.onAfterAllocateLQTY, (currentEpoch, msg.sender, userState, allocation, initiativeState) + ) + ); } require( userState.allocatedLQTY == 0 || userState.allocatedLQTY <= uint88(stakingV1.stakes(deriveUserProxyAddress(msg.sender))), - "Governance: insufficient-or-unallocated-lqty" + "Governance: insufficient-or-allocated-lqty" ); globalState = state; userStates[msg.sender] = userState; } + /// @inheritdoc IGovernance + function unregisterInitiative(address _initiative) external nonReentrant { + /// Enforce FSM + (VoteSnapshot memory votesSnapshot_, GlobalState memory state) = _snapshotVotes(); + (InitiativeVoteSnapshot memory votesForInitiativeSnapshot_, InitiativeState memory initiativeState) = + _snapshotVotesForInitiative(_initiative); + + (InitiativeStatus status,,) = + getInitiativeState(_initiative, votesSnapshot_, votesForInitiativeSnapshot_, initiativeState); + require(status != InitiativeStatus.NONEXISTENT, "Governance: initiative-not-registered"); + require(status != InitiativeStatus.WARM_UP, "Governance: initiative-in-warm-up"); + require(status == InitiativeStatus.UNREGISTERABLE, "Governance: cannot-unregister-initiative"); + + // Remove weight from current state + uint16 currentEpoch = epoch(); + + /// @audit Invariant: Must only claim once or unregister + // NOTE: Safe to remove | See `check_claim_soundness` + assert(initiativeState.lastEpochClaim < currentEpoch - 1); + + // recalculate the average staking timestamp for all counted voting LQTY if the initiative was counted in + /// @audit Trophy: `test_property_sum_of_lqty_global_user_matches_0` + // Removing votes from state desynchs the state until all users remove their votes from the initiative + + state.countedVoteLQTYAverageTimestamp = _calculateAverageTimestamp( + state.countedVoteLQTYAverageTimestamp, + initiativeState.averageStakingTimestampVoteLQTY, + state.countedVoteLQTY, + state.countedVoteLQTY - initiativeState.voteLQTY + ); + assert(state.countedVoteLQTY >= initiativeState.voteLQTY); + /// RECON: Overflow + state.countedVoteLQTY -= initiativeState.voteLQTY; + + globalState = state; + + /// weeks * 2^16 > u32 so the contract will stop working before this is an issue + registeredInitiatives[_initiative] = UNREGISTERED_INITIATIVE; + + emit UnregisterInitiative(_initiative, currentEpoch); + + // Replaces try / catch | Enforces sufficient gas is passed + safeCallWithMinGas( + _initiative, MIN_GAS_TO_HOOK, 0, abi.encodeCall(IInitiative.onUnregisterInitiative, (currentEpoch)) + ); + } + /// @inheritdoc IGovernance function claimForInitiative(address _initiative) external nonReentrant returns (uint256) { + // Accrue and update state (VoteSnapshot memory votesSnapshot_,) = _snapshotVotes(); - (InitiativeVoteSnapshot memory votesForInitiativeSnapshot_,) = _snapshotVotesForInitiative(_initiative); + (InitiativeVoteSnapshot memory votesForInitiativeSnapshot_, InitiativeState memory initiativeState) = + _snapshotVotesForInitiative(_initiative); - // return 0 if the initiative has no votes - if (votesSnapshot_.votes == 0 || votesForInitiativeSnapshot_.votes == 0) return 0; + // Compute values on accrued state + (InitiativeStatus status,, uint256 claimableAmount) = + getInitiativeState(_initiative, votesSnapshot_, votesForInitiativeSnapshot_, initiativeState); - uint256 claim = votesForInitiativeSnapshot_.votes * boldAccrued / votesSnapshot_.votes; + if (status != InitiativeStatus.CLAIMABLE) { + return 0; + } + + /// @audit INVARIANT: You can only claim for previous epoch + assert(votesSnapshot_.forEpoch == epoch() - 1); - votesForInitiativeSnapshot_.votes = 0; - votesForInitiativeSnapshot[_initiative] = votesForInitiativeSnapshot_; // implicitly prevents double claiming + /// All unclaimed rewards are always recycled + /// Invariant `lastEpochClaim` is < epoch() - 1; | + /// If `lastEpochClaim` is older than epoch() - 1 it means the initiative couldn't claim any rewards this epoch + initiativeStates[_initiative].lastEpochClaim = epoch() - 1; - bold.safeTransfer(_initiative, claim); + // @audit INVARIANT, because of rounding errors the system can overpay + /// We upscale the timestamp to reduce the impact of the loss + /// However this is still possible + uint256 available = bold.balanceOf(address(this)); + if (claimableAmount > available) { + claimableAmount = available; + } - emit ClaimForInitiative(_initiative, claim, votesSnapshot_.forEpoch); + bold.safeTransfer(_initiative, claimableAmount); - try IInitiative(_initiative).onClaimForInitiative(votesSnapshot_.forEpoch, claim) {} catch {} + emit ClaimForInitiative(_initiative, claimableAmount, votesSnapshot_.forEpoch); + + // Replaces try / catch | Enforces sufficient gas is passed + safeCallWithMinGas( + _initiative, + MIN_GAS_TO_HOOK, + 0, + abi.encodeCall(IInitiative.onClaimForInitiative, (votesSnapshot_.forEpoch, claimableAmount)) + ); - return claim; + return claimableAmount; } } diff --git a/src/UniV4Donations.sol b/src/UniV4Donations.sol index 654aa016..6666b18c 100644 --- a/src/UniV4Donations.sol +++ b/src/UniV4Donations.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -import {IERC20} from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; -import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "openzeppelin/contracts/interfaces/IERC20.sol"; +import {SafeERC20} from "openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol"; import {IHooks} from "v4-core/src/interfaces/IHooks.sol"; @@ -84,8 +84,29 @@ contract UniV4Donations is BribeInitiative, BaseHook { return _vesting; } + /// @dev TO FIX + uint256 public received; + + /// @notice On claim we deposit the rewards - This is to prevent a griefing + function onClaimForInitiative(uint16, uint256 _bold) external override onlyGovernance { + received += _bold; + } + function _donateToPool() internal returns (uint256) { - Vesting memory _vesting = _restartVesting(uint240(governance.claimForInitiative(address(this)))); + /// @audit TODO: Need to use storage value here I think + /// TODO: Test and fix release speed, which looks off + + // Claim again // NOTE: May be grifed + governance.claimForInitiative(address(this)); + + /// @audit Includes the queued rewards + uint256 toUse = received; + + // Reset + received = 0; + + // Rest of logic + Vesting memory _vesting = _restartVesting(uint240(toUse)); uint256 amount = (_vesting.amount * (block.timestamp - vestingEpochStart()) / VESTING_EPOCH_DURATION) - _vesting.released; diff --git a/src/UserProxy.sol b/src/UserProxy.sol index 08ae5fbb..01df8665 100644 --- a/src/UserProxy.sol +++ b/src/UserProxy.sol @@ -1,9 +1,9 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -import {IERC20} from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; -import {IERC20Permit} from "openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Permit.sol"; -import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "openzeppelin/contracts/interfaces/IERC20.sol"; +import {IERC20Permit} from "openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; +import {SafeERC20} from "openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IUserProxy} from "./interfaces/IUserProxy.sol"; import {ILQTYStaking} from "./interfaces/ILQTYStaking.sol"; @@ -36,7 +36,7 @@ contract UserProxy is IUserProxy { /// @inheritdoc IUserProxy function stake(uint256 _amount, address _lqtyFrom) public onlyStakingV2 { - lqty.transferFrom(_lqtyFrom, address(this), _amount); + lqty.safeTransferFrom(_lqtyFrom, address(this), _amount); lqty.approve(address(stakingV1), _amount); stakingV1.stake(_amount); emit Stake(_amount, _lqtyFrom); @@ -61,7 +61,7 @@ contract UserProxy is IUserProxy { } /// @inheritdoc IUserProxy - function unstake(uint256 _amount, address _lqtyRecipient, address _lusdEthRecipient) + function unstake(uint256 _amount, address _recipient) public onlyStakingV2 returns (uint256 lusdAmount, uint256 ethAmount) @@ -69,21 +69,21 @@ contract UserProxy is IUserProxy { stakingV1.unstake(_amount); uint256 lqtyAmount = lqty.balanceOf(address(this)); - if (lqtyAmount > 0) lqty.safeTransfer(_lqtyRecipient, lqtyAmount); + if (lqtyAmount > 0) lqty.safeTransfer(_recipient, lqtyAmount); lusdAmount = lusd.balanceOf(address(this)); - if (lusdAmount > 0) lusd.safeTransfer(_lusdEthRecipient, lusdAmount); + if (lusdAmount > 0) lusd.safeTransfer(_recipient, lusdAmount); ethAmount = address(this).balance; if (ethAmount > 0) { - (bool success,) = payable(_lusdEthRecipient).call{value: ethAmount}(""); - success; + (bool success,) = payable(_recipient).call{value: ethAmount}(""); + require(success, "UserProxy: eth-fail"); } - emit Unstake(_amount, _lqtyRecipient, _lusdEthRecipient, lusdAmount, ethAmount); + emit Unstake(_amount, _recipient, lusdAmount, ethAmount); } /// @inheritdoc IUserProxy - function staked() external view returns (uint96) { - return uint96(stakingV1.stakes(address(this))); + function staked() external view returns (uint88) { + return uint88(stakingV1.stakes(address(this))); } receive() external payable {} diff --git a/src/UserProxyFactory.sol b/src/UserProxyFactory.sol index 1ab5da34..722f442b 100644 --- a/src/UserProxyFactory.sol +++ b/src/UserProxyFactory.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -import {Clones} from "openzeppelin-contracts/contracts/proxy/Clones.sol"; +import {Clones} from "openzeppelin/contracts/proxy/Clones.sol"; import {IUserProxyFactory} from "./interfaces/IUserProxyFactory.sol"; import {UserProxy} from "./UserProxy.sol"; diff --git a/src/interfaces/IBribeInitiative.sol b/src/interfaces/IBribeInitiative.sol index 7f8838ea..4f349c9f 100644 --- a/src/interfaces/IBribeInitiative.sol +++ b/src/interfaces/IBribeInitiative.sol @@ -1,14 +1,14 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -import {IERC20} from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; +import {IERC20} from "openzeppelin/contracts/interfaces/IERC20.sol"; import {IGovernance} from "./IGovernance.sol"; interface IBribeInitiative { event DepositBribe(address depositor, uint128 boldAmount, uint128 bribeTokenAmount, uint16 epoch); - event ModifyLQTYAllocation(address user, uint16 epoch, uint88 lqtyAllocated); - event ModifyTotalLQTYAllocation(uint16 epoch, uint88 totalLQTYAllocated); + event ModifyLQTYAllocation(address user, uint16 epoch, uint88 lqtyAllocated, uint120 averageTimestamp); + event ModifyTotalLQTYAllocation(uint16 epoch, uint88 totalLQTYAllocated, uint120 averageTimestamp); event ClaimBribe(address user, uint16 epoch, uint256 boldAmount, uint256 bribeTokenAmount); /// @notice Address of the governance contract @@ -40,12 +40,18 @@ interface IBribeInitiative { /// @notice Total LQTY allocated to the initiative at a given epoch /// @param _epoch Epoch at which the LQTY was allocated /// @return totalLQTYAllocated Total LQTY allocated - function totalLQTYAllocatedByEpoch(uint16 _epoch) external view returns (uint88 totalLQTYAllocated); + function totalLQTYAllocatedByEpoch(uint16 _epoch) + external + view + returns (uint88 totalLQTYAllocated, uint120 averageTimestamp); /// @notice LQTY allocated by a user to the initiative at a given epoch /// @param _user Address of the user /// @param _epoch Epoch at which the LQTY was allocated by the user /// @return lqtyAllocated LQTY allocated by the user - function lqtyAllocatedByUserAtEpoch(address _user, uint16 _epoch) external view returns (uint88 lqtyAllocated); + function lqtyAllocatedByUserAtEpoch(address _user, uint16 _epoch) + external + view + returns (uint88 lqtyAllocated, uint120 averageTimestamp); /// @notice Deposit bribe tokens for a given epoch /// @dev The caller has to approve this contract to spend the BOLD and bribe tokens. @@ -72,4 +78,10 @@ interface IBribeInitiative { function claimBribes(ClaimData[] calldata _claimData) external returns (uint256 boldAmount, uint256 bribeTokenAmount); + + /// @notice Given a user address return the last recorded epoch for their allocation + function getMostRecentUserEpoch(address _user) external view returns (uint16); + + /// @notice Return the last recorded epoch for the system + function getMostRecentTotalEpoch() external view returns (uint16); } diff --git a/src/interfaces/IGovernance.sol b/src/interfaces/IGovernance.sol index 1a4cb51d..9d1fb76a 100644 --- a/src/interfaces/IGovernance.sol +++ b/src/interfaces/IGovernance.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -import {IERC20} from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; +import {IERC20} from "openzeppelin/contracts/interfaces/IERC20.sol"; import {ILQTYStaking} from "./ILQTYStaking.sol"; @@ -34,6 +34,8 @@ interface IGovernance { uint32 epochVotingCutoff; } + function registerInitialInitiatives(address[] memory _initiatives) external; + /// @notice Address of the LQTY StakingV1 contract /// @return stakingV1 Address of the LQTY StakingV1 contract function stakingV1() external view returns (ILQTYStaking stakingV1); @@ -92,6 +94,7 @@ interface IGovernance { uint224 votes; // Votes at epoch transition uint16 forEpoch; // Epoch for which the votes are counted uint16 lastCountedEpoch; // Epoch at which which the votes where counted last in the global snapshot + uint224 vetos; // Vetos at epoch transition } /// @notice Returns the vote count snapshot of the previous epoch @@ -106,7 +109,7 @@ interface IGovernance { function votesForInitiativeSnapshot(address _initiative) external view - returns (uint224 votes, uint16 forEpoch, uint16 lastCountedEpoch); + returns (uint224 votes, uint16 forEpoch, uint16 lastCountedEpoch, uint224 vetos); struct Allocation { uint88 voteLQTY; // LQTY allocated vouching for the initiative @@ -116,48 +119,49 @@ interface IGovernance { struct UserState { uint88 allocatedLQTY; // LQTY allocated by the user - uint32 averageStakingTimestamp; // Average timestamp at which LQTY was staked by the user + uint120 averageStakingTimestamp; // Average timestamp at which LQTY was staked by the user } struct InitiativeState { uint88 voteLQTY; // LQTY allocated vouching for the initiative uint88 vetoLQTY; // LQTY allocated vetoing the initiative - uint32 averageStakingTimestampVoteLQTY; // Average staking timestamp of the voting LQTY for the initiative - uint32 averageStakingTimestampVetoLQTY; // Average staking timestamp of the vetoing LQTY for the initiative - uint16 counted; // Whether votes should be counted in the next snapshot (in 'globalAllocation.countedLQTY') + uint120 averageStakingTimestampVoteLQTY; // Average staking timestamp of the voting LQTY for the initiative + uint120 averageStakingTimestampVetoLQTY; // Average staking timestamp of the vetoing LQTY for the initiative + uint16 lastEpochClaim; } struct GlobalState { uint88 countedVoteLQTY; // Total LQTY that is included in vote counting - uint32 countedVoteLQTYAverageTimestamp; // Average timestamp: derived initiativeAllocation.averageTimestamp + uint120 countedVoteLQTYAverageTimestamp; // Average timestamp: derived initiativeAllocation.averageTimestamp } + /// TODO: Bold balance? Prob cheaper /// @notice Returns the user's state /// @param _user Address of the user /// @return allocatedLQTY LQTY allocated by the user /// @return averageStakingTimestamp Average timestamp at which LQTY was staked (deposited) by the user - function userStates(address _user) external view returns (uint88 allocatedLQTY, uint32 averageStakingTimestamp); + function userStates(address _user) external view returns (uint88 allocatedLQTY, uint120 averageStakingTimestamp); /// @notice Returns the initiative's state /// @param _initiative Address of the initiative /// @return voteLQTY LQTY allocated vouching for the initiative /// @return vetoLQTY LQTY allocated vetoing the initiative /// @return averageStakingTimestampVoteLQTY // Average staking timestamp of the voting LQTY for the initiative /// @return averageStakingTimestampVetoLQTY // Average staking timestamp of the vetoing LQTY for the initiative - /// @return counted // Whether votes should be counted in the next snapshot (in 'globalAllocation.countedLQTY') + /// @return lastEpochClaim // Last epoch at which rewards were claimed function initiativeStates(address _initiative) external view returns ( uint88 voteLQTY, uint88 vetoLQTY, - uint32 averageStakingTimestampVoteLQTY, - uint32 averageStakingTimestampVetoLQTY, - uint16 counted + uint120 averageStakingTimestampVoteLQTY, + uint120 averageStakingTimestampVetoLQTY, + uint16 lastEpochClaim ); /// @notice Returns the global state /// @return countedVoteLQTY Total LQTY that is included in vote counting /// @return countedVoteLQTYAverageTimestamp Average timestamp: derived initiativeAllocation.averageTimestamp - function globalState() external view returns (uint88 countedVoteLQTY, uint32 countedVoteLQTYAverageTimestamp); + function globalState() external view returns (uint88 countedVoteLQTY, uint120 countedVoteLQTYAverageTimestamp); /// @notice Returns the amount of voting and vetoing LQTY a user allocated to an initiative /// @param _user Address of the user /// @param _initiative Address of the initiative @@ -213,16 +217,17 @@ interface IGovernance { /// @param _currentTimestamp Current timestamp /// @param _averageTimestamp Average timestamp at which the LQTY was staked /// @return votes Number of votes - function lqtyToVotes(uint88 _lqtyAmount, uint256 _currentTimestamp, uint32 _averageTimestamp) + function lqtyToVotes(uint88 _lqtyAmount, uint120 _currentTimestamp, uint120 _averageTimestamp) external pure - returns (uint240); + returns (uint208); /// @notice Voting threshold is the max. of either: /// - 4% of the total voting LQTY in the previous epoch /// - or the minimum number of votes necessary to claim at least MIN_CLAIM BOLD + /// This value can be offsynch, use the non view `calculateVotingThreshold` to always retrieve the most up to date value /// @return votingThreshold Voting threshold - function calculateVotingThreshold() external view returns (uint256 votingThreshold); + function getLatestVotingThreshold() external view returns (uint256 votingThreshold); /// @notice Snapshots votes for the previous epoch and accrues funds for the current epoch /// @param _initiative Address of the initiative @@ -242,14 +247,16 @@ interface IGovernance { /// @notice Allocates the user's LQTY to initiatives /// @dev The user can only allocate to active initiatives (older than 1 epoch) and has to have enough unallocated - /// LQTY available - /// @param _initiatives Addresses of the initiatives to allocate to - /// @param _deltaLQTYVotes Delta LQTY to allocate to the initiatives as votes - /// @param _deltaLQTYVetos Delta LQTY to allocate to the initiatives as vetos + /// LQTY available, the initiatives listed must be unique, and towards the end of the epoch a user can only maintain or reduce their votes + /// @param _resetInitiatives Addresses of the initiatives the caller was previously allocated to, must be reset to prevent desynch of voting power + /// @param _initiatives Addresses of the initiatives to allocate to, can match or be different from `_resetInitiatives` + /// @param _absoluteLQTYVotes Delta LQTY to allocate to the initiatives as votes + /// @param absoluteLQTYVetos Delta LQTY to allocate to the initiatives as vetos function allocateLQTY( + address[] calldata _resetInitiatives, address[] memory _initiatives, - int176[] memory _deltaLQTYVotes, - int176[] memory _deltaLQTYVetos + int88[] memory _absoluteLQTYVotes, + int88[] memory absoluteLQTYVetos ) external; /// @notice Splits accrued funds according to votes received between all initiatives diff --git a/src/interfaces/IInitiative.sol b/src/interfaces/IInitiative.sol index b530291a..ddb3179b 100644 --- a/src/interfaces/IInitiative.sol +++ b/src/interfaces/IInitiative.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; +import {IGovernance} from "./IGovernance.sol"; + interface IInitiative { /// @notice Callback hook that is called by Governance after the initiative was successfully registered /// @param _atEpoch Epoch at which the initiative is registered @@ -13,9 +15,16 @@ interface IInitiative { /// @notice Callback hook that is called by Governance after the LQTY allocation is updated by a user /// @param _currentEpoch Epoch at which the LQTY allocation is updated /// @param _user Address of the user that updated their LQTY allocation - /// @param _voteLQTY Allocated voting LQTY - /// @param _vetoLQTY Allocated vetoing LQTY - function onAfterAllocateLQTY(uint16 _currentEpoch, address _user, uint88 _voteLQTY, uint88 _vetoLQTY) external; + /// @param _userState User state + /// @param _allocation Allocation state from user to initiative + /// @param _initiativeState Initiative state + function onAfterAllocateLQTY( + uint16 _currentEpoch, + address _user, + IGovernance.UserState calldata _userState, + IGovernance.Allocation calldata _allocation, + IGovernance.InitiativeState calldata _initiativeState + ) external; /// @notice Callback hook that is called by Governance after the claim for the last epoch was distributed /// to the initiative diff --git a/src/interfaces/IUserProxy.sol b/src/interfaces/IUserProxy.sol index 126812b7..4169e93f 100644 --- a/src/interfaces/IUserProxy.sol +++ b/src/interfaces/IUserProxy.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -import {IERC20} from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; +import {IERC20} from "openzeppelin/contracts/interfaces/IERC20.sol"; import {ILQTYStaking} from "../interfaces/ILQTYStaking.sol"; @@ -9,9 +9,7 @@ import {PermitParams} from "../utils/Types.sol"; interface IUserProxy { event Stake(uint256 amount, address lqtyFrom); - event Unstake( - uint256 lqtyUnstaked, address lqtyRecipient, address lusdEthRecipient, uint256 lusdAmount, uint256 ethAmount - ); + event Unstake(uint256 lqtyUnstaked, address indexed lqtyRecipient, uint256 lusdAmount, uint256 ethAmount); /// @notice Address of the LQTY token /// @return lqty Address of the LQTY token @@ -38,14 +36,11 @@ interface IUserProxy { function stakeViaPermit(uint256 _amount, address _lqtyFrom, PermitParams calldata _permitParams) external; /// @notice Unstakes a given amount of LQTY tokens from the V1 staking contract and claims the accrued rewards /// @param _amount Amount of LQTY tokens to unstake - /// @param _lqtyRecipient Address to which the unstaked LQTY tokens should be sent - /// @param _lusdEthRecipient Address to which the unstaked LUSD and ETH rewards should be sent + /// @param _recipient Address to which the tokens should be sent /// @return lusdAmount Amount of LUSD tokens claimed /// @return ethAmount Amount of ETH claimed - function unstake(uint256 _amount, address _lqtyRecipient, address _lusdEthRecipient) - external - returns (uint256 lusdAmount, uint256 ethAmount); + function unstake(uint256 _amount, address _recipient) external returns (uint256 lusdAmount, uint256 ethAmount); /// @notice Returns the current amount LQTY staked by a user in the V1 staking contract /// @return staked Amount of LQTY tokens staked - function staked() external view returns (uint96); + function staked() external view returns (uint88); } diff --git a/src/utils/DoubleLinkedList.sol b/src/utils/DoubleLinkedList.sol index 7624d591..e91cb14f 100644 --- a/src/utils/DoubleLinkedList.sol +++ b/src/utils/DoubleLinkedList.sol @@ -6,7 +6,7 @@ pragma solidity ^0.8.24; /// and the tail is defined as the null item's next pointer ([tail][prev][item][next][head]) library DoubleLinkedList { struct Item { - uint88 value; + uint224 value; uint16 prev; uint16 next; } @@ -53,7 +53,7 @@ library DoubleLinkedList { /// @param list Linked list which contains the item /// @param id Id of the item /// @return _ Value of the item - function getValue(List storage list, uint16 id) internal view returns (uint88) { + function getValue(List storage list, uint16 id) internal view returns (uint224) { return list.items[id].value; } @@ -81,7 +81,7 @@ library DoubleLinkedList { /// @param id Id of the item to insert /// @param value Value of the item to insert /// @param next Id of the item which should follow item `id` - function insert(List storage list, uint16 id, uint88 value, uint16 next) internal { + function insert(List storage list, uint16 id, uint224 value, uint16 next) internal { if (contains(list, id)) revert ItemInList(); if (next != 0 && !contains(list, next)) revert ItemNotInList(); uint16 prev = list.items[next].prev; diff --git a/src/utils/EncodingDecodingLib.sol b/src/utils/EncodingDecodingLib.sol new file mode 100644 index 00000000..79026859 --- /dev/null +++ b/src/utils/EncodingDecodingLib.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +library EncodingDecodingLib { + function encodeLQTYAllocation(uint88 _lqty, uint120 _averageTimestamp) internal pure returns (uint224) { + uint224 _value = (uint224(_lqty) << 120) | _averageTimestamp; + return _value; + } + + function decodeLQTYAllocation(uint224 _value) internal pure returns (uint88, uint120) { + return (uint88(_value >> 120), uint120(_value)); + } +} diff --git a/src/utils/Math.sol b/src/utils/Math.sol index 2e1d6246..dd608737 100644 --- a/src/utils/Math.sol +++ b/src/utils/Math.sol @@ -1,20 +1,17 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -function add(uint88 a, int192 b) pure returns (uint88) { +function add(uint88 a, int88 b) pure returns (uint88) { if (b < 0) { - return uint88(a - uint88(uint192(-b))); + return a - abs(b); } - return uint88(a + uint88(uint192(b))); -} - -function sub(uint256 a, int256 b) pure returns (uint128) { - if (b < 0) { - return uint128(a + uint256(-b)); - } - return uint128(a - uint256(b)); + return a + abs(b); } function max(uint256 a, uint256 b) pure returns (uint256) { return a > b ? a : b; } + +function abs(int88 a) pure returns (uint88) { + return a < 0 ? uint88(uint256(-int256(a))) : uint88(a); +} diff --git a/src/utils/Ownable.sol b/src/utils/Ownable.sol new file mode 100644 index 00000000..46792439 --- /dev/null +++ b/src/utils/Ownable.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.24; + +/** + * Based on OpenZeppelin's Ownable contract: + * https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/access/Ownable.sol + * + * @dev Contract module which provides a basic access control mechanism, where + * there is an account (an owner) that can be granted exclusive access to + * specific functions. + * + * This module is used through inheritance. It will make available the modifier + * `onlyOwner`, which can be applied to your functions to restrict their use to + * the owner. + */ +contract Ownable { + address private _owner; + + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + /** + * @dev Initializes the contract setting `initialOwner` as the initial owner. + */ + constructor(address initialOwner) { + _owner = initialOwner; + emit OwnershipTransferred(address(0), initialOwner); + } + + /** + * @dev Returns the address of the current owner. + */ + function owner() public view returns (address) { + return _owner; + } + + /** + * @dev Throws if called by any account other than the owner. + */ + modifier onlyOwner() { + require(isOwner(), "Ownable: caller is not the owner"); + _; + } + + /** + * @dev Returns true if the caller is the current owner. + */ + function isOwner() public view returns (bool) { + return msg.sender == _owner; + } + + /** + * @dev Leaves the contract without owner. It will not be possible to call + * `onlyOwner` functions anymore. + * + * NOTE: Renouncing ownership will leave the contract without an owner, + * thereby removing any functionality that is only available to the owner. + * + * NOTE: This function is not safe, as it doesn’t check owner is calling it. + * Make sure you check it before calling it. + */ + function _renounceOwnership() internal { + emit OwnershipTransferred(_owner, address(0)); + _owner = address(0); + } +} diff --git a/src/utils/SafeCallMinGas.sol b/src/utils/SafeCallMinGas.sol new file mode 100644 index 00000000..759d8be2 --- /dev/null +++ b/src/utils/SafeCallMinGas.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @notice Given the gas requirement, ensures that the current context has sufficient gas to perform a call + a fixed buffer +/// @dev Credits: https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/src/libraries/SafeCall.sol#L100-L107 +function hasMinGas(uint256 _minGas, uint256 _reservedGas) view returns (bool) { + bool _hasMinGas; + assembly { + // Equation: gas × 63 ≥ minGas × 64 + 63(40_000 + reservedGas) + _hasMinGas := iszero(lt(mul(gas(), 63), add(mul(_minGas, 64), mul(add(40000, _reservedGas), 63)))) + } + return _hasMinGas; +} + +/// @dev Performs a call ignoring the recipient existing or not, passing the exact gas value, ignoring any return value +function safeCallWithMinGas(address _target, uint256 _gas, uint256 _value, bytes memory _calldata) + returns (bool success) +{ + /// @audit This is not necessary + /// But this is basically a worst case estimate of mem exp cost + operations before the call + require(hasMinGas(_gas, 1_000), "Must have minGas"); + + // dispatch message to recipient + // by assembly calling "handle" function + // we call via assembly to avoid memcopying a very large returndata + // returned by a malicious contract + assembly { + success := + call( + _gas, // gas + _target, // recipient + _value, // ether value + add(_calldata, 0x20), // inloc + mload(_calldata), // inlen + 0, // outloc + 0 // outlen + ) + + // Ignore all return values + } + return (success); +} diff --git a/src/utils/UniqueArray.sol b/src/utils/UniqueArray.sol new file mode 100644 index 00000000..17812174 --- /dev/null +++ b/src/utils/UniqueArray.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @dev Checks that there's no duplicate addresses +/// @param arr - List to check for dups +function _requireNoDuplicates(address[] calldata arr) pure { + uint256 arrLength = arr.length; + // only up to len - 1 (no j to check if i == len - 1) + for (uint i; i < arrLength - 1;) { + for (uint j = i + 1; j < arrLength;) { + require(arr[i] != arr[j], "dup"); + + unchecked { + ++j; + } + } + + unchecked { + ++i; + } + } +} + +function _requireNoNegatives(int88[] memory vals) pure { + uint256 arrLength = vals.length; + + for (uint i; i < arrLength; i++) { + require(vals[i] >= 0, "Cannot be negative"); + } +} diff --git a/test/BribeInitiative.t.sol b/test/BribeInitiative.t.sol index 6f8df633..6d9e301d 100644 --- a/test/BribeInitiative.t.sol +++ b/test/BribeInitiative.t.sol @@ -1,10 +1,11 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.24; -import {Test} from "forge-std/Test.sol"; +import {Test, console2} from "forge-std/Test.sol"; import {MockERC20} from "forge-std/mocks/MockERC20.sol"; import {IGovernance} from "../src/interfaces/IGovernance.sol"; +import {IBribeInitiative} from "../src/interfaces/IBribeInitiative.sol"; import {Governance} from "../src/Governance.sol"; import {BribeInitiative} from "../src/BribeInitiative.sol"; @@ -15,7 +16,9 @@ contract BribeInitiativeTest is Test { MockERC20 private lqty; MockERC20 private lusd; address private stakingV1; - address private constant user = address(0xF977814e90dA44bFA03b6295A0616a897441aceC); + address private constant user1 = address(0xF977814e90dA44bFA03b6295A0616a897441aceC); + address private constant user2 = address(0x10C9cff3c4Faa8A60cB8506a7A99411E6A199038); + address private user3 = makeAddr("user3"); address private constant lusdHolder = address(0xcA7f01403C4989d2b1A9335A2F09dD973709957c); address private constant initiative = address(0x1); address private constant initiative2 = address(0x2); @@ -29,7 +32,7 @@ contract BribeInitiativeTest is Test { uint128 private constant VOTING_THRESHOLD_FACTOR = 0.04e18; uint88 private constant MIN_CLAIM = 500e18; uint88 private constant MIN_ACCRUAL = 1000e18; - uint32 private constant EPOCH_DURATION = 604800; + uint32 private constant EPOCH_DURATION = 7 days; // 7 days uint32 private constant EPOCH_VOTING_CUTOFF = 518400; Governance private governance; @@ -41,8 +44,10 @@ contract BribeInitiativeTest is Test { lqty = deployMockERC20("Liquity", "LQTY", 18); lusd = deployMockERC20("Liquity USD", "LUSD", 18); - vm.store(address(lqty), keccak256(abi.encode(address(lusdHolder), 4)), bytes32(abi.encode(10000e18))); - vm.store(address(lusd), keccak256(abi.encode(address(lusdHolder), 4)), bytes32(abi.encode(10000e18))); + vm.store(address(lqty), keccak256(abi.encode(address(lusdHolder), 4)), bytes32(abi.encode(10_000_000e18))); + vm.store(address(lusd), keccak256(abi.encode(address(lusdHolder), 4)), bytes32(abi.encode(10_000_000e18))); + vm.store(address(lqty), keccak256(abi.encode(address(lusdHolder), 4)), bytes32(abi.encode(10_000_000e18))); + vm.store(address(lusd), keccak256(abi.encode(address(lusdHolder), 4)), bytes32(abi.encode(10_000_000e18))); stakingV1 = address(new MockStakingV1(address(lqty))); @@ -72,44 +77,657 @@ contract BribeInitiativeTest is Test { epochDuration: EPOCH_DURATION, epochVotingCutoff: EPOCH_VOTING_CUTOFF }), + address(this), initialInitiatives ); vm.startPrank(lusdHolder); - lqty.transfer(user, 1e18); - lusd.transfer(user, 1e18); + lqty.transfer(user1, 1_000_000e18); + lusd.transfer(user1, 1_000_000e18); + lqty.transfer(user2, 1_000_000e18); + lusd.transfer(user2, 1_000_000e18); + lqty.transfer(user3, 1_000_000e18); + lusd.transfer(user3, 1_000_000e18); vm.stopPrank(); } - function test_claimBribes() public { - vm.startPrank(user); - address userProxy = governance.deployUserProxy(); - lqty.approve(address(userProxy), 1e18); - governance.depositLQTY(1e18); - vm.stopPrank(); + // test total allocation vote case + function test_totalLQTYAllocatedByEpoch_vote() public { + // staking LQTY into governance for user1 in first epoch + _stakeLQTY(user1, 10e18); + + // fast forward to second epoch + vm.warp(block.timestamp + EPOCH_DURATION); + + // allocate LQTY to the bribeInitiative + _allocateLQTY(user1, 10e18, 0); + // total LQTY allocated for this epoch should increase + (uint88 totalLQTYAllocated,) = bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 10e18); + } + + // test total allocation veto case + function test_totalLQTYAllocatedByEpoch_veto() public { + _stakeLQTY(user1, 10e18); + + // fast forward to second epoch + vm.warp(block.timestamp + EPOCH_DURATION); + + // allocate LQTY to veto bribeInitiative + _allocateLQTY(user1, 0, 10e18); + // total LQTY allocated for this epoch should not increase + (uint88 totalLQTYAllocated,) = bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 0); + } + + // user1 allocates multiple times in different epochs + function test_allocating_same_initiative_multiple_epochs() public { + _stakeLQTY(user1, 10e18); + + // fast forward to second epoch + vm.warp(block.timestamp + EPOCH_DURATION); + + // allocate LQTY to the bribeInitiative + _allocateLQTY(user1, 5e18, 0); + + // total LQTY allocated for this epoch should increase + (uint88 totalLQTYAllocated1,) = bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + (uint88 userLQTYAllocated1,) = bribeInitiative.lqtyAllocatedByUserAtEpoch(user1, governance.epoch()); + assertEq(totalLQTYAllocated1, 5e18); + assertEq(userLQTYAllocated1, 5e18); + + // fast forward to third epoch + vm.warp(block.timestamp + EPOCH_DURATION); + + _allocateLQTY(user1, 5e18, 0); + + // total LQTY allocated for this epoch should not change + (uint88 totalLQTYAllocated2,) = bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + (uint88 userLQTYAllocated2,) = bribeInitiative.lqtyAllocatedByUserAtEpoch(user1, governance.epoch()); + assertEq(totalLQTYAllocated2, 5e18); + assertEq(userLQTYAllocated1, 5e18); + } + + // user1 allocates multiple times in same epoch + function test_totalLQTYAllocatedByEpoch_vote_same_epoch() public { + _stakeLQTY(user1, 10e18); + + vm.warp(block.timestamp + EPOCH_DURATION); + + // user1 allocates in first epoch + _allocateLQTY(user1, 5e18, 0); + (uint88 totalLQTYAllocated1,) = bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + (uint88 userLQTYAllocated1,) = bribeInitiative.lqtyAllocatedByUserAtEpoch(user1, governance.epoch()); + assertEq(totalLQTYAllocated1, 5e18); + assertEq(userLQTYAllocated1, 5e18); + + _allocateLQTY(user1, 5e18, 0); + (uint88 totalLQTYAllocated2,) = bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + (uint88 userLQTYAllocated2,) = bribeInitiative.lqtyAllocatedByUserAtEpoch(user1, governance.epoch()); + assertEq(totalLQTYAllocated2, 5e18); + assertEq(userLQTYAllocated2, 5e18); + } + + function test_allocation_stored_in_list() public { + _stakeLQTY(user1, 10e18); + + vm.warp(block.timestamp + EPOCH_DURATION); + + // user1 allocates in first epoch + _allocateLQTY(user1, 5e18, 0); + (uint88 totalLQTYAllocated1,) = bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + (uint88 userLQTYAllocated1,) = bribeInitiative.lqtyAllocatedByUserAtEpoch(user1, governance.epoch()); + assertEq(totalLQTYAllocated1, 5e18); + assertEq(userLQTYAllocated1, 5e18); + + console2.log("current governance epoch: ", governance.epoch()); + // user's linked-list should be updated to have a value for the current epoch + (uint88 allocatedAtEpoch,) = bribeInitiative.lqtyAllocatedByUserAtEpoch(user1, governance.epoch()); + console2.log("allocatedAtEpoch: ", allocatedAtEpoch); + } + + // test total allocation by multiple users in multiple epochs + function test_totalLQTYAllocatedByEpoch_vote_multiple_epochs() public { + _stakeLQTY(user1, 10e18); + _stakeLQTY(user2, 10e18); + + vm.warp(block.timestamp + EPOCH_DURATION); + + // user1 allocates in first epoch + _allocateLQTY(user1, 10e18, 0); + (uint88 totalLQTYAllocated1,) = bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + (uint88 userLQTYAllocated1,) = bribeInitiative.lqtyAllocatedByUserAtEpoch(user1, governance.epoch()); + assertEq(totalLQTYAllocated1, 10e18); + assertEq(userLQTYAllocated1, 10e18); + + // user2 allocates in second epoch + vm.warp(block.timestamp + EPOCH_DURATION); + + // user allocations should be disjoint because they're in separate epochs + _allocateLQTY(user2, 10e18, 0); + (uint88 totalLQTYAllocated2,) = bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + (uint88 userLQTYAllocated2,) = bribeInitiative.lqtyAllocatedByUserAtEpoch(user2, governance.epoch()); + assertEq(totalLQTYAllocated2, 20e18); + assertEq(userLQTYAllocated2, 10e18); + } + + // test total allocations for multiple users in the same epoch + function test_totalLQTYAllocatedByEpoch_vote_same_epoch_multiple() public { + _stakeLQTY(user1, 10e18); + _stakeLQTY(user2, 10e18); + + vm.warp(block.timestamp + EPOCH_DURATION); + + // user1 allocates in first epoch + _allocateLQTY(user1, 10e18, 0); + (uint88 totalLQTYAllocated1,) = bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + (uint88 userLQTYAllocated1,) = bribeInitiative.lqtyAllocatedByUserAtEpoch(user1, governance.epoch()); + assertEq(totalLQTYAllocated1, 10e18); + assertEq(userLQTYAllocated1, 10e18); - vm.warp(block.timestamp + 365 days); + _allocateLQTY(user2, 10e18, 0); + (uint88 totalLQTYAllocated2,) = bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + (uint88 userLQTYAllocated2,) = bribeInitiative.lqtyAllocatedByUserAtEpoch(user2, governance.epoch()); + assertEq(totalLQTYAllocated2, 20e18); + assertEq(userLQTYAllocated2, 10e18); + } + + // test total allocation doesn't grow from start to end of epoch + function test_totalLQTYAllocatedByEpoch_growth() public { + _stakeLQTY(user1, 10e18); + _stakeLQTY(user2, 10e18); + + vm.warp(block.timestamp + EPOCH_DURATION); + + // user1 allocates in first epoch + _allocateLQTY(user1, 10e18, 0); + (uint88 totalLQTYAllocated1,) = bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated1, 10e18); + + // warp to the end of the epoch + vm.warp(block.timestamp + (EPOCH_VOTING_CUTOFF - 1)); + (uint88 totalLQTYAllocated2,) = bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated2, 10e18); + } + + // test depositing bribe + function test_depositBribe_success() public { vm.startPrank(lusdHolder); lqty.approve(address(bribeInitiative), 1e18); lusd.approve(address(bribeInitiative), 1e18); bribeInitiative.depositBribe(1e18, 1e18, governance.epoch() + 1); vm.stopPrank(); + } + + // user that votes in an epoch that has bribes allocated to it will receive bribes on claiming + function test_claimBribes() public { + // =========== epoch 1 ================== + // user stakes in epoch 1 + _stakeLQTY(user1, 1e18); - vm.startPrank(user); + // =========== epoch 2 ================== + vm.warp(block.timestamp + EPOCH_DURATION); + assertEq(2, governance.epoch(), "not in epoch 2"); - vm.warp(block.timestamp + 365 days); + // lusdHolder deposits lqty and lusd bribes claimable in epoch 3 + _depositBribe(1e18, 1e18, governance.epoch() + 1); + uint16 depositedBribe = governance.epoch() + 1; - address[] memory initiatives = new address[](1); - initiatives[0] = address(bribeInitiative); - int176[] memory deltaVoteLQTY = new int176[](1); - deltaVoteLQTY[0] = 1e18; - int176[] memory deltaVetoLQTY = new int176[](1); - governance.allocateLQTY(initiatives, deltaVoteLQTY, deltaVetoLQTY); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()), 1e18); - - // should be zero since user was not deposited at that time + // =========== epoch 3 ================== + vm.warp(block.timestamp + EPOCH_DURATION); + assertEq(3, governance.epoch(), "not in epoch 3"); + + // user votes on bribeInitiative + _allocateLQTY(user1, 1e18, 0); + + // =========== epoch 5 ================== + vm.warp(block.timestamp + (EPOCH_DURATION * 2)); + assertEq(5, governance.epoch(), "not in epoch 5"); + + // user should receive bribe from their allocated stake + (uint256 boldAmount, uint256 bribeTokenAmount) = + _claimBribe(user1, depositedBribe, depositedBribe, depositedBribe); + assertEq(boldAmount, 1e18); + assertEq(bribeTokenAmount, 1e18); + } + + // user that votes in an epoch that has bribes allocated to it will receive bribes on claiming + // forge test --match-test test_high_deny_last_claim -vv + function test_high_deny_last_claim() public { + /// @audit Overflow due to rounding error in bribes total math vs user math + // See: `test_we_can_compare_votes_and_vetos` + // And `test_crit_user_can_dilute_total_votes` + vm.warp(block.timestamp + EPOCH_DURATION); + + // =========== epoch 1 ================== + // user stakes in epoch 1 + vm.warp(block.timestamp + 5); + _stakeLQTY(user1, 1e18); + vm.warp(block.timestamp + 7); + _stakeLQTY(user2, 1e18); + + // lusdHolder deposits lqty and lusd bribes claimable in epoch 3 + _depositBribe(1e18, 1e18, governance.epoch()); + _allocateLQTY(user1, 1e18, 0); + _allocateLQTY(user2, 1, 0); + _allocateLQTY(user2, 0, 0); + + // =========== epoch 2 ================== + vm.warp(block.timestamp + EPOCH_DURATION); // Needs to cause rounding error + assertEq(3, governance.epoch(), "not in epoch 2"); + + // user votes on bribeInitiative + + // user should receive bribe from their allocated stake + (uint256 boldAmount, uint256 bribeTokenAmount) = _claimBribe(user1, 2, 2, 2); + assertEq(boldAmount, 1e18); + assertEq(bribeTokenAmount, 1e18); + } + + // check that bribes deposited after user votes can be claimed + function test_claimBribes_deposited_after_vote() public { + // =========== epoch 1 ================== + // user stakes in epoch 1 + _stakeLQTY(user1, 1e18); + + // =========== epoch 2 ================== + vm.warp(block.timestamp + EPOCH_DURATION); + assertEq(2, governance.epoch(), "not in epoch 2"); + + // lusdHolder deposits lqty and lusd bribes claimable in epoch 3 + _depositBribe(1e18, 1e18, governance.epoch() + 1); + + // =========== epoch 3 ================== + vm.warp(block.timestamp + EPOCH_DURATION); + assertEq(3, governance.epoch(), "not in epoch 3"); + + // user votes on bribeInitiative + _allocateLQTY(user1, 1e18, 0); + + // lusdHolder deposits lqty and lusd bribes claimable in epoch 4 + _depositBribe(1e18, 1e18, governance.epoch() + 1); + + // =========== epoch 5 ================== + // warp ahead two epochs because bribes can't be claimed in current epoch + vm.warp(block.timestamp + (EPOCH_DURATION * 2)); + assertEq(5, governance.epoch(), "not in epoch 5"); + + // check amount of bribes in epoch 3 + (uint128 boldAmountFromStorage, uint128 bribeTokenAmountFromStorage) = + IBribeInitiative(bribeInitiative).bribeByEpoch(governance.epoch() - 2); + assertEq(boldAmountFromStorage, 1e18, "boldAmountFromStorage != 1e18"); + assertEq(bribeTokenAmountFromStorage, 1e18, "bribeTokenAmountFromStorage != 1e18"); + + // check amount of bribes in epoch 4 + (boldAmountFromStorage, bribeTokenAmountFromStorage) = + IBribeInitiative(bribeInitiative).bribeByEpoch(governance.epoch() - 1); + assertEq(boldAmountFromStorage, 1e18, "boldAmountFromStorage != 1e18"); + assertEq(bribeTokenAmountFromStorage, 1e18, "bribeTokenAmountFromStorage != 1e18"); + + // user should receive bribe from their allocated stake for each epoch + + // user claims for epoch 3 + uint16 claimEpoch = governance.epoch() - 2; // claim for epoch 3 + uint16 prevAllocationEpoch = governance.epoch() - 2; // epoch 3 + (uint256 boldAmount, uint256 bribeTokenAmount) = + _claimBribe(user1, claimEpoch, prevAllocationEpoch, prevAllocationEpoch); + assertEq(boldAmount, 1e18); + assertEq(bribeTokenAmount, 1e18); + + // user claims for epoch 4 + claimEpoch = governance.epoch() - 1; // claim for epoch 4 + prevAllocationEpoch = governance.epoch() - 2; // epoch 3 + (boldAmount, bribeTokenAmount) = _claimBribe(user1, claimEpoch, prevAllocationEpoch, prevAllocationEpoch); + assertEq(boldAmount, 1e18); + assertEq(bribeTokenAmount, 1e18); + } + + // check that received bribes are proportional to user's stake in the initiative + function test_claimedBribes_fraction() public { + // =========== epoch 1 ================== + // both users stake in epoch 1 + _stakeLQTY(user1, 1e18); + _stakeLQTY(user2, 1e18); + + // =========== epoch 2 ================== + vm.warp(block.timestamp + EPOCH_DURATION); + assertEq(2, governance.epoch(), "not in epoch 2"); + + // lusdHolder deposits lqty and lusd bribes claimable in epoch 3 + _depositBribe(1e18, 1e18, governance.epoch() + 1); + + // =========== epoch 3 ================== + vm.warp(block.timestamp + EPOCH_DURATION); + assertEq(3, governance.epoch(), "not in epoch 3"); + + // users both vote on bribeInitiative + _allocateLQTY(user1, 1e18, 0); + _allocateLQTY(user2, 1e18, 0); + + // =========== epoch 4 ================== + vm.warp(block.timestamp + EPOCH_DURATION); + assertEq(4, governance.epoch(), "not in epoch 4"); + + // user claims for epoch 3 + uint16 claimEpoch = governance.epoch() - 1; // claim for epoch 3 + uint16 prevAllocationEpoch = governance.epoch() - 1; // epoch 3 + (uint256 boldAmount, uint256 bribeTokenAmount) = + _claimBribe(user1, claimEpoch, prevAllocationEpoch, prevAllocationEpoch); + + // calculate user share of total allocation for initiative for the given epoch as percentage + (uint88 userLqtyAllocated,) = bribeInitiative.lqtyAllocatedByUserAtEpoch(user1, 3); + (uint88 totalLqtyAllocated,) = bribeInitiative.totalLQTYAllocatedByEpoch(3); + uint256 userShareOfTotalAllocated = uint256((userLqtyAllocated * 10_000) / totalLqtyAllocated); + console2.log("userLqtyAllocated: ", userLqtyAllocated); + console2.log("totalLqtyAllocated: ", totalLqtyAllocated); + + // calculate user received bribes as share of total bribes as percentage + (uint128 boldAmountForEpoch, uint128 bribeTokenAmountForEpoch) = bribeInitiative.bribeByEpoch(3); + uint256 userShareOfTotalBoldForEpoch = (boldAmount * 10_000) / uint256(boldAmountForEpoch); + uint256 userShareOfTotalBribeForEpoch = (bribeTokenAmount * 10_000) / uint256(bribeTokenAmountForEpoch); + + // check that they're equivalent + assertEq( + userShareOfTotalAllocated, + userShareOfTotalBoldForEpoch, + "userShareOfTotalAllocated != userShareOfTotalBoldForEpoch" + ); + assertEq( + userShareOfTotalAllocated, + userShareOfTotalBribeForEpoch, + "userShareOfTotalAllocated != userShareOfTotalBribeForEpoch" + ); + } + + function test_claimedBribes_fraction_fuzz(uint88 user1StakeAmount, uint88 user2StakeAmount, uint88 user3StakeAmount) + public + { + // =========== epoch 1 ================== + user1StakeAmount = uint88(bound(uint256(user1StakeAmount), 1, lqty.balanceOf(user1))); + user2StakeAmount = uint88(bound(uint256(user2StakeAmount), 1, lqty.balanceOf(user2))); + user3StakeAmount = uint88(bound(uint256(user3StakeAmount), 1, lqty.balanceOf(user3))); + + // all users stake in epoch 1 + _stakeLQTY(user1, user1StakeAmount); + _stakeLQTY(user2, user2StakeAmount); + _stakeLQTY(user3, user3StakeAmount); + + // =========== epoch 2 ================== + vm.warp(block.timestamp + EPOCH_DURATION); + assertEq(2, governance.epoch(), "not in epoch 2"); + + // lusdHolder deposits lqty and lusd bribes claimable in epoch 3 + _depositBribe(1e18, 1e18, governance.epoch() + 1); + + // =========== epoch 3 ================== + vm.warp(block.timestamp + EPOCH_DURATION); + assertEq(3, governance.epoch(), "not in epoch 3"); + + // users all vote on bribeInitiative + _allocateLQTY(user1, int88(user1StakeAmount), 0); + _allocateLQTY(user2, int88(user2StakeAmount), 0); + _allocateLQTY(user3, int88(user3StakeAmount), 0); + + // =========== epoch 4 ================== + vm.warp(block.timestamp + EPOCH_DURATION); + assertEq(4, governance.epoch(), "not in epoch 4"); + + // all users claim bribes for epoch 3 + uint16 claimEpoch = governance.epoch() - 1; // claim for epoch 3 + uint16 prevAllocationEpoch = governance.epoch() - 1; // epoch 3 + (uint256 boldAmount1, uint256 bribeTokenAmount1) = + _claimBribe(user1, claimEpoch, prevAllocationEpoch, prevAllocationEpoch); + (uint256 boldAmount2, uint256 bribeTokenAmount2) = + _claimBribe(user2, claimEpoch, prevAllocationEpoch, prevAllocationEpoch); + (uint256 boldAmount3, uint256 bribeTokenAmount3) = + _claimBribe(user3, claimEpoch, prevAllocationEpoch, prevAllocationEpoch); + + // calculate user share of total allocation for initiative for the given epoch as percentage + uint256 userShareOfTotalAllocated1 = _getUserShareOfAllocationAsPercentage(user1, 3); + uint256 userShareOfTotalAllocated2 = _getUserShareOfAllocationAsPercentage(user2, 3); + uint256 userShareOfTotalAllocated3 = _getUserShareOfAllocationAsPercentage(user3, 3); + + // calculate user received bribes as share of total bribes as percentage + (uint256 userShareOfTotalBoldForEpoch1, uint256 userShareOfTotalBribeForEpoch1) = + _getBribesAsPercentageOfTotal(3, boldAmount1, bribeTokenAmount1); + (uint256 userShareOfTotalBoldForEpoch2, uint256 userShareOfTotalBribeForEpoch2) = + _getBribesAsPercentageOfTotal(3, boldAmount2, bribeTokenAmount2); + (uint256 userShareOfTotalBoldForEpoch3, uint256 userShareOfTotalBribeForEpoch3) = + _getBribesAsPercentageOfTotal(3, boldAmount3, bribeTokenAmount3); + + // check that they're equivalent + // user1 + assertEq( + userShareOfTotalAllocated1, + userShareOfTotalBoldForEpoch1, + "userShareOfTotalAllocated1 != userShareOfTotalBoldForEpoch1" + ); + assertEq( + userShareOfTotalAllocated1, + userShareOfTotalBribeForEpoch1, + "userShareOfTotalAllocated1 != userShareOfTotalBribeForEpoch1" + ); + // user2 + assertEq( + userShareOfTotalAllocated2, + userShareOfTotalBoldForEpoch2, + "userShareOfTotalAllocated2 != userShareOfTotalBoldForEpoch2" + ); + assertEq( + userShareOfTotalAllocated2, + userShareOfTotalBribeForEpoch2, + "userShareOfTotalAllocated2 != userShareOfTotalBribeForEpoch2" + ); + // user3 + assertEq( + userShareOfTotalAllocated3, + userShareOfTotalBoldForEpoch3, + "userShareOfTotalAllocated3 != userShareOfTotalBoldForEpoch3" + ); + assertEq( + userShareOfTotalAllocated3, + userShareOfTotalBribeForEpoch3, + "userShareOfTotalAllocated3 != userShareOfTotalBribeForEpoch3" + ); + } + + // only users that voted receive bribe, vetoes shouldn't receive anything + function test_only_voter_receives_bribes() public { + // =========== epoch 1 ================== + // both users stake in epoch 1 + _stakeLQTY(user1, 1e18); + _stakeLQTY(user2, 1e18); + + // =========== epoch 2 ================== + vm.warp(block.timestamp + EPOCH_DURATION); + assertEq(2, governance.epoch(), "not in epoch 2"); + + // lusdHolder deposits lqty and lusd bribes claimable in epoch 3 + _depositBribe(1e18, 1e18, governance.epoch() + 1); + + // =========== epoch 3 ================== + vm.warp(block.timestamp + EPOCH_DURATION); + assertEq(3, governance.epoch(), "not in epoch 3"); + + // user1 votes on bribeInitiative + _allocateLQTY(user1, 1e18, 0); + // user2 vetos on bribeInitiative + _allocateLQTY(user2, 0, 1e18); + + // =========== epoch 4 ================== + vm.warp(block.timestamp + EPOCH_DURATION); + assertEq(4, governance.epoch(), "not in epoch 4"); + + // user claims for epoch 3 + uint16 claimEpoch = governance.epoch() - 1; // claim for epoch 3 + uint16 prevAllocationEpoch = governance.epoch() - 1; // epoch 3 + (uint256 boldAmount, uint256 bribeTokenAmount) = + _claimBribe(user1, claimEpoch, prevAllocationEpoch, prevAllocationEpoch); + assertEq(boldAmount, 1e18, "voter doesn't receive full bold bribe amount"); + assertEq(bribeTokenAmount, 1e18, "voter doesn't receive full bribe amount"); + + // user2 should receive no bribes if they try to claim + claimEpoch = governance.epoch() - 1; // claim for epoch 3 + prevAllocationEpoch = governance.epoch() - 1; // epoch 3 + (boldAmount, bribeTokenAmount) = _claimBribe(user2, claimEpoch, prevAllocationEpoch, prevAllocationEpoch); + assertEq(boldAmount, 0, "vetoer receives bold bribe amount"); + assertEq(bribeTokenAmount, 0, "vetoer receives bribe amount"); + } + + // checks that user can receive bribes for an epoch in which they were allocated even if they're no longer allocated + function test_decrement_after_claimBribes() public { + // =========== epoch 1 ================== + // user stakes in epoch 1 + _stakeLQTY(user1, 1e18); + + // =========== epoch 2 ================== + vm.warp(block.timestamp + EPOCH_DURATION); + assertEq(2, governance.epoch(), "not in epoch 2"); + + // lusdHolder deposits lqty and lusd bribes claimable in epoch 3 + _depositBribe(1e18, 1e18, governance.epoch() + 1); + + // =========== epoch 3 ================== + vm.warp(block.timestamp + EPOCH_DURATION); + assertEq(3, governance.epoch(), "not in epoch 3"); + + // user votes on bribeInitiative + _allocateLQTY(user1, 1e18, 0); + + // lusdHolder deposits lqty and lusd bribes claimable in epoch 4 + _depositBribe(1e18, 1e18, governance.epoch() + 1); + + // =========== epoch 5 ================== + // warp ahead two epochs because bribes can't be claimed in current epoch + vm.warp(block.timestamp + (EPOCH_DURATION * 2)); + console2.log("current epoch: ", governance.epoch()); + + // user should receive bribe from their allocated stake in epoch 2 + uint16 claimEpoch = governance.epoch() - 2; // claim for epoch 3 + uint16 prevAllocationEpoch = governance.epoch() - 2; // epoch 3 + (uint256 boldAmount, uint256 bribeTokenAmount) = + _claimBribe(user1, claimEpoch, prevAllocationEpoch, prevAllocationEpoch); + assertEq(boldAmount, 1e18); + assertEq(bribeTokenAmount, 1e18); + + // decrease user allocation for the initiative + _allocateLQTY(user1, 0, 0); + + // check if user can still receive bribes after removing votes + claimEpoch = governance.epoch() - 1; // claim for epoch 4 + prevAllocationEpoch = governance.epoch() - 2; // epoch 3 + (boldAmount, bribeTokenAmount) = _claimBribe(user1, claimEpoch, prevAllocationEpoch, prevAllocationEpoch); + assertEq(boldAmount, 1e18); + assertEq(bribeTokenAmount, 1e18); + } + + function test_lqty_immediately_allocated() public { + // =========== epoch 1 ================== + // user stakes in epoch 1 + _stakeLQTY(user1, 1e18); + + // =========== epoch 2 ================== + vm.warp(block.timestamp + EPOCH_DURATION); + assertEq(2, governance.epoch(), "not in epoch 2"); + + // lusdHolder deposits lqty and lusd bribes claimable in epoch 3 + _depositBribe(1e18, 1e18, governance.epoch() + 1); + + // =========== epoch 3 ================== + vm.warp(block.timestamp + EPOCH_DURATION); + assertEq(3, governance.epoch(), "not in epoch 3"); + + // user votes on bribeInitiative + _allocateLQTY(user1, 1e18, 0); + (uint88 lqtyAllocated,) = bribeInitiative.lqtyAllocatedByUserAtEpoch(user1, governance.epoch()); + assertEq(lqtyAllocated, 1e18, "lqty doesn't immediately get allocated"); + } + + // forge test --match-test test_rationalFlow -vvvv + function test_rationalFlow() public { + vm.warp(block.timestamp + (EPOCH_DURATION)); // Initiative not active + + // We are now at epoch + + // Deposit + _stakeLQTY(user1, 1e18); + + // Deposit Bribe for now + _allocateLQTY(user1, 5e17, 0); + /// @audit Allocate b4 or after bribe should be irrelevant + + /// @audit WTF + _depositBribe(1e18, 1e18, governance.epoch()); + /// @audit IMO this should also work + + _allocateLQTY(user1, 5e17, 0); + + /// @audit Allocate b4 or after bribe should be irrelevant + + // deposit bribe for Epoch + 2 + _depositBribe(1e18, 1e18, governance.epoch() + 1); + + (uint88 totalLQTYAllocated,) = bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + (uint88 userLQTYAllocated,) = bribeInitiative.lqtyAllocatedByUserAtEpoch(user1, governance.epoch()); + assertEq(totalLQTYAllocated, 5e17, "total allocation"); + assertEq(userLQTYAllocated, 5e17, "user allocation"); + + vm.warp(block.timestamp + (EPOCH_DURATION)); + // We are now at epoch + 1 // Should be able to claim epoch - 1 + + // user should receive bribe from their allocated stake + (uint256 boldAmount, uint256 bribeTokenAmount) = + _claimBribe(user1, governance.epoch() - 1, governance.epoch() - 1, governance.epoch() - 1); + assertEq(boldAmount, 1e18, "bold amount"); + assertEq(bribeTokenAmount, 1e18, "bribe amount"); + + // And they cannot claim the one that is being added currently + _claimBribe(user1, governance.epoch(), governance.epoch() - 1, governance.epoch() - 1, true); + + // decrease user allocation for the initiative + _allocateLQTY(user1, 0, 0); + + (userLQTYAllocated,) = bribeInitiative.lqtyAllocatedByUserAtEpoch(user1, governance.epoch()); + (totalLQTYAllocated,) = bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(userLQTYAllocated, 0, "total allocation"); + assertEq(totalLQTYAllocated, 0, "user allocation"); + } + + /** + * Revert Cases + */ + function test_depositBribe_epoch_too_early_reverts() public { + vm.startPrank(lusdHolder); + + lqty.approve(address(bribeInitiative), 1e18); + lusd.approve(address(bribeInitiative), 1e18); + + vm.expectRevert("BribeInitiative: only-future-epochs"); + bribeInitiative.depositBribe(1e18, 1e18, uint16(0)); + + vm.stopPrank(); + } + + function test_claimBribes_before_deposit_reverts() public { + _stakeLQTY(user1, 1e18); + + vm.warp(block.timestamp + EPOCH_DURATION); + + _depositBribe(1e18, 1e18, governance.epoch() + 1); + + vm.warp(block.timestamp + EPOCH_DURATION); + + _allocateLQTY(user1, 1e18, 0); + + (uint88 totalLQTYAllocated,) = bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + (uint88 userLQTYAllocated,) = bribeInitiative.lqtyAllocatedByUserAtEpoch(user1, governance.epoch()); + assertEq(totalLQTYAllocated, 1e18); + assertEq(userLQTYAllocated, 1e18); + + vm.startPrank(user1); + + // should be zero since user1 was not deposited at that time BribeInitiative.ClaimData[] memory epochs = new BribeInitiative.ClaimData[](1); epochs[0].epoch = governance.epoch() - 1; epochs[0].prevLQTYAllocationEpoch = governance.epoch() - 1; @@ -120,32 +738,269 @@ contract BribeInitiativeTest is Test { assertEq(bribeTokenAmount, 0); vm.stopPrank(); + } + + function test_claimBribes_current_epoch_reverts() public { + _stakeLQTY(user1, 1e18); + + vm.warp(block.timestamp + EPOCH_DURATION); + + _depositBribe(1e18, 1e18, governance.epoch() + 1); + + vm.warp(block.timestamp + EPOCH_DURATION); + + _allocateLQTY(user1, 1e18, 0); + + (uint88 totalLQTYAllocated,) = bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + (uint88 userLQTYAllocated,) = bribeInitiative.lqtyAllocatedByUserAtEpoch(user1, governance.epoch()); + assertEq(totalLQTYAllocated, 1e18); + assertEq(userLQTYAllocated, 1e18); + + vm.startPrank(user1); + + // should be zero since user1 was not deposited at that time + BribeInitiative.ClaimData[] memory epochs = new BribeInitiative.ClaimData[](1); + epochs[0].epoch = governance.epoch(); + epochs[0].prevLQTYAllocationEpoch = governance.epoch() - 1; + epochs[0].prevTotalLQTYAllocationEpoch = governance.epoch() - 1; + vm.expectRevert("BribeInitiative: cannot-claim-for-current-epoch"); + (uint256 boldAmount, uint256 bribeTokenAmount) = bribeInitiative.claimBribes(epochs); + assertEq(boldAmount, 0); + assertEq(bribeTokenAmount, 0); - vm.startPrank(lusdHolder); - lqty.approve(address(bribeInitiative), 1e18); - lusd.approve(address(bribeInitiative), 1e18); - bribeInitiative.depositBribe(1e18, 1e18, governance.epoch() + 1); - vm.warp(block.timestamp + governance.EPOCH_DURATION()); - vm.warp(block.timestamp + governance.EPOCH_DURATION()); vm.stopPrank(); + } + + function test_claimBribes_same_epoch_reverts() public { + _stakeLQTY(user1, 1e18); + + vm.warp(block.timestamp + EPOCH_DURATION); + + _depositBribe(1e18, 1e18, governance.epoch() + 1); + + vm.warp(block.timestamp + EPOCH_DURATION); - // should be non zero since user was deposited at that time - vm.startPrank(user); + _allocateLQTY(user1, 1e18, 0); + + (uint88 totalLQTYAllocated,) = bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + (uint88 userLQTYAllocated,) = bribeInitiative.lqtyAllocatedByUserAtEpoch(user1, governance.epoch()); + assertEq(totalLQTYAllocated, 1e18); + assertEq(userLQTYAllocated, 1e18); + + // deposit bribe + _depositBribe(1e18, 1e18, governance.epoch() + 1); + vm.warp(block.timestamp + (EPOCH_DURATION * 2)); + + // user should receive bribe from their allocated stake + (uint256 boldAmount1, uint256 bribeTokenAmount1) = + _claimBribe(user1, governance.epoch() - 1, governance.epoch() - 2, governance.epoch() - 2); + assertEq(boldAmount1, 1e18); + assertEq(bribeTokenAmount1, 1e18); + + vm.startPrank(user1); + BribeInitiative.ClaimData[] memory epochs = new BribeInitiative.ClaimData[](1); epochs[0].epoch = governance.epoch() - 1; epochs[0].prevLQTYAllocationEpoch = governance.epoch() - 2; epochs[0].prevTotalLQTYAllocationEpoch = governance.epoch() - 2; - (boldAmount, bribeTokenAmount) = bribeInitiative.claimBribes(epochs); - assertEq(boldAmount, 1e18); - assertEq(bribeTokenAmount, 1e18); + vm.expectRevert("BribeInitiative: already-claimed"); + (uint256 boldAmount2, uint256 bribeTokenAmount2) = bribeInitiative.claimBribes(epochs); vm.stopPrank(); + } - vm.startPrank(user); + function test_claimBribes_no_bribe_reverts() public { + _stakeLQTY(user1, 1e18); + + vm.warp(block.timestamp + EPOCH_DURATION); + + _allocateLQTY(user1, 1e18, 0); + + (uint88 totalLQTYAllocated,) = bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + (uint88 userLQTYAllocated,) = bribeInitiative.lqtyAllocatedByUserAtEpoch(user1, governance.epoch()); + assertEq(totalLQTYAllocated, 1e18); + assertEq(userLQTYAllocated, 1e18); + + vm.startPrank(user1); + BribeInitiative.ClaimData[] memory epochs = new BribeInitiative.ClaimData[](1); + epochs[0].epoch = governance.epoch() - 1; + epochs[0].prevLQTYAllocationEpoch = governance.epoch() - 2; + epochs[0].prevTotalLQTYAllocationEpoch = governance.epoch() - 2; + vm.expectRevert("BribeInitiative: no-bribe"); + (uint256 boldAmount1, uint256 bribeTokenAmount1) = bribeInitiative.claimBribes(epochs); + vm.stopPrank(); + + assertEq(boldAmount1, 0); + assertEq(bribeTokenAmount1, 0); + } + + function test_claimBribes_no_allocation_reverts() public { + _stakeLQTY(user1, 1e18); + + vm.warp(block.timestamp + EPOCH_DURATION); + + _depositBribe(1e18, 1e18, governance.epoch() + 1); + + vm.warp(block.timestamp + EPOCH_DURATION); + + _allocateLQTY(user1, 0, 0); + + (uint88 totalLQTYAllocated,) = bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + (uint88 userLQTYAllocated,) = bribeInitiative.lqtyAllocatedByUserAtEpoch(user1, governance.epoch()); + assertEq(totalLQTYAllocated, 0); + assertEq(userLQTYAllocated, 0); + + // deposit bribe + _depositBribe(1e18, 1e18, governance.epoch() + 1); + vm.warp(block.timestamp + (EPOCH_DURATION * 2)); + + vm.startPrank(user1); + BribeInitiative.ClaimData[] memory epochs = new BribeInitiative.ClaimData[](1); + epochs[0].epoch = governance.epoch() - 1; + epochs[0].prevLQTYAllocationEpoch = governance.epoch() - 2; + epochs[0].prevTotalLQTYAllocationEpoch = governance.epoch() - 2; + vm.expectRevert("BribeInitiative: invalid-prev-total-lqty-allocation-epoch"); + (uint256 boldAmount, uint256 bribeTokenAmount) = bribeInitiative.claimBribes(epochs); + vm.stopPrank(); + + assertEq(boldAmount, 0); + assertEq(bribeTokenAmount, 0); + } + + // requires: no allocation, previousAllocationEpoch > current, next < epoch or next = 0 + function test_claimBribes_invalid_previous_allocation_epoch_reverts() public { + _stakeLQTY(user1, 1e18); + + vm.warp(block.timestamp + EPOCH_DURATION); + + _depositBribe(1e18, 1e18, governance.epoch() + 1); + + vm.warp(block.timestamp + EPOCH_DURATION); + + _allocateLQTY(user1, 0, 0); + + (uint88 totalLQTYAllocated,) = bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + (uint88 userLQTYAllocated,) = bribeInitiative.lqtyAllocatedByUserAtEpoch(user1, governance.epoch()); + assertEq(totalLQTYAllocated, 0); + assertEq(userLQTYAllocated, 0); + + // deposit bribe + _depositBribe(1e18, 1e18, governance.epoch() + 1); + vm.warp(block.timestamp + (EPOCH_DURATION * 2)); + + vm.startPrank(user1); + BribeInitiative.ClaimData[] memory epochs = new BribeInitiative.ClaimData[](1); + epochs[0].epoch = governance.epoch() - 1; + epochs[0].prevLQTYAllocationEpoch = governance.epoch(); + epochs[0].prevTotalLQTYAllocationEpoch = governance.epoch() - 2; + vm.expectRevert("BribeInitiative: invalid-prev-lqty-allocation-epoch"); + (uint256 boldAmount, uint256 bribeTokenAmount) = bribeInitiative.claimBribes(epochs); + vm.stopPrank(); + + assertEq(boldAmount, 0); + assertEq(bribeTokenAmount, 0); + } + + /** + * Helpers + */ + function _stakeLQTY(address staker, uint88 amount) public { + vm.startPrank(staker); + address userProxy = governance.deriveUserProxyAddress(staker); + lqty.approve(address(userProxy), amount); + governance.depositLQTY(amount); + vm.stopPrank(); + } + + function _allocateLQTY(address staker, int88 deltaVoteLQTYAmt, int88 deltaVetoLQTYAmt) public { + vm.startPrank(staker); + address[] memory initiatives = new address[](1); initiatives[0] = address(bribeInitiative); - deltaVoteLQTY[0] = -0.5e18; - governance.allocateLQTY(initiatives, deltaVoteLQTY, deltaVetoLQTY); - governance.allocateLQTY(initiatives, deltaVoteLQTY, deltaVetoLQTY); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 0); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()), 0); + + // voting in favor of the initiative with half of user1's stake + int88[] memory deltaVoteLQTY = new int88[](1); + deltaVoteLQTY[0] = deltaVoteLQTYAmt; + + int88[] memory deltaVetoLQTY = new int88[](1); + deltaVetoLQTY[0] = deltaVetoLQTYAmt; + + governance.allocateLQTY(initiatives, initiatives, deltaVoteLQTY, deltaVetoLQTY); vm.stopPrank(); } + + function _allocate(address staker, address initiative, int88 votes, int88 vetos) internal { + vm.startPrank(staker); + + address[] memory initiatives = new address[](1); + initiatives[0] = initiative; + int88[] memory deltaLQTYVotes = new int88[](1); + deltaLQTYVotes[0] = votes; + int88[] memory deltaLQTYVetos = new int88[](1); + deltaLQTYVetos[0] = vetos; + + governance.allocateLQTY(initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos); + + vm.stopPrank(); + } + + function _depositBribe(uint128 boldAmount, uint128 bribeAmount, uint16 epoch) public { + vm.startPrank(lusdHolder); + lqty.approve(address(bribeInitiative), boldAmount); + lusd.approve(address(bribeInitiative), bribeAmount); + bribeInitiative.depositBribe(boldAmount, bribeAmount, epoch); + vm.stopPrank(); + } + + function _depositBribe(address _initiative, uint128 boldAmount, uint128 bribeAmount, uint16 epoch) public { + vm.startPrank(lusdHolder); + lqty.approve(_initiative, boldAmount); + lusd.approve(_initiative, bribeAmount); + BribeInitiative(_initiative).depositBribe(boldAmount, bribeAmount, epoch); + vm.stopPrank(); + } + + function _claimBribe( + address claimer, + uint16 epoch, + uint16 prevLQTYAllocationEpoch, + uint16 prevTotalLQTYAllocationEpoch + ) public returns (uint256 boldAmount, uint256 bribeTokenAmount) { + return _claimBribe(claimer, epoch, prevLQTYAllocationEpoch, prevTotalLQTYAllocationEpoch, false); + } + + function _claimBribe( + address claimer, + uint16 epoch, + uint16 prevLQTYAllocationEpoch, + uint16 prevTotalLQTYAllocationEpoch, + bool expectRevert + ) public returns (uint256 boldAmount, uint256 bribeTokenAmount) { + vm.startPrank(claimer); + BribeInitiative.ClaimData[] memory epochs = new BribeInitiative.ClaimData[](1); + epochs[0].epoch = epoch; + epochs[0].prevLQTYAllocationEpoch = prevLQTYAllocationEpoch; + epochs[0].prevTotalLQTYAllocationEpoch = prevTotalLQTYAllocationEpoch; + if (expectRevert) { + vm.expectRevert(); + } + (boldAmount, bribeTokenAmount) = bribeInitiative.claimBribes(epochs); + vm.stopPrank(); + } + + function _getUserShareOfAllocationAsPercentage(address user, uint16 epoch) + internal + returns (uint256 userShareOfTotalAllocated) + { + (uint88 userLqtyAllocated,) = bribeInitiative.lqtyAllocatedByUserAtEpoch(user, epoch); + (uint88 totalLqtyAllocated,) = bribeInitiative.totalLQTYAllocatedByEpoch(epoch); + userShareOfTotalAllocated = (uint256(userLqtyAllocated) * 10_000) / uint256(totalLqtyAllocated); + } + + function _getBribesAsPercentageOfTotal(uint16 epoch, uint256 userBoldAmount, uint256 userBribeTokenAmount) + internal + returns (uint256 userShareOfTotalBoldForEpoch, uint256 userShareOfTotalBribeForEpoch) + { + (uint128 boldAmountForEpoch, uint128 bribeTokenAmountForEpoch) = bribeInitiative.bribeByEpoch(epoch); + uint256 userShareOfTotalBoldForEpoch = (userBoldAmount * 10_000) / uint256(boldAmountForEpoch); + uint256 userShareOfTotalBribeForEpoch = (userBribeTokenAmount * 10_000) / uint256(bribeTokenAmountForEpoch); + return (userShareOfTotalBoldForEpoch, userShareOfTotalBribeForEpoch); + } } diff --git a/test/BribeInitiativeAllocate.t.sol b/test/BribeInitiativeAllocate.t.sol index 8fa21b17..3653a68f 100644 --- a/test/BribeInitiativeAllocate.t.sol +++ b/test/BribeInitiativeAllocate.t.sol @@ -2,11 +2,14 @@ pragma solidity ^0.8.24; import {Test} from "forge-std/Test.sol"; +import {console} from "forge-std/console.sol"; import {MockERC20} from "forge-std/mocks/MockERC20.sol"; import {Governance} from "../src/Governance.sol"; import {BribeInitiative} from "../src/BribeInitiative.sol"; +import {IGovernance} from "../src/interfaces/IGovernance.sol"; + import {MockStakingV1} from "./mocks/MockStakingV1.sol"; import {MockGovernance} from "./mocks/MockGovernance.sol"; @@ -64,13 +67,51 @@ contract BribeInitiativeAllocateTest is Test { vm.startPrank(address(governance)); - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user2, 1e18, 0); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user2, governance.epoch()), 1e18); - - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, 1000e18, 0); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1001e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()), 1000e18); + { + IGovernance.UserState memory userState = + IGovernance.UserState({allocatedLQTY: 1e18, averageStakingTimestamp: uint32(block.timestamp)}); + IGovernance.Allocation memory allocation = IGovernance.Allocation({voteLQTY: 1e18, vetoLQTY: 0, atEpoch: 1}); + IGovernance.InitiativeState memory initiativeState = IGovernance.InitiativeState({ + voteLQTY: 1e18, + vetoLQTY: 0, + averageStakingTimestampVoteLQTY: uint32(block.timestamp), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user2, userState, allocation, initiativeState); + } + (uint88 totalLQTYAllocated, uint120 totalAverageTimestamp) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 1e18); + assertEq(totalAverageTimestamp, uint120(block.timestamp)); + (uint88 userLQTYAllocated, uint120 userAverageTimestamp) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user2, governance.epoch()); + assertEq(userLQTYAllocated, 1e18); + assertEq(userAverageTimestamp, uint120(block.timestamp)); + + { + IGovernance.UserState memory userState2 = + IGovernance.UserState({allocatedLQTY: 1000e18, averageStakingTimestamp: uint32(block.timestamp)}); + IGovernance.Allocation memory allocation2 = + IGovernance.Allocation({voteLQTY: 1000e18, vetoLQTY: 0, atEpoch: 1}); + IGovernance.InitiativeState memory initiativeState2 = IGovernance.InitiativeState({ + voteLQTY: 1001e18, + vetoLQTY: 0, + averageStakingTimestampVoteLQTY: uint32(block.timestamp), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, userState2, allocation2, initiativeState2); + } + + (uint88 totalLQTYAllocated2, uint120 totalAverageTimestamp2) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated2, 1001e18); + assertEq(totalAverageTimestamp2, block.timestamp); + (uint88 userLQTYAllocated2, uint120 userAverageTimestamp2) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()); + assertEq(userLQTYAllocated2, 1000e18); + assertEq(userAverageTimestamp2, block.timestamp); vm.startPrank(lusdHolder); lqty.approve(address(bribeInitiative), 1000e18); @@ -81,10 +122,27 @@ contract BribeInitiativeAllocateTest is Test { vm.startPrank(address(governance)); - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, 2000e18, 0); - - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 2001e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()), 2000e18); + { + IGovernance.UserState memory userState = + IGovernance.UserState({allocatedLQTY: 2000e18, averageStakingTimestamp: uint32(1)}); + IGovernance.Allocation memory allocation = + IGovernance.Allocation({voteLQTY: 2000e18, vetoLQTY: 0, atEpoch: 2}); + IGovernance.InitiativeState memory initiativeState = IGovernance.InitiativeState({ + voteLQTY: 2001e18, + vetoLQTY: 0, + averageStakingTimestampVoteLQTY: uint32(1), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, userState, allocation, initiativeState); + } + + (totalLQTYAllocated, totalAverageTimestamp) = bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 2001e18); + assertEq(totalAverageTimestamp, 1); + (userLQTYAllocated, userAverageTimestamp) = bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()); + assertEq(userLQTYAllocated, 2000e18); + assertEq(userAverageTimestamp, 1); governance.setEpoch(3); @@ -94,22 +152,67 @@ contract BribeInitiativeAllocateTest is Test { claimData[0].epoch = 2; claimData[0].prevLQTYAllocationEpoch = 2; claimData[0].prevTotalLQTYAllocationEpoch = 2; - bribeInitiative.claimBribes(claimData); + (uint256 boldAmount, uint256 bribeTokenAmount) = bribeInitiative.claimBribes(claimData); + assertGt(boldAmount, 999e18); + assertGt(bribeTokenAmount, 999e18); } function test_onAfterAllocateLQTY_newEpoch_NoVetoToVeto() public { governance.setEpoch(1); + vm.warp(governance.EPOCH_DURATION()); // warp to end of first epoch vm.startPrank(address(governance)); - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user2, 1e18, 0); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user2, governance.epoch()), 1e18); - - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, 1000e18, 0); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1001e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()), 1000e18); - + // set user2 allocations like governance would using onAfterAllocateLQTY at epoch 1 + // sets avgTimestamp to current block.timestamp + { + IGovernance.UserState memory userState = + IGovernance.UserState({allocatedLQTY: 1e18, averageStakingTimestamp: uint32(block.timestamp)}); + IGovernance.Allocation memory allocation = IGovernance.Allocation({voteLQTY: 1e18, vetoLQTY: 0, atEpoch: 1}); + IGovernance.InitiativeState memory initiativeState = IGovernance.InitiativeState({ + voteLQTY: 1e18, + vetoLQTY: 0, + averageStakingTimestampVoteLQTY: uint32(block.timestamp), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user2, userState, allocation, initiativeState); + (uint88 totalLQTYAllocated, uint120 totalAverageTimestamp) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 1e18); + assertEq(totalAverageTimestamp, uint120(block.timestamp)); + (uint88 userLQTYAllocated, uint120 userAverageTimestamp) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user2, governance.epoch()); + assertEq(userLQTYAllocated, 1e18); + assertEq(userAverageTimestamp, uint120(block.timestamp)); + } + + // set user2 allocations like governance would using onAfterAllocateLQTY at epoch 1 + // sets avgTimestamp to current block.timestamp + { + IGovernance.UserState memory userState = + IGovernance.UserState({allocatedLQTY: 1e18, averageStakingTimestamp: uint32(block.timestamp)}); + IGovernance.Allocation memory allocation = IGovernance.Allocation({voteLQTY: 1e18, vetoLQTY: 0, atEpoch: 1}); + IGovernance.InitiativeState memory initiativeState = IGovernance.InitiativeState({ + voteLQTY: 1001e18, + vetoLQTY: 0, + averageStakingTimestampVoteLQTY: uint32(block.timestamp), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user2, userState, allocation, initiativeState); + + (uint88 totalLQTYAllocated, uint120 totalAverageTimestamp) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 1001e18); + assertEq(totalAverageTimestamp, uint120(block.timestamp)); + (uint88 userLQTYAllocated, uint120 userAverageTimestamp) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user2, governance.epoch()); + assertEq(userLQTYAllocated, 1e18); + assertEq(userAverageTimestamp, uint120(block.timestamp)); + } + + // lusdHolder deposits bribes into the initiative vm.startPrank(lusdHolder); lqty.approve(address(bribeInitiative), 1000e18); lusd.approve(address(bribeInitiative), 1000e18); @@ -117,18 +220,60 @@ contract BribeInitiativeAllocateTest is Test { vm.stopPrank(); governance.setEpoch(2); + vm.warp(block.timestamp + governance.EPOCH_DURATION()); // warp to second epoch ts vm.startPrank(address(governance)); - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, 2000e18, 1); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()), 0); - - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user2, 1e18, 1); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 0); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user2, governance.epoch()), 0); + // set allocation in initiative for user in epoch 1 + // sets avgTimestamp to current block.timestamp + { + IGovernance.UserState memory userState = + IGovernance.UserState({allocatedLQTY: 1e18, averageStakingTimestamp: uint32(block.timestamp)}); + IGovernance.Allocation memory allocation = IGovernance.Allocation({voteLQTY: 0, vetoLQTY: 1, atEpoch: 1}); + IGovernance.InitiativeState memory initiativeState = IGovernance.InitiativeState({ + voteLQTY: 0, + vetoLQTY: 1, + averageStakingTimestampVoteLQTY: uint32(block.timestamp), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, userState, allocation, initiativeState); + (uint88 totalLQTYAllocated, uint120 totalAverageTimestamp) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 0); + assertEq(totalAverageTimestamp, uint120(block.timestamp)); + (uint88 userLQTYAllocated, uint120 userAverageTimestamp) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()); + assertEq(userLQTYAllocated, 0); + assertEq(userAverageTimestamp, uint120(block.timestamp)); + } + + // set allocation in initiative for user2 in epoch 1 + // sets avgTimestamp to current block.timestamp + { + IGovernance.UserState memory userState = + IGovernance.UserState({allocatedLQTY: 1e18, averageStakingTimestamp: uint32(block.timestamp)}); + IGovernance.Allocation memory allocation = IGovernance.Allocation({voteLQTY: 0, vetoLQTY: 1, atEpoch: 1}); + IGovernance.InitiativeState memory initiativeState = IGovernance.InitiativeState({ + voteLQTY: 0, + vetoLQTY: 1, + averageStakingTimestampVoteLQTY: uint32(block.timestamp), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user2, userState, allocation, initiativeState); + (uint88 totalLQTYAllocated, uint120 totalAverageTimestamp) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 0); + assertEq(totalAverageTimestamp, uint120(block.timestamp)); + (uint88 userLQTYAllocated, uint120 userAverageTimestamp) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user2, governance.epoch()); + assertEq(userLQTYAllocated, 0); + assertEq(userAverageTimestamp, uint120(block.timestamp)); + } governance.setEpoch(3); + vm.warp(block.timestamp + governance.EPOCH_DURATION()); // warp to third epoch ts vm.startPrank(address(user)); @@ -136,27 +281,89 @@ contract BribeInitiativeAllocateTest is Test { claimData[0].epoch = 2; claimData[0].prevLQTYAllocationEpoch = 2; claimData[0].prevTotalLQTYAllocationEpoch = 2; - vm.expectRevert("BribeInitiative: invalid-prev-lqty-allocation-epoch"); // nothing to claim - bribeInitiative.claimBribes(claimData); + (uint256 boldAmount, uint256 bribeTokenAmount) = bribeInitiative.claimBribes(claimData); + assertEq(boldAmount, 0, "boldAmount nonzero"); + assertEq(bribeTokenAmount, 0, "bribeTokenAmount nonzero"); } function test_onAfterAllocateLQTY_newEpoch_VetoToNoVeto() public { governance.setEpoch(1); + vm.warp(governance.EPOCH_DURATION()); // warp to end of first epoch vm.startPrank(address(governance)); - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user2, 1e18, 0); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user2, governance.epoch()), 1e18); - - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, 1000e18, 0); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1001e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()), 1000e18); + IGovernance.UserState memory userState = + IGovernance.UserState({allocatedLQTY: 1e18, averageStakingTimestamp: uint32(block.timestamp)}); + IGovernance.Allocation memory allocation = + IGovernance.Allocation({voteLQTY: 1e18, vetoLQTY: 0, atEpoch: uint16(governance.epoch())}); + IGovernance.InitiativeState memory initiativeState = IGovernance.InitiativeState({ + voteLQTY: 1e18, + vetoLQTY: 0, + averageStakingTimestampVoteLQTY: uint32(block.timestamp), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user2, userState, allocation, initiativeState); + + (uint88 totalLQTYAllocated, uint120 totalAverageTimestamp) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 1e18); + assertEq(totalAverageTimestamp, uint120(block.timestamp)); + (uint88 userLQTYAllocated, uint120 userAverageTimestamp) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user2, governance.epoch()); + assertEq(userLQTYAllocated, 1e18); + assertEq(userAverageTimestamp, uint120(block.timestamp)); + + IGovernance.UserState memory userStateVeto = + IGovernance.UserState({allocatedLQTY: 1000e18, averageStakingTimestamp: uint32(block.timestamp)}); + IGovernance.Allocation memory allocationVeto = + IGovernance.Allocation({voteLQTY: 0, vetoLQTY: 1000e18, atEpoch: uint16(governance.epoch())}); + IGovernance.InitiativeState memory initiativeStateVeto = IGovernance.InitiativeState({ + voteLQTY: 1e18, + vetoLQTY: 1000e18, + averageStakingTimestampVoteLQTY: uint32(block.timestamp), + averageStakingTimestampVetoLQTY: uint32(block.timestamp), + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY( + governance.epoch(), user, userStateVeto, allocationVeto, initiativeStateVeto + ); + + (uint88 totalLQTYAllocatedAfterVeto, uint120 totalAverageTimestampAfterVeto) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocatedAfterVeto, 1e18); + assertEq(totalAverageTimestampAfterVeto, uint120(block.timestamp)); + (uint88 userLQTYAllocatedAfterVeto, uint120 userAverageTimestampAfterVeto) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()); + assertEq(userLQTYAllocatedAfterVeto, 0); + assertEq(userAverageTimestampAfterVeto, uint120(block.timestamp)); governance.setEpoch(2); - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, 2000e18, 1); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()), 0); + vm.warp(block.timestamp + governance.EPOCH_DURATION()); // warp to second epoch ts + + IGovernance.UserState memory userStateNewEpoch = + IGovernance.UserState({allocatedLQTY: 1, averageStakingTimestamp: uint32(block.timestamp)}); + IGovernance.Allocation memory allocationNewEpoch = + IGovernance.Allocation({voteLQTY: 0, vetoLQTY: 1, atEpoch: uint16(governance.epoch())}); + IGovernance.InitiativeState memory initiativeStateNewEpoch = IGovernance.InitiativeState({ + voteLQTY: 1e18, + vetoLQTY: 1, + averageStakingTimestampVoteLQTY: uint32(block.timestamp), + averageStakingTimestampVetoLQTY: uint32(block.timestamp), + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY( + governance.epoch(), user, userStateNewEpoch, allocationNewEpoch, initiativeStateNewEpoch + ); + + (uint88 totalLQTYAllocatedNewEpoch, uint120 totalAverageTimestampNewEpoch) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocatedNewEpoch, 1e18); + assertEq(totalAverageTimestampNewEpoch, uint120(block.timestamp)); + (uint88 userLQTYAllocatedNewEpoch, uint120 userAverageTimestampNewEpoch) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()); + assertEq(userLQTYAllocatedNewEpoch, 0); + assertEq(userAverageTimestampNewEpoch, uint120(block.timestamp)); vm.startPrank(lusdHolder); lqty.approve(address(bribeInitiative), 1000e18); @@ -167,11 +374,34 @@ contract BribeInitiativeAllocateTest is Test { vm.startPrank(address(governance)); governance.setEpoch(3); - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, 2000e18, 0); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 2001e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()), 2000e18); + vm.warp(block.timestamp + governance.EPOCH_DURATION()); // warp to third epoch ts + + IGovernance.UserState memory userStateNewEpoch3 = + IGovernance.UserState({allocatedLQTY: 2000e18, averageStakingTimestamp: uint32(block.timestamp)}); + IGovernance.Allocation memory allocationNewEpoch3 = + IGovernance.Allocation({voteLQTY: 2000e18, vetoLQTY: 0, atEpoch: uint16(governance.epoch())}); + IGovernance.InitiativeState memory initiativeStateNewEpoch3 = IGovernance.InitiativeState({ + voteLQTY: 2001e18, + vetoLQTY: 0, + averageStakingTimestampVoteLQTY: uint32(block.timestamp), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY( + governance.epoch(), user, userStateNewEpoch3, allocationNewEpoch3, initiativeStateNewEpoch3 + ); + + (uint88 totalLQTYAllocatedNewEpoch3, uint120 totalAverageTimestampNewEpoch3) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocatedNewEpoch3, 2001e18); + assertEq(totalAverageTimestampNewEpoch3, uint120(block.timestamp)); + (uint88 userLQTYAllocatedNewEpoch3, uint120 userAverageTimestampNewEpoch3) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()); + assertEq(userLQTYAllocatedNewEpoch3, 2000e18); + assertEq(userAverageTimestampNewEpoch3, uint120(block.timestamp)); governance.setEpoch(4); + vm.warp(block.timestamp + governance.EPOCH_DURATION()); // warp to fourth epoch ts vm.startPrank(address(user)); @@ -187,23 +417,105 @@ contract BribeInitiativeAllocateTest is Test { vm.startPrank(address(governance)); - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user2, 1e18, 0); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user2, governance.epoch()), 1e18); - - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, 1000e18, 0); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1001e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()), 1000e18); + { + IGovernance.UserState memory userState = + IGovernance.UserState({allocatedLQTY: 1e18, averageStakingTimestamp: uint32(block.timestamp)}); + IGovernance.Allocation memory allocation = + IGovernance.Allocation({voteLQTY: 1e18, vetoLQTY: 0, atEpoch: uint16(governance.epoch())}); + IGovernance.InitiativeState memory initiativeState = IGovernance.InitiativeState({ + voteLQTY: 1e18, + vetoLQTY: 0, + averageStakingTimestampVoteLQTY: uint32(block.timestamp), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user2, userState, allocation, initiativeState); + + (uint88 totalLQTYAllocated, uint120 totalAverageTimestamp) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 1e18); + assertEq(totalAverageTimestamp, uint120(block.timestamp)); + (uint88 userLQTYAllocated, uint120 userAverageTimestamp) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user2, governance.epoch()); + assertEq(userLQTYAllocated, 1e18); + assertEq(userAverageTimestamp, uint120(block.timestamp)); + } + + { + IGovernance.UserState memory userState = + IGovernance.UserState({allocatedLQTY: 1000e18, averageStakingTimestamp: uint32(block.timestamp)}); + IGovernance.Allocation memory allocation = + IGovernance.Allocation({voteLQTY: 1000e18, vetoLQTY: 0, atEpoch: uint16(governance.epoch())}); + IGovernance.InitiativeState memory initiativeState = IGovernance.InitiativeState({ + voteLQTY: 1001e18, + vetoLQTY: 0, + averageStakingTimestampVoteLQTY: uint32(block.timestamp), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, userState, allocation, initiativeState); + + (uint88 totalLQTYAllocated, uint120 totalAverageTimestamp) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 1001e18); + assertEq(totalAverageTimestamp, uint120(block.timestamp)); + (uint88 userLQTYAllocated, uint120 userAverageTimestamp) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()); + assertEq(userLQTYAllocated, 1000e18); + assertEq(userAverageTimestamp, uint120(block.timestamp)); + } governance.setEpoch(2); - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, 2000e18, 1); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()), 0); + + { + IGovernance.UserState memory userState = + IGovernance.UserState({allocatedLQTY: 1, averageStakingTimestamp: uint32(block.timestamp)}); + IGovernance.Allocation memory allocation = + IGovernance.Allocation({voteLQTY: 0, vetoLQTY: 1, atEpoch: uint16(governance.epoch())}); + IGovernance.InitiativeState memory initiativeState = IGovernance.InitiativeState({ + voteLQTY: 1e18, + vetoLQTY: 0, + averageStakingTimestampVoteLQTY: uint32(block.timestamp), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, userState, allocation, initiativeState); + + (uint88 totalLQTYAllocated, uint120 totalAverageTimestamp) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 1e18); + assertEq(totalAverageTimestamp, uint120(block.timestamp)); + (uint88 userLQTYAllocated, uint120 userAverageTimestamp) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()); + assertEq(userLQTYAllocated, 0); + assertEq(userAverageTimestamp, uint120(block.timestamp)); + } governance.setEpoch(3); - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, 2000e18, 1); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()), 0); + + { + IGovernance.UserState memory userState = + IGovernance.UserState({allocatedLQTY: 1, averageStakingTimestamp: uint32(block.timestamp)}); + IGovernance.Allocation memory allocation = + IGovernance.Allocation({voteLQTY: 0, vetoLQTY: 1, atEpoch: uint16(governance.epoch())}); + IGovernance.InitiativeState memory initiativeState = IGovernance.InitiativeState({ + voteLQTY: 1e18, + vetoLQTY: 0, + averageStakingTimestampVoteLQTY: uint32(block.timestamp), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, userState, allocation, initiativeState); + + (uint88 totalLQTYAllocated, uint120 totalAverageTimestamp) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 1e18); + assertEq(totalAverageTimestamp, uint120(block.timestamp)); + (uint88 userLQTYAllocated, uint120 userAverageTimestamp) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()); + assertEq(userLQTYAllocated, 0); + assertEq(userAverageTimestamp, uint120(block.timestamp)); + } } function test_onAfterAllocateLQTY_sameEpoch_NoVetoToNoVeto() public { @@ -214,22 +526,84 @@ contract BribeInitiativeAllocateTest is Test { vm.stopPrank(); governance.setEpoch(1); + vm.warp(governance.EPOCH_DURATION()); // warp to end of first epoch vm.startPrank(address(governance)); - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user2, 1e18, 0); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user2, governance.epoch()), 1e18); - - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, 1000e18, 0); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1001e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()), 1000e18); - - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, 2000e18, 0); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 2001e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()), 2000e18); + { + IGovernance.UserState memory userState = + IGovernance.UserState({allocatedLQTY: 1e18, averageStakingTimestamp: uint32(block.timestamp)}); + IGovernance.Allocation memory allocation = + IGovernance.Allocation({voteLQTY: 1e18, vetoLQTY: 0, atEpoch: uint16(governance.epoch())}); + IGovernance.InitiativeState memory initiativeState = IGovernance.InitiativeState({ + voteLQTY: 1e18, + vetoLQTY: 0, + averageStakingTimestampVoteLQTY: uint32(block.timestamp), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user2, userState, allocation, initiativeState); + + (uint88 totalLQTYAllocated, uint120 totalAverageTimestamp) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 1e18); + assertEq(totalAverageTimestamp, uint120(block.timestamp)); + (uint88 userLQTYAllocated, uint120 userAverageTimestamp) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user2, governance.epoch()); + assertEq(userLQTYAllocated, 1e18); + assertEq(userAverageTimestamp, uint120(block.timestamp)); + } + + { + IGovernance.UserState memory userState = + IGovernance.UserState({allocatedLQTY: 1000e18, averageStakingTimestamp: uint32(block.timestamp)}); + IGovernance.Allocation memory allocation = + IGovernance.Allocation({voteLQTY: 1000e18, vetoLQTY: 0, atEpoch: uint16(governance.epoch())}); + IGovernance.InitiativeState memory initiativeState = IGovernance.InitiativeState({ + voteLQTY: 1001e18, + vetoLQTY: 0, + averageStakingTimestampVoteLQTY: uint32(block.timestamp), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, userState, allocation, initiativeState); + + (uint88 totalLQTYAllocated, uint120 totalAverageTimestamp) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 1001e18); + assertEq(totalAverageTimestamp, uint120(block.timestamp)); + (uint88 userLQTYAllocated, uint120 userAverageTimestamp) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()); + assertEq(userLQTYAllocated, 1000e18); + assertEq(userAverageTimestamp, uint120(block.timestamp)); + } + + { + IGovernance.UserState memory userState = + IGovernance.UserState({allocatedLQTY: 2000e18, averageStakingTimestamp: uint32(block.timestamp)}); + IGovernance.Allocation memory allocation = + IGovernance.Allocation({voteLQTY: 2000e18, vetoLQTY: 0, atEpoch: uint16(governance.epoch())}); + IGovernance.InitiativeState memory initiativeState = IGovernance.InitiativeState({ + voteLQTY: 2001e18, + vetoLQTY: 0, + averageStakingTimestampVoteLQTY: uint32(block.timestamp), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, userState, allocation, initiativeState); + + (uint88 totalLQTYAllocated, uint120 totalAverageTimestamp) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 2001e18); + assertEq(totalAverageTimestamp, uint120(block.timestamp)); + (uint88 userLQTYAllocated, uint120 userAverageTimestamp) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()); + assertEq(userLQTYAllocated, 2000e18); + assertEq(userAverageTimestamp, uint120(block.timestamp)); + } governance.setEpoch(2); + vm.warp(block.timestamp + governance.EPOCH_DURATION()); // warp to second epoch ts vm.startPrank(address(user)); @@ -248,22 +622,84 @@ contract BribeInitiativeAllocateTest is Test { vm.stopPrank(); governance.setEpoch(1); + vm.warp(governance.EPOCH_DURATION()); // warp to end of first epoch vm.startPrank(address(governance)); - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user2, 1e18, 0); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user2, governance.epoch()), 1e18); - - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, 1000e18, 0); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1001e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()), 1000e18); - - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, 2000e18, 1); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()), 0); + { + IGovernance.UserState memory userState = + IGovernance.UserState({allocatedLQTY: 1e18, averageStakingTimestamp: uint32(block.timestamp)}); + IGovernance.Allocation memory allocation = + IGovernance.Allocation({voteLQTY: 1e18, vetoLQTY: 0, atEpoch: uint16(governance.epoch())}); + IGovernance.InitiativeState memory initiativeState = IGovernance.InitiativeState({ + voteLQTY: 1e18, + vetoLQTY: 0, + averageStakingTimestampVoteLQTY: uint32(block.timestamp), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user2, userState, allocation, initiativeState); + + (uint88 totalLQTYAllocated, uint120 totalAverageTimestamp) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 1e18); + assertEq(totalAverageTimestamp, uint120(block.timestamp)); + (uint88 userLQTYAllocated, uint120 userAverageTimestamp) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user2, governance.epoch()); + assertEq(userLQTYAllocated, 1e18); + assertEq(userAverageTimestamp, uint120(block.timestamp)); + } + + { + IGovernance.UserState memory userState = + IGovernance.UserState({allocatedLQTY: 1000e18, averageStakingTimestamp: uint32(block.timestamp)}); + IGovernance.Allocation memory allocation = + IGovernance.Allocation({voteLQTY: 1000e18, vetoLQTY: 0, atEpoch: uint16(governance.epoch())}); + IGovernance.InitiativeState memory initiativeState = IGovernance.InitiativeState({ + voteLQTY: 1001e18, + vetoLQTY: 0, + averageStakingTimestampVoteLQTY: uint32(block.timestamp), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, userState, allocation, initiativeState); + + (uint88 totalLQTYAllocated, uint120 totalAverageTimestamp) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 1001e18); + assertEq(totalAverageTimestamp, uint120(block.timestamp)); + (uint88 userLQTYAllocated, uint120 userAverageTimestamp) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()); + assertEq(userLQTYAllocated, 1000e18); + assertEq(userAverageTimestamp, uint120(block.timestamp)); + } + + { + IGovernance.UserState memory userState = + IGovernance.UserState({allocatedLQTY: 1, averageStakingTimestamp: uint32(block.timestamp)}); + IGovernance.Allocation memory allocation = + IGovernance.Allocation({voteLQTY: 0, vetoLQTY: 1, atEpoch: uint16(governance.epoch())}); + IGovernance.InitiativeState memory initiativeState = IGovernance.InitiativeState({ + voteLQTY: 1e18, + vetoLQTY: 0, + averageStakingTimestampVoteLQTY: uint32(block.timestamp), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, userState, allocation, initiativeState); + + (uint88 totalLQTYAllocated, uint120 totalAverageTimestamp) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 1e18); + assertEq(totalAverageTimestamp, uint120(block.timestamp)); + (uint88 userLQTYAllocated, uint120 userAverageTimestamp) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()); + assertEq(userLQTYAllocated, 0); + assertEq(userAverageTimestamp, uint120(block.timestamp)); + } governance.setEpoch(2); + vm.warp(block.timestamp + governance.EPOCH_DURATION()); // warp to second epoch ts vm.startPrank(address(user)); @@ -271,8 +707,9 @@ contract BribeInitiativeAllocateTest is Test { claimData[0].epoch = 1; claimData[0].prevLQTYAllocationEpoch = 1; claimData[0].prevTotalLQTYAllocationEpoch = 1; - vm.expectRevert("BribeInitiative: invalid-prev-lqty-allocation-epoch"); // nothing to claim - bribeInitiative.claimBribes(claimData); + (uint256 boldAmount, uint256 bribeTokenAmount) = bribeInitiative.claimBribes(claimData); + assertEq(boldAmount, 0); + assertEq(bribeTokenAmount, 0); } function test_onAfterAllocateLQTY_sameEpoch_VetoToNoVeto() public { @@ -283,26 +720,108 @@ contract BribeInitiativeAllocateTest is Test { vm.stopPrank(); governance.setEpoch(1); + vm.warp(governance.EPOCH_DURATION()); // warp to end of first epoch vm.startPrank(address(governance)); - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user2, 1e18, 0); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user2, governance.epoch()), 1e18); - - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, 1000e18, 0); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1001e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()), 1000e18); - - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, 2000e18, 1); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()), 0); - - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, 2000e18, 0); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 2001e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()), 2000e18); + { + IGovernance.UserState memory userState = + IGovernance.UserState({allocatedLQTY: 1e18, averageStakingTimestamp: uint32(block.timestamp)}); + IGovernance.Allocation memory allocation = + IGovernance.Allocation({voteLQTY: 1e18, vetoLQTY: 0, atEpoch: uint16(governance.epoch())}); + IGovernance.InitiativeState memory initiativeState = IGovernance.InitiativeState({ + voteLQTY: 1e18, + vetoLQTY: 0, + averageStakingTimestampVoteLQTY: uint32(block.timestamp), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user2, userState, allocation, initiativeState); + + (uint88 totalLQTYAllocated, uint120 totalAverageTimestamp) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 1e18); + assertEq(totalAverageTimestamp, uint120(block.timestamp)); + (uint88 userLQTYAllocated, uint120 userAverageTimestamp) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user2, governance.epoch()); + assertEq(userLQTYAllocated, 1e18); + assertEq(userAverageTimestamp, uint120(block.timestamp)); + } + + { + IGovernance.UserState memory userState = + IGovernance.UserState({allocatedLQTY: 1000e18, averageStakingTimestamp: uint32(block.timestamp)}); + IGovernance.Allocation memory allocation = + IGovernance.Allocation({voteLQTY: 1000e18, vetoLQTY: 0, atEpoch: uint16(governance.epoch())}); + IGovernance.InitiativeState memory initiativeState = IGovernance.InitiativeState({ + voteLQTY: 1001e18, + vetoLQTY: 0, + averageStakingTimestampVoteLQTY: uint32(block.timestamp), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, userState, allocation, initiativeState); + + (uint88 totalLQTYAllocated, uint120 totalAverageTimestamp) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 1001e18); + assertEq(totalAverageTimestamp, uint120(block.timestamp)); + (uint88 userLQTYAllocated, uint120 userAverageTimestamp) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()); + assertEq(userLQTYAllocated, 1000e18); + assertEq(userAverageTimestamp, uint120(block.timestamp)); + } + + { + IGovernance.UserState memory userState = + IGovernance.UserState({allocatedLQTY: 1, averageStakingTimestamp: uint32(block.timestamp)}); + IGovernance.Allocation memory allocation = + IGovernance.Allocation({voteLQTY: 0, vetoLQTY: 1, atEpoch: uint16(governance.epoch())}); + IGovernance.InitiativeState memory initiativeState = IGovernance.InitiativeState({ + voteLQTY: 1e18, + vetoLQTY: 0, + averageStakingTimestampVoteLQTY: uint32(block.timestamp), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, userState, allocation, initiativeState); + + (uint88 totalLQTYAllocated, uint120 totalAverageTimestamp) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 1e18); + assertEq(totalAverageTimestamp, uint120(block.timestamp)); + (uint88 userLQTYAllocated, uint120 userAverageTimestamp) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()); + assertEq(userLQTYAllocated, 0); + assertEq(userAverageTimestamp, uint120(block.timestamp)); + } + + { + IGovernance.UserState memory userState = + IGovernance.UserState({allocatedLQTY: 2000e18, averageStakingTimestamp: uint32(block.timestamp)}); + IGovernance.Allocation memory allocation = + IGovernance.Allocation({voteLQTY: 2000e18, vetoLQTY: 0, atEpoch: uint16(governance.epoch())}); + IGovernance.InitiativeState memory initiativeState = IGovernance.InitiativeState({ + voteLQTY: 2001e18, + vetoLQTY: 0, + averageStakingTimestampVoteLQTY: uint32(block.timestamp), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, userState, allocation, initiativeState); + + (uint88 totalLQTYAllocated, uint120 totalAverageTimestamp) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 2001e18); + assertEq(totalAverageTimestamp, uint120(block.timestamp)); + (uint88 userLQTYAllocated, uint120 userAverageTimestamp) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()); + assertEq(userLQTYAllocated, 2000e18); + assertEq(userAverageTimestamp, uint120(block.timestamp)); + } governance.setEpoch(2); + vm.warp(block.timestamp + governance.EPOCH_DURATION()); // warp to second epoch ts vm.startPrank(address(user)); @@ -318,43 +837,123 @@ contract BribeInitiativeAllocateTest is Test { vm.startPrank(address(governance)); - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user2, 1e18, 0); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user2, governance.epoch()), 1e18); - - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, 1000e18, 0); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1001e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()), 1000e18); - - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, 2000e18, 1); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()), 0); - - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, 1000e18, 1); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()), 0); + { + IGovernance.UserState memory userState = + IGovernance.UserState({allocatedLQTY: 1e18, averageStakingTimestamp: uint32(block.timestamp)}); + IGovernance.Allocation memory allocation = + IGovernance.Allocation({voteLQTY: 1e18, vetoLQTY: 0, atEpoch: uint16(governance.epoch())}); + IGovernance.InitiativeState memory initiativeState = IGovernance.InitiativeState({ + voteLQTY: 1e18, + vetoLQTY: 0, + averageStakingTimestampVoteLQTY: uint32(block.timestamp), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user2, userState, allocation, initiativeState); + + (uint88 totalLQTYAllocated, uint120 totalAverageTimestamp) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 1e18); + assertEq(totalAverageTimestamp, uint120(block.timestamp)); + (uint88 userLQTYAllocated, uint120 userAverageTimestamp) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user2, governance.epoch()); + assertEq(userLQTYAllocated, 1e18); + assertEq(userAverageTimestamp, uint120(block.timestamp)); + } + + { + IGovernance.UserState memory userState = + IGovernance.UserState({allocatedLQTY: 1000e18, averageStakingTimestamp: uint32(block.timestamp)}); + IGovernance.Allocation memory allocation = + IGovernance.Allocation({voteLQTY: 1000e18, vetoLQTY: 0, atEpoch: uint16(governance.epoch())}); + IGovernance.InitiativeState memory initiativeState = IGovernance.InitiativeState({ + voteLQTY: 1001e18, + vetoLQTY: 0, + averageStakingTimestampVoteLQTY: uint32(block.timestamp), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, userState, allocation, initiativeState); + + (uint88 totalLQTYAllocated, uint120 totalAverageTimestamp) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 1001e18); + assertEq(totalAverageTimestamp, uint120(block.timestamp)); + (uint88 userLQTYAllocated, uint120 userAverageTimestamp) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()); + assertEq(userLQTYAllocated, 1000e18); + assertEq(userAverageTimestamp, uint120(block.timestamp)); + } + + { + IGovernance.UserState memory userState = + IGovernance.UserState({allocatedLQTY: 1, averageStakingTimestamp: uint120(block.timestamp)}); + IGovernance.Allocation memory allocation = + IGovernance.Allocation({voteLQTY: 0, vetoLQTY: 1, atEpoch: uint16(governance.epoch())}); + IGovernance.InitiativeState memory initiativeState = IGovernance.InitiativeState({ + voteLQTY: 1e18, + vetoLQTY: 0, + averageStakingTimestampVoteLQTY: uint120(block.timestamp), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, userState, allocation, initiativeState); + + (uint88 totalLQTYAllocated, uint120 totalAverageTimestamp) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 1e18); + assertEq(totalAverageTimestamp, uint120(block.timestamp)); + (uint88 userLQTYAllocated, uint120 userAverageTimestamp) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()); + assertEq(userLQTYAllocated, 0); + assertEq(userAverageTimestamp, uint120(block.timestamp)); + } + + { + IGovernance.UserState memory userState = + IGovernance.UserState({allocatedLQTY: 2, averageStakingTimestamp: uint120(block.timestamp)}); + IGovernance.Allocation memory allocation = + IGovernance.Allocation({voteLQTY: 0, vetoLQTY: 2, atEpoch: uint16(governance.epoch())}); + IGovernance.InitiativeState memory initiativeState = IGovernance.InitiativeState({ + voteLQTY: 1e18, + vetoLQTY: 0, + averageStakingTimestampVoteLQTY: uint120(block.timestamp), + averageStakingTimestampVetoLQTY: 0, + lastEpochClaim: 0 + }); + bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, userState, allocation, initiativeState); + + (uint88 totalLQTYAllocated, uint120 totalAverageTimestamp) = + bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()); + assertEq(totalLQTYAllocated, 1e18); + assertEq(totalAverageTimestamp, uint120(block.timestamp)); + (uint88 userLQTYAllocated, uint120 userAverageTimestamp) = + bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()); + assertEq(userLQTYAllocated, 0); + assertEq(userAverageTimestamp, uint32(block.timestamp)); + } } - function test_onAfterAllocateLQTY() public { - governance.setEpoch(1); + // function test_onAfterAllocateLQTY() public { + // governance.setEpoch(1); - vm.startPrank(address(governance)); + // vm.startPrank(address(governance)); - // first total deposit, first user deposit - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, 1000e18, 0); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1000e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()), 1000e18); + // // first total deposit, first user deposit + // bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, 1000e18, 0); + // assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1000e18); + // assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()), 1000e18); - // second total deposit, second user deposit - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, 1000e18, 0); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1000e18); // should stay the same - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()), 1000e18); // should stay the same + // // second total deposit, second user deposit + // bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user, 1000e18, 0); + // assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 1000e18); // should stay the same + // assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user, governance.epoch()), 1000e18); // should stay the same - // third total deposit, first user deposit - bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user2, 1000e18, 0); - assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 2000e18); - assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user2, governance.epoch()), 1000e18); + // // third total deposit, first user deposit + // bribeInitiative.onAfterAllocateLQTY(governance.epoch(), user2, 1000e18, 0); + // assertEq(bribeInitiative.totalLQTYAllocatedByEpoch(governance.epoch()), 2000e18); + // assertEq(bribeInitiative.lqtyAllocatedByUserAtEpoch(user2, governance.epoch()), 1000e18); - vm.stopPrank(); - } + // vm.stopPrank(); + // } } diff --git a/test/CurveV2GaugeRewards.t.sol b/test/CurveV2GaugeRewards.t.sol index e46a137f..bb0edec8 100644 --- a/test/CurveV2GaugeRewards.t.sol +++ b/test/CurveV2GaugeRewards.t.sol @@ -42,6 +42,8 @@ contract CurveV2GaugeRewardsTest is Test { ILiquidityGauge private gauge; CurveV2GaugeRewards private curveV2GaugeRewards; + address mockGovernance = address(0x123123); + function setUp() public { vm.createSelectFork(vm.rpcUrl("mainnet"), 20430000); @@ -68,7 +70,7 @@ contract CurveV2GaugeRewardsTest is Test { curveV2GaugeRewards = new CurveV2GaugeRewards( // address(vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 1)), - address(new MockGovernance()), + address(mockGovernance), address(lusd), address(lqty), address(gauge), @@ -96,6 +98,7 @@ contract CurveV2GaugeRewardsTest is Test { epochDuration: EPOCH_DURATION, epochVotingCutoff: EPOCH_VOTING_CUTOFF }), + address(this), initialInitiatives ); @@ -117,14 +120,47 @@ contract CurveV2GaugeRewardsTest is Test { vm.stopPrank(); } - function test_depositIntoGauge() public { - vm.startPrank(lusdHolder); - lusd.transfer(address(curveV2GaugeRewards), 1000e18); - vm.stopPrank(); + function test_claimAndDepositIntoGaugeFuzz(uint128 amt) public { + deal(address(lusd), mockGovernance, amt); + vm.assume(amt > 604800); - vm.mockCall( - address(governance), abi.encode(IGovernance.claimForInitiative.selector), abi.encode(uint256(1000e18)) - ); - curveV2GaugeRewards.depositIntoGauge(); + // Pretend a Proposal has passed + vm.startPrank(address(mockGovernance)); + lusd.transfer(address(curveV2GaugeRewards), amt); + + assertEq(lusd.balanceOf(address(curveV2GaugeRewards)), amt); + curveV2GaugeRewards.onClaimForInitiative(0, amt); + assertEq(lusd.balanceOf(address(curveV2GaugeRewards)), curveV2GaugeRewards.remainder()); + } + + /// @dev If the amount rounds down below 1 per second it reverts + function test_claimAndDepositIntoGaugeGrief() public { + uint256 amt = 604800 - 1; + deal(address(lusd), mockGovernance, amt); + + // Pretend a Proposal has passed + vm.startPrank(address(mockGovernance)); + lusd.transfer(address(curveV2GaugeRewards), amt); + + assertEq(lusd.balanceOf(address(curveV2GaugeRewards)), amt); + curveV2GaugeRewards.onClaimForInitiative(0, amt); + assertEq(lusd.balanceOf(address(curveV2GaugeRewards)), curveV2GaugeRewards.remainder()); + } + + /// @dev Fuzz test that shows that given a total = amt + dust, the dust is lost permanently + function test_noDustGriefFuzz(uint128 amt, uint128 dust) public { + uint256 total = uint256(amt) + uint256(dust); + deal(address(lusd), mockGovernance, total); + + // Pretend a Proposal has passed + vm.startPrank(address(mockGovernance)); + // Dust amount + lusd.transfer(address(curveV2GaugeRewards), amt); + // Rest + lusd.transfer(address(curveV2GaugeRewards), dust); + + assertEq(lusd.balanceOf(address(curveV2GaugeRewards)), total); + curveV2GaugeRewards.onClaimForInitiative(0, amt); + assertEq(lusd.balanceOf(address(curveV2GaugeRewards)), curveV2GaugeRewards.remainder() + dust); } } diff --git a/test/E2E.t.sol b/test/E2E.t.sol new file mode 100644 index 00000000..6a41fbf3 --- /dev/null +++ b/test/E2E.t.sol @@ -0,0 +1,384 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import {Test, console2} from "forge-std/Test.sol"; +import {VmSafe} from "forge-std/Vm.sol"; +import {console} from "forge-std/console.sol"; + +import {IERC20} from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; + +import {IGovernance} from "../src/interfaces/IGovernance.sol"; +import {ILQTY} from "../src/interfaces/ILQTY.sol"; + +import {BribeInitiative} from "../src/BribeInitiative.sol"; +import {Governance} from "../src/Governance.sol"; +import {UserProxy} from "../src/UserProxy.sol"; + +import {PermitParams} from "../src/utils/Types.sol"; + +import {MockInitiative} from "./mocks/MockInitiative.sol"; + +contract E2ETests is Test { + IERC20 private constant lqty = IERC20(address(0x6DEA81C8171D0bA574754EF6F8b412F2Ed88c54D)); + IERC20 private constant lusd = IERC20(address(0x5f98805A4E8be255a32880FDeC7F6728C6568bA0)); + address private constant stakingV1 = address(0x4f9Fbb3f1E99B56e0Fe2892e623Ed36A76Fc605d); + address private constant user = address(0xF977814e90dA44bFA03b6295A0616a897441aceC); + address private constant user2 = address(0x10C9cff3c4Faa8A60cB8506a7A99411E6A199038); + address private constant lusdHolder = address(0xcA7f01403C4989d2b1A9335A2F09dD973709957c); + + uint128 private constant REGISTRATION_FEE = 1e18; + uint128 private constant REGISTRATION_THRESHOLD_FACTOR = 0.01e18; + uint128 private constant UNREGISTRATION_THRESHOLD_FACTOR = 4e18; + uint16 private constant REGISTRATION_WARM_UP_PERIOD = 4; + uint16 private constant UNREGISTRATION_AFTER_EPOCHS = 4; + uint128 private constant VOTING_THRESHOLD_FACTOR = 0.04e18; + uint88 private constant MIN_CLAIM = 500e18; + uint88 private constant MIN_ACCRUAL = 1000e18; + uint32 private constant EPOCH_DURATION = 604800; + uint32 private constant EPOCH_VOTING_CUTOFF = 518400; + + Governance private governance; + address[] private initialInitiatives; + + address private baseInitiative2; + address private baseInitiative3; + address private baseInitiative1; + + function setUp() public { + vm.createSelectFork(vm.rpcUrl("mainnet"), 20430000); + + baseInitiative1 = address( + new BribeInitiative( + address(vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 3)), + address(lusd), + address(lqty) + ) + ); + + baseInitiative2 = address( + new BribeInitiative( + address(vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 2)), + address(lusd), + address(lqty) + ) + ); + + baseInitiative3 = address( + new BribeInitiative( + address(vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 1)), + address(lusd), + address(lqty) + ) + ); + + initialInitiatives.push(baseInitiative1); + initialInitiatives.push(baseInitiative2); + + governance = new Governance( + address(lqty), + address(lusd), + stakingV1, + address(lusd), + IGovernance.Configuration({ + registrationFee: REGISTRATION_FEE, + registrationThresholdFactor: REGISTRATION_THRESHOLD_FACTOR, + unregistrationThresholdFactor: UNREGISTRATION_THRESHOLD_FACTOR, + registrationWarmUpPeriod: REGISTRATION_WARM_UP_PERIOD, + unregistrationAfterEpochs: UNREGISTRATION_AFTER_EPOCHS, + votingThresholdFactor: VOTING_THRESHOLD_FACTOR, + minClaim: MIN_CLAIM, + minAccrual: MIN_ACCRUAL, + epochStart: uint32(block.timestamp - EPOCH_DURATION), + /// @audit KEY + epochDuration: EPOCH_DURATION, + epochVotingCutoff: EPOCH_VOTING_CUTOFF + }), + address(this), + initialInitiatives + ); + } + + // forge test --match-test test_initialInitiativesCanBeVotedOnAtStart -vv + function test_initialInitiativesCanBeVotedOnAtStart() public { + /// @audit NOTE: In order for this to work, the constructor must set the start time a week behind + /// This will make the initiatives work on the first epoch + vm.startPrank(user); + // Check that we can vote on the first epoch, right after deployment + _deposit(1000e18); + + console.log("epoch", governance.epoch()); + _allocate(baseInitiative1, 1e18, 0); // Doesn't work due to cool down I think + + // And for sanity, you cannot vote on new ones, they need to be added first + deal(address(lusd), address(user), REGISTRATION_FEE); + lusd.approve(address(governance), REGISTRATION_FEE); + governance.registerInitiative(address(0x123123)); + + vm.expectRevert(); + _allocate(address(0x123123), 1e18, 0); + + // Whereas in next week it will work + vm.warp(block.timestamp + EPOCH_DURATION); + _allocate(address(0x123123), 1e18, 0); + } + + function test_canYouVoteWith100MLNLQTY() public { + deal(address(lqty), user, 100_000_000e18); + vm.startPrank(user); + // Check that we can vote on the first epoch, right after deployment + _deposit(100_000_000e18); + + console.log("epoch", governance.epoch()); + _allocate(baseInitiative1, 100_000_000e18, 0); + } + + function test_canYouVoteWith100MLNLQTY_after_10_years() public { + deal(address(lqty), user, 100_000_000e18); + deal(address(lusd), user, 1e18); + + vm.startPrank(user); + lusd.approve(address(governance), 1e18); + + // Check that we can vote on the first epoch, right after deployment + _deposit(100_000_000e18); + + vm.warp(block.timestamp + 365 days * 10); + address newInitiative = address(0x123123); + governance.registerInitiative(newInitiative); + + vm.warp(block.timestamp + EPOCH_DURATION); + + console.log("epoch", governance.epoch()); + _allocate(newInitiative, 100_000_000e18, 0); + } + + // forge test --match-test test_noVetoGriefAtEpochOne -vv + function test_noVetoGriefAtEpochOne() public { + /// @audit NOTE: In order for this to work, the constructor must set the start time a week behind + /// This will make the initiatives work on the first epoch + vm.startPrank(user); + // Check that we can vote on the first epoch, right after deployment + _deposit(1000e18); + + console.log("epoch", governance.epoch()); + _allocate(baseInitiative1, 0, 1e18); // Doesn't work due to cool down I think + + vm.expectRevert(); + governance.unregisterInitiative(baseInitiative1); + + vm.warp(block.timestamp + EPOCH_DURATION); + governance.unregisterInitiative(baseInitiative1); + } + + // forge test --match-test test_deregisterIsSound -vv + function test_deregisterIsSound() public { + // Deregistration works as follows: + // We stop voting + // We wait for `UNREGISTRATION_AFTER_EPOCHS` + // The initiative is removed + vm.startPrank(user); + // Check that we can vote on the first epoch, right after deployment + _deposit(1000e18); + + console.log("epoch", governance.epoch()); + _allocate(baseInitiative1, 1e18, 0); // Doesn't work due to cool down I think + + // And for sanity, you cannot vote on new ones, they need to be added first + deal(address(lusd), address(user), REGISTRATION_FEE); + lusd.approve(address(governance), REGISTRATION_FEE); + + address newInitiative = address(0x123123); + governance.registerInitiative(newInitiative); + assertEq(uint256(Governance.InitiativeStatus.WARM_UP), _getInitiativeStatus(newInitiative), "Cooldown"); + + uint256 skipCount; + + // WARM_UP at 0 + + // Whereas in next week it will work + vm.warp(block.timestamp + EPOCH_DURATION); // 1 + ++skipCount; + assertEq(uint256(Governance.InitiativeStatus.SKIP), _getInitiativeStatus(newInitiative), "SKIP"); + + // Cooldown on epoch Staert + vm.warp(block.timestamp + EPOCH_DURATION); // 2 + ++skipCount; + assertEq(uint256(Governance.InitiativeStatus.SKIP), _getInitiativeStatus(newInitiative), "SKIP"); + + vm.warp(block.timestamp + EPOCH_DURATION); // 3 + ++skipCount; + assertEq(uint256(Governance.InitiativeStatus.SKIP), _getInitiativeStatus(newInitiative), "SKIP"); + + vm.warp(block.timestamp + EPOCH_DURATION); // 3 + ++skipCount; + assertEq(uint256(Governance.InitiativeStatus.SKIP), _getInitiativeStatus(newInitiative), "SKIP"); + + vm.warp(block.timestamp + EPOCH_DURATION); // 4 + ++skipCount; + assertEq( + uint256(Governance.InitiativeStatus.UNREGISTERABLE), _getInitiativeStatus(newInitiative), "UNREGISTERABLE" + ); + + /// 4 + 1 ?? + assertEq(skipCount, UNREGISTRATION_AFTER_EPOCHS + 1, "Skipped exactly UNREGISTRATION_AFTER_EPOCHS"); + } + + // forge test --match-test test_unregisterWorksCorrectlyEvenAfterXEpochs -vv + function test_unregisterWorksCorrectlyEvenAfterXEpochs(uint8 epochsInFuture) public { + vm.warp(block.timestamp + epochsInFuture * EPOCH_DURATION); + vm.startPrank(user); + // Check that we can vote on the first epoch, right after deployment + _deposit(1000e18); + + // And for sanity, you cannot vote on new ones, they need to be added first + deal(address(lusd), address(user), REGISTRATION_FEE * 2); + lusd.approve(address(governance), REGISTRATION_FEE * 2); + + address newInitiative = address(0x123123); + address newInitiative2 = address(0x1231234); + governance.registerInitiative(newInitiative); + governance.registerInitiative(newInitiative2); + assertEq(uint256(Governance.InitiativeStatus.WARM_UP), _getInitiativeStatus(newInitiative), "Cooldown"); + assertEq(uint256(Governance.InitiativeStatus.WARM_UP), _getInitiativeStatus(newInitiative2), "Cooldown"); + + uint256 skipCount; + + // SPEC: + // Initiative is at WARM_UP at registration epoch + + // The following EPOCH it can be voted on, it has status SKIP + + vm.warp(block.timestamp + EPOCH_DURATION); // 1 + ++skipCount; + assertEq(uint256(Governance.InitiativeStatus.SKIP), _getInitiativeStatus(newInitiative), "SKIP"); + + _allocate(newInitiative2, 1e18, 0); + + // 2nd Week of SKIP + + // Cooldown on epoch Staert + vm.warp(block.timestamp + EPOCH_DURATION); // 2 + ++skipCount; + assertEq(uint256(Governance.InitiativeStatus.SKIP), _getInitiativeStatus(newInitiative), "SKIP"); + + // 3rd Week of SKIP + + vm.warp(block.timestamp + EPOCH_DURATION); // 3 + ++skipCount; + assertEq(uint256(Governance.InitiativeStatus.SKIP), _getInitiativeStatus(newInitiative), "SKIP"); + + // 4th Week of SKIP | If it doesn't get any rewards it will be UNREGISTERABLE + + vm.warp(block.timestamp + EPOCH_DURATION); // 3 + ++skipCount; + assertEq(uint256(Governance.InitiativeStatus.SKIP), _getInitiativeStatus(newInitiative), "SKIP"); + + vm.warp(block.timestamp + EPOCH_DURATION); // 4 + ++skipCount; + assertEq( + uint256(Governance.InitiativeStatus.UNREGISTERABLE), _getInitiativeStatus(newInitiative), "UNREGISTERABLE" + ); + + /// It was SKIP for 4 EPOCHS, it is now UNREGISTERABLE + assertEq(skipCount, UNREGISTRATION_AFTER_EPOCHS + 1, "Skipped exactly UNREGISTRATION_AFTER_EPOCHS"); + } + + // forge test --match-test test_unregisterWorksCorrectlyEvenAfterXEpochs_andCanBeSavedAtLast -vv + function test_unregisterWorksCorrectlyEvenAfterXEpochs_andCanBeSavedAtLast(uint8 epochsInFuture) public { + vm.warp(block.timestamp + epochsInFuture * EPOCH_DURATION); + vm.startPrank(user); + // Check that we can vote on the first epoch, right after deployment + _deposit(1000e18); + + // And for sanity, you cannot vote on new ones, they need to be added first + deal(address(lusd), address(user), REGISTRATION_FEE * 2); + lusd.approve(address(governance), REGISTRATION_FEE * 2); + + address newInitiative = address(0x123123); + address newInitiative2 = address(0x1231234); + governance.registerInitiative(newInitiative); + governance.registerInitiative(newInitiative2); + assertEq(uint256(Governance.InitiativeStatus.WARM_UP), _getInitiativeStatus(newInitiative), "Cooldown"); + assertEq(uint256(Governance.InitiativeStatus.WARM_UP), _getInitiativeStatus(newInitiative2), "Cooldown"); + + uint256 skipCount; + + // SPEC: + // Initiative is at WARM_UP at registration epoch + + // The following EPOCH it can be voted on, it has status SKIP + + vm.warp(block.timestamp + EPOCH_DURATION); // 1 + ++skipCount; + assertEq(uint256(Governance.InitiativeStatus.SKIP), _getInitiativeStatus(newInitiative), "SKIP"); + + _allocate(newInitiative2, 1e18, 0); + + // 2nd Week of SKIP + + // Cooldown on epoch Staert + vm.warp(block.timestamp + EPOCH_DURATION); // 2 + ++skipCount; + assertEq(uint256(Governance.InitiativeStatus.SKIP), _getInitiativeStatus(newInitiative), "SKIP"); + + // 3rd Week of SKIP + + vm.warp(block.timestamp + EPOCH_DURATION); // 3 + ++skipCount; + assertEq(uint256(Governance.InitiativeStatus.SKIP), _getInitiativeStatus(newInitiative), "SKIP"); + + // 4th Week of SKIP | If it doesn't get any rewards it will be UNREGISTERABLE + + vm.warp(block.timestamp + EPOCH_DURATION); // 3 + ++skipCount; + assertEq(uint256(Governance.InitiativeStatus.SKIP), _getInitiativeStatus(newInitiative), "SKIP"); + + // Allocating to it, saves it + _allocate(newInitiative, 1e18, 0); + + vm.warp(block.timestamp + EPOCH_DURATION); // 4 + ++skipCount; + assertEq(uint256(Governance.InitiativeStatus.CLAIMABLE), _getInitiativeStatus(newInitiative), "UNREGISTERABLE"); + } + + function _deposit(uint88 amt) internal { + address userProxy = governance.deployUserProxy(); + + lqty.approve(address(userProxy), amt); + governance.depositLQTY(amt); + } + + function _allocate(address initiative, int88 votes, int88 vetos) internal { + address[] memory initiativesToDeRegister = new address[](5); + initiativesToDeRegister[0] = baseInitiative1; + initiativesToDeRegister[1] = baseInitiative2; + initiativesToDeRegister[2] = baseInitiative3; + initiativesToDeRegister[3] = address(0x123123); + initiativesToDeRegister[4] = address(0x1231234); + + address[] memory initiatives = new address[](1); + initiatives[0] = initiative; + int88[] memory deltaLQTYVotes = new int88[](1); + deltaLQTYVotes[0] = votes; + int88[] memory deltaLQTYVetos = new int88[](1); + deltaLQTYVetos[0] = vetos; + + governance.allocateLQTY(initiativesToDeRegister, initiatives, deltaLQTYVotes, deltaLQTYVetos); + } + + function _allocate(address[] memory initiatives, int88[] memory votes, int88[] memory vetos) internal { + address[] memory initiativesToDeRegister = new address[](5); + initiativesToDeRegister[0] = baseInitiative1; + initiativesToDeRegister[1] = baseInitiative2; + initiativesToDeRegister[2] = baseInitiative3; + initiativesToDeRegister[3] = address(0x123123); + initiativesToDeRegister[4] = address(0x1231234); + + governance.allocateLQTY(initiativesToDeRegister, initiatives, votes, vetos); + } + + function _getInitiativeStatus(address _initiative) internal returns (uint256) { + (Governance.InitiativeStatus status,,) = governance.getInitiativeState(_initiative); + return uint256(status); + } +} diff --git a/test/EncodingDecoding.t.sol b/test/EncodingDecoding.t.sol new file mode 100644 index 00000000..49e205e0 --- /dev/null +++ b/test/EncodingDecoding.t.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import {Test, console2} from "forge-std/Test.sol"; + +import {EncodingDecodingLib} from "src/utils/EncodingDecodingLib.sol"; + +contract EncodingDecodingTest is Test { + // value -> encoding -> decoding -> value + function test_encoding_and_decoding_symmetrical(uint88 lqty, uint120 averageTimestamp) public { + uint224 encodedValue = EncodingDecodingLib.encodeLQTYAllocation(lqty, averageTimestamp); + (uint88 decodedLqty, uint120 decodedAverageTimestamp) = EncodingDecodingLib.decodeLQTYAllocation(encodedValue); + + assertEq(lqty, decodedLqty); + assertEq(averageTimestamp, decodedAverageTimestamp); + + // Redo + uint224 reEncoded = EncodingDecodingLib.encodeLQTYAllocation(decodedLqty, decodedAverageTimestamp); + (uint88 reDecodedLqty, uint120 reDecodedAverageTimestamp) = + EncodingDecodingLib.decodeLQTYAllocation(encodedValue); + + assertEq(reEncoded, encodedValue); + assertEq(reDecodedLqty, decodedLqty); + assertEq(reDecodedAverageTimestamp, decodedAverageTimestamp); + } + + // receive -> undo -> check -> redo -> compare + function test_receive_undo_compare(uint120 encodedValue) public { + _receive_undo_compare(encodedValue); + } + + // receive -> undo -> check -> redo -> compare + function _receive_undo_compare(uint224 encodedValue) public { + /// These values fail because we could pass a value that is bigger than intended + (uint88 decodedLqty, uint120 decodedAverageTimestamp) = EncodingDecodingLib.decodeLQTYAllocation(encodedValue); + + uint224 encodedValue2 = EncodingDecodingLib.encodeLQTYAllocation(decodedLqty, decodedAverageTimestamp); + (uint88 decodedLqty2, uint120 decodedAverageTimestamp2) = + EncodingDecodingLib.decodeLQTYAllocation(encodedValue2); + + assertEq(encodedValue, encodedValue2, "encoded values not equal"); + assertEq(decodedLqty, decodedLqty2, "decoded lqty not equal"); + assertEq(decodedAverageTimestamp, decodedAverageTimestamp2, "decoded timestamps not equal"); + } +} diff --git a/test/Governance.t.sol b/test/Governance.t.sol index eb2a5554..e3f1ea3a 100644 --- a/test/Governance.t.sol +++ b/test/Governance.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.24; -import {Test} from "forge-std/Test.sol"; +import {Test, console2} from "forge-std/Test.sol"; import {VmSafe} from "forge-std/Vm.sol"; import {console} from "forge-std/console.sol"; @@ -26,18 +26,18 @@ contract GovernanceInternal is Governance { address _bold, Configuration memory _config, address[] memory _initiatives - ) Governance(_lqty, _lusd, _stakingV1, _bold, _config, _initiatives) {} + ) Governance(_lqty, _lusd, _stakingV1, _bold, _config, msg.sender, _initiatives) {} - function averageAge(uint32 _currentTimestamp, uint32 _averageTimestamp) external pure returns (uint32) { + function averageAge(uint120 _currentTimestamp, uint120 _averageTimestamp) external pure returns (uint120) { return _averageAge(_currentTimestamp, _averageTimestamp); } function calculateAverageTimestamp( - uint32 _prevOuterAverageTimestamp, - uint32 _newInnerAverageTimestamp, + uint120 _prevOuterAverageTimestamp, + uint120 _newInnerAverageTimestamp, uint88 _prevLQTYBalance, uint88 _newLQTYBalance - ) external view returns (uint32) { + ) external view returns (uint208) { return _calculateAverageTimestamp( _prevOuterAverageTimestamp, _newInnerAverageTimestamp, _prevLQTYBalance, _newLQTYBalance ); @@ -119,6 +119,7 @@ contract GovernanceTest is Test { epochDuration: EPOCH_DURATION, epochVotingCutoff: EPOCH_VOTING_CUTOFF }), + address(this), initialInitiatives ); @@ -145,8 +146,8 @@ contract GovernanceTest is Test { } // should not revert under any input - function test_averageAge(uint32 _currentTimestamp, uint32 _timestamp) public { - uint32 averageAge = governanceInternal.averageAge(_currentTimestamp, _timestamp); + function test_averageAge(uint120 _currentTimestamp, uint120 _timestamp) public { + uint120 averageAge = governanceInternal.averageAge(_currentTimestamp, _timestamp); if (_timestamp == 0 || _currentTimestamp < _timestamp) { assertEq(averageAge, 0); } else { @@ -170,6 +171,7 @@ contract GovernanceTest is Test { ); } + // forge test --match-test test_depositLQTY_withdrawLQTY -vv function test_depositLQTY_withdrawLQTY() public { uint256 timeIncrease = 86400 * 30; vm.warp(block.timestamp + timeIncrease); @@ -195,10 +197,10 @@ contract GovernanceTest is Test { // deploy and deposit 1 LQTY governance.depositLQTY(1e18); assertEq(UserProxy(payable(userProxy)).staked(), 1e18); - (uint88 allocatedLQTY, uint32 averageStakingTimestamp) = governance.userStates(user); + (uint88 allocatedLQTY, uint120 averageStakingTimestamp) = governance.userStates(user); assertEq(allocatedLQTY, 0); // first deposit should have an averageStakingTimestamp if block.timestamp - assertEq(averageStakingTimestamp, block.timestamp); + assertEq(averageStakingTimestamp, block.timestamp * 1e26); vm.warp(block.timestamp + timeIncrease); @@ -208,7 +210,7 @@ contract GovernanceTest is Test { (allocatedLQTY, averageStakingTimestamp) = governance.userStates(user); assertEq(allocatedLQTY, 0); // subsequent deposits should have a stake weighted average - assertEq(averageStakingTimestamp, block.timestamp - timeIncrease / 2); + assertEq(averageStakingTimestamp, (block.timestamp - timeIncrease / 2) * 1e26, "Avg ts"); // withdraw 0.5 half of LQTY vm.warp(block.timestamp + timeIncrease); @@ -220,21 +222,18 @@ contract GovernanceTest is Test { vm.startPrank(user); - vm.expectRevert("Governance: insufficient-unallocated-lqty"); - governance.withdrawLQTY(type(uint88).max); - governance.withdrawLQTY(1e18); assertEq(UserProxy(payable(userProxy)).staked(), 1e18); (allocatedLQTY, averageStakingTimestamp) = governance.userStates(user); assertEq(allocatedLQTY, 0); - assertEq(averageStakingTimestamp, (block.timestamp - timeIncrease) - timeIncrease / 2); + assertEq(averageStakingTimestamp, ((block.timestamp - timeIncrease) - timeIncrease / 2) * 1e26, "avg ts2"); // withdraw remaining LQTY governance.withdrawLQTY(1e18); assertEq(UserProxy(payable(userProxy)).staked(), 0); (allocatedLQTY, averageStakingTimestamp) = governance.userStates(user); assertEq(allocatedLQTY, 0); - assertEq(averageStakingTimestamp, (block.timestamp - timeIncrease) - timeIncrease / 2); + assertEq(averageStakingTimestamp, ((block.timestamp - timeIncrease) - timeIncrease / 2) * 1e26, "avg ts3"); vm.stopPrank(); } @@ -303,9 +302,9 @@ contract GovernanceTest is Test { // deploy and deposit 1 LQTY governance.depositLQTYViaPermit(1e18, permitParams); assertEq(UserProxy(payable(userProxy)).staked(), 1e18); - (uint88 allocatedLQTY, uint32 averageStakingTimestamp) = governance.userStates(wallet.addr); + (uint88 allocatedLQTY, uint120 averageStakingTimestamp) = governance.userStates(wallet.addr); assertEq(allocatedLQTY, 0); - assertEq(averageStakingTimestamp, block.timestamp); + assertEq(averageStakingTimestamp, block.timestamp * 1e26); } function test_claimFromStakingV1() public { @@ -384,11 +383,11 @@ contract GovernanceTest is Test { } // should not revert under any input - function test_lqtyToVotes(uint88 _lqtyAmount, uint256 _currentTimestamp, uint32 _averageTimestamp) public { + function test_lqtyToVotes(uint88 _lqtyAmount, uint120 _currentTimestamp, uint120 _averageTimestamp) public { governance.lqtyToVotes(_lqtyAmount, _currentTimestamp, _averageTimestamp); } - function test_calculateVotingThreshold() public { + function test_getLatestVotingThreshold() public { governance = new Governance( address(lqty), address(lusd), @@ -407,11 +406,12 @@ contract GovernanceTest is Test { epochDuration: EPOCH_DURATION, epochVotingCutoff: EPOCH_VOTING_CUTOFF }), + address(this), initialInitiatives ); // is 0 when the previous epochs votes are 0 - assertEq(governance.calculateVotingThreshold(), 0); + assertEq(governance.getLatestVotingThreshold(), 0); // check that votingThreshold is is high enough such that MIN_CLAIM is met IGovernance.VoteSnapshot memory snapshot = IGovernance.VoteSnapshot(1e18, 1); @@ -428,7 +428,7 @@ contract GovernanceTest is Test { vm.store(address(governance), bytes32(uint256(1)), bytes32(abi.encode(boldAccrued))); assertEq(governance.boldAccrued(), 1000e18); - assertEq(governance.calculateVotingThreshold(), MIN_CLAIM / 1000); + assertEq(governance.getLatestVotingThreshold(), MIN_CLAIM / 1000); // check that votingThreshold is 4% of votes of previous epoch governance = new Governance( @@ -449,6 +449,7 @@ contract GovernanceTest is Test { epochDuration: EPOCH_DURATION, epochVotingCutoff: EPOCH_VOTING_CUTOFF }), + address(this), initialInitiatives ); @@ -466,7 +467,7 @@ contract GovernanceTest is Test { vm.store(address(governance), bytes32(uint256(1)), bytes32(abi.encode(boldAccrued))); assertEq(governance.boldAccrued(), 1000e18); - assertEq(governance.calculateVotingThreshold(), 10000e18 * 0.04); + assertEq(governance.getLatestVotingThreshold(), 10000e18 * 0.04); } // should not revert under any state @@ -477,6 +478,8 @@ contract GovernanceTest is Test { uint128 _votingThresholdFactor, uint88 _minClaim ) public { + _votingThresholdFactor = _votingThresholdFactor % 1e18; + /// Clamp to prevent misconfig governance = new Governance( address(lqty), address(lusd), @@ -495,6 +498,7 @@ contract GovernanceTest is Test { epochDuration: EPOCH_DURATION, epochVotingCutoff: EPOCH_VOTING_CUTOFF }), + address(this), initialInitiatives ); @@ -511,7 +515,7 @@ contract GovernanceTest is Test { vm.store(address(governance), bytes32(uint256(1)), bytes32(abi.encode(_boldAccrued))); assertEq(governance.boldAccrued(), _boldAccrued); - governance.calculateVotingThreshold(); + governance.getLatestVotingThreshold(); } function test_registerInitiative() public { @@ -550,7 +554,7 @@ contract GovernanceTest is Test { lqty.approve(address(userProxy), 1e18); governance.depositLQTY(1e18); - vm.warp(block.timestamp + 365 days); + vm.warp(block.timestamp + governance.EPOCH_DURATION()); // should revert if `_initiative` is zero vm.expectRevert("Governance: zero-address"); @@ -567,6 +571,8 @@ contract GovernanceTest is Test { vm.stopPrank(); } + // TODO: Broken: Fix it by simplifying most likely + // forge test --match-test test_unregisterInitiative -vv function test_unregisterInitiative() public { vm.startPrank(user); @@ -593,7 +599,7 @@ contract GovernanceTest is Test { lusd.approve(address(governance), 1e18); lqty.approve(address(userProxy), 1e18); governance.depositLQTY(1e18); - vm.warp(block.timestamp + 365 days); + vm.warp(block.timestamp + governance.EPOCH_DURATION()); // should revert if the initiative isn't registered vm.expectRevert("Governance: initiative-not-registered"); @@ -605,9 +611,10 @@ contract GovernanceTest is Test { // should revert if the initiative is still in the registration warm up period vm.expectRevert("Governance: initiative-in-warm-up"); + /// @audit should fail due to not waiting enough time governance.unregisterInitiative(baseInitiative3); - vm.warp(block.timestamp + 365 days); + vm.warp(block.timestamp + governance.EPOCH_DURATION()); // should revert if the initiative is still active or the vetos don't meet the threshold vm.expectRevert("Governance: cannot-unregister-initiative"); @@ -623,24 +630,7 @@ contract GovernanceTest is Test { assertEq(votes, 1e18); assertEq(forEpoch, governance.epoch() - 1); - IGovernance.InitiativeVoteSnapshot memory initiativeSnapshot = - IGovernance.InitiativeVoteSnapshot(0, governance.epoch() - 1, 0); - vm.store( - address(governance), - keccak256(abi.encode(baseInitiative3, uint256(3))), - bytes32( - abi.encodePacked( - uint16(initiativeSnapshot.lastCountedEpoch), - uint16(initiativeSnapshot.forEpoch), - uint224(initiativeSnapshot.votes) - ) - ) - ); - (uint224 votes_, uint16 forEpoch_, uint16 lastCountedEpoch) = - governance.votesForInitiativeSnapshot(baseInitiative3); - assertEq(votes_, 0); - assertEq(forEpoch_, governance.epoch() - 1); - assertEq(lastCountedEpoch, 0); + vm.warp(block.timestamp + governance.EPOCH_DURATION() * UNREGISTRATION_AFTER_EPOCHS); governance.unregisterInitiative(baseInitiative3); @@ -653,92 +643,386 @@ contract GovernanceTest is Test { vm.startPrank(user); lusd.approve(address(governance), 1e18); - + vm.expectRevert("Governance: initiative-already-registered"); governance.registerInitiative(baseInitiative3); - atEpoch = governance.registeredInitiatives(baseInitiative3); - assertEq(atEpoch, governance.epoch()); + } - vm.warp(block.timestamp + 365 days); + /// Used to demonstrate how composite voting could allow using more power than intended + // forge test --match-test test_crit_accounting_mismatch -vv + function test_crit_accounting_mismatch() public { + // User setup + vm.startPrank(user); + address userProxy = governance.deployUserProxy(); - initiativeSnapshot = IGovernance.InitiativeVoteSnapshot(1, governance.epoch() - 1, governance.epoch() - 1); - vm.store( - address(governance), - keccak256(abi.encode(baseInitiative3, uint256(3))), - bytes32( - abi.encodePacked( - uint16(initiativeSnapshot.lastCountedEpoch), - uint16(initiativeSnapshot.forEpoch), - uint224(initiativeSnapshot.votes) - ) - ) - ); - (votes_, forEpoch_, lastCountedEpoch) = governance.votesForInitiativeSnapshot(baseInitiative3); - assertEq(votes_, 1); - assertEq(forEpoch_, governance.epoch() - 1); - assertEq(lastCountedEpoch, governance.epoch() - 1); + lqty.approve(address(userProxy), 1_000e18); + governance.depositLQTY(1_000e18); - IGovernance.GlobalState memory globalState = IGovernance.GlobalState(type(uint88).max, uint32(block.timestamp)); - vm.store( - address(governance), - bytes32(uint256(4)), - bytes32( - abi.encodePacked( - uint136(0), uint32(globalState.countedVoteLQTYAverageTimestamp), uint88(globalState.countedVoteLQTY) - ) - ) - ); - (uint88 countedVoteLQTY, uint32 countedVoteLQTYAverageTimestamp) = governance.globalState(); - assertEq(countedVoteLQTY, type(uint88).max); - assertEq(countedVoteLQTYAverageTimestamp, block.timestamp); + vm.warp(block.timestamp + governance.EPOCH_DURATION()); - IGovernance.InitiativeState memory initiativeState = IGovernance.InitiativeState( - 1, 10e18, uint32(block.timestamp - 365 days), uint32(block.timestamp - 365 days), 1 - ); - vm.store( - address(governance), - keccak256(abi.encode(baseInitiative3, uint256(6))), - bytes32( - abi.encodePacked( - uint16(initiativeState.counted), - uint32(initiativeState.averageStakingTimestampVetoLQTY), - uint32(initiativeState.averageStakingTimestampVoteLQTY), - uint88(initiativeState.vetoLQTY), - uint88(initiativeState.voteLQTY) - ) - ) + /// Setup and vote for 2 initiatives, 0.1% vs 99.9% + address[] memory initiatives = new address[](2); + initiatives[0] = baseInitiative1; + initiatives[1] = baseInitiative2; + int88[] memory deltaLQTYVotes = new int88[](2); + deltaLQTYVotes[0] = 1e18; + deltaLQTYVotes[1] = 999e18; + int88[] memory deltaLQTYVetos = new int88[](2); + + governance.allocateLQTY(initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos); + + (uint256 allocatedLQTY,) = governance.userStates(user); + assertEq(allocatedLQTY, 1_000e18); + + (uint88 voteLQTY1,, uint120 averageStakingTimestampVoteLQTY1,,) = governance.initiativeStates(baseInitiative1); + + (uint88 voteLQTY2,,,,) = governance.initiativeStates(baseInitiative2); + + // Get power at time of vote + uint256 votingPower = governance.lqtyToVotes( + voteLQTY1, uint120(block.timestamp) * uint120(1e26), averageStakingTimestampVoteLQTY1 ); + assertGt(votingPower, 0, "Non zero power"); + + /// @audit TODO Fully digest and explain the bug + // Warp to end so we check the threshold against future threshold + + { + vm.warp(block.timestamp + governance.EPOCH_DURATION()); + + ( + IGovernance.VoteSnapshot memory snapshot, + IGovernance.InitiativeVoteSnapshot memory initiativeVoteSnapshot1 + ) = governance.snapshotVotesForInitiative(baseInitiative1); + (, IGovernance.InitiativeVoteSnapshot memory initiativeVoteSnapshot2) = + governance.snapshotVotesForInitiative(baseInitiative2); + + uint256 threshold = governance.getLatestVotingThreshold(); + assertLt(initiativeVoteSnapshot1.votes, threshold, "it didn't get rewards"); + + uint256 votingPowerWithProjection = governance.lqtyToVotes( + voteLQTY1, + uint120(governance.epochStart() + governance.EPOCH_DURATION()), + averageStakingTimestampVoteLQTY1 + ); + assertLt(votingPower, threshold, "Current Power is not enough - Desynch A"); + assertLt(votingPowerWithProjection, threshold, "Future Power is also not enough - Desynch B"); + } + } + + // Same setup as above (but no need for bug) + // Show that you cannot withdraw + // forge test --match-test test_canAlwaysRemoveAllocation -vv + function test_canAlwaysRemoveAllocation() public { + // User setup + vm.startPrank(user); + address userProxy = governance.deployUserProxy(); + + lqty.approve(address(userProxy), 1_000e18); + governance.depositLQTY(1_000e18); + + vm.warp(block.timestamp + governance.EPOCH_DURATION()); + + /// Setup and vote for 2 initiatives, 0.1% vs 99.9% + address[] memory initiatives = new address[](2); + initiatives[0] = baseInitiative1; + initiatives[1] = baseInitiative2; + int88[] memory deltaLQTYVotes = new int88[](2); + deltaLQTYVotes[0] = 1e18; + deltaLQTYVotes[1] = 999e18; + int88[] memory deltaLQTYVetos = new int88[](2); + + governance.allocateLQTY(initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos); + + // Warp to end so we check the threshold against future threshold + + { + vm.warp(block.timestamp + governance.EPOCH_DURATION()); + + ( + IGovernance.VoteSnapshot memory snapshot, + IGovernance.InitiativeVoteSnapshot memory initiativeVoteSnapshot1 + ) = governance.snapshotVotesForInitiative(baseInitiative1); + + uint256 threshold = governance.getLatestVotingThreshold(); + assertLt(initiativeVoteSnapshot1.votes, threshold, "it didn't get rewards"); + } + + // Roll for + vm.warp(block.timestamp + governance.UNREGISTRATION_AFTER_EPOCHS() * governance.EPOCH_DURATION()); + governance.unregisterInitiative(baseInitiative1); + + // @audit Warmup is not necessary + // Warmup would only work for urgent veto + // But urgent veto is not relevant here + + // I want to remove my allocation + address[] memory removeInitiatives = new address[](2); + removeInitiatives[0] = baseInitiative1; + removeInitiatives[1] = baseInitiative2; + int88[] memory removeDeltaLQTYVotes = new int88[](2); + // don't need to explicitly remove allocation because it already gets reset + removeDeltaLQTYVotes[0] = 0; + int88[] memory removeDeltaLQTYVetos = new int88[](2); + + governance.allocateLQTY(removeInitiatives, removeInitiatives, removeDeltaLQTYVotes, removeDeltaLQTYVetos); + + removeDeltaLQTYVotes[0] = -1e18; + + vm.expectRevert("Cannot be negative"); + governance.allocateLQTY(removeInitiatives, removeInitiatives, removeDeltaLQTYVotes, removeDeltaLQTYVetos); + + address[] memory reAddInitiatives = new address[](1); + reAddInitiatives[0] = baseInitiative1; + int88[] memory reAddDeltaLQTYVotes = new int88[](1); + reAddDeltaLQTYVotes[0] = 1e18; + int88[] memory reAddDeltaLQTYVetos = new int88[](1); + + /// @audit This MUST revert, an initiative should not be re-votable once disabled + vm.expectRevert("Governance: active-vote-fsm"); + governance.allocateLQTY(reAddInitiatives, reAddInitiatives, reAddDeltaLQTYVotes, reAddDeltaLQTYVetos); + } + + // Used to identify an accounting bug where vote power could be added to global state + // While initiative is unregistered + // forge test --match-test test_allocationRemovalTotalLqtyMathIsSound -vv + function test_allocationRemovalTotalLqtyMathIsSound() public { + vm.startPrank(user2); + address userProxy_2 = governance.deployUserProxy(); + + lqty.approve(address(userProxy_2), 1_000e18); + governance.depositLQTY(1_000e18); + + // User setup + vm.startPrank(user); + address userProxy = governance.deployUserProxy(); + + lqty.approve(address(userProxy), 1_000e18); + governance.depositLQTY(1_000e18); + + vm.warp(block.timestamp + governance.EPOCH_DURATION()); + + /// Setup and vote for 2 initiatives, 0.1% vs 99.9% + address[] memory initiatives = new address[](2); + initiatives[0] = baseInitiative1; + initiatives[1] = baseInitiative2; + int88[] memory deltaLQTYVotes = new int88[](2); + deltaLQTYVotes[0] = 1e18; + deltaLQTYVotes[1] = 999e18; + int88[] memory deltaLQTYVetos = new int88[](2); + + governance.allocateLQTY(initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos); + + vm.startPrank(user2); + governance.allocateLQTY(initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos); + + vm.startPrank(user); + + // Roll for the rest of the epochs so we can unregister + vm.warp(block.timestamp + (governance.UNREGISTRATION_AFTER_EPOCHS()) * governance.EPOCH_DURATION()); + governance.unregisterInitiative(baseInitiative1); + + // Get state here + // Get initiative state + (uint88 b4_countedVoteLQTY, uint120 b4_countedVoteLQTYAverageTimestamp) = governance.globalState(); + + // I want to remove my allocation + address[] memory removeInitiatives = new address[](2); + removeInitiatives[0] = baseInitiative1; + removeInitiatives[1] = baseInitiative2; + int88[] memory removeDeltaLQTYVotes = new int88[](2); + // don't need to explicitly remove allocation because it already gets reset + removeDeltaLQTYVotes[0] = 0; + removeDeltaLQTYVotes[1] = 999e18; + + int88[] memory removeDeltaLQTYVetos = new int88[](2); + + governance.allocateLQTY(removeInitiatives, removeInitiatives, removeDeltaLQTYVotes, removeDeltaLQTYVetos); + + { + // Get state here + // TODO Get initiative state + (uint88 after_countedVoteLQTY, uint120 after_countedVoteLQTYAverageTimestamp) = governance.globalState(); + + assertEq(after_countedVoteLQTY, b4_countedVoteLQTY, "LQTY should not change"); + assertEq( + b4_countedVoteLQTYAverageTimestamp, after_countedVoteLQTYAverageTimestamp, "Avg TS should not change" + ); + } + } + + // Remove allocation but check accounting + // Need to find bug in accounting code + // forge test --match-test test_addRemoveAllocation_accounting -vv + function test_addRemoveAllocation_accounting() public { + // User setup + vm.startPrank(user); + address userProxy = governance.deployUserProxy(); + + lqty.approve(address(userProxy), 1_000e18); + governance.depositLQTY(1_000e18); + + vm.warp(block.timestamp + governance.EPOCH_DURATION()); + + /// Setup and vote for 2 initiatives, 0.1% vs 99.9% + address[] memory initiatives = new address[](2); + initiatives[0] = baseInitiative1; + initiatives[1] = baseInitiative2; + int88[] memory deltaLQTYVotes = new int88[](2); + deltaLQTYVotes[0] = 1e18; + deltaLQTYVotes[1] = 999e18; + int88[] memory deltaLQTYVetos = new int88[](2); + + governance.allocateLQTY(initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos); + + // Warp to end so we check the threshold against future threshold + { + vm.warp(block.timestamp + governance.EPOCH_DURATION()); + + ( + IGovernance.VoteSnapshot memory snapshot, + IGovernance.InitiativeVoteSnapshot memory initiativeVoteSnapshot1 + ) = governance.snapshotVotesForInitiative(baseInitiative1); + + uint256 threshold = governance.getLatestVotingThreshold(); + assertLt(initiativeVoteSnapshot1.votes, threshold, "it didn't get rewards"); + } + + // Roll for + vm.warp(block.timestamp + governance.UNREGISTRATION_AFTER_EPOCHS() * governance.EPOCH_DURATION()); + + /// === END SETUP === /// - // should update the average timestamp for counted lqty if the initiative has been counted in + // Grab values b4 unregistering and b4 removing user allocation + + (uint88 b4_countedVoteLQTY, uint120 b4_countedVoteLQTYAverageTimestamp) = governance.globalState(); + (uint88 b4_allocatedLQTY, uint120 b4_averageStakingTimestamp) = governance.userStates(user); + (uint88 b4_voteLQTY,,,,) = governance.initiativeStates(baseInitiative1); + + // Unregistering + governance.unregisterInitiative(baseInitiative1); + + // We expect, the initiative to have the same values (because we track them for storage purposes) + // TODO: Could change some of the values to make them 0 in view stuff + // We expect the state to already have those removed + // We expect the user to not have any changes + + (uint88 after_countedVoteLQTY,) = governance.globalState(); + + assertEq(after_countedVoteLQTY, b4_countedVoteLQTY - b4_voteLQTY, "Global Lqty change after unregister"); + assertEq(1e18, b4_voteLQTY, "sanity check"); + + (uint88 after_allocatedLQTY, uint120 after_averageStakingTimestamp) = governance.userStates(user); + + // We expect no changes here ( - uint88 voteLQTY, - uint88 vetoLQTY, - uint32 averageStakingTimestampVoteLQTY, - uint32 averageStakingTimestampVetoLQTY, - uint16 counted - ) = governance.initiativeStates(baseInitiative3); - assertEq(voteLQTY, 1); - assertEq(vetoLQTY, 10e18); - assertEq(averageStakingTimestampVoteLQTY, block.timestamp - 365 days); - assertEq(averageStakingTimestampVetoLQTY, block.timestamp - 365 days); - assertEq(counted, 1); + uint88 after_voteLQTY, + uint88 after_vetoLQTY, + uint120 after_averageStakingTimestampVoteLQTY, + uint120 after_averageStakingTimestampVetoLQTY, + uint16 after_lastEpochClaim + ) = governance.initiativeStates(baseInitiative1); + assertEq(b4_voteLQTY, after_voteLQTY, "Initiative votes are the same"); + + // Need to test: + // Total Votes + // User Votes + // Initiative Votes + + // I cannot + address[] memory removeInitiatives = new address[](2); + removeInitiatives[0] = baseInitiative1; + removeInitiatives[1] = baseInitiative2; // all user initiatives previously allocated to need to be included for resetting + int88[] memory removeDeltaLQTYVotes = new int88[](2); + removeDeltaLQTYVotes[0] = 0; + removeDeltaLQTYVotes[1] = 0; + int88[] memory removeDeltaLQTYVetos = new int88[](2); + + /// @audit the next call MUST not revert - this is a critical bug + governance.allocateLQTY(removeInitiatives, removeInitiatives, removeDeltaLQTYVotes, removeDeltaLQTYVetos); + + // After user counts LQTY the + { + (uint88 after_user_countedVoteLQTY, uint120 after_user_countedVoteLQTYAverageTimestamp) = + governance.globalState(); + // The LQTY was already removed + assertEq(after_user_countedVoteLQTY, 0, "Removal 1"); + } - governance.unregisterInitiative(baseInitiative3); + // User State allocated LQTY changes by entire previous allocation amount + // Timestamp should not change + { + (uint88 after_user_allocatedLQTY,) = governance.userStates(user); + assertEq(after_user_allocatedLQTY, 0, "Removal 2"); + } - assertEq(governance.registeredInitiatives(baseInitiative3), 0); + // Check user math only change is the LQTY amt + // user was the only one allocated so since all alocations were reset, the initative lqty should be 0 + { + (uint88 after_user_voteLQTY,,,,) = governance.initiativeStates(baseInitiative1); - // should delete the initiative state and the registration timestamp - (voteLQTY, vetoLQTY, averageStakingTimestampVoteLQTY, averageStakingTimestampVetoLQTY, counted) = - governance.initiativeStates(baseInitiative3); - assertEq(voteLQTY, 0); - assertEq(vetoLQTY, 0); - assertEq(averageStakingTimestampVoteLQTY, 0); - assertEq(averageStakingTimestampVetoLQTY, 0); - assertEq(counted, 0); + assertEq(after_user_voteLQTY, 0, "Removal 3"); + } + } - vm.stopPrank(); + // Just pass a negative value and see what happens + // forge test --match-test test_overflow_crit -vv + function test_overflow_crit() public { + // User setup + vm.startPrank(user); + address userProxy = governance.deployUserProxy(); + + lqty.approve(address(userProxy), 1_000e18); + governance.depositLQTY(1_000e18); + + vm.warp(block.timestamp + governance.EPOCH_DURATION()); + + /// Setup and vote for 2 initiatives, 0.1% vs 99.9% + address[] memory initiatives = new address[](2); + initiatives[0] = baseInitiative1; + initiatives[1] = baseInitiative2; + int88[] memory deltaLQTYVotes = new int88[](2); + deltaLQTYVotes[0] = 1e18; + deltaLQTYVotes[1] = 999e18; + int88[] memory deltaLQTYVetos = new int88[](2); + + governance.allocateLQTY(initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos); + (uint88 allocatedB4Test,,) = governance.lqtyAllocatedByUserToInitiative(user, baseInitiative1); + console.log("allocatedB4Test", allocatedB4Test); + + vm.warp(block.timestamp + governance.EPOCH_DURATION()); + vm.warp(block.timestamp + governance.EPOCH_DURATION()); + vm.warp(block.timestamp + governance.EPOCH_DURATION()); + vm.warp(block.timestamp + governance.EPOCH_DURATION()); + + address[] memory removeInitiatives = new address[](2); + removeInitiatives[0] = baseInitiative1; + removeInitiatives[1] = baseInitiative2; + int88[] memory removeDeltaLQTYVotes = new int88[](2); + removeDeltaLQTYVotes[0] = 0; + int88[] memory removeDeltaLQTYVetos = new int88[](2); + + (uint88 allocatedB4Removal,,) = governance.lqtyAllocatedByUserToInitiative(user, baseInitiative1); + console.log("allocatedB4Removal", allocatedB4Removal); + + governance.allocateLQTY(removeInitiatives, removeInitiatives, removeDeltaLQTYVotes, removeDeltaLQTYVetos); + (uint88 allocatedAfterRemoval,,) = governance.lqtyAllocatedByUserToInitiative(user, baseInitiative1); + console.log("allocatedAfterRemoval", allocatedAfterRemoval); + + // @audit this test no longer reverts due to underflow because of resetting before each allocation + // vm.expectRevert(); + governance.allocateLQTY(removeInitiatives, removeInitiatives, removeDeltaLQTYVotes, removeDeltaLQTYVetos); + (uint88 allocatedAfter,,) = governance.lqtyAllocatedByUserToInitiative(user, baseInitiative1); + console.log("allocatedAfter", allocatedAfter); } - function test_allocateLQTY() public { + /// Find some random amount + /// Divide into chunks + /// Ensure chunks above 1 wei + /// Go ahead and remove + /// Ensure that at the end you remove 100% + function test_fuzz_canRemoveExtact() public {} + + function test_allocateLQTY_single() public { vm.startPrank(user); address userProxy = governance.deployUserProxy(); @@ -746,23 +1030,23 @@ contract GovernanceTest is Test { lqty.approve(address(userProxy), 1e18); governance.depositLQTY(1e18); - (uint88 allocatedLQTY, uint32 averageStakingTimestampUser) = governance.userStates(user); + (uint88 allocatedLQTY, uint120 averageStakingTimestampUser) = governance.userStates(user); assertEq(allocatedLQTY, 0); (uint88 countedVoteLQTY,) = governance.globalState(); assertEq(countedVoteLQTY, 0); address[] memory initiatives = new address[](1); initiatives[0] = baseInitiative1; - int176[] memory deltaLQTYVotes = new int176[](1); - deltaLQTYVotes[0] = 1e18; - int176[] memory deltaLQTYVetos = new int176[](1); + int88[] memory deltaLQTYVotes = new int88[](1); + deltaLQTYVotes[0] = 1e18; //this should be 0 + int88[] memory deltaLQTYVetos = new int88[](1); // should revert if the initiative has been registered in the current epoch - vm.expectRevert("Governance: initiative-not-active"); - governance.allocateLQTY(initiatives, deltaLQTYVotes, deltaLQTYVetos); + vm.expectRevert("Governance: active-vote-fsm"); + governance.allocateLQTY(initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos); - vm.warp(block.timestamp + 365 days); - governance.allocateLQTY(initiatives, deltaLQTYVotes, deltaLQTYVetos); + vm.warp(block.timestamp + governance.EPOCH_DURATION()); + governance.allocateLQTY(initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos); (allocatedLQTY,) = governance.userStates(user); assertEq(allocatedLQTY, 1e18); @@ -770,20 +1054,19 @@ contract GovernanceTest is Test { ( uint88 voteLQTY, uint88 vetoLQTY, - uint32 averageStakingTimestampVoteLQTY, - uint32 averageStakingTimestampVetoLQTY, - uint16 counted + uint120 averageStakingTimestampVoteLQTY, + uint120 averageStakingTimestampVetoLQTY, ) = governance.initiativeStates(baseInitiative1); // should update the `voteLQTY` and `vetoLQTY` variables assertEq(voteLQTY, 1e18); assertEq(vetoLQTY, 0); // should update the average staking timestamp for the initiative based on the average staking timestamp of the user's // voting and vetoing LQTY - assertEq(averageStakingTimestampVoteLQTY, block.timestamp - 365 days); + assertEq(averageStakingTimestampVoteLQTY, (block.timestamp - governance.EPOCH_DURATION()) * 1e26); assertEq(averageStakingTimestampVoteLQTY, averageStakingTimestampUser); assertEq(averageStakingTimestampVetoLQTY, 0); // should remove or add the initiatives voting LQTY from the counter - assertEq(counted, 1); + (countedVoteLQTY,) = governance.globalState(); assertEq(countedVoteLQTY, 1e18); @@ -798,12 +1081,12 @@ contract GovernanceTest is Test { // should snapshot the global and initiatives votes if there hasn't been a snapshot in the current epoch yet (, uint16 forEpoch) = governance.votesSnapshot(); assertEq(forEpoch, governance.epoch() - 1); - (, forEpoch,) = governance.votesForInitiativeSnapshot(baseInitiative1); + (, forEpoch,,) = governance.votesForInitiativeSnapshot(baseInitiative1); assertEq(forEpoch, governance.epoch() - 1); vm.stopPrank(); - vm.warp(block.timestamp + 365 days); + vm.warp(block.timestamp + governance.EPOCH_DURATION()); vm.startPrank(user2); @@ -812,63 +1095,174 @@ contract GovernanceTest is Test { lqty.approve(address(user2Proxy), 1e18); governance.depositLQTY(1e18); - (, uint32 averageAge) = governance.userStates(user2); - assertEq(governance.lqtyToVotes(1e18, block.timestamp, averageAge), 0); + (, uint120 averageAge) = governance.userStates(user2); + assertEq(governance.lqtyToVotes(1e18, uint120(block.timestamp) * uint120(1e26), averageAge), 0); deltaLQTYVetos[0] = 1e18; vm.expectRevert("Governance: vote-and-veto"); - governance.allocateLQTY(initiatives, deltaLQTYVotes, deltaLQTYVetos); + governance.allocateLQTY(initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos); deltaLQTYVetos[0] = 0; - governance.allocateLQTY(initiatives, deltaLQTYVotes, deltaLQTYVetos); + governance.allocateLQTY(initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos); // should update the user's allocated LQTY balance (allocatedLQTY,) = governance.userStates(user2); assertEq(allocatedLQTY, 1e18); - (voteLQTY, vetoLQTY, averageStakingTimestampVoteLQTY, averageStakingTimestampVetoLQTY, counted) = + (voteLQTY, vetoLQTY, averageStakingTimestampVoteLQTY, averageStakingTimestampVetoLQTY,) = governance.initiativeStates(baseInitiative1); assertEq(voteLQTY, 2e18); assertEq(vetoLQTY, 0); - assertEq(averageStakingTimestampVoteLQTY, block.timestamp - 365 days); + assertEq(averageStakingTimestampVoteLQTY, (block.timestamp - governance.EPOCH_DURATION()) * 1e26); assertGt(averageStakingTimestampVoteLQTY, averageStakingTimestampUser); assertEq(averageStakingTimestampVetoLQTY, 0); - assertEq(counted, 1); // should revert if the user doesn't have enough unallocated LQTY available - vm.expectRevert("Governance: insufficient-unallocated-lqty"); + vm.expectRevert("Governance: must-allocate-zero"); governance.withdrawLQTY(1e18); vm.warp(block.timestamp + EPOCH_DURATION - governance.secondsWithinEpoch() - 1); + // user can only unallocate after voting cutoff initiatives[0] = baseInitiative1; - deltaLQTYVotes[0] = 1e18; - // should only allow for unallocating votes or allocating vetos after the epoch voting cutoff - vm.expectRevert("Governance: epoch-voting-cutoff"); - governance.allocateLQTY(initiatives, deltaLQTYVotes, deltaLQTYVetos); - - initiatives[0] = baseInitiative1; - deltaLQTYVotes[0] = -1e18; - governance.allocateLQTY(initiatives, deltaLQTYVotes, deltaLQTYVetos); + deltaLQTYVotes[0] = 0; + governance.allocateLQTY(initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos); (allocatedLQTY,) = governance.userStates(user2); assertEq(allocatedLQTY, 0); (countedVoteLQTY,) = governance.globalState(); + console.log("countedVoteLQTY: ", countedVoteLQTY); assertEq(countedVoteLQTY, 1e18); - (voteLQTY, vetoLQTY, averageStakingTimestampVoteLQTY, averageStakingTimestampVetoLQTY, counted) = + (voteLQTY, vetoLQTY, averageStakingTimestampVoteLQTY, averageStakingTimestampVetoLQTY,) = governance.initiativeStates(baseInitiative1); assertEq(voteLQTY, 1e18); assertEq(vetoLQTY, 0); assertEq(averageStakingTimestampVoteLQTY, averageStakingTimestampUser); assertEq(averageStakingTimestampVetoLQTY, 0); - assertEq(counted, 1); vm.stopPrank(); } + function test_allocateLQTY_after_cutoff() public { + vm.startPrank(user); + + address userProxy = governance.deployUserProxy(); + + lqty.approve(address(userProxy), 1e18); + governance.depositLQTY(1e18); + + (uint88 allocatedLQTY, uint120 averageStakingTimestampUser) = governance.userStates(user); + assertEq(allocatedLQTY, 0); + (uint88 countedVoteLQTY,) = governance.globalState(); + assertEq(countedVoteLQTY, 0); + + address[] memory initiatives = new address[](1); + initiatives[0] = baseInitiative1; + int88[] memory deltaLQTYVotes = new int88[](1); + deltaLQTYVotes[0] = 1e18; //this should be 0 + int88[] memory deltaLQTYVetos = new int88[](1); + + // should revert if the initiative has been registered in the current epoch + vm.expectRevert("Governance: active-vote-fsm"); + governance.allocateLQTY(initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos); + + vm.warp(block.timestamp + governance.EPOCH_DURATION()); + governance.allocateLQTY(initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos); + + (allocatedLQTY,) = governance.userStates(user); + assertEq(allocatedLQTY, 1e18); + + ( + uint88 voteLQTY, + uint88 vetoLQTY, + uint120 averageStakingTimestampVoteLQTY, + uint120 averageStakingTimestampVetoLQTY, + ) = governance.initiativeStates(baseInitiative1); + // should update the `voteLQTY` and `vetoLQTY` variables + assertEq(voteLQTY, 1e18); + assertEq(vetoLQTY, 0); + // should update the average staking timestamp for the initiative based on the average staking timestamp of the user's + // voting and vetoing LQTY + assertEq(averageStakingTimestampVoteLQTY, (block.timestamp - governance.EPOCH_DURATION()) * 1e26, "TS"); + assertEq(averageStakingTimestampVoteLQTY, averageStakingTimestampUser); + assertEq(averageStakingTimestampVetoLQTY, 0); + // should remove or add the initiatives voting LQTY from the counter + + (countedVoteLQTY,) = governance.globalState(); + assertEq(countedVoteLQTY, 1e18); + + uint16 atEpoch; + (voteLQTY, vetoLQTY, atEpoch) = governance.lqtyAllocatedByUserToInitiative(user, baseInitiative1); + // should update the allocation mapping from user to initiative + assertEq(voteLQTY, 1e18); + assertEq(vetoLQTY, 0); + assertEq(atEpoch, governance.epoch()); + assertGt(atEpoch, 0); + + // should snapshot the global and initiatives votes if there hasn't been a snapshot in the current epoch yet + (, uint16 forEpoch) = governance.votesSnapshot(); + assertEq(forEpoch, governance.epoch() - 1); + (, forEpoch,,) = governance.votesForInitiativeSnapshot(baseInitiative1); + assertEq(forEpoch, governance.epoch() - 1); + + vm.stopPrank(); + + vm.warp(block.timestamp + governance.EPOCH_DURATION()); + + vm.startPrank(user2); + + address user2Proxy = governance.deployUserProxy(); + + lqty.approve(address(user2Proxy), 1e18); + governance.depositLQTY(1e18); + + (, uint120 averageAge) = governance.userStates(user2); + assertEq(governance.lqtyToVotes(1e18, uint120(block.timestamp) * uint120(1e26), averageAge), 0); + + deltaLQTYVetos[0] = 1e18; + + vm.expectRevert("Governance: vote-and-veto"); + governance.allocateLQTY(initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos); + + deltaLQTYVetos[0] = 0; + + governance.allocateLQTY(initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos); + + // should update the user's allocated LQTY balance + (allocatedLQTY,) = governance.userStates(user2); + assertEq(allocatedLQTY, 1e18); + + (voteLQTY, vetoLQTY, averageStakingTimestampVoteLQTY, averageStakingTimestampVetoLQTY,) = + governance.initiativeStates(baseInitiative1); + assertEq(voteLQTY, 2e18); + assertEq(vetoLQTY, 0); + assertEq(averageStakingTimestampVoteLQTY, (block.timestamp - governance.EPOCH_DURATION()) * 1e26, "TS 2"); + assertGt(averageStakingTimestampVoteLQTY, averageStakingTimestampUser); + assertEq(averageStakingTimestampVetoLQTY, 0); + + // should revert if the user doesn't have enough unallocated LQTY available + vm.expectRevert("Governance: must-allocate-zero"); + governance.withdrawLQTY(1e18); + + vm.warp(block.timestamp + EPOCH_DURATION - governance.secondsWithinEpoch() - 1); + + initiatives[0] = baseInitiative1; + deltaLQTYVotes[0] = 1e18; + // should only allow for unallocating votes or allocating vetos after the epoch voting cutoff + // vm.expectRevert("Governance: epoch-voting-cutoff"); + governance.allocateLQTY(initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos); + (allocatedLQTY,) = governance.userStates(msg.sender); + // this no longer reverts but the user allocation doesn't increase either way + assertEq(allocatedLQTY, 0, "user can allocate after voting cutoff"); + + vm.stopPrank(); + } + + function test_allocate_unregister() public {} + function test_allocateLQTY_multiple() public { vm.startPrank(user); @@ -885,14 +1279,14 @@ contract GovernanceTest is Test { address[] memory initiatives = new address[](2); initiatives[0] = baseInitiative1; initiatives[1] = baseInitiative2; - int176[] memory deltaLQTYVotes = new int176[](2); + int88[] memory deltaLQTYVotes = new int88[](2); deltaLQTYVotes[0] = 1e18; deltaLQTYVotes[1] = 1e18; - int176[] memory deltaLQTYVetos = new int176[](2); + int88[] memory deltaLQTYVetos = new int88[](2); - vm.warp(block.timestamp + 365 days); + vm.warp(block.timestamp + governance.EPOCH_DURATION()); - governance.allocateLQTY(initiatives, deltaLQTYVotes, deltaLQTYVetos); + governance.allocateLQTY(initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos); (allocatedLQTY,) = governance.userStates(user); assertEq(allocatedLQTY, 2e18); @@ -902,21 +1296,20 @@ contract GovernanceTest is Test { ( uint88 voteLQTY, uint88 vetoLQTY, - uint32 averageStakingTimestampVoteLQTY, - uint32 averageStakingTimestampVetoLQTY, - uint16 counted + uint120 averageStakingTimestampVoteLQTY, + uint120 averageStakingTimestampVetoLQTY, ) = governance.initiativeStates(baseInitiative1); assertEq(voteLQTY, 1e18); assertEq(vetoLQTY, 0); - (voteLQTY, vetoLQTY, averageStakingTimestampVoteLQTY, averageStakingTimestampVetoLQTY, counted) = + (voteLQTY, vetoLQTY, averageStakingTimestampVoteLQTY, averageStakingTimestampVetoLQTY,) = governance.initiativeStates(baseInitiative2); assertEq(voteLQTY, 1e18); assertEq(vetoLQTY, 0); } function test_allocateLQTY_fuzz_deltaLQTYVotes(uint88 _deltaLQTYVotes) public { - vm.assume(_deltaLQTYVotes > 0); + vm.assume(_deltaLQTYVotes > 0 && _deltaLQTYVotes < uint88(type(int88).max)); vm.startPrank(user); @@ -928,19 +1321,19 @@ contract GovernanceTest is Test { address[] memory initiatives = new address[](1); initiatives[0] = baseInitiative1; - int176[] memory deltaLQTYVotes = new int176[](1); - deltaLQTYVotes[0] = int176(uint176(_deltaLQTYVotes)); - int176[] memory deltaLQTYVetos = new int176[](1); + int88[] memory deltaLQTYVotes = new int88[](1); + deltaLQTYVotes[0] = int88(uint88(_deltaLQTYVotes)); + int88[] memory deltaLQTYVetos = new int88[](1); - vm.warp(block.timestamp + 365 days); + vm.warp(block.timestamp + governance.EPOCH_DURATION()); - governance.allocateLQTY(initiatives, deltaLQTYVotes, deltaLQTYVetos); + governance.allocateLQTY(initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos); vm.stopPrank(); } function test_allocateLQTY_fuzz_deltaLQTYVetos(uint88 _deltaLQTYVetos) public { - vm.assume(_deltaLQTYVetos > 0); + vm.assume(_deltaLQTYVetos > 0 && _deltaLQTYVetos < uint88(type(int88).max)); vm.startPrank(user); @@ -952,17 +1345,18 @@ contract GovernanceTest is Test { address[] memory initiatives = new address[](1); initiatives[0] = baseInitiative1; - int176[] memory deltaLQTYVotes = new int176[](1); - int176[] memory deltaLQTYVetos = new int176[](1); - deltaLQTYVetos[0] = int176(uint176(_deltaLQTYVetos)); + int88[] memory deltaLQTYVotes = new int88[](1); + int88[] memory deltaLQTYVetos = new int88[](1); + deltaLQTYVetos[0] = int88(uint88(_deltaLQTYVetos)); - vm.warp(block.timestamp + 365 days); - - governance.allocateLQTY(initiatives, deltaLQTYVotes, deltaLQTYVetos); + vm.warp(block.timestamp + governance.EPOCH_DURATION()); + governance.allocateLQTY(initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos); + /// @audit needs overflow tests!! vm.stopPrank(); } + // forge test --match-test test_claimForInitiative -vv function test_claimForInitiative() public { vm.startPrank(user); @@ -972,7 +1366,7 @@ contract GovernanceTest is Test { lqty.approve(address(userProxy), 1000e18); governance.depositLQTY(1000e18); - vm.warp(block.timestamp + 365 days); + vm.warp(block.timestamp + governance.EPOCH_DURATION()); vm.stopPrank(); @@ -985,23 +1379,23 @@ contract GovernanceTest is Test { address[] memory initiatives = new address[](2); initiatives[0] = baseInitiative1; initiatives[1] = baseInitiative2; - int176[] memory deltaVoteLQTY = new int176[](2); + int88[] memory deltaVoteLQTY = new int88[](2); deltaVoteLQTY[0] = 500e18; deltaVoteLQTY[1] = 500e18; - int176[] memory deltaVetoLQTY = new int176[](2); - governance.allocateLQTY(initiatives, deltaVoteLQTY, deltaVetoLQTY); + int88[] memory deltaVetoLQTY = new int88[](2); + governance.allocateLQTY(initiatives, initiatives, deltaVoteLQTY, deltaVetoLQTY); (uint88 allocatedLQTY,) = governance.userStates(user); assertEq(allocatedLQTY, 1000e18); vm.warp(block.timestamp + governance.EPOCH_DURATION() + 1); // should compute the claim and transfer it to the initiative - assertEq(governance.claimForInitiative(baseInitiative1), 5000e18); - governance.claimForInitiative(baseInitiative1); + + assertEq(governance.claimForInitiative(baseInitiative1), 5000e18, "first claim"); + // 2nd claim = 0 assertEq(governance.claimForInitiative(baseInitiative1), 0); - assertEq(lusd.balanceOf(baseInitiative1), 5000e18); - assertEq(governance.claimForInitiative(baseInitiative2), 5000e18); + assertEq(governance.claimForInitiative(baseInitiative2), 5000e18, "first claim 2"); assertEq(governance.claimForInitiative(baseInitiative2), 0); assertEq(lusd.balanceOf(baseInitiative2), 5000e18); @@ -1014,20 +1408,106 @@ contract GovernanceTest is Test { vm.startPrank(user); + console.log("here 1"); initiatives[0] = baseInitiative1; initiatives[1] = baseInitiative2; deltaVoteLQTY[0] = 495e18; - deltaVoteLQTY[1] = -495e18; - governance.allocateLQTY(initiatives, deltaVoteLQTY, deltaVetoLQTY); + // deltaVoteLQTY[1] = -495e18; + deltaVoteLQTY[1] = 0; // @audit user can't deallocate because votes already get reset + governance.allocateLQTY(initiatives, initiatives, deltaVoteLQTY, deltaVetoLQTY); + console.log("here 2"); vm.warp(block.timestamp + governance.EPOCH_DURATION() + 1); assertEq(governance.claimForInitiative(baseInitiative1), 10000e18); - // should not allow double claiming assertEq(governance.claimForInitiative(baseInitiative1), 0); assertEq(lusd.balanceOf(baseInitiative1), 15000e18); + (Governance.InitiativeStatus status,, uint256 claimable) = governance.getInitiativeState(baseInitiative2); + console.log("res", uint8(status)); + console.log("claimable", claimable); + (uint224 votes,,, uint224 vetos) = governance.votesForInitiativeSnapshot(baseInitiative2); + console.log("snapshot votes", votes); + console.log("snapshot vetos", vetos); + + console.log("governance.getLatestVotingThreshold()", governance.getLatestVotingThreshold()); + assertEq(governance.claimForInitiative(baseInitiative2), 0, "zero 2"); + assertEq(governance.claimForInitiative(baseInitiative2), 0, "zero 3"); + + assertEq(lusd.balanceOf(baseInitiative2), 5000e18, "zero bal"); + + vm.stopPrank(); + } + + // this shouldn't happen + function off_claimForInitiativeEOA() public { + address EOAInitiative = address(0xbeef); + + vm.startPrank(user); + + // deploy + address userProxy = governance.deployUserProxy(); + + lqty.approve(address(userProxy), 1000e18); + governance.depositLQTY(1000e18); + + vm.warp(block.timestamp + governance.EPOCH_DURATION()); + + vm.stopPrank(); + + vm.startPrank(lusdHolder); + lusd.transfer(address(governance), 10000e18); + vm.stopPrank(); + + vm.startPrank(user); + + address[] memory initiatives = new address[](2); + initiatives[0] = EOAInitiative; // attempt for an EOA + initiatives[1] = baseInitiative2; + int88[] memory deltaVoteLQTY = new int88[](2); + deltaVoteLQTY[0] = 500e18; + deltaVoteLQTY[1] = 500e18; + int88[] memory deltaVetoLQTY = new int88[](2); + governance.allocateLQTY(initiatives, initiatives, deltaVoteLQTY, deltaVetoLQTY); + (uint88 allocatedLQTY,) = governance.userStates(user); + assertEq(allocatedLQTY, 1000e18); + + vm.warp(block.timestamp + governance.EPOCH_DURATION() + 1); + + // should compute the claim and transfer it to the initiative + assertEq(governance.claimForInitiative(EOAInitiative), 5000e18); + governance.claimForInitiative(EOAInitiative); + assertEq(governance.claimForInitiative(EOAInitiative), 0); + assertEq(lusd.balanceOf(EOAInitiative), 5000e18); + + assertEq(governance.claimForInitiative(baseInitiative2), 5000e18); + assertEq(governance.claimForInitiative(baseInitiative2), 0); + + assertEq(lusd.balanceOf(baseInitiative2), 5000e18); + + vm.stopPrank(); + + vm.startPrank(lusdHolder); + lusd.transfer(address(governance), 10000e18); + vm.stopPrank(); + + vm.startPrank(user); + + initiatives[0] = EOAInitiative; + initiatives[1] = baseInitiative2; + deltaVoteLQTY[0] = 495e18; + deltaVoteLQTY[1] = -495e18; + governance.allocateLQTY(initiatives, initiatives, deltaVoteLQTY, deltaVetoLQTY); + + vm.warp(block.timestamp + governance.EPOCH_DURATION() + 1); + + assertEq(governance.claimForInitiative(EOAInitiative), 10000e18); + // should not allow double claiming + assertEq(governance.claimForInitiative(EOAInitiative), 0); + + assertEq(lusd.balanceOf(EOAInitiative), 15000e18); + assertEq(governance.claimForInitiative(baseInitiative2), 0); assertEq(governance.claimForInitiative(baseInitiative2), 0); @@ -1039,7 +1519,7 @@ contract GovernanceTest is Test { function test_multicall() public { vm.startPrank(user); - vm.warp(block.timestamp + 365 days); + vm.warp(block.timestamp + governance.EPOCH_DURATION()); uint88 lqtyAmount = 1000e18; uint256 lqtyBalance = lqty.balanceOf(user); @@ -1049,27 +1529,27 @@ contract GovernanceTest is Test { bytes[] memory data = new bytes[](7); address[] memory initiatives = new address[](1); initiatives[0] = baseInitiative1; - int176[] memory deltaVoteLQTY = new int176[](1); - deltaVoteLQTY[0] = int176(uint176(lqtyAmount)); - int176[] memory deltaVetoLQTY = new int176[](1); + int88[] memory deltaVoteLQTY = new int88[](1); + deltaVoteLQTY[0] = int88(uint88(lqtyAmount)); + int88[] memory deltaVetoLQTY = new int88[](1); - int176[] memory deltaVoteLQTY_ = new int176[](1); - deltaVoteLQTY_[0] = -int176(uint176(lqtyAmount)); + int88[] memory deltaVoteLQTY_ = new int88[](1); + deltaVoteLQTY_[0] = 0; data[0] = abi.encodeWithSignature("deployUserProxy()"); data[1] = abi.encodeWithSignature("depositLQTY(uint88)", lqtyAmount); data[2] = abi.encodeWithSignature( - "allocateLQTY(address[],int176[],int176[])", initiatives, deltaVoteLQTY, deltaVetoLQTY + "allocateLQTY(address[],address[],int88[],int88[])", initiatives, initiatives, deltaVoteLQTY, deltaVetoLQTY ); data[3] = abi.encodeWithSignature("userStates(address)", user); data[4] = abi.encodeWithSignature("snapshotVotesForInitiative(address)", baseInitiative1); data[5] = abi.encodeWithSignature( - "allocateLQTY(address[],int176[],int176[])", initiatives, deltaVoteLQTY_, deltaVetoLQTY + "allocateLQTY(address[],address[],int88[],int88[])", initiatives, initiatives, deltaVoteLQTY_, deltaVetoLQTY ); data[6] = abi.encodeWithSignature("withdrawLQTY(uint88)", lqtyAmount); bytes[] memory response = governance.multicall(data); - (uint88 allocatedLQTY,) = abi.decode(response[3], (uint88, uint32)); + (uint88 allocatedLQTY,) = abi.decode(response[3], (uint88, uint120)); assertEq(allocatedLQTY, lqtyAmount); (IGovernance.VoteSnapshot memory votes, IGovernance.InitiativeVoteSnapshot memory votesForInitiative) = abi.decode(response[4], (IGovernance.VoteSnapshot, IGovernance.InitiativeVoteSnapshot)); @@ -1106,19 +1586,19 @@ contract GovernanceTest is Test { lqty.approve(address(userProxy), 1e18); governance.depositLQTY(1e18); - vm.warp(block.timestamp + 365 days); + vm.warp(block.timestamp + governance.EPOCH_DURATION()); governance.registerInitiative(address(mockInitiative)); uint16 atEpoch = governance.registeredInitiatives(address(mockInitiative)); assertEq(atEpoch, governance.epoch()); - vm.warp(block.timestamp + 365 days); + vm.warp(block.timestamp + governance.EPOCH_DURATION()); address[] memory initiatives = new address[](1); initiatives[0] = address(mockInitiative); - int176[] memory deltaLQTYVotes = new int176[](1); - int176[] memory deltaLQTYVetos = new int176[](1); - governance.allocateLQTY(initiatives, deltaLQTYVotes, deltaLQTYVetos); + int88[] memory deltaLQTYVotes = new int88[](1); + int88[] memory deltaLQTYVetos = new int88[](1); + governance.allocateLQTY(initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos); // check that votingThreshold is is high enough such that MIN_CLAIM is met snapshot = IGovernance.VoteSnapshot(1, governance.epoch() - 1); @@ -1132,7 +1612,7 @@ contract GovernanceTest is Test { assertEq(forEpoch, governance.epoch() - 1); IGovernance.InitiativeVoteSnapshot memory initiativeSnapshot = - IGovernance.InitiativeVoteSnapshot(1, governance.epoch() - 1, governance.epoch() - 1); + IGovernance.InitiativeVoteSnapshot(1, governance.epoch() - 1, governance.epoch() - 1, 0); vm.store( address(governance), keccak256(abi.encode(address(mockInitiative), uint256(3))), @@ -1144,7 +1624,7 @@ contract GovernanceTest is Test { ) ) ); - (uint224 votes_, uint16 forEpoch_, uint16 lastCountedEpoch) = + (uint224 votes_, uint16 forEpoch_, uint16 lastCountedEpoch,) = governance.votesForInitiativeSnapshot(address(mockInitiative)); assertEq(votes_, 1); assertEq(forEpoch_, governance.epoch() - 1); @@ -1152,7 +1632,9 @@ contract GovernanceTest is Test { governance.claimForInitiative(address(mockInitiative)); - initiativeSnapshot = IGovernance.InitiativeVoteSnapshot(0, governance.epoch() - 1, 0); + vm.warp(block.timestamp + governance.EPOCH_DURATION()); + + initiativeSnapshot = IGovernance.InitiativeVoteSnapshot(0, governance.epoch() - 1, 0, 0); vm.store( address(governance), keccak256(abi.encode(address(mockInitiative), uint256(3))), @@ -1164,11 +1646,929 @@ contract GovernanceTest is Test { ) ) ); - (votes_, forEpoch_, lastCountedEpoch) = governance.votesForInitiativeSnapshot(address(mockInitiative)); - assertEq(votes_, 0); - assertEq(forEpoch_, governance.epoch() - 1); - assertEq(lastCountedEpoch, 0); + (votes_, forEpoch_, lastCountedEpoch,) = governance.votesForInitiativeSnapshot(address(mockInitiative)); + assertEq(votes_, 0, "votes"); + assertEq(forEpoch_, governance.epoch() - 1, "forEpoch_"); + assertEq(lastCountedEpoch, 0, "lastCountedEpoch"); + + vm.warp(block.timestamp + governance.EPOCH_DURATION() * 4); governance.unregisterInitiative(address(mockInitiative)); } + + // CS exploit PoC + function test_allocateLQTY_overflow() public { + vm.startPrank(user); + + address[] memory initiatives = new address[](2); + initiatives[0] = baseInitiative1; + initiatives[1] = baseInitiative2; + + int88[] memory deltaLQTYVotes = new int88[](2); + deltaLQTYVotes[0] = 0; + deltaLQTYVotes[1] = type(int88).max; + int88[] memory deltaLQTYVetos = new int88[](2); + deltaLQTYVetos[0] = 0; + deltaLQTYVetos[1] = 0; + + vm.warp(block.timestamp + governance.EPOCH_DURATION()); + vm.expectRevert("Governance: insufficient-or-allocated-lqty"); + governance.allocateLQTY(initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos); + + deltaLQTYVotes[0] = 0; + deltaLQTYVotes[1] = 0; + deltaLQTYVetos[0] = 0; + deltaLQTYVetos[1] = type(int88).max; + + vm.expectRevert("Governance: insufficient-or-allocated-lqty"); + governance.allocateLQTY(initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos); + + vm.stopPrank(); + } + + function test_voting_power_increase() public { + // =========== epoch 1 ================== + governance = new Governance( + address(lqty), + address(lusd), + address(stakingV1), + address(lusd), + IGovernance.Configuration({ + registrationFee: REGISTRATION_FEE, + registrationThresholdFactor: REGISTRATION_THRESHOLD_FACTOR, + unregistrationThresholdFactor: UNREGISTRATION_THRESHOLD_FACTOR, + registrationWarmUpPeriod: REGISTRATION_WARM_UP_PERIOD, + unregistrationAfterEpochs: UNREGISTRATION_AFTER_EPOCHS, + votingThresholdFactor: VOTING_THRESHOLD_FACTOR, + minClaim: MIN_CLAIM, + minAccrual: MIN_ACCRUAL, + epochStart: uint32(block.timestamp), + epochDuration: EPOCH_DURATION, + epochVotingCutoff: EPOCH_VOTING_CUTOFF + }), + address(this), + initialInitiatives + ); + + // 1. user stakes liquity + uint88 lqtyAmount = 1e18; + _stakeLQTY(user, lqtyAmount); + + (uint88 allocatedLQTY0, uint120 averageStakingTimestamp0) = governance.userStates(user); + uint240 currentUserPower0 = + governance.lqtyToVotes(allocatedLQTY0, uint120(block.timestamp) * uint120(1e26), averageStakingTimestamp0); + + (uint88 voteLQTY0,, uint120 averageStakingTimestampVoteLQTY0,,) = governance.initiativeStates(baseInitiative1); + uint240 currentInitiativePower0 = governance.lqtyToVotes( + voteLQTY0, uint120(block.timestamp) * uint120(1e26), averageStakingTimestampVoteLQTY0 + ); + + // (uint224 votes, uint16 forEpoch,,) = governance.votesForInitiativeSnapshot(baseInitiative1); + // console2.log("votes0: ", votes); + + // =========== epoch 2 ================== + // 2. user allocates in epoch 2 for initiative to be active + vm.warp(block.timestamp + EPOCH_DURATION); // warp to second epoch + + _allocateLQTY(user, lqtyAmount); + + // check user voting power for the current epoch + (uint88 allocatedLQTY1, uint120 averageStakingTimestamp1) = governance.userStates(user); + uint240 currentUserPower1 = + governance.lqtyToVotes(allocatedLQTY1, uint120(block.timestamp) * uint120(1e26), averageStakingTimestamp1); + // user's allocated lqty should immediately increase their voting power + assertGt(currentUserPower1, 0, "current user voting power is 0"); + + // check initiative voting power for the current epoch + (uint88 voteLQTY1,, uint120 averageStakingTimestampVoteLQTY1,,) = governance.initiativeStates(baseInitiative1); + uint240 currentInitiativePower1 = governance.lqtyToVotes( + voteLQTY1, uint120(block.timestamp) * uint120(1e26), averageStakingTimestampVoteLQTY1 + ); + assertGt(currentInitiativePower1, 0, "current initiative voting power is 0"); + assertEq(currentUserPower1, currentInitiativePower1, "initiative and user voting power should be equal"); + + // (uint224 votes, uint16 forEpoch,,) = governance.votesForInitiativeSnapshot(baseInitiative1); + + // =========== epoch 2 (end) ================== + // 3. warp to end of epoch 2 to see increase in voting power + // NOTE: voting power increases after any amount of time because the block.timestamp passed into vote power calculation changes + vm.warp(block.timestamp + EPOCH_DURATION - 1); + governance.snapshotVotesForInitiative(baseInitiative1); + + // user voting power should increase over a given chunk of time + (uint88 allocatedLQTY2, uint120 averageStakingTimestamp2) = governance.userStates(user); + uint240 currentUserPower2 = + governance.lqtyToVotes(allocatedLQTY2, uint120(block.timestamp) * uint120(1e26), averageStakingTimestamp2); + assertGt(currentUserPower2, currentUserPower1); + + // initiative voting power should increase over a given chunk of time + (uint88 voteLQTY2,, uint120 averageStakingTimestampVoteLQTY2,,) = governance.initiativeStates(baseInitiative1); + uint240 currentInitiativePower2 = governance.lqtyToVotes( + voteLQTY2, uint120(block.timestamp) * uint120(1e26), averageStakingTimestampVoteLQTY2 + ); + assertEq( + currentUserPower2, currentInitiativePower2, "user power and initiative power should increase by same amount" + ); + + // votes should only get counted in the next epoch after they were allocated + (uint224 votes, uint16 forEpoch,,) = governance.votesForInitiativeSnapshot(baseInitiative1); + assertEq(votes, 0, "votes get counted in epoch that they were allocated"); + + // =========== epoch 3 ================== + // 4. warp to third epoch and check voting power + vm.warp(block.timestamp + 1); + governance.snapshotVotesForInitiative(baseInitiative1); + + // user voting power should increase + (uint88 allocatedLQTY3, uint120 averageStakingTimestamp3) = governance.userStates(user); + uint240 currentUserPower3 = + governance.lqtyToVotes(allocatedLQTY3, uint120(block.timestamp) * uint120(1e26), averageStakingTimestamp3); + + // votes should match the voting power for the initiative and subsequently the user since they're the only one allocated + (uint88 voteLQTY3,, uint120 averageStakingTimestampVoteLQTY3,,) = governance.initiativeStates(baseInitiative1); + uint240 currentInitiativePower3 = governance.lqtyToVotes( + voteLQTY3, uint120(block.timestamp) * uint120(1e26), averageStakingTimestampVoteLQTY3 + ); + + // votes should be counted in this epoch + (votes, forEpoch,,) = governance.votesForInitiativeSnapshot(baseInitiative1); + assertEq(votes, currentUserPower3, "initiative votes != user allocated lqty power"); + assertEq(votes, currentInitiativePower3, "initiative votes != iniative allocated lqty power"); + + // TODO: check the increase in votes at the end of this epoch + vm.warp(block.timestamp + EPOCH_DURATION - 1); + governance.snapshotVotesForInitiative(baseInitiative1); + + (uint88 allocatedLQTY4, uint120 averageStakingTimestamp4) = governance.userStates(user); + uint240 currentUserPower4 = + governance.lqtyToVotes(allocatedLQTY4, uint120(block.timestamp) * uint120(1e26), averageStakingTimestamp4); + + (uint88 voteLQTY4,, uint120 averageStakingTimestampVoteLQTY4,,) = governance.initiativeStates(baseInitiative1); + uint240 currentInitiativePower4 = governance.lqtyToVotes( + voteLQTY4, uint120(block.timestamp) * uint120(1e26), averageStakingTimestampVoteLQTY4 + ); + + // checking if snapshotting at the end of an epoch increases the voting power + (uint224 votes2,,,) = governance.votesForInitiativeSnapshot(baseInitiative1); + assertEq(votes, votes2, "votes for an initiative snapshot increase in same epoch"); + + // =========== epoch 3 (end) ================== + } + + // increase in user voting power and initiative voting power should be equivalent + function test_voting_power_in_same_epoch_as_allocation() public { + // =========== epoch 1 ================== + governance = new Governance( + address(lqty), + address(lusd), + address(stakingV1), + address(lusd), + IGovernance.Configuration({ + registrationFee: REGISTRATION_FEE, + registrationThresholdFactor: REGISTRATION_THRESHOLD_FACTOR, + unregistrationThresholdFactor: UNREGISTRATION_THRESHOLD_FACTOR, + registrationWarmUpPeriod: REGISTRATION_WARM_UP_PERIOD, + unregistrationAfterEpochs: UNREGISTRATION_AFTER_EPOCHS, + votingThresholdFactor: VOTING_THRESHOLD_FACTOR, + minClaim: MIN_CLAIM, + minAccrual: MIN_ACCRUAL, + epochStart: uint32(block.timestamp), + epochDuration: EPOCH_DURATION, + epochVotingCutoff: EPOCH_VOTING_CUTOFF + }), + address(this), + initialInitiatives + ); + + // 1. user stakes liquity + uint88 lqtyAmount = 1e18; + _stakeLQTY(user, lqtyAmount); + + // =========== epoch 2 ================== + // 2. user allocates in epoch 2 for initiative to be active + vm.warp(block.timestamp + EPOCH_DURATION); // warp to second epoch + assertEq(2, governance.epoch(), "not in epoch 2"); + + // check user voting power before allocation at epoch start + (uint88 allocatedLQTY0, uint120 averageStakingTimestamp0) = governance.userStates(user); + uint240 currentUserPower0 = + governance.lqtyToVotes(allocatedLQTY0, uint120(block.timestamp) * uint120(1e26), averageStakingTimestamp0); + assertEq(currentUserPower0, 0, "user has voting power > 0"); + + // check initiative voting power before allocation at epoch start + (uint88 voteLQTY0,, uint120 averageStakingTimestampVoteLQTY0,,) = governance.initiativeStates(baseInitiative1); + uint240 currentInitiativePower0 = governance.lqtyToVotes( + voteLQTY0, uint120(block.timestamp) * uint120(1e26), averageStakingTimestampVoteLQTY0 + ); + assertEq(currentInitiativePower0, 0, "current initiative voting power is > 0"); + + _allocateLQTY(user, lqtyAmount); + + vm.warp(block.timestamp + (EPOCH_DURATION - 1)); // warp to end of second epoch + assertEq(2, governance.epoch(), "not in epoch 2"); + + // check user voting power after allocation at epoch end + (uint88 allocatedLQTY1, uint120 averageStakingTimestamp1) = governance.userStates(user); + uint240 currentUserPower1 = + governance.lqtyToVotes(allocatedLQTY1, uint120(block.timestamp) * uint120(1e26), averageStakingTimestamp1); + assertGt(currentUserPower1, 0, "user has no voting power after allocation"); + + // check initiative voting power after allocation at epoch end + (uint88 voteLQTY1,, uint120 averageStakingTimestampVoteLQTY1,,) = governance.initiativeStates(baseInitiative1); + uint240 currentInitiativePower1 = governance.lqtyToVotes( + voteLQTY1, uint120(block.timestamp) * uint120(1e26), averageStakingTimestampVoteLQTY1 + ); + assertGt(currentInitiativePower1, 0, "initiative has no voting power after allocation"); + + // check that user and initiative voting power is equivalent at epoch end + assertEq(currentUserPower1, currentInitiativePower1, "currentUserPower1 != currentInitiativePower1"); + + vm.warp(block.timestamp + (EPOCH_DURATION * 40)); + assertEq(42, governance.epoch(), "not in epoch 42"); + + // get user voting power after multiple epochs + (uint88 allocatedLQTY2, uint120 averageStakingTimestamp2) = governance.userStates(user); + uint240 currentUserPower2 = + governance.lqtyToVotes(allocatedLQTY2, uint120(block.timestamp) * uint120(1e26), averageStakingTimestamp2); + assertGt(currentUserPower2, currentUserPower1, "user voting power doesn't increase"); + + // get initiative voting power after multiple epochs + (uint88 voteLQTY2,, uint120 averageStakingTimestampVoteLQTY2,,) = governance.initiativeStates(baseInitiative1); + uint240 currentInitiativePower2 = governance.lqtyToVotes( + voteLQTY2, uint120(block.timestamp) * uint120(1e26), averageStakingTimestampVoteLQTY2 + ); + assertGt(currentInitiativePower2, currentInitiativePower1, "initiative voting power doesn't increase"); + + // check that initiative and user voting always track each other + assertEq(currentUserPower2, currentInitiativePower2, "voting powers don't match"); + } + + // initiative's increase in voting power after a snapshot is the same as the increase in power calculated using the initiative's allocation at the start and end of the epoch + // | deposit | allocate | snapshot | + // |====== epoch 1=====|==== epoch 2 =====|==== epoch 3 ====| + function test_voting_power_increase_in_an_epoch() public { + // =========== epoch 1 ================== + governance = new Governance( + address(lqty), + address(lusd), + address(stakingV1), + address(lusd), + IGovernance.Configuration({ + registrationFee: REGISTRATION_FEE, + registrationThresholdFactor: REGISTRATION_THRESHOLD_FACTOR, + unregistrationThresholdFactor: UNREGISTRATION_THRESHOLD_FACTOR, + registrationWarmUpPeriod: REGISTRATION_WARM_UP_PERIOD, + unregistrationAfterEpochs: UNREGISTRATION_AFTER_EPOCHS, + votingThresholdFactor: VOTING_THRESHOLD_FACTOR, + minClaim: MIN_CLAIM, + minAccrual: MIN_ACCRUAL, + epochStart: uint32(block.timestamp), + epochDuration: EPOCH_DURATION, + epochVotingCutoff: EPOCH_VOTING_CUTOFF + }), + address(this), + initialInitiatives + ); + + // 1. user stakes lqty + uint88 lqtyAmount = 1e18; + _stakeLQTY(user, lqtyAmount); + + // =========== epoch 2 (start) ================== + // 2. user allocates in epoch 2 for initiative to be active + vm.warp(block.timestamp + EPOCH_DURATION); // warp to second epoch + + // get initiative voting power at start of epoch + (uint88 voteLQTY0,, uint120 averageStakingTimestampVoteLQTY0,,) = governance.initiativeStates(baseInitiative1); + uint240 currentInitiativePower0 = governance.lqtyToVotes( + voteLQTY0, uint120(block.timestamp) * uint120(1e26), averageStakingTimestampVoteLQTY0 + ); + assertEq(currentInitiativePower0, 0, "initiative voting power is > 0"); + + _allocateLQTY(user, lqtyAmount); + + // =========== epoch 3 ================== + // 3. warp to third epoch and check voting power + vm.warp(block.timestamp + EPOCH_DURATION); + governance.snapshotVotesForInitiative(baseInitiative1); + + // get initiative voting power at time of snapshot + (uint88 voteLQTY1,, uint120 averageStakingTimestampVoteLQTY1,,) = governance.initiativeStates(baseInitiative1); + uint240 currentInitiativePower1 = governance.lqtyToVotes( + voteLQTY1, uint120(block.timestamp) * uint120(1e26), averageStakingTimestampVoteLQTY1 + ); + assertGt(currentInitiativePower1, 0, "initiative voting power is 0"); + + uint240 deltaInitiativeVotingPower = currentInitiativePower1 - currentInitiativePower0; + + // 4. votes should be counted in this epoch + (uint224 votes,,,) = governance.votesForInitiativeSnapshot(baseInitiative1); + assertEq(votes, deltaInitiativeVotingPower, "voting power should increase by amount user allocated"); + } + + // checking that voting power calculated from lqtyAllocatedByUserToInitiative is equivalent to the voting power using values returned by userStates + function test_voting_power_lqtyAllocatedByUserToInitiative() public { + // =========== epoch 1 ================== + governance = new Governance( + address(lqty), + address(lusd), + address(stakingV1), + address(lusd), + IGovernance.Configuration({ + registrationFee: REGISTRATION_FEE, + registrationThresholdFactor: REGISTRATION_THRESHOLD_FACTOR, + unregistrationThresholdFactor: UNREGISTRATION_THRESHOLD_FACTOR, + registrationWarmUpPeriod: REGISTRATION_WARM_UP_PERIOD, + unregistrationAfterEpochs: UNREGISTRATION_AFTER_EPOCHS, + votingThresholdFactor: VOTING_THRESHOLD_FACTOR, + minClaim: MIN_CLAIM, + minAccrual: MIN_ACCRUAL, + epochStart: uint32(block.timestamp), + epochDuration: EPOCH_DURATION, + epochVotingCutoff: EPOCH_VOTING_CUTOFF + }), + address(this), + initialInitiatives + ); + + // 1. user stakes lqty + uint88 lqtyAmount = 1e18; + _stakeLQTY(user, lqtyAmount); + + // =========== epoch 2 (start) ================== + // 2. user allocates in epoch 2 for initiative to be active + vm.warp(block.timestamp + EPOCH_DURATION); // warp to second epoch + + _allocateLQTY(user, lqtyAmount); + + // get user voting power at start of epoch from lqtyAllocatedByUserToInitiative + (uint88 voteLQTY0,,) = governance.lqtyAllocatedByUserToInitiative(user, baseInitiative1); + (uint88 allocatedLQTY, uint120 averageStakingTimestamp) = governance.userStates(user); + uint240 currentInitiativePowerFrom1 = + governance.lqtyToVotes(voteLQTY0, uint120(block.timestamp) * uint120(1e26), averageStakingTimestamp); + uint240 currentInitiativePowerFrom2 = + governance.lqtyToVotes(allocatedLQTY, uint120(block.timestamp) * uint120(1e26), averageStakingTimestamp); + + assertEq( + currentInitiativePowerFrom1, + currentInitiativePowerFrom2, + "currentInitiativePowerFrom1 != currentInitiativePowerFrom2" + ); + } + + // checking if allocating to a different initiative in a different epoch modifies the avgStakingTimestamp + function test_average_timestamp() public { + // =========== epoch 1 ================== + governance = new Governance( + address(lqty), + address(lusd), + address(stakingV1), + address(lusd), + IGovernance.Configuration({ + registrationFee: REGISTRATION_FEE, + registrationThresholdFactor: REGISTRATION_THRESHOLD_FACTOR, + unregistrationThresholdFactor: UNREGISTRATION_THRESHOLD_FACTOR, + registrationWarmUpPeriod: REGISTRATION_WARM_UP_PERIOD, + unregistrationAfterEpochs: UNREGISTRATION_AFTER_EPOCHS, + votingThresholdFactor: VOTING_THRESHOLD_FACTOR, + minClaim: MIN_CLAIM, + minAccrual: MIN_ACCRUAL, + epochStart: uint32(block.timestamp), + epochDuration: EPOCH_DURATION, + epochVotingCutoff: EPOCH_VOTING_CUTOFF + }), + address(this), + initialInitiatives + ); + + // 1. user stakes lqty + uint88 lqtyAmount = 2e18; + _stakeLQTY(user, lqtyAmount); + + // =========== epoch 2 (start) ================== + // 2. user allocates in epoch 2 + vm.warp(block.timestamp + EPOCH_DURATION); // warp to second epoch + + // user allocates to baseInitiative1 + _allocateLQTY(user, 1e18); + + // get user voting power at start of epoch 2 from lqtyAllocatedByUserToInitiative + (, uint120 averageStakingTimestamp1) = governance.userStates(user); + + // =========== epoch 3 (start) ================== + // 3. user allocates to baseInitiative2 in epoch 3 + vm.warp(block.timestamp + EPOCH_DURATION); // warp to third epoch + + _allocateLQTYToInitiative(user, baseInitiative2, 1e18); + + // get user voting power at start of epoch 3 from lqtyAllocatedByUserToInitiative + (, uint120 averageStakingTimestamp2) = governance.userStates(user); + assertEq(averageStakingTimestamp1, averageStakingTimestamp2); + } + + // checking if allocating to same initiative modifies the average timestamp + // forge test --match-test test_average_timestamp_same_initiative -vv + function test_average_timestamp_same_initiative() public { + // =========== epoch 1 ================== + governance = new Governance( + address(lqty), + address(lusd), + address(stakingV1), + address(lusd), + IGovernance.Configuration({ + registrationFee: REGISTRATION_FEE, + registrationThresholdFactor: REGISTRATION_THRESHOLD_FACTOR, + unregistrationThresholdFactor: UNREGISTRATION_THRESHOLD_FACTOR, + registrationWarmUpPeriod: REGISTRATION_WARM_UP_PERIOD, + unregistrationAfterEpochs: UNREGISTRATION_AFTER_EPOCHS, + votingThresholdFactor: VOTING_THRESHOLD_FACTOR, + minClaim: MIN_CLAIM, + minAccrual: MIN_ACCRUAL, + epochStart: uint32(block.timestamp), + epochDuration: EPOCH_DURATION, + epochVotingCutoff: EPOCH_VOTING_CUTOFF + }), + address(this), + initialInitiatives + ); + + // 1. user stakes lqty + uint88 lqtyAmount = 2e18; + _stakeLQTY(user, lqtyAmount); + + // =========== epoch 2 (start) ================== + // 2. user allocates in epoch 2 + vm.warp(block.timestamp + EPOCH_DURATION); // warp to second epoch + + // user allocates to baseInitiative1 + _allocateLQTY(user, 1e18); + + // get user voting power at start of epoch 2 from lqtyAllocatedByUserToInitiative + (, uint120 averageStakingTimestamp1) = governance.userStates(user); + console2.log("averageStakingTimestamp1: ", averageStakingTimestamp1); + + // =========== epoch 3 (start) ================== + // 3. user allocates to baseInitiative1 in epoch 3 + vm.warp(block.timestamp + EPOCH_DURATION + 200); // warp to third epoch + + _allocateLQTY(user, 1e18); + + // get user voting power at start of epoch 3 from lqtyAllocatedByUserToInitiative + (, uint120 averageStakingTimestamp2) = governance.userStates(user); + assertEq(averageStakingTimestamp1, averageStakingTimestamp2, "average timestamps differ"); + } + + // checking if allocating to same initiative modifies the average timestamp + function test_average_timestamp_allocate_same_initiative_fuzz(uint256 allocateAmount) public { + // =========== epoch 1 ================== + governance = new Governance( + address(lqty), + address(lusd), + address(stakingV1), + address(lusd), + IGovernance.Configuration({ + registrationFee: REGISTRATION_FEE, + registrationThresholdFactor: REGISTRATION_THRESHOLD_FACTOR, + unregistrationThresholdFactor: UNREGISTRATION_THRESHOLD_FACTOR, + registrationWarmUpPeriod: REGISTRATION_WARM_UP_PERIOD, + unregistrationAfterEpochs: UNREGISTRATION_AFTER_EPOCHS, + votingThresholdFactor: VOTING_THRESHOLD_FACTOR, + minClaim: MIN_CLAIM, + minAccrual: MIN_ACCRUAL, + epochStart: uint32(block.timestamp), + epochDuration: EPOCH_DURATION, + epochVotingCutoff: EPOCH_VOTING_CUTOFF + }), + address(this), + initialInitiatives + ); + + // 1. user stakes lqty + uint88 lqtyAmount = uint88(allocateAmount % lqty.balanceOf(user)); + vm.assume(lqtyAmount > 0); + _stakeLQTY(user, lqtyAmount); + + // =========== epoch 2 (start) ================== + // 2. user allocates in epoch 2 + vm.warp(block.timestamp + EPOCH_DURATION); // warp to second epoch + + // clamp lqtyAmount by half of what user staked + uint88 lqtyAmount2 = uint88(bound(allocateAmount, 1, lqtyAmount)); + _allocateLQTY(user, lqtyAmount2); + + // get user voting power at start of epoch 2 from lqtyAllocatedByUserToInitiative + (, uint120 averageStakingTimestamp1) = governance.userStates(user); + + // =========== epoch 3 (start) ================== + // 3. user allocates to baseInitiative1 in epoch 3 + vm.warp(block.timestamp + EPOCH_DURATION); // warp to third epoch + + // clamp lqtyAmount by amount user staked + vm.assume(lqtyAmount > lqtyAmount2); + vm.assume(lqtyAmount - lqtyAmount2 > 1); + uint88 lqtyAmount3 = uint88(bound(allocateAmount, 1, lqtyAmount - lqtyAmount2)); + _allocateLQTY(user, lqtyAmount3); + + // get user voting power at start of epoch 3 from lqtyAllocatedByUserToInitiative + (, uint120 averageStakingTimestamp2) = governance.userStates(user); + assertEq( + averageStakingTimestamp1, averageStakingTimestamp2, "averageStakingTimestamp1 != averageStakingTimestamp2" + ); + } + + function test_voting_snapshot_start_vs_end_epoch() public { + // =========== epoch 1 ================== + governance = new Governance( + address(lqty), + address(lusd), + address(stakingV1), + address(lusd), + IGovernance.Configuration({ + registrationFee: REGISTRATION_FEE, + registrationThresholdFactor: REGISTRATION_THRESHOLD_FACTOR, + unregistrationThresholdFactor: UNREGISTRATION_THRESHOLD_FACTOR, + registrationWarmUpPeriod: REGISTRATION_WARM_UP_PERIOD, + unregistrationAfterEpochs: UNREGISTRATION_AFTER_EPOCHS, + votingThresholdFactor: VOTING_THRESHOLD_FACTOR, + minClaim: MIN_CLAIM, + minAccrual: MIN_ACCRUAL, + epochStart: uint32(block.timestamp), + epochDuration: EPOCH_DURATION, + epochVotingCutoff: EPOCH_VOTING_CUTOFF + }), + address(this), + initialInitiatives + ); + + // 1. user stakes lqty + uint88 lqtyAmount = 1e18; + _stakeLQTY(user, lqtyAmount); + + // =========== epoch 2 (start) ================== + // 2. user allocates in epoch 2 + vm.warp(block.timestamp + EPOCH_DURATION); // warp to second epoch + + // get initiative voting power at start of epoch + (uint88 voteLQTY0,, uint120 averageStakingTimestampVoteLQTY0,,) = governance.initiativeStates(baseInitiative1); + uint240 currentInitiativePower0 = governance.lqtyToVotes( + voteLQTY0, uint120(block.timestamp) * uint120(1e26), averageStakingTimestampVoteLQTY0 + ); + assertEq(currentInitiativePower0, 0, "initiative voting power is > 0"); + + _allocateLQTY(user, lqtyAmount); + + uint256 stateBeforeSnapshottingVotes = vm.snapshot(); + + // =========== epoch 3 (start) ================== + // 3a. warp to start of third epoch + vm.warp(block.timestamp + EPOCH_DURATION); + assertEq(3, governance.epoch(), "not in 3rd epoch"); + governance.snapshotVotesForInitiative(baseInitiative1); + + // get initiative voting power at start of epoch + (uint88 voteLQTY1,, uint120 averageStakingTimestampVoteLQTY1,,) = governance.initiativeStates(baseInitiative1); + uint240 currentInitiativePower1 = governance.lqtyToVotes( + voteLQTY1, uint120(block.timestamp) * uint120(1e26), averageStakingTimestampVoteLQTY1 + ); + + // 4a. votes from snapshotting at begging of epoch + (uint224 votes,,,) = governance.votesForInitiativeSnapshot(baseInitiative1); + + console2.log("currentInitiativePower1: ", currentInitiativePower1); + console2.log("votes: ", votes); + + // =========== epoch 3 (end) ================== + // revert EVM to state before snapshotting + vm.revertTo(stateBeforeSnapshottingVotes); + + // 3b. warp to end of third epoch + vm.warp(block.timestamp + (EPOCH_DURATION * 2) - 1); + assertEq(3, governance.epoch(), "not in 3rd epoch"); + governance.snapshotVotesForInitiative(baseInitiative1); + + // 4b. votes from snapshotting at end of epoch + (uint224 votes2,,,) = governance.votesForInitiativeSnapshot(baseInitiative1); + assertEq(votes, votes2, "votes from snapshot are dependent on time at snapshot"); + } + + // checks that there's no difference to resulting voting power from allocating at start or end of epoch + function test_voting_power_no_difference_in_allocating_start_or_end_of_epoch() public { + // =========== epoch 1 ================== + governance = new Governance( + address(lqty), + address(lusd), + address(stakingV1), + address(lusd), + IGovernance.Configuration({ + registrationFee: REGISTRATION_FEE, + registrationThresholdFactor: REGISTRATION_THRESHOLD_FACTOR, + unregistrationThresholdFactor: UNREGISTRATION_THRESHOLD_FACTOR, + registrationWarmUpPeriod: REGISTRATION_WARM_UP_PERIOD, + unregistrationAfterEpochs: UNREGISTRATION_AFTER_EPOCHS, + votingThresholdFactor: VOTING_THRESHOLD_FACTOR, + minClaim: MIN_CLAIM, + minAccrual: MIN_ACCRUAL, + epochStart: uint32(block.timestamp), + epochDuration: EPOCH_DURATION, + epochVotingCutoff: EPOCH_VOTING_CUTOFF + }), + address(this), + initialInitiatives + ); + + // 1. user stakes liquity + uint88 lqtyAmount = 1e18; + _stakeLQTY(user, lqtyAmount); + + uint256 stateBeforeAllocation = vm.snapshot(); + + // =========== epoch 2 (start) ================== + // 2a. user allocates at start of epoch 2 + vm.warp(block.timestamp + EPOCH_DURATION); // warp to second epoch + + _allocateLQTY(user, lqtyAmount); + + // =========== epoch 3 ================== + // 3a. warp to third epoch and check voting power + vm.warp(block.timestamp + EPOCH_DURATION); + governance.snapshotVotesForInitiative(baseInitiative1); + + // get voting power from allocation in previous epoch + (uint224 votesFromAllocatingAtEpochStart,,,) = governance.votesForInitiativeSnapshot(baseInitiative1); + + // ======================================== + // ===== revert to initial state ========== + // ======================================== + + // =============== epoch 1 =============== + // revert EVM to state before allocation + vm.revertTo(stateBeforeAllocation); + + // =============== epoch 2 (end - just before cutoff) =============== + // 2b. user allocates at end of epoch 2 + vm.warp(block.timestamp + (EPOCH_DURATION * 2) - governance.EPOCH_VOTING_CUTOFF()); // warp to end of second epoch before the voting cutoff + + _allocateLQTY(user, lqtyAmount); + + // =========== epoch 3 ================== + // 3b. warp to third epoch and check voting power + vm.warp(block.timestamp + EPOCH_DURATION + 1); + governance.snapshotVotesForInitiative(baseInitiative1); + + // get voting power from allocation in previous epoch + (uint224 votesFromAllocatingAtEpochEnd,,,) = governance.votesForInitiativeSnapshot(baseInitiative1); + assertEq( + votesFromAllocatingAtEpochStart, + votesFromAllocatingAtEpochEnd, + "allocating is more favorable at certain point in epoch" + ); + } + + // deallocating is correctly reflected in voting power for next epoch + function test_voting_power_decreases_next_epoch() public { + // =========== epoch 1 ================== + governance = new Governance( + address(lqty), + address(lusd), + address(stakingV1), + address(lusd), + IGovernance.Configuration({ + registrationFee: REGISTRATION_FEE, + registrationThresholdFactor: REGISTRATION_THRESHOLD_FACTOR, + unregistrationThresholdFactor: UNREGISTRATION_THRESHOLD_FACTOR, + registrationWarmUpPeriod: REGISTRATION_WARM_UP_PERIOD, + unregistrationAfterEpochs: UNREGISTRATION_AFTER_EPOCHS, + votingThresholdFactor: VOTING_THRESHOLD_FACTOR, + minClaim: MIN_CLAIM, + minAccrual: MIN_ACCRUAL, + epochStart: uint32(block.timestamp), + epochDuration: EPOCH_DURATION, + epochVotingCutoff: EPOCH_VOTING_CUTOFF + }), + address(this), + initialInitiatives + ); + + // 1. user stakes lqty + uint88 lqtyAmount = 1e18; + _stakeLQTY(user, lqtyAmount); + + // =========== epoch 2 (start) ================== + // 2. user allocates in epoch 2 for initiative + vm.warp(block.timestamp + EPOCH_DURATION); // warp to second epoch + + _allocateLQTY(user, lqtyAmount); + + // =========== epoch 3 ================== + // 3. warp to third epoch and check voting power + vm.warp(block.timestamp + EPOCH_DURATION); + console2.log("current epoch A: ", governance.epoch()); + governance.snapshotVotesForInitiative(baseInitiative1); + + // 4. votes should be counted in this epoch + (uint224 votes,,,) = governance.votesForInitiativeSnapshot(baseInitiative1); + assertGt(votes, 0, "voting power should increase"); + + _deAllocateLQTY(user, 0); + + governance.snapshotVotesForInitiative(baseInitiative1); + + // 5. votes should still be counted in this epoch + (uint224 votes2,,,) = governance.votesForInitiativeSnapshot(baseInitiative1); + assertGt(votes2, 0, "voting power should not decrease this epoch"); + + // =========== epoch 4 ================== + vm.warp(block.timestamp + EPOCH_DURATION); + console2.log("current epoch B: ", governance.epoch()); + governance.snapshotVotesForInitiative(baseInitiative1); + + // 6. votes should be decreased in this epoch + (uint224 votes3,,,) = governance.votesForInitiativeSnapshot(baseInitiative1); + assertEq(votes3, 0, "voting power should be decreased in this epoch"); + } + + // checking if deallocating changes the averageStakingTimestamp + function test_deallocating_decreases_avg_timestamp() public { + // =========== epoch 1 ================== + governance = new Governance( + address(lqty), + address(lusd), + address(stakingV1), + address(lusd), + IGovernance.Configuration({ + registrationFee: REGISTRATION_FEE, + registrationThresholdFactor: REGISTRATION_THRESHOLD_FACTOR, + unregistrationThresholdFactor: UNREGISTRATION_THRESHOLD_FACTOR, + registrationWarmUpPeriod: REGISTRATION_WARM_UP_PERIOD, + unregistrationAfterEpochs: UNREGISTRATION_AFTER_EPOCHS, + votingThresholdFactor: VOTING_THRESHOLD_FACTOR, + minClaim: MIN_CLAIM, + minAccrual: MIN_ACCRUAL, + epochStart: uint32(block.timestamp), + epochDuration: EPOCH_DURATION, + epochVotingCutoff: EPOCH_VOTING_CUTOFF + }), + address(this), + initialInitiatives + ); + + // 1. user stakes lqty + uint88 lqtyAmount = 1e18; + _stakeLQTY(user, lqtyAmount); + + // =========== epoch 2 (start) ================== + // 2. user allocates in epoch 2 for initiative + vm.warp(block.timestamp + EPOCH_DURATION); // warp to second epoch + + _allocateLQTY(user, lqtyAmount); + + // =========== epoch 3 ================== + // 3. warp to third epoch and check voting power + vm.warp(block.timestamp + EPOCH_DURATION); + governance.snapshotVotesForInitiative(baseInitiative1); + + (, uint120 averageStakingTimestampBefore) = governance.userStates(user); + + _deAllocateLQTY(user, 0); + + (, uint120 averageStakingTimestampAfter) = governance.userStates(user); + assertEq(averageStakingTimestampBefore, averageStakingTimestampAfter); + } + + // vetoing shouldn't affect voting power of the initiative + function test_vote_and_veto() public { + // =========== epoch 1 ================== + governance = new Governance( + address(lqty), + address(lusd), + address(stakingV1), + address(lusd), + IGovernance.Configuration({ + registrationFee: REGISTRATION_FEE, + registrationThresholdFactor: REGISTRATION_THRESHOLD_FACTOR, + unregistrationThresholdFactor: UNREGISTRATION_THRESHOLD_FACTOR, + registrationWarmUpPeriod: REGISTRATION_WARM_UP_PERIOD, + unregistrationAfterEpochs: UNREGISTRATION_AFTER_EPOCHS, + votingThresholdFactor: VOTING_THRESHOLD_FACTOR, + minClaim: MIN_CLAIM, + minAccrual: MIN_ACCRUAL, + epochStart: uint32(block.timestamp), + epochDuration: EPOCH_DURATION, + epochVotingCutoff: EPOCH_VOTING_CUTOFF + }), + address(this), + initialInitiatives + ); + + // 1. user stakes lqty + uint88 lqtyAmount = 1e18; + _stakeLQTY(user, lqtyAmount); + + // 1. user2 stakes lqty + _stakeLQTY(user2, lqtyAmount); + + // =========== epoch 2 (start) ================== + // 2a. user allocates votes in epoch 2 for initiative + vm.warp(block.timestamp + EPOCH_DURATION); // warp to second epoch + + _allocateLQTY(user, lqtyAmount); + + // 2b. user2 allocates vetos for initiative + _veto(user2, lqtyAmount); + + // =========== epoch 3 ================== + // 3. warp to third epoch and check voting power + vm.warp(block.timestamp + EPOCH_DURATION); + console2.log("current epoch A: ", governance.epoch()); + governance.snapshotVotesForInitiative(baseInitiative1); + + // voting power for initiative should be the same as votes from snapshot + (uint88 voteLQTY,, uint120 averageStakingTimestampVoteLQTY,,) = governance.initiativeStates(baseInitiative1); + uint240 currentInitiativePower = + governance.lqtyToVotes(voteLQTY, uint120(block.timestamp) * uint120(1e26), averageStakingTimestampVoteLQTY); + + // 4. votes should not affect accounting for votes + (uint224 votes,,,) = governance.votesForInitiativeSnapshot(baseInitiative1); + assertEq(votes, currentInitiativePower, "voting power of initiative should not be affected by vetos"); + } + + function _stakeLQTY(address staker, uint88 amount) internal { + vm.startPrank(staker); + address userProxy = governance.deriveUserProxyAddress(staker); + lqty.approve(address(userProxy), amount); + + governance.depositLQTY(amount); + vm.stopPrank(); + } + + function _allocateLQTY(address allocator, uint88 amount) internal { + vm.startPrank(allocator); + + // always pass all possible initiatives to deregister for simplicity + address[] memory initiativesToDeRegister = new address[](4); + initiativesToDeRegister[0] = baseInitiative1; + initiativesToDeRegister[1] = baseInitiative2; + initiativesToDeRegister[2] = baseInitiative3; + initiativesToDeRegister[3] = address(0x123123); + + address[] memory initiatives = new address[](1); + initiatives[0] = baseInitiative1; + int88[] memory deltaLQTYVotes = new int88[](1); + deltaLQTYVotes[0] = int88(amount); + int88[] memory deltaLQTYVetos = new int88[](1); + + governance.allocateLQTY(initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos); + vm.stopPrank(); + } + + function _allocateLQTYToInitiative(address allocator, address initiative, uint88 amount) internal { + vm.startPrank(allocator); + + address[] memory initiativesToDeRegister = new address[](4); + initiativesToDeRegister[0] = baseInitiative1; + initiativesToDeRegister[1] = baseInitiative2; + initiativesToDeRegister[2] = baseInitiative3; + initiativesToDeRegister[3] = address(0x123123); + + address[] memory initiatives = new address[](1); + initiatives[0] = initiative; + int88[] memory deltaLQTYVotes = new int88[](1); + deltaLQTYVotes[0] = int88(amount); + int88[] memory deltaLQTYVetos = new int88[](1); + + governance.allocateLQTY(initiativesToDeRegister, initiatives, deltaLQTYVotes, deltaLQTYVetos); + vm.stopPrank(); + } + + function _veto(address allocator, uint88 amount) internal { + vm.startPrank(allocator); + + address[] memory initiatives = new address[](1); + initiatives[0] = baseInitiative1; + int88[] memory deltaLQTYVotes = new int88[](1); + int88[] memory deltaLQTYVetos = new int88[](1); + deltaLQTYVetos[0] = int88(amount); + + governance.allocateLQTY(initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos); + vm.stopPrank(); + } + + function _deAllocateLQTY(address allocator, uint88 amount) internal { + vm.startPrank(allocator); + + address[] memory initiativesToDeRegister = new address[](4); + initiativesToDeRegister[0] = baseInitiative1; + initiativesToDeRegister[1] = baseInitiative2; + initiativesToDeRegister[2] = baseInitiative3; + initiativesToDeRegister[3] = address(0x123123); + + address[] memory initiatives = new address[](1); + initiatives[0] = baseInitiative1; + int88[] memory deltaLQTYVotes = new int88[](1); + deltaLQTYVotes[0] = -int88(amount); + int88[] memory deltaLQTYVetos = new int88[](1); + + governance.allocateLQTY(initiativesToDeRegister, initiatives, deltaLQTYVotes, deltaLQTYVetos); + vm.stopPrank(); + } } diff --git a/test/GovernanceAttacks.t.sol b/test/GovernanceAttacks.t.sol new file mode 100644 index 00000000..5937894d --- /dev/null +++ b/test/GovernanceAttacks.t.sol @@ -0,0 +1,276 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; + +import {IERC20} from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; + +import {IGovernance} from "../src/interfaces/IGovernance.sol"; + +import {Governance} from "../src/Governance.sol"; +import {UserProxy} from "../src/UserProxy.sol"; + +import {MaliciousInitiative} from "./mocks/MaliciousInitiative.sol"; + +contract GovernanceTest is Test { + IERC20 private constant lqty = IERC20(address(0x6DEA81C8171D0bA574754EF6F8b412F2Ed88c54D)); + IERC20 private constant lusd = IERC20(address(0x5f98805A4E8be255a32880FDeC7F6728C6568bA0)); + address private constant stakingV1 = address(0x4f9Fbb3f1E99B56e0Fe2892e623Ed36A76Fc605d); + address private constant user = address(0xF977814e90dA44bFA03b6295A0616a897441aceC); + address private constant user2 = address(0x10C9cff3c4Faa8A60cB8506a7A99411E6A199038); + address private constant lusdHolder = address(0xcA7f01403C4989d2b1A9335A2F09dD973709957c); + + uint128 private constant REGISTRATION_FEE = 1e18; + uint128 private constant REGISTRATION_THRESHOLD_FACTOR = 0.01e18; + uint128 private constant UNREGISTRATION_THRESHOLD_FACTOR = 4e18; + uint16 private constant REGISTRATION_WARM_UP_PERIOD = 4; + uint16 private constant UNREGISTRATION_AFTER_EPOCHS = 4; + uint128 private constant VOTING_THRESHOLD_FACTOR = 0.04e18; + uint88 private constant MIN_CLAIM = 500e18; + uint88 private constant MIN_ACCRUAL = 1000e18; + uint32 private constant EPOCH_DURATION = 604800; + uint32 private constant EPOCH_VOTING_CUTOFF = 518400; + + Governance private governance; + address[] private initialInitiatives; + + MaliciousInitiative private maliciousInitiative1; + MaliciousInitiative private maliciousInitiative2; + MaliciousInitiative private eoaInitiative; + + function setUp() public { + vm.createSelectFork(vm.rpcUrl("mainnet"), 20430000); + + maliciousInitiative1 = new MaliciousInitiative(); + maliciousInitiative2 = new MaliciousInitiative(); + eoaInitiative = MaliciousInitiative(address(0x123123123123)); + + initialInitiatives.push(address(maliciousInitiative1)); + + governance = new Governance( + address(lqty), + address(lusd), + stakingV1, + address(lusd), + IGovernance.Configuration({ + registrationFee: REGISTRATION_FEE, + registrationThresholdFactor: REGISTRATION_THRESHOLD_FACTOR, + unregistrationThresholdFactor: UNREGISTRATION_THRESHOLD_FACTOR, + registrationWarmUpPeriod: REGISTRATION_WARM_UP_PERIOD, + unregistrationAfterEpochs: UNREGISTRATION_AFTER_EPOCHS, + votingThresholdFactor: VOTING_THRESHOLD_FACTOR, + minClaim: MIN_CLAIM, + minAccrual: MIN_ACCRUAL, + epochStart: uint32(block.timestamp), + epochDuration: EPOCH_DURATION, + epochVotingCutoff: EPOCH_VOTING_CUTOFF + }), + address(this), + initialInitiatives + ); + } + + // forge test --match-test test_all_revert_attacks_hardcoded -vv + // All calls should never revert due to malicious initiative + function test_all_revert_attacks_hardcoded() public { + vm.warp(block.timestamp + governance.EPOCH_DURATION()); + + vm.startPrank(user); + + // should not revert if the user doesn't have a UserProxy deployed yet + address userProxy = governance.deriveUserProxyAddress(user); + lqty.approve(address(userProxy), 1e18); + + // deploy and deposit 1 LQTY + governance.depositLQTY(1e18); + assertEq(UserProxy(payable(userProxy)).staked(), 1e18); + (uint88 allocatedLQTY, uint120 averageStakingTimestamp) = governance.userStates(user); + assertEq(allocatedLQTY, 0); + // first deposit should have an averageStakingTimestamp if block.timestamp + assertEq(averageStakingTimestamp, block.timestamp * 1e26); // TODO: Normalize + vm.stopPrank(); + + vm.startPrank(lusdHolder); + lusd.transfer(address(governance), 10000e18); + vm.stopPrank(); + + address maliciousWhale = address(0xb4d); + deal(address(lusd), maliciousWhale, 2000e18); + vm.startPrank(maliciousWhale); + lusd.approve(address(governance), type(uint256).max); + + /// === REGISTRATION REVERTS === /// + uint256 registerNapshot = vm.snapshot(); + + maliciousInitiative2.setRevertBehaviour( + MaliciousInitiative.FunctionType.REGISTER, MaliciousInitiative.RevertType.THROW + ); + governance.registerInitiative(address(maliciousInitiative2)); + vm.revertTo(registerNapshot); + + maliciousInitiative2.setRevertBehaviour( + MaliciousInitiative.FunctionType.REGISTER, MaliciousInitiative.RevertType.OOG + ); + governance.registerInitiative(address(maliciousInitiative2)); + vm.revertTo(registerNapshot); + + maliciousInitiative2.setRevertBehaviour( + MaliciousInitiative.FunctionType.REGISTER, MaliciousInitiative.RevertType.RETURN_BOMB + ); + governance.registerInitiative(address(maliciousInitiative2)); + vm.revertTo(registerNapshot); + + maliciousInitiative2.setRevertBehaviour( + MaliciousInitiative.FunctionType.REGISTER, MaliciousInitiative.RevertType.REVERT_BOMB + ); + governance.registerInitiative(address(maliciousInitiative2)); + vm.revertTo(registerNapshot); + + // Reset and continue + maliciousInitiative2.setRevertBehaviour( + MaliciousInitiative.FunctionType.REGISTER, MaliciousInitiative.RevertType.NONE + ); + governance.registerInitiative(address(maliciousInitiative2)); + + // Register EOA + governance.registerInitiative(address(eoaInitiative)); + + vm.stopPrank(); + + vm.warp(block.timestamp + governance.EPOCH_DURATION()); + + vm.startPrank(user); + address[] memory initiatives = new address[](2); + initiatives[0] = address(maliciousInitiative2); + initiatives[1] = address(eoaInitiative); + int88[] memory deltaVoteLQTY = new int88[](2); + deltaVoteLQTY[0] = 5e17; + deltaVoteLQTY[1] = 5e17; + int88[] memory deltaVetoLQTY = new int88[](2); + + /// === Allocate LQTY REVERTS === /// + uint256 allocateSnapshot = vm.snapshot(); + + maliciousInitiative2.setRevertBehaviour( + MaliciousInitiative.FunctionType.ALLOCATE, MaliciousInitiative.RevertType.THROW + ); + governance.allocateLQTY(initiatives, initiatives, deltaVoteLQTY, deltaVetoLQTY); + vm.revertTo(allocateSnapshot); + + maliciousInitiative2.setRevertBehaviour( + MaliciousInitiative.FunctionType.ALLOCATE, MaliciousInitiative.RevertType.OOG + ); + governance.allocateLQTY(initiatives, initiatives, deltaVoteLQTY, deltaVetoLQTY); + vm.revertTo(allocateSnapshot); + + maliciousInitiative2.setRevertBehaviour( + MaliciousInitiative.FunctionType.ALLOCATE, MaliciousInitiative.RevertType.RETURN_BOMB + ); + governance.allocateLQTY(initiatives, initiatives, deltaVoteLQTY, deltaVetoLQTY); + vm.revertTo(allocateSnapshot); + + maliciousInitiative2.setRevertBehaviour( + MaliciousInitiative.FunctionType.ALLOCATE, MaliciousInitiative.RevertType.REVERT_BOMB + ); + governance.allocateLQTY(initiatives, initiatives, deltaVoteLQTY, deltaVetoLQTY); + vm.revertTo(allocateSnapshot); + + maliciousInitiative2.setRevertBehaviour( + MaliciousInitiative.FunctionType.ALLOCATE, MaliciousInitiative.RevertType.NONE + ); + governance.allocateLQTY(initiatives, initiatives, deltaVoteLQTY, deltaVetoLQTY); + + vm.warp(block.timestamp + governance.EPOCH_DURATION() + 1); + + /// === Claim for initiative REVERTS === /// + uint256 claimShapsnot = vm.snapshot(); + + maliciousInitiative2.setRevertBehaviour( + MaliciousInitiative.FunctionType.CLAIM, MaliciousInitiative.RevertType.THROW + ); + governance.claimForInitiative(address(maliciousInitiative2)); + vm.revertTo(claimShapsnot); + + maliciousInitiative2.setRevertBehaviour( + MaliciousInitiative.FunctionType.CLAIM, MaliciousInitiative.RevertType.OOG + ); + governance.claimForInitiative(address(maliciousInitiative2)); + vm.revertTo(claimShapsnot); + + maliciousInitiative2.setRevertBehaviour( + MaliciousInitiative.FunctionType.CLAIM, MaliciousInitiative.RevertType.RETURN_BOMB + ); + governance.claimForInitiative(address(maliciousInitiative2)); + vm.revertTo(claimShapsnot); + + maliciousInitiative2.setRevertBehaviour( + MaliciousInitiative.FunctionType.CLAIM, MaliciousInitiative.RevertType.REVERT_BOMB + ); + governance.claimForInitiative(address(maliciousInitiative2)); + vm.revertTo(claimShapsnot); + + maliciousInitiative2.setRevertBehaviour( + MaliciousInitiative.FunctionType.CLAIM, MaliciousInitiative.RevertType.NONE + ); + governance.claimForInitiative(address(maliciousInitiative2)); + + governance.claimForInitiative(address(eoaInitiative)); + + /// === Unregister Reverts === /// + + vm.startPrank(user); + initiatives = new address[](3); + initiatives[0] = address(maliciousInitiative2); + initiatives[1] = address(eoaInitiative); + initiatives[2] = address(maliciousInitiative1); + deltaVoteLQTY = new int88[](3); + deltaVoteLQTY[0] = 0; + deltaVoteLQTY[1] = 0; + deltaVoteLQTY[2] = 5e17; + deltaVetoLQTY = new int88[](3); + governance.allocateLQTY(initiatives, initiatives, deltaVoteLQTY, deltaVetoLQTY); + + (Governance.VoteSnapshot memory v, Governance.InitiativeVoteSnapshot memory initData) = + governance.snapshotVotesForInitiative(address(maliciousInitiative2)); + + // Inactive for 4 epochs + // Add another proposal + + vm.warp(block.timestamp + governance.EPOCH_DURATION() * 5); + + /// @audit needs 5? + (v, initData) = governance.snapshotVotesForInitiative(address(maliciousInitiative2)); + uint256 unregisterSnapshot = vm.snapshot(); + + maliciousInitiative2.setRevertBehaviour( + MaliciousInitiative.FunctionType.UNREGISTER, MaliciousInitiative.RevertType.THROW + ); + governance.unregisterInitiative(address(maliciousInitiative2)); + vm.revertTo(unregisterSnapshot); + + maliciousInitiative2.setRevertBehaviour( + MaliciousInitiative.FunctionType.UNREGISTER, MaliciousInitiative.RevertType.OOG + ); + governance.unregisterInitiative(address(maliciousInitiative2)); + vm.revertTo(unregisterSnapshot); + + maliciousInitiative2.setRevertBehaviour( + MaliciousInitiative.FunctionType.UNREGISTER, MaliciousInitiative.RevertType.RETURN_BOMB + ); + governance.unregisterInitiative(address(maliciousInitiative2)); + vm.revertTo(unregisterSnapshot); + + maliciousInitiative2.setRevertBehaviour( + MaliciousInitiative.FunctionType.UNREGISTER, MaliciousInitiative.RevertType.REVERT_BOMB + ); + governance.unregisterInitiative(address(maliciousInitiative2)); + vm.revertTo(unregisterSnapshot); + + maliciousInitiative2.setRevertBehaviour( + MaliciousInitiative.FunctionType.UNREGISTER, MaliciousInitiative.RevertType.NONE + ); + governance.unregisterInitiative(address(maliciousInitiative2)); + + governance.unregisterInitiative(address(eoaInitiative)); + } +} diff --git a/test/Math.t.sol b/test/Math.t.sol new file mode 100644 index 00000000..5464b175 --- /dev/null +++ b/test/Math.t.sol @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; + +import {add, abs} from "src/utils/Math.sol"; +import {console} from "forge-std/console.sol"; + +contract AddComparer { + function libraryAdd(uint88 a, int88 b) public pure returns (uint88) { + return add(a, b); + } + // Differential test + // Verify that it will revert any time it overflows + // Verify we can never get a weird value + + function referenceAdd(uint88 a, int88 b) public pure returns (uint88) { + // Upscale both + int96 scaledA = int96(int256(uint256(a))); + int96 tempB = int96(b); + + int96 res = scaledA + tempB; + if (res < 0) { + revert("underflow"); + } + + if (res > int96(int256(uint256(type(uint88).max)))) { + revert("Too big"); + } + + return uint88(uint96(res)); + } +} + +contract AbsComparer { + function libraryAbs(int88 a) public pure returns (uint88) { + return abs(a); // by definition should fit, since input was int88 -> uint88 -> int88 + } + + event DebugEvent2(int256); + event DebugEvent(uint256); + + function referenceAbs(int88 a) public returns (uint88) { + int256 bigger = a; + uint256 ref = bigger < 0 ? uint256(-bigger) : uint256(bigger); + emit DebugEvent2(bigger); + emit DebugEvent(ref); + if (ref > type(uint88).max) { + revert("Too big"); + } + if (ref < type(uint88).min) { + revert("Too small"); + } + return uint88(ref); + } +} + +contract MathTests is Test { + // forge test --match-test test_math_fuzz_comparison -vv + function test_math_fuzz_comparison(uint88 a, int88 b) public { + vm.assume(a < uint88(type(int88).max)); + AddComparer tester = new AddComparer(); + + bool revertLib; + bool revertRef; + uint88 resultLib; + uint88 resultRef; + + try tester.libraryAdd(a, b) returns (uint88 x) { + resultLib = x; + } catch { + revertLib = true; + } + + try tester.referenceAdd(a, b) returns (uint88 x) { + resultRef = x; + } catch { + revertRef = true; + } + + // Negative overflow + if (revertLib == true && revertRef == false) { + // Check if we had a negative value + if (resultRef < 0) { + revertRef = true; + resultRef = uint88(0); + } + + // Check if we overflow on the positive + if (resultRef > uint88(type(int88).max)) { + // Overflow due to above limit + revertRef = true; + resultRef = uint88(0); + } + } + + assertEq(revertLib, revertRef, "Reverts"); // This breaks + assertEq(resultLib, resultRef, "Results"); // This should match excluding overflows + } + + /// @dev test that abs never incorrectly overflows + // forge test --match-test test_fuzz_abs_comparison -vv + /** + * [FAIL. Reason: reverts: false != true; counterexample: calldata=0x2c945365ffffffffffffffffffffffffffffffffffffffffff8000000000000000000000 args=[-154742504910672534362390528 [-1.547e26]]] + */ + function test_fuzz_abs_comparison(int88 a) public { + AbsComparer tester = new AbsComparer(); + + bool revertLib; + bool revertRef; + uint88 resultLib; + uint88 resultRef; + + try tester.libraryAbs(a) returns (uint88 x) { + resultLib = x; + } catch { + revertLib = true; + } + + try tester.referenceAbs(a) returns (uint88 x) { + resultRef = x; + } catch { + revertRef = true; + } + + assertEq(revertLib, revertRef, "reverts"); + assertEq(resultLib, resultRef, "results"); + } + + /// @dev Test that Abs never revert + /// It reverts on the smaller possible number + function test_fuzz_abs(int88 a) public { + /** + * Encountered 1 failing test in test/Math.t.sol:MathTests + * [FAIL. Reason: panic: arithmetic underflow or overflow (0x11); counterexample: calldata=0x804d552cffffffffffffffffffffffffffffffffffffffff800000000000000000000000 args=[-39614081257132168796771975168 [-3.961e28]]] test_fuzz_abs(int88) (runs: 0, μ: 0, ~: 0) + */ + /// @audit Reverts at the absolute minimum due to overflow as it will remain negative + abs(a); + } +} diff --git a/test/SafeCallWithMinGas.t.sol b/test/SafeCallWithMinGas.t.sol new file mode 100644 index 00000000..370d6f10 --- /dev/null +++ b/test/SafeCallWithMinGas.t.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; + +import {safeCallWithMinGas} from "src/utils/SafeCallMinGas.sol"; + +contract BasicRecipient { + bool public callWasValid; + + function validCall() external { + callWasValid = true; + } +} + +contract FallbackRecipient { + bytes public received; + + fallback() external payable { + received = msg.data; + } +} + +contract SafeCallWithMinGasTests is Test { + function test_basic_nonExistent(uint256 gas, uint256 value, bytes memory theData) public { + vm.assume(gas < 30_000_000); + // Call to non existent succeeds + address nonExistent = address(0x123123123); + assert(nonExistent.code.length == 0); + + safeCallWithMinGas(address(0x123123123), gas, value, theData); + } + + function test_basic_contractData(uint256 gas, uint256 value, bytes memory theData) public { + vm.assume(gas < 30_000_000); + vm.assume(gas > 50_000 + theData.length * 2_100); + /// @audit Approximation + FallbackRecipient recipient = new FallbackRecipient(); + // Call to non existent succeeds + + vm.deal(address(this), value); + + safeCallWithMinGas(address(recipient), gas, value, theData); + assertEq(keccak256(recipient.received()), keccak256(theData), "same data"); + } + + function test_basic_contractCall() public { + BasicRecipient recipient = new BasicRecipient(); + // Call to non existent succeeds + + safeCallWithMinGas(address(recipient), 35_000, 0, abi.encodeCall(BasicRecipient.validCall, ())); + assertEq(recipient.callWasValid(), true, "Call success"); + } +} diff --git a/test/TEST.md b/test/TEST.md index 05f23f21..e0095552 100644 --- a/test/TEST.md +++ b/test/TEST.md @@ -40,7 +40,7 @@ Governance: - should return the correct number of seconds elapsed within an epoch for a given block.timestamp - lqtyToVotes() - should not revert under any input -- calculateVotingThreshold() +- getLatestVotingThreshold() - should return a votingThreshold that's either - high enough such that MIN_CLAIM is met - 4% of the votes from the previous epoch diff --git a/test/UniV4Donations.t.sol b/test/UniV4Donations.t.sol index 51fe21a5..574ed2d7 100644 --- a/test/UniV4Donations.t.sol +++ b/test/UniV4Donations.t.sol @@ -123,6 +123,7 @@ contract UniV4DonationsTest is Test, Deployers { epochDuration: EPOCH_DURATION, epochVotingCutoff: EPOCH_VOTING_CUTOFF }), + address(this), initialInitiatives ); } @@ -131,21 +132,25 @@ contract UniV4DonationsTest is Test, Deployers { manager.initialize(uniV4Donations.poolKey(), SQRT_PRICE_1_1, ZERO_BYTES); } - function test_modifyPosition() public { + //// TODO: e2e test - With real governance and proposals + + function test_modifyPositionFuzz() public { manager.initialize(uniV4Donations.poolKey(), SQRT_PRICE_1_1, ZERO_BYTES); vm.startPrank(lusdHolder); - lusd.transfer(address(uniV4Donations), 1000e18); + vm.stopPrank(); - vm.mockCall( - address(governance), abi.encode(IGovernance.claimForInitiative.selector), abi.encode(uint256(1000e18)) - ); - assertEq(uniV4Donations.donateToPool(), 0); + /// TODO: This is a mock call, we need a E2E test as well + vm.prank(address(governance)); + uniV4Donations.onClaimForInitiative(0, 1000e18); + + vm.startPrank(lusdHolder); + assertEq(uniV4Donations.donateToPool(), 0, "d"); (uint240 amount, uint16 epoch, uint256 released) = uniV4Donations.vesting(); - assertEq(amount, 1000e18); - assertEq(epoch, 1); - assertEq(released, 0); + assertEq(amount, 1000e18, "amt"); + assertEq(epoch, 1, "epoch"); + assertEq(released, 0, "released"); vm.warp(block.timestamp + uniV4Donations.VESTING_EPOCH_DURATION() / 2); lusd.approve(address(modifyLiquidityRouter), type(uint256).max); @@ -181,4 +186,65 @@ contract UniV4DonationsTest is Test, Deployers { vm.stopPrank(); } + + function test_modifyPositionFuzz(uint128 amt) public { + manager.initialize(uniV4Donations.poolKey(), SQRT_PRICE_1_1, ZERO_BYTES); + + deal(address(lusd), address(uniV4Donations), amt); + + /// TODO: This is a mock call, we need a E2E test as well + vm.prank(address(governance)); + uniV4Donations.onClaimForInitiative(0, amt); + + vm.startPrank(lusdHolder); + assertEq(uniV4Donations.donateToPool(), 0, "d"); + (uint240 amount, uint16 epoch, uint256 released) = uniV4Donations.vesting(); + assertEq(amount, amt, "amt"); + assertEq(epoch, 1, "epoch"); + assertEq(released, 0, "released"); + + vm.warp(block.timestamp + uniV4Donations.VESTING_EPOCH_DURATION() / 2); + lusd.approve(address(modifyLiquidityRouter), type(uint256).max); + usdc.approve(address(modifyLiquidityRouter), type(uint256).max); + modifyLiquidityRouter.modifyLiquidity( + uniV4Donations.poolKey(), + IPoolManager.ModifyLiquidityParams( + TickMath.minUsableTick(MAX_TICK_SPACING), TickMath.maxUsableTick(MAX_TICK_SPACING), 1000, 0 + ), + bytes("") + ); + (amount, epoch, released) = uniV4Donations.vesting(); + assertEq(amount, amt); + assertEq(released, amount * 50 / 100); + assertEq(epoch, 1); + + vm.warp(block.timestamp + (uniV4Donations.VESTING_EPOCH_DURATION() / 2) - 1); + uint256 donated = uniV4Donations.donateToPool(); + assertGe(donated, amount * 49 / 100); + /// @audit Used to be Gt + assertLe(donated, amount * 50 / 100, "less than 50%"); + /// @audit Used to be Lt + (amount, epoch, released) = uniV4Donations.vesting(); + assertEq(amount, amt); + assertEq(epoch, 1); + assertGe(released, amount * 99 / 100); + /// @audit Used to be Gt + + vm.warp(block.timestamp + 1); + vm.mockCall(address(governance), abi.encode(IGovernance.claimForInitiative.selector), abi.encode(uint256(0))); + uniV4Donations.donateToPool(); + (amount, epoch, released) = uniV4Donations.vesting(); + + /// @audit Counterexample + // [FAIL. Reason: end results in dust: 1 > 0; counterexample: calldata=0x38b4b04f000000000000000000000000000000000000000000000000000000000000000c args=[12]] test_modifyPositionFuzz(uint128) (runs: 4, μ: 690381, ~: 690381) + if (amount > 1) { + assertLe(amount, amt / 100, "end results in dust"); + /// @audit Used to be Lt + } + + assertEq(epoch, 2); + assertEq(released, 0); + + vm.stopPrank(); + } } diff --git a/test/UserProxy.t.sol b/test/UserProxy.t.sol index d35a98a5..17124d52 100644 --- a/test/UserProxy.t.sol +++ b/test/UserProxy.t.sol @@ -111,11 +111,11 @@ contract UserProxyTest is Test { userProxy.stake(1e18, user); - (uint256 lusdAmount, uint256 ethAmount) = userProxy.unstake(0, user, user); + (uint256 lusdAmount, uint256 ethAmount) = userProxy.unstake(0, user); assertEq(lusdAmount, 0); assertEq(ethAmount, 0); - vm.warp(block.timestamp + 365 days); + vm.warp(block.timestamp + 7 days); uint256 ethBalance = uint256(vm.load(stakingV1, bytes32(uint256(3)))); vm.store(stakingV1, bytes32(uint256(3)), bytes32(abi.encodePacked(ethBalance + 1e18))); @@ -123,7 +123,7 @@ contract UserProxyTest is Test { uint256 lusdBalance = uint256(vm.load(stakingV1, bytes32(uint256(4)))); vm.store(stakingV1, bytes32(uint256(4)), bytes32(abi.encodePacked(lusdBalance + 1e18))); - (lusdAmount, ethAmount) = userProxy.unstake(1e18, user, user); + (lusdAmount, ethAmount) = userProxy.unstake(1e18, user); assertEq(lusdAmount, 1e18); assertEq(ethAmount, 1e18); diff --git a/test/VotingPower.t.sol b/test/VotingPower.t.sol new file mode 100644 index 00000000..bbfe0de8 --- /dev/null +++ b/test/VotingPower.t.sol @@ -0,0 +1,472 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import {Test, console2} from "forge-std/Test.sol"; +import {VmSafe} from "forge-std/Vm.sol"; +import {console} from "forge-std/console.sol"; + +import {IERC20} from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; + +import {IGovernance} from "../src/interfaces/IGovernance.sol"; +import {ILQTY} from "../src/interfaces/ILQTY.sol"; + +import {BribeInitiative} from "../src/BribeInitiative.sol"; +import {Governance} from "../src/Governance.sol"; +import {UserProxy} from "../src/UserProxy.sol"; + +import {PermitParams} from "../src/utils/Types.sol"; + +import {MockInitiative} from "./mocks/MockInitiative.sol"; + +contract VotingPowerTest is Test { + IERC20 private constant lqty = IERC20(address(0x6DEA81C8171D0bA574754EF6F8b412F2Ed88c54D)); + IERC20 private constant lusd = IERC20(address(0x5f98805A4E8be255a32880FDeC7F6728C6568bA0)); + address private constant stakingV1 = address(0x4f9Fbb3f1E99B56e0Fe2892e623Ed36A76Fc605d); + address private constant user = address(0xF977814e90dA44bFA03b6295A0616a897441aceC); + address private constant user2 = address(0x10C9cff3c4Faa8A60cB8506a7A99411E6A199038); + address private constant lusdHolder = address(0xcA7f01403C4989d2b1A9335A2F09dD973709957c); + + uint128 private constant REGISTRATION_FEE = 1e18; + uint128 private constant REGISTRATION_THRESHOLD_FACTOR = 0.01e18; + uint128 private constant UNREGISTRATION_THRESHOLD_FACTOR = 4e18; + uint16 private constant REGISTRATION_WARM_UP_PERIOD = 4; + uint16 private constant UNREGISTRATION_AFTER_EPOCHS = 4; + uint128 private constant VOTING_THRESHOLD_FACTOR = 0.04e18; + uint88 private constant MIN_CLAIM = 500e18; + uint88 private constant MIN_ACCRUAL = 1000e18; + uint32 private constant EPOCH_DURATION = 604800; + uint32 private constant EPOCH_VOTING_CUTOFF = 518400; + + Governance private governance; + address[] private initialInitiatives; + + address private baseInitiative2; + address private baseInitiative3; + address private baseInitiative1; + + function setUp() public { + vm.createSelectFork(vm.rpcUrl("mainnet"), 20430000); + + baseInitiative1 = address( + new BribeInitiative( + address(vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 3)), + address(lusd), + address(lqty) + ) + ); + + baseInitiative2 = address( + new BribeInitiative( + address(vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 2)), + address(lusd), + address(lqty) + ) + ); + + baseInitiative3 = address( + new BribeInitiative( + address(vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 1)), + address(lusd), + address(lqty) + ) + ); + + initialInitiatives.push(baseInitiative1); + initialInitiatives.push(baseInitiative2); + + governance = new Governance( + address(lqty), + address(lusd), + stakingV1, + address(lusd), + IGovernance.Configuration({ + registrationFee: REGISTRATION_FEE, + registrationThresholdFactor: REGISTRATION_THRESHOLD_FACTOR, + unregistrationThresholdFactor: UNREGISTRATION_THRESHOLD_FACTOR, + registrationWarmUpPeriod: REGISTRATION_WARM_UP_PERIOD, + unregistrationAfterEpochs: UNREGISTRATION_AFTER_EPOCHS, + votingThresholdFactor: VOTING_THRESHOLD_FACTOR, + minClaim: MIN_CLAIM, + minAccrual: MIN_ACCRUAL, + epochStart: uint32(block.timestamp - EPOCH_DURATION), + epochDuration: EPOCH_DURATION, + epochVotingCutoff: EPOCH_VOTING_CUTOFF + }), + address(this), + initialInitiatives + ); + } + + /// Compare with removing all and re-allocating all at the 2nd epoch + // forge test --match-test test_math_soundness -vv + function test_math_soundness() public { + // Given a Multiplier, I can wait 8 times more time + // Or use 8 times more amt + uint8 multiplier = 2; + + uint88 lqtyAmount = 1e18; + + uint256 powerInTheFuture = governance.lqtyToVotes(lqtyAmount, multiplier + 1, 1); + // Amt when delta is 1 + // 0 when delta is 0 + uint256 powerFromMoreDeposits = + governance.lqtyToVotes(lqtyAmount * multiplier, uint32(block.timestamp + 1), uint32(block.timestamp)); + + assertEq(powerInTheFuture, powerFromMoreDeposits, "Same result"); + } + + function test_math_soundness_fuzz(uint32 multiplier) public view { + vm.assume(multiplier < type(uint32).max - 1); + uint88 lqtyAmount = 1e10; + + uint256 powerInTheFuture = governance.lqtyToVotes(lqtyAmount, multiplier + 1, 1); + + // Amt when delta is 1 + // 0 when delta is 0 + uint256 powerFromMoreDeposits = + governance.lqtyToVotes(lqtyAmount * multiplier, uint32(block.timestamp + 1), uint32(block.timestamp)); + + assertEq(powerInTheFuture, powerFromMoreDeposits, "Same result"); + } + + function _averageAge(uint32 _currentTimestamp, uint32 _averageTimestamp) internal pure returns (uint32) { + if (_averageTimestamp == 0 || _currentTimestamp < _averageTimestamp) return 0; + return _currentTimestamp - _averageTimestamp; + } + + function _calculateAverageTimestamp( + uint32 _prevOuterAverageTimestamp, + uint32 _newInnerAverageTimestamp, + uint88 _prevLQTYBalance, + uint88 _newLQTYBalance + ) internal view returns (uint32) { + if (_newLQTYBalance == 0) return 0; + + uint32 prevOuterAverageAge = _averageAge(uint32(block.timestamp), _prevOuterAverageTimestamp); + uint32 newInnerAverageAge = _averageAge(uint32(block.timestamp), _newInnerAverageTimestamp); + + uint88 newOuterAverageAge; + if (_prevLQTYBalance <= _newLQTYBalance) { + uint88 deltaLQTY = _newLQTYBalance - _prevLQTYBalance; + uint240 prevVotes = uint240(_prevLQTYBalance) * uint240(prevOuterAverageAge); + uint240 newVotes = uint240(deltaLQTY) * uint240(newInnerAverageAge); + uint240 votes = prevVotes + newVotes; + newOuterAverageAge = uint32(votes / uint240(_newLQTYBalance)); + } else { + uint88 deltaLQTY = _prevLQTYBalance - _newLQTYBalance; + uint240 prevVotes = uint240(_prevLQTYBalance) * uint240(prevOuterAverageAge); + uint240 newVotes = uint240(deltaLQTY) * uint240(newInnerAverageAge); + uint240 votes = (prevVotes >= newVotes) ? prevVotes - newVotes : 0; + newOuterAverageAge = uint32(votes / uint240(_newLQTYBalance)); + } + + if (newOuterAverageAge > block.timestamp) return 0; + return uint32(block.timestamp - newOuterAverageAge); + } + + // This test prepares for comparing votes and vetos for state + // forge test --match-test test_we_can_compare_votes_and_vetos -vv + // function test_we_can_compare_votes_and_vetos() public { + /// TODO AUDIT Known bug with rounding math + // uint32 current_time = 123123123; + // vm.warp(current_time); + // // State at X + // // State made of X and Y + // uint32 time = current_time - 124; + // uint88 votes = 124; + // uint240 power = governance.lqtyToVotes(votes, current_time, time); + + // assertEq(power, (_averageAge(current_time, time)) * votes, "simple product"); + + // // if it's a simple product we have the properties of multiplication, we can get back the value by dividing the tiem + // uint88 resultingVotes = uint88(power / _averageAge(current_time, time)); + + // assertEq(resultingVotes, votes, "We can get it back"); + + // // If we can get it back, then we can also perform other operations like addition and subtraction + // // Easy when same TS + + // // // But how do we sum stuff with different TS? + // // // We need to sum the total and sum the % of average ts + // uint88 votes_2 = 15; + // uint32 time_2 = current_time - 15; + + // uint240 power_2 = governance.lqtyToVotes(votes_2, current_time, time_2); + + // uint240 total_power = power + power_2; + + // assertLe(total_power, uint240(type(uint88).max), "LT"); + + // uint88 total_liquity = votes + votes_2; + + // uint32 avgTs = _calculateAverageTimestamp(time, time_2, votes, total_liquity); + + // console.log("votes", votes); + // console.log("time", current_time - time); + // console.log("power", power); + + // console.log("votes_2", votes_2); + // console.log("time_2", current_time - time_2); + // console.log("power_2", power_2); + + // uint256 total_power_from_avg = governance.lqtyToVotes(total_liquity, current_time, avgTs); + + // console.log("total_liquity", total_liquity); + // console.log("avgTs", current_time - avgTs); + // console.log("total_power_from_avg", total_power_from_avg); + + // // Now remove the same math so we show that the rounding can be weaponized, let's see + + // // WTF + + // // Prev, new, prev new + // // AVG TS is the prev outer + // // New Inner is time + // uint32 attacked_avg_ts = _calculateAverageTimestamp( + // avgTs, + // time_2, // User removes their time + // total_liquity, + // votes // Votes = total_liquity - Vote_2 + // ); + + // // NOTE: != time due to rounding error + // console.log("attacked_avg_ts", current_time - attacked_avg_ts); + + // // BASIC VOTING TEST + // // AFTER VOTING POWER IS X + // // AFTER REMOVING VOTING IS 0 + + // // Add a middle of random shit + // // Show that the math remains sound + + // // Off by 40 BPS????? WAYY TOO MUCH | SOMETHING IS WRONG + + // // It doesn't sum up exactly becasue of rounding errors + // // But we need the rounding error to be in favour of the protocol + // // And currently they are not + // assertEq(total_power, total_power_from_avg, "Sums up"); + + // // From those we can find the average timestamp + // uint88 resultingReturnedVotes = uint88(total_power_from_avg / _averageAge(current_time, time)); + // assertEq(resultingReturnedVotes, total_liquity, "Lqty matches"); + // } + + // forge test --match-test test_crit_user_can_dilute_total_votes -vv + function test_crit_user_can_dilute_total_votes() public { + // User A deposits normaly + vm.startPrank(user); + + _stakeLQTY(user, 124); + + vm.warp(block.timestamp + 124 - 15); + + vm.startPrank(user2); + _stakeLQTY(user2, 15); + + vm.warp(block.timestamp + 15); + + vm.startPrank(user); + _allocate(address(baseInitiative1), 124, 0); + uint256 user1_avg = _getAverageTS(baseInitiative1); + + vm.startPrank(user2); + _allocate(address(baseInitiative1), 15, 0); + _allocate(address(baseInitiative1), 0, 0); + + uint256 griefed_avg = _getAverageTS(baseInitiative1); + + uint256 vote_power_1 = governance.lqtyToVotes(124, uint32(block.timestamp), uint32(user1_avg)); + uint256 vote_power_2 = governance.lqtyToVotes(124, uint32(block.timestamp), uint32(griefed_avg)); + + console.log("vote_power_1", vote_power_1); + console.log("vote_power_2", vote_power_2); + + // assertEq(user1_avg, griefed_avg, "same avg"); // BREAKS, OFF BY ONE + + // Causes a loss of power of 1 second per time this is done + + vm.startPrank(user); + _allocate(address(baseInitiative1), 0, 0); + + uint256 final_avg = _getAverageTS(baseInitiative1); + console.log("final_avg", final_avg); + + // This is not an issue, except for bribes, bribes can get the last claimer DOSS + } + + // forge test --match-test test_can_we_spam_to_revert -vv + function test_can_we_spam_to_revert() public { + // User A deposits normaly + vm.startPrank(user); + + _stakeLQTY(user, 124); + + vm.warp(block.timestamp + 124); + + vm.startPrank(user2); + _stakeLQTY(user2, 15); + + vm.startPrank(user); + _allocate(address(baseInitiative1), 124, 0); + + vm.startPrank(user2); + _allocate(address(baseInitiative1), 15, 0); + _allocate(address(baseInitiative1), 0, 0); + + uint256 griefed_avg = _getAverageTS(baseInitiative1); + console.log("griefed_avg", griefed_avg); + console.log("block.timestamp", block.timestamp); + + console.log("0?"); + + uint256 currentMagnifiedTs = uint120(block.timestamp) * uint120(1e26); + + vm.startPrank(user2); + _allocate(address(baseInitiative1), 15, 0); + _allocate(address(baseInitiative1), 0, 0); + + uint256 ts = _getAverageTS(baseInitiative1); + uint256 delta = currentMagnifiedTs - ts; + console.log("griefed_avg", ts); + console.log("delta", delta); + console.log("currentMagnifiedTs", currentMagnifiedTs); + + console.log("0?"); + uint256 i; + while (i++ < 122) { + console.log("i", i); + _allocate(address(baseInitiative1), 15, 0); + _allocate(address(baseInitiative1), 0, 0); + } + + console.log("1?"); + + ts = _getAverageTS(baseInitiative1); + delta = currentMagnifiedTs - ts; + console.log("griefed_avg", ts); + console.log("delta", delta); + console.log("currentMagnifiedTs", currentMagnifiedTs); + + // One more time + _allocate(address(baseInitiative1), 15, 0); + _allocate(address(baseInitiative1), 0, 0); + _allocate(address(baseInitiative1), 15, 0); + _allocate(address(baseInitiative1), 0, 0); + _allocate(address(baseInitiative1), 15, 0); + _allocate(address(baseInitiative1), 0, 0); + _allocate(address(baseInitiative1), 15, 0); + + /// NOTE: Keep 1 wei to keep rounding error + _allocate(address(baseInitiative1), 1, 0); + + ts = _getAverageTS(baseInitiative1); + console.log("griefed_avg", ts); + + vm.startPrank(user); + _allocate(address(baseInitiative1), 0, 0); + _allocate(address(baseInitiative1), 124, 0); + + ts = _getAverageTS(baseInitiative1); + console.log("end_ts", ts); + } + + // forge test --match-test test_basic_reset_flow -vv + function test_basic_reset_flow() public { + vm.startPrank(user); + // =========== epoch 1 ================== + // 1. user stakes lqty + int88 lqtyAmount = 2e18; + _stakeLQTY(user, uint88(lqtyAmount / 2)); + + // user allocates to baseInitiative1 + _allocate(address(baseInitiative1), lqtyAmount / 2, 0); // 50% to it + (uint88 allocatedLQTY,) = governance.userStates(user); + assertEq(allocatedLQTY, uint88(lqtyAmount / 2), "half"); + + _allocate(address(baseInitiative1), lqtyAmount / 2, 0); // 50% to it + assertEq(allocatedLQTY, uint88(lqtyAmount / 2), "still half, the math is absolute now"); + } + + // forge test --match-test test_cutoff_logic -vv + function test_cutoff_logic() public { + vm.startPrank(user); + // =========== epoch 1 ================== + // 1. user stakes lqty + int88 lqtyAmount = 2e18; + _stakeLQTY(user, uint88(lqtyAmount)); + + // user allocates to baseInitiative1 + _allocate(address(baseInitiative1), lqtyAmount / 2, 0); // 50% to it + (uint88 allocatedLQTY,) = governance.userStates(user); + assertEq(allocatedLQTY, uint88(lqtyAmount / 2), "Half"); + + // Go to Cutoff + // See that you can reduce + // See that you can Veto as much as you want + vm.warp(block.timestamp + (EPOCH_DURATION) - governance.EPOCH_VOTING_CUTOFF() + 1); // warp to end of second epoch before the voting cutoff + + // Go to end of epoch, lazy math + while (!(governance.secondsWithinEpoch() > governance.EPOCH_VOTING_CUTOFF())) { + vm.warp(block.timestamp + 6 hours); + } + assertTrue( + governance.secondsWithinEpoch() > governance.EPOCH_VOTING_CUTOFF(), "We should not be able to vote more" + ); + + vm.expectRevert(); // cannot allocate more + _allocate(address(baseInitiative1), lqtyAmount, 0); + + // Can allocate less + _allocate(address(baseInitiative1), lqtyAmount / 2 - 1, 0); + + // Can Veto more than allocate + _allocate(address(baseInitiative1), 0, lqtyAmount); + } + + // Check if Flashloan can be used to cause issues? + // A flashloan would cause issues in the measure in which it breaks any specific property + // Or expectation + + // Remove votes + // Removing votes would force you to exclusively remove + // You can always remove at any time afacit + // Removing just updates that + the weights + // The weights are the avg time * the number + + function _getAverageTS(address initiative) internal view returns (uint256) { + (,, uint120 averageStakingTimestampVoteLQTY,,) = governance.initiativeStates(initiative); + + return averageStakingTimestampVoteLQTY; + } + + function _stakeLQTY(address _user, uint88 amount) internal { + address userProxy = governance.deriveUserProxyAddress(_user); + lqty.approve(address(userProxy), amount); + + governance.depositLQTY(amount); + } + + function _allocate(address initiative, int88 votes, int88 vetos) internal { + address[] memory initiativesToReset = new address[](3); + initiativesToReset[0] = baseInitiative1; + initiativesToReset[1] = baseInitiative2; + initiativesToReset[2] = baseInitiative3; + address[] memory initiatives = new address[](1); + initiatives[0] = initiative; + int88[] memory deltaLQTYVotes = new int88[](1); + deltaLQTYVotes[0] = votes; + int88[] memory deltaLQTYVetos = new int88[](1); + deltaLQTYVetos[0] = vetos; + + governance.allocateLQTY(initiativesToReset, initiatives, deltaLQTYVotes, deltaLQTYVetos); + } + + function _reset() internal { + address[] memory initiativesToReset = new address[](3); + initiativesToReset[0] = baseInitiative1; + initiativesToReset[1] = baseInitiative2; + initiativesToReset[2] = baseInitiative3; + + governance.resetAllocations(initiativesToReset, true); + } +} diff --git a/test/mocks/MaliciousInitiative.sol b/test/mocks/MaliciousInitiative.sol new file mode 100644 index 00000000..494d9284 --- /dev/null +++ b/test/mocks/MaliciousInitiative.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {IInitiative} from "src/interfaces/IInitiative.sol"; +import {IGovernance} from "src/interfaces/IGovernance.sol"; + +contract MaliciousInitiative is IInitiative { + enum FunctionType { + NONE, + REGISTER, + UNREGISTER, + ALLOCATE, + CLAIM + } + + enum RevertType { + NONE, + THROW, + OOG, + RETURN_BOMB, + REVERT_BOMB + } + + mapping(FunctionType => RevertType) revertBehaviours; + + /// @dev specify the revert behaviour on each function + function setRevertBehaviour(FunctionType ft, RevertType rt) external { + revertBehaviours[ft] = rt; + } + + // Do stuff on each hook + function onRegisterInitiative(uint16) external view override { + _performRevertBehaviour(revertBehaviours[FunctionType.REGISTER]); + } + + function onUnregisterInitiative(uint16) external view override { + _performRevertBehaviour(revertBehaviours[FunctionType.UNREGISTER]); + } + + function onAfterAllocateLQTY( + uint16, + address, + IGovernance.UserState calldata, + IGovernance.Allocation calldata, + IGovernance.InitiativeState calldata + ) external view override { + _performRevertBehaviour(revertBehaviours[FunctionType.ALLOCATE]); + } + + function onClaimForInitiative(uint16, uint256) external view override { + _performRevertBehaviour(revertBehaviours[FunctionType.CLAIM]); + } + + function _performRevertBehaviour(RevertType action) internal pure { + if (action == RevertType.THROW) { + revert("A normal Revert"); + } + + // 3 gas per iteration, consider changing to storage changes if traces are cluttered + if (action == RevertType.OOG) { + uint256 i; + while (true) { + ++i; + } + } + + if (action == RevertType.RETURN_BOMB) { + uint256 _bytes = 2_000_000; + assembly { + return(0, _bytes) + } + } + + if (action == RevertType.REVERT_BOMB) { + uint256 _bytes = 2_000_000; + assembly { + revert(0, _bytes) + } + } + + return; // NONE + } +} diff --git a/test/mocks/MockERC20Tester.sol b/test/mocks/MockERC20Tester.sol new file mode 100644 index 00000000..f239dca5 --- /dev/null +++ b/test/mocks/MockERC20Tester.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-2.0 +pragma solidity ^0.8.0; + +import {MockERC20} from "forge-std/mocks/MockERC20.sol"; + +contract MockERC20Tester is MockERC20 { + address owner; + + modifier onlyOwner() { + require(msg.sender == owner); + _; + } + + constructor(address recipient, uint256 mintAmount, string memory name, string memory symbol, uint8 decimals) { + super.initialize(name, symbol, decimals); + _mint(recipient, mintAmount); + + owner = msg.sender; + } + + function mint(address to, uint256 amount) public onlyOwner { + _mint(to, amount); + } +} diff --git a/test/mocks/MockGovernance.sol b/test/mocks/MockGovernance.sol index 3d34b145..ee94c8c7 100644 --- a/test/mocks/MockGovernance.sol +++ b/test/mocks/MockGovernance.sol @@ -4,6 +4,9 @@ pragma solidity ^0.8.24; contract MockGovernance { uint16 private __epoch; + uint32 public constant EPOCH_START = 0; + uint32 public constant EPOCH_DURATION = 7 days; + function claimForInitiative(address) external pure returns (uint256) { return 1000e18; } @@ -15,4 +18,17 @@ contract MockGovernance { function epoch() external view returns (uint16) { return __epoch; } + + function _averageAge(uint120 _currentTimestamp, uint120 _averageTimestamp) internal pure returns (uint120) { + if (_averageTimestamp == 0 || _currentTimestamp < _averageTimestamp) return 0; + return _currentTimestamp - _averageTimestamp; + } + + function lqtyToVotes(uint88 _lqtyAmount, uint120 _currentTimestamp, uint120 _averageTimestamp) + public + pure + returns (uint208) + { + return uint208(_lqtyAmount) * uint208(_averageAge(_currentTimestamp, _averageTimestamp)); + } } diff --git a/test/mocks/MockInitiative.sol b/test/mocks/MockInitiative.sol index 8861f420..47205589 100644 --- a/test/mocks/MockInitiative.sol +++ b/test/mocks/MockInitiative.sol @@ -22,11 +22,17 @@ contract MockInitiative is IInitiative { } /// @inheritdoc IInitiative - function onAfterAllocateLQTY(uint16, address, uint88, uint88) external virtual { + function onAfterAllocateLQTY( + uint16, + address, + IGovernance.UserState calldata, + IGovernance.Allocation calldata, + IGovernance.InitiativeState calldata + ) external virtual { address[] memory initiatives = new address[](0); - int176[] memory deltaLQTYVotes = new int176[](0); - int176[] memory deltaLQTYVetos = new int176[](0); - governance.allocateLQTY(initiatives, deltaLQTYVotes, deltaLQTYVetos); + int88[] memory deltaLQTYVotes = new int88[](0); + int88[] memory deltaLQTYVetos = new int88[](0); + governance.allocateLQTY(initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos); } /// @inheritdoc IInitiative diff --git a/test/recon/BeforeAfter.sol b/test/recon/BeforeAfter.sol new file mode 100644 index 00000000..4f52366a --- /dev/null +++ b/test/recon/BeforeAfter.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-2.0 +pragma solidity ^0.8.0; + +import {Asserts} from "@chimera/Asserts.sol"; +import {Setup} from "./Setup.sol"; +import {IGovernance} from "src/interfaces/IGovernance.sol"; +import {IBribeInitiative} from "src/interfaces/IBribeInitiative.sol"; +import {Governance} from "src/Governance.sol"; + +abstract contract BeforeAfter is Setup, Asserts { + struct Vars { + uint16 epoch; + mapping(address => Governance.InitiativeStatus) initiativeStatus; + // initiative => user => epoch => claimed + mapping(address => mapping(address => mapping(uint16 => bool))) claimedBribeForInitiativeAtEpoch; + mapping(address user => uint128 lqtyBalance) userLqtyBalance; + mapping(address user => uint128 lusdBalance) userLusdBalance; + } + + Vars internal _before; + Vars internal _after; + + modifier withChecks() { + __before(); + _; + __after(); + } + + function __before() internal { + uint16 currentEpoch = governance.epoch(); + _before.epoch = currentEpoch; + for (uint8 i; i < deployedInitiatives.length; i++) { + address initiative = deployedInitiatives[i]; + (Governance.InitiativeStatus status,,) = governance.getInitiativeState(initiative); + _before.initiativeStatus[initiative] = status; + _before.claimedBribeForInitiativeAtEpoch[initiative][user][currentEpoch] = + IBribeInitiative(initiative).claimedBribeAtEpoch(user, currentEpoch); + } + + for (uint8 j; j < users.length; j++) { + _before.userLqtyBalance[users[j]] = uint128(lqty.balanceOf(user)); + _before.userLusdBalance[users[j]] = uint128(lusd.balanceOf(user)); + } + } + + function __after() internal { + uint16 currentEpoch = governance.epoch(); + _after.epoch = currentEpoch; + for (uint8 i; i < deployedInitiatives.length; i++) { + address initiative = deployedInitiatives[i]; + (Governance.InitiativeStatus status,,) = governance.getInitiativeState(initiative); + _after.initiativeStatus[initiative] = status; + _after.claimedBribeForInitiativeAtEpoch[initiative][user][currentEpoch] = + IBribeInitiative(initiative).claimedBribeAtEpoch(user, currentEpoch); + } + + for (uint8 j; j < users.length; j++) { + _after.userLqtyBalance[users[j]] = uint128(lqty.balanceOf(user)); + _after.userLusdBalance[users[j]] = uint128(lusd.balanceOf(user)); + } + } +} diff --git a/test/recon/CryticTester.sol b/test/recon/CryticTester.sol new file mode 100644 index 00000000..95d29eb1 --- /dev/null +++ b/test/recon/CryticTester.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-2.0 +pragma solidity ^0.8.0; + +import {TargetFunctions} from "./TargetFunctions.sol"; +import {CryticAsserts} from "@chimera/CryticAsserts.sol"; + +// echidna . --contract CryticTester --config echidna.yaml +// echidna . --contract CryticTester --config echidna.yaml --format text --test-limit 1000000 --test-mode assertion --workers 10 +// medusa fuzz +contract CryticTester is TargetFunctions, CryticAsserts { + constructor() payable { + setup(); + } +} diff --git a/test/recon/CryticToFoundry.sol b/test/recon/CryticToFoundry.sol new file mode 100644 index 00000000..4e1a2d2e --- /dev/null +++ b/test/recon/CryticToFoundry.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-2.0 +pragma solidity ^0.8.0; + +import {Test} from "forge-std/Test.sol"; +import {TargetFunctions} from "./TargetFunctions.sol"; +import {FoundryAsserts} from "@chimera/FoundryAsserts.sol"; +import {IBribeInitiative} from "src/interfaces/IBribeInitiative.sol"; +import {IGovernance} from "src/interfaces/IGovernance.sol"; +import {Governance} from "src/Governance.sol"; + +import {console} from "forge-std/console.sol"; + +contract CryticToFoundry is Test, TargetFunctions, FoundryAsserts { + function setUp() public { + setup(); + } + + // forge test --match-test test_optimize_property_sum_of_initatives_matches_total_votes_insolvency_0 -vv + function test_optimize_property_sum_of_initatives_matches_total_votes_insolvency_0() public { + vm.warp(block.timestamp + 574062); + + vm.roll(block.number + 280); + + governance_depositLQTY_2(106439091954186822399173735); + + vm.roll(block.number + 748); + vm.warp(block.timestamp + 75040); + governance_depositLQTY(2116436955066717227177); + + governance_allocateLQTY_clamped_single_initiative(1, 1, 0); + + helper_deployInitiative(); + + governance_registerInitiative(1); + + vm.warp(block.timestamp + 566552); + + vm.roll(block.number + 23889); + + governance_allocateLQTY_clamped_single_initiative_2nd_user(31, 1314104679369829143691540410, 0); + (,, uint256 votedPowerSum, uint256 govPower) = _getInitiativeStateAndGlobalState(); + console.log("votedPowerSum", votedPowerSum); + console.log("govPower", govPower); + assert(optimize_property_sum_of_initatives_matches_total_votes_insolvency()); + } +} diff --git a/test/recon/PROPERTIES.md b/test/recon/PROPERTIES.md new file mode 100644 index 00000000..6e42a6cf --- /dev/null +++ b/test/recon/PROPERTIES.md @@ -0,0 +1,46 @@ +## BribeInitiative + +| Property | Description | Implemented | Tested | +| --- | --- | --- | --- | +| BI-01 | User should receive percentage of bribes corresponding to their allocation | ✅ | | +| BI-02 | User can only claim bribes once in an epoch | ✅ | | +| BI-03 | Accounting for user allocation amount is always correct | ✅ | | +| BI-04 | Accounting for total allocation amount is always correct | ✅ | | +| BI-05 | Dust amount remaining after claiming should be less than 100 million wei | ✅ | | +| BI-06 | Accounting for bribe amount for an epoch is always correct | | | +| BI-07 | Sum of user allocations for an epoch = totalLqty allocation for the epoch | | | +| BI-08 | User can’t claim bribes for an epoch in which they aren’t allocated | | | +| BI-09 | User can’t be allocated for future epoch | | | +| BI-10 | totalLQTYAllocatedByEpoch ≥ lqtyAllocatedByUserAtEpoch | | | + +## Governance +| Property | Description | Tested | +| --- | --- | --- | +| GV-01 | Initiative state should only return one state per epoch | ✅ | + +| GV-02 | Initiative in Unregistered state reverts if a user tries to reregister it | | +| GV-03 | Initiative in Unregistered state reverts if a user tries to unregister it | | +| GV-04 | Initiative in Unregistered state reverts if a user tries to claim rewards for it | | + +| GV-05 | A user can always vote if an initiative is active | | +| GV-06 | A user can always remove votes if an initiative is inactive | | +| GV-07 | A user cannot allocate to an initiative if it’s inactive | | +| GV-08 | A user cannot vote more than their voting power | | + +| GV-09 | The sum of votes ≤ total votes | ✅ | + +| GV-10 | Contributions are linear | | + +| GV-11 | Initiatives that are removable can’t be blocked from being removed | | NOTE: currently a removed can go back to being valid + +| GV-12 | Removing vote allocation in multiple chunks results in 100% of requested amount being removed | | + +| GV-13 | If a user has X votes and removes Y votes, they always have X - Y votes left | | +| GV-14 | If a user has X votes and removes Y votes, then withdraws X - Y votes they have 0 left | | + +| GV-15 | A newly created initiative should be in `SKIP` state | | +| GV-16 | An initiative that didn't meet the threshold should be in `SKIP` | | +| GV-17 | An initiative that has sufficiently high vetoes in the next epoch should be `UNREGISTERABLE` | | +| GV-18 | An initiative that has reached sufficient votes in the previous epoch should become `CLAIMABLE` in this epoch | | +| GV-19 | A `CLAIMABLE` initiative can remain `CLAIMABLE` in the epoch, or can become `CLAIMED` once someone claims the rewards | | +| GV-20 | A `CLAIMABLE` initiative can become `CLAIMED` once someone claims the rewards | | \ No newline at end of file diff --git a/test/recon/Properties.sol b/test/recon/Properties.sol new file mode 100644 index 00000000..b639746a --- /dev/null +++ b/test/recon/Properties.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-2.0 +pragma solidity ^0.8.0; + +import {BeforeAfter} from "./BeforeAfter.sol"; + +// NOTE: OptimizationProperties imports Governance properties, to reuse a few fetchers +import {OptimizationProperties} from "./properties/OptimizationProperties.sol"; +import {BribeInitiativeProperties} from "./properties/BribeInitiativeProperties.sol"; +import {SynchProperties} from "./properties/SynchProperties.sol"; +import {RevertProperties} from "./properties/RevertProperties.sol"; +import {TsProperties} from "./properties/TsProperties.sol"; + +abstract contract Properties is + OptimizationProperties, + BribeInitiativeProperties, + SynchProperties, + RevertProperties, + TsProperties +{} diff --git a/test/recon/Setup.sol b/test/recon/Setup.sol new file mode 100644 index 00000000..446c4cc5 --- /dev/null +++ b/test/recon/Setup.sol @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: GPL-2.0 +pragma solidity ^0.8.0; + +import {BaseSetup} from "@chimera/BaseSetup.sol"; +import {vm} from "@chimera/Hevm.sol"; +import {IERC20} from "forge-std/interfaces/IERC20.sol"; + +import {MockERC20Tester} from "../mocks/MockERC20Tester.sol"; +import {MockStakingV1} from "../mocks/MockStakingV1.sol"; +import {Governance} from "src/Governance.sol"; +import {BribeInitiative} from "../../src/BribeInitiative.sol"; +import {IBribeInitiative} from "../../src/interfaces/IBribeInitiative.sol"; +import {IGovernance} from "src/interfaces/IGovernance.sol"; + +abstract contract Setup is BaseSetup { + Governance governance; + MockERC20Tester internal lqty; + MockERC20Tester internal lusd; + IBribeInitiative internal initiative1; + + address internal user = address(this); + address internal user2 = address(0x537C8f3d3E18dF5517a58B3fB9D9143697996802); // derived using makeAddrAndKey + address internal stakingV1; + address internal userProxy; + address[] internal users; + address[] internal deployedInitiatives; + uint256 internal user2Pk = 23868421370328131711506074113045611601786642648093516849953535378706721142721; // derived using makeAddrAndKey + bool internal claimedTwice; + bool internal unableToClaim; + + uint128 internal constant REGISTRATION_FEE = 1e18; + uint128 internal constant REGISTRATION_THRESHOLD_FACTOR = 0.01e18; + uint128 internal constant UNREGISTRATION_THRESHOLD_FACTOR = 4e18; + uint16 internal constant REGISTRATION_WARM_UP_PERIOD = 4; + uint16 internal constant UNREGISTRATION_AFTER_EPOCHS = 4; + uint128 internal constant VOTING_THRESHOLD_FACTOR = 0.04e18; + uint88 internal constant MIN_CLAIM = 500e18; + uint88 internal constant MIN_ACCRUAL = 1000e18; + uint32 internal constant EPOCH_DURATION = 604800; + uint32 internal constant EPOCH_VOTING_CUTOFF = 518400; + + uint120 magnifiedStartTS; + + function setup() internal virtual override { + vm.warp(block.timestamp + EPOCH_DURATION * 4); // Somehow Medusa goes back after the constructor + // Random TS that is realistic + users.push(user); + users.push(user2); + + uint256 initialMintAmount = type(uint88).max; + lqty = new MockERC20Tester(user, initialMintAmount, "Liquity", "LQTY", 18); + lusd = new MockERC20Tester(user, initialMintAmount, "Liquity USD", "LUSD", 18); + lqty.mint(user2, initialMintAmount); + + stakingV1 = address(new MockStakingV1(address(lqty))); + governance = new Governance( + address(lqty), + address(lusd), + stakingV1, + address(lusd), // bold + IGovernance.Configuration({ + registrationFee: REGISTRATION_FEE, + registrationThresholdFactor: REGISTRATION_THRESHOLD_FACTOR, + unregistrationThresholdFactor: UNREGISTRATION_THRESHOLD_FACTOR, + registrationWarmUpPeriod: REGISTRATION_WARM_UP_PERIOD, + unregistrationAfterEpochs: UNREGISTRATION_AFTER_EPOCHS, + votingThresholdFactor: VOTING_THRESHOLD_FACTOR, + minClaim: MIN_CLAIM, + minAccrual: MIN_ACCRUAL, + epochStart: uint32(block.timestamp - EPOCH_DURATION), + /// @audit will this work? + epochDuration: EPOCH_DURATION, + epochVotingCutoff: EPOCH_VOTING_CUTOFF + }), + address(this), + deployedInitiatives // no initial initiatives passed in because don't have cheatcodes for calculating address where gov will be deployed + ); + + // deploy proxy so user can approve it + userProxy = governance.deployUserProxy(); + lqty.approve(address(userProxy), initialMintAmount); + lusd.approve(address(userProxy), initialMintAmount); + + // approve governance for user's tokens + lqty.approve(address(governance), initialMintAmount); + lusd.approve(address(governance), initialMintAmount); + + // register one of the initiatives, leave the other for registering/unregistering via TargetFunction + initiative1 = IBribeInitiative(address(new BribeInitiative(address(governance), address(lusd), address(lqty)))); + deployedInitiatives.push(address(initiative1)); + + governance.registerInitiative(address(initiative1)); + + magnifiedStartTS = uint120(block.timestamp) * uint120(1e18); + } + + function _getDeployedInitiative(uint8 index) internal view returns (address initiative) { + return deployedInitiatives[index % deployedInitiatives.length]; + } + + function _getClampedTokenBalance(address token, address holder) internal view returns (uint256 balance) { + return IERC20(token).balanceOf(holder); + } + + function _getRandomUser(uint8 index) internal view returns (address randomUser) { + return users[index % users.length]; + } + + function _getInitiativeStatus(address) internal returns (uint256) { + (Governance.InitiativeStatus status,,) = governance.getInitiativeState(_getDeployedInitiative(0)); + return uint256(status); + } +} diff --git a/test/recon/TargetFunctions.sol b/test/recon/TargetFunctions.sol new file mode 100644 index 00000000..f1a7335a --- /dev/null +++ b/test/recon/TargetFunctions.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-2.0 +pragma solidity ^0.8.0; + +import {BaseTargetFunctions} from "@chimera/BaseTargetFunctions.sol"; +import {vm} from "@chimera/Hevm.sol"; +import {IERC20Permit} from "openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Permit.sol"; +import {console2} from "forge-std/Test.sol"; + +import {Properties} from "./Properties.sol"; +import {GovernanceTargets} from "./targets/GovernanceTargets.sol"; +import {BribeInitiativeTargets} from "./targets/BribeInitiativeTargets.sol"; +import {MaliciousInitiative} from "../mocks/MaliciousInitiative.sol"; +import {BribeInitiative} from "../../src/BribeInitiative.sol"; +import {ILQTYStaking} from "../../src/interfaces/ILQTYStaking.sol"; +import {IInitiative} from "../../src/interfaces/IInitiative.sol"; +import {IUserProxy} from "../../src/interfaces/IUserProxy.sol"; +import {PermitParams} from "../../src/utils/Types.sol"; + +abstract contract TargetFunctions is GovernanceTargets, BribeInitiativeTargets { + // helper to deploy initiatives for registering that results in more bold transferred to the Governance contract + function helper_deployInitiative() public withChecks { + address initiative = address(new BribeInitiative(address(governance), address(lusd), address(lqty))); + deployedInitiatives.push(initiative); + } + + // helper to simulate bold accrual in Governance contract + function helper_accrueBold(uint88 boldAmount) public withChecks { + boldAmount = uint88(boldAmount % lusd.balanceOf(user)); + // target contract is the user so it can transfer directly + lusd.transfer(address(governance), boldAmount); + } +} diff --git a/test/recon/properties/BribeInitiativeProperties.sol b/test/recon/properties/BribeInitiativeProperties.sol new file mode 100644 index 00000000..6c041115 --- /dev/null +++ b/test/recon/properties/BribeInitiativeProperties.sol @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: GPL-2.0 +pragma solidity ^0.8.0; + +import {BeforeAfter} from "../BeforeAfter.sol"; +import {IBribeInitiative} from "../../../src/interfaces/IBribeInitiative.sol"; + +abstract contract BribeInitiativeProperties is BeforeAfter { + function property_BI01() public { + uint16 currentEpoch = governance.epoch(); + + for (uint8 i; i < deployedInitiatives.length; i++) { + address initiative = deployedInitiatives[i]; + for (uint8 j; j < users.length; j++) { + // if the bool switches, the user has claimed their bribe for the epoch + if ( + _before.claimedBribeForInitiativeAtEpoch[initiative][users[j]][currentEpoch] + != _after.claimedBribeForInitiativeAtEpoch[initiative][user][currentEpoch] + ) { + // calculate user balance delta of the bribe tokens + uint128 userLqtyBalanceDelta = _after.userLqtyBalance[users[j]] - _before.userLqtyBalance[users[j]]; + uint128 userLusdBalanceDelta = _after.userLusdBalance[users[j]] - _before.userLusdBalance[users[j]]; + + // calculate balance delta as a percentage of the total bribe for this epoch + // this is what user DOES receive + (uint128 bribeBoldAmount, uint128 bribeBribeTokenAmount) = + IBribeInitiative(initiative).bribeByEpoch(currentEpoch); + uint128 lqtyPercentageOfBribe = (userLqtyBalanceDelta * 10_000) / bribeBribeTokenAmount; + uint128 lusdPercentageOfBribe = (userLusdBalanceDelta * 10_000) / bribeBoldAmount; + + // Shift right by 40 bits (128 - 88) to get the 88 most significant bits for needed downcasting to compare with lqty allocations + uint88 lqtyPercentageOfBribe88 = uint88(lqtyPercentageOfBribe >> 40); + uint88 lusdPercentageOfBribe88 = uint88(lusdPercentageOfBribe >> 40); + + // calculate user allocation percentage of total for this epoch + // this is what user SHOULD receive + (uint88 lqtyAllocatedByUserAtEpoch,) = + IBribeInitiative(initiative).lqtyAllocatedByUserAtEpoch(users[j], currentEpoch); + (uint88 totalLQTYAllocatedAtEpoch,) = + IBribeInitiative(initiative).totalLQTYAllocatedByEpoch(currentEpoch); + uint88 allocationPercentageOfTotal = + (lqtyAllocatedByUserAtEpoch * 10_000) / totalLQTYAllocatedAtEpoch; + + // check that allocation percentage and received bribe percentage match + eq( + lqtyPercentageOfBribe88, + allocationPercentageOfTotal, + "BI-01: User should receive percentage of LQTY bribes corresponding to their allocation" + ); + eq( + lusdPercentageOfBribe88, + allocationPercentageOfTotal, + "BI-01: User should receive percentage of BOLD bribes corresponding to their allocation" + ); + } + } + } + } + + function property_BI02() public { + t(!claimedTwice, "B2-01: User can only claim bribes once in an epoch"); + } + + function property_BI03() public { + for (uint8 i; i < deployedInitiatives.length; i++) { + IBribeInitiative initiative = IBribeInitiative(deployedInitiatives[i]); + + (uint88 voteLQTY,, uint16 epoch) = governance.lqtyAllocatedByUserToInitiative(user, deployedInitiatives[i]); + + try initiative.lqtyAllocatedByUserAtEpoch(user, epoch) returns (uint88 amt, uint120) { + eq(voteLQTY, amt, "Allocation must match"); + } catch { + t(false, "Allocation doesn't match governance"); + } + } + } + + function property_BI04() public { + uint16 currentEpoch = governance.epoch(); + for (uint8 i; i < deployedInitiatives.length; i++) { + IBribeInitiative initiative = IBribeInitiative(deployedInitiatives[i]); + + // NOTE: This doesn't revert in the future! + uint88 lastKnownLQTYAlloc = _getLastLQTYAllocationKnown(initiative, currentEpoch); + + // We compare when we don't get a revert (a change happened this epoch) + + (uint88 voteLQTY,,,,) = governance.initiativeStates(deployedInitiatives[i]); + + eq(lastKnownLQTYAlloc, voteLQTY, "BI-04: Initiative Account matches governace"); + } + } + + function _getLastLQTYAllocationKnown(IBribeInitiative initiative, uint16 targetEpoch) + internal + view + returns (uint88) + { + uint16 mostRecentTotalEpoch = initiative.getMostRecentTotalEpoch(); + (uint88 totalLQTYAllocatedAtEpoch,) = initiative.totalLQTYAllocatedByEpoch( + (targetEpoch < mostRecentTotalEpoch) ? targetEpoch : mostRecentTotalEpoch + ); + return totalLQTYAllocatedAtEpoch; + } + + // TODO: Looks pretty wrong and inaccurate + // Loop over the initiative + // Have all users claim all + // See what the result is + // See the dust + // Dust cap check + // function property_BI05() public { + // // users can't claim for current epoch so checking for previous + // uint16 checkEpoch = governance.epoch() - 1; + + // for (uint8 i; i < deployedInitiatives.length; i++) { + // address initiative = deployedInitiatives[i]; + // // for any epoch: expected balance = Bribe - claimed bribes, actual balance = bribe token balance of initiative + // // so if the delta between the expected and actual is > 0, dust is being collected + + // uint256 lqtyClaimedAccumulator; + // uint256 lusdClaimedAccumulator; + // for (uint8 j; j < users.length; j++) { + // // if the bool switches, the user has claimed their bribe for the epoch + // if ( + // _before.claimedBribeForInitiativeAtEpoch[initiative][user][checkEpoch] + // != _after.claimedBribeForInitiativeAtEpoch[initiative][user][checkEpoch] + // ) { + // // add user claimed balance delta to the accumulator + // lqtyClaimedAccumulator += _after.userLqtyBalance[users[j]] - _before.userLqtyBalance[users[j]]; + // lusdClaimedAccumulator += _after.userLqtyBalance[users[j]] - _before.userLqtyBalance[users[j]]; + // } + // } + + // (uint128 boldAmount, uint128 bribeTokenAmount) = IBribeInitiative(initiative).bribeByEpoch(checkEpoch); + + // // shift 128 bit to the right to get the most significant bits of the accumulator (256 - 128 = 128) + // uint128 lqtyClaimedAccumulator128 = uint128(lqtyClaimedAccumulator >> 128); + // uint128 lusdClaimedAccumulator128 = uint128(lusdClaimedAccumulator >> 128); + + // // find delta between bribe and claimed amount (how much should be remaining in contract) + // uint128 lusdDelta = boldAmount - lusdClaimedAccumulator128; + // uint128 lqtyDelta = bribeTokenAmount - lqtyClaimedAccumulator128; + + // uint128 initiativeLusdBalance = uint128(lusd.balanceOf(initiative) >> 128); + // uint128 initiativeLqtyBalance = uint128(lqty.balanceOf(initiative) >> 128); + + // lte( + // lusdDelta - initiativeLusdBalance, + // 1e8, + // "BI-05: Bold token dust amount remaining after claiming should be less than 100 million wei" + // ); + // lte( + // lqtyDelta - initiativeLqtyBalance, + // 1e8, + // "BI-05: Bribe token dust amount remaining after claiming should be less than 100 million wei" + // ); + // } + // } + + function property_BI07() public { + // sum user allocations for an epoch + // check that this matches the total allocation for the epoch + for (uint8 i; i < deployedInitiatives.length; i++) { + IBribeInitiative initiative = IBribeInitiative(deployedInitiatives[i]); + uint16 currentEpoch = initiative.getMostRecentTotalEpoch(); + + uint88 sumLqtyAllocated; + for (uint8 j; j < users.length; j++) { + // NOTE: We need to grab user latest + uint16 userEpoch = initiative.getMostRecentUserEpoch(users[j]); + (uint88 lqtyAllocated,) = initiative.lqtyAllocatedByUserAtEpoch(users[j], userEpoch); + sumLqtyAllocated += lqtyAllocated; + } + + (uint88 totalLQTYAllocated,) = initiative.totalLQTYAllocatedByEpoch(currentEpoch); + eq( + sumLqtyAllocated, + totalLQTYAllocated, + "BI-07: Sum of user LQTY allocations for an epoch != total LQTY allocation for the epoch" + ); + } + } + + function property_sum_of_votes_in_bribes_match() public { + uint16 currentEpoch = governance.epoch(); + + // sum user allocations for an epoch + // check that this matches the total allocation for the epoch + for (uint8 i; i < deployedInitiatives.length; i++) { + IBribeInitiative initiative = IBribeInitiative(deployedInitiatives[i]); + uint256 sumOfPower; + for (uint8 j; j < users.length; j++) { + (uint88 lqtyAllocated, uint120 userTS) = initiative.lqtyAllocatedByUserAtEpoch(users[j], currentEpoch); + sumOfPower += governance.lqtyToVotes(lqtyAllocated, userTS, uint32(block.timestamp)); + } + (uint88 totalLQTYAllocated, uint120 totalTS) = initiative.totalLQTYAllocatedByEpoch(currentEpoch); + + uint256 totalRecordedPower = governance.lqtyToVotes(totalLQTYAllocated, totalTS, uint32(block.timestamp)); + + gte(totalRecordedPower, sumOfPower, "property_sum_of_votes_in_bribes_match"); + } + } + + function property_BI08() public { + // users can only claim for epoch that has already passed + uint16 checkEpoch = governance.epoch() - 1; + + // use lqtyAllocatedByUserAtEpoch to determine if a user is allocated for an epoch + // use claimedBribeForInitiativeAtEpoch to determine if user has claimed bribe for an epoch (would require the value changing from false -> true) + for (uint8 i; i < deployedInitiatives.length; i++) { + IBribeInitiative initiative = IBribeInitiative(deployedInitiatives[i]); + for (uint8 j; j < users.length; j++) { + (uint88 lqtyAllocated,) = initiative.lqtyAllocatedByUserAtEpoch(users[j], checkEpoch); + + // check that user had no lqtyAllocated for the epoch and therefore shouldn't be able to claim for it + if (lqtyAllocated == 0) { + // since bool could only possibly change from false -> true, just check that it's the same before and after + bool claimedBefore = + _before.claimedBribeForInitiativeAtEpoch[address(initiative)][users[j]][checkEpoch]; + bool claimedAfter = + _before.claimedBribeForInitiativeAtEpoch[address(initiative)][users[j]][checkEpoch]; + t( + claimedBefore == claimedAfter, + "BI-08: User cannot claim bribes for an epoch in which they are not allocated" + ); + } + } + } + } + + // BI-09: User can’t be allocated for future epoch + function property_BI09() public { + // get one past current epoch in governance + uint16 checkEpoch = governance.epoch() + 1; + // check if any user is allocated for the epoch + for (uint8 i; i < deployedInitiatives.length; i++) { + IBribeInitiative initiative = IBribeInitiative(deployedInitiatives[i]); + for (uint8 j; j < users.length; j++) { + (uint88 lqtyAllocated,) = initiative.lqtyAllocatedByUserAtEpoch(users[j], checkEpoch); + + eq(lqtyAllocated, 0, "BI-09: User cannot be allocated for future epoch"); + } + } + } + + // BI-10: totalLQTYAllocatedByEpoch ≥ lqtyAllocatedByUserAtEpoch + function property_BI10() public { + uint16 checkEpoch = governance.epoch(); + + // check each user allocation for the epoch against the total for the epoch + for (uint8 i; i < deployedInitiatives.length; i++) { + IBribeInitiative initiative = IBribeInitiative(deployedInitiatives[i]); + for (uint8 j; j < users.length; j++) { + (uint88 lqtyAllocated,) = initiative.lqtyAllocatedByUserAtEpoch(users[j], checkEpoch); + (uint88 totalLQTYAllocated,) = initiative.totalLQTYAllocatedByEpoch(checkEpoch); + + gte(totalLQTYAllocated, lqtyAllocated, "BI-10: totalLQTYAllocatedByEpoch >= lqtyAllocatedByUserAtEpoch"); + } + } + } +} diff --git a/test/recon/properties/GovernanceProperties.sol b/test/recon/properties/GovernanceProperties.sol new file mode 100644 index 00000000..99e40b4f --- /dev/null +++ b/test/recon/properties/GovernanceProperties.sol @@ -0,0 +1,561 @@ +// SPDX-License-Identifier: GPL-2.0 +pragma solidity ^0.8.0; + +import {BeforeAfter} from "../BeforeAfter.sol"; +import {Governance} from "src/Governance.sol"; +import {IGovernance} from "src/interfaces/IGovernance.sol"; +import {MockStakingV1} from "test/mocks/MockStakingV1.sol"; +import {vm} from "@chimera/Hevm.sol"; +import {IUserProxy} from "src/interfaces/IUserProxy.sol"; + +abstract contract GovernanceProperties is BeforeAfter { + uint256 constant TOLLERANCE = 1e19; // NOTE: 1e18 is 1 second due to upscaling + /// So we accept at most 10 seconds of errors + + /// A Initiative cannot change in status + /// Except for being unregistered + /// Or claiming rewards + function property_GV01() public { + // first check that epoch hasn't changed after the operation + if (_before.epoch == _after.epoch) { + // loop through the initiatives and check that their status hasn't changed + for (uint8 i; i < deployedInitiatives.length; i++) { + address initiative = deployedInitiatives[i]; + + // Hardcoded Allowed FSM + if (_before.initiativeStatus[initiative] == Governance.InitiativeStatus.UNREGISTERABLE) { + // ALLOW TO SET DISABLE + if (_after.initiativeStatus[initiative] == Governance.InitiativeStatus.DISABLED) { + return; + } + } + + if (_before.initiativeStatus[initiative] == Governance.InitiativeStatus.CLAIMABLE) { + // ALLOW TO CLAIM + if (_after.initiativeStatus[initiative] == Governance.InitiativeStatus.CLAIMED) { + return; + } + } + + if (_before.initiativeStatus[initiative] == Governance.InitiativeStatus.NONEXISTENT) { + // Registered -> SKIP is ok + if (_after.initiativeStatus[initiative] == Governance.InitiativeStatus.WARM_UP) { + return; + } + } + + eq( + uint256(_before.initiativeStatus[initiative]), + uint256(_after.initiativeStatus[initiative]), + "GV-01: Initiative state should only return one state per epoch" + ); + } + } + } + + function property_GV_09() public { + // User stakes + // User allocated + + // allocated is always <= stakes + for (uint256 i; i < users.length; i++) { + // Only sum up user votes + address userProxyAddress = governance.deriveUserProxyAddress(users[i]); + uint256 stake = MockStakingV1(stakingV1).stakes(userProxyAddress); + + (uint88 user_allocatedLQTY,) = governance.userStates(users[i]); + lte(user_allocatedLQTY, stake, "User can never allocated more than stake"); + } + } + + // View vs non view must have same results + function property_viewTotalVotesAndStateEquivalency() public { + for (uint8 i; i < deployedInitiatives.length; i++) { + (IGovernance.InitiativeVoteSnapshot memory initiativeSnapshot_view,,) = + governance.getInitiativeSnapshotAndState(deployedInitiatives[i]); + (, IGovernance.InitiativeVoteSnapshot memory initiativeVoteSnapshot) = + governance.snapshotVotesForInitiative(deployedInitiatives[i]); + + eq(initiativeSnapshot_view.votes, initiativeVoteSnapshot.votes, "votes"); + eq(initiativeSnapshot_view.forEpoch, initiativeVoteSnapshot.forEpoch, "forEpoch"); + eq(initiativeSnapshot_view.lastCountedEpoch, initiativeVoteSnapshot.lastCountedEpoch, "lastCountedEpoch"); + eq(initiativeSnapshot_view.vetos, initiativeVoteSnapshot.vetos, "vetos"); + } + } + + function property_viewCalculateVotingThreshold() public { + (,, bool shouldUpdate) = governance.getTotalVotesAndState(); + + if (!shouldUpdate) { + // If it's already synched it must match + uint256 latestKnownThreshold = governance.getLatestVotingThreshold(); + uint256 calculated = governance.calculateVotingThreshold(); + eq(latestKnownThreshold, calculated, "match"); + } + } + + // Function sound total math + + // NOTE: Global vs Uer vs Initiative requires changes + // User is tracking votes and vetos together + // Whereas Votes and Initiatives only track Votes + /// The Sum of LQTY allocated by Users matches the global state + // NOTE: Sum of positive votes + // Remove the initiative from Unregistered Initiatives + function property_sum_of_lqty_global_user_matches() public { + // Get state + // Get all users + // Sum up all voted users + // Total must match + (uint256 totalUserCountedLQTY, uint256 totalCountedLQTY) = _getGlobalLQTYAndUserSum(); + + eq(totalUserCountedLQTY, totalCountedLQTY, "Global vs SUM(Users_lqty) must match"); + } + + function _getGlobalLQTYAndUserSum() internal returns (uint256, uint256) { + ( + uint88 totalCountedLQTY, + // uint32 after_user_countedVoteLQTYAverageTimestamp // TODO: How do we do this? + ) = governance.globalState(); + + uint256 totalUserCountedLQTY; + for (uint256 i; i < users.length; i++) { + // Only sum up user votes + (uint88 user_voteLQTY,) = _getAllUserAllocations(users[i], true); + totalUserCountedLQTY += user_voteLQTY; + } + + return (totalUserCountedLQTY, totalCountedLQTY); + } + + // NOTE: In principle this will work since this is a easier to reach property vs checking each initiative + function property_ensure_user_alloc_cannot_dos() public { + for (uint256 i; i < users.length; i++) { + // Only sum up user votes + (uint88 user_voteLQTY,) = _getAllUserAllocations(users[i], false); + + lte(user_voteLQTY, uint88(type(int88).max), "User can never allocate more than int88"); + } + } + + /// The Sum of LQTY allocated to Initiatives matches the Sum of LQTY allocated by users + function property_sum_of_lqty_initiative_user_matches() public { + // Get Initiatives + // Get all users + // Sum up all voted users & initiatives + // Total must match + uint256 totalInitiativesCountedVoteLQTY; + uint256 totalInitiativesCountedVetoLQTY; + for (uint256 i; i < deployedInitiatives.length; i++) { + (uint88 voteLQTY, uint88 vetoLQTY,,,) = governance.initiativeStates(deployedInitiatives[i]); + totalInitiativesCountedVoteLQTY += voteLQTY; + totalInitiativesCountedVetoLQTY += vetoLQTY; + } + + uint256 totalUserCountedLQTY; + for (uint256 i; i < users.length; i++) { + (uint88 user_allocatedLQTY,) = governance.userStates(users[i]); + totalUserCountedLQTY += user_allocatedLQTY; + } + + eq( + totalInitiativesCountedVoteLQTY + totalInitiativesCountedVetoLQTY, + totalUserCountedLQTY, + "SUM(Initiatives_lqty) vs SUM(Users_lqty) must match" + ); + } + + // TODO: also `lqtyAllocatedByUserToInitiative` + // For each user, for each initiative, allocation is correct + function property_sum_of_user_initiative_allocations() public { + for (uint256 i; i < deployedInitiatives.length; i++) { + (uint88 initiative_voteLQTY, uint88 initiative_vetoLQTY,,,) = + governance.initiativeStates(deployedInitiatives[i]); + + // Grab all users and sum up their participations + uint256 totalUserVotes; + uint256 totalUserVetos; + for (uint256 j; j < users.length; j++) { + (uint88 vote_allocated, uint88 veto_allocated) = _getUserAllocation(users[j], deployedInitiatives[i]); + totalUserVotes += vote_allocated; + totalUserVetos += veto_allocated; + } + + eq(initiative_voteLQTY, totalUserVotes, "Sum of users, matches initiative votes"); + eq(initiative_vetoLQTY, totalUserVetos, "Sum of users, matches initiative vetos"); + } + } + + // sum of voting power for users that allocated to an initiative == the voting power of the initiative + /// TODO ?? + function property_sum_of_user_voting_weights_strict() public { + // loop through all users + // - calculate user voting weight for the given timestamp + // - sum user voting weights for the given epoch + // - compare with the voting weight of the initiative for the epoch for the same timestamp + VotesSumAndInitiativeSum[] memory votesSumAndInitiativeValues = _getUserVotesSumAndInitiativesVotes(); + + for (uint256 i; i < votesSumAndInitiativeValues.length; i++) { + eq( + votesSumAndInitiativeValues[i].userSum, + votesSumAndInitiativeValues[i].initiativeWeight, + "initiative voting weights and user's allocated weight differs for initiative" + ); + } + } + + function property_sum_of_user_voting_weights_bounded() public { + // loop through all users + // - calculate user voting weight for the given timestamp + // - sum user voting weights for the given epoch + // - compare with the voting weight of the initiative for the epoch for the same timestamp + VotesSumAndInitiativeSum[] memory votesSumAndInitiativeValues = _getUserVotesSumAndInitiativesVotes(); + + for (uint256 i; i < votesSumAndInitiativeValues.length; i++) { + eq(votesSumAndInitiativeValues[i].userSum, votesSumAndInitiativeValues[i].initiativeWeight, "Matching"); + t( + votesSumAndInitiativeValues[i].userSum == votesSumAndInitiativeValues[i].initiativeWeight + || ( + votesSumAndInitiativeValues[i].userSum + >= votesSumAndInitiativeValues[i].initiativeWeight - TOLLERANCE + && votesSumAndInitiativeValues[i].userSum + <= votesSumAndInitiativeValues[i].initiativeWeight + TOLLERANCE + ), + "initiative voting weights and user's allocated weight match within tollerance" + ); + } + } + + struct VotesSumAndInitiativeSum { + uint256 userSum; + uint256 initiativeWeight; + } + + function _getUserVotesSumAndInitiativesVotes() internal returns (VotesSumAndInitiativeSum[] memory) { + VotesSumAndInitiativeSum[] memory acc = new VotesSumAndInitiativeSum[](deployedInitiatives.length); + for (uint256 i; i < deployedInitiatives.length; i++) { + uint240 userWeightAccumulatorForInitiative; + for (uint256 j; j < users.length; j++) { + (uint88 userVoteLQTY,,) = governance.lqtyAllocatedByUserToInitiative(users[j], deployedInitiatives[i]); + // TODO: double check that okay to use this average timestamp + (, uint120 averageStakingTimestamp) = governance.userStates(users[j]); + // add the weight calculated for each user's allocation to the accumulator + userWeightAccumulatorForInitiative += governance.lqtyToVotes( + userVoteLQTY, uint120(block.timestamp) * uint120(1e18), averageStakingTimestamp + ); + } + + (uint88 initiativeVoteLQTY,, uint120 initiativeAverageStakingTimestampVoteLQTY,,) = + governance.initiativeStates(deployedInitiatives[i]); + uint240 initiativeWeight = governance.lqtyToVotes( + initiativeVoteLQTY, uint120(block.timestamp) * uint120(1e18), initiativeAverageStakingTimestampVoteLQTY + ); + + acc[i].userSum = userWeightAccumulatorForInitiative; + acc[i].initiativeWeight = initiativeWeight; + } + + return acc; + } + + function property_allocations_are_never_dangerously_high() public { + for (uint256 i; i < deployedInitiatives.length; i++) { + for (uint256 j; j < users.length; j++) { + (uint88 vote_allocated, uint88 veto_allocated) = _getUserAllocation(users[j], deployedInitiatives[i]); + lte(vote_allocated, uint88(type(int88).max), "Vote is never above int88.max"); + lte(veto_allocated, uint88(type(int88).max), "Veto is Never above int88.max"); + } + } + } + + function property_sum_of_initatives_matches_total_votes_strict() public { + // Sum up all initiatives + // Compare to total votes + (uint256 allocatedLQTYSum, uint256 totalCountedLQTY, uint256 votedPowerSum, uint256 govPower) = + _getInitiativeStateAndGlobalState(); + + eq(allocatedLQTYSum, totalCountedLQTY, "LQTY Sum of Initiative State matches Global State at all times"); + eq(votedPowerSum, govPower, "Voting Power Sum of Initiative State matches Global State at all times"); + } + + function property_sum_of_initatives_matches_total_votes_bounded() public { + // Sum up all initiatives + // Compare to total votes + (uint256 allocatedLQTYSum, uint256 totalCountedLQTY, uint256 votedPowerSum, uint256 govPower) = + _getInitiativeStateAndGlobalState(); + + t( + allocatedLQTYSum == totalCountedLQTY + || (allocatedLQTYSum >= totalCountedLQTY - TOLLERANCE && allocatedLQTYSum <= totalCountedLQTY + TOLLERANCE), + "Sum of Initiative LQTY And State matches within absolute tollerance" + ); + + t( + votedPowerSum == govPower + || (votedPowerSum >= govPower - TOLLERANCE && votedPowerSum <= govPower + TOLLERANCE), + "Sum of Initiative LQTY And State matches within absolute tollerance" + ); + } + + function _getInitiativeStateAndGlobalState() internal returns (uint256, uint256, uint256, uint256) { + (uint88 totalCountedLQTY, uint120 global_countedVoteLQTYAverageTimestamp) = governance.globalState(); + + // Can sum via projection I guess + + // Global Acc + // Initiative Acc + uint256 allocatedLQTYSum; + uint256 votedPowerSum; + for (uint256 i; i < deployedInitiatives.length; i++) { + ( + uint88 voteLQTY, + uint88 vetoLQTY, + uint120 averageStakingTimestampVoteLQTY, + uint120 averageStakingTimestampVetoLQTY, + ) = governance.initiativeStates(deployedInitiatives[i]); + + // Conditional, only if not DISABLED + (Governance.InitiativeStatus status,,) = governance.getInitiativeState(deployedInitiatives[i]); + // Conditionally add based on state + if (status != Governance.InitiativeStatus.DISABLED) { + allocatedLQTYSum += voteLQTY; + // Sum via projection + votedPowerSum += governance.lqtyToVotes( + voteLQTY, + uint120(block.timestamp) * uint120(governance.TIMESTAMP_PRECISION()), + averageStakingTimestampVoteLQTY + ); + } + } + + uint256 govPower = governance.lqtyToVotes( + totalCountedLQTY, + uint120(block.timestamp) * uint120(governance.TIMESTAMP_PRECISION()), + global_countedVoteLQTYAverageTimestamp + ); + + return (allocatedLQTYSum, totalCountedLQTY, votedPowerSum, govPower); + } + + /// NOTE: This property can break in some specific combinations of: + /// Becomes SKIP due to high treshold + /// threshold is lowered + /// Initiative becomes claimable + function check_skip_consistecy(uint8 initiativeIndex) public { + // If a initiative has no votes + // In the next epoch it can either be SKIP or UNREGISTERABLE + address initiative = _getDeployedInitiative(initiativeIndex); + + (Governance.InitiativeStatus status,,) = governance.getInitiativeState(initiative); + if (status == Governance.InitiativeStatus.SKIP) { + vm.warp(block.timestamp + governance.EPOCH_DURATION()); + (Governance.InitiativeStatus newStatus,,) = governance.getInitiativeState(initiative); + t( + uint256(status) == uint256(newStatus) + || uint256(newStatus) == uint256(Governance.InitiativeStatus.UNREGISTERABLE) + || uint256(newStatus) == uint256(Governance.InitiativeStatus.CLAIMABLE), + "Either SKIP or UNREGISTERABLE or CLAIMABLE" + ); + } + } + + function check_warmup_unregisterable_consistency(uint8 initiativeIndex) public { + // Status after MUST NOT be UNREGISTERABLE + address initiative = _getDeployedInitiative(initiativeIndex); + (Governance.InitiativeStatus status,,) = governance.getInitiativeState(initiative); + + if (status == Governance.InitiativeStatus.WARM_UP) { + vm.warp(block.timestamp + governance.EPOCH_DURATION()); + (Governance.InitiativeStatus newStatus,,) = governance.getInitiativeState(initiative); + + // Next status must be SKIP, because by definition it has + // Received no votes (cannot) + // Must not be UNREGISTERABLE + t(uint256(newStatus) == uint256(Governance.InitiativeStatus.SKIP), "Must be SKIP"); + } + } + + /// NOTE: This property can break in some specific combinations of: + /// Becomes unregisterable due to high treshold + /// Is not unregistered + /// threshold is lowered + /// Initiative becomes claimable + function check_unregisterable_consistecy(uint8 initiativeIndex) public { + // If a initiative has no votes and is UNREGISTERABLE + // In the next epoch it will remain UNREGISTERABLE + address initiative = _getDeployedInitiative(initiativeIndex); + + (Governance.InitiativeStatus status,,) = governance.getInitiativeState(initiative); + if (status == Governance.InitiativeStatus.UNREGISTERABLE) { + vm.warp(block.timestamp + governance.EPOCH_DURATION()); + (Governance.InitiativeStatus newStatus,,) = governance.getInitiativeState(initiative); + t(uint256(status) == uint256(newStatus), "UNREGISTERABLE must remain UNREGISTERABLE"); + } + } + + // TODO: Maybe check snapshot of states and ensure it can never be less than 4 epochs b4 unregisterable + + function check_claim_soundness() public { + // Check if initiative is claimable + // If it is assert the check + for (uint256 i; i < deployedInitiatives.length; i++) { + (Governance.InitiativeStatus status,,) = governance.getInitiativeState(deployedInitiatives[i]); + + (, Governance.InitiativeState memory initiativeState,) = + governance.getInitiativeSnapshotAndState(deployedInitiatives[i]); + + if (status == Governance.InitiativeStatus.CLAIMABLE) { + t(governance.epoch() > 0, "Can never be claimable in epoch 0!"); // Overflow Check, also flags misconfiguration + // Normal check + t(initiativeState.lastEpochClaim < governance.epoch() - 1, "Cannot be CLAIMABLE, should be CLAIMED"); + } + } + } + + // TODO: Optimization property to show max loss + // TODO: Same identical optimization property for Bribes claiming + /// Should prob change the math to view it in bribes for easier debug + function check_claimable_solvency() public { + // Accrue all initiatives + // Get bold amount + // Sum up the initiatives claimable vs the bold + + // Check if initiative is claimable + // If it is assert the check + uint256 claimableSum; + for (uint256 i; i < deployedInitiatives.length; i++) { + // NOTE: Non view so it accrues state + (Governance.InitiativeStatus status,, uint256 claimableAmount) = + governance.getInitiativeState(deployedInitiatives[i]); + + claimableSum += claimableAmount; + } + + // Grab accrued + uint256 boldAccrued = governance.boldAccrued(); + + lte(claimableSum, boldAccrued, "Total Claims are always LT all bold"); + } + + function check_realized_claiming_solvency() public { + uint256 claimableSum; + for (uint256 i; i < deployedInitiatives.length; i++) { + uint256 claimed = governance.claimForInitiative(deployedInitiatives[i]); + + claimableSum += claimed; + } + + // Grab accrued + uint256 boldAccrued = governance.boldAccrued(); + + lte(claimableSum, boldAccrued, "Total Claims are always LT all bold"); + } + + // TODO: Optimization of this to determine max damage, and max insolvency + + function _getUserAllocation(address theUser, address initiative) + internal + view + returns (uint88 votes, uint88 vetos) + { + (votes, vetos,) = governance.lqtyAllocatedByUserToInitiative(theUser, initiative); + } + + function _getAllUserAllocations(address theUser, bool skipDisabled) internal returns (uint88 votes, uint88 vetos) { + for (uint256 i; i < deployedInitiatives.length; i++) { + (uint88 allocVotes, uint88 allocVetos,) = + governance.lqtyAllocatedByUserToInitiative(theUser, deployedInitiatives[i]); + if (skipDisabled) { + (Governance.InitiativeStatus status,,) = governance.getInitiativeState(deployedInitiatives[i]); + + // Conditionally add based on state + if (status != Governance.InitiativeStatus.DISABLED) { + votes += allocVotes; + vetos += allocVetos; + } + } else { + // Always add + votes += allocVotes; + vetos += allocVetos; + } + } + } + + function property_alloc_deposit_reset_is_idempotent( + uint8 initiativesIndex, + uint96 deltaLQTYVotes, + uint96 deltaLQTYVetos, + uint88 lqtyAmount + ) public withChecks { + address targetInitiative = _getDeployedInitiative(initiativesIndex); + + // 0. Reset first to ensure we start fresh, else the totals can be out of whack + // TODO: prob unnecessary + // Cause we always reset anyway + { + int88[] memory zeroes = new int88[](deployedInitiatives.length); + + governance.allocateLQTY(deployedInitiatives, deployedInitiatives, zeroes, zeroes); + } + + // GET state and initiative data before allocation + (uint88 totalCountedLQTY, uint120 user_countedVoteLQTYAverageTimestamp) = governance.globalState(); + ( + uint88 voteLQTY, + uint88 vetoLQTY, + uint120 averageStakingTimestampVoteLQTY, + uint120 averageStakingTimestampVetoLQTY, + ) = governance.initiativeStates(targetInitiative); + + // Allocate + { + uint96 stakedAmount = IUserProxy(governance.deriveUserProxyAddress(user)).staked(); + + address[] memory initiatives = new address[](1); + initiatives[0] = targetInitiative; + int88[] memory deltaLQTYVotesArray = new int88[](1); + deltaLQTYVotesArray[0] = int88(uint88(deltaLQTYVotes % stakedAmount)); + int88[] memory deltaLQTYVetosArray = new int88[](1); + deltaLQTYVetosArray[0] = int88(uint88(deltaLQTYVetos % stakedAmount)); + + governance.allocateLQTY(deployedInitiatives, initiatives, deltaLQTYVotesArray, deltaLQTYVetosArray); + } + + // Deposit (Changes total LQTY an hopefully also changes ts) + { + (, uint120 averageStakingTimestamp1) = governance.userStates(user); + + lqtyAmount = uint88(lqtyAmount % lqty.balanceOf(user)); + governance.depositLQTY(lqtyAmount); + (, uint120 averageStakingTimestamp2) = governance.userStates(user); + + require(averageStakingTimestamp2 > averageStakingTimestamp1, "Must have changed"); + } + + // REMOVE STUFF to remove the user data + { + int88[] memory zeroes = new int88[](deployedInitiatives.length); + governance.allocateLQTY(deployedInitiatives, deployedInitiatives, zeroes, zeroes); + } + + // Check total allocation and initiative allocation + { + (uint88 after_totalCountedLQTY, uint120 after_user_countedVoteLQTYAverageTimestamp) = + governance.globalState(); + ( + uint88 after_voteLQTY, + uint88 after_vetoLQTY, + uint120 after_averageStakingTimestampVoteLQTY, + uint120 after_averageStakingTimestampVetoLQTY, + ) = governance.initiativeStates(targetInitiative); + + eq(voteLQTY, after_voteLQTY, "Same vote"); + eq(vetoLQTY, after_vetoLQTY, "Same veto"); + eq(averageStakingTimestampVoteLQTY, after_averageStakingTimestampVoteLQTY, "Same ts vote"); + eq(averageStakingTimestampVetoLQTY, after_averageStakingTimestampVetoLQTY, "Same ts veto"); + + eq(totalCountedLQTY, after_totalCountedLQTY, "Same total LQTY"); + eq(user_countedVoteLQTYAverageTimestamp, after_user_countedVoteLQTYAverageTimestamp, "Same total ts"); + } + } +} diff --git a/test/recon/properties/OptimizationProperties.sol b/test/recon/properties/OptimizationProperties.sol new file mode 100644 index 00000000..6c6c8d2f --- /dev/null +++ b/test/recon/properties/OptimizationProperties.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: GPL-2.0 +pragma solidity ^0.8.0; + +import {BeforeAfter} from "../BeforeAfter.sol"; +import {Governance} from "src/Governance.sol"; +import {IGovernance} from "src/interfaces/IGovernance.sol"; +import {MockStakingV1} from "test/mocks/MockStakingV1.sol"; +import {vm} from "@chimera/Hevm.sol"; +import {IUserProxy} from "src/interfaces/IUserProxy.sol"; +import {GovernanceProperties} from "./GovernanceProperties.sol"; +import {console} from "forge-std/console.sol"; + +// NOTE: These run only if you use `optimization` mode and set the correct prefix +// See echidna.yaml +abstract contract OptimizationProperties is GovernanceProperties { + function optimize_max_sum_of_user_voting_weights_insolvent() public returns (int256) { + VotesSumAndInitiativeSum[] memory results = _getUserVotesSumAndInitiativesVotes(); + + int256 max = 0; + + // User have more than initiative, we are insolvent + for (uint256 i; i < results.length; i++) { + if (results[i].userSum > results[i].initiativeWeight) { + max = int256(results[i].userSum) - int256(results[i].initiativeWeight); + } + } + + return max; + } + + function optimize_max_sum_of_user_voting_weights_underpaying() public returns (int256) { + VotesSumAndInitiativeSum[] memory results = _getUserVotesSumAndInitiativesVotes(); + + int256 max = 0; + + for (uint256 i; i < results.length; i++) { + // Initiative has more than users, we are underpaying + if (results[i].initiativeWeight > results[i].userSum) { + max = int256(results[i].initiativeWeight) - int256(results[i].userSum); + } + } + + return max; + } + + function optimize_max_claim_insolvent() public returns (int256) { + uint256 claimableSum; + for (uint256 i; i < deployedInitiatives.length; i++) { + // NOTE: Non view so it accrues state + (Governance.InitiativeStatus status,, uint256 claimableAmount) = + governance.getInitiativeState(deployedInitiatives[i]); + + claimableSum += claimableAmount; + } + + // Grab accrued + uint256 boldAccrued = governance.boldAccrued(); + + int256 max; + if (claimableSum > boldAccrued) { + max = int256(claimableSum) - int256(boldAccrued); + } + + return max; + } + + // NOTE: This property is not particularly good as you can just do a donation and not vote + // This douesn't really highlight a loss + function optimize_max_claim_underpay() public returns (int256) { + uint256 claimableSum; + for (uint256 i; i < deployedInitiatives.length; i++) { + // NOTE: Non view so it accrues state + (Governance.InitiativeStatus status,, uint256 claimableAmount) = + governance.getInitiativeState(deployedInitiatives[i]); + + claimableSum += claimableAmount; + } + + // Grab accrued + uint256 boldAccrued = governance.boldAccrued(); + + int256 max; + if (boldAccrued > claimableSum) { + max = int256(boldAccrued) - int256(claimableSum); + } + + return max; + } + + function property_sum_of_initatives_matches_total_votes_insolvency_assertion() public { + uint256 delta = 0; + + (,, uint256 votedPowerSum, uint256 govPower) = _getInitiativeStateAndGlobalState(); + + if (votedPowerSum > govPower) { + delta = votedPowerSum - govPower; + + console.log("votedPowerSum * 1e18 / govPower", votedPowerSum * 1e18 / govPower); + } + + console.log("votedPowerSum", votedPowerSum); + console.log("govPower", govPower); + console.log("delta", delta); + + t(delta < 4e25, "Delta is too big"); // 3e25 was found via optimization, no value past that was found + } + + function optimize_property_sum_of_lqty_global_user_matches_insolvency() public returns (int256) { + int256 max = 0; + + (uint256 totalUserCountedLQTY, uint256 totalCountedLQTY) = _getGlobalLQTYAndUserSum(); + + if (totalUserCountedLQTY > totalCountedLQTY) { + max = int256(totalUserCountedLQTY) - int256(totalCountedLQTY); + } + + return max; + } + + function optimize_property_sum_of_lqty_global_user_matches_underpaying() public returns (int256) { + int256 max = 0; + + (uint256 totalUserCountedLQTY, uint256 totalCountedLQTY) = _getGlobalLQTYAndUserSum(); + + if (totalCountedLQTY > totalUserCountedLQTY) { + max = int256(totalCountedLQTY) - int256(totalUserCountedLQTY); + } + + return max; + } + + function optimize_property_sum_of_initatives_matches_total_votes_insolvency() public returns (bool) { + int256 max = 0; + + (,, uint256 votedPowerSum, uint256 govPower) = _getInitiativeStateAndGlobalState(); + + if (votedPowerSum > govPower) { + max = int256(votedPowerSum) - int256(govPower); + } + + return max < 3e25; + } + + function optimize_property_sum_of_initatives_matches_total_votes_underpaying() public returns (int256) { + int256 max = 0; + + (,, uint256 votedPowerSum, uint256 govPower) = _getInitiativeStateAndGlobalState(); + + if (govPower > votedPowerSum) { + max = int256(govPower) - int256(votedPowerSum); + } + + return max; // 177155848800000000000000000000000000 (2^117) + } +} diff --git a/test/recon/properties/RevertProperties.sol b/test/recon/properties/RevertProperties.sol new file mode 100644 index 00000000..6d73d5e6 --- /dev/null +++ b/test/recon/properties/RevertProperties.sol @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: GPL-2.0 +pragma solidity ^0.8.0; + +import {BeforeAfter} from "../BeforeAfter.sol"; +import {Governance} from "src/Governance.sol"; +import {IGovernance} from "src/interfaces/IGovernance.sol"; +import {IBribeInitiative} from "src/interfaces/IBribeInitiative.sol"; + +// The are view functions that should never revert +abstract contract RevertProperties is BeforeAfter { + function property_computingGlobalPowerNeverReverts() public { + (uint88 totalCountedLQTY, uint120 global_countedVoteLQTYAverageTimestamp) = governance.globalState(); + + try governance.lqtyToVotes( + totalCountedLQTY, + uint120(block.timestamp) * uint120(governance.TIMESTAMP_PRECISION()), + global_countedVoteLQTYAverageTimestamp + ) {} catch { + t(false, "Should never revert"); + } + } + + function property_summingInitiativesPowerNeverReverts() public { + uint256 votedPowerSum; + for (uint256 i; i < deployedInitiatives.length; i++) { + ( + uint88 voteLQTY, + uint88 vetoLQTY, + uint120 averageStakingTimestampVoteLQTY, + uint120 averageStakingTimestampVetoLQTY, + ) = governance.initiativeStates(deployedInitiatives[i]); + + // Sum via projection + uint256 prevSum = votedPowerSum; + unchecked { + try governance.lqtyToVotes( + voteLQTY, + uint120(block.timestamp) * uint120(governance.TIMESTAMP_PRECISION()), + averageStakingTimestampVoteLQTY + ) returns (uint208 res) { + votedPowerSum += res; + } catch { + t(false, "Should never revert"); + } + } + gte(votedPowerSum, prevSum, "overflow detected"); + } + } + + function property_shouldNeverRevertSnapshotAndState(uint8 initiativeIndex) public { + address initiative = _getDeployedInitiative(initiativeIndex); + + try governance.getInitiativeSnapshotAndState(initiative) {} + catch { + t(false, "should never revert"); + } + } + + function property_shouldGetTotalVotesAndState() public { + try governance.getTotalVotesAndState() {} + catch { + t(false, "should never revert"); + } + } + + function property_shouldNeverRevertepoch() public { + try governance.epoch() {} + catch { + t(false, "should never revert"); + } + } + + function property_shouldNeverRevertepochStart(uint8 initiativeIndex) public { + address initiative = _getDeployedInitiative(initiativeIndex); + + try governance.getInitiativeSnapshotAndState(initiative) {} + catch { + t(false, "should never revert"); + } + } + + function property_shouldNeverRevertsecondsWithinEpoch() public { + try governance.secondsWithinEpoch() {} + catch { + t(false, "should never revert"); + } + } + + function property_shouldNeverRevertlqtyToVotes() public { + // TODO GRAB THE STATE VALUES + // governance.lqtyToVotes(); + } + + function property_shouldNeverRevertgetLatestVotingThreshold() public { + try governance.getLatestVotingThreshold() {} + catch { + t(false, "should never revert"); + } + } + + function property_shouldNeverRevertcalculateVotingThreshold() public { + try governance.calculateVotingThreshold() {} + catch { + t(false, "should never revert"); + } + } + + function property_shouldNeverRevertgetTotalVotesAndState() public { + try governance.getTotalVotesAndState() {} + catch { + t(false, "should never revert"); + } + } + + function property_shouldNeverRevertgetInitiativeSnapshotAndState(uint8 initiativeIndex) public { + address initiative = _getDeployedInitiative(initiativeIndex); + + try governance.getInitiativeSnapshotAndState(initiative) {} + catch { + t(false, "should never revert"); + } + } + + function property_shouldNeverRevertsnapshotVotesForInitiative(uint8 initiativeIndex) public { + address initiative = _getDeployedInitiative(initiativeIndex); + + try governance.snapshotVotesForInitiative(initiative) {} + catch { + t(false, "should never revert"); + } + } + + function property_shouldNeverRevertgetInitiativeState(uint8 initiativeIndex) public { + address initiative = _getDeployedInitiative(initiativeIndex); + + try governance.getInitiativeState(initiative) {} + catch { + t(false, "should never revert"); + } + } + + function property_shouldNeverRevertgetInitiativeState_arbitrary(address initiative) public { + try governance.getInitiativeState(initiative) {} + catch { + t(false, "should never revert"); + } + } + + /// TODO: Consider creating this with somewhat realistic value + /// Arbitrary values can too easily overflow + // function property_shouldNeverRevertgetInitiativeState_arbitrary( + // address _initiative, + // IGovernance.VoteSnapshot memory _votesSnapshot, + // IGovernance.InitiativeVoteSnapshot memory _votesForInitiativeSnapshot, + // IGovernance.InitiativeState memory _initiativeState + // ) public { + // // NOTE: Maybe this can revert due to specific max values + // try governance.getInitiativeState( + // _initiative, + // _votesSnapshot, + // _votesForInitiativeSnapshot, + // _initiativeState + // ) {} catch { + // t(false, "should never revert"); + // } + // } +} diff --git a/test/recon/properties/SynchProperties.sol b/test/recon/properties/SynchProperties.sol new file mode 100644 index 00000000..1414c64c --- /dev/null +++ b/test/recon/properties/SynchProperties.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-2.0 +pragma solidity ^0.8.0; + +import {BeforeAfter} from "../BeforeAfter.sol"; +import {Governance} from "src/Governance.sol"; +import {IGovernance} from "src/interfaces/IGovernance.sol"; +import {IBribeInitiative} from "src/interfaces/IBribeInitiative.sol"; + +abstract contract SynchProperties is BeforeAfter { + // Properties that ensure that the states are synched + + // Go through each initiative + // Go through each user + // Ensure that a non zero vote uses the user latest TS + // This ensures that the math is correct in removal and addition + function property_initiative_ts_matches_user_when_non_zero() public { + // For all strategies + for (uint256 i; i < deployedInitiatives.length; i++) { + for (uint256 j; j < users.length; j++) { + (uint88 votes,, uint16 epoch) = + governance.lqtyAllocatedByUserToInitiative(users[j], deployedInitiatives[i]); + + // Grab epoch from initiative + (uint88 lqtyAllocatedByUserAtEpoch, uint120 ts) = + IBribeInitiative(deployedInitiatives[i]).lqtyAllocatedByUserAtEpoch(users[j], epoch); + + // Check that TS matches (only for votes) + eq(lqtyAllocatedByUserAtEpoch, votes, "Votes must match at all times"); + + if (votes != 0) { + // if we're voting and the votes are different from 0 + // then we check user TS + (, uint120 averageStakingTimestamp) = governance.userStates(users[j]); + + eq(averageStakingTimestamp, ts, "Timestamp must be most recent when it's non zero"); + } else { + // NOTE: If votes are zero the TS is passed, but it is not a useful value + // This is left here as a note for the reviewer + } + } + } + } +} diff --git a/test/recon/properties/TsProperties.sol b/test/recon/properties/TsProperties.sol new file mode 100644 index 00000000..1fa84773 --- /dev/null +++ b/test/recon/properties/TsProperties.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-2.0 +pragma solidity ^0.8.0; + +import {BeforeAfter} from "../BeforeAfter.sol"; +import {Governance} from "src/Governance.sol"; +import {IGovernance} from "src/interfaces/IGovernance.sol"; +import {IBribeInitiative} from "src/interfaces/IBribeInitiative.sol"; + +abstract contract TsProperties is BeforeAfter { + // Properties that ensure that a user TS is somewhat sound + + function property_user_ts_is_always_greater_than_start() public { + for (uint256 i; i < users.length; i++) { + (uint88 user_allocatedLQTY, uint120 userTs) = governance.userStates(users[i]); + if (user_allocatedLQTY > 0) { + gte(userTs, magnifiedStartTS, "User ts must always be GTE than start"); + } + } + } + + function property_global_ts_is_always_greater_than_start() public { + (uint88 totalCountedLQTY, uint120 globalTs) = governance.globalState(); + + if (totalCountedLQTY > 0) { + gte(globalTs, magnifiedStartTS, "Global ts must always be GTE than start"); + } + } + + // TODO: Waiting 1 second should give 1 an extra second * WAD power +} diff --git a/test/recon/targets/BribeInitiativeTargets.sol b/test/recon/targets/BribeInitiativeTargets.sol new file mode 100644 index 00000000..694c7e0e --- /dev/null +++ b/test/recon/targets/BribeInitiativeTargets.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: GPL-2.0 +pragma solidity ^0.8.0; + +import {Test} from "forge-std/Test.sol"; +import {BaseTargetFunctions} from "@chimera/BaseTargetFunctions.sol"; +import {vm} from "@chimera/Hevm.sol"; + +import {IInitiative} from "src/interfaces/IInitiative.sol"; +import {IBribeInitiative} from "src/interfaces/IBribeInitiative.sol"; +import {DoubleLinkedList} from "src/utils/DoubleLinkedList.sol"; +import {Properties} from "../Properties.sol"; + +abstract contract BribeInitiativeTargets is Test, BaseTargetFunctions, Properties { + using DoubleLinkedList for DoubleLinkedList.List; + + // NOTE: initiatives that get called here are deployed but not necessarily registered + + function initiative_depositBribe(uint128 boldAmount, uint128 bribeTokenAmount, uint16 epoch, uint8 initiativeIndex) + public + withChecks + { + IBribeInitiative initiative = IBribeInitiative(_getDeployedInitiative(initiativeIndex)); + + // clamp token amounts using user balance + boldAmount = uint128(boldAmount % lusd.balanceOf(user)); + bribeTokenAmount = uint128(bribeTokenAmount % lqty.balanceOf(user)); + + lusd.approve(address(initiative), boldAmount); + lqty.approve(address(initiative), bribeTokenAmount); + + (uint128 boldAmountB4, uint128 bribeTokenAmountB4) = IBribeInitiative(initiative).bribeByEpoch(epoch); + + initiative.depositBribe(boldAmount, bribeTokenAmount, epoch); + + (uint128 boldAmountAfter, uint128 bribeTokenAmountAfter) = IBribeInitiative(initiative).bribeByEpoch(epoch); + + eq(boldAmountB4 + boldAmount, boldAmountAfter, "Bold amount tracking is sound"); + eq(bribeTokenAmountB4 + bribeTokenAmount, bribeTokenAmountAfter, "Bribe amount tracking is sound"); + } + + // Canaries are no longer necessary + // function canary_bribeWasThere(uint8 initiativeIndex) public { + // uint16 epoch = governance.epoch(); + // IBribeInitiative initiative = IBribeInitiative(_getDeployedInitiative(initiativeIndex)); + + // (uint128 boldAmount, uint128 bribeTokenAmount) = initiative.bribeByEpoch(epoch); + // t(boldAmount == 0 && bribeTokenAmount == 0, "A bribe was found"); + // } + + // bool hasClaimedBribes; + // function canary_has_claimed() public { + // t(!hasClaimedBribes, "has claimed"); + // } + + function clamped_claimBribes(uint8 initiativeIndex) public { + IBribeInitiative initiative = IBribeInitiative(_getDeployedInitiative(initiativeIndex)); + + uint16 userEpoch = initiative.getMostRecentUserEpoch(user); + uint16 stateEpoch = initiative.getMostRecentTotalEpoch(); + initiative_claimBribes(governance.epoch() - 1, userEpoch, stateEpoch, initiativeIndex); + } + + function initiative_claimBribes( + uint16 epoch, + uint16 prevAllocationEpoch, + uint16 prevTotalAllocationEpoch, + uint8 initiativeIndex + ) public withChecks { + IBribeInitiative initiative = IBribeInitiative(_getDeployedInitiative(initiativeIndex)); + + // clamp epochs by using the current governance epoch + epoch = epoch % governance.epoch(); + prevAllocationEpoch = prevAllocationEpoch % governance.epoch(); + prevTotalAllocationEpoch = prevTotalAllocationEpoch % governance.epoch(); + + IBribeInitiative.ClaimData[] memory claimData = new IBribeInitiative.ClaimData[](1); + claimData[0] = IBribeInitiative.ClaimData({ + epoch: epoch, + prevLQTYAllocationEpoch: prevAllocationEpoch, + prevTotalLQTYAllocationEpoch: prevTotalAllocationEpoch + }); + + bool alreadyClaimed = initiative.claimedBribeAtEpoch(user, epoch); + + try initiative.claimBribes(claimData) { + // Claiming at the same epoch is an issue + if (alreadyClaimed) { + // toggle canary that breaks the BI-02 property + claimedTwice = true; + } + } catch { + // NOTE: This is not a full check, but a sufficient check for some cases + /// Specifically we may have to look at the user last epoch + /// And see if we need to port over that balance from then + (uint88 lqtyAllocated,) = initiative.lqtyAllocatedByUserAtEpoch(user, epoch); + bool claimedBribe = initiative.claimedBribeAtEpoch(user, epoch); + if (initiative.getMostRecentTotalEpoch() != prevTotalAllocationEpoch) { + return; // We are in a edge case + } + + // Check if there are bribes + (uint128 boldAmount, uint128 bribeTokenAmount) = initiative.bribeByEpoch(epoch); + bool bribeWasThere; + if (boldAmount != 0 || bribeTokenAmount != 0) { + bribeWasThere = true; + } + + if (lqtyAllocated > 0 && !claimedBribe && bribeWasThere) { + // user wasn't able to claim a bribe they were entitled to + unableToClaim = true; + /// @audit Consider adding this as a test once claiming is simplified + } + } + } +} diff --git a/test/recon/targets/GovernanceTargets.sol b/test/recon/targets/GovernanceTargets.sol new file mode 100644 index 00000000..d8ef2244 --- /dev/null +++ b/test/recon/targets/GovernanceTargets.sol @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: GPL-2.0 +pragma solidity ^0.8.0; + +import {BaseTargetFunctions} from "@chimera/BaseTargetFunctions.sol"; +import {vm} from "@chimera/Hevm.sol"; +import {IERC20Permit} from "openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Permit.sol"; +import {console2} from "forge-std/Test.sol"; + +import {Properties} from "../Properties.sol"; +import {MaliciousInitiative} from "../../mocks/MaliciousInitiative.sol"; +import {BribeInitiative} from "src/BribeInitiative.sol"; +import {Governance} from "src/Governance.sol"; +import {ILQTYStaking} from "src/interfaces/ILQTYStaking.sol"; +import {IInitiative} from "src/interfaces/IInitiative.sol"; +import {IUserProxy} from "src/interfaces/IUserProxy.sol"; +import {PermitParams} from "src/utils/Types.sol"; +import {add} from "src/utils/Math.sol"; + +abstract contract GovernanceTargets is BaseTargetFunctions, Properties { + // clamps to a single initiative to ensure coverage in case both haven't been registered yet + function governance_allocateLQTY_clamped_single_initiative( + uint8 initiativesIndex, + uint96 deltaLQTYVotes, + uint96 deltaLQTYVetos + ) public withChecks { + uint96 stakedAmount = IUserProxy(governance.deriveUserProxyAddress(user)).staked(); // clamp using the user's staked balance + + address[] memory initiatives = new address[](1); + initiatives[0] = _getDeployedInitiative(initiativesIndex); + int88[] memory deltaLQTYVotesArray = new int88[](1); + deltaLQTYVotesArray[0] = int88(uint88(deltaLQTYVotes % (stakedAmount + 1))); + int88[] memory deltaLQTYVetosArray = new int88[](1); + deltaLQTYVetosArray[0] = int88(uint88(deltaLQTYVetos % (stakedAmount + 1))); + + // User B4 + // (uint88 b4_user_allocatedLQTY,) = governance.userStates(user); // TODO + // StateB4 + (uint88 b4_global_allocatedLQTY,) = governance.globalState(); + + (Governance.InitiativeStatus status,,) = governance.getInitiativeState(initiatives[0]); + + try governance.allocateLQTY(deployedInitiatives, initiatives, deltaLQTYVotesArray, deltaLQTYVetosArray) { + t(deltaLQTYVotesArray[0] == 0 || deltaLQTYVetosArray[0] == 0, "One alloc must be zero"); + } catch { + // t(false, "Clamped allocated should not revert"); // TODO: Consider adding overflow check here + } + + // The test here should be: + // If initiative was DISABLED + // No Global State accounting should change + // User State accounting should change + + // If Initiative was anything else + // Global state and user state accounting should change + + // (uint88 after_user_allocatedLQTY,) = governance.userStates(user); // TODO + (uint88 after_global_allocatedLQTY,) = governance.globalState(); + + if (status == Governance.InitiativeStatus.DISABLED) { + // NOTE: It could be 0 + lte(after_global_allocatedLQTY, b4_global_allocatedLQTY, "Alloc can only be strictly decreasing"); + } + } + + function governance_allocateLQTY_clamped_single_initiative_2nd_user( + uint8 initiativesIndex, + uint96 deltaLQTYVotes, + uint96 deltaLQTYVetos + ) public withChecks { + uint96 stakedAmount = IUserProxy(governance.deriveUserProxyAddress(user2)).staked(); // clamp using the user's staked balance + + address[] memory initiatives = new address[](1); + initiatives[0] = _getDeployedInitiative(initiativesIndex); + int88[] memory deltaLQTYVotesArray = new int88[](1); + deltaLQTYVotesArray[0] = int88(uint88(deltaLQTYVotes % stakedAmount)); + int88[] memory deltaLQTYVetosArray = new int88[](1); + deltaLQTYVetosArray[0] = int88(uint88(deltaLQTYVetos % stakedAmount)); + + require(stakedAmount > 0, "0 stake"); + + vm.prank(user2); + governance.allocateLQTY(deployedInitiatives, initiatives, deltaLQTYVotesArray, deltaLQTYVetosArray); + } + + function governance_resetAllocations() public { + governance.resetAllocations(deployedInitiatives, true); + } + + function governance_resetAllocations_user_2() public { + vm.prank(user2); + governance.resetAllocations(deployedInitiatives, true); + } + + // TODO: if userState.allocatedLQTY != 0 deposit and withdraw must always revert + + // Resetting never fails and always resets + function property_resetting_never_reverts() public withChecks { + int88[] memory zeroes = new int88[](deployedInitiatives.length); + + try governance.allocateLQTY(deployedInitiatives, deployedInitiatives, zeroes, zeroes) {} + catch { + t(false, "must never revert"); + } + + (uint88 user_allocatedLQTY,) = governance.userStates(user); + + eq(user_allocatedLQTY, 0, "User has 0 allocated on a reset"); + } + + function depositTsIsRational(uint88 lqtyAmount) public withChecks { + uint88 stakedAmount = IUserProxy(governance.deriveUserProxyAddress(user)).staked(); // clamp using the user's staked balance + + // Deposit on zero + if (stakedAmount == 0) { + lqtyAmount = uint88(lqtyAmount % lqty.balanceOf(user)); + governance.depositLQTY(lqtyAmount); + + // assert that user TS is now * WAD + (, uint120 ts) = governance.userStates(user); + eq(ts, block.timestamp * 1e26, "User TS is scaled by WAD"); + } else { + // Make sure the TS can never bo before itself + (, uint120 ts_b4) = governance.userStates(user); + lqtyAmount = uint88(lqtyAmount % lqty.balanceOf(user)); + governance.depositLQTY(lqtyAmount); + + (, uint120 ts_after) = governance.userStates(user); + + gte(ts_after, ts_b4, "User TS must always increase"); + } + } + + function depositMustFailOnNonZeroAlloc(uint88 lqtyAmount) public withChecks { + (uint88 user_allocatedLQTY,) = governance.userStates(user); + + require(user_allocatedLQTY != 0, "0 alloc"); + + lqtyAmount = uint88(lqtyAmount % lqty.balanceOf(user)); + try governance.depositLQTY(lqtyAmount) { + t(false, "Deposit Must always revert when user is not reset"); + } catch {} + } + + function withdrwaMustFailOnNonZeroAcc(uint88 _lqtyAmount) public withChecks { + (uint88 user_allocatedLQTY,) = governance.userStates(user); + + require(user_allocatedLQTY != 0); + + try governance.withdrawLQTY(_lqtyAmount) { + t(false, "Withdraw Must always revert when user is not reset"); + } catch {} + } + + // For every previous epoch go grab ghost values and ensure they match snapshot + // For every initiative, make ghost values and ensure they match + // For all operations, you also need to add the VESTED AMT? + + function governance_allocateLQTY(int88[] memory _deltaLQTYVotes, int88[] memory _deltaLQTYVetos) + public + withChecks + { + governance.allocateLQTY(deployedInitiatives, deployedInitiatives, _deltaLQTYVotes, _deltaLQTYVetos); + } + + function governance_claimForInitiative(uint8 initiativeIndex) public withChecks { + address initiative = _getDeployedInitiative(initiativeIndex); + governance.claimForInitiative(initiative); + } + + function governance_claimForInitiativeFuzzTest(uint8 initiativeIndex) public withChecks { + address initiative = _getDeployedInitiative(initiativeIndex); + + // TODO Use view functions to get initiative and snapshot data + // Pass those and verify the claim amt matches received + // Check if we can claim + + // TODO: Check FSM as well, the initiative can be CLAIMABLE + // And must become CLAIMED right after + + uint256 received = governance.claimForInitiative(initiative); + uint256 secondReceived = governance.claimForInitiative(initiative); + if (received != 0) { + eq(secondReceived, 0, "Cannot claim twice"); + } + } + + function governance_claimForInitiativeDoesntRevert(uint8 initiativeIndex) public withChecks { + require(governance.epoch() > 2); // Prevent reverts due to timewarp + address initiative = _getDeployedInitiative(initiativeIndex); + + try governance.claimForInitiative(initiative) {} + catch { + t(false, "claimForInitiative should never revert"); + } + } + + function governance_claimFromStakingV1(uint8 recipientIndex) public withChecks { + address rewardRecipient = _getRandomUser(recipientIndex); + governance.claimFromStakingV1(rewardRecipient); + } + + function governance_deployUserProxy() public withChecks { + governance.deployUserProxy(); + } + + function governance_depositLQTY(uint88 lqtyAmount) public withChecks { + lqtyAmount = uint88(lqtyAmount % lqty.balanceOf(user)); + governance.depositLQTY(lqtyAmount); + } + + function governance_depositLQTY_2(uint88 lqtyAmount) public withChecks { + // Deploy and approve since we don't do it in constructor + vm.prank(user2); + try governance.deployUserProxy() returns (address proxy) { + vm.prank(user2); + lqty.approve(proxy, type(uint88).max); + } catch {} + + lqtyAmount = uint88(lqtyAmount % lqty.balanceOf(user2)); + vm.prank(user2); + governance.depositLQTY(lqtyAmount); + } + + function governance_depositLQTYViaPermit(uint88 _lqtyAmount) public withChecks { + // Get the current block timestamp for the deadline + uint256 deadline = block.timestamp + 1 hours; + + // Create the permit message + bytes32 permitTypeHash = + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + bytes32 domainSeparator = IERC20Permit(address(lqty)).DOMAIN_SEPARATOR(); + + uint256 nonce = IERC20Permit(address(lqty)).nonces(user); + + bytes32 structHash = + keccak256(abi.encode(permitTypeHash, user, address(governance), _lqtyAmount, nonce, deadline)); + + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(user2Pk, digest); + + PermitParams memory permitParams = + PermitParams({owner: user2, spender: user, value: _lqtyAmount, deadline: deadline, v: v, r: r, s: s}); + // TODO: BROKEN + governance.depositLQTYViaPermit(_lqtyAmount, permitParams); + } + + function governance_registerInitiative(uint8 initiativeIndex) public withChecks { + address initiative = _getDeployedInitiative(initiativeIndex); + governance.registerInitiative(initiative); + } + + function governance_snapshotVotesForInitiative(address _initiative) public withChecks { + governance.snapshotVotesForInitiative(_initiative); + } + + function governance_unregisterInitiative(uint8 initiativeIndex) public withChecks { + address initiative = _getDeployedInitiative(initiativeIndex); + governance.unregisterInitiative(initiative); + } + + function governance_withdrawLQTY(uint88 _lqtyAmount) public withChecks { + governance.withdrawLQTY(_lqtyAmount); + } + + function governance_withdrawLQTY_shouldRevertWhenClamped(uint88 _lqtyAmount) public withChecks { + uint88 stakedAmount = IUserProxy(governance.deriveUserProxyAddress(user)).staked(); // clamp using the user's staked balance + + // Ensure we have 0 votes + try governance.resetAllocations(deployedInitiatives, true) {} + catch { + t(false, "Should not revert cause OOG is unlikely"); + } + + _lqtyAmount %= stakedAmount + 1; + try governance.withdrawLQTY(_lqtyAmount) {} + catch { + t(false, "Clamped withdraw should not revert"); + } + } +} diff --git a/test/recon/trophies/SecondTrophiesToFoundry.sol b/test/recon/trophies/SecondTrophiesToFoundry.sol new file mode 100644 index 00000000..f46bd61b --- /dev/null +++ b/test/recon/trophies/SecondTrophiesToFoundry.sol @@ -0,0 +1,297 @@ +// SPDX-License-Identifier: GPL-2.0 +pragma solidity ^0.8.0; + +import {Test} from "forge-std/Test.sol"; +import {TargetFunctions} from "../TargetFunctions.sol"; +import {FoundryAsserts} from "@chimera/FoundryAsserts.sol"; +import {IBribeInitiative} from "src/interfaces/IBribeInitiative.sol"; +import {IGovernance} from "src/interfaces/IGovernance.sol"; +import {Governance} from "src/Governance.sol"; + +import {console} from "forge-std/console.sol"; + +contract SecondTrophiesToFoundry is Test, TargetFunctions, FoundryAsserts { + function setUp() public { + setup(); + } + + // forge test --match-test test_property_sum_of_initatives_matches_total_votes_strict_2 -vv + function test_property_sum_of_initatives_matches_total_votes_strict_2() public { + governance_depositLQTY_2(2); + + vm.warp(block.timestamp + 434544); + + vm.roll(block.number + 1); + + vm.roll(block.number + 1); + vm.warp(block.timestamp + 171499); + governance_allocateLQTY_clamped_single_initiative_2nd_user(0, 1, 0); + + helper_deployInitiative(); + + governance_depositLQTY(2); + + vm.warp(block.timestamp + 322216); + + vm.roll(block.number + 1); + + governance_registerInitiative(1); + + vm.roll(block.number + 1); + vm.warp(block.timestamp + 449572); + governance_allocateLQTY_clamped_single_initiative(1, 75095343, 0); + + vm.roll(block.number + 1); + vm.warp(block.timestamp + 436994); + property_sum_of_initatives_matches_total_votes_strict(); + // Of by 1 + // I think this should be off by a bit more than 1 + // But ultimately always less + } + + // forge test --match-test test_property_sum_of_user_voting_weights_0 -vv + function test_property_sum_of_user_voting_weights_0() public { + vm.warp(block.timestamp + 365090); + + vm.roll(block.number + 1); + + governance_depositLQTY_2(3); + + vm.warp(block.timestamp + 164968); + + vm.roll(block.number + 1); + + governance_depositLQTY(2); + + vm.warp(block.timestamp + 74949); + + vm.roll(block.number + 1); + + governance_allocateLQTY_clamped_single_initiative_2nd_user(0, 2, 0); + + governance_allocateLQTY_clamped_single_initiative(0, 1, 0); + + property_sum_of_user_voting_weights_bounded(); + + /// Of by 2 + } + + // forge test --match-test test_property_sum_of_lqty_global_user_matches_3 -vv + function test_property_sum_of_lqty_global_user_matches_3() public { + vm.roll(block.number + 2); + vm.warp(block.timestamp + 45381); + governance_depositLQTY_2(161673733563); + + vm.roll(block.number + 92); + vm.warp(block.timestamp + 156075); + property_BI03(); + + vm.roll(block.number + 305); + vm.warp(block.timestamp + 124202); + property_BI04(); + + vm.roll(block.number + 2); + vm.warp(block.timestamp + 296079); + governance_allocateLQTY_clamped_single_initiative_2nd_user(0, 1, 0); + + vm.roll(block.number + 4); + vm.warp(block.timestamp + 179667); + helper_deployInitiative(); + + governance_depositLQTY(2718660550802480907); + + vm.roll(block.number + 6); + vm.warp(block.timestamp + 383590); + property_BI07(); + + vm.warp(block.timestamp + 246073); + + vm.roll(block.number + 79); + + vm.roll(block.number + 4); + vm.warp(block.timestamp + 322216); + governance_depositLQTY(1); + + vm.warp(block.timestamp + 472018); + + vm.roll(block.number + 215); + + governance_registerInitiative(1); + + vm.roll(block.number + 1); + vm.warp(block.timestamp + 419805); + governance_allocateLQTY_clamped_single_initiative(1, 3700338125821584341973, 0); + + vm.warp(block.timestamp + 379004); + + vm.roll(block.number + 112); + + governance_unregisterInitiative(0); + + property_sum_of_lqty_global_user_matches(); + } + + // forge test --match-test test_governance_claimForInitiativeDoesntRevert_5 -vv + function test_governance_claimForInitiativeDoesntRevert_5() public { + governance_depositLQTY_2(96505858); + _loginitiative_and_state(); // 0 + + vm.roll(block.number + 3); + vm.warp(block.timestamp + 191303); + property_BI03(); + _loginitiative_and_state(); // 1 + + vm.warp(block.timestamp + 100782); + + vm.roll(block.number + 1); + + vm.roll(block.number + 1); + vm.warp(block.timestamp + 344203); + governance_allocateLQTY_clamped_single_initiative_2nd_user(0, 1, 0); + _loginitiative_and_state(); // 2 + + vm.warp(block.timestamp + 348184); + + vm.roll(block.number + 177); + + helper_deployInitiative(); + _loginitiative_and_state(); // 3 + + helper_accrueBold(1000135831883853852074); + _loginitiative_and_state(); // 4 + + governance_depositLQTY(2293362807359); + _loginitiative_and_state(); // 5 + + vm.roll(block.number + 2); + vm.warp(block.timestamp + 151689); + property_BI04(); + _loginitiative_and_state(); // 6 + + governance_registerInitiative(1); + _loginitiative_and_state(); // 7 + property_sum_of_initatives_matches_total_votes_strict(); + + vm.roll(block.number + 3); + vm.warp(block.timestamp + 449572); + governance_allocateLQTY_clamped_single_initiative(1, 330671315851182842292, 0); + _loginitiative_and_state(); // 8 + property_sum_of_initatives_matches_total_votes_strict(); + + governance_resetAllocations(); // NOTE: This leaves 1 vote from user2, and removes the votes from user1 + _loginitiative_and_state(); // In lack of reset, we have 2 wei error | With reset the math is off by 7x + property_sum_of_initatives_matches_total_votes_strict(); + console.log("time 0", block.timestamp); + + vm.warp(block.timestamp + 231771); + vm.roll(block.number + 5); + _loginitiative_and_state(); + console.log("time 0", block.timestamp); + + // Both of these are fine + // Meaning all LQTY allocation is fine here + // Same for user voting weights + property_sum_of_user_voting_weights_bounded(); + property_sum_of_lqty_global_user_matches(); + + /// === BROKEN === /// + // property_sum_of_initatives_matches_total_votes_strict(); // THIS IS THE BROKEN PROPERTY + (IGovernance.VoteSnapshot memory snapshot,,) = governance.getTotalVotesAndState(); + + uint256 initiativeVotesSum; + for (uint256 i; i < deployedInitiatives.length; i++) { + (IGovernance.InitiativeVoteSnapshot memory initiativeSnapshot,,) = + governance.getInitiativeSnapshotAndState(deployedInitiatives[i]); + (Governance.InitiativeStatus status,,) = governance.getInitiativeState(deployedInitiatives[i]); + + // if (status != Governance.InitiativeStatus.DISABLED) { + // FIX: Only count total if initiative is not disabled + initiativeVotesSum += initiativeSnapshot.votes; + // } + } + console.log("snapshot.votes", snapshot.votes); + console.log("initiativeVotesSum", initiativeVotesSum); + console.log("bold.balance", lusd.balanceOf(address(governance))); + governance_claimForInitiativeDoesntRevert(0); // Because of the quickfix this will not revert anymore + } + + uint256 loggerCount; + + function _loginitiative_and_state() internal { + (IGovernance.VoteSnapshot memory snapshot, IGovernance.GlobalState memory state,) = + governance.getTotalVotesAndState(); + console.log(""); + console.log("loggerCount", loggerCount++); + console.log("snapshot.votes", snapshot.votes); + + console.log("state.countedVoteLQTY", state.countedVoteLQTY); + console.log("state.countedVoteLQTYAverageTimestamp", state.countedVoteLQTYAverageTimestamp); + + for (uint256 i; i < deployedInitiatives.length; i++) { + ( + IGovernance.InitiativeVoteSnapshot memory initiativeSnapshot, + IGovernance.InitiativeState memory initiativeState, + ) = governance.getInitiativeSnapshotAndState(deployedInitiatives[i]); + + console.log("initiativeState.voteLQTY", initiativeState.voteLQTY); + console.log( + "initiativeState.averageStakingTimestampVoteLQTY", initiativeState.averageStakingTimestampVoteLQTY + ); + + assertEq(snapshot.forEpoch, initiativeSnapshot.forEpoch, "No desynch"); + console.log("initiativeSnapshot.votes", initiativeSnapshot.votes); + } + } + + // forge test --match-test test_property_BI07_4 -vv + function test_property_BI07_4() public { + vm.warp(block.timestamp + 562841); + + vm.roll(block.number + 1); + + governance_depositLQTY_2(2); + + vm.warp(block.timestamp + 243877); + + vm.roll(block.number + 1); + + governance_allocateLQTY_clamped_single_initiative_2nd_user(0, 1, 0); + + vm.warp(block.timestamp + 403427); + + vm.roll(block.number + 1); + + // SHIFTS the week + // Doesn't check latest alloc for each user + // Property is broken due to wrong spec + // For each user you need to grab the latest via the Governance.allocatedByUser + property_resetting_never_reverts(); + + property_BI07(); + } + + // forge test --match-test test_property_sum_of_user_voting_weights_0 -vv + function test_property_sum_of_user_voting_weights_1() public { + vm.warp(block.timestamp + 365090); + + vm.roll(block.number + 1); + + governance_depositLQTY_2(3); + + vm.warp(block.timestamp + 164968); + + vm.roll(block.number + 1); + + governance_depositLQTY(2); + + vm.warp(block.timestamp + 74949); + + vm.roll(block.number + 1); + + governance_allocateLQTY_clamped_single_initiative_2nd_user(0, 2, 0); + + governance_allocateLQTY_clamped_single_initiative(0, 1, 0); + + property_sum_of_user_voting_weights_bounded(); + } +} diff --git a/test/recon/trophies/TrophiesToFoundry.sol b/test/recon/trophies/TrophiesToFoundry.sol new file mode 100644 index 00000000..c26a8632 --- /dev/null +++ b/test/recon/trophies/TrophiesToFoundry.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-2.0 +pragma solidity ^0.8.0; + +import {Test} from "forge-std/Test.sol"; +import {TargetFunctions} from "../TargetFunctions.sol"; +import {Governance} from "src/Governance.sol"; +import {FoundryAsserts} from "@chimera/FoundryAsserts.sol"; + +import {console} from "forge-std/console.sol"; + +contract TrophiesToFoundry is Test, TargetFunctions, FoundryAsserts { + function setUp() public { + setup(); + } + + // forge test --match-test test_check_unregisterable_consistecy_0 -vv + /// This shows another issue tied to snapshot vs voting + /// This state transition will not be possible if you always unregister an initiative + /// But can happen if unregistering is skipped + // function test_check_unregisterable_consistecy_0() public { + /// TODO AUDIT Known bug + // vm.roll(block.number + 1); + // vm.warp(block.timestamp + 385918); + // governance_depositLQTY(2); + + // vm.roll(block.number + 1); + // vm.warp(block.timestamp + 300358); + // governance_allocateLQTY_clamped_single_initiative(0, 0, 1); + + // vm.roll(block.number + 1); + // vm.warp(block.timestamp + 525955); + // property_resetting_never_reverts(); + + // uint256 state = _getInitiativeStatus(_getDeployedInitiative(0)); + // assertEq(state, 5, "Should not be this tbh"); + // // check_unregisterable_consistecy(0); + // uint16 epoch = _getLastEpochClaim(_getDeployedInitiative(0)); + + // console.log(epoch + governance.UNREGISTRATION_AFTER_EPOCHS() < governance.epoch() - 1); + + // vm.warp(block.timestamp + governance.EPOCH_DURATION()); + // uint256 newState = _getInitiativeStatus(_getDeployedInitiative(0)); + + // uint16 lastEpochClaim = _getLastEpochClaim(_getDeployedInitiative(0)); + + // console.log("governance.UNREGISTRATION_AFTER_EPOCHS()", governance.UNREGISTRATION_AFTER_EPOCHS()); + // console.log("governance.epoch()", governance.epoch()); + + // console.log(lastEpochClaim + governance.UNREGISTRATION_AFTER_EPOCHS() < governance.epoch() - 1); + + // console.log("lastEpochClaim", lastEpochClaim); + + // assertEq(epoch, lastEpochClaim, "epochs"); + // assertEq(newState, state, "??"); + // } + + function _getLastEpochClaim(address _initiative) internal returns (uint16) { + (, uint16 epoch,) = governance.getInitiativeState(_initiative); + return epoch; + } +}